쿼리 N번 → 1번: TODO 주석으로 남아있던 주문분배 N+1

Do Hyun ·2026년 6월 5일

트러블슈팅

목록 보기
4/5

쿼리 N번 → 1번: TODO 주석으로 남아있던 주문분배 N+1 제거하기

배경

우리 시스템의 주문분배(Order Distribution) 는 주문이 들어왔을 때 어느 물류센터에서 출고할지를 결정하는 핵심 배치다.

분배 로직의 핵심은 간단하다.

센터별 실제 가용재고 = 외부 시스템 재고 − 이미 분배된 주문 수량

여기서 "이미 분배된 주문 수량"을 계산하는 getAvailableQuantity 메서드에 N+1 문제가 숨어 있었고, 주석으로도 명시되어 있었다.

//TODO 대량으로 내릴때 성능 개선이 꼭 필요하다.
private Long getAvailableQuantity(Long attrPrdCd, DistributionStockVO skuStockDTO, ...) {
    ...
    ditributedQty = gsOrderItemRepository.getDistributedAndUndistributedSkuQuantity(
        skuId, CenterCodeEnum.valueOf(skuStockDTO.getCenterCode())
    );
    ...
}

문제 상황

호출 흐름

getAvailableCenterInventory(attrPrdCd, skuIds, ...)
  └─ getSkuStockBySkuIdSet(skuIds)          // 재고 조회: 1번 (OK)
  └─ for each DistributionStockVO {          // SKU 수만큼 반복
       getAvailableQuantity(...)             // ← 여기서 DB 쿼리 1번씩
         └─ getDistributedAndUndistributedSkuQuantity(skuId, centerCode) // N번!
     }

SKU가 10개면 DB 쿼리가 10번, 100개면 100번 날아간다.

문제 쿼리

// 기존: skuId 하나씩 개별 조회
public Long getDistributedAndUndistributedSkuQuantity(Long skuId, CenterCodeEnum centerCode) {
    sb.append("SELECT SUM(goics.quantity * sam.quantity) ");
    sb.append("  FROM GsOrderItem goi, GsOrderItemCenterSku goics, SkuAttrMap sam ");
    sb.append(" WHERE sam.skuId = :skuId ");      // ← 단건 조회
    sb.append("   AND goics.centerCode IN ('DCXX', :centerCode) ");
    // ...
}

이 쿼리가 루프마다 실행되므로, SKU N개 → DB 쿼리 N번이 보장된다.


해결

핵심 아이디어

루프 안에 있는 DB 조회를 루프 밖으로 꺼내고, IN 절로 한 번에 처리한다.

1) 배치 조회 메서드 추가

기존 메서드는 그대로 두고, Set<Long> 을 받아 Map<skuId, qty> 를 반환하는 배치 버전을 추가했다.

// 인터페이스
Map<Long, Long> getDistributedSkuQuantityBatch(Set<Long> skuIds, CenterCodeEnum centerCode);
Map<Long, Long> getDistributedAndUndistributedSkuQuantityBatch(Set<Long> skuIds, CenterCodeEnum centerCode);
// 구현 — GROUP BY로 skuId별 합계를 한 방에
public Map<Long, Long> getDistributedAndUndistributedSkuQuantityBatch(Set<Long> skuIds, CenterCodeEnum centerCode) {
    sb.append("SELECT sam.skuId, SUM(goics.quantity * sam.quantity) ");
    sb.append("  FROM GsOrderItem goi, GsOrderItemCenterSku goics, SkuAttrMap sam ");
    sb.append(" WHERE sam.skuId IN :skuIds ");    // ← IN절로 전체 조회
    sb.append("   AND goics.centerCode IN ('DCXX', :centerCode) ");
    sb.append(" GROUP BY sam.skuId ");
    // ...

    Map<Long, Long> result = new HashMap<>();
    for (Object[] row : (List<Object[]>) query.getResultList()) {
        if (row[1] != null) {
            result.put(Long.parseLong(row[0].toString()), Long.parseLong(row[1].toString()));
        }
    }
    return result;
}

2) 서비스 리팩터링

getAvailableCenterInventory에서 루프 전에 배치 조회를 한 번 실행하고, 루프 안에서는 Map 룩업만 한다.

// 기존 구조
for (DistributionStockVO skuStockDTO : skuStockBySkuIdSet) {
    // 루프마다 DB 쿼리 1번
    getAvailableQuantity(attrPrdCd, skuStockDTO, distributionTypeEnum);
}

// 개선 구조
// 1. 루프 전: 전체 skuId를 한 번에 배치 조회
Map<String, Map<Long, Long>> distributedQtyByCenterAndSku =
    batchFetchDistributedQuantities(attrPrdCd, skuStockBySkuIdSet, distributionTypeEnum);

