GraphQL에서 N+1 쿼리 점진적으로 개선하기

Woosik Kim
12 min readSep 1, 2022
source: https://dev.to/ldsrogan/graphql-dataloader-27k5

이번 게시글에서는 GraphQL에서 발생하는 N+1 문제와 이를 Dataloader로 해결하는 과정을 다뤄보려고 한다. 다만 Dataloader를 왜 사용해야 하는지에 초점을 맞춰서 이야기를 풀어나가보려고 한다. 마지막 지점의 소스코드는 필자의 GitHub에 올려두었으니 참고바란다.

N+1 Problem

닳고 닳은 예제지만 1:N 관계를 갖는 Post와 Comment가 있다고 가정해보면 GraphQL Schema는 아래와 같을 것이다.

작성한 스키마와 같이 posts를 가져올 수 있는 Resolver를 간단하게 만들어보면 아래와 같은 모습일 것이다.

이 때 comments 필드를 제외하고 요청을 보냈을 때는 Query 하위에 있는 posts 필드가 호출되면서 데이터베이스로 단일 쿼리만 들어가기 때문에 문제가 발생하지 않는다.

(물론 id 필드만 요청해도 모든 필드를 SELECT 해서 오버패칭이 발생하긴 하지만 이 부분은 N+1 문제와 밀접한 관련이 없기 때문에 생략하고 넘어가겠다)

하지만 comments 필드를 포함하여 보내면 어떻게 될까?

posts가 resolve되면 그 다음으로 comments가 resolve된다. GraphQL에서는 클라이언트가 요청한 쿼리에 적어둔 필드를 찾기 위해 필드를 nested하게 조회한다.

마치 이런 느낌?

주석으로 달린 로그에서 본 것처럼 Post를 요청하는 쿼리 1개와 Post의 수 N개만큼 쿼리가 날아가 N+1번의 추가적인 라운드 트립이 발생하여 큰 네트워크 통신 비용을 초래한다.

뿐만 아니라 HTTP 통신은 무상태성(Stateless)를 지니고 있기 때문에 DB 인스턴스에서는 A-Z까지 요청을 각각 처리한다. 즉, 쿼리 옵티마이저가 무수히 많은 실행계획을 줄 세우는 등 비효율적인 작업이 포함될 수 있다.

이는 아직 테스트 단계에 있거나 트래픽이 많지 않을 때는 성능 저하가 체감되지 않겠지만 조금만 트래픽이 늘어도 데이터베이스에 과부하가 걸리게 된다.

이 경우 트러블슈팅도 빡시다

prefetch로 가져오기

그렇다면 위와 동일한 GraphQL 쿼리로 요청했을 때 posts 필드에서 Eager하게 처리하는 방식으로 코드를 수정해보면 어떨까?

총 두 번의 쿼리 로그가 잡혔다.

  • 다수의 Post를 요청하는 쿼리
  • 해당 Post의 id 필드를 SQL의 in 오퍼레이터에 포함시켜 다수의 Comment를 가져오는 쿼리

참고로 필자가 사용하고 있는 Prisma ORM에서는 N+1 문제를 해결하기 위해 nested read 문법을 사용했을 때 단일 쿼리(Join)가 아닌 두 개의 쿼리(Where … in …)로 요청한다. ORM에 따라서 비슷한 문법이지만 쿼리를 만드는 과정에서 약간 다를 수 있음을 참고하기 바란다. 더불어 Prisma에서는 쿼리 최적화를 위해 몇 가지 가이드라인을 제공하고 있으니 관심있다면 Query optimization 파트에서 읽어보길 바란다

하지만 이는 완벽한 해결법이 아니다. 클라이언트 요청에 comments 필드가 포함되어 있지 않더라도 posts Resolver가 resolve 되는 시점에 항상 comments 데이터를 로드해서 오버패칭이 일어나기 때문이다.

info 객체 활용하기

그렇다면 클라이언트가 보낸 요청에 따라서 ORM 인자를 수정하면 되지 않을까? 다행히도 GraphQL 요청을 거쳐가는 Resolver Field에는 클라이언트가 요청한 필드를 확인할 수 있는 info 객체를 포함하고 있다.

info 객체는 현재 작업 실행 상태에 대한 모든 정보를 담고 있어서 약간 복잡한 구조로 구성된 객체이다. (필자는 전 회사에서 처음 GraphQL 서버 코드를 읽을 때 대체 뭐하는 놈인지 한참 헤맸다)

graphql-fields 라는 라이브러리는 info 객체로부터 클라이언트가 요청한 필드가 무엇인지 분석해서 우리가 보기 좋은 데이터 구조로 파싱해준다. 이 라이브러리를 활용해서 코드를 일부 수정해보자.

코드를 보면 Resolver Field의 4번째 인자인 info 객체로 클라이언트가 요청한 필드를 가져와 comments 필드 유무에 따라서 옵셔널하게 데이터를 가져온다. 이 경우 comments 필드 요청 여부에 따라 데이터베이스로 날아가는 쿼리의 내용이 달라진다.

