
GraphQL은 클라이언트에게 강력한 유연성을 제공하지만, 서버 개발자에게는 N+1 문제라는 거대한 숙제를 안겨줍니다.
K-pop 플랫폼 IdolGlow의 상품 탐색 기능을 개발하며, 이 고질적인 문제를 어떻게 원천 차단하고 예측 가능한 성능을 확보했는지 실제 설계 사례를 통해 공유합니다.
상품 20개를 조회할 때, wishCount와 imageUrl 필드가 각각 리졸버에서 DB를 조회한다면 어떤 일이 벌어질까요?
결과: 총 41번(1 + 20 + 20)의 쿼리가 발생합니다.
이것이 바로 N+1 문제입니다.
N+1은 GraphQL만의 문제가 아니라 "데이터 접근 경로가 1+N 구조로 열려 있는가"의 문제입니다.
결국 N+1은 서버 구현자가 "이 정보가 필요하다"라는 사실을 언제 인지했느냐에 따라 결정됩니다.
GraphQL 생태계에서 N+1의 표준 해법은 DataLoader입니다.
IN 쿼리로 묶어 처리합니다.IdolGlow는 복잡한 필터링 조건을 처리해야 합니다.
이를 위해 단순 필드 리졸버에 의존하지 않고 강력한 검색 엔진급 쿼리 저장소를 설계했습니다.
IdolGlow는 DataLoader 라이브러리를 쓰기 전, 리포지토리 단계에서 수동 배치 로딩 패턴을 적용하여 성능을 극대화했습니다.
wishCount, reviewMetrics, thumbnailUrl을 각각 IN 쿼리 한 번으로 가져옵니다.// 리포지토리 구현 예시
override fun browseProducts(criteria: ProductSearchCriteria): ProductBrowseResult {
// 1단계: ID 목록만 (1번 쿼리)
val ids = searchQueryRepository.findOrderedProductIds(criteria)
// 2단계: 배치 조회 (각 1번 쿼리)
val wishCounts = discoveryRepository.findWishCounts(ids)
val reviewMetrics = discoveryRepository.findReviewMetrics(ids)
val thumbnails = imageService.findThumbnailUrls(ids)
// 3단계: 메모리 조립 (0번 쿼리)
val items = queryRepository.hydrate(ids, wishCounts, reviewMetrics, thumbnails)
return ProductBrowseResult(items = items)
}
이렇게 하면 필드가 아무리 많아도 쿼리 수는 1 + 필드 종류 수로 고정됩니다.
예: 1 + 3 = 4번 쿼리
서버 내부 로직을 동일한 서비스로 공유하기 때문에 순수 DB 성능 차이는 거의 없습니다.
다만 인프라 계층에서의 차이가 존재합니다.
POST를 사용하므로 캐싱 계층이 쿼리 본문을 파싱해야 하는 복잡함이 있습니다.IdolGlow는 이 점을 고려하여, 상품 탐색처럼 캐싱이 중요한 도메인은 REST와 GraphQL 경로 모두에서 동일한 최적화 로직을 타도록 설계했습니다.
만약 서비스가 성장하여 중첩된 리소스 예: 상품 -> 리뷰 -> 작성자 의 깊이가 깊어지거나, 클라이언트가 선택한 필드에 따라 DB 부하를 동적으로 줄여야 하는 시점이 온다면 그때 리졸버 단위로 쪼개고 DataLoader를 붙이는 리팩터링이 의미를 가집니다.
"N+1 문제는 기술의 탓이 아니라 설계의 선택입니다."
IdolGlow는 "미리 아는 정보는 처음부터 배치로 처리한다"는 원칙을 고수했습니다.
복잡한 상품 탐색 환경에서는 유연한 리졸버보다 견고한 배치 로딩 전략이 시스템의 안정성을 보장하는 가장 확실한 방법이기 때문입니다.
이번 구현을 통해 N+1 문제는 단순히 GraphQL이나 ORM의 문제가 아니라, 데이터를 어떤 단위로 읽고 조립할 것인가에 대한 설계 문제라는 점을 배웠습니다.
처음에는 GraphQL의 필드 리졸버 구조만 보면 클라이언트가 원하는 필드만 가져올 수 있어 효율적이라고 생각하기 쉽습니다.
하지만 상품 목록처럼 여러 개의 데이터를 한 번에 조회하고, 각 상품마다 위시 수, 리뷰 점수, 썸네일 같은 부가 정보를 함께 조립해야 하는 경우에는 필드 단위 조회가 오히려 반복 쿼리를 만들 수 있습니다.
그래서 중요한 것은 “GraphQL을 썼는가”가 아니라, 반복될 수 있는 조회를 미리 식별하고 ID 목록 기준으로 한 번에 처리하는 구조를 만들었는가였습니다.
상품 ID를 먼저 모으고, 위시 수·리뷰 지표·썸네일 이미지를 배치로 조회한 뒤 조립하는 방식으로 성능을 예측 가능하게 만들었습니다.
GraphQL은 필드 선택의 자유도가 높고 확장성이 좋아 보이지만, 실제 서비스에서는 그 자유도가 서버 내부의 조회 비용으로 돌아올 수 있습니다.
특히 상품 탐색처럼 목록 조회가 많고, 여러 집계 데이터가 함께 필요한 화면에서는 무작정 필드 리졸버를 잘게 쪼개기보다 처음부터 조회 흐름을 통제하는 배치 전략이 더 현실적이었습니다.
또한 성능 최적화는 나중에 붙이는 장식이 아니라, 도메인 조회 흐름을 설계할 때부터 같이 고민해야 한다는 점을 체감했습니다.
조회 수가 작을 때는 문제가 드러나지 않지만, 상품 수가 늘고 부가 필드가 많아지면 N+1은 바로 병목이 됩니다.
결국 백엔드 개발자는 API 모양만 만드는 사람이 아니라, 데이터가 실제로 어떻게 움직이는지까지 책임지는 사람이어야 한다고 느꼈습니다.