thumnail.png

일단, DataLoader가 뭔지 설명하기 전 GraphQL에서 발생할 수 있는 n+1 문제를 이해해야 한다.

n+1 문제란?

query {
	posts {
		id
		title
    contents
    user {...}
	}
}

다음과 같이 posts라는 데이터를 조회하는 쿼리가 있을 때, posts 데이터를 가져오는 쿼리 1개, 그리고 각각의 posts 별로 가지고 있는 user라는 데이터를 가져오는 쿼리가 n개가 필요하며 이를 N + 1 문제라고 한다.

즉, 내부 객체를 얻기 위해 외부 객체에서 N개의 쿼리를 만드는 경우를 말한다.

다음과 같은 N+1 문제를 해결하기 위해서는 보통 세 가지 구성 요소가 필요하다.

dataloader.png

  • Data Source: 일괄 가져오기를 지원
  • Data Loader: 요청을 batch
  • Data Fetcher: 컨텍스트를 전달

이제 이 구조에서 사용하는 DataLoader에 대해서 알아보자

DataLoader란?

DataLoader는 Facebook에서 개발했으며 일괄 처리 및 캐싱을 통해 데이터베이스 또는 웹 서비스와 같은 다양한 원격 데이터 소스 요청에 대한 비용을 줄이는 기능을 한다. 즉, 성능 최적화를 위한 도구이다.

DataLoader는 크게 2가지 특징을 가지고 있다.

batch

n+1에 대한 문제를 해결해 주는 것이 batch이다. DataLoader는 특정 데이터를 가져오기 위한 각각의 요청을 batch(모아서) 한번의 요청으로 보내주며 요청으로 받은 결과 값들을 각각에 요청에 맞게 보내준다.

cache

모든 요청을 캐싱하는 것이 아닌 사용자의 단일 요청별로 생성된다. cache에 대한 내용은 설정으로 변경이 가능하며 DataLoader의 instance는 자체적으로 cacheMap 을 가지고 있어 같은 key에 대한 요청이 들어오면 caching된 값을 사용하게 된다. 하지만 web application에서 이런방식은 메모리가 끊임없이 늘어나 위험할 수 있으므로 요청마다 새로운 DataLoader 객체를 생성해서 사용하는것을 권장하고 있다.

구현

const { ApolloServer, gql } = require('apollo-server');
const DataLoader = require('dataloader');

const typeDefs = gql`
  type User {
    id: Int
    name: String
    age: Int
  }

  type Post {
    id: Int
    title: String
    contents: String
    user: User
  }

  type Query {
    posts: [Post]
  }
`;

const users = [
  {
    id: 1,
    name: 'chan',
    age: 19,
  },
  {
    id: 2,
    name: 'yeong',
    age: 20,
  },
  {
    id: 3,
    name: 'cho',
    age: 21,
  },
];

const posts = [
  {
    id: 1,
    title: 'test post title 1',
    contents: 'test content',
    user: 1,
  },
  {
    id: 2,
    title: 'test post title 2',
    contents: 'test content',
    user: 2,
  },
  {
    id: 3,
    title: 'test post title 3',
    contents: 'test content',
    user: 3,
  },
  {
    id: 4,
    titles: 'test post title 4',
    content: 'test content',
    user: 3,
  },
  {
    id: 5,
    title: 'test post title 5',
    contents: 'test content',
    user: 1,
  },
];

const resolvers = {
  Query: {
    posts: () => {
      return posts;
    },
  },
};

const server = new ApolloServer({ typeDefs, resolvers });

server.listen().then(({ url }) => console.log(`server ready at ${url}`));

우선 기본적인 Apollo Server를 구현했는데 처음에 소개한 예시와 같이 posts 데이터들과 그 안에 id 값을 가지고 있는 user 프로퍼티를 선언해 놓았다. 이제 선언만 해 놓은 posts resolver에 직접 DataLoader를 적용하며 구현을 해보려고 한다.

// Data Source
// 유저들의 id 배열을 받아와 한번에 조회
const getUsers = (ids) => {
  return new Promise((res) => res(users.filter((user) => ids.includes(user.id))));
};

우선 데이터들을 일괄적으로 가져와야 하는 Data Source이다. 실제로는 DB나 아니면 다른 데이터 요청의 일괄 가져오기를 사용하겠지만 간단하게 구현해보는 용도이므로 기존의 데이터를 filter해서 가져왔다.

만약 실제 db에서 데이터를 가져오려면 or연산을 통해 해당되는 id를 가지고 있는 값을 모두 조회하면 될것이다.

// batch function
// 각각의 dataloader.load로 가져온 key들을 배열로 받아 데이터 요청
const batchGetUser = async (keys) => {
  const _users = await getUsers(keys);
  const usersMap = {};
  _users.forEach((user) => (usersMap[user.id] = user));
  return keys.map((id) => usersMap[id] || null);
};

