NestJS GraphQL Code-First

NestJS GraphQL이란?

NestJS는 @nestjs/graphql 패키지를 통해 GraphQL API를 일급 시민으로 지원합니다. Code-First(TypeScript 데코레이터로 스키마 자동 생성)와 Schema-First(.graphql SDL 파일 기반) 두 가지 접근 방식을 제공하며, Apollo Server와 Mercurius 드라이버를 선택할 수 있습니다.

REST API 대비 GraphQL의 강점은 오버페칭/언더페칭 해결, 단일 엔드포인트, 강력한 타입 시스템입니다. 이 글에서는 Code-First 접근 방식으로 Resolver, DataLoader, Subscription, 인증/인가까지 심화 분석합니다.

설치와 기본 설정

npm install @nestjs/graphql @nestjs/apollo @apollo/server graphql

// app.module.ts
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { GraphQLModule } from '@nestjs/graphql';

@Module({
  imports: [
    GraphQLModule.forRoot<ApolloDriverConfig>({
      driver: ApolloDriver,
      autoSchemaFile: join(process.cwd(), 'src/schema.gql'), // Code-First
      sortSchema: true,
      playground: process.env.NODE_ENV !== 'production',
      introspection: process.env.NODE_ENV !== 'production',
      context: ({ req, res }) => ({ req, res }),
      formatError: (error) => ({
        message: error.message,
        code: error.extensions?.code,
        path: error.path,
      }),
    }),
    UserModule,
    PostModule,
  ],
})
export class AppModule {}

ObjectType: 스키마 정의

Code-First에서는 TypeScript 클래스와 데코레이터로 GraphQL 스키마를 정의합니다:

// user.model.ts
@ObjectType({ description: '사용자' })
export class UserType {
  @Field(() => ID)
  id: string;

  @Field()
  email: string;

  @Field()
  name: string;

  @Field(() => UserRole)
  role: UserRole;

  @Field({ nullable: true })
  bio?: string;

  @Field(() => Int)
  loginCount: number;

  @Field(() => [PostType], { description: '작성한 게시글' })
  posts?: PostType[];

  @Field()
  createdAt: Date;
}

// Enum 정의
registerEnumType(UserRole, {
  name: 'UserRole',
  description: '사용자 역할',
});

// Input 타입
@InputType()
export class CreateUserInput {
  @Field()
  @IsEmail()
  email: string;

  @Field()
  @MinLength(2)
  @MaxLength(50)
  name: string;

  @Field(() => UserRole, { defaultValue: UserRole.USER })
  role: UserRole;

  @Field({ nullable: true })
  bio?: string;
}

@InputType()
export class UpdateUserInput extends PartialType(CreateUserInput) {
  @Field(() => ID)
  id: string;
}

Resolver: Query와 Mutation

@Resolver(() => UserType)
export class UserResolver {
  constructor(
    private readonly userService: UserService,
    private readonly postService: PostService,
  ) {}

  // Query: 단일 조회
  @Query(() => UserType, { name: 'user', nullable: true })
  async getUser(@Args('id', { type: () => ID }) id: string) {
    return this.userService.findById(id);
  }

  // Query: 목록 조회 + 페이지네이션
  @Query(() => UserConnection)
  async users(
    @Args() pagination: PaginationArgs,
    @Args('filter', { nullable: true }) filter?: UserFilterInput,
  ) {
    return this.userService.findAll(pagination, filter);
  }

  // Mutation: 생성
  @Mutation(() => UserType)
  async createUser(@Args('input') input: CreateUserInput) {
    return this.userService.create(input);
  }

  // Mutation: 수정
  @Mutation(() => UserType)
  async updateUser(@Args('input') input: UpdateUserInput) {
    return this.userService.update(input.id, input);
  }

  // Mutation: 삭제
  @Mutation(() => Boolean)
  async deleteUser(@Args('id', { type: () => ID }) id: string) {
    return this.userService.delete(id);
  }

  // Field Resolver: User.posts 필드 요청 시에만 실행
  @ResolveField(() => [PostType])
  async posts(@Parent() user: UserType) {
    return this.postService.findByAuthorId(user.id);
  }

  // Field Resolver: 계산 필드
  @ResolveField(() => Int)
  async postCount(@Parent() user: UserType) {
    return this.postService.countByAuthorId(user.id);
  }
}

Cursor 기반 페이지네이션

// pagination.ts — Relay 스타일 커넥션
@ObjectType()
export class PageInfo {
  @Field({ nullable: true })
  endCursor?: string;

  @Field()
  hasNextPage: boolean;

  @Field({ nullable: true })
  startCursor?: string;

  @Field()
  hasPreviousPage: boolean;
}

@ObjectType()
export class UserEdge {
  @Field(() => UserType)
  node: UserType;

  @Field()
  cursor: string;
}

@ObjectType()
export class UserConnection {
  @Field(() => [UserEdge])
  edges: UserEdge[];

  @Field(() => PageInfo)
  pageInfo: PageInfo;

  @Field(() => Int)
  totalCount: number;
}

@ArgsType()
export class PaginationArgs {
  @Field(() => Int, { defaultValue: 20 })
  @Min(1)
  @Max(100)
  first: number;

  @Field({ nullable: true })
  after?: string;
}

DataLoader: N+1 문제 해결

GraphQL의 가장 흔한 성능 문제인 N+1 쿼리를 DataLoader로 해결합니다:

// post.loader.ts
@Injectable({ scope: Scope.REQUEST })
export class PostLoader {
  constructor(private readonly postService: PostService) {}

