N + 1
이전 글의 예제에서 Post를 가져오는데에 Post와 Comments는 1:N 관계를 가진다.
이 관계에서 comments를 조회할 때 comment가 lazy loding되어 N + 1 문제가 발생할 수 있다.
(이 예제에서는 TypeORM의 N + 1 문제는 제외했다.)
//lazy loading
async findAll(authorId?: number): Promise<Post[]> {
if (authorId) return await this.postRepo.find({ where: { authorId: authorId } });
return await this.postRepo.find();
}
//eager loading
async findAll(authorId?: number): Promise<Post[]> {
const options: FindManyOptions<Post> = {
relations: ['author', 'comments', 'comments.author']
};
if (authorId) {
options.where = { authorId: authorId };
}
return await this.postRepo.find(options);
}
graphQL의 N + 1
미리 join연산을 통해 fetch하더라도 graphQL에서도 N + 1문제가 발생할 수 있다.
이전 글과 동일하게 데이터를 post 10개, user 10명, comment는 post당 10개로 설정했다.
//Post Resolver
@ResolveField(() => User)
async author(@Parent() post: Post): Promise<User> {
return await this.userService.findOne(post.authorId);
}
@ResolveField(() => [Comment])
async comments(@Parent() post: Post): Promise<Comment[]> {
return await this.postService.getCommentsByPostId(post.id);
}
//Comment Resolver
@ResolveField(() => User)
async author(@Parent() comment: Comment): Promise<User> {
return await this.userService.findOne(comment.authorId);
}
기존의 코드는 각 리졸버에서 개별적으로 가져오는 방식으로 동작하기때문에 아래와 같이 동작한다.
- 10개의 post를 가져오는 쿼리
- 각 post에 대해 author를 가져오는 10개의 쿼리
- 각 post에 대해 comments을 가져오는 10개의 쿼리
- comments 각각의 author을 가져오는 쿼리 (게시물 당 10개의 댓글 - 10 x 10)
이러한 방식으로 총 121개의 쿼리가 실행된다;;;;;;
만약 리졸버에서 join해서 fetch하더라도, 데이터를 실제로 가져오는 백단에서는 여전히 OverFetching이 발생한다.
OverFetching을 방지하고자 select를 커스터마이징할 수도 없다. 일관성을 해치고 다른 쿼리를 위해 추가로 코드를 작성해야한다. 다른 잠재적인 문제들도 발생할 것이다.
엔터티 간의 관계를 명시할때, TypeORM에서 eager을 제공해주긴 하지만, 위의 경우는 이런 속성들로 해결할 수 없다.
DataLoader
dataloader은 페이스북에서 만든 라이브러리로 여러 요청을 하나의 배치로 묶어서 한 번에 데이터베이스에 요청을 보낼 수 있다.
@Injectable({ scope: Scope.REQUEST })
export class UserLoader {
constructor(
private readonly userRepo: UserRepository,
) { }
findById = new DataLoader<number, User>(
async (authorIds: number[]) => {
const user: User[] = await this.userRepo.findByIds(authorIds);
return authorIds.map((id: number) => user.find((user: User) => user.id === id));
}
);
}
@Injectable({ scope: Scope.REQUEST })
export class CommentLoader {
constructor(
private readonly commentRepo: CommentRepository,
) { }
findByPostId = new DataLoader<number, Comment[]>(
async (postIds: number[]) => {
const comments: Comment[] = await this.commentRepo.findByPostIds(postIds);
return postIds.map((id: number) => comments.filter((comment: Comment) => comment.postId === id));
}
)
}
DataLoader를 사용하면 각 요청이 배치로 처리되어 쿼리 수가 대폭 줄어든다.
@Resolver(of => Post)
export class PostResolver {
constructor(
private postService: PostService,
private userLoader: UserLoader,
private commentLoader: CommentLoader,
) { }
@Query(() => [Post])
posts(): Promise<Post[]> {
return this.postService.findAll();
}
@ResolveField(() => User)
author(@Parent() post: Post): Promise<User> {
console.log(this.userLoader.findById)
return this.userLoader.findById.load(post.authorId);
}
@ResolveField(() => [Comment])
comments(@Parent() post: Post): Promise<Comment[]> {
return this.commentLoader.findByPostId.load(post.id);
}
}
반면 DataLoader은 각 요청이 배치처리되어 필요할 때 한번에 데이터를 요청하기 때문에 쿼리 수가 대폭 줄어들게 된다.
- post 10개를 가져오는 쿼리
- post 각각의 author을 한 번에 가져오는 쿼리
- comments를 한 번에 가져오는 쿼리
DataLoader 인스턴스가 생성되고 내부적으로 요청을 수집한다. 아래의 로그는 posts에 대한 각 author들의 정보를 받아오는 DataLoader의 인스턴스 로그이다. load(1), load(2)와 같은 함수 호출에서 DataLoader의 배치된 요청들은 tick에서 한 번에 처리하여 데이터베이스에 fetch요청을 보낸다.
DataLoader {
_batchLoadFn: [AsyncFunction (anonymous)],
_maxBatchSize: Infinity,
_batchScheduleFn: [Function (anonymous)],
_cacheKeyFn: [Function (anonymous)],
_cacheMap: Map(0) {},
_batch: null,
name: null
}
DataLoader {
_batchLoadFn: [AsyncFunction (anonymous)],
_maxBatchSize: Infinity,
_batchScheduleFn: [Function (anonymous)],
_cacheKeyFn: [Function (anonymous)],
_cacheMap: Map(1) { 1 => Promise { <pending> } },
_batch: { hasDispatched: false, keys: [ 1 ], callbacks: [ [Object] ] },
name: null
}
DataLoader {
_batchLoadFn: [AsyncFunction (anonymous)],
_maxBatchSize: Infinity,
_batchScheduleFn: [Function (anonymous)],
_cacheKeyFn: [Function (anonymous)],
_cacheMap: Map(2) { 1 => Promise { <pending> }, 2 => Promise { <pending> } },
_batch: {
hasDispatched: false,
keys: [ 1, 2 ],
callbacks: [ [Object], [Object] ]
},
name: null
}
(...생략...)
DataLoader {
_batchLoadFn: [AsyncFunction (anonymous)],
_maxBatchSize: Infinity,
_batchScheduleFn: [Function (anonymous)],
_cacheKeyFn: [Function (anonymous)],
_cacheMap: Map(10) {
1 => Promise { <pending> },
2 => Promise { <pending> },
3 => Promise { <pending> },
4 => Promise { <pending> },
5 => Promise { <pending> },
6 => Promise { <pending> },
7 => Promise { <pending> },
8 => Promise { <pending> },
9 => Promise { <pending> },
10 => Promise { <pending> }
},
_batch: {
hasDispatched: false,
keys: [
1, 2, 3, 4, 5,
6, 7, 8, 9, 10
],
callbacks: [
[Object], [Object],
[Object], [Object],
[Object], [Object],
[Object], [Object],
[Object], [Object]
]
},
name: null
}
아래는 DataLoader의 load 함수다. 요청을 배치로 묶어 처리하고, 캐싱을 통해 중복을 방지하는 역할 또한 수행한다.
코드를 간략히 살펴보면, 배치를 가져와 캐시 키를 생성하고 캐시의 활성 여부에 따라 promise를 재활용할지 생성할지를 결정해 캐시를 업데이트 후 promise를 반환한다. 그래서 위 예제에서, 1부터 10번까지의 key에 대해 배치를 쌓아나갈 수 있던 것이다.
_proto.load = function load(key) {
if (key === null || key === undefined) {
throw new TypeError('The loader.load() function must be called with a value, ' + ("but got: " + String(key) + "."));
}
var batch = getCurrentBatch(this);
var cacheMap = this._cacheMap;
var cacheKey = this._cacheKeyFn(key); // If caching and there is a cache-hit, return cached Promise.
if (cacheMap) {
var cachedPromise = cacheMap.get(cacheKey);
if (cachedPromise) {
var cacheHits = batch.cacheHits || (batch.cacheHits = []);
return new Promise(function (resolve) {
cacheHits.push(function () {
resolve(cachedPromise);
});
});
}
} // Otherwise, produce a new Promise for this key, and enqueue it to be
// dispatched along with the current batch.
batch.keys.push(key);
var promise = new Promise(function (resolve, reject) {
batch.callbacks.push({
resolve: resolve,
reject: reject
});
}); // If caching, cache this promise.
if (cacheMap) {
cacheMap.set(cacheKey, promise);
}
return promise;
}
이벤트 루프와 태스크 큐에서의 DataLoader 처리
DataLoader은 Node의 이벤트 루프와 태스크 큐를 활용하여 요청을 배치로 처리한다.
Promise는 다음 tick에서 실행되므로 배치 처리는 여러 요청을 수집한 후 다음 tick에서 한 번에 처리할 수 있게 된다.
참조
2023.04 ~ 백엔드 개발자의 기록
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!