실시간을 hooks로: React, Vue, Nuxt를 위한 패키지
· Hibira
pusher-js를 React나 Vue에 연결할 때 모든 팀이 같은 useEffect를 쓰고 같은 함정에 빠집니다. 저희는 이를 세 개의 패키지로 정리하고, AI 답변을 모든 탭과 모든 팀원에게 보낼 수 있게 했습니다.

pusher-js를 React에 연결하는 코드는 보통 이렇게 시작합니다.
useEffect(() => {
const channel = pusher.subscribe('orders');
channel.bind('order.created', (order) => setOrders((list) => [order, ...list]));
return () => pusher.unsubscribe('orders');
}, []);여섯 줄, 별 문제 없어 보입니다. 그런데 프로젝트가 커지면 주문 목록과 오른쪽 위의 알림 벨이 모두 orders를 듣게 됩니다. 페이지를 바꾸면 목록은 언마운트되고 벨은 남는데, 그 뒤로 벨에는 알림이 오지 않습니다. 개발 모드에서는 React의 StrictMode가 컴포넌트를 두 번 마운트해서 연결도 두 개가 열립니다. Next.js로 옮기면 서버 렌더링 중에도 new Pusher()가 실행되어, 아무도 쓰지 않는 WebSocket이 서버에서 열립니다.
저희는 이런 코드를 여러 프로젝트에서 봐 왔습니다. 쓰는 방식도 거의 같고, 빠지는 함정도 같았습니다. 그래서 이를 세 개의 패키지로 정리했습니다. @hibira-io/react, @hibira-io/vue, @hibira-io/nuxt입니다. 이 글에서는 무엇을 어떻게 해결했는지, 그리고 내친김에 만든 또 하나의 기능, AI의 답변을 작성되는 그대로 모든 탭과 모든 팀원에게 보내는 방법을 소개합니다.
함정 하나: 두 컴포넌트, 하나의 채널
pusher-js의 subscribe()는 같은 채널 이름을 한 번만 구독하므로 두 컴포넌트는 같은 채널 객체를 받습니다. 문제는 unsubscribe()입니다. 누가 아직 쓰고 있는지와 상관없이 채널 전체의 구독을 해제합니다.
저희의 해법은 채널마다 카운트를 두는 것입니다. 사용하는 컴포넌트가 마운트될 때마다 하나 늘리고, 언마운트되면 하나 줄이고, 0이 될 때만 실제로 구독을 해제합니다. useChannel('orders')나 useEvent('orders', 'order.created', handler)만 쓰면, 어느 쪽이 먼저 마운트되고 먼저 사라지는지 신경 쓸 필요가 없습니다.
마운트
언마운트
useEvent는 최신 핸들러도 기억합니다. 컴포넌트는 렌더링할 때마다 새 함수를 만드는데, 매번 이벤트를 다시 바인딩하면 바쁜 채널에서 잠깐씩 빈틈이 생깁니다. 저희는 한 번만 바인딩하고, 이벤트가 오면 가장 최신 핸들러를 호출합니다.
함정 둘: 서버 렌더링과 StrictMode
Next.js, Remix, Nuxt는 먼저 서버에서 페이지를 렌더링합니다. 컴포넌트 본문이나 useMemo 안에서 연결을 만들면 서버도 연결합니다. 아무도 쓰지 않는 WebSocket이 하나 더 생기고, 연결 수도 하나 더 집계됩니다.
React 18부터 StrictMode는 정리되지 않은 부수 효과를 찾기 위해, 개발 모드에서 컴포넌트를 마운트, 언마운트, 다시 마운트합니다. 직접 작성한 연결 코드는 바로 여기서 두 번째 연결을 남기기 쉽습니다.
HibiraProvider는 useEffect 안에서만 연결하고 언마운트되면 끊습니다. StrictMode의 두 번째 마운트는 깨끗한 새 연결을 받습니다. 연결이 열리기 전까지 usePresence는 빈 멤버 목록을, useConnectionState는 initialized를 반환하는데, 서버가 렌더링한 결과와 같으므로 hydration 불일치 경고가 나오지 않습니다. Vue 버전도 같은 방식입니다. 플러그인은 브라우저에서 처음 사용될 때 연결하고, 서버에서는 아무것도 하지 않습니다.
하나 더: AI의 답변을 질문한 탭에만 보내지 않기
지난 2년 사이 많은 제품이 AI 어시스턴트를 추가했습니다. 흔한 방식은 Server-Sent Events입니다. 브라우저가 요청을 보내면, 백엔드가 모델의 출력을 같은 응답에 조금씩 써서 돌려줍니다.
한 사람이 탭 하나에서 쓸 때는 잘 동작합니다. 하지만 제품이 협업 도구가 되면 문제가 생깁니다. Alice가 공유된 사양 문서에서 AI에게 질문해도, 옆자리의 Bob은 답변이 작성되는 모습을 볼 수 없습니다. Alice 쪽에서 작성이 끝나 저장되고 Bob이 새로고침해야 비로소 보입니다. Alice가 직접 열어 둔 다른 탭에도 보이지 않습니다. 휴대폰 연결이 잠깐 끊기면 SSE 응답도 끊겨서 답변이 절반만 남습니다.
Server-Sent Events
채널로 보내기
trigger()그래서 패키지에 백엔드용 streamToChannel()과 브라우저용 useStream()을 넣었습니다. 백엔드가 모델의 스트림을 streamToChannel()에 넘기면 텍스트가 채널로 발행되고, 그 채널을 구독한 컴포넌트는 어디서든 useStream()으로 점점 길어지는 텍스트를 받습니다.

