04 — GraphQL over WebSocket
질문: 서버가 클라이언트에게 반복해서 데이터를 보내야 한다면 (subscription) — 어떤 transport를 쓰나? 메시지 시퀀스는 어떻게 생겼나? 한 줄 답: 표준은
graphql-ws(서브프로토콜 이름:graphql-transport-ws).subscriptions-transport-ws(Apollo가 2017년에 만든 것)는 2020년부터 deprecated다. 메시지 시퀀스:ConnectionInit→ConnectionAck→Subscribe→Next(여러 번) →Complete. 인증 헤더는 HTTP가 아니라ConnectionInit.payload에 실어 보낸다.
Why — 왜 WebSocket인가
GraphQL의 세 operation type 중 subscription은 본질적으로 다르다:
| Operation | 의미 | 응답 횟수 |
|---|---|---|
| query | ”지금 이 데이터를 줘” | 1번 |
| mutation | ”이 데이터를 바꿔” | 1번 |
| subscription | ”이 데이터가 바뀔 때마다 알려줘” | N번 (수십~수만) |
HTTP는 1요청 → 1응답 모델이다. subscription은 그걸 깬다. 서버가 클라이언트에게 언제든 push해야 하고, 클라이언트가 그 연결을 유지해야 한다. 이걸 표현할 수 있는 transport이 몇 가지 있다:
- HTTP long-polling — 매번 connection 재수립, 비효율
- Server-Sent Events (SSE) — 서버→클라이언트 단방향 stream (→
06) - WebSocket — 양방향 full duplex stream
GraphQL subscription의 de facto는 WebSocket이다. 이유는 양방향이라는 점 — 클라이언트가 subscription을 시작/중단하는 메시지도 같은 채널로 보낼 수 있기 때문이다.
How — graphql-ws의 메시지 시퀀스
graphql-ws 서브프로토콜 이름은 **graphql-transport-ws**다 (혼란스럽지만 사실이다 — 라이브러리는 graphql-ws, 서브프로토콜은 graphql-transport-ws).
메시지 종류
| Direction | Type | Payload | 의미 |
|---|---|---|---|
| C → S | connection_init | 자유 (보통 auth) | “이 connection을 시작하겠다” |
| S → C | connection_ack | (없음) | “OK, 받아들였다” |
| S → C | connection_error | { message } | ”거부한다” (인증 실패 등) |
| C → S | subscribe | { query, variables, operationName } | ”이 operation을 시작” — id로 구분 |
| S → C | next | ExecutionResult | ”여기 다음 결과” — data + errors |
| S → C | error | GraphQLError[] | ”이 subscription 자체가 실패” |
| S → C | complete | (없음) | “이 subscription은 끝났다” |
| C → S | complete | (없음) | “내가 이 subscription을 그만두겠다” |
| C ↔ S | ping / pong | (없음) | keep-alive |
id는 클라이언트가 만든 subscription identifier다. 한 WebSocket connection 위에 여러 subscription을 다중화할 수 있다.
How — 실제 JS 코드
클라이언트 (graphql-ws)
import { createClient } from 'graphql-ws';
const client = createClient({
url: 'wss://api.example.com/graphql',
connectionParams: () => ({
// 이게 ConnectionInit.payload로 감 — 인증 토큰 위치
Authorization: `Bearer ${getToken()}`,
}),
// 토큰이 바뀌면 connection 재수립
shouldRetry: () => true,
});
// subscription 시작
const unsubscribe = client.subscribe(
{
query: `subscription OnComment($postId: ID!) {
onComment(postId: $postId) { id text author { name } }
}`,
variables: { postId: 'p_42' },
},
{
next: (result) => console.log('new comment:', result.data),
error: (err) => console.error(err),
complete: () => console.log('done'),
},
);
// 끝낼 때
unsubscribe();서버 (graphql-ws + ws on Node)
import { useServer } from 'graphql-ws/use/ws';
import { WebSocketServer } from 'ws';
import { schema } from './schema';
const wsServer = new WebSocketServer({
server: httpServer,
path: '/graphql',
});
useServer(
{
schema,
// connection_init.payload를 받아서 context 만듦
context: async (ctx) => {
const token = ctx.connectionParams?.Authorization;
const user = await verifyToken(token);
if (!user) throw new Error('Unauthorized'); // → connection_error
return { user };
},
onSubscribe: async (ctx, message) => {
// subscribe 시점에 추가 권한 검사 가능
},
},
wsServer,
);What — subscriptions-transport-ws는 deprecated다
이름 충돌의 비극 — subscriptions-transport-ws 라이브러리가 사용하는 서브프로토콜 이름이 **graphql-ws**다. 그리고 2020년에 새로 나온 라이브러리 이름이 **graphql-ws**다. 그 새 라이브러리의 서브프로토콜이 **graphql-transport-ws**다.
| 라이브러리 이름 | WebSocket 서브프로토콜 | |
|---|---|---|
| 옛것 (deprecated) | subscriptions-transport-ws | graphql-ws |
| 새것 (표준) | graphql-ws | graphql-transport-ws |
이 교차된 이름 짓기가 모든 혼란의 원인이다. 새 프로젝트는 graphql-ws 라이브러리 + graphql-transport-ws 서브프로토콜.
두 프로토콜의 실제 차이
| 항목 | subscriptions-transport-ws (legacy) | graphql-ws (new) |
|---|---|---|
| 메시지 type 이름 | GQL_CONNECTION_INIT | connection_init |
| ping/pong | 서버가 매 N초마다 GQL_CONNECTION_KEEP_ALIVE 전송 | 양방향 ping/pong, 표준 WS ping 활용 |
| query/mutation도 가능 | ✅ | ✅ (한 connection으로 다중화) |
| spec 문서화 | 일관성 부족, 변종 다수 | 한 spec 문서 (README) |
| 보안 패치만 받음 | ✅ (2020~) | 활발히 개발 |
What — 인증의 위치
HTTP 헤더가 아니라 ConnectionInit.payload에 토큰을 실어 보낸다.
이유:
- 브라우저 WebSocket API는 handshake 단계에서 custom 헤더를 못 보낸다 (only via Sec-WebSocket-Protocol or cookies).
- 그래서 handshake 이후 첫 메시지인
ConnectionInit에 인증 정보를 실어 보내는 게 표준.
// 클라이언트
createClient({
url: 'wss://...',
connectionParams: { Authorization: 'Bearer ...' },
});// 서버
useServer({
context: (ctx) => {
const token = ctx.connectionParams?.Authorization;
// ...
},
});토큰 갱신 — connection이 몇 시간씩 유지되면 토큰이 만료된다. 두 전략:
- 만료 직전 disconnect + reconnect — 새 토큰으로 새 connection.
- 클라이언트가 주기적으로 새 토큰을 메시지로 보냄 — 비표준, custom 메시지 필요.
대부분의 production 셋업은 (1)을 쓴다.
What-if — WS subscription의 함정
subscriptions-transport-ws를 새로 설치 → 5년 묵은 deprecated를 production에 올린다. 검색 결과의 첫 페이지가 여전히 이걸 안내한다.- 인증을 HTTP 헤더로 보내려고 시도 → 브라우저에서 불가능. 항상
connectionParams로. - load balancer가 WebSocket을 끊음 — AWS ALB는 idle timeout 60초 기본. 그보다 자주 ping을 보내야 connection이 유지된다.
- HTTP/2 reverse proxy 뒤에서 WS — nginx·envoy는 명시적 설정이 필요.
proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade;. - subscription을 모든 read에 쓰려고 함 — HTTP query보다 비싸다. long-lived connection의 비용은
05에서.
Insight — graphql-ws는 라이브러리 한 개의 승부에서 이긴 결과다
2017~2020년은 Apollo가 GraphQL 생태계의 모든 layer를 정의하던 시기였다. Apollo Server, Apollo Client, Apollo Engine, 그리고 subscriptions-transport-ws까지. 그런데 subscriptions-transport-ws만은 Apollo도 만족 못 했다 — 메시지 spec이 정리되지 않았고, ping 처리가 일관성 없었으며, 여러 클라이언트가 서로 안 맞았다.
2020년 enisdenjo(Denis Badurina)라는 외부 개발자가 전혀 새로 다시 쓴 graphql-ws를 발표했다. Apollo 외부에서 만들어진 게 Apollo 공식 권장이 된 드문 사례다.
이건 오픈소스의 메리토크라시가 어떻게 동작하는지의 좋은 사례다 — 더 잘 만들면 기존 권력도 양보한다.
흥미로운 이야기 — Hasura가 GraphQL subscription을 대중화시켰다
GraphQL subscription이 실제로 production에 쓰이기 시작한 결정적 순간은 Hasura의 등장(2018)이다. Hasura는 PostgreSQL을 자동 GraphQL API로 변환해주는데, subscription을 PostgreSQL의 LISTEN/NOTIFY로 자동 매핑했다. 개발자가 코드를 한 줄도 안 쓰고 실시간 데이터를 얻을 수 있게 됐다. 그 결과로 subscription이 “Apollo 전용 실험 기능”에서 PostgreSQL이 있는 모든 곳의 default가 됐다.
PubSub 백엔드와 scaling 문제는 다음 문서에서.
요약
- subscription의 transport = WebSocket.
- 라이브러리 =
graphql-ws(enisdenjo). 서브프로토콜 =graphql-transport-ws.subscriptions-transport-ws는 deprecated. 새 프로젝트는 절대 쓰지 말 것.- 메시지 시퀀스:
connection_init→connection_ack→subscribe→next(N번) →complete.- 인증은
connection_init.payload(헤더 아님).
다음: 05 — Subscriptions & PubSub — WebSocket은 통로고, 그 안에 흘릴 이벤트 백엔드가 진짜 비용이다.