Graphql + DataLoader를 이용한 N+1문제 해결
DataLoader
Graphql에서는 N+1 문제를 해결하기 위한 방법인 DataLoader에 대해서 소개를 하겠습니다. 또 Graphql에서 DataLoader를 어떤 방법으로 적용해야 하는지 정리해 보려고 합니다.
N+1 문제
N+1 문제란 성능에 관한 문제 중 하나로 주로 관계형 데이터베이스에서 1:N 관계를 가지는 테이블에서 일어나는 문제입니다.
데이터베이스에 Author 테이블과 Post테이블이 있다고 가정해봅시다. 한 Author는 여러 개의 Post를 작성할 수 있습니다. 1:N 관계를 형성합니다. 이때, 모든 Author의 모든 Post를 가져오고자 한다면,
SELECT * FROM Author;
먼저, 모든 Author를 가져온 후, n개의 author에 대해서 post를 가져올 수 있습니다.
SELECT * FROM Post WHERE author_id = ?;
n개의 Author들을 가져오는 쿼리가 1번 실행됩니다. 그리고 n명의 author에 대한 Post를 가져오는 쿼리가 n번 실행됩니다. 즉 쿼리가 N+1번만큼 실행됩니다.
이것이 N+1 문제로, 위처럼 작성하면 성능 측면에서 지극히 비효율적이지만 로직을 이해하기는 쉽다는 장점이 있습니다.
그렇지만 이렇게 하위 엔티티들을 첫 쿼리 실행 시 한 번에 가져오지 않고, Lazy Loading으로 불러오기에 필요한 곳에서 사용되어야 할 때 쿼리가 실행되는 문제가 생깁니다.
위 N+1문제를 where ~ in
을 사용하거나, join
을 상용하여 하나의 쿼리로 해결할 수 있습니다.
const resolver = {
Query:{
posts: async()=>{
const authors = await Post.find({relations:['posts'})
return authors;
}
}
}
하자만 위와 같이 resolver를 구성한다면 query에서 posts 필드를 요청하지 않아도 join 해서 data를 가져오게 됩니다. client가 받는 데이터는 overfetching이 일어나지 않지만 실제 데이터를 load 하는 곳에서 overfetching이 일어나게 됩니다.
Graphql에서의 N+1 문제
내부적으로 authors쿼리는 모든 authors를 데이터베이스에서 가져오고, authors의 post필드는 author의 id를 받아 post를 가져오는 쿼리가 될 것입니다. 결국 위에서 말한 N+1문제가 일어나게 됩니다 이를 해결해 줄 수 있는 것이 Dataloader입니다.
Dataloader
Dataloader는 data fetch 할 때 나타나는 N+1 문제를 batching(일괄 처리)을 통해 1+1로 변환해주는 라이브러리입니다.
Dataloader는 Graphql에서 자주 쓰이지만, 다른 상황에서도 쓰일 수 있습니다. 기본 개념은 데이터베이스에 대한 요청을 Batching(일괄처리)과 Cacing(캐싱)하는 것입니다.
이 중 N+1문제를 해결하는 것은 Batching입니다. Dataloader는 같은 데이터베이스에 대한 각각의 요청을 한 번에 모아서 요청을 보냅니다.
Dataloader는 javascript의 event-loop를 이용합니다. 주요 기능은 batching은 event-loop 중 하나의 trick에서 실행된 data fetch에 대한 요청을 하나의 요청으로 모아서 실행하고 그 결과를 다시 알맞게 분배하는 역할을 합니다.
const DataLoader = require("dataloader");
// 임시 data
const posts = [
{ id: 1, title: "test1" },
{ id: 2, title: "test2" },
{ id: 3, title: "test3" },
{ id: 4, title: "test4" },
{ id: 5, title: "test5" },
];
// fake db operation
const findAllPosts = () =>
new Promise((resolve) => {
setTimeout(() => {
resolve(posts);
}, 100);
});
// batchLoadFn 의 결과는 promise여야 한다.
const batchLoadFn = async (keys) => {
const results = await findAllPosts();
console.log(keys);
console.log("🐶");
// db 에서 받아온 결과를 요청온 key에 mapping
return keys.map((k) => results.find((p) => p.id === k));
};
const postLoader = new DataLoader(batchLoadFn);
// tick 1
postLoader.load(1).then(console.log);
postLoader.load(2).then(console.log);
// tick 2
setTimeout(() => {
postLoader.load(3).then(console.log);
postLoader.load(4).then(console.log);
}, 100);
Dataloader의 constructor는 batch요청을 어떻게 처리할지에 대한 함수를 인자로 받습니다. 함수의 역할은 하나의 tick에서 들어온 post들에 대한 요청을 모아서 하나의 요청을 만듭니다.
setTimeout은 event-loop 상에 하나의 tick에서 실행되지 않고 다음으로 실행을 미루게 됩니다.
위 코드의 결과는 아래와 같습니다.
sub-resolver에 대해서 공부하다가 dataloader에 대해서 알게 되었는데 다음번에는 graphql에서 실제 사용해보고 eventloop에 대해서도 더 자세히 알아보겠습니다.