안동민 개발노트 아이콘

안동민 개발노트

6장 : GraphQL 서버 구축

데이터 로더와 N+1 문제 해결

지난 절에서 NestJS와 GraphQL을 사용해 쿼리, 뮤테이션, 서브스크립션을 구현하는 방법을 살펴보았습니다. 이제 GraphQL 서버 성능을 최적화하기 위해, 가장 흔한 병목 중 하나인 N+1 문제DataLoader로 해결하는 방법을 다룹니다.

GraphQL의 유연성은 강력하지만, 데이터 페칭(data fetching) 방식에 주의하지 않으면 성능 병목 현상이 발생하기 쉽습니다. 특히 데이터베이스 쿼리가 반복적으로 발생하는 N+1 문제는 규모가 커질수록 서버 부하를 크게 증가시킬 수 있습니다.


N+1 문제란 무엇인가?

N+1 문제는 데이터베이스에서 관계형 데이터를 가져올 때 발생하는 일반적인 성능 문제입니다. 설명하기 위해 다음과 같은 시나리오를 가정해 봅시다.

우리가 게시물 목록을 조회하면서 각 게시물의 작성자 정보도 함께 가져와야 합니다.

N+1 문제 발생 전 (일반적인 REST 또는 비효율적인 GraphQL)
  • 1번 쿼리: 모든 게시물(N개)을 가져옵니다. (예: SELECT * FROM posts;)
  • N번 쿼리: 각 게시물에 대해 해당 게시물의 작성자(User) 정보를 개별적으로 가져옵니다. (예: 각 게시물마다 SELECT * FROM users WHERE id = [authorId];) 총 1 + N번의 데이터베이스 쿼리가 발생합니다. 게시물 수가 늘어날수록 쿼리 수가 선형적으로 증가하여 성능 저하의 주범이 됩니다.
예시 쿼리
query {
  posts {
    id
    title
    author { # 이 부분이 N+1 문제를 발생시킵니다.
      id
      username
    }
  }
}

posts 리졸버가 모든 게시물을 가져온 후, 각 Post 객체의 author 필드를 해결하기 위해 author 리졸버가 각 게시물마다 별도의 데이터베이스 쿼리를 실행하는 것이 전형적인 N+1 문제입니다.


데이터 로더란?

데이터 로더(DataLoader)는 Facebook에서 개발한 유틸리티 라이브러리로, GraphQL의 N+1 문제를 해결하는 데 특화되어 있습니다. 데이터 로더의 핵심 아이디어는 두 가지입니다.

배치 처리(Batching): 동일한 틱(tick) 내에서 여러 번 요청된 데이터 로드 요청을 모아서 단 한 번의 데이터베이스 또는 API 호출로 처리합니다. (예: N번의 SELECT * FROM users WHERE id = [id] 쿼리를 SELECT * FROM users WHERE id IN ([id1], [id2], ...)와 같은 한 번의 쿼리로 합칩니다.)

캐싱(Caching): 한 번 로드된 데이터는 캐시에 저장하여 동일한 ID로 다시 요청될 경우 데이터베이스를 다시 조회하지 않고 캐시된 값을 반환합니다.

DataLoader는 각 GraphQL 요청 사이클(per-request cycle) 동안에만 요청들을 수집하고 배치합니다. 이는 하나의 HTTP 요청 내에서만 작동하며, 다른 HTTP 요청의 데이터 로더 인스턴스와는 독립적입니다.


NestJS에서 DataLoader 구현하기

NestJS에서 DataLoader를 통합하는 가장 좋은 방법은 @nestjs/graphql과 함께 제공되는 유틸리티와 모듈 기반 아키텍처를 활용하는 것입니다.

단계 1: 필요한 패키지 설치
npm install dataloader
단계 2: User 데이터 로더 서비스 생성

사용자 정보를 배치 로드할 UsersLoader 서비스를 생성합니다. 이 서비스는 ID 배열을 받아 사용자 배열을 반환하는 배치 함수를 가집니다.

nest g s users/loaders/users.loader
src/users/loaders/users.loader.ts
import { Injectable, Scope } from '@nestjs/common';
import * as DataLoader from 'dataloader'; // DataLoader 임포트
import { UsersService } from '../users.service'; // 사용자 데이터를 가져올 서비스
import { User } from '../models/user.model';

