05 — Subscriptions & PubSub
질문: WebSocket을 깔았다. 그런데 서버가 어떤 이벤트를 흘려보낼지는 어디서 오는가? 여러 서버 인스턴스를 띄우면 다른 인스턴스에서 발생한 이벤트는 어떻게 도달하는가? 한 줄 답: subscription의 비용은 transport가 아니라 백엔드다 — 서버는 (1) pub/sub broker(Redis pub/sub, PostgreSQL LISTEN/NOTIFY, Kafka)로 인스턴스간 이벤트를 fan-out하고, (2) long-lived connection을 메모리에 유지하며, (3) sticky routing으로 load balancer가 같은 connection을 같은 인스턴스로 보낸다. 이 셋이 안 갖춰지면 단일 노드 데모에서는 동작하지만 production에서는 침묵한다.
이전 문서는 transport(WebSocket)을 다뤘다. 이 문서는 그 위에 흘리는 이벤트가 어디서 오는지, 그리고 서버를 여러 대 띄우면 무엇이 깨지는지를 다룬다.
Why — single-node 데모와 production의 골짜기
대부분의 GraphQL subscription 튜토리얼은 단일 노드에서 끝난다.
import { PubSub } from 'graphql-subscriptions'; // 인메모리
const pubsub = new PubSub();
// mutation에서
async createComment(_, args) {
const comment = await db.insert(args);
pubsub.publish('COMMENT_ADDED', { onComment: comment });
return comment;
}
// subscription에서
onComment: {
subscribe: () => pubsub.asyncIterator(['COMMENT_ADDED']),
}이 코드는 완벽하게 동작한다. 단일 인스턴스에서만. Node 인스턴스를 두 대로 늘리면 즉시 깨진다:
- Instance A에서 mutation 발생 → A의 메모리에 publish → A에 연결된 클라이언트만 받음.
- Instance B에 연결된 클라이언트는 영원히 못 받음.
이게 production-grade subscription의 출발점이다.
How — subscription의 실제 데이터 흐름
핵심 3가지:
- Broker가 fan-out — 인스턴스 어디서 발생해도 모든 인스턴스가 받는다.
- Sticky routing — Client 1의 모든 메시지(initial subscribe, ping, complete)가 항상 Instance A로 가야 한다. 그렇지 않으면 subscription state가 흩어진다.
- Long-lived memory — 각 인스턴스는 자기에 연결된 클라이언트 × 자기가 subscribe한 토픽을 메모리에 유지한다.
What — PubSub broker 선택지
1) Redis Pub/Sub
import { RedisPubSub } from 'graphql-redis-subscriptions';
import Redis from 'ioredis';
const pubsub = new RedisPubSub({
publisher: new Redis({ host: 'redis.svc' }),
subscriber: new Redis({ host: 'redis.svc' }),
});
// 사용법은 인메모리 PubSub과 동일| 장점 | 단점 |
|---|---|
| 빠름 (microseconds) | 메시지 손실 가능 — 구독자가 잠시 끊기면 그 사이 메시지는 사라짐 |
| 셋업 단순 | 영속성 없음 (Redis Streams는 다름 — 별개) |
| 광범위한 호스팅 지원 | broker 하나의 처리량 한계 (~수만 msg/s) |
언제 — 짧은 알림(채팅, presence, 라이브 카운터). 손실이 치명적이지 않은 도메인.
2) PostgreSQL LISTEN/NOTIFY
-- mutation 후
NOTIFY comment_added, '{"id": "c_42", "text": "..."}';
-- subscription 쪽
LISTEN comment_added;import { Pool } from 'pg';
import { PostgresPubSub } from '@graphile/pg-pubsub';
const pubsub = new PostgresPubSub({ pool: new Pool(...) });| 장점 | 단점 |
|---|---|
| 이미 갖고 있는 DB가 broker가 됨 | NOTIFY payload는 8KB 제한 (Postgres 한계) |
| 트랜잭션과 묶어서 commit 후 알림 가능 | 처리량 제한 (수천 msg/s) |
| 추가 인프라 0 | 영속성 없음 (구독 시점부터만) |
언제 — PostgreSQL을 이미 쓰고, 이벤트량이 분당 수만 미만일 때. Hasura·PostGraphile이 이 패턴.
3) Kafka
// graphql-kafka-subscriptions 등 사용| 장점 | 단점 |
|---|---|
| 영속성 — replay 가능 | 셋업·운영 복잡 |
| 초당 수십만~수백만 msg | 지연 (millisecond 단위) |
| consumer group으로 partitioned 처리 | broker 비용 |
언제 — 이벤트 자체가 first-class인 도메인 (e-commerce 주문, 금융 거래). subscription은 그 이벤트 stream의 한 consumer일 뿐.
비교 표
| Broker | 처리량 | 영속성 | 셋업 비용 | Best use |
|---|---|---|---|---|
| In-memory | ∞ (1노드) | 없음 | 0 | 단일 인스턴스 데모 |
| Redis pub/sub | ~10만 msg/s | 없음 | 낮음 | 채팅, 알림 |
| Redis Streams | ~10만 msg/s | 있음 | 중간 | 손실 못 견디는 경우 |
| Postgres LISTEN/NOTIFY | ~1만 msg/s | 없음 | 0 (이미 있음) | 작은~중간 규모, DB 트랜잭션과 묶임 |
| Kafka | ~100만 msg/s | 있음, 영구 | 높음 | 이벤트가 도메인 자체 |
What — Sticky session이 왜 필요한가
WebSocket은 한 번 맺어진 TCP는 자동으로 sticky지만, 재연결 / sub-second failover / connection-draining 시점에는 다른 인스턴스로 갈 수 있다. 그러면:
- 인증 상태 손실 —
connection_init을 다시 받아야 함 (이건 graphql-ws가 자동 처리). - subscription state 손실 — 클라이언트가 다시
subscribe메시지를 보내야 함. - 이벤트 갭 — reconnect 사이에 발생한 이벤트는 못 받는다 (broker가 영속적이 아니면).
실무 셋업:
- AWS ALB: target group의 stickiness 활성화, cookie-based.
- nginx:
ip_hash또는hash $remote_addr consistent. - Kubernetes Service:
sessionAffinity: ClientIP.
What — 메모리 비용
각 subscription은 서버 메모리에 영구적으로 자리를 잡는다.
| 항목 | 크기 추정 |
|---|---|
| WebSocket connection (Node.js + ws) | ~20-40KB |
| subscription state per topic | ~1-5KB |
| 클라이언트 한 명이 동시 5개 subscription | ~50KB |
| 1만 동시 사용자 | 500MB 메모리 |
| 10만 동시 사용자 | 5GB 메모리 |
게다가 각 인스턴스가 broker에서 topic들을 다 받는다 — 본인이 안 갖고 있는 클라이언트의 이벤트도 일단 수신해서 필터링한다. broker 처리량이 인스턴스 수에 비례해서 증폭된다.
이게 subscription을 모든 read에 쓰지 마라 라고 충고하는 이유다.
What-if — production에서 마주치는 사고
- 단일 노드에서 잘 됐는데 K8s로 올렸더니 침묵 → PubSub broker 없음. 인메모리 PubSub은 그 인스턴스 안에서만 fan-out.
- subscription이 가끔 누락됨 → sticky session 없음. reconnect 시 state 손실.
- 대규모 알림에서 broker가 죽음 → Redis pub/sub의 처리량 한계. Redis Streams 또는 Kafka로 이전.
graphql-redis-subscriptions에서 메시지 순서 뒤바뀜 → Redis pub/sub은 순서 보장 없음. 순서가 중요하면 Kafka의 partition key를 써야 함.- PostgreSQL NOTIFY payload가 잘림 → 8KB 제한. ID만 보내고 클라이언트가 다시 fetch하는 패턴으로 전환.
Insight — subscription은 이벤트 시스템에 GraphQL의 옷을 입힌 것
subscription을 기능으로 보면 어렵다. 하지만 기존 이벤트 시스템(Kafka, Redis Streams) 위에 GraphQL이라는 type-safe 클라이언트를 얹은 것으로 보면 명확해진다.
subscription은 event bus의 한 클라이언트에 불과하다. 도메인 이벤트가 broker에 있고, subscription은 그것의 GraphQL view다.
이 관점에서 보면:
- subscription을 별도 인프라로 다루는 게 자연스럽다.
- 이미 있는 event bus가 있다면 subscription을 그 위에 얹는 게 가장 적은 추가다.
- event bus가 없는데 subscription을 만들면 — 그건 동시에 event bus를 만드는 일이다.
흥미로운 이야기 — Slack은 GraphQL subscription을 안 쓴다
Slack의 실시간 메시지는 GraphQL이 아니라 자체 WebSocket 프로토콜이다. 이유는:
- 이벤트량 — 수억 명의 동시 사용자. PubSub broker가 GraphQL layer를 통과시키기에는 오버헤드가 큼.
- 자체 분산 시스템 — Slack은 이미 channels × messages × users의 라우팅을 자체 시스템으로 처리한다. GraphQL의 schema-based fan-out은 그것에 맞지 않는다.
반대로 GitHub은 GraphQL subscription을 적극 쓴다 — PR 댓글, CI 상태, notification. PostgreSQL LISTEN/NOTIFY + Pusher로 처리한다고 알려져 있다.
교훈: subscription은 모든 실시간 도메인의 정답이 아니다. 스키마와 그래프 모델이 자연스럽게 맞을 때만 GraphQL subscription이 이긴다.
요약
- subscription은 transport (WS) + PubSub broker + sticky routing의 셋이 있어야 production에서 동작한다.
- broker는 처리량·영속성·복잡도의 trade-off — Redis pub/sub(빠르고 손실 OK) ↔ Postgres NOTIFY(이미 있음·작은 규모) ↔ Kafka(영속적·큰 규모).
- 단일 노드 데모에서는 인메모리 PubSub로 충분하지만, 그건 production이 아니다.
- subscription의 진짜 비용은 *메모리(connection per user)*와 broker 처리량이다. 모든 read에 쓰면 안 된다.
- subscription을 event bus의 한 view로 보는 게 가장 명확한 멘탈 모델.
다음: 06 — SSE & @defer/@stream — WebSocket 없이도 서버 → 클라이언트 stream이 가능하다. HTTP 위의 incremental delivery.