개발
home

BFF GraphQL N+1 호출 개선 — DataLoader로 12콜을 1콜로

Created
2026/07/02
Tags
N+1 문제
성능 최적화
node
Kotlin
트러블슈팅
2026-07-02

개요

상품 상세 페이지(PDP) 진입 시 일부 상품이 10초 이상 걸리는 인시던트를 만났습니다.
원인을 추적해 보니 BFF(GraphQL)가 상품 노드마다 백엔드 API를 단건 호출하는 N+1 문제였고, DataLoader 배치를 적용해 트레이스 기준 12콜을 1콜로 줄였습니다.
배포 후 서버 간 요청 수는 약 80% 감소했고, p95 latency는 60~80% 개선되어 백엔드 서버의 pod 수까지 줄일 수 있었습니다. 그 과정을 정리합니다.

본문

문제 발견: PDP가 10초씩 걸린다

딜 목록에서 상품 상세 페이지로 들어갈 때 일부 상품의 상세가 매우 늦게 뜬다는 제보가 있었습니다.
Datadog APM에서 PDP GraphQL 쿼리를 확인해 보니 median duration이 10초를 초과한 상태였고, BFF 로그에는 다음 에러가 반복되고 있었습니다.
Timed out while checking out a connection from connection pool
Plain Text
복사
MongoDB 커넥션 풀에서 커넥션을 빌리지 못해 타임아웃이 나는 상황이었습니다. 급한 불을 끄기 위해 두 가지 임시 조치를 먼저 했습니다.
커넥션 풀 maxPoolSize 상향 (20 → 30)
BFF 레플리카 일시 증설
지표는 내려갔지만, DBA 분과 함께 본 결론은 같았습니다. 커넥션 풀 사이즈는 근본 원인이 아니다. 풀을 늘리는 것은 증상 완화일 뿐, 애초에 왜 커넥션이 그렇게 많이 필요한지를 찾아야 했습니다.

원인 분석: 진짜 범인은 N+1 호출

APM 트레이스를 열어 보니 답이 바로 보였습니다. 동일한 패턴의 쿼리가 단일 요청 안에서 수십 개씩 나가고 있었습니다.
GraphQL 리졸버는 노드 단위로 실행됩니다. PDP 쿼리가 상품 12개를 내려주면, 각 상품의 dealV2 필드 리졸버가 각자 백엔드 API의 딜 단건 조회(GET /deals/{id})를 호출했습니다.
쿼리 하나하나는 빠릅니다 (수십 ms)
하지만 요청 1건이 서버 간 HTTP 콜 12개 + 건별 DB 조회로 팬아웃됩니다
트래픽이 몰리면 커넥션 풀이 순간적으로 고갈되고, 풀 대기 타임아웃 → 10초+ 지연으로 이어집니다
N+1은 ORM(JPA Lazy Loading)만의 문제가 아닙니다. GraphQL 리졸버 구조에서는 서버 간 API 호출에서도 똑같이 발생하며, 마이크로서비스 환경에서는 커넥션 풀 고갈이라는 형태로 터집니다.

해결 1: 백엔드에 배치 조회 엔드포인트 추가

먼저 백엔드(Kotlin/Spring) API에 ID 다건 배치 엔드포인트를 추가했습니다. POST /deals/by-ids 형태로, ID 목록을 받아 IN 조회 2회로 다건을 응답합니다.
@Transactional(readOnly = true) override fun getDealsByDealProductIds(dealProductIds: List<String>): List<DealByDealProductIdResponseDTO> { if (dealProductIds.isEmpty()) { return emptyList() } val dealProducts = dealProductOutputPort.findLiveByIdIn(dealProductIds) val dealsById = dealOutputPort.findLiveByIdsIn(dealProducts.map { it.dealId }.distinct()) .associateBy { it.id } return dealProducts.mapNotNull { dealProduct -> val deal = dealsById[dealProduct.dealId] ?: return@mapNotNull null DealByDealProductIdResponseDTO.of(dealProductId = dealProduct.getId(), deal = deal) } }
Kotlin
복사
설계할 때 신경 쓴 부분은 두 가지입니다.
단건과 동일한 시맨틱 유지: 기존 단건 조회와 같은 live(=미삭제) 조건으로 조회해 동작 차이가 없도록 했습니다
누락 키는 예외 대신 제외: 단건은 미존재 시 예외를 던지지만, 배치에서는 삭제/만료된 ID 하나 때문에 전체 배치가 실패하면 안 되므로 결과에서 제외합니다
엔드포인트를 GET이 아닌 POST로 만든 이유: ID 수십 개를 쿼리스트링에 실으면 URL 길이 제한에 걸릴 수 있어, body로 받도록 했습니다.

