사람함께에이전트▾
사람 — 직접 정하고 책임지는 비즈니스 규칙과 흐름. 직접 읽어보세요.
함께 — 개념은 알아두고, 세부 규칙은 에이전트가 따릅니다.
에이전트 — 에이전트가 따르는 규칙과 레퍼런스. 필요할 때 찾아보세요.
일반▾
인터페이스▾
관측성▾
성능▾
네이티브▾
개발▾

실시간

실시간 기능은 WebSocket 연결 하나를 열어 두고, 요청을 새로 보내지 않고도 작은 이벤트를 주고받습니다. 채팅, 게임, 라이브 에디터, 대시보드, 접속 상태 표시가 모두 이 연결 위에서 돌아갑니다.
도구방향
↳ 이럴 때
message브라우저 → 서버
예: 읽음 처리, 커서 이동, 입력 중 표시, 게임 입력
pubsub서버 → room을 구독한 모든 브라우저
예: 새 채팅 메시지, 알림
.live()데이터베이스 → 그 목록을 띄운 모든 화면
예: 채팅 내역처럼 저장된 행의 목록
  • room이 받는 사람을 정합니다. pubsub 이벤트는 그 room을 구독한 브라우저에만 도착합니다.
  • 저장된 목록에는 pubsub이 필요 없습니다. slice에 .live()를 선언하면 행을 저장하는 것만으로 충분합니다.

message로 보내기

브라우저가 서버로 보내는 작은 동작에는 message를 씁니다. 읽음 처리, 커서 이동, 입력 중 표시, 게임 입력이 대표적입니다. 읽음 처리는 .msg()로 인자를 선언합니다:
apps/myapp/lib/chat/chat.signal.ts
  • .msg() 하나가 인자 하나입니다. 브라우저는 같은 순서로 fetch.readMessage(chatId, messageId)를 호출합니다.
  • 읽은 사람은 .with(Self)로 받습니다. 브라우저가 보낸 사용자 id는 믿지 않습니다.
  • 응답은 보낸 쪽에만 갑니다. 반환값은 보낸 브라우저의 fetch.listenReadMessage(fn)으로 도착합니다.
  • 가드는 직접 적습니다. message는 자기 옵션의 guards로만 검사합니다. 여기서는 User가 로그인한 사용자만 통과시킵니다.

pubsub으로 room에 보내기

서버가 room 안의 모두에게 이벤트 하나를 보낼 때는 pubsub을 씁니다. 새 채팅 메시지가 가장 쉬운 예입니다. signal에는 room과, 구독마다 준비할 일을 선언합니다:
apps/myapp/lib/chat/chat.signal.ts
  • .room()이 room을 정합니다. room은 이 endpoint와 chatId의 조합이므로, 채팅마다 room이 따로 생깁니다.
  • exec는 브라우저가 구독할 때 실행됩니다. 여기서는 아무것도 보내지 않고, publish는 아래 채팅 흐름처럼 service가 맡습니다.
  • 정리 작업은 두 이벤트에 모두 등록합니다. unsubscribe는 브라우저가 room을 떠날 때, disconnect는 소켓이 끊길 때 실행되며, 두 이벤트에 함께 등록한 핸들러는 한 번만 실행됩니다.
  • ws.socketId는 사용자가 아니라 연결입니다. 다시 연결하면 id가 바뀌므로, 사용자별 상태는 계정 기준으로 둡니다.
브라우저에서 호출하기
message와 pubsub은 브라우저에서 각각 fetch 함수가 됩니다:
fetch.readMessage(chatId, messageId)
message를 보내고, 아무것도 돌려주지 않습니다.
fetch.listenReadMessage(fn)
이 브라우저가 받는 응답마다 fn을 실행하고, 듣기를 멈추는 함수를 돌려줍니다.
fetch.subscribeMessageAdded(chatId, fn)
그 채팅의 room에 들어가 publish마다 fn을 실행하고, 구독을 끊는 함수를 돌려줍니다.
구독은 useEffect 안에서 하고, 받은 구독 해제 함수를 cleanup으로 돌려줍니다.

채팅 흐름

