# 실시간을 hooks로: React, Vue, Nuxt를 위한 패키지

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

Source: https://hibira.io/ko/blog/react-vue-nuxt-realtime
Updated: 2026-11-03

pusher-js를 React에 연결하는 코드는 보통 이렇게 시작합니다.

```jsx
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()`입니다. 누가 아직 쓰고 있는지와 상관없이 채널 전체의 구독을 해제합니다.

```text
sequence: 두 컴포넌트가 orders 채널을 공유하다가 하나가 언마운트될 때
주문 목록 | 알림 벨 | pusher-js
1 > 3: subscribe('orders')
2 > 3: subscribe('orders') | 같은 채널을 받음
1 > 3: unsubscribe('orders') | 목록 언마운트
note: 채널 전체가 해제되어 벨에는 더 이상 알림이 오지 않음
```

저희의 해법은 채널마다 카운트를 두는 것입니다. 사용하는 컴포넌트가 마운트될 때마다 하나 늘리고, 언마운트되면 하나 줄이고, 0이 될 때만 실제로 구독을 해제합니다. `useChannel('orders')`나 `useEvent('orders', 'order.created', handler)`만 쓰면, 어느 쪽이 먼저 마운트되고 먼저 사라지는지 신경 쓸 필요가 없습니다.

```text
flow: 카운트로 실제 해제 시점을 정한다
== 마운트
주문 목록|useEvent('orders') --카운트 1--> *채널 orders|구독 --카운트 2--> 알림 벨|useEvent('orders')
== 언마운트
주문 목록|언마운트 --카운트 1--> *채널 orders|구독 유지 --카운트 0--> 알림 벨|사라진 뒤에 해제
```

`useEvent`는 최신 핸들러도 기억합니다. 컴포넌트는 렌더링할 때마다 새 함수를 만드는데, 매번 이벤트를 다시 바인딩하면 바쁜 채널에서 잠깐씩 빈틈이 생깁니다. 저희는 한 번만 바인딩하고, 이벤트가 오면 가장 최신 핸들러를 호출합니다.

## 함정 둘: 서버 렌더링과 StrictMode

Next.js, Remix, Nuxt는 먼저 서버에서 페이지를 렌더링합니다. 컴포넌트 본문이나 `useMemo` 안에서 연결을 만들면 서버도 연결합니다. 아무도 쓰지 않는 WebSocket이 하나 더 생기고, 연결 수도 하나 더 집계됩니다.

React 18부터 StrictMode는 정리되지 않은 부수 효과를 찾기 위해, 개발 모드에서 컴포넌트를 마운트, 언마운트, 다시 마운트합니다. 직접 작성한 연결 코드는 바로 여기서 두 번째 연결을 남기기 쉽습니다.

```text
flow: 연결은 브라우저에서, 마운트된 뒤에만
서버 렌더링|연결 안 함, hooks는 빈 값 반환 --HTML--> 브라우저 hydration|서버와 같은 화면 --마운트 후--> *연결|ws.hibira.io
```

`HibiraProvider`는 `useEffect` 안에서만 연결하고 언마운트되면 끊습니다. StrictMode의 두 번째 마운트는 깨끗한 새 연결을 받습니다. 연결이 열리기 전까지 `usePresence`는 빈 멤버 목록을, `useConnectionState`는 `initialized`를 반환하는데, 서버가 렌더링한 결과와 같으므로 hydration 불일치 경고가 나오지 않습니다. Vue 버전도 같은 방식입니다. 플러그인은 브라우저에서 처음 사용될 때 연결하고, 서버에서는 아무것도 하지 않습니다.

## 하나 더: AI의 답변을 질문한 탭에만 보내지 않기

지난 2년 사이 많은 제품이 AI 어시스턴트를 추가했습니다. 흔한 방식은 Server-Sent Events입니다. 브라우저가 요청을 보내면, 백엔드가 모델의 출력을 같은 응답에 조금씩 써서 돌려줍니다.

한 사람이 탭 하나에서 쓸 때는 잘 동작합니다. 하지만 제품이 협업 도구가 되면 문제가 생깁니다. Alice가 공유된 사양 문서에서 AI에게 질문해도, 옆자리의 Bob은 답변이 작성되는 모습을 볼 수 없습니다. Alice 쪽에서 작성이 끝나 저장되고 Bob이 새로고침해야 비로소 보입니다. Alice가 직접 열어 둔 다른 탭에도 보이지 않습니다. 휴대폰 연결이 잠깐 끊기면 SSE 응답도 끊겨서 답변이 절반만 남습니다.

```text
flow: SSE는 질문한 요청에만 답하고, 채널은 구독한 모두에게 보낸다
== Server-Sent Events
모델 --스트림--> 백엔드 --같은 HTTP 응답--> 질문한 탭
== 채널로 보내기
모델 --스트림--> 백엔드|streamToChannel() --`trigger()`--> *Hibira|팬아웃 --WebSocket--> 모든 탭, 모든 팀원
```

그래서 패키지에 백엔드용 `streamToChannel()`과 브라우저용 `useStream()`을 넣었습니다. 백엔드가 모델의 스트림을 `streamToChannel()`에 넘기면 텍스트가 채널로 발행되고, 그 채널을 구독한 컴포넌트는 어디서든 `useStream()`으로 점점 길어지는 텍스트를 받습니다.

