🔷 GraphQL4. 전송 계층06 — SSE & @defer/@stream

06 — SSE & @defer/@stream

질문: subscription 말고도 서버가 한 클라이언트에게 여러 번 응답을 보내야 할 케이스가 있다 — 한 쿼리 안의 느린 부분을 먼저 빠른 것 뒤로 미루는 경우. 이걸 어떻게 transport에 실어 보내나? 한 줄 답: @defer/@stream 디렉티브는 한 쿼리의 응답을 여러 청크로 분할한다 — transport는 두 갈래다: multipart/mixed (Apollo가 production에 채택) 또는 Server-Sent Events (graphql-sse). 이 둘은 WebSocket 없이 HTTP 위에서 streaming을 실현한다. 2026년 현재 spec은 working draft — Apollo는 production에 쓰지만 GraphQL spec의 정식 stable에는 아직 못 들어갔다.

이전 두 문서는 subscription(WS) — 여러 이벤트, 무기한 stream을 다뤘다. 이 문서는 한 쿼리, 여러 청크를 다룬다. 둘은 비슷해 보이지만 완전히 다른 use case다.


Why — 한 쿼리 안에 빠른 것과 느린 것이 섞여 있을 때

User profile 페이지를 그린다고 하자.

query UserProfile($id: ID!) {
  user(id: $id) {
    name           # DB 조회 — 10ms
    avatarUrl      # DB 조회 — 10ms
    recommendations(limit: 20) {   # ML 모델 추론 — 800ms
      title
      score
    }
  }
}

recommendations만 800ms. 전체 응답이 810ms 뒤에야 클라이언트에 도착한다. 사용자는 800ms 동안 빈 화면을 본다 — name이미 서버에 있는데.

해결책 두 가지:

  1. 쿼리를 둘로 쪼갬UserHeader + UserRecommendations. 클라이언트 코드가 복잡해지고, 두 번의 round trip이 생긴다.
  2. 한 쿼리 안에서 일부를 나중에 보냄@defer. 클라이언트는 한 쿼리만 쓰고, 서버가 준비된 부분부터 흘려 보냄.

이게 @defer/@stream의 motivation이다.


How — @defer@stream 디렉티브

@defer — 한 블록을 나중으로

query UserProfile($id: ID!) {
  user(id: $id) {
    name
    avatarUrl
    ... on User @defer(label: "recs") {
      recommendations(limit: 20) {
        title
        score
      }
    }
  }
}

서버 응답은 두 청크로 나뉜다:

청크 1 (10ms 후):
{
  "data": { "user": { "name": "Lee", "avatarUrl": "..." } },
  "hasNext": true
}

청크 2 (800ms 후):
{
  "incremental": [{
    "label": "recs",
    "path": ["user"],
    "data": { "recommendations": [...] }
  }],
  "hasNext": false
}

@stream — 리스트의 원소를 하나씩

query Feed {
  posts @stream(initialCount: 5, label: "more") {
    id
    title
  }
}

서버 응답:

청크 1: 처음 5개 posts
청크 2~N: 나머지를 *원소 단위*로 흘려 보냄

@stream리스트 페이지네이션의 streaming 버전이다 — 클라이언트가 처음 5개로 화면을 그리는 동안 나머지가 뒤따라 도착한다.


What — Transport 두 갈래

Transport 1: multipart/mixed

요청:

POST /graphql HTTP/1.1
Accept: multipart/mixed; deferSpec=20220824, application/json
Content-Type: application/json
 
{ "query": "query { user { name ... @defer { recommendations { ... } } } }" }

응답:

HTTP/1.1 200 OK
Content-Type: multipart/mixed; boundary="-"; deferSpec=20220824
Transfer-Encoding: chunked
 
---
Content-Type: application/json; charset=utf-8
 
{ "hasNext": true, "data": { "user": { "name": "Lee" } } }
 
---
Content-Type: application/json; charset=utf-8
 
{ "hasNext": false, "incremental": [{ "label": "recs", "path": ["user"], "data": {...} }] }
 
-----

이게 Apollo가 production에 쓰는 형식이다. apollo-client@3.7+ + apollo-server@4+에서 자동 지원.

Transport 2: Server-Sent Events (SSE)

요청:

POST /graphql/stream HTTP/1.1
Accept: text/event-stream
Content-Type: application/json
 
{ "query": "query { ... @defer { ... } }" }

응답:

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
 
event: next
data: {"hasNext":true,"data":{"user":{"name":"Lee"}}}
 
event: next
data: {"hasNext":false,"incremental":[...]}
 
event: complete
data:

이건 graphql-sse 라이브러리(graphql-ws와 같은 저자)가 표준화한 형식이다.

비교

항목multipart/mixedSSE
브라우저 APIfetch + ReadableStreamEventSource (또는 fetch)
자동 reconnect✅ 내장
HTTP/2 친화
CORS 처리일반 fetch와 동일EventSource는 cookie 제한
Apollo 공식 지원✅ default⚠️ 옵션
Subscription도 가능✅ (graphql-sse는 subscription도 함)

실무 선택: Apollo Client를 쓰면 기본 multipart/mixed. 별도 transport 없이 HTTP만 쓸 거면 SSE가 reconnect 등에서 편함.


