본문 바로가기

개발바닥/GraphQL

[개발] GraphQL - DataLoader로 최적화 하기

안녕하세요 devport 입니다. 이번 포스팅에서는 GraphQL을 운영할 시에 필연적으로 발생하는 N+1문제를 해결 할 수 있는 DataLoader에 대해서 알아보도록 하겠습니다. GraphQL을 사용하고 어느정도 규모의 서비스를 운영하실 계획이라면 DataLoader 기법을 적용하는 것은 필수라고 생각합니다. 

 

GraphQL의 구조적인 문제로 인한 "N+1 문제"

GraphQL은 데이터베이스로 데이터를 처리할때에 편리한 기능들을 제공하고 있습니다. 하지만 대량의 데이터를 가져오는데에 연쇄 리졸버로 연관된 데이터를 쿼리를 하게된다면 어떻게 될까요? 쿼리에 대한 데이터가 N개이고 또 내부의 리졸버가 N번 만큼 발생하여 데이터베이스에 쿼리를 하게되면 발생하는 쿼리의 수는 N번 만큼 발생하게 될겁니다. 이러한 현상을 "N+1 문제"라고 합니다.

 

아래는 이해를 돕기위한 GraphQL의 동작 예제입니다. 

const typeDefs = gql`
  type Author {
    id: ID
    name: String
    books: [Book]
  }
  type Book {
    id: ID
    title: String
    author: String
  }
  type Query {
    allAuthor: [Author]
  }
  `
  
  const resolvers = {
    Query: {
      allAuthor (root, args, ctx) {
          return ctx.mongo.Author.find()
      }
    },
    Author: {
      books (parent) {
      	console.log(`fetching book ${parent.id}`)
      	return ctx.mongo.Book.find({author: parent})
      }
    }
  }

스키마를 확인해 보시면 Author 내부에는 Book 타입에 대한 정보를 가지고 있습니다.

또한 books 리졸버가 호출될 때마다 console.log를 출력하도록 작성해두었습니다.

 

 

allAuthor 리졸버는 모든 저자의 항목을 요청하게 되는데 이를 실행하게 되면 5명의 저자에 대한 정보를 가져올 수 있도록 데이터가 존재한다고 하면

 

Author의 수는 5명으로 books 리졸버도 5번 호출이 되어 5번의 쿼리가 발생한다는 것을 알 수 있습니다. 5번정도의 쿼리는 성능상에 많은 영향을 끼치지 않지만 저자의 수가 증가 하는 만큼 비례하여 쿼리가 발생하게 됩니다.

 

최적화 기법 DataLoader

DataLoader는 N+1문제에서 발생하는 많은 수의 쿼리를 일괄 처리 및 캐싱 할 수 있도록 해주는 라이브러리 입니다. 

자세히 궁금한 부분은 Dataloader 레포지토리에서 확인 하시길 바랍니다.

저는 위 예제에서 dataLoader를 적용해 보도록 하겠습니다.

 

우선 dataloader 모듈을 프로젝트에 설치합니다.

npm install --save dataloader

books 리졸버에 dataloader를 구현해보겠습니다.

const resolvers = {
  Query: {
    allAuthor (root, args, ctx) {
        return ctx.mongo.Author.find()
    }
  },
  Author: {
    books (parent) {
      return await bookLoader.load(parent.name)
    }
  }
}

// dataloader
const bookLoader = new dataloader(function (keys) {
  return batchBooks(keys)
}, {
  cacheKeyFn: key => key.toString(),
  cache: false
})

// 일괄 처리
async function batchBooks (keys) {
  const bookList = await mongo.Book.find({author: {$in: keys})
  let index = {}
  bookList.forEach(book => {
    if (!index[book.author]) index[book.author] = []
    index[book.author].push(book)
  })
  return keys.map(key => {
    return index[key]
  })
}

위의 동작을 정리 해보자면 

 1. allAuthor에서 반환 되는 저자의 정보가 반환됩니다.

 2. 반환 된 저자의 정보만큼 books 리졸버가 호출되며 bookLoader.load에 입력되는 인자를 수집합니다.

 3. bookLoader에서는 수집된 키를 모아 batchBooks 함수를 호출합니다.

 4. batchBooks함수에서 수집된 키를 조회하고 반환된 항목을 bookLoader.load에 입력했던 키와 매치될 수 있도록 키 맵을 생성합니다.

 

위의 동작으로 인하여 여러번 쿼리해야 하던 로직이 단 한번 Database에 조회하게 되게 됩니다. 물론 dataloader 캐쉬까지 활성화 한다면 DB 조회의 횟수는 더욱 적어 질겁니다.