데이터 로더와 N+1 문제 해결
GraphQL의 유연성은 강력하지만, 잘못 구현하면 N+1 문제로 인해 성능이 크게 저하될 수 있습니다.
DataLoader를 사용하면 이 문제를 효과적으로 해결할 수 있습니다.
N+1 문제의 원인과 영향
N+1 문제는 GraphQL 쿼리에서 관계된 데이터를 가져올 때 발생합니다.
예를 들어, 사용자 목록과 각 사용자의 게시물을 가져오는 쿼리에서
- 사용자 목록을 가져오는 쿼리 1번
- 각 사용자의 게시물을 가져오는 쿼리 N번
이로 인해 데이터베이스 쿼리가 급격히 증가하여 성능이 저하됩니다.
DataLoader의 개념과 작동 원리
DataLoader는 Facebook에서 개발한 유틸리티로, 데이터 액세스 요청을 일괄 처리하고 캐싱합니다. 주요 특징:
- 배치 처리: 여러 개별 요청을 하나의 배치 요청으로 통합
- 캐싱: 동일한 요청에 대한 결과를 메모리에 캐시
- 요청 중복 제거: 동일한 데이터에 대한 중복 요청 방지
NestJS에서 DataLoader 구현
- 설치
npm install dataloader
- DataLoader 서비스 생성
import DataLoader from 'dataloader';
import { Injectable } from '@nestjs/common';
import { PostService } from './post.service';
@Injectable()
export class PostLoader {
constructor(private readonly postService: PostService) {}
public readonly batchPosts = new DataLoader(async (userIds: string[]) => {
const posts = await this.postService.findByUserIds(userIds);
const postMap = new Map(posts.map(post => [post.userId, post]));
return userIds.map(userId => postMap.get(userId) || []);
});
}
- 리졸버에 DataLoader 통합
@Resolver(of => User)
export class UserResolver {
constructor(private readonly postLoader: PostLoader) {}
@ResolveField()
async posts(@Parent() user: User) {
return this.postLoader.batchPosts.load(user.id);
}
}
복잡한 관계에서의 DataLoader 사용
다중 관계 처리 예시
@Injectable()
export class UserLoader {
constructor(private readonly userService: UserService) {}
public readonly batchUsers = new DataLoader(async (userIds: string[]) => {
const users = await this.userService.findByIds(userIds);
return userIds.map(id => users.find(user => user.id === id));
});
}
@Resolver(of => Post)
export class PostResolver {
constructor(private readonly userLoader: UserLoader) {}
@ResolveField()
async author(@Parent() post: Post) {
return this.userLoader.batchUsers.load(post.authorId);
}
}
캐싱 전략과 배치 처리 최적화
- 캐시 수명 설정
const loader = new DataLoader(batchFn, {
maxBatchSize: 100,
cache: true,
cacheMap: new LRUMap(1000), // LRU 캐시 사용
});
- 배치 사이즈 조정
const loader = new DataLoader(batchFn, { maxBatchSize: 100 });
잠재적 문제와 해결 방법
1. 캐시 일관성
- 문제 : 데이터 변경 시 캐시 불일치
- 해결 : 변경 후 캐시 클리어 또는 TTL 설정
loader.clear(key); // 특정 키의 캐시 삭제
loader.clearAll(); // 전체 캐시 삭제
2. 메모리 사용
- 문제 : 과도한 캐시로 인한 메모리 부족
- 해결 : LRU 캐시 사용 또는 주기적 캐시 클리어
import LRUCache from 'lru-cache';
const loader = new DataLoader(batchFn, {
cacheMap: new LRUCache({ max: 1000 }),
});
쿼리 복잡성에 따른 전략 변화
- 간단한 쿼리 : 기본 DataLoader 사용
- 복잡한 쿼리 : 중첩 DataLoader 또는 커스텀 배치 함수 사용
예시
const nestedLoader = new DataLoader(async (keys: string[]) => {
const results = await mainLoader.loadMany(keys);
return Promise.all(results.map(result => subLoader.load(result.id)));
});
DataLoader와 ORM 통합
TypeORM과 DataLoader 통합 예시
@Injectable()
export class UserLoader {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}
public readonly batchUsers = new DataLoader(async (userIds: string[]) => {
const users = await this.userRepository.findByIds(userIds);
return userIds.map(id => users.find(user => user.id === id));
});
}
주의사항
- ORM의 지연 로딩 기능과 충돌 가능성 주의
- 복잡한 관계에서는 맞춤형 쿼리 작성 고려
Best Practices와 성능 모니터링
1. 요청별 DataLoader 인스턴스 생성
@Injectable()
export class DataLoaderFactory {
createLoaders() {
return {
userLoader: new DataLoader(batchUsers),
postLoader: new DataLoader(batchPosts),
};
}
}
@Resolver(of => User)
export class UserResolver {
@ResolveField()
async posts(@Parent() user: User, @Context() { loaders }) {
return loaders.postLoader.load(user.id);
}
}
2. 프로메스 체이닝 최소화
// 좋음
const [user, posts] = await Promise.all([
userLoader.load(id),
postLoader.load(id),
]);
// 피해야 할 것
const user = await userLoader.load(id);
const posts = await postLoader.load(user.id);
3. 배치 함수 최적화
- 데이터베이스 쿼리 최적화
- 인덱스 적절히 사용
4. 성능 모니터링
- GraphQL 쿼리 복잡도 모니터링
- 데이터베이스 쿼리 실행 시간 추적
- APM(Application Performance Monitoring) 도구 사용
5. 로깅 및 디버깅
const loader = new DataLoader(batchFn, {
onBatch: (keys) => console.log('Batch loading:', keys),
});
DataLoader를 사용하면 N+1 문제를 효과적으로 해결하고 애플리케이션의 성능을 크게 향상시킬 수 있습니다.
DataLoader의 배치 처리와 캐싱 메커니즘은 데이터베이스 쿼리를 최적화하고 중복 요청을 제거하여 전체적인 응답 시간을 단축시킵니다.
복잡한 데이터 관계를 다룰 때는 여러 DataLoader를 조합하여 사용할 수 있습니다.
이를 통해 다중 레벨의 관계도 효율적으로 처리할 수 있으며, 각 엔티티 타입별로 별도의 DataLoader를 만들어 사용하는 것이 일반적입니다.
DataLoader를 ORM과 함께 사용할 때는 ORM의 지연 로딩 기능과의 상호작용에 주의해야 합니다.
경우에 따라서는 ORM의 기능을 우회하고 직접 최적화된 쿼리를 작성하는 것이 더 효율적일 수 있습니다.