🔷 GraphQL4. 전송 계층05 — Subscriptions & PubSub

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가지:

  1. Broker가 fan-out — 인스턴스 어디서 발생해도 모든 인스턴스가 받는다.
  2. Sticky routing — Client 1의 모든 메시지(initial subscribe, ping, complete)가 항상 Instance A로 가야 한다. 그렇지 않으면 subscription state가 흩어진다.
  3. 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 프로토콜이다. 이유는:

  1. 이벤트량 — 수억 명의 동시 사용자. PubSub broker가 GraphQL layer를 통과시키기에는 오버헤드가 큼.
  2. 자체 분산 시스템 — Slack은 이미 channels × messages × users의 라우팅을 자체 시스템으로 처리한다. GraphQL의 schema-based fan-out은 그것에 맞지 않는다.

반대로 GitHub은 GraphQL subscription을 적극 쓴다 — PR 댓글, CI 상태, notification. PostgreSQL LISTEN/NOTIFY + Pusher로 처리한다고 알려져 있다.

교훈: subscription은 모든 실시간 도메인의 정답이 아니다. 스키마와 그래프 모델이 자연스럽게 맞을 때만 GraphQL subscription이 이긴다.


요약

  1. subscription은 transport (WS) + PubSub broker + sticky routing의 셋이 있어야 production에서 동작한다.
  2. broker는 처리량·영속성·복잡도의 trade-off — Redis pub/sub(빠르고 손실 OK) ↔ Postgres NOTIFY(이미 있음·작은 규모) ↔ Kafka(영속적·큰 규모).
  3. 단일 노드 데모에서는 인메모리 PubSub로 충분하지만, 그건 production이 아니다.
  4. subscription의 진짜 비용은 *메모리(connection per user)*와 broker 처리량이다. 모든 read에 쓰면 안 된다.
  5. subscription을 event bus의 한 view로 보는 게 가장 명확한 멘탈 모델.

다음: 06 — SSE & @defer/@stream — WebSocket 없이도 서버 → 클라이언트 stream이 가능하다. HTTP 위의 incremental delivery.