위 코드 블록에서 보았다시피 comments 필드를 요청하지 않을 때는 추가적인 쿼리가 발생하지 않는다. 처음보다 상당히 많은 진전이 있다. 나는 사실 서버 어플리케이션에서 주로 정해진 리소스 중심으로 데이터를 서빙한다면(마치 REST API처럼) 이 정도만 해도 크게 복잡해지지 않는 선에서 많은 일을 할 수 있다고 생각한다.

하지만 이 방법은 관계의 뎁스가 깊어질수록 ORM에 들어가는 인자의 조건이 복잡해질 수 있다. 또한 다른 Resolver Field의 Scope에서 같은 리소스를 요청하는 상황이 온다면 info 객체를 활용하는 것에도 한계가 있다.

dataloader 활용하기

의도해서 조금 난감한 상황을 만들어보자. 그 후 dataloader로 문제를 해결할 것이다.

Post 자원을 가져오는 Query의 Resolver Field를 2가지로 나누어 좀 더 의미론적인 이름을 붙여주었다.

dataloader 없이 코드를 작성한다면 아래와 같을 것이다.

우리가 열어둔 모든 Resolver를 사용하는 GraphQL 쿼리를 하나 만들어보고 요청해보자.

총 4번의 쿼리가 날아갔고 내용은 아래와 같다.

  1. 최근 Post를 20개 조회하는 쿼리
  2. 1번에 해당하는 Comment를 조회하는 쿼리
  3. 조회수가 높은 순으로 Post 3개를 조회하는 쿼리
  4. 3번에 해당하는 Comment를 조회하는 쿼리

Post를 조회하는 부분은 조건이 다르니 그렇다치고, Comment를 조회할 때는 두 쿼리 모두 WHERE IN 절을 통해 리소스를 가져오는데 중복된 쿼리가 날아가고 있다.

사실 이런 케이스는 굉장히 흔하다. GraphQL은 클라이언트가 중심이 되는 API 스펙이다. 나는 클라이언트가 필요한 자원을 스스로 포매팅해서 만들지 않도록 리소스를 추상화하여 좀 더 상황에 알맞게 데이터를 전달하는 것이 GraphQL이 추구하는 바라고 생각한다. 어차피 단일 요청으로 해결이 되니까.

몇몇 서버 개발자들은 실제 Entity와 GraphQL Resolver를 일원화해서 사용하려고 하는데 이는 개인적으로 좋지 않은 시도라고 생각한다. 클라이언트에서 다양한 리소스를 가져오기 위해 단일 요청을 보냈을 때 큰 성능상의 이슈 없이 데이터를 서빙하는 것은 온전히 서버 개발자의 몫이다.

아니 온전히 내 몫..

자 그럼 다시 마주한 문제를 해결하기 위해 dataloader를 적용해보자.

npm 모듈인 dataloader는 GraphQL, ORM 등에서 발생하는 N+1 문제를 이벤트루프의 원리를 이용해 작지만 우아한 아이디어로 해결해주는 라이브러리이다. 실제로 GitHub에서 dataloader 소스를 보면 주석을 모두 포함하더라도 400줄 밖에 안된다. 그 중에서도 핵심 컨셉만 빠르게 파악하고 싶다면 여기에서 load, dispatchBatch 메소드만 확인하더라도 분명 많은 부분을 이해하는데 도움이 될 것이다.

먼저 dataloader를 사용하려면 DataLoader 클래스를 통해 인스턴스화 하는 과정에서 첫번째 인자로 배치 함수를 등록해줘야 한다. 이게 뭔 소린가 싶을 수도 있다. 하지만 일단은 너무 이해하려 하지 말고 따라와줬으면 한다.

배치 함수에는 key의 배열이 들어온다. 간단하게 pseudo 코드를 작성해보면 이렇다.

이처럼 배치 함수는 dataloader의 .load(key) 메소드를 통해 적재된 key 값의 배열이 인자로 들어오기로 약속되어 있다. 자 그럼 맨 처음에 우리가 다뤘던 예제를 다시 한번 살펴보자.

posts 필드를 통해 각각의 Post 가 Resolve 되고, Post 가 포함하고 있는 comments Field Resolver가 요청한 Post 의 수만큼 호출되었던 걸 상기해보고 배치 함수를 만들어보자. (필자는 Prisma Client를 클로저로 넣어서 사용했는데 일단은 무시하고 넘어가자)

parent.id (Post 객체의 id)를 통해 Comment 리소스를 가져오고 있었으니 postid 값으로 구성된 key의 배열이 인자로 들어올 것이다. 이렇게 들어온 key 배열을 이용해 Comment 객체의 postId 에 포함되는 데이터만 WHERE IN 절을 통해 가져온 후, 이를 처음 들어온 postIds의 순서에 맞게 데이터를 분배해준다.

