GraphQL 구독: WebSocket 및 SSE 전송 계층 심층 분석
Daniel Hayes
Full-Stack Engineer · Leapcell

소개
오늘날의 동적인 웹 환경에서 실시간 데이터 업데이트는 더 이상 사치가 아니라 근본적인 기대 사항입니다. 라이브 채팅 애플리케이션, 협업 문서부터 금융 대시보드 및 IoT 모니터링에 이르기까지 사용자는 즉각적인 피드백과 최신 정보를 요구합니다. 강력한 데이터 가져오기 기능을 갖춘 GraphQL은 서버로부터의 실시간 업데이트를 클라이언트가 수신할 수 있도록 하는 구독 기능을 통해 이 요구를 충족합니다. 그러나 우아한 GraphQL 구독 API의 이면에는 중요한 결정이 놓여 있습니다. 이러한 실시간 메시지를 어떻게 전송할 것인가? 이것이 바로 두 가지 주요 웹 기술인 WebSocket과 서버 전송 이벤트(SSE) 간의 근본적인 싸움으로 우리를 이끕니다. 이들의 뉘앙스, 강점 및 약점을 이해하는 것은 강력하고 확장 가능하며 효율적인 실시간 GraphQL 애플리케이션 구축을 목표로 하는 백엔드 개발자에게 매우 중요합니다. 이 기사에서는 이러한 전송 계층 간의 핵심 차이점을 자세히 살펴보고, 원칙, 구현 전략 및 실용적인 애플리케이션 시나리오를 탐색하여 정보에 입각한 선택을 할 수 있도록 안내합니다.
핵심 개념
싸움에 들어가기 전에 이 논의에 관련된 핵심 기술에 대한 명확한 이해를 확립해 보겠습니다.
GraphQL 구독
GraphQL 구독은 클라이언트가 서버의 이벤트에 가입할 수 있도록 하는 GraphQL의 강력한 기능입니다. 쿼리(한 번 데이터를 가져옴) 및 뮤테이션(데이터를 수정함)과 달리 구독은 클라이언트와 서버 간에 지속적인 연결을 유지합니다. 서버에서 특정 이벤트가 발생하면 메시지가 실시간으로 모든 구독 클라이언트에 푸시됩니다. 이는 GraphQL 스키마에 Subscription 유형을 정의하여 클라이언트가 구독할 수 있는 필드를 노출함으로써 달성됩니다.
type Subscription { commentAdded(postId: ID!): Comment postLiked(postId: ID!): Post }
클라이언트가 commentAdded를 구독하면 서버는 지정된 postId에 새 댓글이 추가될 때마다 새 Comment 개체를 푸시합니다.
WebSockets
WebSocket은 단일 TCP 연결을 통해 전이중, 지속적인 통신 채널을 제공합니다. 이는 WebSocket 연결이 설정되면 클라이언트와 서버 모두 독립적이고 동시에 메시지를 보내고 받을 수 있음을 의미합니다. 이러한 양방향 기능은 인스턴트 메시징, 온라인 게임 및 실시간 협업 도구와 같이 빈번하고 낮은 지연 시간의 양방향 통신을 요구하는 애플리케이션에 WebSocket을 이상적으로 만듭니다.
서버 전송 이벤트(SSE)
서버 전송 이벤트(SSE)는 단일 HTTP 연결을 통해 서버에서 클라이언트로 이벤트 데이터를 단방향으로 푸시하는 표준입니다. WebSocket과 달리 SSE는 단방향으로 — 데이터는 서버에서 클라이언트로만 흐릅니다. 이로 인해 SSE는 클라이언트가 서버로 빈번하게 데이터를 다시 보내지 않고 주로 업데이트를 수신해야 하는 시나리오에 특히 적합합니다. 주식 시세, 뉴스 피드 또는 서버가 정보의 주요 소스인 실시간 대시보드를 생각해 보세요. SSE는 HTTP를 기반으로 구축되어 방화벽 친화적이며 서버 푸시를 위해 구현하기가 종종 더 간단하다는 이점도 있습니다.
전송 계층 싸움
이제 GraphQL 구독의 맥락에서 WebSocket과 SSE를 비교하고 원칙, 구현 및 애플리케이션 시나리오를 살펴봅니다.
GraphQL 구독을 사용한 WebSocket 원칙 및 구현
WebSocket은 양방향 특성으로 인해 GraphQL 구독에 대한 매우 효율적이고 다재다능한 전송을 제공합니다.
원칙:
- 지속적인 연결: 단일 TCP 연결이 설정되고 열린 상태로 유지됩니다.
- 전이중: 클라이언트와 서버 모두 동시에 메시지를 보내고 받을 수 있습니다.
- 낮은 오버헤드: 핸드셰이크가 완료되면 데이터 프레임은 HTTP 요청보다 작습니다.
- 프로토콜 무관: WebSocket은 모든 유형의 데이터(텍스트, 바이너리)를 전송할 수 있습니다.
구현:
WebSocket을 통한 GraphQL 구독 구현에는 일반적으로 전용 WebSocket 서버 또는 WebSocket 업그레이드를 지원하는 호환 HTTP 서버가 포함됩니다. graphql-ws 또는 subscriptions-transport-ws(후자는 graphql-ws에 의해 사용 중단되었지만)와 같은 라이브러리는 일반적으로 서버 측에서 GraphQL over WebSocket 프로토콜을 처리하는 데 사용됩니다.
Express 및 ws와 함께 graphql-ws를 사용하는 간단한 Node.js 예제를 살펴보겠습니다.
// server.js import express from 'express'; import { createServer } from 'http'; import { WebSocketServer } from 'ws'; import { useServer } from 'graphql-ws/lib/use/ws'; import { execute, subscribe } from 'graphql'; import { makeExecutableSchema } from '@graphql-tools/schema'; import { PubSub } from 'graphql-subscriptions'; // 간단한 이벤트 게시용 const pubsub = new PubSub(); const COMMENTS_CHANNEL = 'COMMENTS_CHANNEL'; const typeDefs = ` type Comment { id: ID! content: String! } type Query { hello: String } type Mutation { addComment(content: String!): Comment } type Subscription { commentAdded: Comment } `; const resolvers = { Query: { hello: () => 'Hello GraphQL!', }, Mutation: { addComment: (parent, { content }) => { const newComment = { id: String(Date.now()), content }; pubsub.publish(COMMENTS_CHANNEL, { commentAdded: newComment }); return newComment; }, }, Subscription: { commentAdded: { subscribe: () => pubsub.asyncIterator(COMMENTS_CHANNEL), }, }, }; const schema = makeExecutableSchema({ typeDefs, resolvers }); const app = express(); const httpServer = createServer(app); // GraphQL 구독을 위한 WebSocket 서버 생성 const wsServer = new WebSocketServer({ server: httpServer, path: '/graphql', // WebSocket 엔드포인트 }); useServer( { schema, execute, subscribe, onConnect: (ctx) => { console.log('Client connected for GraphQL Subscription'); // 여기에 인증 또는 권한 부여를 구현할 수 있습니다. }, onDisconnect: (ctx, code, reason) => { console.log('Client disconnected from GraphQL Subscription'); }, }, wsServer ); httpServer.listen(4000, () => { console.log('GraphQL server running on http://localhost:4000'); console.log('GraphQL Subscriptions available at ws://localhost:4000/graphql'); });
클라이언트 측에서는 subscriptions-transport-ws(또는 graphql-ws 클라이언트)와 같은 WebSocket 클라이언트 라이브러리가 사용됩니다.
// client.js (간소화된 클라이언트 설정 기준 예제) import { createClient } from 'graphql-ws'; const client = createClient({ url: 'ws://localhost:4000/graphql', }); const COMMENT_SUBSCRIPTION = ` subscription OnCommentAdded { commentAdded { id content } } `; (async () => { const onNext = ({ data }) => { console.log('Received comment:', data.commentAdded); }; const onError = (error) => { console.error('Subscription error:', error); }; const unsubscribe = client.subscribe( { query: COMMENT_SUBSCRIPTION }, { next: onNext, error: onError, complete: () => console.log('Subscription complete') } ); // 시연을 위해 일부 시간 후 또는 사용자 동작 시 구독을 취소할 수 있습니다. // setTimeout(() => { // unsubscribe(); // }, 10000); })();
WebSocket의 애플리케이션 시나리오:
- 실시간 채팅 애플리케이션: 메시지를 보내고 받는 데 양방향 통신이 필수적입니다.
- 협업 편집기: 동일한 문서를 업데이트하는 여러 사용자는 즉각적인 양방향 동기화가 필요합니다.
- 온라인 게임: 게임 상태 업데이트 및 플레이어 동작을 위한 낮은 지연 시간, 양방향 통신.
- 금융 거래 플랫폼: 고주파 업데이트 및 사용자 주문 배치.
서버 전송 이벤트의 원칙 및 GraphQL 구독을 사용한 구현
SSE는 HTTP 기반의 간단한 접근 방식을 제공하여 서버에서 클라이언트로 데이터를 푸시합니다.
원칙:
- 단방향: 데이터는 서버에서 클라이언트로만 흐릅니다.
- HTTP 기반: 표준 HTTP를 사용하여 연결하므로 방화벽 친화적입니다.
- 자동 재연결: 브라우저는 연결이 끊어지면 자동으로 연결을 다시 설정합니다.
- 간단함: 서버에서 클라이언트로 통신하는 데 WebSocket보다 간단한 API입니다.
구현:
GraphQL 구독에 SSE를 사용하려면 일반적으로 text/event-stream 데이터를 스트리밍하는 HTTP 엔드포인트가 필요합니다. 각 이벤트는 `data: {json_payload}
` 형식으로 형식화됩니다. 구독에 대해 SSE를 지원하는 GraphQL 서버는 일반적으로 이 SSE 스트림에 대한 특수한 HTTP POST 요청을 매핑합니다.
GraphQL 구독에 대한 SSE를 사용하는 개념적(그리고 단순화된) 서버 측 예제입니다. SSE 지원을 GraphQL 구독에 제공하는 라이브러리를 가정합니다.
// server-sse.js (GraphQL 구독을 위한 개념적 SSE 구현) import express from 'express'; import { makeExecutableSchema } from '@graphql-tools/schema'; import { PubSub } from 'graphql-subscriptions'; import { execute, subscribe } from 'graphql'; const pubsub = new PubSub(); const COMMENTS_CHANNEL = 'COMMENTS_CHANNEL'; const typeDefs = ` type Comment { id: ID! content: String! } type Query { hello: String } type Mutation { addComment(content: String!): Comment } type Subscription { commentAdded: Comment } `; const resolvers = { Query: { hello: () => 'Hello GraphQL SSE!' }, Mutation: { addComment: (parent, { content }) => { const newComment = { id: String(Date.now()), content }; pubsub.publish(COMMENTS_CHANNEL, { commentAdded: newComment }); return newComment; }, }, Subscription: { commentAdded: { subscribe: () => pubsub.asyncIterator(COMMENTS_CHANNEL), }, }, }; const schema = makeExecutableSchema({ typeDefs, resolvers }); const app = express(); app.use(express.json()); // 뮤테이션 엔드포인트 (일반 HTTP POST) app.post('/graphql', async (req, res) => { const { query, variables } = req.body; const result = await execute({ schema, document: query, variableValues: variables }); res.json(result); }); // 구독을 위한 SSE 엔드포인트 app.post('/graphql-sse', async (req, res) => { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.flushHeaders(); // 클라이언트에 헤더 플러시 const { query, variables, operationName } = req.body; try { const subscriber = await subscribe({ schema, document: query, variableValues: variables, operationName, contextValue: {}, // 필요한 경우 컨텍스트 추가 }); if (subscriber.errors) { res.write(`event: error\ndata: ${JSON.stringify(subscriber.errors)}\n\n`); res.end(); return; } // 비동기 이터레이터에 구독 const iterator = subscriber[Symbol.asyncIterator](); const sendEvent = async () => { try { const { value, done } = await iterator.next(); if (done) { res.write('event: complete\ndata: Subscription completed\n\n'); res.end(); return; } res.write(`event: message\ndata: ${JSON.stringify(value)}\n\n`); // 다음 이벤트 전송 예약 process.nextTick(sendEvent); // 또는 더 제어된 루프/발행자 사용 } catch (error) { res.write(`event: error\ndata: ${JSON.stringify({ message: error.message })}\n\n`); res.end(); } }; sendEvent(); // 이벤트 전송 시작 req.on('close', () => { // 클라이언트 연결 끊김 시 구독 정리 if (subscriber.return) { subscriber.return(); // 비동기 이터레이터 종료 } console.log('Client disconnected from SSE subscription'); }); } catch (error) { res.write(`event: error\ndata: ${JSON.stringify({ message: error.message })}\n\n`); res.end(); } }); app.listen(4001, () => { console.log('GraphQL server with SSE running on http://localhost:4001 and sse at http://localhost:4001/graphql-sse'); });
클라이언트 측에서는 브라우저의 네이티브 EventSource API가 사용됩니다.
// client-sse.js const COMMENT_SUBSCRIPTION_QUERY = ` subscription OnCommentAdded { commentAdded { id content } } `; // SSE 스트림을 시작하기 위한 POST 요청 시뮬레이션 async function subscribeWithSSE() { const response = await fetch('http://localhost:4001/graphql-sse', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream' // 클라이언트가 SSE를 기대함을 나타냄 }, body: JSON.stringify({ query: COMMENT_SUBSCRIPTION_QUERY }) }); if (!response.ok) { console.error('Failed to initiate SSE subscription:', response.statusText); return; } // 이벤트 스트림을 처리하기 위해 ReadableStreamReader 사용 const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) { console.log('SSE stream finished.'); break; } buffer += decoder.decode(value, { stream: true }); // 버퍼에서 전체 이벤트 처리 let eventBoundary; while ((eventBoundary = buffer.indexOf(' ')) !== -1) { const eventData = buffer.substring(0, eventBoundary).trim(); buffer = buffer.substring(eventBoundary + 2); // 버퍼를 이벤트 뒤로 이동 if (eventData.startsWith('event: message')) { const dataPayload = eventData.substring('event: message data: '.length); try { const parsedData = JSON.parse(dataPayload); console.log('Received SSE comment:', parsedData.data.commentAdded); } catch (e) { console.error('Failed to parse SSE data:', e, dataPayload); } } else if (eventData.startsWith('event: error')) { console.error('SSE Error:', eventData); } else if (eventData.startsWith('event: complete')) { console.log('SSE Subscription complete notification.'); reader.cancel(); // 읽기 중지 break; } } } } subscribeWithSSE();
참고: EventSource API는 클라이언트 측 소비를 단순화하지만, GraphQL over SSE를 시작하기 위한 서버 측 프로토콜은 일반적으로 구독 쿼리를 지정하는 초기 HTTP POST 요청을 포함하고, 그런 다음 서버가 text/event-stream 응답을 스트리밍합니다. @graphql-yoga/graphql-sse 라이브러리 또는 이와 유사한 라이브러리는 보다 강력한 서버 측 구현을 제공할 수 있습니다.
SSE의 애플리케이션 시나리오:
- 실시간 대시보드: 데이터가 주로 서버에서 클라이언트로 흐르는 지표, 분석 또는 주가 표시.
- 뉴스 피드: 새 기사 또는 업데이트를 사용자에게 푸시.
- 라이브 스포츠 점수: 실시간으로 점수 및 게임 이벤트 업데이트.
- 알림 시스템: 사용자에게 푸시 알림 전송.
결론
GraphQL 구독에 대한 전송 계층으로 WebSocket과 SSE 중에서 선택하는 것은 궁극적으로 애플리케이션의 특정 요구 사항에 달려 있습니다. WebSocket은 채팅 및 협업 편집과 같이 낮은 지연 시간의 양방향 데이터 교환을 요구하는 대화형 시나리오에 이상적인 탁월한 양방향 통신을 제공합니다. 반대로 SSE는 실제 대시보드, 뉴스 피드 및 알림 시스템과 같이 클라이언트가 주로 업데이트를 소비하는 데 완벽하게 적합한 단방향 서버-클라이언트 데이터 푸시를 위한 간단하고 HTTP 친화적인 솔루션을 제공합니다. 실시간 데이터 흐름의 특성을 신중하게 평가하면 GraphQL 구독에 대한 성능, 복잡성 및 리소스 활용도를 최적화하는 전송 계층을 선택할 수 있습니다.\n 본질적으로 WebSocket은 대화형 실시간 경험을 지원하고, SSE는 효율적인 수동 데이터 소비에 탁월합니다.

