🔷 GraphQL4. 전송 계층04 — GraphQL over WebSocket

04 — GraphQL over WebSocket

질문: 서버가 클라이언트에게 반복해서 데이터를 보내야 한다면 (subscription) — 어떤 transport를 쓰나? 메시지 시퀀스는 어떻게 생겼나? 한 줄 답: 표준은 graphql-ws (서브프로토콜 이름: graphql-transport-ws). subscriptions-transport-ws(Apollo가 2017년에 만든 것)는 2020년부터 deprecated다. 메시지 시퀀스: ConnectionInitConnectionAckSubscribeNext(여러 번) → Complete. 인증 헤더는 HTTP가 아니라 ConnectionInit.payload에 실어 보낸다.


Why — 왜 WebSocket인가

GraphQL의 세 operation type 중 subscription은 본질적으로 다르다:

Operation의미응답 횟수
query”지금 이 데이터를 줘”1번
mutation”이 데이터를 바꿔”1번
subscription”이 데이터가 바뀔 때마다 알려줘”N번 (수십~수만)

HTTP는 1요청 → 1응답 모델이다. subscription은 그걸 깬다. 서버가 클라이언트에게 언제든 push해야 하고, 클라이언트가 그 연결을 유지해야 한다. 이걸 표현할 수 있는 transport이 몇 가지 있다:

  1. HTTP long-polling — 매번 connection 재수립, 비효율
  2. Server-Sent Events (SSE) — 서버→클라이언트 단방향 stream (→ 06)
  3. WebSocket — 양방향 full duplex stream

GraphQL subscription의 de factoWebSocket이다. 이유는 양방향이라는 점 — 클라이언트가 subscription을 시작/중단하는 메시지도 같은 채널로 보낼 수 있기 때문이다.


How — graphql-ws의 메시지 시퀀스

graphql-ws 서브프로토콜 이름은 **graphql-transport-ws**다 (혼란스럽지만 사실이다 — 라이브러리graphql-ws, 서브프로토콜graphql-transport-ws).

메시지 종류

DirectionTypePayload의미
C → Sconnection_init자유 (보통 auth)“이 connection을 시작하겠다”
S → Cconnection_ack(없음)“OK, 받아들였다”
S → Cconnection_error{ message }”거부한다” (인증 실패 등)
C → Ssubscribe{ query, variables, operationName }”이 operation을 시작” — id로 구분
S → CnextExecutionResult”여기 다음 결과” — data + errors
S → CerrorGraphQLError[]”이 subscription 자체가 실패”
S → Ccomplete(없음)“이 subscription은 끝났다”
C → Scomplete(없음)“내가 이 subscription을 그만두겠다”
C ↔ Sping / 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-wsdeprecated

이름 충돌의 비극subscriptions-transport-ws 라이브러리가 사용하는 서브프로토콜 이름이 **graphql-ws**다. 그리고 2020년에 새로 나온 라이브러리 이름이 **graphql-ws**다. 그 새 라이브러리의 서브프로토콜이 **graphql-transport-ws**다.

라이브러리 이름WebSocket 서브프로토콜
옛것 (deprecated)subscriptions-transport-wsgraphql-ws
새것 (표준)graphql-wsgraphql-transport-ws

교차된 이름 짓기가 모든 혼란의 원인이다. 새 프로젝트는 graphql-ws 라이브러리 + graphql-transport-ws 서브프로토콜.

두 프로토콜의 실제 차이

항목subscriptions-transport-ws (legacy)graphql-ws (new)
메시지 type 이름GQL_CONNECTION_INITconnection_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이 몇 시간씩 유지되면 토큰이 만료된다. 두 전략:

  1. 만료 직전 disconnect + reconnect — 새 토큰으로 새 connection.
  2. 클라이언트가 주기적으로 새 토큰을 메시지로 보냄 — 비표준, 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 문제는 다음 문서에서.


요약

  1. subscription의 transport = WebSocket.
  2. 라이브러리 = graphql-ws (enisdenjo). 서브프로토콜 = graphql-transport-ws.
  3. subscriptions-transport-wsdeprecated. 새 프로젝트는 절대 쓰지 말 것.
  4. 메시지 시퀀스: connection_initconnection_acksubscribenext(N번) → complete.
  5. 인증은 connection_init.payload (헤더 아님).

다음: 05 — Subscriptions & PubSub — WebSocket은 통로고, 그 안에 흘릴 이벤트 백엔드가 진짜 비용이다.