  // 사용자 ID로 게시글 배치 로딩
  readonly byAuthorId = new DataLoader<string, PostType[]>(
    async (authorIds: readonly string[]) => {
      const posts = await this.postService.findByAuthorIds([...authorIds]);

      // authorId별로 그룹핑
      const postMap = new Map<string, PostType[]>();
      for (const post of posts) {
        const existing = postMap.get(post.authorId) || [];
        existing.push(post);
        postMap.set(post.authorId, existing);
      }

      // 입력 순서대로 반환 (DataLoader 규약)
      return authorIds.map(id => postMap.get(id) || []);
    },
  );
}

// Resolver에서 DataLoader 사용
@Resolver(() => UserType)
export class UserResolver {
  constructor(private readonly postLoader: PostLoader) {}

  @ResolveField(() => [PostType])
  async posts(@Parent() user: UserType) {
    // N+1 방지: 같은 요청 내 모든 user.posts를 하나의 쿼리로 배치
    return this.postLoader.byAuthorId.load(user.id);
  }
}

핵심: DataLoader는 Scope.REQUEST로 설정해야 합니다. 요청마다 새 인스턴스를 생성하여 캐시가 요청 간에 공유되지 않도록 합니다.

Subscription: 실시간 이벤트

// GraphQL 모듈에 Subscription 활성화
GraphQLModule.forRoot<ApolloDriverConfig>({
  driver: ApolloDriver,
  autoSchemaFile: true,
  subscriptions: {
    'graphql-ws': true,           // 최신 프로토콜
    'subscriptions-transport-ws': true, // 레거시 호환
  },
  installSubscriptionHandlers: true,
})

// pub-sub 설정
@Module({
  providers: [
    {
      provide: 'PUB_SUB',
      useValue: new PubSub(),     // 프로덕션에서는 RedisPubSub 사용
    },
  ],
  exports: ['PUB_SUB'],
})
export class PubSubModule {}

// Resolver에서 Subscription 정의
@Resolver(() => PostType)
export class PostResolver {
  constructor(
    @Inject('PUB_SUB') private readonly pubSub: PubSub,
    private readonly postService: PostService,
  ) {}

  @Mutation(() => PostType)
  async createPost(@Args('input') input: CreatePostInput) {
    const post = await this.postService.create(input);
    // 이벤트 발행
    await this.pubSub.publish('postCreated', { postCreated: post });
    return post;
  }

  // 실시간 구독
  @Subscription(() => PostType, {
    filter: (payload, variables) =>
      // 특정 작성자의 글만 구독
      !variables.authorId || payload.postCreated.authorId === variables.authorId,
  })
  postCreated(
    @Args('authorId', { nullable: true }) authorId?: string,
  ) {
    return this.pubSub.asyncIterableIterator('postCreated');
  }
}

인증과 인가

// GQL Auth Guard
@Injectable()
export class GqlAuthGuard extends AuthGuard('jwt') {
  getRequest(context: ExecutionContext) {
    const ctx = GqlExecutionContext.create(context);
    return ctx.getContext().req;
  }
}

// 현재 사용자 데코레이터
export const CurrentUser = createParamDecorator(
  (data: unknown, context: ExecutionContext) => {
    const ctx = GqlExecutionContext.create(context);
    return ctx.getContext().req.user;
  },
);

// 역할 기반 Guard
@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const roles = this.reflector.get<string[]>('roles', context.getHandler());
    if (!roles) return true;

    const ctx = GqlExecutionContext.create(context);
    const user = ctx.getContext().req.user;
    return roles.includes(user.role);
  }
}

// Resolver에서 사용
@Resolver(() => UserType)
export class UserResolver {

  @Query(() => UserType)
  @UseGuards(GqlAuthGuard)
  async me(@CurrentUser() user: User) {
    return user;
  }

  @Mutation(() => Boolean)
  @UseGuards(GqlAuthGuard, RolesGuard)
  @SetMetadata('roles', ['ADMIN'])
  async deleteUser(@Args('id', { type: () => ID }) id: string) {
    return this.userService.delete(id);
  }
}

Complexity와 Depth 제한

GraphQL은 클라이언트가 자유롭게 쿼리를 구성하므로, 악의적인 깊은 중첩 쿼리를 방어해야 합니다:

npm install graphql-query-complexity graphql-depth-limit

// app.module.ts
GraphQLModule.forRoot<ApolloDriverConfig>({
  driver: ApolloDriver,
  autoSchemaFile: true,
  validationRules: [
    depthLimit(7),                // 최대 깊이 7단계
  ],
  plugins: [
    {
      async requestDidStart() {
        return {
          async didResolveOperation({ request, document }) {
            const complexity = getComplexity({
              schema,
              operationName: request.operationName,
              query: document,
              variables: request.variables,
              estimators: [
                fieldExtensionsEstimator(),
                simpleEstimator({ defaultComplexity: 1 }),
              ],
            });
            if (complexity > 100) {
              throw new Error(`Query too complex: ${complexity}/100`);
            }
          },
        };
      },
    },
  ],
})

관련 글: NestJS Interceptor 6가지 패턴에서 응답 변환 패턴을, NestJS DI Scope·Provider 심화에서 REQUEST 스코프 DataLoader 관리를 함께 확인하세요.

마무리

NestJS GraphQL은 Code-First 접근으로 TypeScript 타입과 GraphQL 스키마를 단일 소스로 관리합니다. DataLoader로 N+1 방지, Subscription으로 실시간 이벤트, Complexity 제한으로 보안 — 이 세 가지가 프로덕션 GraphQL API의 핵심입니다. REST와 GraphQL을 하이브리드로 제공하는 것도 NestJS에서는 같은 서비스 레이어를 공유하여 쉽게 구현할 수 있습니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux