GraphQL 성능 튜닝 실전: N+1 쿼리, DataLoader, 복잡도 제한으로 API 지연 줄이기

GraphQL은 느린 것이 아니라 쉽게 느려진다
GraphQL을 처음 도입하면 프론트엔드 개발 속도가 빨라집니다. 필요한 필드만 요청하고, 여러 REST API를 조합하지 않아도 되고, 화면 단위 데이터 요구사항을 스키마로 표현할 수 있습니다. 문제는 서비스가 커진 뒤에 나타납니다. 단순해 보이는 쿼리 하나가 내부적으로 수백 개 resolver를 호출하고, resolver마다 데이터베이스를 따로 조회하면서 p95 latency가 갑자기 튑니다.
가장 흔한 원인은 N+1 쿼리입니다. 게시글 목록 20개를 가져온 뒤 각 게시글의 작성자를 resolver에서 개별 조회하면, 목록 1번 + 작성자 20번으로 총 21번의 쿼리가 발생합니다. 여기에 댓글 수, 좋아요 여부, 태그, 권한 정보가 붙으면 쿼리 수는 쉽게 폭발합니다. GraphQL의 장점인 중첩 필드 선택이 데이터 접근 계층에서는 반복 조회로 바뀌는 것입니다.
이 글에서는 GraphQL API를 운영 환경에서 빠르고 안전하게 유지하기 위한 패턴을 정리합니다. DataLoader로 batch와 cache를 적용하는 법, resolver waterfall을 추적하는 법, query complexity와 depth를 제한하는 법, persisted query로 비용을 예측 가능하게 만드는 법을 다룹니다.
1. Resolver는 함수가 아니라 데이터 접근 경계다
GraphQL resolver는 필드마다 실행됩니다. 이 구조는 유연하지만 성능을 숨기기 쉽습니다. Post.author resolver 안에서 userRepository.findById(post.authorId)를 호출하는 코드는 보기에는 자연스럽습니다. 하지만 목록 화면에서 post가 50개라면 같은 resolver가 50번 호출됩니다. ORM의 lazy relation과 결합되면 개발자는 쿼리 수를 거의 인지하지 못합니다.
Resolver를 설계할 때는 "이 필드는 단독으로 몇 번 호출되는가"가 아니라 "부모 리스트와 함께 호출될 때 몇 번 반복되는가"를 봐야 합니다. 목록 화면, 검색 결과, 관리자 테이블처럼 리스트 중심 화면은 N+1의 주 무대입니다. 개별 상세 페이지에서는 문제가 없던 resolver가 리스트에서는 장애 원인이 됩니다.
Resolver 내부에서 직접 DB를 호출하지 않고, request-scoped loader를 거치게 만드는 것이 기본 방어선입니다. 같은 요청 안에서 동일한 key 조회를 모으고, 한 번의 batch query로 처리합니다. 이때 loader는 전역 singleton이 아니라 요청 단위로 생성되어야 합니다. 전역 cache는 사용자별 권한과 tenant 경계를 섞을 수 있어 위험합니다.
2. DataLoader의 핵심은 batch와 짧은 cache다
DataLoader는 같은 tick 안에 들어온 여러 key 요청을 모아 batch function으로 전달합니다. load(1), load(2), load(3)이 여러 resolver에서 호출되더라도 실제 DB 쿼리는 WHERE id IN (1,2,3) 형태로 한 번만 나갑니다. 또한 같은 요청 안에서 load(1)이 반복되면 cache된 Promise를 재사용합니다.
중요한 것은 batch function의 반환 순서입니다. DataLoader는 입력 key 배열과 같은 순서로 결과를 돌려받기를 기대합니다. DB에서 IN 쿼리를 실행하면 순서가 보장되지 않으므로, 결과를 Map으로 만든 뒤 입력 key 순서대로 재정렬해야 합니다. 누락된 값은 null 또는 Error로 채워야 합니다. 이 규칙을 어기면 작성자와 게시글이 엉뚱하게 매칭되는 치명적인 버그가 생깁니다.
Cache 범위도 조심해야 합니다. 요청 단위 cache는 안전합니다. 하지만 loader를 process 전역으로 두면 사용자 A가 본 권한 결과를 사용자 B가 재사용할 수 있습니다. 특히 viewerCanEdit, isLikedByMe, tenantConfig 같은 필드는 사용자와 tenant context가 key에 포함되어야 합니다. 단순 ID만 key로 쓰면 보안 버그가 됩니다.
DataLoader가 모든 성능 문제를 해결하지는 않습니다. Batch size가 너무 커지면 IN 쿼리 자체가 느려질 수 있습니다. 데이터베이스 파라미터 제한이나 query planner 특성도 봐야 합니다. Batch size 상한을 두고, 필요하면 key를 여러 묶음으로 나누는 전략이 필요합니다.
3. Query Complexity를 제한한다
GraphQL은 클라이언트가 쿼리 모양을 정합니다. 그래서 서버는 "이 쿼리가 얼마나 비싼가"를 실행 전에 평가해야 합니다. 단순 depth 제한만으로는 부족합니다. 깊이는 얕지만 리스트 필드를 여러 개 요청하면 비용이 높을 수 있습니다. 반대로 깊이는 깊어도 단건 조회라면 괜찮을 수 있습니다.
Complexity 계산은 필드별 비용과 list multiplier를 조합합니다. 예를 들어 posts(first: 50) { comments(first: 20) { author { ... } } }는 잠재적으로 1,000개 댓글 resolver를 만들 수 있습니다. 이런 쿼리는 인증된 내부 도구가 아니라면 제한해야 합니다. Public API라면 pagination 인자를 필수로 만들고, 최대 page size를 강제합니다.
Depth limit, complexity limit, alias limit, directive limit도 함께 봐야 합니다. 같은 필드를 alias로 수십 번 요청하면 캐시가 잘 동작해도 서버 parsing과 validation 비용이 증가합니다. Fragment를 재귀적으로 조합해 분석을 어렵게 만드는 쿼리도 있습니다. GraphQL API는 타입 안전하지만 자동으로 비용 안전한 것은 아닙니다.
Persisted query는 운영 안정성에 큰 도움이 됩니다. 클라이언트가 임의 문자열 쿼리를 보내는 대신, 사전에 등록된 query hash만 실행하게 하면 서버는 비용을 미리 분석할 수 있습니다. 모바일 앱이나 웹 프론트엔드 배포 파이프라인에서 쿼리를 수집하고, 서버는 허용 목록만 실행합니다. 공격 표면과 예측 불가능한 비용이 줄어듭니다.
4. Resolver Tracing으로 병목을 본다
GraphQL 성능 문제는 하나의 SQL slow query로만 나타나지 않습니다. 작은 resolver들이 많이 모여 느려지는 경우가 많습니다. 그래서 resolver별 실행 시간, 호출 횟수, batch size, cache hit ratio를 봐야 합니다. OpenTelemetry span을 resolver 단위로 남기되, 모든 필드에 무조건 span을 만들면 trace가 지나치게 커질 수 있습니다.
실무에서는 상위 object resolver와 외부 I/O resolver 중심으로 추적합니다. DB, Redis, HTTP 호출이 있는 resolver는 반드시 관측 대상입니다. Pure field mapping resolver는 제외해도 됩니다. Trace waterfall에서 같은 resolver가 수십 번 반복된다면 DataLoader 누락을 의심합니다. Batch size가 항상 1이라면 loader를 쓰고 있어도 batching window가 깨졌을 수 있습니다.
쿼리별 통계도 필요합니다. Operation name, normalized query hash, latency, error rate, DB query count를 집계하면 어떤 화면이 서버 비용을 많이 쓰는지 알 수 있습니다. 단, raw query 전체를 label로 넣으면 카디널리티가 폭발합니다. Hash와 operation name을 사용하고, 상세 쿼리는 샘플링된 trace에서 확인하는 편이 안전합니다.
5. Schema 설계가 성능을 결정한다
GraphQL 성능은 resolver 최적화만으로 끝나지 않습니다. Schema 자체가 비싼 접근을 유도할 수 있습니다. 예를 들어 모든 타입에 children, relatedItems, stats 같은 필드를 자유롭게 열어두면 클라이언트는 편하지만 서버 비용은 예측하기 어렵습니다. 비싼 필드는 명확한 pagination과 filter를 요구해야 합니다.
집계 필드도 조심해야 합니다. post.commentCount를 매번 COUNT(*)로 계산하면 리스트에서 바로 병목이 됩니다. 자주 쓰는 count는 denormalized counter나 materialized view, 별도 read model을 고려해야 합니다. GraphQL schema는 이상적인 객체 모델이 아니라 운영 가능한 read API 계약이어야 합니다.
권한 검사도 성능의 일부입니다. 필드마다 개별 권한 API를 호출하면 N+1이 권한 레이어에서 발생합니다. 권한 역시 batchable해야 합니다. 사용자별 접근 가능한 resource 목록을 미리 가져오거나, permission loader를 통해 resource ID들을 한 번에 평가해야 합니다.
실무 체크리스트
- 리스트 화면의 nested field에서 N+1 쿼리가 발생하지 않는가
- DataLoader가 요청 단위로 생성되고 tenant/user context를 안전하게 포함하는가
- Batch function이 입력 key 순서대로 결과를 반환하는가
- Pagination 최대 크기와 query complexity 제한이 있는가
- Persisted query 또는 allowlist로 임의 고비용 쿼리를 막는가
- Resolver별 latency, 호출 횟수, batch size, cache hit ratio를 보고 있는가
- Operation hash별 DB query count와 p95 latency를 추적하는가
- 비싼 집계 필드가 read model이나 counter로 분리되어 있는가
6. 운영 테스트는 쿼리 모양으로 만든다
GraphQL 성능 테스트는 단순히 endpoint 하나에 부하를 주는 방식으로는 부족합니다. 같은 /graphql URL이라도 operation 모양에 따라 비용이 완전히 달라집니다. 목록 중심 쿼리, 상세 쿼리, nested relation이 깊은 쿼리, alias가 많은 쿼리, 권한 필드가 많은 쿼리를 따로 만들어야 합니다. 실제 프론트엔드에서 사용하는 operation을 수집해 대표 세트를 구성하는 것이 가장 현실적입니다.
테스트에서는 응답 시간만 보지 않습니다. DB query count, DataLoader batch size, cache hit ratio, resolver 호출 횟수, complexity score를 함께 기록합니다. 응답 시간은 캐시나 테스트 데이터 크기에 따라 우연히 좋아 보일 수 있지만, query count가 폭증하는 구조는 운영 데이터가 커지면 결국 드러납니다. 성능 회귀를 막으려면 특정 operation의 query count 상한을 테스트에 넣는 것도 도움이 됩니다.
배포 후에는 operation hash별 지표를 봐야 합니다. 전체 p95가 괜찮아도 특정 화면의 operation 하나가 느릴 수 있습니다. 느린 operation을 찾으면 schema 문제인지, loader 누락인지, DB 인덱스 문제인지, 권한 검사 비용인지 분리해 봅니다. GraphQL 운영은 URL 단위가 아니라 operation 단위로 관측해야 합니다.
결론: GraphQL 성능은 자유도에 경계선을 긋는 일이다
GraphQL은 클라이언트와 서버 사이의 강력한 계약입니다. 하지만 그 자유도를 그대로 열어두면 서버 비용은 예측하기 어려워집니다. N+1을 막는 DataLoader, 비용을 제한하는 complexity rule, 병목을 찾는 resolver tracing, 안정적인 쿼리 집합을 만드는 persisted query가 함께 있어야 운영 가능한 GraphQL이 됩니다.
성능 좋은 GraphQL 서버는 resolver를 빠르게 만드는 서버가 아닙니다. 클라이언트가 어떤 모양으로 데이터를 요구하더라도 서버 비용이 통제 가능한 범위에 머물도록 설계된 서버입니다. Schema는 제품 API인 동시에 비용 계약입니다.