GraphQL N+1 쿼리의 함정에서 빠져나오는 실전 최적화 기법
GraphQL을 도입하면서 개발이 편해진 것처럼 느껴지다가도, 갑자기 데이터베이스 쿼리가 폭발적으로 증가하는 경험을 해본 적 있나요? 이것이 바로 N+1 쿼리 문제입니다. 상황을 방치하면 성능은 급격히 떨어지고, 사용자 경험은 엉망이 됩니다. 이 글에서는 N+1 문제가 왜 발생하는지 이해하고, 실제 프로젝트에서 즉시 적용할 수 있는 해결책들을 살펴보겠습니다.
N+1 쿼리 문제, 정확히 뭐가 문제일까?
N+1 쿼리 문제는 매우 단순한 원리에서 비롯됩니다. 예를 들어 게시글 목록을 가져오는 상황을 생각해봅시다. 게시글 10개를 조회하는데, 각 게시글의 작성자 정보까지 함께 표시해야 한다고 가정하면, 보통 이렇게 진행됩니다:
- 1번 쿼리: 게시글 10개를 데이터베이스에서 조회 (1회)
- 2번~11번 쿼리: 각 게시글의 작성자 정보를 하나씩 조회 (10회)
결국 총 11번의 데이터베이스 쿼리가 실행되는 거죠. 이것이 "1+N" 문제라고 불리는 이유입니다. 게시글이 100개면 101번, 1,000개면 1,001번의 쿼리가 실행됩니다. GraphQL 리졸버가 각 필드를 독립적으로 해결하려고 하기 때문에 자연스럽게 이런 문제가 발생하는 거예요.
DataLoader로 요청을 한 번에 묶기
가장 효과적인 해결책은 DataLoader를 사용하는 것입니다. DataLoader는 여러 요청을 모아서 한 번에 처리함으로써 데이터베이스 접근을 획기적으로 줄여줍니다.
작동 원리는 이렇습니다: 개별 리졸버 함수들이 DataLoader에 필요한 데이터의 ID를 등록합니다. 같은 마이크로태스크 틱(microtask tick) 안에서 등록된 모든 요청이 모이면, DataLoader는 이들을 배치로 만들어 한 번의 데이터베이스 쿼리로 처리합니다. 게시글 작성자 조회 예시라면, 10개의 개별 요청이 "ID가 1, 3, 5, 7, 9, 11, 13, 15, 17, 19인 사용자 정보를 한 번에 조회해줘"라는 하나의 배치 쿼리로 변환되는 식입니다.
설정도 간단합니다. Node.js라면 dataloader npm 패키지를 설치한 후, 각 DataLoader 인스턴스마다 배치 함수를 정의하고, GraphQL 컨텍스트에 주입하면 됩니다. 중요한 건 DataLoader 인스턴스를 요청 단위로 생성해야 한다는 점입니다. 여러 요청을 걸쳐 같은 인스턴스를 재사용하면 캐싱으로 인한 데이터 정확성 문제가 발생할 수 있거든요.
쿼리 설계 단계에서 문제 차단하기
더 근본적인 방법은 GraphQL 스키마와 리졸버를 설계할 때부터 N+1 문제를 염두에 두는 것입니다.
첫 번째로 고려할 점은 관계가 깊은 데이터는 선택적으로 제공하는 것입니다. 모든 필드를 항상 조회할 필요는 없거든요. 예를 들어 게시글 목록 페이지에서는 작성자의 이름과 프로필 사진만 필요하지만, 상세 페이지에서는 추가 정보가 필요할 수 있습니다. 이런 경우 별도의 쿼리 필드를 나누거나, 리졸버 로직에서 필드 선택 정보를 활용해 불필요한 조회를 스킵할 수 있습니다.
두 번째로는 조인(JOIN)을 최대한 데이터베이스 레벨에서 처리하는 것입니다. GraphQL 리졸버에서 관계 데이터를 각각 조회하는 대신, 기본 데이터를 조회할 때 SQL 조인으로 필요한 정보를 함께 가져오면 쿼리 수를 크게 줄일 수 있습니다.
캐싱과 쿼리 최적화 레이어 활용
DataLoader도 배치 처리이지만, 추가로 Redis 같은 캐싱층을 도입하면 더욱 강력합니다. 자주 조회되는 데이터(예: 사용자 정보, 분류 데이터)는 메모리에 캐싱해두면, 데이터베이스 접근 자체를 줄일 수 있거든요.
또한 GraphQL 쿼리 분석 도구나 미들웨어를 사용해 과도하게 깊은 쿼리나 중복된 필드 요청을 감지하고 제한할 수도 있습니다. 클라이언트가 의도치 않게 비효율적인 쿼리를 작성하는 경우도 많으니까요.
모니터링과 지속적인 개선
최적화도 중요하지만, 문제를 조기에 감지하는 게 더 중요합니다. Apollo Server나 다른 GraphQL 서버는 리졸버 실행 시간과 쿼리 복잡도를 로깅하는 기능을 제공합니다. 이런 데이터를 수집해 성능 저하가 있는 엔드포인트를 찾아내세요.
프로덕션 환경에서는 느린 쿼리를 감지했을 때 경고를 보내고, 개발 단계에서부터 성능 테스트를 습관화하는 것도 도움이 됩니다. 실제로 사용할 데이터 규모로 테스트하면 N+1 같은 문제들이 쉽게 눈에 띕니다.