What — Spec 상태 (2026)

@defer/@streamGraphQL spec PR #742로 제안됐다 — 2020년 시작, 2026년 현재 여전히 working draft다.

spec이 아직 draft인 이유:

  • 응답 페이로드 모양이 여러 번 바뀌었다 (incremental 키, hasNext 키, label 의미 등).
  • transport 전쟁 — multipart vs SSE vs custom JSON streaming — 합의 미완.
  • error 의미론 — 청크 1은 성공, 청크 2는 실패면 errors[]를 어디에 둘 것인가.

production 도입 시 주의 — Apollo의 현재 구현에 lock-in되며, spec stabilize 시 마이너 breaking change가 있을 수 있다. 보수적 팀은 내부 사용에만 한정하고, 외부 API에는 stabilize 전까지 보류하는 게 일반적.


What — React Suspense와의 매칭

@defer/@stream진짜 가치를 보여주는 건 React Suspense와 결합할 때다.

function UserProfile({ id }) {
  const { data } = useQuery(USER_QUERY, { variables: { id } });
 
  return (
    <div>
      <Header name={data.user.name} avatar={data.user.avatarUrl} />
      <Suspense fallback={<RecsSkeleton />}>
        <Recommendations data={data.user.recommendations} />
      </Suspense>
    </div>
  );
}

서버가 청크 1을 보내면 → Header가 즉시 그려진다. Suspense 안의 자식은 recommendations가 아직 undefined라서 fallback을 보여준다. 청크 2가 도착하면 → Apollo가 cache를 업데이트 → Suspense가 자식을 다시 그린다.

한 쿼리·한 컴포넌트 트리에서 부분적 SSR의 효과가 난다. Next.js의 App Router도 내부적으로 비슷한 패턴을 쓴다 (HTML streaming + RSC payload streaming).


What-if — @defer/@stream의 함정

  • CDN/프록시가 응답을 buffer함 → 일부 CDN(특히 Vercel·Cloudflare 일부 플랜)은 chunked transfer-encoding을 안 흘려보낸다. streaming의 의미가 사라짐.
  • HTTP/1.1 + single TCP — 브라우저가 같은 origin에 6개 connection 제한 — streaming response 하나가 connection을 점유.
  • error in defer chunk — 처음 청크는 200 + data, 두 번째 청크가 에러면? HTTP status는 못 바꿈 — 청크 안에서 errors로 표현해야 함.
  • client cache 불일치 — Apollo는 청크별로 cache 업데이트하지만, 다른 라이브러리는 안 그럴 수 있음.
  • spec 변경 위험 — 위 §spec status 참조.

Insight — SSE의 부활

Server-Sent Events는 2009년에 HTML5 표준에 들어갔지만 거의 잊혀졌다 — WebSocket이 모든 실시간 use case를 가져갔기 때문. 그런데 2023~2024년 무렵 재등장한다.

이유:

  1. HTTP/2·HTTP/3에서 multiplexing이 잘 됨 — SSE의 6 connection 제한이 사라짐.
  2. 단방향이면 충분한 경우가 많음 — LLM streaming(OpenAI API 등)이 SSE로 굳어졌다.
  3. EventSource의 자동 reconnect가 production에서 편하다 — WS reconnect는 직접 구현해야 함.
  4. 단순함 — HTTP 그대로 — 디버깅·proxy·캐시 인프라가 그대로 쓰임.

GraphQL에서도 graphql-sse대안 transport로 자리잡았다 — 특히 subscription을 WS 없이 하고 싶을 때.


흥미로운 이야기 — Netflix는 @defer5년 먼저 만들었다

Netflix는 2018년경 자체 GraphQL 서버에 @defer 비슷한 디렉티브를 먼저 구현했다. 그들의 motivation은 단순했다 — Netflix 홈페이지 한 화면을 그리는데 30개 row × 각 row마다 ML 추천 호출 — 모든 row가 끝나기를 기다리면 5초가 걸렸다.

@defer기본 layout은 즉시, 각 row의 추천은 도착하는 대로 흘려보내자 체감 로딩 시간이 90% 줄었다.

2019년 GraphQL Summit에서 Netflix가 발표한 이 패턴이 spec PR #742의 직접적 영감이 됐다.

한 회사의 production 패턴이 spec 제안이 된다 — GraphQL이 그렇게 자라온 방식이다. subscription도 그랬고, federation도 그랬고, @defer도 그렇다.


요약

  1. @defer/@stream한 쿼리의 응답을 여러 청크로 나누는 디렉티브.
  2. Transport: multipart/mixed (Apollo 표준) 또는 SSE (graphql-sse).
  3. spec은 여전히 working draft (2026) — Apollo는 production에 쓰지만 breaking change 위험 있음.
  4. React Suspense와 짝을 이룰 때 진짜 가치 — 한 쿼리로 progressive rendering.
  5. SSE는 HTTP/2 시대에 부활했다 — 단방향이면 WS보다 단순하고 견고함.

다음 챕터: 05 — Cache & Performance — 이 transport들 위에서 캐시가 어떻게 작동하는지. POST가 캐시 안 되는 문제를 클라이언트 cache·persisted query·HTTP cache·DataLoader가 어떻게 메우는가.