채팅 메시지는 네 단계를 거쳐 화면에 나타납니다. 목록 자체에는 구독 코드를 직접 쓰지 않습니다.
1. 저장한 뒤 publish
저장해야 하는 데이터라면 먼저 데이터베이스에 기록하고, service가 성공한 뒤에 publish합니다:
apps/myapp/lib/chat/chat.service.ts
  • room 인자가 먼저, 보낼 값이 마지막입니다. messageAdded(chatId, chatMessage)는 그 채팅의 room으로 publish합니다.
  • 저장이 실패하면 아무것도 보내지 않습니다. createChatMessage가 실패하면 publish까지 가지 않으므로, 저장되지 않은 메시지를 받는 브라우저도 없습니다.
  • signal은 필드 이름으로 주입됩니다. chatSignal이라는 필드는 chat 모듈의 signal로 연결됩니다.
2. live slice 선언
목록을 채우는 slice에 .live()를 선언합니다:
apps/myapp/lib/chatMessage/chatMessage.signal.ts
  • 목록은 저장만으로 갱신됩니다. 문서 단위의 생성·수정·삭제는 열린 목록마다 전달되므로, messageAdded는 목록이 아닌 다른 곳에서 들을 때만 필요합니다.
  • 쿼리 단위 쓰기는 전달되지 않습니다. update<Filter>, remove<Filter>, updateById, removeById는 훅을 실행하지 않아 live 목록에 닿지 않습니다.
  • room도 slice의 가드를 씁니다. live room은 목록과 같은 행을 보내므로, 가드를 init()에 적습니다.
3. route에서 스냅샷 불러오기
첫 목록은 브라우저가 직접 가져오지 않습니다. route가 첫 바이트를 보내기 전에 slice를 불러 스냅샷을 init prop으로 넘기므로, 첫 화면은 서버가 그린 HTML입니다:
apps/myapp/page/chat/[chatId]/_index.tsx
4. Load.Units로 그리기
Load.Units가 그 스냅샷으로 store를 채우고, 그 뒤로는 live slice가 목록을 최신으로 유지합니다:
apps/myapp/lib/chatMessage/ChatMessage.Zone.tsx
  • room은 알아서 열립니다. Load.Units가 live slice를 구독하므로 목록에는 effect가 필요 없습니다.
  • Zone은 아무것도 fetch하지 않습니다. 서버 데이터를 useState에 담지도 않습니다.

room 설계

room key가 곧 이벤트를 받는 범위입니다. 받을 사람의 범위만큼만 좁게 잡습니다.
  • 좁은 key를 씁니다. chatId, gameId, documentId가 좋은 예입니다.
  • 모든 사용자가 함께 쓰는 거대한 room은 피합니다. 정말 모두가 받아야 하는 이벤트일 때만 씁니다.
  • 보내고 구독할 수 있는 사람을 가드로 제한합니다. User는 로그인만 확인하므로, 채팅 멤버만 들이려면 context.getArg("chatId")로 chatId를 읽는 가드를 직접 만듭니다.
  • 로그인 상태가 바뀌면 가드가 다시 실행됩니다. 구독 중인 room을 다시 검사해 통과하지 못하면 끊으므로, 가드에는 부수 효과를 두지 않습니다.

꿀팁

  • payload는 작게 유지합니다. 화면 전체 데이터 대신 id와 작은 변경분을 보냅니다.
  • 명령은 올려 보내고, 알림은 내려보냅니다. 명령에는 message를, 알림에는 pubsub을 씁니다.
  • 유실되면 곤란한 이벤트는 먼저 저장합니다. 저장이 성공한 뒤에 publish합니다.
  • 너무 잦은 이벤트는 클라이언트에서 throttle합니다. 게임 입력과 커서 이동이 대표적입니다.
  • 바이트 스트림은 pubsub(Binary)로 보냅니다. JSON을 거치지 않으며, { backpressure: "queue" }를 선언하지 않으면 뒤처진 구독자는 가장 최근 프레임만 받습니다.

MIT 라이선스 하에 배포되었습니다.

내 AI에 이 문서 연결하기

MCPhttps://akanjs.com/mcp
Copyright © 2026 Akan.js 모든 권리 보유.시스템 관리자bassman