IdolGlow가 N+1 문제를 해결하는 필드 리졸버 대신 루트 배치 조회 선택 방법

궁금하면 500원·2026년 4월 24일

미생의 개발 이야기

목록 보기
79/81

IdolGlow의 실제 설계에서 GraphQL N+1 문제와 배치 배우기

GraphQL은 클라이언트에게 강력한 유연성을 제공하지만, 서버 개발자에게는 N+1 문제라는 거대한 숙제를 안겨줍니다.

K-pop 플랫폼 IdolGlow의 상품 탐색 기능을 개발하며, 이 고질적인 문제를 어떻게 원천 차단하고 예측 가능한 성능을 확보했는지 실제 설계 사례를 통해 공유합니다.


1. GraphQL에서 N+1이 특히 아픈 이유

REST와 GraphQL의 패러다임 차이

  • REST: 서버가 응답 스키마를 미리 알고 한 번의 로직으로 DTO를 조립합니다.
  • GraphQL: 클라이언트가 요청한 필드에 따라 각 필드 리졸버가 독립적으로 실행될 수 있습니다.

필드 리졸버가 개별 DB 쿼리를 던질 때

상품 20개를 조회할 때, wishCountimageUrl 필드가 각각 리졸버에서 DB를 조회한다면 어떤 일이 벌어질까요?

  1. 루트 리졸버: 상품 20개 조회 (1번)
  2. wishCount 리졸버: 각 상품마다 위시 카운트 조회 (20번)
  3. imageUrl 리졸버: 각 상품마다 이미지 조회 (20번)

결과:41번(1 + 20 + 20)의 쿼리가 발생합니다.
이것이 바로 N+1 문제입니다.


2. 필드 리졸버와 데이터 접근 경로 N+1의 정체

N+1은 GraphQL만의 문제가 아니라 "데이터 접근 경로가 1+N 구조로 열려 있는가"의 문제입니다.

  • 경로 1 위험: 필드별 독립 리졸버가 루프를 돌며 DB를 직접 호출.
  • 경로 2 IdolGlow 방식: 루트 리졸버에서 필요한 모든 정보를 배치로 가져와 메모리에서 조립.

결국 N+1은 서버 구현자가 "이 정보가 필요하다"라는 사실을 언제 인지했느냐에 따라 결정됩니다.


3. DataLoader 패턴

GraphQL 생태계에서 N+1의 표준 해법은 DataLoader입니다.

  1. 배치: 여러 번의 개별 요청을 모았다가 하나의 IN 쿼리로 묶어 처리합니다.
  2. 캐싱: 동일한 요청 범위 내에서 같은 키로 호출되면 DB를 다시 치지 않고 메모리 캐시를 반환합니다.

4. IdolGlow 상품 탐색의 설계

IdolGlow는 복잡한 필터링 조건을 처리해야 합니다.
이를 위해 단순 필드 리졸버에 의존하지 않고 강력한 검색 엔진급 쿼리 저장소를 설계했습니다.

주요 필터링 전략

  • 지역 필터: Haversine 공식으로 사용자 좌표 기준 반경 내 상품 필터링.
  • 카테고리 필터: 다중 태그 선택 시 모든 태그를 포함하는 상품만 추출.
  • 가격 필터: 상품에 속한 여러 옵션 중 최저가를 기준으로 범위 필터링.

5. 리포지토리 단계에서의 배치 로딩 구현

IdolGlow는 DataLoader 라이브러리를 쓰기 전, 리포지토리 단계에서 수동 배치 로딩 패턴을 적용하여 성능을 극대화했습니다.

구현 3단계

  1. 1단계 : 필터와 정렬 조건에 맞는 상품 ID 20개만 빠르게 조회합니다.
  2. 2단계 : 수집된 ID 목록을 들고 wishCount, reviewMetrics, thumbnailUrl을 각각 IN 쿼리 한 번으로 가져옵니다.
  3. 3단계: 가져온 정보를 Map 형태로 저장하고, ID 순서에 맞춰 DTO를 조립합니다.
// 리포지토리 구현 예시
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번 쿼리


6.성능과 캐싱 GraphQL 와 REST

