사람함께에이전트▾
사람 — 직접 정하고 책임지는 비즈니스 규칙과 흐름. 직접 읽어보세요.
함께 — 개념은 알아두고, 세부 규칙은 에이전트가 따릅니다.
에이전트 — 에이전트가 따르는 규칙과 레퍼런스. 필요할 때 찾아보세요.
실시간
실시간 기능은 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가 바뀌므로, 사용자별 상태는 계정 기준으로 둡니다.


pubsub은 직접 선언하지 않으면 가드가 없습니다. slice의 guard map은 생성된 query·mutation endpoint에만 적용되므로,
guards 배열이 없는 room은 소켓을 열 수 있는 누구에게나 열려 있습니다.브라우저에서 호출하기
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" }를 선언하지 않으면 뒤처진 구독자는 가장 최근 프레임만 받습니다.