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은 이미 서버에 있는데.
해결책 두 가지:
- 쿼리를 둘로 쪼갬 —
UserHeader+UserRecommendations. 클라이언트 코드가 복잡해지고, 두 번의 round trip이 생긴다. - 한 쿼리 안에서 일부를 나중에 보냄 —
@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/mixed | SSE |
|---|---|---|
| 브라우저 API | fetch + ReadableStream | EventSource (또는 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/@stream는 GraphQL 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년 무렵 재등장한다.
이유:
- HTTP/2·HTTP/3에서 multiplexing이 잘 됨 — SSE의 6 connection 제한이 사라짐.
- 단방향이면 충분한 경우가 많음 — LLM streaming(OpenAI API 등)이 SSE로 굳어졌다.
- EventSource의 자동 reconnect가 production에서 편하다 — WS reconnect는 직접 구현해야 함.
- 단순함 — HTTP 그대로 — 디버깅·proxy·캐시 인프라가 그대로 쓰임.
GraphQL에서도 graphql-sse가 대안 transport로 자리잡았다 — 특히 subscription을 WS 없이 하고 싶을 때.
흥미로운 이야기 — Netflix는 @defer를 5년 먼저 만들었다
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도 그렇다.
요약
@defer/@stream은 한 쿼리의 응답을 여러 청크로 나누는 디렉티브.- Transport: multipart/mixed (Apollo 표준) 또는 SSE (
graphql-sse).- spec은 여전히 working draft (2026) — Apollo는 production에 쓰지만 breaking change 위험 있음.
- React Suspense와 짝을 이룰 때 진짜 가치 — 한 쿼리로 progressive rendering.
- SSE는 HTTP/2 시대에 부활했다 — 단방향이면 WS보다 단순하고 견고함.
다음 챕터: 05 — Cache & Performance — 이 transport들 위에서 캐시가 어떻게 작동하는지. POST가 캐시 안 되는 문제를 클라이언트 cache·persisted query·HTTP cache·DataLoader가 어떻게 메우는가.