서버 내부 로직을 동일한 서비스로 공유하기 때문에 순수 DB 성능 차이는 거의 없습니다.
다만 인프라 계층에서의 차이가 존재합니다.

  • REST: URL 자체가 캐시 키가 되므로 CDN이나 리버스 프록시 캐싱에 매우 유리합니다.
  • GraphQL: 주로 POST를 사용하므로 캐싱 계층이 쿼리 본문을 파싱해야 하는 복잡함이 있습니다.

IdolGlow는 이 점을 고려하여, 상품 탐색처럼 캐싱이 중요한 도메인은 REST와 GraphQL 경로 모두에서 동일한 최적화 로직을 타도록 설계했습니다.


7. 언제 DataLoader를 고려할 것인가

현재 IdolGlow 설계의 가치

  • 예측 가능성: 필드 리졸버가 없으므로 N+1이 원천적으로 발생할 수 없습니다.
  • 단순성: REST와 GraphQL이 동일한 서비스 로직을 공유하여 유지보수가 쉽습니다.

DataLoader가 필요한 시점

만약 서비스가 성장하여 중첩된 리소스 예: 상품 -> 리뷰 -> 작성자 의 깊이가 깊어지거나, 클라이언트가 선택한 필드에 따라 DB 부하를 동적으로 줄여야 하는 시점이 온다면 그때 리졸버 단위로 쪼개고 DataLoader를 붙이는 리팩터링이 의미를 가집니다.


IdolGlow의 설계

"N+1 문제는 기술의 탓이 아니라 설계의 선택입니다."

IdolGlow는 "미리 아는 정보는 처음부터 배치로 처리한다"는 원칙을 고수했습니다.
복잡한 상품 탐색 환경에서는 유연한 리졸버보다 견고한 배치 로딩 전략이 시스템의 안정성을 보장하는 가장 확실한 방법이기 때문입니다.

이번 구현을 통해 N+1 문제는 단순히 GraphQL이나 ORM의 문제가 아니라, 데이터를 어떤 단위로 읽고 조립할 것인가에 대한 설계 문제라는 점을 배웠습니다.

처음에는 GraphQL의 필드 리졸버 구조만 보면 클라이언트가 원하는 필드만 가져올 수 있어 효율적이라고 생각하기 쉽습니다.

하지만 상품 목록처럼 여러 개의 데이터를 한 번에 조회하고, 각 상품마다 위시 수, 리뷰 점수, 썸네일 같은 부가 정보를 함께 조립해야 하는 경우에는 필드 단위 조회가 오히려 반복 쿼리를 만들 수 있습니다.

그래서 중요한 것은 “GraphQL을 썼는가”가 아니라, 반복될 수 있는 조회를 미리 식별하고 ID 목록 기준으로 한 번에 처리하는 구조를 만들었는가였습니다.

상품 ID를 먼저 모으고, 위시 수·리뷰 지표·썸네일 이미지를 배치로 조회한 뒤 조립하는 방식으로 성능을 예측 가능하게 만들었습니다.

GraphQL은 필드 선택의 자유도가 높고 확장성이 좋아 보이지만, 실제 서비스에서는 그 자유도가 서버 내부의 조회 비용으로 돌아올 수 있습니다.

특히 상품 탐색처럼 목록 조회가 많고, 여러 집계 데이터가 함께 필요한 화면에서는 무작정 필드 리졸버를 잘게 쪼개기보다 처음부터 조회 흐름을 통제하는 배치 전략이 더 현실적이었습니다.

또한 성능 최적화는 나중에 붙이는 장식이 아니라, 도메인 조회 흐름을 설계할 때부터 같이 고민해야 한다는 점을 체감했습니다.

조회 수가 작을 때는 문제가 드러나지 않지만, 상품 수가 늘고 부가 필드가 많아지면 N+1은 바로 병목이 됩니다.

결국 백엔드 개발자는 API 모양만 만드는 사람이 아니라, 데이터가 실제로 어떻게 움직이는지까지 책임지는 사람이어야 한다고 느꼈습니다.

profile
그냥 코딩할래요 재미있어요

0개의 댓글