본문 바로가기

javascript/이외

[graphql] graphql N+1 문제와 prisma ORM의 해결 방식

Query optimization | Prisma Docs

 

Query optimization

How Prisma optimizes queries under the hood

www.prisma.io


 graphql은 resolver을 적절히 연결하고, 이를 결합하여 한번의 요청으로 많은 데이터를 가져올 수 있다. 이때 resolver이 동작하는 과정에서 N+1 문제가 발생하므로, 이를 처리하기 위한 적절한 방법이 요구된다.

N+1 문제

 1번의 쿼리를 이용하여 A 엔티티를 N개 가진 배열을 읽어왔을 때, A엔티티에 대한 연관 관계 B 엔티티를 가져오기 위해 N번의 쿼리를 추가로 수행하는 상황이다. 간단하게 생각하면, 아래처럼 동작하는 상황이 N+1이라고 볼 수 있다.

const users = db.user.find();

for(const user of users) {
    user.posts = db.post.find({ where { userId: user.id } } );
}

 위 코드에서는 user 배열을 가져온 이후, user과 관련된 데이터를 for문을 순회하며 하나씩 가져온다. 사람이 직접 쿼리를 수행한다면 각각의 id 값을 비교하는 대신 한번에 모든 데이터를 가져올 수 있도록 where userId in ( ~ ) 과 같은 형식으로 코드를 작성하겠지만, ORM은 정의된 명령을 수행할 뿐이므로 명시한 코드대로 각 유저마다 하나의 쿼리를 보낸다.

fetching

용어를 정리하고 간다.

  • over-fetching
    : API를 호출할 때 필요한 것보다 많은 데이터를 가져오는 경우. 응답 상의 데이터 중 사용하지 않는 것이 존재한다. 클라이언트가 요구하는 것 이상의 데이터를 전송하므로, 통신 상에 리소스 낭비가 발생한다.
  • under-fetching
    : 하나의 API만으로는 충분한 데이터를 받지 못해 여러 번의 요청을 수행해야 하는 경우. REST API는 단일 요청에 대해 한 종류의 객체만 받도록 설계되므로, 전체 데이터를 위해 여러 번의 호출이 필요할 수 있으며, 이 경우 단일 호출에 비해 네트워크가 지연될 수 있다.

두 경우 모두 이상적인 방향(필요한 데이터만 요청 / API 요청 최소)과는 거리가 멀다. graphql은 두 문제를 해결한다.

N+1 문제

 인간이 생각하는 것처럼 WHERE ~  IN 문법이나 JOIN 등에 대응되는 쿼리를 통해 데이터를 한번에 가져오면 된다. 그런데, 모든 상황에 대해 데이터를 한번에 가져오는 방법이 유효한 것은 아니다.

 graphql의 경우 필요한 데이터를 한번에 가져오기는 조금 어려운 구조이다. 다음 쿼리를 생각해보자.

query Query {
    users {
        id
        name
        posts {
            id
            content
        }
    }
}

쿼리에 정의된 각 필드에 대해 대응되는 resolver이 동작한다. 특정 필드에 대해 따로 resolver을 등록하지 않으면 default resolver이 동작하여, 정의된 필드가 존재하는지 여부 정도를 검사하게 된다.

https://www.apollographql.com/docs/apollo-server/data/resolvers/#default-resolvers

 위 정의된 users이하 posts 역시 resolver에 의해 별도로 데이터를 가져올 수 있도록 정의되어야 하며, user에서 요구하지 않으면 관련된 데이터를 가져와서는 안된다. 즉, user 데이터를 데이터베이스에서 읽어올 때 posts 정보까지 전부 가져오도록 처리하는 것은 좋은 방법이 아니다. 위와 같은 흐름에 따라 posts 리졸버를 다음과 같이 작성했다고 하자.

  const postsResolver = async (parent, args, ctx) => {
    return await ctx.db().post.findMany({
      where: {
        userId: parent.id
      }
    });
  };

 postsResolver은 유저 각각마다 동작하여 데이터베이스에 쿼리를 전달하므로, N명의 유저가 있다면 총 N번의 요청을 보내게 된다. 이렇게 가져온 M개의 post에 대해 댓글 정보를 추가적으로 가져와야 한다면? 총 N + M + 1번의 쿼리가 발생할 것이다. 이런 사실만 보더라도 엔티티 단위로 쿼리를 진행하는 방식은 매우 비효율적이며, 시스템 규모와 고객층이 확장됨에 따라 데이터베이스에 지나친 부하를 가할 수 있다.


GraphQL에 대한 N+1 문제 해결 방법

 기존 방식대로는 N+1 문제가 발생한다는 것은 알겠다. 그렇다면 어떻게 이를 최적화할 수 있을까?

