Computer Science/GraphQL

Graphql + DataLoader를 이용한 N+1문제 해결

nayoon030303 2021. 10. 24. 00:54

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에 대해서도 더 자세히 알아보겠습니다.