icon
6장 : GraphQL 서버 구축

스키마 정의와 리졸버 구현


안녕하세요! 지난 절에서는 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) PostUser를 참조하고 UserPost를 참조하는 경우 순환 참조가 발생할 수 있습니다. 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-typesPartialType과는 다릅니다.

리졸버 구현: 쿼리, 뮤테이션, 서브스크립션

리졸버는 클라이언트의 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): createPostupdatePost 뮤테이션을 정의합니다. 반환 타입은 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 서브스크립션의 핵심 메커니즘입니다.

스키마와 리졸버의 연동

GraphQLModuleautoSchemaFile 설정 덕분에, 우리가 @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 서버 테스트

  1. 애플리케이션을 실행합니다: npm run start:dev
  2. 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 이벤트가 발생하기를 기다립니다.

  • 다른 탭에서 게시물 생성 (뮤테이션 재실행): 위의 "새 게시물 생성" 뮤테이션을 다시 실행해 봅니다.

  • 서브스크립션 탭 확인: 서브스크립션 탭으로 돌아오면, 방금 생성된 새로운 게시물 정보가 실시간으로 수신된 것을 확인할 수 있습니다.

이와 유사하게 postUpdatedpostDeleted 서브스크립션도 테스트할 수 있습니다.


이것으로 NestJS에서 GraphQL 스키마를 정의하고 쿼리, 뮤테이션, 서브스크립션을 포함한 리졸버를 구현하는 방법을 자세히 살펴보았습니다. GraphQL의 강력한 타입 시스템과 유연한 쿼리 기능은 클라이언트와 서버 간의 데이터 통신을 혁신적으로 개선할 수 있습니다.