https://www.npmjs.com/package/dataloader

 

dataloader

A data loading utility to reduce requests to a backend via batching and caching.. Latest version: 2.2.2, last published: 9 months ago. Start using dataloader in your project by running `npm i dataloader`. There are 1346 other projects in the npm registry u

www.npmjs.com

dataloader 모듈은 batch를 통해 N+1 문제를 해결한다. resolver 각각마다 요청하던 쿼리를 한 군데로 모아 한번만 처리한 후, 얻은 데이터를 분배하는 방식으로 쿼리 횟수를 줄이자는 것이다. 내부적으로 nodejs의 event loop를 활용, 단일 틱 내에 발생한 개별적인 요청을 모아서 처리한 후 다시 분배하여 데이터를 제공한다.

아래는 공식 문서에서 긁어온 코드이다.

const DataLoader = require('dataloader');

async function batchFunction(keys) {
  const results = await db.fetchAllKeys(keys);
  return keys.map(key => results[key] || new Error(`No result for ${key}`));
}

const loader = new DataLoader(batchFunction);

const user = await userLoader.load(1);
const invitedBy = await userLoader.load(user.invitedByID);

const user = await userLoader.load(2);
const lastInvited = await userLoader.load(user.lastInvitedID);
  • batchFunction: 키 목록을 받고, 대응되는 데이터를 제공한다. 데이터가 존재하지 않는 경우 null로 채워 keys 배열과 길이가 같은 결과 배열을 반환해야 한다.
  • DataLoader: 키 값을 모아 배치를 수행하고, 데이터를 분배해주는 클래스
  • userLoader.load: 키 값을 전달하고 대응되는 데이터를 제공받기 위한 메서드

위에 작성했던 코드를 dataLoader을 이용하여 표현하면 대략 아래와 같다.

const DataLoader = require('dataloader');

async function postsBatchFunction(keys) {
  const results =  ctx.db().post.findMany({
      where: {
        id: {
          in: keys
        }
      }
    });
    
  return keys.map(key => results.find(it => it.userId === key));
}

const loader = new DataLoader(postsBatchFunction);

const postsResolver = async (parent, args, ctx) => {
    return await loader.load(parent.id);
  };

Prisma와 GraphQL N+1 해결법

 

prisma client에는 위에서 보인 dataloader 기능이 내장되어 있어, 간단한 문법만으로도 GraphQL에서 발생하는 N+1 문제를 해결할 수 있다.

https://www.prisma.io/docs/guides/performance-and-optimization/query-optimization-performance#solving-n1-in-graphql-with-findunique-and-prismas-dataloader

 

Query optimization

How Prisma optimizes queries under the hood

www.prisma.io

 이 글을 쓰는 시점에서 findUnique 메서드를 이용하면 배치를 통해 데이터를 가져온다고 한다. 배치를 통해 데이터를 동시에 읽어오기 위해서는 아래 조건을 만족해야 한다.

  1. 요청이 동일한 tick 내에 정의되어야 한다.
  2. 요청은 동일한 where / include 옵션을 가져야 한다.

작성한 코드 예시는 다음과 같다.

export const userResolver: UserResolvers = {
  posts: async (parent, args, ctx) => {
    return await ctx.db().user.findUnique({
      where: {
        id: parent.id
      }
    }).posts();
  },
};

정말 findUnique를 사용하는 것만으로도 N+1 문제가 해결될까 의심되어 코드를 실행하고 결과를 살펴보았다.

findMany 메서드를 이용한 경우

findMany 방식으로 처리한 결과는 전형적인 N+1 문제를 보여주고 있다.

findUnique 메서드를 이용한 경우

findUnique 메서드를 이용하였더니, 신기하게도 내부적으로 IN 쿼리로 변경하여 처리하는 모습을 볼 수 있다. N+1 문제가 발생한 상황에 비해 더 좋은 결과를 보여주고 있다.


결론

  • graphql에서 N+1 문제를 회피하기 위해 dataloader 라이브러리 사용을 고려할 수 있다.
  • prisma를 사용하는 경우, findUnique 메서드를 사용하면 자체적인 배치 작업을 통해 N+1 문제를 회피한다.

 어플리케이션에 N+1 문제 등 다양한 비효율이 발생할 수 있음을 인지하고, 이를 적절한 방법으로 처리하는 것이 중요하다는 것을 느꼈다. ORM을 통한 명령이 실제로는 어떻게 처리되고 있는지 파악하고, 이를 최적화하기 위해 노력해야겠다.

 개인적으로 prisma ORM의 사용성이 좋아서 선호하는 편인데, graphql 측면에서도 편리한 기능을 제공하고 있다는 사실을 알데 되니 더 애착이 생긴다.