배치 함수를 생성했으니 실제로 .load() 메소드를 호출하여 사용해보자. 먼저 dataloader는 공식적으로 요청당 하나의 인스턴스를 생성하는 것이 바람직하다고 말하고 있다. 필자도 왜 instance per request로 사용하는지 조금 찾아봤는데 만약 Application-level(Singleton)로 사용하게 되면 다른 요청에서 캐싱된 데이터가 잘못 나타날 수도 있다고 한다. (아마 다른 곳에서 update 되었는데 stale한 데이터가 리턴될 수 있는 가능성을 말하는게 아닐까싶다)

REST API라면 요청 처리를 시작하는 컨트롤러에서 인스턴스를 생성해도 되겠지만 GraphQL은 요청이 어느 Scope(Resolver)에서 마무리 될 지 알 수 없으니 보통 ApolloServer 인스턴스를 생성할 때 위와 같이 context 내부에 넣어준다. 이 경우 응답이 나가면 dataloader 인스턴스는 참조가 사라지면서 가바지 콜렉터에 의해 제거된다. Apollo Server 공식 문서에서도 dataloader 객체는 context에 붙여 사용하면 유용하다고 가이드한다.

만약 Nest.js를 사용한다면 DataLoader 클래스에 @Injectable({ scope: Scope.REQUEST }) 와 같이 데코레이터를 달아주고 원하는 서비스 클래스에 DI 해주는 패턴도 많이 사용하니 참고하길 바란다.

그럼 Resolver의 코드를 수정해보자. info 객체를 받아오던 부분을 제거하고, Post 객체의 comments 필드에 대한 Resolver 함수를 추가할 것이다.

아까 context 에 넣어둔 commentByPostIdLoaderPost 객체의 id 값을 load하는 코드를 추가했다. 그럼 이제 아까와 동일한 쿼리로 요청해보자.

아까와는 다르게 Comment 를 조회하는 쿼리가 2번이 아닌 1번 요청되었다. 어떤 일이 벌어진걸까?

  • 먼저 latestPosts 에서 가져왔던 게시글 20개와 bestPosts 에서 가져왔던 게시글 3개 총 23개의 Post 객체를 리턴한다.
  • 그 후 Post 객체 하위에 있는 comments 필드를 Resolve하는 과정에서 Field Resolver가 23회 호출될 것이고, comentByPostIdLoader.load(key)postId 23개가 Dataloader 객체 내부에 있는 배치 안 쪽에 추가된다.
  • CallStack 내부가 비게 되면 DataLoader 내부에 있는 dispatchBatch() 함수가 호출되면서 미리 등록해두었던 배치 함수를 호출한다.
  • load() 함수를 사용했던 곳에서는 인자로 넣어두었던 key 값에 매핑되는 Comment 데이터를 확보하게 된다.

부족하지만 이해를 돕기 위해 아래에 gif 이미지를 하나 만들어보았다. (postId가 23번이 아닌 2번 들어간다고 가정했다)

dataloader-flow.gif (이런거 만들기 좋은 툴 알고 계시면 댓글로 부탁드립니다 🙏)

이렇게 dataloader로 쿼리 성능을 최적화하는 방법을 알아보았다.

dataloader 내부 흐름

DataLoader 내부에는 어떤 일이 벌어졌던 걸까? (이 부분은 궁금하지 않다면 꼭 보지 않아도 괜찮다)

커밋 5000개 이하 스크롤 금지

처음에 배치 함수와 함께 데이터로더를 생성한 후, load() 메서드가 최초로 호출되면 클래스 내부에 private 속성으로 _batch 객체를 생성한다. load(key) 메서드가 호출될 때마다 _batch 객체 내부에 keys 배열에는 키가 추가되고, callbacks 배열에는 Promise의 콜백 인자로 들어오는 resolve 함수와 reject 함수를 추가한다. _batch 객체의 구조는 아래와 같다.

최초 load() 메서드가 호출되는 시점에 클래스 _batchScheduleFn 함수를 미리 호출해둔다. 이 함수는 CallStack이 비었을 때 내부에 있는 dispatchBatch 호출하면서 key가 적재된 keys 와 함께 우리가 미리 등록해두었던 batchFn 을 호출한다.

핵심적인 플로우는 아니지만 load() 함수 맨 뒤쪽에는 들어온 key 값과 미리 만들어 둔 Promise 객체를 _cacheMap 이라는 객체에 등록한다. 이는 동일한 키가 load 되었을 때 _cacheMap 에 보관 중인 값인지 확인하고 값이 있다면 만들어 둔 Promise 객체를 그대로 리턴한다. (즉, 동일한 key가 들어왔을 때 중복으로 keys 에 적재하지 않는다)

마치며

지금까지 GraphQL에서 N+1 문제를 점진적으로 해결하는 방법들에 대해서 알아보았다. 최근 우리 팀에서 DataLoader가 필요한 시점이 왔는데 팀원들에게 설명해주는 과정에서 ‘내가 이걸 정말 잘 알고 있는 건가?’ 라는 생각이 들어 다시금 글로써 정리하게 되었다. 이 글을 통해 많은 사람들이 도움을 얻었으면 한다.

References

--

--