// 요청 스코프로 DataLoader를 생성하여 요청마다 새로운 인스턴스를 보장합니다.
// 이렇게 해야 한 요청 내에서만 배치가 유효하며, 다른 요청과 섞이지 않습니다.
@Injectable({ scope: Scope.REQUEST })
export class UsersLoader {
  constructor(private usersService: UsersService) {}

  // DataLoader 인스턴스를 저장할 속성
  public readonly batchUsers = new DataLoader(async (userIds: number[]) => {
    // 1. 배치 처리: usersService를 통해 여러 ID를 한 번에 조회합니다.
    // 이 메서드는 usersService에 실제 데이터베이스 쿼리 로직을 가집니다.
    // 여기서는 findByIds를 가정합니다.
    console.log(`[DataLoader] Fetching users with IDs: ${userIds.join(', ')}`);
    const users = await this.usersService.findByIds(userIds);

    // 2. 캐싱을 위한 결과 매핑: DataLoader는 요청된 순서대로 결과를 기대합니다.
    // 따라서 Map을 사용하여 ID와 User 객체를 매핑하고, 요청된 ID 순서대로 반환해야 합니다.
    const userMap = new Map<number, User>();
    users.forEach(user => userMap.set(user.id, user));

    return userIds.map(id => userMap.get(id));
  });
}
users.service.ts 업데이트 (findByIds 메서드 추가)

UsersService에 여러 ID로 사용자를 조회하는 findByIds 메서드를 추가합니다. 실제로는 데이터베이스 쿼리를 여기에 구현합니다.

src/users/users.service.ts
import { Injectable } from '@nestjs/common';
import { User } from './models/user.model'; // User 모델 임포트 (이전 절에서 정의)

@Injectable()
export class UsersService {
  private 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' },
    { id: 104, username: 'user4', email: 'user4@example.com' },
  ];

  findOne(id: number): User | undefined {
    return this.users.find(user => user.id === id);
  }

  // DataLoader가 호출할 배치 함수
  async findByIds(ids: number[]): Promise<User[]> {
    // 실제 DB 쿼리: SELECT * FROM users WHERE id IN (101, 102, 103);
    return this.users.filter(user => ids.includes(user.id));
  }
}
단계 3: UsersLoaderUsersModule에 프로바이더로 등록

UsersLoaderUsersService에 의존하므로, UsersModule에서 함께 제공되어야 합니다.

src/users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersLoader } from './loaders/users.loader'; // UsersLoader 임포트
// import { UsersV1Controller, UsersV2Controller } from './users.controller'; // REST 컨트롤러 (이 예제와 무관)
import { User } from './models/user.model'; // User 모델도 임포트

@Module({
  // controllers: [UsersV1Controller, UsersV2Controller], // REST 컨트롤러는 여기서 제거하거나 주석 처리
  providers: [UsersService, UsersLoader, User], // UsersLoader와 UsersService를 프로바이더로 등록
  exports: [UsersService, UsersLoader, User], // 다른 모듈에서 사용 가능하도록 export
})
export class UsersModule {}
단계 4: GraphQL Context에 DataLoader 주입

각 GraphQL 요청마다 새로운 DataLoader 인스턴스를 생성하고, 이를 리졸버에서 접근할 수 있도록 GraphQL 컨텍스트(Context)에 주입해야 합니다.

src/app.module.ts (업데이트: GraphQLModule 설정)
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 임포트
import { UsersLoader } from './users/loaders/users.loader'; // UsersLoader 임포트