![두 브라우저에서 같은 AI 답변이 작성되는 모습을 동시에 보는 화면: 왼쪽은 질문한 Alice, 오른쪽은 Bob](https://hibira.io/portal/articles/demo-shared-answer-ko.webp)

로컬에서 테스트하며 찍은 화면입니다. Alice가 "AI에게 묻기"를 누르면 답변이 Alice와 Bob의 화면에 동시에 나타납니다. Bob 쪽에서는 아무것도 할 필요가 없습니다.

### 토큰마다 메시지 하나가 아닙니다

모델은 1초에 수십 개의 토큰을 씁니다. 토큰마다 메시지를 발행하면 조금 긴 답변 하나가 1,000개의 메시지가 됩니다. 메시지 한도는 금방 바닥나고, 받는 쪽 브라우저도 1,000번 다시 렌더링해야 합니다.

`streamToChannel()`은 토큰을 모아 두었다가 100밀리초마다 한 번 발행합니다. 모델이 아무리 빨라도 채널에는 1초에 약 10개의 메시지만 도착하고, 화면의 텍스트는 끊김 없이 흘러나오는 것처럼 보입니다.

```text
bars: 1,000 토큰을 초당 50 토큰으로 생성할 때 발행되는 메시지 수 (실측)
unit: 개
토큰마다 하나: 1000
*streamToChannel(), 100밀리초마다 하나: 203
```

모델이 빠를수록 절약도 커집니다. 같은 1,000 토큰이라도 초당 100 토큰이면 103개로 충분합니다. 각 메시지는 3,500바이트 이내로 유지되어 Free 요금제의 메시지당 4 KB 제한보다 작고, 긴 텍스트를 나눌 때도 한글이나 이모지를 중간에서 자르지 않습니다.

### 순서, 끊긴 연결, 중간에 들어온 사람

모든 메시지에는 순번이 붙어 있어서 `useStream()`은 그 순서대로 텍스트를 잇습니다. 순서가 뒤바뀌어 도착해도 빠진 메시지를 기다렸다가 잇습니다.

마지막 메시지에는 "완료" 표시와 함께, 메시지 하나에 들어가는 한 전체 답변이 실립니다. 중간에 들어온 사람을 위해서입니다. Bob이 답변이 절반쯤 작성됐을 때 페이지를 열면 앞부분은 보지 못하지만, 마지막 메시지가 도착하는 순간 화면이 완성된 답변으로 바뀝니다. 연결이 잠깐 끊겼던 사람도 마찬가지입니다.

```text
sequence: 답변 중간에 Bob이 들어올 때
백엔드 | *Hibira | Alice | Bob
1 > 2: 0~5번 메시지 | 앞부분 텍스트
2 > 3: 0~5번 메시지
note: Bob이 이때 페이지를 열고 구독
1 > 2: 6~9번 메시지
2 > 3: 6~9번 메시지
2 > 4: 6~9번 메시지 | 뒷부분만 보임
1 > 2: 10번: 완료 + 전체 답변
2 > 3: 완료
2 > 4: 전체 답변 | Bob의 화면이 채워짐
```

답변이 너무 길어 마지막 메시지에 전체 텍스트가 들어가지 않으면, 중간에 들어온 사람에게는 `incomplete: true`가 보입니다. 이때 API를 한 번 호출해 저장된 답변을 가져오면 됩니다. 모델에 오류가 나거나 중단되면 구독자에게 `error`도 전달되므로 "생성 중"에 멈춰 있지 않습니다.

## 코드로는 이렇게

### React

```jsx
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의 스트림 형식을 모두 알아봅니다.

```js
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를 넘길 수 있어서, 라우트 파라미터가 바뀌면 구독도 자동으로 따라 바뀝니다.

```js
// main.js
createApp(App)
  .use(createHibira({ appKey: import.meta.env.VITE_HIBIRA_KEY, options: { channelAuthorization: { endpoint: '/api/hibira/auth', transport: 'ajax' } } }))
  .mount('#app');
```

```vue
<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()`을 바로 쓸 수 있습니다.

```ts
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@hibira-io/nuxt'],
});
```

```ts
// 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 경고도 없음.

## 시작하기

```sh
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](https://github.com/hibira-io/react), [hibira-io/vue](https://github.com/hibira-io/vue), [hibira-io/nuxt](https://github.com/hibira-io/nuxt). Pusher 프로토콜을 쓰므로 호환되는 서버라면 어디에나 연결할 수 있습니다. 이미 Laravel Echo를 쓰는 프로젝트라면 `echo.connector.pusher`를 넘겨 Echo와 같은 연결을 함께 쓸 수 있습니다.

Free 요금제는 신용카드가 필요 없습니다. 제품에 AI 어시스턴트를 넣으려 하거나, 직접 작성한 `useEffect`가 실시간 메시지를 잔뜩 처리하고 있다면, 페이지 하나를 이 hooks로 바꿔 보세요. 의견이 있으면 GitHub에 issue를 열어 알려 주세요.
