GraphQL의 N+1 문제: Laravel API가 느려지는 이유와 Lighthouse를 사용한 해결 방법
요약
GraphQL API에서 발생하는 N+1 문제는 목록 크기가 커질수록 응답 시간이 급격히 느려지는 현상입니다. 이 문제는 반복문 내부에서 데이터베이스 쿼리가 과도하게 실행되어 발생하며, Laravel의 Lighthouse 패키지를 사용하면 `@belongsTo` 같은 표준 관계 지시어를 통해 단일 그룹 쿼리로 효율적으로 해결할 수 있습니다.
핵심 포인트
- N+1 문제: 목록 크기에 비례하여 DB 쿼리 수가 증가하는 현상입니다.
- 해결 원칙: 루프 내부에서 쿼리를 실행하지 않고, 필요한 키를 모아 한 번에 처리해야 합니다.
- Lighthouse의 `@belongsTo` 지시어는 기본적으로 그룹 쿼리를 사용하여 N+1 문제를 방지합니다.
- 계산된 필드(Aggregation)의 경우, `BatchLoader` 패턴을 직접 구현하여 배치 처리가 필요합니다.
Laravel API에 GraphQL을 연결했습니다. 모든 것이 작동하고, 쿼리는 깔끔하며, 프론트엔드 개발자들은 필요한 필드만 요청하는 것에 만족합니다. 그러다가 어느 날, 10개의 요소를 담았던 목록이 500개가 되고 — 80ms 만에 응답하던 API가 4초 동안 응답하기 시작합니다.
열 번 중 아홉 번, 원인은 같습니다: N+1 문제. 하지만 좋은 소식은, 어디를 봐야 할지 알면 몇 분 안에 수정할 수 있다는 것입니다.
문제 재현하기
간단한 사례를 들어보겠습니다: 작성자(auteur)가 있는 기사 목록입니다.
query {
articles(first: 20) {
data {
...
언뜻 보기엔 아무 문제가 없어 보입니다. 하지만 만약 auteur 관계가 순진하게 해결된다면 — 매번 관계를 다시 불러오는 PHP 접근자(accessor), 또는 잘못 작성된 커스텀 리졸버(resolver)의 경우 — 데이터베이스 측에서는 실제로 다음과 같은 일이 발생합니다:
SELECT * FROM articles LIMIT 20; -- 1개의 쿼리
SELECT * FROM users WHERE id = 1; -- 기사 1에 대해
...
단 하나의 GraphQL 쿼리에 21개의 SQL 쿼리가 필요합니다. 이것을 500개 요소 목록이나, 두 단계의 중첩된 관계(기사 → 댓글 → 댓글 작성자)에 곱하면, 애플리케이션 코드에서 눈에 띄게 변경된 부분이 없음에도 불구하고 응답 시간이 폭발적으로 증가하는 이유를 이해할 수 있습니다.
이것이 바로 N+1 문제입니다: 초기 쿼리(
해결책: 해결하기 전에 그룹화하기
어떤 언어나 프레임워크에서든 N+1 문제를 100% 해결하는 원칙은 한 문장으로 요약됩니다.
루프 내부에서 절대 쿼리를 실행하지 마십시오. 필요한 모든 키를 먼저 수집한 다음, 단일 그룹 호출로 해결하십시오.
Lighthouse (Laravel 생태계에서 가장 많이 사용되는 GraphQL 패키지)를 사용하면 표준 관계 지시어(directives)가 이미 이 원칙을 구현하여 제공합니다:
type Article {
titre: String!
auteur: Utilisateur! @belongsTo
...
}
내부적으로 @belongsTo는 아티클마다 작가를 로드하지 않습니다. 대신 현재 실행 페이지의 20개 user_id를 수집한 다음, 응답을 구성하기 직전에 단일 그룹 쿼리를 실행합니다:
SELECT * FROM articles LIMIT 20;
SELECT * FROM users WHERE id IN (1, 2, 3, ..., 20);
총 2개의 SQL 쿼리이며, 목록 크기와는 무관합니다. 이는 표준 관계 지시어(@hasMany, @belongsTo, @belongsToMany)를 사용하는 순간 Lighthouse의 기본 동작입니다. 추가적인 설정은 필요하지 않습니다.
Eloquent 관계가 아닌 계산된 필터는 어떨까요?
진짜 함정은 평균 점수, 조회수 등 다른 테이블에 대한 집계(aggregation)가 필요한 계산된 필드를 노출할 때 발생합니다. 단순한 resolver는 다음과 같을 것입니다:
final class ResolveNoteMoyenne
{
public function __invoke(Article $article, array $args)
...
}
동일한 N+1 문제가 다시 발생하지만, 이번에는 표준 Eloquent 관계 외부로 이동했을 뿐입니다. 해결책은 BatchLoader를 사용하여 배치 처리 패턴을 직접 구현하는 것입니다:
final class NoteMoyenneLoader extends BatchLoader
{
public function resolve(): array
...
}
final class ResolveNoteMoyenne
{
public function __invoke(Article $article, array $args)
...
}
결과는 네이티브 지시문(directives natives)의 결과와 동일합니다. 목록에 5개의 기사가 포함되든 5000개가 포함되든 상관없이 전체 목록에 대해 단 하나의 GROUP BY 쿼리만 실행됩니다.
프로덕션 환경에 도달하기 전에 N+1을 감지하는 방법
세 가지 습관만 있으면 충분합니다:
- Laravel Debugbar: 개발 단계에서 사용하며, GraphQL 쿼리로 실행된 정확한 SQL 쿼리 수를 표시합니다. 이 숫자가 반환되는 목록의 크기와 함께 증가한다면, N+1 문제가 있는 것입니다.
DB::listen(): 통합 테스트(integration tests)에서 사용하여, 실행된 쿼리 수에 대한 단언(assertion)과 결합합니다.- Laravel Telescope: 각 쿼리의 출처(stack trace)와 함께 상세한 보기 기능을 제공합니다.
실용적인 규칙: GraphQL 쿼리의 SQL 쿼리 수는 절대로 반환하는 목록의 크기에 의존해서는 안 됩니다. 만약 그렇다면, 배치 처리(batching)를 아직 사용하지 않는 관계나 계산된 필드를 찾아보세요.
이것은 시작점일 뿐입니다
N+1 문제는 GraphQL에서 접하게 되는 첫 번째 성능 문제인 경우가 많지만, 이는 단지 시작점일 뿐입니다. 그 다음에는 캐싱(무엇을 캐시할지, 그리고 특히 언제 무효화할지)의 문제가 따르고, 오용으로부터 보호하기 위한 쿼리 복잡도 제한(limitation de la complexité des requêtes), 그리고 잘못 코딩했거나 악의적인 클라이언트에 의해 과부하되는 것을 방지하기 위한 속도 제한(rate limiting) 등의 문제가 있습니다.
저는 이 모든 것—N+1, Redis 캐시, 속도 제한, 확장성(scaling), 그리고 Laravel을 사용한 완전한 GraphQL API 구축과 Angular 클라이언트에서의 소비까지—를 종합하여 전체 교육 과정 **[GraphQL with Laravel]**에 담았습니다.
이 글이 시간을 절약해 주었다면, 이 교육 과정은 훨씬 더 많은 시간을 절약해 줄 것입니다.
N+1이나 Laravel을 사용한 GraphQL에 대해 궁금한 점이 있으신가요? 댓글 창이 열려 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기