@Module({
  imports: [
    GraphQLModule.forRoot<ApolloDriverConfig>({
      driver: ApolloDriver,
      autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
      sortSchema: true,
      playground: true,
      subscriptions: {
        'graphql-ws': true,
        'subscriptions-transport-ws': true,
      },
      // context를 통해 DataLoader 인스턴스 주입
      // 이 함수는 GraphQL 요청마다 실행됩니다.
      context: ({ req, connection }) => {
        // HTTP 요청 (쿼리/뮤테이션) 또는 WebSocket 연결 (서브스크립션)에 따라 다름
        if (connection) {
          // 서브스크립션 연결 시 DataLoader를 초기화
          return { dataLoaders: new UsersLoader(connection.context.usersService) };
        } else {
          // HTTP 요청 시 DataLoader를 초기화 (req.injectedServices에 접근)
          // UsersLoader는 요청 스코프이므로, NestJS의 DI 시스템을 통해 주입받아야 합니다.
          // 이를 위해 ApolloDriverConfig의 buildServiceContext 옵션을 사용하거나,
          // 또는 다음과 같이 Injector를 사용하여 수동으로 인스턴스를 가져올 수 있습니다.
          // NestJS 9+에서는 request.injector를 사용하여 요청 스코프 인스턴스에 접근 가능합니다.
          // 더 간단한 방법은 아래 buildServiceContext를 사용하는 것입니다.
        }
      },
      // NestJS 9+에서 요청 스코프 프로바이더를 GraphQL context에 쉽게 주입하는 방법
      // 이 함수는 각 GraphQL 요청에 대해 한 번만 실행됩니다.
      buildServiceContext: async (context) => {
        const { req, connection } = context;
        if (req) {
          // HTTP 요청 (쿼리, 뮤테이션)
          // UsersLoader 인스턴스를 NestJS의 Request scoped Injector를 통해 가져옵니다.
          const usersLoader = await req.injector.resolve(UsersLoader);
          return { usersLoader };
        } else if (connection) {
          // WebSocket 연결 (서브스크립션)
          // 서브스크립션은 별도의 LifeCycle을 가지므로, DataLoader를 직접 생성합니다.
          // 이 경우 UsersService도 직접 주입해야 할 수 있습니다. (singleton으로 등록된 서비스)
          // 예시: const usersService = app.get(UsersService);
          // return { usersLoader: new UsersLoader(usersService) };
          // 하지만 일반적으로 Subscription Context는 조금 다르게 관리될 수 있습니다.
          // 간단하게는 서브스크립션에서는 DataLoader를 사용하지 않거나,
          // 별도의 DataLoader 인스턴스를 생성하는 것으로 시작할 수 있습니다.
          // 여기서는 간단하게 HTTP 요청에 대해서만 DataLoader를 설정합니다.
          return {}; // 서브스크립션 요청에는 DataLoader가 필요 없을 수도 있습니다.
        }
        return {};
      },
    }),
    PostsModule,
    UsersModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

주의: NestJS의 요청 스코프(Request scope) 프로바이더를 context에 주입하는 것은 약간 복잡할 수 있습니다. 위 예시는 NestJS 9+에서 buildServiceContextreq.injector.resolve를 사용하는 방식을 보여줍니다.

단계 5: 리졸버에서 DataLoader 사용

PostsResolver에서 author 필드를 해결할 때 UsersLoader를 사용하도록 수정합니다.

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';
import { UsersLoader } from '../users/loaders/users.loader'; // UsersLoader 임포트
import { GqlContext } from 'src/common/decorators/gql-context.decorator'; // Custom Decorator (아래 정의)

const pubSub = new PubSub();
src/common/decorators/gql-context.decorator.ts (새로 생성)
// GqlContext 커스텀 데코레이터 정의
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';

export const GqlContext = createParamDecorator(
  (data: unknown, context: ExecutionContext) => {
    const gqlContext = GqlExecutionContext.create(context).getContext();
    return gqlContext;
  },
);

@Resolver(() => Post)
export class PostsResolver {
  private readonly posts: Post[] = [
    { id: 1, title: '첫 번째 게시물', content: 'GraphQL 학습 중입니다.', authorId: 101 }, // authorId로 변경
    { id: 2, title: '두 번째 게시물', content: 'NestJS와 GraphQL 통합!', authorId: 102 }, // authorId로 변경
    { id: 3, title: '세 번째 게시물', content: 'N+1 문제 해결!', authorId: 101 }, // 동일 저자
  ];
  private nextPostId = 4; // nextPostId 업데이트

  // (User 임시 데이터는 UsersService로 이동했으므로 여기서 제거)

  constructor(private readonly usersLoader: UsersLoader) {} // UsersLoader 주입 (컨트롤러 아님 리졸버)

  @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 필드를 해결합니다.
  // DataLoader를 사용하여 N+1 문제를 해결합니다.
  @ResolveField(() => User, { description: '게시물 작성자 정보를 반환합니다.' })
  async author(@Parent() post: Post, @GqlContext() context: any): Promise<User> {
    // context.usersLoader를 통해 DataLoader 인스턴스에 접근
    // console.log(`[ResolveField] Fetching author for post ${post.id} (authorId: ${post.authorId})`);
    return context.usersLoader.batchUsers.load(post.authorId); // DataLoader의 load 메서드 사용
  }

  // Mutation 코드 (createPost, updatePost, deletePost)는 user.model.ts 업데이트를 반영하여 수정 필요
  @Mutation(() => Post, { description: '새로운 게시물을 생성합니다.' })
  async createPost(@Args('createPostInput') createPostInput: CreatePostInput): Promise<Post> {
    const newPost: Post = {
      id: this.nextPostId++,
      title: createPostInput.title,
      content: createPostInput.content,
      authorId: createPostInput.authorId, // authorId 직접 저장
    };
    this.posts.push(newPost);

    await pubSub.publish('postAdded', { postAdded: newPost });
    return newPost;
  }
  // ... (updatePost, deletePost도 authorId 필드를 직접 다루도록 수정)

  // Subscription 코드 (동일)
}
src/posts/models/post.model.ts 업데이트 (authorId 필드 추가)
src/posts/models/post.model.ts
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { User } from '../../users/models/user.model';

@ObjectType()
export class Post {
  @Field(() => Int, { description: '게시물의 고유 ID' })
  id: number;

  @Field({ description: '게시물의 제목' })
  title: string;

  @Field({ nullable: true, description: '게시물의 내용' })
  content?: string;

  @Field(() => Int, { description: '게시물 작성자의 고유 ID (내부용)' })
  authorId: number; // 새로운 필드: 작성자 ID를 직접 가짐

  @Field(() => User, { description: '게시물 작성자 정보' })
  // author 필드는 authorId를 통해 ResolveField에서 해결됩니다.
  author: User; // 이 필드는 실제 데이터가 아니라 GraphQL 스키마를 위한 가상 필드입니다.
}
src/users/models/user.model.ts 업데이트 (username, email 필드 추가)
src/users/models/user.model.ts
import { Field, Int, ObjectType } from '@nestjs/graphql';

@ObjectType()
export class User {
  @Field(() => Int)
  id: number;

  @Field()
  username: string; // GraphQL 필드로 추가

  @Field()
  email: string; // GraphQL 필드로 추가
}

N+1 문제 해결 확인

애플리케이션을 실행합니다. npm run start:dev

http://localhost:3000/graphql로 접속하여 GraphQL Playground를 엽니다.

다음 쿼리를 실행해 봅니다.

query GetPostsWithAuthors {
  posts {
    id
    title
    author {
      id
      username
    }
  }
}

콘솔 로그를 확인해 보면, 이전에는 [ResolveField] 로그가 게시물 수만큼 여러 번 출력되었을 것이지만, DataLoader를 적용한 후에는 [DataLoader] 로그가 단 한 번만 (모든 사용자 ID를 포함하여) 출력되는 것을 볼 수 있습니다.

결과 예시
[DataLoader] Fetching users with IDs: 101, 102

(만약 데이터에 101, 102 외에 다른 사용자 ID가 있다면 함께 출력될 것입니다.)

이 로그는 여러 게시물의 작성자 정보를 가져오기 위해 단 한 번의 findByIds 호출 (즉, 한 번의 데이터베이스 쿼리)만 발생했음을 의미합니다. 이것이 바로 DataLoader가 N+1 문제를 해결하는 방식입니다.

마지막으로 적용 전후를 한 화면에서 비교하면, 리졸버 호출 횟수가 아니라 실제 배치 조회 단위가 줄어드는 지점이 더 분명해집니다. 아래 다이어그램처럼 요청 컨텍스트, DataLoader 캐시, findByIds 호출을 함께 확인하면 회귀를 빠르게 잡을 수 있습니다.


DataLoader는 GraphQL 요청 안에서 같은 종류의 조회를 모아 배치 처리하고, 요청 범위 캐시로 중복 조회를 줄이는 도구입니다. NestJS에서는 요청 범위 provider, loader 생성 위치, 캐시 무효화 기준을 명확히 두고 적용합니다.

이것으로 6장 GraphQL 서버 구축의 세 번째 절을 마칩니다.


다음 다이어그램은 GraphQL N+1 문제를 감지하고 DataLoader로 배치 조회와 요청 캐시를 적용하는 기준입니다.