본문 바로가기
graphql

[GraphQL] GraphQL와 N+1

by 박연호의 개발 블로그 2021. 9. 26.

N+1문제는 orm을 사용하면 빈번하게 마주하는 입니다. N+1이 무엇이고, 어떻게 해결할 수 있는지는 다른 많은 블로그에서 참고할 수 있습니다.

 

이 글에서는 그것보다는 GraphQL을 사용하는 유저로서 GraphQL의 특징과 연결지어 N+1을 설명하고 왜 문제가 되는지, 어떻게 해결할 수 있는지 알아 보겠습니다.


GraphQL resolver는 데이터를 어떻게 처리할까

기존의 rest api의 경우 /users 엔드 포인트에 요청을 보내면 원하든 원하지 않든 항상 유저와 유저의 게시글을 가져오게 됩니다. 이를 over fetching이라고 하죠. 비슷하게 하나의 엔드 포인트 요청으로 원하는 데이터를 못가져오게 되어 여러번 요청((under fetching))을 보내야 합니다.

 

이와는 다르게 Graphql는 클라이언트에서 원하는 데이터를 입맛대로 조회할 수 있습니다. 이는 클라이언트와 서버가 서로 GraphQL Schema로 의사소통 하기 때문입니다. 이때 GraphQL schema를 정의하는 언어가 SDL(Schema Definition Language)이라고 합니다.

 

 type User {
    id: Int
    name: String
    posts: [Post]
  }

  type Post {
    id: Int
    content: String
    author: User
    authorId: Int
  }

  type Query {
    users: [User]
  }

이렇게 서버와 클라이언트 사이의 하나의 interface를 만들고, 클라이언트는 SDL을 보고 원하는 데이터를 가져올 수 있습니다. 사실 SDL만 정의한다고 아무일도 일어나지 않습니다. SDL은 그냥 껍데기일 뿐이며 클라이언트의 요청을 실제로 처리하는 부분은 resolver 이며, resolver는 type의 각 필드에 존재합니다.

 

현재 DB에는 4명의 유저가 있으며, 각각의 유저는 5개의 게시글을 가지고 있습니다.

const resolvers = {
  Query: {
    users: async (parent, args, ctx, info) => {
      return prisma.user.findMany({})
    },
  },
  User: {
    posts: async (parent, args, ctx, info) => {
      return prisma.post.findMany({
        where: {
          authorId: parent.id,
        },
      })
    },
  },
}
query{
  users {           
    posts {
      content        
    }
  }
}

 

여기서 재밌는 점은 users query에서는 유저만 조회했을 뿐 게시글은 같이 조회하지 않았다는 것입니다. 그럼에도 클라이언트에서 유저의 게시글을 받아볼 수 있는 것은 posts field를 같이 질의했기 때문입니다.  클라이언트에서 받아보는 게시글은 실제로 users field가 아닌 posts field에서 데이터를 가져올 수 있습니다.

 

그렇다면 반대로, 아래처럼 클라이언트에서 질의를 한다면 post field resolver가 실행되지 않을 것입니다.

query{
  users {         
    name
  }
}

만약 rest api의 경우 /users에서 항상 유저의 게시글을 조회해야 보낸다면, 클라이언트에서는 필요하지도 않은 게시글을 항상 받아보게 될 것입니다. 하지만 GraphQL의 경우 클라이언트에서 입맛대로 질의하면 그에 맞게 데이터가 오기 때문에 데이터에 대한 주도권이 프론트에 있습니다.

 

기존의 rest api의 경우 서버에서 고정된 데이터를 보냈기 때문에 endpoint마다 반환되는 데이터가 항상 고정되었습니다. 반면에 GraphQL의 경우 질의를 통해 내가 원하는 데이터를 받아볼 수 있으며, 이 데이터는 각각의 field에 정의된 resolver 함수에 의해 가져오게 됩니다.

users query

user.posts
user.posts
user.posts
user.posts

 

users resolver에서 모든 유저를 조회했고, 각각의 User type의 posts field에서 유저의 게시글을 조회했기 때문에 위처럼 로그가 출력되게 됩니다. 각각의 유저마다 posts field resolver가 실행되는 것이 비효율적인 것 같아 보여도, 이렇게 동작하는게 GraphQL 입니다.


Graphql와 N+1 문제

위에서 GraphQL이 어떤식으로 클라이언트의 요청을 처리하는지 봤습니다. 근데 이게 좀 비효율적으로 동작합니다. 각각의 resolver에서는 prisma를 사용하여 DB를 조회하고 있는데, 이때 실행되는 쿼리를 한번 보겠습니다.

prisma:query SELECT `test`.`User`.`id`, `test`.`User`.`name` FROM `test`.`User` WHERE 1=1
prisma:query SELECT `test`.`Post`.`id`, `test`.`Post`.`content`, `test`.`Post`.`authorId` FROM `test`.`Post` WHERE `test`.`Post`.`authorId` = ?
prisma:query SELECT `test`.`Post`.`id`, `test`.`Post`.`content`, `test`.`Post`.`authorId` FROM `test`.`Post` WHERE `test`.`Post`.`authorId` = ?
prisma:query SELECT `test`.`Post`.`id`, `test`.`Post`.`content`, `test`.`Post`.`authorId` FROM `test`.`Post` WHERE `test`.`Post`.`authorId` = ?
prisma:query SELECT `test`.`Post`.`id`, `test`.`Post`.`content`, `test`.`Post`.`authorId` FROM `test`.`Post` WHERE `test`.`Post`.`authorId` = ?

