GraphQL Subscriptions의 프로토콜과 구현을 다룹니다. WebSocket 전송, PubSub 패턴, Redis를 활용한 확장, 필터링 전략, 그리고 Federation 환경에서의 한계와 SSE 등 대안 패턴을 살펴봅니다.
GraphQL의 세 번째 루트 타입인 Subscription은 서버에서 클라이언트로 실시간 데이터를 푸시하는 메커니즘입니다. Query와 Mutation이 요청-응답 모델인 반면, Subscription은 이벤트 기반 스트리밍 모델입니다.
type Subscription {
messageCreated(channelId: ID!): Message!
orderStatusChanged(orderId: ID!): Order!
notificationReceived: Notification!
}
type Message {
id: ID!
content: String!
author: User!
channel: Channel!
createdAt: DateTime!
}클라이언트는 구독을 시작하면 서버와의 지속적인 연결을 유지하고, 관련 이벤트가 발생할 때마다 데이터를 받습니다.
subscription OnNewMessage {
messageCreated(channelId: "general") {
id
content
author {
name
avatar
}
createdAt
}
}GraphQL Subscription의 표준 전송 프로토콜은 graphql-ws입니다. WebSocket 위에서 동작하며, 구독의 생명주기를 관리합니다.
레거시 프로토콜인 subscriptions-transport-ws는 더 이상 유지보수되지 않습니다. 새로운 프로젝트에서는 반드시 graphql-ws 라이브러리를 사용해야 합니다.
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import { ApolloServer } from '@apollo/server';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
const httpServer = createServer(app);
const wsServer = new WebSocketServer({
server: httpServer,
path: '/graphql',
});
// WebSocket 서버에 graphql-ws 프로토콜 적용
const serverCleanup = useServer(
{
schema,
context: async (ctx) => {
// ConnectionInit의 payload에서 인증 처리
const token = ctx.connectionParams?.token as string | undefined;
const currentUser = token ? await verifyToken(token) : null;
return { currentUser, loaders: createLoaders(prisma), prisma };
},
onConnect: async (ctx) => {
const token = ctx.connectionParams?.token as string | undefined;
if (!token) {
return false; // 연결 거부
}
return true;
},
onDisconnect: () => {
console.log('Client disconnected');
},
},
wsServer,
);
const server = new ApolloServer({
schema,
plugins: [
ApolloServerPluginDrainHttpServer({ httpServer }),
{
async serverWillStart() {
return {
async drainServer() {
await serverCleanup.dispose();
},
};
},
},
],
});Subscription 리졸버는 PubSub(Publish-Subscribe) 패턴을 사용하여 이벤트를 구독하고 발행합니다.
개발 환경에서 사용하는 간단한 구현입니다.
import { PubSub } from 'graphql-subscriptions';
const pubsub = new PubSub();
// 이벤트 채널 상수
const EVENTS = {
MESSAGE_CREATED: 'MESSAGE_CREATED',
ORDER_STATUS_CHANGED: 'ORDER_STATUS_CHANGED',
NOTIFICATION_RECEIVED: 'NOTIFICATION_RECEIVED',
} as const;
const resolvers = {
Subscription: {
messageCreated: {
subscribe: (_parent: unknown, args: { channelId: string }) => {
return pubsub.asyncIterableIterator(
`${EVENTS.MESSAGE_CREATED}.${args.channelId}`
);
},
},
},
Mutation: {
sendMessage: async (
_parent: unknown,
args: { input: SendMessageInput },
context: Context,
) => {
const message = await context.dataSources.messages.create({
...args.input,
authorId: context.currentUser!.id,
});
// 이벤트 발행
await pubsub.publish(
`${EVENTS.MESSAGE_CREATED}.${message.channelId}`,
{ messageCreated: message },
);
return message;
},
},
};인메모리 PubSub은 단일 프로세스에서만 동작합니다. 서버 인스턴스가 여러 개이면 한 인스턴스에서 발행한 이벤트가 다른 인스턴스의 구독자에게 전달되지 않습니다. 프로덕션에서는 반드시 외부 메시지 브로커를 사용해야 합니다.
프로덕션 환경에서는 Redis PubSub을 사용하여 다중 인스턴스 간 이벤트를 전파합니다.
import { RedisPubSub } from 'graphql-redis-subscriptions';
import Redis from 'ioredis';
const options = {
host: process.env.REDIS_HOST ?? 'localhost',
port: Number(process.env.REDIS_PORT ?? 6379),
retryStrategy: (times: number) => Math.min(times * 50, 2000),
};
const pubsub = new RedisPubSub({
publisher: new Redis(options),
subscriber: new Redis(options),
});Redis를 매개로 하면 어떤 인스턴스에서 이벤트가 발생하든 모든 인스턴스의 구독자에게 전달됩니다.
모든 이벤트를 모든 구독자에게 보내는 것은 비효율적입니다. withFilter를 사용하여 구독자별로 이벤트를 필터링합니다.
import { withFilter } from 'graphql-subscriptions';
const resolvers = {
Subscription: {
messageCreated: {
subscribe: withFilter(
(_parent: unknown, args: { channelId: string }) => {
return pubsub.asyncIterableIterator(EVENTS.MESSAGE_CREATED);
},
(
payload: { messageCreated: Message },
variables: { channelId: string },
) => {
// 해당 채널의 메시지만 필터링
return payload.messageCreated.channelId === variables.channelId;
},
),
},
orderStatusChanged: {
subscribe: withFilter(
() => pubsub.asyncIterableIterator(EVENTS.ORDER_STATUS_CHANGED),
(
payload: { orderStatusChanged: Order },
variables: { orderId: string },
context: Context,
) => {
const order = payload.orderStatusChanged;
// 해당 주문이면서 권한이 있는 사용자만
return (
order.id === variables.orderId &&
(order.userId === context.currentUser?.id ||
context.currentUser?.role === 'ADMIN')
);
},
),
},
},
};Apollo Client에서 구독을 사용하는 방법입니다.
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient } from 'graphql-ws';
import { split, HttpLink, ApolloClient, InMemoryCache } from '@apollo/client';
import { getMainDefinition } from '@apollo/client/utilities';
const httpLink = new HttpLink({
uri: 'https://api.example.com/graphql',
});
const wsLink = new GraphQLWsLink(
createClient({
url: 'wss://api.example.com/graphql',
connectionParams: {
token: getAuthToken(),
},
}),
);
// HTTP와 WebSocket을 쿼리 타입에 따라 분기
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
);
},
wsLink,
httpLink,
);
const client = new ApolloClient({
link: splitLink,
cache: new InMemoryCache(),
});import { useSubscription, gql } from '@apollo/client';
const MESSAGE_SUBSCRIPTION = gql`
subscription OnNewMessage($channelId: ID!) {
messageCreated(channelId: $channelId) {
id
content
author {
name
avatar
}
createdAt
}
}
`;
function ChatMessages({ channelId }: { channelId: string }) {
const { data, loading, error } = useSubscription(MESSAGE_SUBSCRIPTION, {
variables: { channelId },
});
if (loading) return <p>연결 중...</p>;
if (error) return <p>구독 오류: {error.message}</p>;
return (
<div>
<p>새 메시지: {data?.messageCreated.content}</p>
</div>
);
}Apollo Federation 환경에서 Subscription은 공식적으로 지원되지 않습니다. Apollo Router는 HTTP 기반이며 WebSocket 연결을 서브그래프로 라우팅하는 메커니즘이 없습니다.
1. 전용 구독 서비스: Federation과 별도로 구독 전용 GraphQL 서버를 운영합니다.
2. Server-Sent Events(SSE): WebSocket 대신 HTTP 기반 SSE를 사용하면 기존 HTTP 인프라를 활용할 수 있습니다.
import { createYoga } from 'graphql-yoga';
// GraphQL Yoga는 SSE를 통한 구독을 기본 지원
const yoga = createYoga({
schema,
// SSE를 통해 구독 전달 (별도 WebSocket 서버 불필요)
});3. 폴링(Polling): 실시간성 요구가 낮은 경우, 주기적인 Query 요청으로 대체합니다.
import { useQuery, gql } from '@apollo/client';
const NOTIFICATIONS_QUERY = gql`
query Notifications {
notifications(unreadOnly: true) {
id
message
createdAt
}
}
`;
function NotificationBadge() {
const { data } = useQuery(NOTIFICATIONS_QUERY, {
pollInterval: 30000, // 30초마다 재조회
});
const count = data?.notifications.length ?? 0;
return count > 0 ? <span>{count}</span> : null;
}실시간성의 수준을 정확히 정의하는 것이 중요합니다. "1초 이내 전달"이 필요하면 Subscription이나 SSE가 적합하고, "30초 이내"면 폴링으로 충분합니다. 불필요하게 복잡한 실시간 인프라를 구축하지 않는 것이 좋습니다.
WebSocket 연결은 서버 리소스를 지속적으로 소비합니다. 적절한 관리가 필요합니다.
const wsServerConfig = {
// 연결 초기화 타임아웃 (5초)
connectionInitWaitTimeout: 5000,
// 하트비트 간격
keepAlive: 12000,
onConnect: async (ctx: Context) => {
// 동시 연결 수 제한
const connectionCount = await getActiveConnectionCount(ctx.extra.socket);
if (connectionCount > 100) {
return false; // 연결 거부
}
return true;
},
};구독이 정상적으로 해제되지 않으면 메모리 누수가 발생할 수 있습니다. 클라이언트 측에서 컴포넌트 언마운트 시 구독을 해제하고, 서버 측에서는 비활성 연결을 정리해야 합니다.
이번 장에서는 GraphQL Subscription의 프로토콜, 구현, 확장 방법을 학습했습니다. graphql-ws 프로토콜 위에서 PubSub 패턴으로 이벤트를 발행/구독하고, Redis PubSub으로 다중 인스턴스 환경을 지원하는 방법을 다루었습니다. Federation 환경에서의 구독 한계와 SSE, 폴링 등의 대안도 살펴보았습니다.
실시간 기능은 강력하지만 운영 복잡도를 크게 높입니다. 진정으로 실시간이 필요한 기능에만 Subscription을 적용하고, 나머지는 더 단순한 방법을 선택하는 것이 현명합니다.
7장에서는 GraphQL API의 인증과 인가를 다룹니다. JWT 기반 인증 미들웨어, 컨텍스트를 통한 사용자 주입, 리졸버 수준과 디렉티브 기반 인가, RBAC/ABAC 모델, 그리고 Authorization Facade 패턴을 살펴보겠습니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
GraphQL API에서의 인증과 인가 전략을 다룹니다. JWT 인증 미들웨어, 컨텍스트 기반 사용자 주입, 리졸버 수준 인가, 디렉티브 기반 선언적 인가, RBAC/ABAC 모델을 학습합니다.
Apollo Federation 2.0을 활용한 마이크로서비스 GraphQL 아키텍처를 다룹니다. 서브그래프와 슈퍼그래프 개념, 엔티티 참조 리졸버, 주요 디렉티브, 스키마 컴포지션 과정을 학습합니다.
GraphQL API의 다양한 캐싱 전략을 다룹니다. HTTP 캐싱의 한계, Persisted Queries를 통한 CDN 캐싱, @cacheControl 디렉티브, Redis 기반 리졸버 캐싱, 캐시 무효화 패턴을 학습합니다.