로컬에서 테스트하며 찍은 화면입니다. Alice가 "AI에게 묻기"를 누르면 답변이 Alice와 Bob의 화면에 동시에 나타납니다. Bob 쪽에서는 아무것도 할 필요가 없습니다.
토큰마다 메시지 하나가 아닙니다
모델은 1초에 수십 개의 토큰을 씁니다. 토큰마다 메시지를 발행하면 조금 긴 답변 하나가 1,000개의 메시지가 됩니다. 메시지 한도는 금방 바닥나고, 받는 쪽 브라우저도 1,000번 다시 렌더링해야 합니다.
streamToChannel()은 토큰을 모아 두었다가 100밀리초마다 한 번 발행합니다. 모델이 아무리 빨라도 채널에는 1초에 약 10개의 메시지만 도착하고, 화면의 텍스트는 끊김 없이 흘러나오는 것처럼 보입니다.
모델이 빠를수록 절약도 커집니다. 같은 1,000 토큰이라도 초당 100 토큰이면 103개로 충분합니다. 각 메시지는 3,500바이트 이내로 유지되어 Free 요금제의 메시지당 4 KB 제한보다 작고, 긴 텍스트를 나눌 때도 한글이나 이모지를 중간에서 자르지 않습니다.
순서, 끊긴 연결, 중간에 들어온 사람
모든 메시지에는 순번이 붙어 있어서 useStream()은 그 순서대로 텍스트를 잇습니다. 순서가 뒤바뀌어 도착해도 빠진 메시지를 기다렸다가 잇습니다.
마지막 메시지에는 "완료" 표시와 함께, 메시지 하나에 들어가는 한 전체 답변이 실립니다. 중간에 들어온 사람을 위해서입니다. Bob이 답변이 절반쯤 작성됐을 때 페이지를 열면 앞부분은 보지 못하지만, 마지막 메시지가 도착하는 순간 화면이 완성된 답변으로 바뀝니다. 연결이 잠깐 끊겼던 사람도 마찬가지입니다.
답변이 너무 길어 마지막 메시지에 전체 텍스트가 들어가지 않으면, 중간에 들어온 사람에게는 incomplete: true가 보입니다. 이때 API를 한 번 호출해 저장된 답변을 가져오면 됩니다. 모델에 오류가 나거나 중단되면 구독자에게 error도 전달되므로 "생성 중"에 멈춰 있지 않습니다.
코드로는 이렇게
React
import { HibiraProvider, useEvent, usePresence, useStream } from '@hibira-io/react';
export function App() {
return (
<HibiraProvider appKey="your app key" options={{ channelAuthorization: { endpoint: '/api/hibira/auth', transport: 'ajax' } }}>
<Room id="spec-42" />
</HibiraProvider>
);
}
function Room({ id }) {
const { members } = usePresence(`presence-room-${id}`);
const { text, done } = useStream(`private-room-${id}-ai`);
useEvent(`private-room-${id}`, 'comment.created', (comment) => addComment(comment));
return (
<>
<Avatars members={members} />
<Answer text={text} loading={!done} />
</>
);
}백엔드(Node.js, 프레임워크는 무엇이든)는 모델의 스트림을 그대로 streamToChannel()에 넘깁니다. Anthropic, OpenAI, Gemini, Vercel AI SDK의 스트림 형식을 모두 알아봅니다.
import Pusher from 'pusher';
import { streamToChannel } from '@hibira-io/react/server';
const pusher = new Pusher({ appId, key, secret, host: 'ws.hibira.io', useTLS: true });
const stream = anthropic.messages.stream({ model, max_tokens: 1024, messages });
await streamToChannel(pusher, `private-room-${id}-ai`, stream, { meta: { asker: user.name } });Vue
Vue 3과 Vue 2.7에서 동작합니다. 채널 이름에 ref나 getter를 넘길 수 있어서, 라우트 파라미터가 바뀌면 구독도 자동으로 따라 바뀝니다.
// main.js
createApp(App)
.use(createHibira({ appKey: import.meta.env.VITE_HIBIRA_KEY, options: { channelAuthorization: { endpoint: '/api/hibira/auth', transport: 'ajax' } } }))
.mount('#app');<script setup>
import { usePresence, useStream } from '@hibira-io/vue';
const props = defineProps({ id: String });
const { members, count } = usePresence(() => `presence-room-${props.id}`);
const { text, done } = useStream(() => `private-room-${props.id}-ai`);
</script>Nuxt
Nuxt 3과 Nuxt 4에서는 모듈 설정 한 줄이면 됩니다. composables는 자동으로 import되고, 서버 라우트에서도 useHibiraServer()와 streamToChannel()을 바로 쓸 수 있습니다.
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@hibira-io/nuxt'],
});// server/api/ask.post.ts
export default defineEventHandler(async (event) => {
const { roomId, question } = await readBody(event);
const stream = anthropic.messages.stream({ model, max_tokens: 1024, messages: [{ role: 'user', content: question }] });
event.waitUntil(streamToChannel(useHibiraServer(event), `private-room-${roomId}-ai`, stream));
return { ok: true };
});아직 Nuxt 2를 쓰는 프로젝트도 잊지 않았습니다. Nuxt 2.16 이상은 Vue 2.7 위에서 동작하므로 @hibira-io/nuxt/nuxt2 모듈을 쓰면 됩니다.
테스트는 이렇게 합니다
세 패키지 모두 작고, pusher-js 말고는 의존성이 없습니다. 하지만 여러 환경에서 돌아가기 때문에 테스트 범위는 코드 자체보다 훨씬 넓습니다.
- React 17, 18, 19와 Vue 3.5, Vue 2.7에서 각각 단위 테스트 실행.
- 엔드투엔드 테스트: 두 클라이언트를 실제 서버에 연결해 presence 참여와 이탈, 클라이언트 이벤트, 200 토큰 스트림이 양쪽에서 빠짐없이 조립되는지 확인.
- Nuxt 4.6, 3.21, 2.18로 작은 앱을 하나씩 만들어 Chromium에서 두 사용자로 열고, 연결, presence, 서버 이벤트, 스트리밍 텍스트를 확인. 콘솔 오류도 hydration 경고도 없음.
시작하기
npm install @hibira-io/react pusher-js # React
npm install @hibira-io/vue pusher-js # Vue 3 / Vue 2.7
npx nuxi module add @hibira-io/nuxt # Nuxt 3 / 4시작하기 전에 Hibira 콘솔에서 몇 가지를 설정해 두세요.
- 앱을 만들고 자격 증명을 보관하세요. App ID와 key는 앱의 "개요" 페이지에서 언제든 볼 수 있습니다. secret은 앱을 만들 때 한 번만 표시되므로 바로 백엔드의 환경 변수에 저장하세요(Nuxt는
NUXT_PUBLIC_HIBIRA_KEY,NUXT_HIBIRA_APP_ID,NUXT_HIBIRA_SECRET). key는 공개해도 되지만 secret은 서버에만 둡니다. - 허용 오리진을 추가하세요. Free 요금제에서는
*를 쓸 수 없습니다. 사이트 주소(개발 중이라면 예를 들어http://localhost:3000)를 "허용 오리진"에 추가하지 않으면 브라우저가 연결되지 않습니다. - 인증 라우트를 준비하세요. 비공개 채널과 presence 채널에는 로그인한 사용자를 확인하고 구독에 서명하는 백엔드의
/api/hibira/auth가 필요합니다. 세 README 모두에 그대로 복사해 쓸 수 있는 예제가 있습니다. - 메시지당 4 KB. Free 요금제에서는 이벤트 하나가 최대 4 KB입니다.
streamToChannel()은 자동으로 이 범위를 지키지만, 직접 발행하는 이벤트의 크기는 신경 써 주세요.
위의 Alice와 Bob 화면을 직접 재현하려면 서로 다른 브라우저 두 개, 또는 일반 창과 시크릿 창을 쓰고 각각 다른 사용자로 로그인하세요. 같은 브라우저의 탭 두 개는 보통 같은 사용자라서 presence에서는 한 명으로 셉니다.
세 패키지 모두 MIT 라이선스이고 소스는 GitHub에 있습니다: hibira-io/react, hibira-io/vue, hibira-io/nuxt. Pusher 프로토콜을 쓰므로 호환되는 서버라면 어디에나 연결할 수 있습니다. 이미 Laravel Echo를 쓰는 프로젝트라면 echo.connector.pusher를 넘겨 Echo와 같은 연결을 함께 쓸 수 있습니다.
Free 요금제는 신용카드가 필요 없습니다. 제품에 AI 어시스턴트를 넣으려 하거나, 직접 작성한 useEffect가 실시간 메시지를 잔뜩 처리하고 있다면, 페이지 하나를 이 hooks로 바꿔 보세요. 의견이 있으면 GitHub에 issue를 열어 알려 주세요.