처음 모든 유저를 조회하는 쿼리가 1번 실행되고, 4명의 유저가 조회되었습니다. 이후 각각의 유저 마다 게시글을 조회하는 쿼리가 총 4번 실행되고 있습니다.

 

지금은 4명의 유저이지만, 만약 유저가 10000이 존재한다면 각각 유저의 게시글을 조회하는 쿼리 또한 10000번이 실행되며, DB에 성능적으로 무리가 가게 됩니다.

 

이를 N+1문제라고 합니다. N명의 유저를 조회할 때 각각의 유저마다 추가적으로 게시글(1)을 조회해야 하기 때문 입니다. 

 

근데, 생각해보면... 굳이 각각의 유저마다 따로 게시글을 조회해야 하나 ? 그냥 where in으로 한번에 조회하면 안되나? 라는 생각이 들게 됩니다. 저 또한 그랬구요. 

select * from post where authorId in('1','2','3','4');

네, 당연히 그렇게 하면 됩니다. 하지만 위에서 살펴 보았듯이 각각의 유저마다 posts field resolver가 실행되기 때문에 각 유저에 해당하는 게시글만 조회할 수 있습니다.

 

정리하면, posts field resolver는 각각의 유저마다 실행되기 때문에 1000명의 유저를 조회할 때 각각의 유저마다 추가적으로 게시글을 조회하는 쿼리가 필요하며 이를 N+1 문제라고 합니다. 각각의 유저 게시글을 조회하는게 아닌 where in으로 게시글을 한번에 조회할 수 있지만 GraphQL 설계상 그렇게 할 수 없습니다.


DataLoader

위에서 where in으로 게시글 한번에 조회하면 되는거 아냐? 를 할 수 있는 것이 DataLoader입니다.

 

오해할 수도 있는데, DataLoader가 4번 실행되는 resolver를 1번만 실행되게 해주는 작업은 아닙니다. 각각의 resolver에서 DB조회를 여러번이 아닌 한번에 해주는 것이 DataLoader 입니다.

 

DataLoader에 보면 batch라는 말이 자주 나오는데, 그 뜻을 알아보면 다음과 같습니다.

async function batchPosts(authorIds: readonly number[]) {
  const posts = await prisma.post.findMany({
    where: { authorId: { in: authorIds.map((v) => v) } },
  })

  const result = authorIds.map((authorId) =>
    posts.filter((post) => post.authorId === authorId)
  )
  return result
}

const bookLoader = new DataLoader(batchPosts)

const resolvers = {
  Query: {
    users: async (parent, args, ctx, info) => {
      return prisma.user.findMany({})
    },
  },
  User: {
    posts: async (parent, args, ctx, info) => {
      return bookLoader.load(parent.id)
    },
  },
}
prisma:query SELECT `test`.`User`.`id`, `test`.`User`.`name` FROM `test`.`User` WHERE 1=1
prisma:query SELECT `test`.`Post`.`id`, `test`.`Post`.`content`, `test`.`Post`.`authorId` FROM `test`.`Post` WHERE `test`.`Post`.`authorId` IN (?,?,?,?)

 

Dataloader는 event loop의 특징을 사용하는데, load메서드에서 key(유저 아이디)값을 캐시하고 promise를 반환 합니다.

 

 

이후에 process.nextTick()의 인자로 dispatchBatch함수가 실행되게 됩니다(일괄처리 작업). 

 

DataLoader를 사용하여 처리 하였을 때 각각의 유저마다 게시글을 조회하는 대신, 게시글 조회를 일괄로 처리하기 때문에 N+1문제는 발생하지 않게 됩니다. 

 

실제로 쿼리를 보면, 기존에 유저 1번조회 + 각각의 유저마다 N번의 게시글을 조회하는 것이 아닌, 유저1번 조회 + where in으로 게시글 조회 총 2번의 쿼리만 실행되게 됩니다.


마무리

GraphQL resolver의 어떤 특징때문에 DataLoader를 사용해야 하는지 많은 사람들이 공감했으면 좋겠습니다. 이번기회에 DataLoader 코드를 봤는데 key를 cache하여 process.nextTick()을 사용하여 이벤트 루프 다음 틱 전에 일괄로 처리한다는 방법이 되게 인상적이였습니다. 

 

참고

- https://github.com/graphql/dataloader/blob/main/src/index.js

- https://well-balanced.medium.com/graphql%EC%97%90%EC%84%9C-n-1-%EC%BF%BC%EB%A6%AC-%EC%A0%90%EC%A7%84%EC%A0%81%EC%9C%BC%EB%A1%9C-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0-d758e925cc19

- https://medium.com/zigbang/dataloader%EB%A1%9C-non-graphql%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%ED%99%9C%EC%9A%A9%ED%95%98%EA%B8%B0-e6619010f60b