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/dataloader2. 배치 함수 작성
배치 함수는 입력 키 순서와 동일한 순서로 결과를 반환해야 합니다. 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개 쿼리 |