// 2. 루프 안: Map 룩업만 (DB 호출 없음)
for (DistributionStockVO skuStockDTO : skuStockBySkuIdSet) {
    getAvailableQuantity(attrPrdCd, skuStockDTO, distributedQtyByCenterAndSku);
}

batchFetchDistributedQuantities는 센터 코드별로 skuId를 그룹핑해 배치 쿼리를 날린다. 분배 타입이 REDISTRIBUTION이냐 아니냐에 따라 다른 배치 메서드를 호출한다.

private Map<String, Map<Long, Long>> batchFetchDistributedQuantities(
        Long attrPrdCd, List<DistributionStockVO> skuStockList,
        DistributionTargetItem.DistributionTypeEnum distributionTypeEnum) {

    // 센터 코드별로 skuId 수집
    Map<String, Set<Long>> skuIdsByCenterCode = new HashMap<>();
    for (DistributionStockVO stock : skuStockList) {
        if (stock.getCenterCode().equals(GUNPO_CENTER_CODE)
                || stock.getCenterCode().equals(ICHEON_CENTER_CODE)) {
            skuIdsByCenterCode
                    .computeIfAbsent(stock.getCenterCode(), k -> new HashSet<>())
                    .add(stock.getSkuId());
        }
    }

    // 센터 코드별로 배치 쿼리 1번씩
    Map<String, Map<Long, Long>> result = new HashMap<>();
    for (Map.Entry<String, Set<Long>> entry : skuIdsByCenterCode.entrySet()) {
        String centerCode = entry.getKey();
        CenterCodeEnum centerCodeEnum = CenterCodeEnum.valueOf(centerCode);
        Map<Long, Long> qtyMap;

        if (distributionTypeEnum == REDISTRIBUTION) {
            if (attrPrdCd.equals(96528474001L)) {  // 기존 긴급 로직 유지
                qtyMap = Collections.emptyMap();
            } else {
                qtyMap = gsOrderItemRepository.getDistributedSkuQuantityBatch(
                    entry.getValue(), centerCodeEnum);
            }
        } else {
            qtyMap = gsOrderItemRepository.getDistributedAndUndistributedSkuQuantityBatch(
                entry.getValue(), centerCodeEnum);
        }
        result.put(centerCode, qtyMap);
    }
    return result;
}

getAvailableQuantity는 DB 쿼리를 제거하고 Map 룩업으로 바뀐다.

// 변경 후 — DB 호출 없음
private Long getAvailableQuantity(Long attrPrdCd, DistributionStockVO skuStockDTO,
        Map<String, Map<Long, Long>> distributedQtyByCenterAndSku) {
    Long result = skuStockDTO.getAvailqty();
    Long skuId = skuStockDTO.getSkuId();
    String centerCode = skuStockDTO.getCenterCode();

    if (centerCode.equals(GUNPO_CENTER_CODE) || centerCode.equals(ICHEON_CENTER_CODE)) {
        Long distributedQty = distributedQtyByCenterAndSku
                .getOrDefault(centerCode, Collections.emptyMap())
                .get(skuId);   // ← Map 룩업 (O(1))

        if (distributedQty != null) {
            result -= distributedQty;
        }
    }
    if (centerCode.equals(ICHEON_CENTER_CODE)) {
        result -= getDC01DesignatedSkuQuantity(skuId);
    }
    return result;
}

변경 전후 비교

항목변경 전변경 후
분배된 수량 조회SKU마다 SELECT 1번 (N번)전체 SKU IN 쿼리 1번
센터 코드 수가 C개일 때N × C 번C 번
현재 운영 환경 (군포 단일)SKU N번1번

현재는 군포 센터만 필터링해 사용하므로 N번 → 1번으로 줄어든다.


기존 특수 케이스 처리

개선 전 코드에는 특정 상품(attrPrdCd = 96528474001L)에 대해 REDISTRIBUTION 시 분배 수량을 0으로 처리하는 긴급 로직이 있었다.

if (attrPrdCd.equals(96528474001L)) {
    ditributedQty = 0L;  // 긴급 로직
}

배치화 후에도 이 케이스는 유지된다. 해당 상품이면 배치 쿼리 자체를 건너뛰고 빈 맵을 넣어두면, map.get(skuId)null을 반환하고 차감 없이 그대로 통과한다.


정리

반복문 안에서 I/O가 일어나고 있다면 항상 의심해야 한다.

  1. 루프 안 DB 조회 → 루프 밖으로 꺼내 IN 쿼리 + Map 전처리
  2. 리턴 타입을 LongMap<Long, Long> 으로 바꾸면 호출부 시그니처도 자연스럽게 정리된다
  3. 기존 특수 케이스(긴급 로직, null 처리)는 루프 밖에서도 동일하게 적용할 수 있다

N+1은 작은 데이터일 땐 눈에 안 띄지만, 주문이 몰리는 시간대에 분배 배치가 느려지는 원인이 된다.

profile
우당탕탕

0개의 댓글