Node
Nest.js
Data Loader

NestJS DataLoader

결론

DataLoader는 GraphQL의 N+1 쿼리 문제를 해결하는 유틸리티입니다. 여러 개별 데이터 요청을 하나의 배치 쿼리로 묶어 데이터베이스 호출 횟수를 획기적으로 줄입니다. LogRocket - How to use DataLoader with NestJS (opens in a new tab)

위 출처에서 "With DataLoader, only two database queries are executed instead of eleven without it. Imagine retrieving 1,000 student objects - with DataLoader, you will still only make two database queries instead of 1,001 queries without it" 라고 명시되어 있습니다.

N+1 문제란?

GraphQL에서 중첩 쿼리를 실행할 때 발생하는 성능 문제입니다.

query {
  posts {      # 1번 쿼리
    title
    createdBy { # N번 추가 쿼리 (게시물 개수만큼)
      name
    }
  }
}

10개의 게시물을 조회하면 총 11번의 데이터베이스 호출이 발생합니다. DEV.to - Using GraphQL DataLoaders with NestJS (opens in a new tab)

DataLoader 동작 원리

배치 처리 (Batching)

단일 이벤트 루프 틱 내에서 발생하는 모든 개별 로드 요청을 하나의 배치 작업으로 통합합니다.

// 개별 요청들
loader.load(1)  // user 1
loader.load(2)  // user 2
loader.load(3)  // user 3
 
// 내부적으로 하나의 쿼리로 통합
// SELECT * FROM users WHERE id IN (1, 2, 3)

캐싱

같은 요청에서 동일한 키로 여러 번 load를 호출해도 실제 데이터베이스 호출은 한 번만 발생합니다.

NestJS 구현 방법

1. 패키지 설치

npm install dataloader
npm install -D @types/dataloader

2. 배치 함수 작성

배치 함수는 입력 키 순서와 동일한 순서로 결과를 반환해야 합니다. LogRocket - How to use DataLoader with NestJS (opens in a new tab)

위 출처에서 "The results returned from your DataLoader need to be in the same order as the ids, so you need to map them to ensure that" 라고 명시되어 있습니다.

// friend.service.ts
@Injectable()
export class FriendService {
  constructor(private readonly friendRepository: FriendRepository) {}
 
  async getFriendsByBatch(studentIds: readonly number[]): Promise<Friend[][]> {
    // 한 번의 쿼리로 모든 데이터 조회
    const friends = await this.friendRepository.findByStudentIds(studentIds);
 
    // 입력 순서에 맞게 매핑 (핵심!)
    return studentIds.map(id =>
      friends.filter(friend => friend.studentId === id)
    );
  }
}

3. DataLoader Service 생성

// dataloader.service.ts
import * as DataLoader from 'dataloader';
 
export interface IDataloaders {
  friendsLoader: DataLoader<number, Friend[]>;
}
 
@Injectable()
export class DataloaderService {
  constructor(private readonly friendService: FriendService) {}
 
  getLoaders(): IDataloaders {
    return {
      friendsLoader: new DataLoader<number, Friend[]>(
        async (keys: readonly number[]) =>
          this.friendService.getFriendsByBatch(keys)
      ),
    };
  }
}

4. GraphQL Module 설정

요청마다 새로운 DataLoader 인스턴스를 생성해야 합니다. context를 함수로 정의하면 매 요청마다 호출됩니다.

// app.module.ts
@Module({
  imports: [
    GraphQLModule.forRootAsync<ApolloDriverConfig>({
      driver: ApolloDriver,
      imports: [DataloaderModule],
      useFactory: (dataloaderService: DataloaderService) => ({
        autoSchemaFile: true,
        context: () => ({
          loaders: dataloaderService.getLoaders(),
        }),
      }),
      inject: [DataloaderService],
    }),
  ],
})
export class AppModule {}

5. Resolver에서 사용

// student.resolver.ts
@Resolver(() => Student)
export class StudentResolver {
  @ResolveField('friends', () => [Friend])
  getFriends(
    @Parent() student: Student,
    @Context() { loaders }: { loaders: IDataloaders }
  ) {
    return loaders.friendsLoader.load(student.id);
  }
}

주의사항

순서 보장 필수

DataLoader는 입력 키 배열과 결과 배열의 순서가 일치해야 합니다.

입력 키기대하는 결과 순서
[1, 3, 4][data1, data3, data4]

map 함수를 사용해 입력 순서를 유지하면서 결과를 매핑하면 됩니다.

요청별 인스턴스 생성

DataLoader는 요청 단위로 캐싱을 수행하므로, 반드시 요청마다 새 인스턴스를 생성해야 합니다. 그렇지 않으면 사용자 간 데이터가 공유될 수 있습니다.

// 올바른 방법: context를 함수로 정의
context: () => ({
  loaders: dataloaderService.getLoaders(),
})
 
// 잘못된 방법: 모든 요청이 같은 인스턴스 공유
context: {
  loaders: dataloaderService.getLoaders(),
}

성능 개선 효과

상황DataLoader 미사용DataLoader 사용
학생 10명 조회11개 쿼리2개 쿼리
학생 1000명 조회1001개 쿼리2개 쿼리

참고 자료