스키마 정의와 리졸버 구현
안녕하세요! 지난 절에서는 NestJS 프로젝트에 GraphQL 환경을 설정하고 기본적인 쿼리를 통해 데이터를 조회하는 방법을 알아보았습니다. 이번 절에서는 GraphQL 서버 구축의 핵심인 스키마 정의(Schema Definition) 를 더 상세하게 다루고, 데이터를 변경하는 뮤테이션(Mutation) 과 실시간 업데이트를 위한 서브스크립션(Subscription) 에 대한 리졸버(Resolver) 구현 방법을 심도 있게 살펴보겠습니다.
GraphQL의 가장 큰 장점 중 하나는 강력한 타입 시스템을 기반으로 API의 모든 기능을 명확하게 정의할 수 있다는 점입니다. 이 정의는 클라이언트와 서버 간의 데이터 계약 역할을 하며, 이를 통해 양쪽 팀은 독립적으로 작업하면서도 API의 동작 방식을 정확히 예측할 수 있습니다.
GraphQL 스키마 정의 심화
GraphQL 스키마는 API가 제공하는 데이터의 형태와 클라이언트가 수행할 수 있는 작업(쿼리, 뮤테이션, 서브스크립션)을 명시합니다. NestJS의 Code-first 방식은 TypeScript 클래스와 데코레이터를 사용하여 이 스키마를 편리하게 정의할 수 있도록 돕습니다.
객체 타입(Object Types) 정의
데이터를 나타내는 가장 기본적인 빌딩 블록입니다. 이전 절의 Post
모델처럼 @ObjectType()
과 @Field()
를 사용하여 정의합니다.
// src/posts/models/post.model.ts (업데이트: User 관계 추가)
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { User } from '../../users/models/user.model'; // User 모델 임포트 (아직 생성 안 됨)
@ObjectType()
export class Post {
@Field(() => Int, { description: '게시물의 고유 ID' })
id: number;
@Field({ description: '게시물의 제목' })
title: string;
@Field({ nullable: true, description: '게시물의 내용' })
content?: string;
@Field(() => User, { description: '게시물 작성자 정보' }) // User 타입 참조
author: User; // 사용자 객체를 직접 포함 (Relation)
}
User
모델 정의 (새로 생성):
User
모듈을 생성하고 (만약 없다면 nest g mo users
), src/users/models/user.model.ts
파일을 생성합니다.
// src/users/models/user.model.ts
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { Post } from '../../posts/models/post.model'; // Post 모델 임포트 (순환 참조 주의)
@ObjectType()
export class User {
@Field(() => Int)
id: number;
@Field()
username: string;
@Field()
email: string;
// @Field(() => [Post])
// posts: Post[]; // 만약 User가 작성한 모든 Post를 포함하려면 이렇게 정의할 수 있습니다.
// 이 경우 순환 참조 문제(User -> Post -> User)를 해결해야 합니다.
// 간단한 예제에서는 일단 생략하거나 TypeORM의 Relation처럼 Lazy Loading 방식으로 처리합니다.
}
참고: 순환 참조(Circular Dependencies)
Post
가 User
를 참조하고 User
가 Post
를 참조하는 경우 순환 참조가 발생할 수 있습니다. NestJS GraphQL은 이를 처리하는 메커니즘을 제공하지만, 간단한 예제에서는 한쪽에서만 참조하거나, 데이터베이스의 관계처럼 리졸버에서 따로 처리하는 것이 일반적입니다.
입력 타입(Input Types) 정의
뮤테이션(데이터 생성/수정) 요청의 인자로 사용되는 데이터 구조입니다. @InputType()
데코레이터를 사용하며, 필드 정의는 ObjectType
과 유사합니다.
// src/posts/dto/create-post.input.ts (새로 생성)
import { InputType, Field, Int } from '@nestjs/graphql';
import { IsNotEmpty, IsString, IsInt, Min } from 'class-validator';
@InputType() // 이 클래스가 GraphQL Input Type임을 선언
export class CreatePostInput {
@ApiProperty({ description: '새 게시물의 제목' }) // Swagger 통합을 위한 데코레이터 (선택 사항)
@IsNotEmpty({ message: '제목은 필수 항목입니다.' })
@IsString({ message: '제목은 문자열이어야 합니다.' })
@Field({ description: '게시물의 제목' })
title: string;
@ApiProperty({ description: '새 게시물의 내용', required: false })
@IsString({ message: '내용은 문자열이어야 합니다.' })
@Field({ nullable: true, description: '게시물의 내용' })
content?: string;
@ApiProperty({ description: '게시물 작성자 ID' })
@IsInt({ message: '작성자 ID는 정수여야 합니다.' })
@Min(1, { message: '작성자 ID는 1 이상이어야 합니다.' })
@Field(() => Int, { description: '게시물 작성자의 고유 ID' })
authorId: number;
}
// src/posts/dto/update-post.input.ts (새로 생성)
import { InputType, Field, Int, PartialType } from '@nestjs/graphql';
import { CreatePostInput } from './create-post.input';
import { IsInt, Min, IsOptional } from 'class-validator';
@InputType()
export class UpdatePostInput extends PartialType(CreatePostInput) {
@ApiProperty({ description: '업데이트할 게시물의 ID' })
@IsInt({ message: 'ID는 정수여야 합니다.' })
@Min(1, { message: 'ID는 1 이상이어야 합니다.' })
@Field(() => Int, { description: '업데이트할 게시물의 고유 ID' })
id: number;
@ApiProperty({ description: '게시물 작성자 ID (선택 사항)' })
@IsOptional()
@IsInt({ message: '작성자 ID는 정수여야 합니다.' })
@Min(1, { message: '작성자 ID는 1 이상이어야 합니다.' })
@Field(() => Int, { nullable: true, description: '게시물 작성자의 고유 ID (업데이트 시 선택 사항)' })
authorId?: number;
}
PartialType
(from@nestjs/graphql
):CreatePostInput
의 모든 필드를 선택적으로 만들어, 부분 업데이트에 유용합니다.@nestjs/mapped-types
의PartialType
과는 다릅니다.
리졸버 구현: 쿼리, 뮤테이션, 서브스크립션
리졸버는 클라이언트의 GraphQL 요청을 실제 데이터 소스(데이터베이스, 외부 API 등)에 연결하는 로직을 담당합니다.
쿼리(Query) 리졸버
데이터를 조회하는 작업입니다. 이전 절에서 구현한 PostsResolver
를 업데이트하여 User
데이터를 함께 처리하도록 합니다.
// 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'; // User 모델 임포트
import { CreatePostInput } from './dto/create-post.input';
import { UpdatePostInput } from './dto/update-post.input';
import { PubSub } from 'graphql-subscriptions'; // PubSub 임포트 (서브스크립션용)
const pubSub = new PubSub(); // PubSub 인스턴스 생성 (실제 앱에서는 Singleton으로 관리)
@Resolver(() => Post)
export class PostsResolver {
// 임시 데이터 스토어 (실제는 DB 연동)
private readonly posts: Post[] = [
{ id: 1, title: '첫 번째 게시물', content: 'GraphQL 학습 중입니다.', author: { id: 101, username: 'user1', email: 'user1@example.com' } },
{ id: 2, title: '두 번째 게시물', content: 'NestJS와 GraphQL 통합!', author: { id: 102, username: 'user2', email: 'user2@example.com' } },
];
private nextPostId = 3;
private readonly users: User[] = [ // 임시 사용자 데이터
{ id: 101, username: 'user1', email: 'user1@example.com' },
{ id: 102, username: 'user2', email: 'user2@example.com' },
{ id: 103, username: 'user3', email: 'user3@example.com' },
];
@Query(() => [Post], { name: 'posts', description: '모든 게시물 목록을 조회합니다.' })
getAllPosts(): Post[] {
return this.posts;
}
@Query(() => Post, { name: 'post', nullable: true, description: 'ID로 특정 게시물을 조회합니다.' })
getPost(@Args('id', { type: () => Int, description: '조회할 게시물의 ID' }) id: number): Post | undefined {
return this.posts.find(post => post.id === id);
}
// ResolveField: Post 타입의 author 필드를 해결합니다.
// Post 쿼리에서 author 필드가 요청될 때 이 메서드가 실행됩니다.
@ResolveField(() => User, { description: '게시물 작성자 정보를 반환합니다.' })
author(@Parent() post: Post): User {
// 실제로는 post.authorId를 사용하여 데이터베이스에서 사용자 정보를 조회합니다.
return this.users.find(user => user.id === post.author.id); // 현재는 임시 데이터에서 조회
}
}
@ResolveField(() => User)
:Post
타입 내의author
필드를 해결하는 리졸버입니다. 클라이언트가post
쿼리에서author
필드를 요청하면 이 메서드가 호출됩니다.@Parent()
: 현재 처리 중인 부모 객체(여기서는Post
인스턴스)를 주입받습니다. 이를 통해post.author.id
와 같은 부모 객체의 정보를 사용하여 관련 데이터를 조회할 수 있습니다.
뮤테이션(Mutation) 리졸버
데이터를 생성, 업데이트, 삭제하는 작업입니다. Mutation
데코레이터를 사용합니다.
// src/posts/posts.resolver.ts (이어서)
// ... (기존 import 및 Query 코드)
@Resolver(() => Post)
export class PostsResolver {
// ... (posts, nextPostId, users 데이터 및 Query 코드)
@Mutation(() => Post, { description: '새로운 게시물을 생성합니다.' })
async createPost(@Args('createPostInput') createPostInput: CreatePostInput): Promise<Post> {
const author = this.users.find(u => u.id === createPostInput.authorId);
if (!author) {
throw new Error(`Author with ID ${createPostInput.authorId} not found`);
}
const newPost: Post = {
id: this.nextPostId++,
...createPostInput,
author: author,
};
this.posts.push(newPost);
// 새로운 게시물이 생성되었음을 서브스크립션 리스너에게 알림
await pubSub.publish('postAdded', { postAdded: newPost }); // 'postAdded' 이벤트 발행
return newPost;
}
@Mutation(() => Post, { description: '기존 게시물을 업데이트합니다.', nullable: true })
async updatePost(@Args('updatePostInput') updatePostInput: UpdatePostInput): Promise<Post | undefined> {
const postIndex = this.posts.findIndex(p => p.id === updatePostInput.id);
if (postIndex === -1) {
return undefined; // 게시물 없음
}
let author: User | undefined = this.posts[postIndex].author;
if (updatePostInput.authorId) {
author = this.users.find(u => u.id === updatePostInput.authorId);
if (!author) {
throw new Error(`Author with ID ${updatePostInput.authorId} not found`);
}
}
this.posts[postIndex] = {
...this.posts[postIndex],
...updatePostInput,
author: author, // 업데이트된 author
};
const updatedPost = this.posts[postIndex];
await pubSub.publish('postUpdated', { postUpdated: updatedPost }); // 'postUpdated' 이벤트 발행
return updatedPost;
}
@Mutation(() => Boolean, { description: 'ID로 게시물을 삭제합니다.' })
async deletePost(@Args('id', { type: () => Int, description: '삭제할 게시물의 ID' }) id: number): Promise<boolean> {
const initialLength = this.posts.length;
const deletedPost = this.posts.find(p => p.id === id); // 삭제될 게시물 찾기
this.posts = this.posts.filter(post => post.id !== id);
if (this.posts.length < initialLength) {
if (deletedPost) {
await pubSub.publish('postDeleted', { postDeleted: id }); // 'postDeleted' 이벤트 발행
}
return true; // 삭제 성공
}
return false; // 삭제 실패 (게시물 없음)
}
// ... (ResolveField 코드)
}
@Mutation(() => Post)
:createPost
와updatePost
뮤테이션을 정의합니다. 반환 타입은Post
입니다.@Mutation(() => Boolean)
:deletePost
뮤테이션을 정의합니다. 반환 타입은Boolean
입니다.@Args('inputName') input: InputType
: 클라이언트로부터 전송된 입력 객체를 DTO 인스턴스로 주입받습니다.PubSub
: GraphQL 서브스크립션 구현을 위한 간단한 이벤트 발행/구독 메커니즘입니다. 실제 프로덕션 환경에서는 Redis Pub/Sub, Kafka 등을 사용하여 여러 인스턴스 간에 이벤트를 공유할 수 있도록 합니다.
서브스크립션(Subscription) 리졸버
클라이언트가 서버에서 발생하는 특정 이벤트에 대해 실시간으로 데이터를 수신하는 작업입니다. Subscription
데코레이터를 사용합니다.
NestJS에서 서브스크립션을 사용하려면 GraphQLModule
설정에 subscriptions
속성을 추가해야 합니다.
// src/app.module.ts (업데이트: subscription 설정 추가)
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'; // UsersModule 임포트 (아직 생성 안 했으면 생성)
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
sortSchema: true,
playground: true,
// 구독(Subscription) 설정 추가
subscriptions: {
'graphql-ws': true, // WebSockets를 통한 구독 활성화
'subscriptions-transport-ws': true, // 레거시 클라이언트를 위한 지원 (선택 사항)
},
}),
PostsModule,
UsersModule, // UsersModule 임포트 (user.model.ts를 사용하기 위함)
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
subscriptions
: 이 객체는 GraphQL 구독을 활성화하고 구성합니다.graphql-ws
는 최신 WebSocket 프로토콜이며,subscriptions-transport-ws
는 이전 버전 클라이언트 지원을 위해 포함될 수 있습니다.
// src/posts/posts.resolver.ts (이어서)
// ... (기존 import, Query, Mutation 코드)
@Resolver(() => Post)
export class PostsResolver {
// ... (posts, nextPostId, users 데이터 및 Query, Mutation 코드)
@Subscription(() => Post, { description: '새로운 게시물이 생성될 때 알림을 받습니다.' })
postAdded() {
// PubSub의 'postAdded' 이벤트 리스너를 반환
return pubSub.asyncIterator('postAdded');
}
@Subscription(() => Post, { description: '게시물이 업데이트될 때 알림을 받습니다.' })
postUpdated() {
return pubSub.asyncIterator('postUpdated');
}
@Subscription(() => Int, { description: '게시물이 삭제될 때 해당 ID를 알림받습니다.' })
postDeleted() {
return pubSub.asyncIterator('postDeleted');
}
}
@Subscription(() => Post)
:postAdded
서브스크립션을 정의합니다. 클라이언트가 이 서브스크립션을 구독하면,pubSub.publish('postAdded', ...)
가 호출될 때마다 새로운 게시물 데이터가 클라이언트로 전송됩니다.pubSub.asyncIterator('eventName')
:PubSub
에서 특정 이벤트 이름에 대한 비동기 이터레이터를 반환합니다. 이는 GraphQL 서브스크립션의 핵심 메커니즘입니다.
스키마와 리졸버의 연동
GraphQLModule
의 autoSchemaFile
설정 덕분에, 우리가 @ObjectType()
, @InputType()
, @Query()
, @Mutation()
, @Subscription()
등으로 정의한 모든 TypeScript 클래스와 메서드는 자동으로 GraphQL SDL 스키마 파일(src/schema.gql
)로 변환됩니다.
이제 src/schema.gql
파일을 확인하면 Post
타입의 author
필드와 createPostInput
, updatePostInput
같은 입력 타입, 그리고 createPost
, updatePost
, deletePost
뮤테이션과 postAdded
, postUpdated
, postDeleted
서브스크립션이 모두 포함된 것을 볼 수 있습니다.
GraphQL 서버 테스트
- 애플리케이션을 실행합니다:
npm run start:dev
http://localhost:3000/graphql
로 접속하여 GraphQL Playground를 엽니다.
뮤테이션 테스트: 게시물 생성
- 새 게시물 생성 (Mutation):
이 쿼리를 실행하면,mutation CreateNewPost { createPost(createPostInput: { title: "새로운 GraphQL 게시물", content: "뮤테이션으로 생성되었습니다.", authorId: 101 # 존재하는 사용자 ID }) { id title content author { id username email } } }
200 OK
응답과 함께 생성된 게시물 정보가 반환될 것입니다.
서브스크립션 테스트: 게시물 추가 알림 받기
GraphQL Playground는 한 번에 하나의 서브스크립션 탭만 지원하므로, 새 탭을 열거나 다른 GraphQL 클라이언트 (예: Apollo Client DevTools for Chrome, Insomnia)를 사용해야 할 수 있습니다.
-
새 탭에서 서브스크립션 구독
subscription OnPostAdded { postAdded { id title author { username } } }
이 서브스크립션을 실행(Play 버튼)하면, 연결이 유지되며 서버에서
postAdded
이벤트가 발생하기를 기다립니다. -
다른 탭에서 게시물 생성 (뮤테이션 재실행): 위의 "새 게시물 생성" 뮤테이션을 다시 실행해 봅니다.
-
서브스크립션 탭 확인: 서브스크립션 탭으로 돌아오면, 방금 생성된 새로운 게시물 정보가 실시간으로 수신된 것을 확인할 수 있습니다.
이와 유사하게 postUpdated
와 postDeleted
서브스크립션도 테스트할 수 있습니다.
이것으로 NestJS에서 GraphQL 스키마를 정의하고 쿼리, 뮤테이션, 서브스크립션을 포함한 리졸버를 구현하는 방법을 자세히 살펴보았습니다. GraphQL의 강력한 타입 시스템과 유연한 쿼리 기능은 클라이언트와 서버 간의 데이터 통신을 혁신적으로 개선할 수 있습니다.