우리 시스템의 주문분배(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절로 한 번에 처리한다.
기존 메서드는 그대로 두고, 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;
}
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가 일어나고 있다면 항상 의심해야 한다.
Long → Map<Long, Long> 으로 바꾸면 호출부 시그니처도 자연스럽게 정리된다N+1은 작은 데이터일 땐 눈에 안 띄지만, 주문이 몰리는 시간대에 분배 배치가 느려지는 원인이 된다.