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 서버 테스트

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

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

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

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


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

목차