GraphQL 구독은 스키마로 정의된 타입 있는 이벤트 스트림이고, 웹소켓은 그 구독이 보통 실려 가는 원시 전송 계층입니다. 제목의 “vs.”는 결정을 내리기 전에 풀어 둘 가치가 있는 계층 혼동입니다. 구독은 웹소켓의 대안이 아닙니다. HTTP가 TCP 위에서 동작하듯, 웹소켓 위에서 실행할 수 있는 것 중 하나입니다. 진짜 선택은 다른 누군가가 명세한 타입 있는 프로토콜과, 프로토콜을 직접 발명해야 하는 원시 파이프 사이의 선택입니다.
핵심 요약
| 질문 | 답변 |
|---|---|
| 웹소켓 | 지속적인 전이중 바이트 파이프. 메시지 의미론은 포함되지 않음 |
| GraphQL 구독 | 스키마로 정의된 이벤트 스트림. 다른 GraphQL 응답처럼 타입이 지정되고 검증됨 |
| 둘의 관계 | 경쟁이 아니라 계층. 구독은 서브프로토콜을 통해 웹소켓(또는 SSE) 위에서 동작 |
| 서브프로토콜 | graphql-ws: init/ack 핸드셰이크, id로 작업 다중화, next/complete 프레임 |
| 진짜 결정 | 기성품 타입 프로토콜 vs. 원시 소켓 + 이제 직접 책임지는 프로토콜 |
코드로 보는 계층 구조
구독이 실제로 회선 위에서 어떤 모습인지, 웹소켓 안에서 오가는 graphql-ws 대화로 보입니다:
// 클라이언트 → 서버, 소켓이 열린 뒤
{ "type": "connection_init", "payload": { "authToken": "…" } }
// 서버 → 클라이언트
{ "type": "connection_ack" }
// 클라이언트가 작업을 시작함. id가 이 구독을 다중화함
{ "type": "subscribe", "id": "1", "payload": {
"query": "subscription { orderUpdated(status: PREPARING) { id status eta } }" } }
// 서버가 타입 있는 이벤트를 스트리밍함. 발생할 때마다 프레임 하나
{ "type": "next", "id": "1", "payload": { "data": { "orderUpdated": { "id": "o42", "status": "READY", "eta": null } } } }
// 어느 쪽이든 스트림을 종료함
{ "type": "complete", "id": "1" }
그리고 대부분의 애플리케이션 코드가 실제로 작성하는 것은, 스택 전체가 관리되는 타입 있는 구독입니다:
// JavaScript — Back4app JS SDK
// A typed subscription over a managed WebSocket fleet: the protocol,
// reconnects, and fan-out are the platform's problem, not yours
const orders = new Parse.Query('Order');
orders.equalTo('status', 'preparing');
const sub = await orders.subscribe();
sub.on('create', (o) => addCard(o)); // typed event, full object
sub.on('update', (o) => refreshCard(o));
sub.on('leave', (o) => removeCard(o)); // edited out of the result set
sub.on('close', () => showOfflineBadge()); // socket lifecycle surfaced // Flutter / Dart — Back4app Flutter SDK
// A typed subscription over a managed WebSocket fleet: the protocol,
// reconnects, and fan-out are the platform's problem, not yours
final liveQuery = LiveQuery();
final orders = QueryBuilder<ParseObject>(ParseObject('Order'))
..whereEqualTo('status', 'preparing');
final sub = await liveQuery.client.subscribe(orders);
sub.on(LiveQueryEvent.create, (o) => addCard(o)); // typed event
sub.on(LiveQueryEvent.update, (o) => refreshCard(o));
sub.on(LiveQueryEvent.leave, (o) => removeCard(o)); // left the set // iOS / Swift — Back4app Swift SDK
// A typed subscription over a managed WebSocket fleet: the protocol,
// reconnects, and fan-out are the platform's problem, not yours
let orders = Order.query("status" == "preparing")
let subscription = orders.subscribeCallback
subscription?.handleEvent { _, event in
switch event {
case .created(let o): addCard(o) // typed event, full object
case .updated(let o): refreshCard(o)
case .left(let o): removeCard(o) // edited out of the result set
default: break
}
} // Android / Kotlin — Back4app Android SDK
// A typed subscription over a managed WebSocket fleet: the protocol,
// reconnects, and fan-out are the platform's problem, not yours
val client = ParseLiveQueryClient.Factory.getClient()
val orders = ParseQuery.getQuery<ParseObject>("Order")
orders.whereEqualTo("status", "preparing")
val sub = client.subscribe(orders)
sub.handleEvent(SubscriptionHandling.Event.CREATE) { _, o -> addCard(o) }
sub.handleEvent(SubscriptionHandling.Event.UPDATE) { _, o -> refreshCard(o) }
sub.handleEvent(SubscriptionHandling.Event.LEAVE) { _, o -> removeCard(o) } 하나의 스택, 세 개의 계층
웹소켓 계층(RFC 6455)이 약속하는 것은 정확히 여기까지입니다. 텍스트 또는 바이너리 프레임으로 이루어진 지속적이고 전이중이며 순서가 보장된 스트림이며, 모든 브라우저에서 작은 API 하나로 접근할 수 있습니다. 메시지가 무엇을 의미하는지는 아무것도 말하지 않습니다. 요청/응답 연결도, 인증 관례도, 오류 신호도, 하나의 소켓 위에서 두 개의 논리 스트림을 실행하는 방법도 없습니다. 모든 원시 소켓 프로젝트가 이 전부를 다시 결정합니다.
구독 계층은 바로 그 빠진 결정들을 표준화한 것입니다. connection_init/connection_ack이 인증을 실어 나르고, 작업별 id가 하나의 소켓 위에서 여러 구독을 다중화합니다. next 프레임은 평범한 GraphQL 응답인 페이로드를 전달합니다. 스키마로 타입이 지정되고, 검증되고, 인트로스펙션이 가능하며, 쿼리와 뮤테이션을 처리하는 것과 같은 클라이언트 장치로 소비됩니다. complete는 이웃 스트림을 죽이지 않고 하나의 스트림을 끝냅니다. 실무에서 중요한 역사적 각주가 하나 있습니다. 초기 생태계에서 나온 구형 서브프로토콜이 아직 배포되어 있으며, 한쪽 프로토콜을 쓰는 클라이언트가 다른 쪽 프로토콜을 쓰는 서버와 통신하면 혼란스러울 만큼 조용하게 실패합니다. 서브프로토콜은 양쪽 끝에서 명시적으로 고정해야 합니다.
그리고 계약의 단위가 소켓이 아니라 메시지이기 때문에 그 아래의 전송 계층은 교체할 수 있습니다. 같은 구독 의미론이 점점 더 서버 전송 이벤트 위에서 실행되고 있으며, 이는 압도적으로 단방향인 구독 트래픽의 형태에 잘 맞고 SSE의 프록시 친화성과 자동 재연결을 물려받습니다. “구독 vs. 웹소켓”이라는 구도는 들여다보는 순간 사라집니다. 하나는 계약이고, 다른 하나는 운반체입니다.
GraphQL 구독 vs. 원시 웹소켓
| GraphQL 구독 | 원시 웹소켓 | |
|---|---|---|
| 계층 | 전송 계층 위의 프로토콜 + 타입 시스템 | 전송 계층 그 자체 |
| 메시지 계약 | 스키마로 정의, 검증, 인트로스펙션 가능 | 직접 발명하고 문서화한 무엇이든 |
| 다중화 | 내장: 하나의 소켓에서 작업별 id | 직접 설계해야 함 |
| 인증 핸드셰이크 | 표준화됨(connection_init 페이로드) | 직접 설계해야 함 |
| 페이로드 | JSON, 스키마 형태 | 텍스트 및 바이너리, 어떤 형식이든 |
| 필터링 | 구독 필드의 인자 | 직접 작성하는 서버 코드 |
| 이벤트당 오버헤드 | JSON 봉투 + 리졸버 실행 | 프레이밍 약 2–14바이트 |
| 생태계 | GraphQL 클라이언트, 코드 생성, 도구 | 기본 소켓 라이브러리 |
| 적합한 경우 | GraphQL 앱에서 이벤트가 타입 있는 API 데이터일 때 | 바이너리, 고빈도, 또는 커스텀 의미론 |
원시 소켓이 타입 있는 구독보다 나은 경우
정직한 사례는 실재하지만, 원시 소켓 애호가들이 말하는 것보다는 좁습니다. 바이너리 페이로드: 오디오 청크, 프로토콜 버퍼, 게임 상태는 웹소켓 프레임에 그대로 실리지만, JSON 구독 봉투 안에서는 인코딩이 필요합니다. 메시지 빈도: 클라이언트당 초당 수천 개의 이벤트에서는 이벤트별 리졸버 실행과 JSON 봉투가 더 이상 잡음이 아니며, 간결한 커스텀 프레임 형식은 정당한 최적화입니다. 커스텀 의미론: 백프레셔, 클라이언트 측 확인 응답, 델타 인코딩, 재개 가능한 커서는 직접 설계하는 프로토콜에 속하며, 이를 구독 프레임에 덧붙이면 명세와 싸우게 됩니다. 그리고 애초에 GraphQL이 없는 경우: 타입 있는 이벤트를 얻으려는 목적만으로 스키마, 리졸버, 클라이언트 도구를 도입하는 것은 주객전도입니다. 메시지 형식을 문서화한 원시 소켓이 더 작습니다. 함정은 반대 방향으로도 작동합니다. 타입이 있고 JSON 형태인 API 이벤트에 원시 소켓을 고른 팀은 결국 다중화, 인증 핸드셰이크, 재연결 의미론을 손으로 작성하게 됩니다. 팀마다 호환되지 않는, 더 못한 graphql-ws가 하나씩 생기는 셈입니다.
일반적인 사용 사례
- 주문 및 상태 스트림: “이 주문이 바뀌면 알려 달라.” 타입 있는 API 데이터에 빈도가 낮은, 구독이 가장 잘 맞는 영역입니다.
- 협업 프레즌스와 댓글: 이미 스키마를 가진 GraphQL 앱에서의 구독입니다. 이벤트는 스키마의 일부일 뿐입니다.
- 금융 시세와 대시보드: 페이로드가 JSON 형태인 동안은 구독, 틱 빈도가 간결한 프레임을 요구하면 원시 소켓입니다.
- 채팅: 어느 계층이든 작동합니다. 결정적인 한 표는 보통 앱이 GraphQL 우선인지 여부입니다. 라이브 쿼리가 이벤트 연결 없이 같은 영역을 다루기 때문입니다.
- 멀티플레이어 상태와 미디어: 바이너리, 고빈도, 지연 시간이 핵심인 경우로, 프로토콜까지 포함해 원시 웹소켓의 영역입니다.
GraphQL 구독과 원시 웹소켓 중 무엇을 선택해야 할까? 결정 매트릭스
| 상황 | 선택 |
|---|---|
| 앱이 이미 GraphQL을 쓰고, 이벤트가 타입 있는 API 데이터임 | GraphQL 구독 |
| 바이너리 프레임, 또는 클라이언트당 초당 수천 개의 이벤트 | 원시 웹소켓 |
| 확인 응답, 델타, 커서, 백프레셔 같은 커스텀 의미론이 필요함 | 원시 웹소켓, 프로토콜 문서화 |
| GraphQL 투자가 없고, 단순한 이벤트 피드 | 작은 프레임 프로토콜을 갖춘 원시 소켓, 또는 SSE |
| 이벤트가 “이 쿼리의 결과가 바뀌었다”임 | 라이브 쿼리 계층. 이벤트 연결이 전혀 필요 없음 |
| 엄격한 프록시, HTTP 전용 인프라 | SSE 위의 구독 |
| 소규모 팀, 프로토콜을 책임질 여력이 없음 | 관리형 백엔드의 타입 있는 구독 |
한계와 트레이드오프
- 구독은 웹소켓 운영을 물려받습니다. 계층을 쌓으면 의미가 더해질 뿐 마법이 생기지는 않습니다. 연결 상태, 스티키 라우팅, 하트비트, 서버 간 pub/sub 백플레인은 여전히 그 아래의 배포 현실입니다.
- 재연결 시 이벤트는 여전히 유실됩니다. graphql-ws는 스트림을 정의할 뿐 재개를 정의하지 않습니다. 소켓이 끊기면 프레임을 놓치며, 따라잡기(다시 쿼리한 뒤 다시 구독)는 어느 스택에서든 애플리케이션 로직입니다.
- 구독자별 해석에는 비용이 듭니다. 필터링된 구독은 이벤트마다, 구독자마다 리졸버와 권한 작업을 실행할 수 있습니다. 수천 명이 구독하는 인기 토픽에서는 이 작업이 구독자 수만큼 늘어납니다. 필터는 서버 측에서 좁게 설계해야 합니다.
- 서브프로토콜 두 개가 공존합니다. 구형과 최신 GraphQL-over-WebSocket 프로토콜은 서로 통하지 않으며, 양 끝이 맞지 않으면 조용히 실패합니다. 버전을 명시적으로 고정해야 합니다.
- 타입 있는 봉투는 처리량에 세금을 매깁니다. 이벤트별 JSON 직렬화와 스키마 검증은 초당 수십 개의 이벤트에서는 보이지 않지만 수천 개에서는 지배적입니다. 어느 쪽이든 가정하기 전에 측정해야 합니다.
Back4app에서의 GraphQL 구독과 웹소켓
Back4app은 관리형 데이터베이스, 자동 생성되는 REST 및 GraphQL API, 인증, 파일 스토리지, Cloud Code 서버리스 함수를 결합한 오픈소스 Backend as a Service(BaaS) 플랫폼입니다. 이 글의 계층 구조는 플랫폼에 그대로 대응됩니다. 자동 생성되는 GraphQL API는 리졸버 코드 없이 스키마로부터 쿼리와 뮤테이션 계층을 다루며, 실시간 계층은 Live Query로 제공됩니다. 관리형 웹소켓 서버군 위에서 타입이 지정되고 ACL로 검사되는 구독 이벤트로, graphql-ws 대신 공개된 LiveQuery 프로토콜을 사용하며, 손으로 연결한 이벤트가 아니라 결과 집합의 변경에 따라 트리거됩니다. 비교표의 타입 있는 프로토콜 열이 제공하는 다중화, 인증, 재연결 처리를 소켓 서버군을 운영하거나 프로토콜 명세를 책임지지 않고 얻을 수 있습니다.
자주 묻는 질문
GraphQL 구독과 웹소켓은 같은 것입니까?
아닙니다. 둘은 서로 다른 계층에 있습니다. 웹소켓은 전송 계층입니다. 지속적인 전이중(full-duplex) 바이트 파이프이며, 그 위를 지나가는 내용에는 관여하지 않습니다. GraphQL 구독은 그 위에 얹힌 프로토콜이자 계약입니다. 스키마로 정의된 작업이며, 그 이벤트는 다른 모든 GraphQL 응답과 똑같이 타입이 지정되고 검증되고 형태가 갖춰진 채 도착합니다. 둘을 직접 비교하는 것은 도로와 버스 노선을 비교하는 것과 같습니다.
GraphQL 구독은 어떤 프로토콜을 사용합니까?
가장 흔한 것은 최신 GraphQL-over-WebSocket 서브프로토콜인 graphql-ws입니다. 클라이언트가 소켓을 열고 connection_init을 보내 connection_ack을 받은 뒤, subscribe 메시지로 작업을 시작합니다. 서버는 구독 id별로 next 페이로드를 스트리밍하고, 어느 쪽이든 complete로 종료합니다. 초기 생태계에서 나온 구형 서브프로토콜도 여전히 쓰이고 있어서, 클라이언트와 서버가 어느 쪽을 쓸지 합의해야 합니다.
GraphQL 구독을 서버 전송 이벤트(SSE) 위에서 실행할 수 있습니까?
그렇습니다. 구독 계약은 전송 방식에 독립적이며, SSE는 점점 인기를 얻고 있는 정당한 전달 수단입니다. 구독 트래픽은 압도적으로 서버에서 클라이언트 방향이므로 단방향 HTTP 스트림이 자연스럽게 맞고, 일반 프록시와 HTTP 의미론과도 잘 어울리며, 자동 재연결도 덤으로 얻습니다. 대부분의 도구에서 웹소켓이 여전히 기본값이지만, "구독에는 웹소켓이 필요하다"는 말은 사실이 아니라 속설입니다.
GraphQL 구독 대신 원시 웹소켓을 언제 사용해야 합니까?
트래픽이 더 이상 타입 있는 API 이벤트처럼 보이지 않을 때입니다. 바이너리 프레임(오디오, 게임 상태, 센서 스트림), 이벤트별 스키마 검증과 JSON 봉투가 실제 처리량을 잡아먹는 매우 높은 메시지 빈도, 또는 커서, 델타, 확인 응답(ack)처럼 구독의 형태와 충돌하는 커스텀 의미론이 필요한 프로토콜이 그렇습니다. 이미 GraphQL 스키마에 투자하지 않았다면, 원시 소켓은 이벤트만을 위해 스키마를 도입하는 일도 피하게 해 줍니다.
GraphQL 구독은 확장됩니까?
전송 계층은 여느 웹소켓 서버군처럼 확장됩니다. 연결 상태, 스티키 라우팅, 서버 사이의 pub/sub 백플레인이 필요합니다. 구독 계층은 자체적인 축을 더합니다. 각 이벤트가 구독자별로 해석되고 필터링될 수 있으므로, 구독자가 많은 인기 토픽에서는 리졸버 작업이 구독자 수만큼 늘어납니다. 관리형 플랫폼은 서버군을 맡아 주지만, 스키마 설계와 구독자별 필터링 규율은 여전히 사용자의 몫입니다.
GraphQL 구독은 라이브 쿼리와 같은 것입니까?
트리거가 다른 가까운 사촌입니다. 구독은 명시적으로 연결한 이름 있는 이벤트에서 발생합니다. 뮤테이션이 토픽에 발행하면 구독자가 받습니다. 라이브 쿼리는 어떤 쿼리의 결과 집합이 바뀔 때 발생합니다. 이벤트를 연결할 필요가 없고 모든 쓰기 경로가 자동으로 포함됩니다. 둘 다 보통 웹소켓 위에서 동작합니다. 라이브 쿼리 계층은 스키마 우선 이벤트 설계를 데이터 자체에 대한 자동 변경 감지와 맞바꿉니다.
구독에는 왜 서브프로토콜이 필요합니까?
아무 프로토콜도 얹지 않은 웹소켓은 순서가 보장된 바이트 스트림일 뿐이기 때문입니다. 두 당사자가 하나의 소켓 위에서 여러 구독을 다중화하고, 이벤트를 작업과 연결하고, 인증을 협상하고, 오류를 알리고, 스트림을 깔끔하게 끝내야 하는 순간 메시지 프레이밍과 규칙이 필요해집니다. 예전에는 모든 팀이 이를 저마다 호환되지 않는 방식으로 서툴게 만들었습니다. graphql-ws는 정확히 그 계층을 표준화해 클라이언트와 서버가 상호 운용될 수 있게 합니다.