해결 2: BFF에 DataLoader 적용

BFF 쪽에는 GraphQL 생태계의 표준 해법인 DataLoader를 적용했습니다.
DataLoader는 같은 이벤트 루프 틱에서 발생한 load(key) 호출들을 모아 배치 함수를 한 번만 실행하고, 결과를 요청 키 순서대로 각 호출자에게 되돌려 줍니다. 구현은 다음과 같습니다.
import DataLoader from 'dataloader' export class DealLoader { constructor(private dealAPI: DealAPI) {} // dealProductId 단위로 딜을 배치 조회한다. // 상품마다 단건 조회를 호출하던 N+1을 1콜로 대체. byDealProductId = new DataLoader<string, Deal | null>( async (dealProductIds) => { const { data } = await this.dealAPI.getDealsByDealProductIds({ dealProductIds: [...new Set(dealProductIds)], }) const dealByDealProductId: Record<string, Deal> = {} data.forEach((item) => { dealByDealProductId[item.dealProductId] = item.deal }) // 순서 보장 (삭제/만료로 누락된 키는 null) return dealProductIds.map((id) => dealByDealProductId[id] ?? null) }, ) }
TypeScript
복사
리졸버 쪽 변경은 한 줄입니다. 호출하는 코드는 여전히 "단건 조회"처럼 생겼지만, 배치는 DataLoader가 투명하게 처리합니다.
// Before: 상품마다 단건 HTTP 호출 (N+1) const deal = await dealService.getDealByDealProductId(dealProductId) // After: 같은 틱의 요청을 모아 1콜로 배치 const deal = await loaders.dealLoader.byDealProductId.load(dealProductId)
TypeScript
복사
DataLoader 배치 함수 작성 시 지켜야 할 계약이 있습니다.
반환 배열은 요청 키 배열과 같은 길이, 같은 순서여야 합니다
조회되지 않은 키는 그 자리에 null을 채워야 합니다 (여기서는 dealV2: null로 내려가 기존 미존재 처리와 동일하게 동작)
중복 키가 들어올 수 있으므로 API 호출 전에 Set으로 중복을 제거했습니다

배포 순서 주의

배포 순서가 강제됩니다: 백엔드 API 먼저 → BFF 나중.
BFF가 새 배치 엔드포인트를 호출하므로, 역순으로 배포하면 404가 발생해 해당 필드가 null로 회귀합니다. 신규 엔드포인트에 의존하는 변경은 항상 공급자(provider)부터 배포해야 합니다.

결과

배포 후 지표로 확인한 개선 효과입니다.
딜 조회 서버 간 요청: 12콜 → 1콜 (요청 수 약 80% 감소)
PDP 쿼리 p95 latency: 60~80% 개선
커넥션 풀 타임아웃 에러 소멸
백엔드 API 유입 요청이 줄어 minReplica 10 → 4로 pod 수 축소 (인프라 비용 절감)
이번 작업에서 배운 것을 정리하면 이렇습니다.
커넥션 풀 튜닝은 증상 완화, N+1 제거가 치료입니다. 풀 사이즈를 늘리기 전에 "왜 커넥션이 이렇게 많이 필요한가"를 먼저 물어야 합니다
GraphQL BFF에서 노드 단위 리졸버가 외부 API를 호출한다면 DataLoader는 선택이 아니라 기본값이어야 합니다
배치 엔드포인트는 단건과 시맨틱을 맞추되, 부분 실패(누락 키)는 예외가 아닌 제외/null로 설계해야 배치 전체가 깨지지 않습니다