서브스크립션과 실시간 기능 구현
안녕하세요! 지난 절에서는 GraphQL 서버의 성능 최적화를 위한 DataLoader의 중요성과 구현 방법에 대해 알아보았습니다. 이제 6장의 마지막 절로, GraphQL의 가장 매력적인 기능 중 하나인 서브스크립션(Subscription) 을 활용하여 실시간 기능을 구축하는 방법을 자세히 살펴보겠습니다.
현대의 웹 애플리케이션은 사용자에게 실시간 업데이트를 제공하는 경우가 많습니다. 채팅 메시지, 알림, 주식 시세, 스포츠 경기 점수 등 실시간으로 변하는 데이터는 사용자 경험을 크게 향상시킵니다. REST API의 경우, 이러한 실시간 기능을 구현하려면 웹훅(Webhook), 롱 폴링(Long Polling) 또는 웹소켓(WebSocket)과 같은 별도의 기술을 사용해야 했습니다. 하지만 GraphQL 서브스크립션은 웹소켓을 기반으로 하여, 단일 GraphQL API 내에서 쿼리, 뮤테이션과 함께 실시간 기능을 일관된 방식으로 제공합니다.
GraphQL 서브스크립션이란?
GraphQL 서브스크립션은 클라이언트가 서버에 특정 이벤트 발생 시 데이터를 푸시(Push) 받도록 요청하는 메커니즘입니다. 클라이언트는 한 번 서브스크립션을 구독하면, 해당 이벤트가 서버에서 발생할 때마다 새로운 데이터가 자동으로 클라이언트로 전송됩니다. 이는 클라이언트가 주기적으로 서버를 쿼리하여 변경 사항을 확인하는 폴링(Polling) 방식보다 훨씬 효율적입니다.
서브스크립션의 동작 방식
- 클라이언트 구독 요청: 클라이언트가 GraphQL 서브스크립션 쿼리를 서버로 전송합니다.
- 웹소켓 연결: 서버는 이 요청을 받으면 클라이언트와 영구적인 웹소켓 연결을 설정합니다.
- 이벤트 리스너 등록: 서버는 해당 서브스크립션 쿼리에 해당하는 내부 이벤트 리스너를 등록합니다.
- 이벤트 발생: 서버의 비즈니스 로직에서 특정 이벤트(예: 새 게시물 생성, 댓글 추가)가 발생하면, 서버는 이벤트를 감지하고 관련 데이터를 준비합니다.
- 데이터 푸시: 서버는 준비된 데이터를 웹소켓 연결을 통해 구독하고 있는 모든 클라이언트에게 실시간으로 푸시합니다.
NestJS에서 서브스크립션 설정 및 구현
지난 절에서 기본적인 서브스크립션 설정을 해보았지만, 여기서는 좀 더 자세히 설명하고 실제 활용 시나리오를 고려합니다.
GraphQLModule
에 서브스크립션 활성화 (재확인)
main.ts
에 추가했던 GraphQLModule
설정에서 subscriptions
옵션이 활성화되어 있어야 합니다.
// src/app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { join } from 'path';
import { PostsModule } from './posts/posts.module';
import { UsersModule } from './users/users.module';
import { UsersLoader } from './users/loaders/users.loader';
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
sortSchema: true,
playground: true,
// **서브스크립션 설정**
subscriptions: {
'graphql-ws': true, // 최신 WebSocket 프로토콜 (권장)
'subscriptions-transport-ws': true, // 레거시 클라이언트 호환성 (선택 사항)
},
// ... (buildServiceContext 등 나머지 설정)
buildServiceContext: async (context) => {
const { req, connection } = context;
if (req) {
const usersLoader = await req.injector.resolve(UsersLoader);
return { usersLoader };
} else if (connection) {
// 서브스크립션 연결 시 별도 로직이 필요할 수 있습니다.
// 예를 들어, 연결 시 인증 토큰을 검증하고 사용자 정보를 컨텍스트에 저장.
// 여기서 UsersLoader를 주입하려면, UsersService가 singleton이어야 합니다.
// const usersService = app.get(UsersService); // 앱 인스턴스에 접근 가능할 때
// return { usersLoader: new UsersLoader(usersService) };
return connection.context; // connection.context는 WebSocket 연결 시 초기 컨텍스트
}
return {};
},
}),
PostsModule,
UsersModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
graphql-ws
는 GraphQL over WebSockets 프로토콜의 최신 구현체이며, subscriptions-transport-ws
는 이전 버전을 지원합니다. 클라이언트 라이브러리에 따라 선택적으로 사용합니다.
PubSub
인스턴스 관리
서버 내부에서 이벤트가 발생했음을 알리기 위해 PubSub
(Publish/Subscribe) 메커니즘이 필요합니다. graphql-subscriptions
라이브러리에서 제공하는 PubSub
은 메모리 기반의 간단한 구현체로, 개발 및 소규모 애플리케이션에 적합합니다.
중요: 프로덕션 환경에서는 단일 서버 인스턴스에서만 작동하는 메모리 기반 PubSub
대신, 여러 서버 인스턴스 간에 이벤트를 공유할 수 있는 Redis Pub/Sub과 같은 외부 메시지 브로커를 사용해야 합니다.
Redis Pub/Sub 설정 예시 (실제 구현은 더 복잡할 수 있음)
-
패키지 설치:
npm install graphql-redis-subscriptions ioredis
-
main.ts
또는 별도 모듈에서RedisPubSub
인스턴스 생성// src/common/pubsub.provider.ts (새 파일) import { Provider } from '@nestjs/common'; import { RedisPubSub } from 'graphql-redis-subscriptions'; import * as Redis from 'ioredis'; export const REDIS_PUB_SUB = 'REDIS_PUB_SUB'; // 주입 토큰 export const pubSubProvider: Provider = { provide: REDIS_PUB_SUB, useFactory: () => { const options = { host: process.env.REDIS_HOST || 'localhost', port: parseInt(process.env.REDIS_PORT || '6379', 10), retryStrategy: times => { // 재접속 로직 return Math.min(times * 50, 2000); }, }; return new RedisPubSub({ publisher: new Redis.Redis(options), subscriber: new Redis.Redis(options), }); }, };
-
AppModule
에pubSubProvider
등록 및REDIS_PUB_SUB
주입// src/app.module.ts (imports 및 providers 업데이트) import { REDIS_PUB_SUB, pubSubProvider } from './common/pubsub.provider'; // 임포트 @Module({ imports: [ GraphQLModule.forRoot<ApolloDriverConfig>({ // ... 기존 설정 subscriptions: { 'graphql-ws': true, onConnect: (context: any) => { // WebSocket 연결 시 인증 로직 // const token = context.connectionParams.authToken; // if (!token) { // throw new Error('Auth token not provided'); // } // const user = verifyToken(token); // return { user, usersService: app.get(UsersService) }; // 컨텍스트에 사용자 정보 및 서비스 주입 return true; // 연결 허용 }, // onDisconnect: () => console.log('Disconnected'), }, }), // ... ], controllers: [AppController], providers: [ AppService, pubSubProvider, // RedisPubSub 프로바이더 등록 ], }) export class AppModule {}
-
리졸버에서
PubSub
주입 및 사용// src/posts/posts.resolver.ts (업데이트) import { Resolver, Query, Mutation, Args, Int, ResolveField, Parent, Subscription } from '@nestjs/graphql'; import { Post } from './models/post.model'; import { User } from '../users/models/user.model'; import { CreatePostInput } from './dto/create-post.input'; import { UpdatePostInput } from './dto/update-post.input'; import { PubSub } from 'graphql-subscriptions'; // 또는 RedisPubSub import { UsersLoader } from '../users/loaders/users.loader'; import { GqlContext } from 'src/common/decorators/gql-context.decorator'; import { Inject } from '@nestjs/common'; import { REDIS_PUB_SUB } from 'src/common/pubsub.provider'; // RedisPubSub 주입 토큰 임포트 // const pubSub = new PubSub(); // 이제 이 라인은 제거하거나 RedisPubSub을 사용 @Resolver(() => Post) export class PostsResolver { private readonly posts: Post[] = [ /* ... */ ]; private nextPostId = 4; constructor( private readonly usersLoader: UsersLoader, @Inject(REDIS_PUB_SUB) private pubSub: PubSub, // PubSub 인스턴스 주입 ) {} // ... (Query, Mutation 메서드) @Mutation(() => Post, { description: '새로운 게시물을 생성합니다.' }) async createPost(@Args('createPostInput') createPostInput: CreatePostInput): Promise<Post> { // ... await this.pubSub.publish('postAdded', { postAdded: newPost }); // 주입받은 pubSub 사용 return newPost; } // ... (다른 Mutation 메서드도 this.pubSub 사용하도록 수정) @Subscription(() => Post, { description: '새로운 게시물이 생성될 때 알림을 받습니다.' }) postAdded() { return this.pubSub.asyncIterator('postAdded'); } // ... (다른 Subscription 메서드) }
이러한 방식으로 PubSub
을 NestJS DI 시스템에 통합하면, 서비스 규모 확장에 용이하며 테스트하기도 더 쉬워집니다.
서브스크립션 리졸버 구현 (재확인)
리졸버에서 PubSub
을 사용하여 이벤트를 구독하고 발행하는 방식은 동일합니다.
// src/posts/posts.resolver.ts (부분 발췌)
// ...
@Subscription(() => Post, { description: '새로운 게시물이 생성될 때 알림을 받습니다.' })
postAdded() {
return this.pubSub.asyncIterator('postAdded'); // 'postAdded' 이벤트 리스너 반환
}
@Subscription(() => Post, { description: '게시물이 업데이트될 때 알림을 받습니다.' })
postUpdated() {
return this.pubSub.asyncIterator('postUpdated');
}
@Subscription(() => Int, { description: '게시물이 삭제될 때 해당 ID를 알림받습니다.' })
postDeleted() {
return this.pubSub.asyncIterator('postDeleted');
}
서브스크립션 테스트하기
- 애플리케이션을 실행합니다:
npm run start:dev
http://localhost:3000/graphql
로 접속하여 GraphQL Playground를 엽니다.
서브스크립션 구독
새로운 탭을 열고 다음 서브스크립션 쿼리를 입력한 후 실행 버튼을 클릭하여 구독을 시작합니다. (Playground는 서브스크립션 탭을 여러 개 지원하지 않으므로, 새 브라우저 탭을 열거나 다른 GraphQL 클라이언트를 사용하는 것이 좋습니다.)
subscription OnPostAdded {
postAdded {
id
title
content
author {
id
username
}
}
}
이제 이 탭은 서버로부터 postAdded
이벤트가 발생하기를 기다리는 상태가 됩니다.
뮤테이션을 통해 이벤트 발생시키기
이전 탭(또는 다른 클라이언트)으로 돌아가서 새로운 게시물을 생성하는 뮤테이션을 실행합니다.
mutation CreatePostForSubscription {
createPost(createPostInput: {
title: "서브스크립션 테스트 게시물",
content: "이것은 실시간으로 전달됩니다!",
authorId: 103 # 존재하는 사용자 ID
}) {
id
title
}
}
이 뮤테이션이 성공적으로 실행되면, PostsResolver
의 createPost
메서드에서 this.pubSub.publish('postAdded', ...)
가 호출될 것입니다.
서브스크립션 탭에서 실시간 업데이트 확인
이제 서브스크립션을 구독하고 있던 탭으로 돌아가면, 방금 생성된 게시물 정보가 실시간으로 수신되어 표시되는 것을 확인할 수 있습니다.
{
"data": {
"postAdded": {
"id": 4,
"title": "서브스크립션 테스트 게시물",
"content": "이것은 실시간으로 전달됩니다!",
"author": {
"id": 103,
"username": "user3"
}
}
}
}
마찬가지로 updatePost
뮤테이션을 실행하면 postUpdated
서브스크립션을 통해 업데이트된 데이터가 전달되고, deletePost
뮤테이션을 실행하면 postDeleted
서브스크립션을 통해 삭제된 게시물의 ID가 전달됩니다.
서브스크립션 사용 시 고려사항
- 확장성: 단일 서버 인스턴스에서는
PubSub
이 작동하지만, 여러 서버 인스턴스를 사용하는 분산 환경에서는RedisPubSub
또는 Apache Kafka, RabbitMQ와 같은 메시지 브로커를 사용하여 이벤트 버스를 구축해야 합니다. - 인증 및 권한 부여: 서브스크립션 연결 시에도 사용자 인증 및 권한 부여가 필요합니다.
GraphQLModule
의onConnect
옵션을 사용하여 웹소켓 연결 시 인증 로직을 구현할 수 있습니다. - 에러 처리: 서브스크립션 중 발생하는 에러에 대한 적절한 처리 전략을 마련해야 합니다.
- 클라이언트 관리: 클라이언트가 비정상적으로 연결을 끊었을 때 서버 자원을 정리하는 로직이 필요할 수 있습니다.
GraphQL 서브스크립션은 클라이언트-서버 간의 실시간 데이터 통신을 위한 강력하고 효율적인 방법을 제공합니다. NestJS는 Apollo Server와의 긴밀한 통합을 통해 이러한 기능을 매우 쉽게 구현할 수 있도록 지원하며, 이를 통해 사용자에게 더욱 동적이고 반응적인 애플리케이션 경험을 제공할 수 있습니다.
이것으로 6장 "GraphQL 서버 구축"을 모두 마칩니다. 이제 여러분은 GraphQL의 기본 개념부터 스키마 정의, 쿼리, 뮤테이션, 서브스크립션 구현, 그리고 성능 최적화를 위한 DataLoader 활용까지, NestJS를 사용하여 강력한 GraphQL 서버를 구축하는 데 필요한 핵심 지식을 갖추게 되었습니다.