이제 DataLoader에서 동작시킬 batch함수를 만들어야 한다. 다음과 같이 Data Source에서 데이터를 일괄적으로 가져온 뒤 usersMap이라는 객체를 만들어 각 id를 통해 바로 유저 데이터에 접근할 수 있는 변수를 만들고 batch함수로 들어온 keys(id 목록)를 map으로 순회시키며 id가 들어온 순서대로 알맞는 유저 데이터들을 반환 시켜준다.

여기서 주의할 점은 keys로 들어온 id들의 순서와 내보내줘야 하는 users의 순서가 일치해야 하고, 개수또한 중요하다. 그렇기 때문에 반환시켜 줄 때 해당 데이터가 없으면 위의 코드처럼 null값을 넣어서라도 순서와 개수를 일치시켜줘야 한다.

// dataloader
const userLoader = new DataLoader(batchGetUser);

const resolvers = {
  Query: {
    // Data Fetcher
    posts: async () => {
      const copiedPosts = JSON.parse(JSON.stringify(posts));
      const result = copiedPosts.map((post) => {
        post.user = userLoader.load(post.user);
        return post;
      });

      return result;
    },
  },
};

이제 만든 batch함수를 DataLoader에 등록해 userLoader라는 DataLoader를 만들고 posts라는 query를 요청했을 때 해당 DataLoader를 통해 user데이터를 가져오도록 구현했다. userLoader.load 메서드에 post 객체에 있던 유저의 id값을 넣어주면 dataloader는 받은 id값들을 모두 모아 단 한번의 요청으로 만들어 batch함수로 전달한다.

물론 클라이언트의 한번의 요청안에 들어있는 모든 user들을 조회하는 요청을 하나로 모아주는 것은 아니다. DataLoader는 JavaScript의 Event Loop의 특성을 이용해 Event Loop의 한 tick을 기준으로 모든 동작을 합쳐서 한번의 동작으로 해결해준다. 즉, Event Loop가 실행되는 시기가 다른 비동기 depth를 가지고 있을 경우 여러번 요청이 이루어 질 수 있다.

하지만 userLoaderload는 사용자의 단일 요청별로 가져온 데이터들을 캐싱하고 있으므로 동일한 유저의 데이터를 조회하는 경우에는 추가적인 연산을 하지 않는다.

여기서 JSON.parse(JSON.stringify())를 사용한 이유는 데이터를 메모리에 있는 객체를 그대로 가져와 사용했끼 때문에 값이 변경되는 것을 막기 위해 deep copy하는 용도로 사용했다. 그러니 신경쓰지 않아도 된다.

// Data Source
// 유저들의 id 배열을 받아와 한번에 조회
const getUsers = (ids) => {
  return new Promise((res) => res(users.filter((user) => ids.includes(user.id))));
};

// batch function
// 각각의 dataloader.load로 가져온 key들을 배열로 받아 데이터 요청
const batchGetUser = async (keys) => {
  const _users = await getUsers(keys);
  const usersMap = {};
  _users.forEach((user) => (usersMap[user.id] = user));
  return keys.map((id) => usersMap[id] || null);
};

// dataloader
const userLoader = new DataLoader(batchGetUser);

const resolvers = {
  Query: {
    // Data Fetcher
    posts: async () => {
      const copiedPosts = JSON.parse(JSON.stringify(posts));
      const result = copiedPosts.map((post) => {
        post.user = userLoader.load(post.user);
        return post;
      });

      return result;
    },
  },
};

모아서 보면 다음과 같은 구조이다. DataLoader가 n개의 쿼리인 users 데이터 조회를 하나의 요청으로 처리해주기 때문에 N + 1 문제를 해결할 수 있게 된다.

결론

요약하자면 DataLoader는 batch함수를 통해 여러 요청의 key값을 받아 or연산을 통해 단 한번만 요청을 할 수 있도록 해 성능 최적화를 적용하고, 이 과정에서 요청보낸 값들은 캐싱해놓는다.

아주 간단한 프로젝트가 아니라면 성능 최적화를 위해 GraphQL을 사용하는 여러 서비스에서 DataLoader를 사용하지 않을 이유가 없을 것 같다.

물론 DataLoader는 GraphQL에 의존하는 녀석이 아니기 때문에 굳이 GraphQL이 아니더라도 다른 요청들을 단일화하는 목적의 최적화에 사용할 수 있다.

본 포스트는 다음 문서를 참고해 작성했습니다.

https://github.com/graphql/dataloader

https://dev.to/mahendranv/graphql-backend-data-loaders-1hlp

https://y0c.github.io/2019/11/24/graphql-query-optimize-with-dataloader/