NestJS GraphQL DataLoader

N+1 문제와 DataLoader

GraphQL에서 가장 흔한 성능 문제는 N+1 쿼리입니다. 사용자 목록을 조회하면서 각 사용자의 게시글을 가져올 때, 사용자 1회 + 게시글 N회 = 총 N+1회 DB 쿼리가 발생합니다. DataLoader는 이를 자동으로 배치 처리하여 2회(사용자 1회 + 게시글 1회)로 줄입니다.

문제 시각화

# GraphQL 쿼리
query {
  users {       # SELECT * FROM users → 1회
    id
    name
    posts {     # 각 user마다 SELECT * FROM posts WHERE author_id = ? → N회
      title
    }
  }
}

# DataLoader 적용 후
# SELECT * FROM users → 1회
# SELECT * FROM posts WHERE author_id IN (1, 2, 3, ..., N) → 1회
# 총 2회!

설치 및 기본 설정

npm install @nestjs/graphql @nestjs/apollo @apollo/server graphql dataloader
// app.module.ts
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';

@Module({
  imports: [
    GraphQLModule.forRoot<ApolloDriverConfig>({
      driver: ApolloDriver,
      autoSchemaFile: true,
      // 요청별 DataLoader 인스턴스 생성을 위한 컨텍스트
      context: ({ req }) => ({ req }),
    }),
  ],
})
export class AppModule {}

DataLoader 구현

기본 패턴 — 요청 스코프

import * as DataLoader from 'dataloader';
import { Injectable, Scope } from '@nestjs/common';

// 요청마다 새 인스턴스 — 캐시 격리
@Injectable({ scope: Scope.REQUEST })
export class PostLoader {
  constructor(private readonly postRepo: PostRepository) {}

  // 작성자 ID로 게시글 배치 로드
  readonly byAuthorId = new DataLoader<string, Post[]>(
    async (authorIds: readonly string[]) => {
      // 한 번의 IN 쿼리로 모든 게시글 조회
      const posts = await this.postRepo.find({
        where: { authorId: In([...authorIds]) },
        order: { createdAt: 'DESC' },
      });

      // authorId별로 그룹화하여 순서대로 반환
      const postMap = new Map<string, Post[]>();
      for (const post of posts) {
        const group = postMap.get(post.authorId) ?? [];
        group.push(post);
        postMap.set(post.authorId, group);
      }

      // DataLoader 규칙: 입력 순서와 동일한 순서로 반환
      return authorIds.map(id => postMap.get(id) ?? []);
    },
  );
}

// ID로 단일 엔티티 로드
@Injectable({ scope: Scope.REQUEST })
export class UserLoader {
  constructor(private readonly userRepo: UserRepository) {}

  readonly byId = new DataLoader<string, User | null>(
    async (ids: readonly string[]) => {
      const users = await this.userRepo.findByIds([...ids]);
      const userMap = new Map(users.map(u => [u.id, u]));
      return ids.map(id => userMap.get(id) ?? null);
    },
  );
}

Resolver에서 사용

@Resolver(() => User)
export class UserResolver {
  constructor(
    private readonly userService: UserService,
    private readonly postLoader: PostLoader,
    private readonly commentLoader: CommentLoader,
  ) {}

  @Query(() => [User])
  async users(): Promise<User[]> {
    return this.userService.findAll();
  }

  // DataLoader로 N+1 방지
  @ResolveField(() => [Post])
  async posts(@Parent() user: User): Promise<Post[]> {
    return this.postLoader.byAuthorId.load(user.id);
  }

  @ResolveField(() => [Comment])
  async recentComments(@Parent() user: User): Promise<Comment[]> {
    return this.commentLoader.byUserId.load(user.id);
  }
}

@Resolver(() => Post)
export class PostResolver {
  constructor(
    private readonly userLoader: UserLoader,
    private readonly commentLoader: CommentLoader,
  ) {}

  // 게시글의 작성자 — 단일 엔티티 로드
  @ResolveField(() => User)
  async author(@Parent() post: Post): Promise<User | null> {
    return this.userLoader.byId.load(post.authorId);
  }

  @ResolveField(() => [Comment])
  async comments(@Parent() post: Post): Promise<Comment[]> {
    return this.commentLoader.byPostId.load(post.id);
  }
}

고급 패턴 — 조건부 배치

// 복합 키 DataLoader — (postId, status) 조합
@Injectable({ scope: Scope.REQUEST })
export class FilteredCommentLoader {
  constructor(private readonly commentRepo: CommentRepository) {}

