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 적용
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로 설계해야 배치 전체가 깨지지 않습니다




