목차
- GraphQL이 N+1을 만들어내는 구조적 이유
- 쿼리 로그에서 N+1 확인하기
- DataLoader의 동작 원리
- 프로젝트에 DataLoader 적용하기
- 적용 전후 성능 비교
- 실무에서 빠지기 쉬운 함정들
- 운영 환경에서 확인할 것들
REST에서 GraphQL로 전환하고 3주쯤 됐을 때 주문 목록 API가 눈에 띄게 느려졌다. pgAdmin에서 쿼리 로그를 열어보니 주문 20건 조회 요청 하나에 SELECT가 62개 찍혀 있었다. JOIN 하나로 200ms 안에 끝나던 쿼리가 평균 1.2초까지 늘어난 상태였다.
DataLoader를 도입해서 이 문제를 해결한 과정과 실무 체크리스트를 남긴다. 환경은 Node.js 18, Apollo Server 4, PostgreSQL 15 기준이다.
GraphQL이 N+1을 만들어내는 구조적 이유
REST API에서는 엔드포인트 하나가 반환하는 데이터 형태가 고정되어 있다. 컨트롤러 레벨에서 JOIN을 걸어 필요한 데이터를 한 번에 가져오고, 그 쿼리를 개발 시점에 최적화할 수 있다. 엔드포인트가 늘어나면 쿼리도 느는 건 맞지만, 적어도 한 요청 안에서 쿼리가 폭발하는 일은 드물다.
GraphQL은 구조 자체가 다르다. 클라이언트가 필요한 필드를 직접 선택하기 때문에, 서버는 필드 단위로 resolver를 쪼개야 한다. Order 타입의 product 필드에는 별도 resolver가 붙고, 해당 필드가 요청될 때만 그 resolver가 실행된다. 유연한 설계인 건 맞는데, 이 독립성이 N+1의 직접적인 원인이 된다.
주문 20건을 가져오는 쿼리 1개 — 이게 "1"이다. 각 주문의 상품을 가져오는 쿼리 20개 — 이게 "N"이다. 상품마다 카테고리를 또 요청했다면 20개가 더 붙는다. depth가 깊어질수록 곱셈으로 늘어난다. 내 경우 주문(1) + 상품(20) + 카테고리(20) + 리뷰 카운트(20) + 기타(1)로 총 62개가 나왔다.
이건 버그가 아니라 트레이드오프다. resolver의 독립성을 유지하면서 성능도 챙기려면 별도의 최적화 레이어가 필요하고, 그 역할을 하는 게 DataLoader다.
| 항목 | REST API | GraphQL (기본 상태) |
|---|---|---|
| 데이터 패칭 방식 | 엔드포인트별 고정 쿼리 (JOIN) | 필드별 독립 resolver |
| 클라이언트 유연성 | 낮음 — 서버가 응답 형태 결정 | 높음 — 클라이언트가 필드 선택 |
| N+1 발생 가능성 | 낮음 | 높음 |
| 쿼리 최적화 시점 | 개발 시점 (정적) | 런타임 (동적, DataLoader 등 필요) |
쿼리 로그에서 N+1 확인하기
N+1이 의심되면 DB 쿼리 로그부터 켜면 된다. PostgreSQL 기준 postgresql.conf에서 log_min_duration_statement = 0으로 설정하면 모든 쿼리가 기록된다. 운영 환경에서는 디스크를 빠르게 채우니 확인 후 바로 끄자. 로그를 열었을 때, 같은 패턴의 SELECT가 WHERE 조건의 ID만 바뀌면서 반복된다면 N+1이다. 그 이상 복잡하게 생각할 것 없다.
DataLoader의 동작 원리
배치(Batching) — 같은 tick의 요청을 모은다
DataLoader의 핵심 메커니즘은 배치 처리다. Node.js 이벤트 루프의 특성을 이용한다.
같은 실행 tick 안에서 들어온 load(id) 호출을 큐에 모아둔다. 현재 tick이 끝나는 시점 — 정확히는 process.nextTick 타이밍 — 에 큐에 쌓인 ID 전부를 배치 함수로 넘긴다. resolver 20개가 각각 productLoader.load(productId)를 호출해도, 실제 DB 쿼리는 WHERE id IN (101, 102, 103, ...) 한 번으로 합쳐진다.
이게 작동하는 이유가 있다. GraphQL 서버는 같은 depth의 resolver를 사실상 동기적으로 실행한다. Order.product resolver 20개가 거의 동시에 실행되면서, 이들의 load() 호출이 전부 같은 tick에 속하게 되는 것이다.
캐싱(Caching) — 같은 키는 한 번만 조회
DataLoader는 요청 스코프 내에서 메모리 캐시를 유지한다. load(101)을 이미 호출한 적이 있으면, 두 번째 호출에서는 DB를 거치지 않고 캐시된 값을 돌려준다. 주문 여러 건이 같은 상품을 참조하는 경우에 쿼리가 더 줄어드는 효과가 있다.
이 캐시는 글로벌이 아니라 요청 단위로 생성되고 파괴된다. A 사용자의 요청에서 캐시된 데이터가 B 사용자에게 넘어가는 일은 구조적으로 발생하지 않는다.
"그냥 JOIN 쓰면 안 되나?"
쓸 수 있다. resolver에서 직접 JOIN 쿼리를 날리면 N+1은 사라진다. 실제로 나도 처음에 그렇게 했다. 작동은 하는데, 클라이언트가 product 필드를 요청하지 않아도 무조건 JOIN이 실행되는 구조가 됐다. GraphQL을 쓰는 의미가 줄어든다. DataLoader를 쓰면 요청된 필드의 resolver만 실행되면서도 배치 처리가 되니까, 유연함과 성능을 같이 가져갈 수 있다.
프로젝트에 DataLoader 적용하기
설치와 배치 함수 작성
DataLoader는 Facebook(현 Meta)이 만든 오픈소스 라이브러리다. npm 패키지명은 dataloader이고, 2026년 4월 기준 최신 버전은 v2.2.3이다 (출처: GitHub — graphql/dataloader, Release Notes v2.2.3).
npm install dataloader
배치 함수를 작성할 때 반드시 지켜야 하는 규약이 하나 있다. 입력 ID 배열과 같은 순서, 같은 길이로 결과를 반환해야 한다. WHERE id IN (3, 1, 2)를 날리면 DB는 보통 1, 2, 3 순서로 반환한다. Map으로 재정렬하지 않으면 주문 A에 상품 B 정보가 붙는 조용한 버그가 생긴다.
// loaders/productLoader.js
const DataLoader = require('dataloader');
const { pool } = require('../db');
async function batchProducts(ids) {
const { rows } = await pool.query(
'SELECT * FROM products WHERE id = ANY($1)', // PostgreSQL 배열 파라미터
[ids]
);
// 핵심: 입력 ids와 동일한 순서로 매핑
const map = new Map(rows.map(p => [p.id, p]));
return ids.map(id => map.get(id) || null);
}
function createProductLoader() {
return new DataLoader(batchProducts);
}
module.exports = { createProductLoader };
Apollo Server context에 연결하기
Apollo Server 4 기준으로, context 팩토리 함수에서 매 요청마다 새 loader 인스턴스를 생성한다.
// server.js — Apollo Server 4
const { ApolloServer } = require('@apollo/server');
const { createProductLoader } = require('./loaders/productLoader');
const { createCategoryLoader } = require('./loaders/categoryLoader');
const server = new ApolloServer({ typeDefs, resolvers });
// 매 요청마다 새 인스턴스 — 글로벌 공유 금지
const context = async () => ({
loaders: {
product: createProductLoader(),
category: createCategoryLoader(),
}
});
// resolvers.js
const resolvers = {
Order: {
// 적용 전: 건별 DB 호출
// product: (order) => getProductById(order.productId),
// 적용 후: DataLoader 배치
product: (order, _, { loaders }) => loaders.product.load(order.productId),
},
Product: {
category: (product, _, { loaders }) => loaders.category.load(product.categoryId),
},
};
resolver 코드 변경량은 의외로 적다. getProductById(id) 호출을 loaders.product.load(id)로 바꾸는 게 전부다. 기존 resolver 구조를 건드리지 않아도 되기 때문에 도입 부담이 낮다.
왜 매번 새 인스턴스를 만드는가
context 함수가 매 요청마다 createProductLoader()를 호출하는 이유가 있다. DataLoader 인스턴스를 서버 시작 시점에 한 번만 만들어 재사용하면, A 사용자 요청의 캐시 데이터가 B 사용자 요청에서 반환될 수 있다. 권한 체크를 우회하는 보안 이슈로 이어지니, 반드시 요청 단위로 생성해야 한다.
적용 전후 성능 비교
아래는 주문 20건 조회 기준 수치다. 각 주문에 상품, 카테고리, 리뷰 카운트를 요청한 GraphQL 쿼리로 로컬에서 측정했다.
| 측정 항목 | 적용 전 | 적용 후 | 변화 |
|---|---|---|---|
| 실행된 SQL 쿼리 수 | 62개 | 4개 | -93.5% |
| 평균 응답 시간 | 1,180ms | 185ms | -84.3% |
| DB 커넥션 풀 사용률 (피크) | 78% | 12% | -84.6% |
| 초당 처리 가능 요청 (k6 기준) | ~42 req/s | ~280 req/s | 약 6.7배 |
쿼리 4개의 구성은 이렇다: 주문 목록 SELECT 1개, 상품 WHERE IN 1개, 카테고리 WHERE IN 1개, 리뷰 카운트 GROUP BY 1개.
커넥션 풀 사용률 변화가 인상적이었다. N+1 상태에서는 62개 쿼리가 거의 동시에 커넥션을 점유하려 한다. 기본 풀 크기 10을 금방 소진하고, 대기 시간이 쌓여서 응답 지연의 주원인이 됐다. DataLoader 적용 후에는 쿼리 4개가 순차 실행되면서 커넥션을 1~2개만 사용한다. 풀 고갈 자체가 사라진 것이다.
실무에서 빠지기 쉬운 함정들
배치 함수 반환 순서 불일치
위에서 언급한 내용인데, 내가 직접 겪어서 한 번 더 쓴다. DataLoader 적용 직후 QA에서 "상품 이미지가 다른 상품 걸로 뜬다"는 리포트가 올라왔다. 원인은 배치 함수에서 Map 없이 DB 결과를 그대로 반환한 것이었다. WHERE id IN (3, 1, 2)의 결과가 [{id:1}, {id:2}, {id:3}]으로 와서 순서가 뒤섞인 거다. 에러가 나지 않고 데이터만 조용히 바뀌기 때문에 발견이 늦었다.
DataLoader 공식 README (GitHub — graphql/dataloader)에도 첫 번째 규약으로 명시되어 있다:
The Array of values must be the same length as the Array of keys, and each index in the Array of values must correspond to the same index in the Array of keys.
maxBatchSize 미설정
PostgreSQL의 IN 절에 공식적인 파라미터 개수 제한은 없다. 그런데 실무에서 파라미터가 수천 개를 넘어가면 쿼리 플래너 성능이 저하된다. 체감상 500개 이상부터 플래닝 시간이 눈에 띄게 느는 것 같았다. DataLoader의 maxBatchSize 옵션으로 한 번에 배치하는 크기를 제한할 수 있다.
new DataLoader(batchFn, { maxBatchSize: 100 });
페이지네이션 크기에 맞춰서 정하면 된다. 목록 최대가 100건이라면 100이면 충분하다.
prime()과 clear()의 오용
DataLoader에는 캐시를 수동 제어하는 prime(key, value)과 clear(key) 메서드가 있다. mutation으로 데이터를 수정한 뒤 캐시를 갱신하려고 prime()을 쓰는 경우가 있는데, 요청 스코프 캐시라서 대부분 불필요하다. mutation과 query가 같은 요청 안에서 실행되는 특수한 경우에만 의미가 있고, 보통은 신경 쓸 필요 없다.
이 부분은 아직 깊이 파보진 못했다. 우리 프로젝트에서는 mutation 후 클라이언트가 refetch하는 패턴이라 prime()을 쓸 일 자체가 없었다.
운영 환경에서 확인할 것들
마지막 항목이 가장 놓치기 쉽다. 신규 팀원이 합류하면 resolver에서 바로 DB를 호출하는 코드를 작성하게 된다. 리뷰 때 매번 잡아야 하는 건 비효율적이라, 우리 팀은 resolver 파일 상단에 // @dataloader: productLoader 같은 주석 컨벤션을 두기로 했다. 자동 검증까지는 아직 안 되고, 코드리뷰 시 확인하는 수준이다.
depth가 깊은 중첩 쿼리를 허용하면 DataLoader로도 감당이 안 되는 수준의 쿼리가 나올 수 있다. graphql-depth-limit 같은 라이브러리로 최대 depth를 제한하는 걸 같이 적용하는 게 좋다. 우리는 depth 5로 제한 중이고, 지금까지 클라이언트 쪽에서 불만이 올라온 적은 없다.
GraphQL N+1 문제는 DataLoader 하나로 대부분 잡히지만, depth 제한과 쿼리 모니터링 없이는 다른 형태로 재발할 수 있다. 새 resolver를 추가할 때마다 DataLoader 적용 여부를 리뷰 항목에 넣어두는 것부터 시작하면 된다.
관련 글
- PostgreSQL 인덱스 최적화 실무 — 인덱스 걸었는데 왜 느린지 모르겠다면 – 인덱스 걸면 빨라진다고들 하는데, 실제로는 그렇지 않은 경우가 많다. EXPLAIN ANALYZE 해석부터 복합 인덱스 컬럼 순서, par…
- TypeScript 유틸리티 타입 실무 가이드 — Partial, Pick, Omit 제대로 쓰기 – User 인터페이스가 7개까지 늘어난 프로젝트를 정리하면서 깨달은 TypeScript 유틸리티 타입 실무 적용법이다. Partial, Pi…
- JavaScript 번들 사이즈 최적화: Webpack과 Vite에서 프로덕션 용량 줄이기 – 번들 사이즈 4.2MB짜리 프로젝트를 890KB까지 줄인 과정을 정리했다. moment.js locale 600KB, lodash 530K…