  readonly byPostAndStatus = new DataLoader<
    { postId: string; status: string },
    Comment[],
    string  // 캐시 키 타입
  >(
    async (keys) => {
      const postIds = [...new Set(keys.map(k => k.postId))];
      const statuses = [...new Set(keys.map(k => k.status))];

      const comments = await this.commentRepo.find({
        where: {
          postId: In(postIds),
          status: In(statuses),
        },
      });

      return keys.map(key =>
        comments.filter(c => c.postId === key.postId && c.status === key.status)
      );
    },
    {
      // 복합 키를 문자열로 변환하여 캐시 키 생성
      cacheKeyFn: (key) => `${key.postId}:${key.status}`,
    },
  );
}

// Resolver에서 사용
@ResolveField(() => [Comment])
async approvedComments(@Parent() post: Post): Promise<Comment[]> {
  return this.filteredCommentLoader.byPostAndStatus.load({
    postId: post.id,
    status: 'approved',
  });
}

NestJS Module 패턴 — DataLoader 팩토리

// dataloader.module.ts — 중앙 관리
@Module({
  providers: [
    UserLoader,
    PostLoader,
    CommentLoader,
    FilteredCommentLoader,
  ],
  exports: [
    UserLoader,
    PostLoader,
    CommentLoader,
    FilteredCommentLoader,
  ],
})
export class DataLoaderModule {}

// 또는 팩토리 패턴으로 동적 생성
@Injectable({ scope: Scope.REQUEST })
export class DataLoaderFactory {
  constructor(
    private readonly userRepo: UserRepository,
    private readonly postRepo: PostRepository,
  ) {}

  private loaders = new Map<string, DataLoader<any, any>>();

  getLoader<K, V>(
    name: string,
    batchFn: DataLoader.BatchLoadFn<K, V>,
  ): DataLoader<K, V> {
    if (!this.loaders.has(name)) {
      this.loaders.set(name, new DataLoader(batchFn));
    }
    return this.loaders.get(name)!;
  }

  get userById() {
    return this.getLoader<string, User | null>('user-by-id', async (ids) => {
      const users = await this.userRepo.findByIds([...ids]);
      const map = new Map(users.map(u => [u.id, u]));
      return ids.map(id => map.get(id) ?? null);
    });
  }
}

캐싱 전략

// DataLoader는 요청 내 자동 캐싱
// 같은 요청에서 user.id = "1"을 여러 번 load해도 DB 쿼리 1회

// 캐시 무효화 — mutation 후
@Mutation(() => Post)
async updatePost(
  @Args('input') input: UpdatePostInput,
): Promise<Post> {
  const post = await this.postService.update(input);
  
  // 관련 캐시 무효화
  this.postLoader.byAuthorId.clear(post.authorId);
  this.postLoader.byAuthorId.prime(post.authorId, /* 새 데이터 */);
  
  return post;
}

// 캐시 비활성화 옵션
const noCacheLoader = new DataLoader(batchFn, {
  cache: false,  // 매번 새로 로드
});

성능 모니터링

// DataLoader에 로깅/메트릭 추가
function createMonitoredLoader<K, V>(
  name: string,
  batchFn: DataLoader.BatchLoadFn<K, V>,
  logger: Logger,
): DataLoader<K, V> {
  return new DataLoader<K, V>(async (keys) => {
    const start = Date.now();
    const result = await batchFn(keys);
    const duration = Date.now() - start;

    logger.debug(
      `DataLoader[${name}]: ${keys.length} keys in ${duration}ms`,
    );

    if (duration > 100) {
      logger.warn(
        `DataLoader[${name}]: Slow batch — ${keys.length} keys, ${duration}ms`,
      );
    }

    return result;
  }, {
    maxBatchSize: 100,  // 배치 크기 제한
  });
}

운영 팁

  • 반드시 REQUEST 스코프: DataLoader 캐시가 요청 간 공유되면 데이터 오염 발생
  • 반환 순서 보장: batch 함수는 입력 키와 동일한 순서로 결과를 반환해야 함 — Map으로 매핑 후 순서대로 추출
  • null 처리: 존재하지 않는 키에 대해 null을 반환해야 함 (빈 배열 아님)
  • maxBatchSize: PostgreSQL의 IN 절 제한에 맞게 설정 (기본 무제한)
  • Code-First 통합: @ResolveField에서만 DataLoader 사용, @Query에서는 직접 쿼리
  • Injection Scope 주의: REQUEST 스코프 DataLoader를 SINGLETON 서비스에 주입하면 에러 발생

정리

DataLoader는 GraphQL N+1 문제의 표준 해결책입니다. 요청 스코프로 캐시를 격리하고, 배치 함수로 IN 쿼리 하나로 모든 관련 데이터를 로드합니다. 복합 키, 조건부 배치, 캐시 무효화까지 마스터하면 GraphQL API의 성능을 드라마틱하게 개선할 수 있습니다.

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