icon

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


 GraphQL의 유연성은 강력하지만, 잘못 구현하면 N+1 문제로 인해 성능이 크게 저하될 수 있습니다.

 DataLoader를 사용하면 이 문제를 효과적으로 해결할 수 있습니다.

N+1 문제의 원인과 영향

 N+1 문제는 GraphQL 쿼리에서 관계된 데이터를 가져올 때 발생합니다.

 예를 들어, 사용자 목록과 각 사용자의 게시물을 가져오는 쿼리에서

  1. 사용자 목록을 가져오는 쿼리 1번
  2. 각 사용자의 게시물을 가져오는 쿼리 N번

 이로 인해 데이터베이스 쿼리가 급격히 증가하여 성능이 저하됩니다.

DataLoader의 개념과 작동 원리

 DataLoader는 Facebook에서 개발한 유틸리티로, 데이터 액세스 요청을 일괄 처리하고 캐싱합니다. 주요 특징:

  1. 배치 처리: 여러 개별 요청을 하나의 배치 요청으로 통합
  2. 캐싱: 동일한 요청에 대한 결과를 메모리에 캐시
  3. 요청 중복 제거: 동일한 데이터에 대한 중복 요청 방지

NestJS에서 DataLoader 구현

  1. 설치
npm install dataloader
  1. 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) || []);
  });
}
  1. 리졸버에 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);
  }
}

캐싱 전략과 배치 처리 최적화

  1. 캐시 수명 설정
const loader = new DataLoader(batchFn, {
  maxBatchSize: 100,
  cache: true,
  cacheMap: new LRUMap(1000),  // LRU 캐시 사용
});
  1. 배치 사이즈 조정
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 }),
});

쿼리 복잡성에 따른 전략 변화

  1. 간단한 쿼리 : 기본 DataLoader 사용
  2. 복잡한 쿼리 : 중첩 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의 기능을 우회하고 직접 최적화된 쿼리를 작성하는 것이 더 효율적일 수 있습니다.