GS리테일 WMS — 슈퍼마켓 TC 출고확정 시스템 개발기

Do Hyun ·2026년 6월 5일

트러블슈팅

목록 보기
3/5

GS리테일 WMS — 슈퍼마켓 TC 출고확정 시스템 개발기

대규모 물류센터의 출고확정 플로우를 처음부터 설계하고, 성능 병목을 찾아 개선하고, 재고 오판단 버그를 잡기까지의 기록입니다.


배경

GS리테일 슈퍼마켓 물류센터에는 TC(Transfer Center, 크로스도킹 센터) 라는 운영 방식이 있습니다. DC(Distribution Center)와 달리 TC는 상품이 입고되자마자 출고 준비를 하는 구조인데, 재고가 시스템상으로 존재하는 시간이 극히 짧고 입고와 출고가 거의 동시에 일어납니다.

이 TC 환경에서 출고확정(Dispatch Confirm) 이란 — "오늘 입고된 물량을 점포별로 배분해서 실제로 출고했다" 는 사실을 시스템에 확정짓는 작업입니다. 이 한 번의 확정 작업 안에 재고 차감, ERP 인터페이스 송신, 출고결과 반영, RSHP 재고 이동, 미출 결재 생성까지 모두 일어납니다.

이 출고확정 시스템을 신규 개발하고, 이후 발생한 성능 문제와 재고 버그를 개선했습니다.


1. 출고확정 플로우 설계

전체 흐름

출고확정 API 하나를 호출하면 내부에서 아래 단계가 순서대로 실행됩니다.

① 사전 검증 (9단계 Error/Warning)
② 미출 결재 문서 자동 생성
③ ShipmentItem 상태 → COMPLETED
④ TC 재고 → 출고 스테이지 Outbound (LPN 기반 FIFO 배분)
⑤ 출고결과 반영 + 출고마감 (병렬)
⑥ TC 잔여재고 → RSHP 로케이션 이동

단계별로 실패하면 이전 단계를 보상(Compensate)하는 구조로 설계했습니다.

사전 검증 — Error vs Warning 분리

확정 버튼을 누르기 전에 사용자에게 미리 알려주는 검증 단계가 있습니다. 여기서 중요한 설계 결정 하나는 Error와 Warning을 분리한 것입니다.

구분항목동작
ERROR출고수량 > 원주문수량확정 차단
ERROR미출사유 누락확정 차단
ERRORTC 재고 부족확정 차단
ERRORRSHP 잔여재고 존재확정 차단
WARNING입출고 수량 차이확인 후 진행 가능
WARNING출고단위 불일치확인 후 진행 가능
WARNING수주수신 오류 이력확인 후 진행 가능
WARNINGRSHP 이동 예정 물량 안내정보성

Error는 절대적인 차단 조건이고, Warning은 사용자가 인지하고 진행할 수 있는 조건입니다. 처음에는 전부 Error로 만들었다가, 현장 운영 피드백을 받고 이 구조로 개편했습니다.

이 검증 단계에서 조회해야 하는 데이터가 많기 때문에 CompletableFuture로 병렬 조회를 적용했습니다.

// 7개 조회를 동시에 실행
CompletableFuture<Map<InventoryCompareKey, Long>> tcInventoryFuture =
    CompletableFuture.supplyAsync(() ->
        toInventoryMap(inventoryFacade.findInventoryGroupByLpnId(centerCode, TC_STAGE)), executor);

CompletableFuture<Map<InventoryCompareKey, Long>> rshpInventoryFuture =
    CompletableFuture.supplyAsync(() ->
        toInventoryMap(inventoryFacade.findInventoryGroupByLpnId(centerCode, RSHP_STAGE)), executor);

CompletableFuture<Map<String, SkuCenterVO>> skuCenterFuture = ...;
CompletableFuture<Map<String, VendorSupplierVO>> vendorSupplierFuture = ...;
// ... 3개 더

CompletableFuture.allOf(tcInventoryFuture, rshpInventoryFuture, ...).join();

LPN 기반 FIFO 재고 배분

TC 재고는 LPN(License Plate Number) 단위로 관리됩니다. 출고 수량을 LPN에 배분할 때 선입선출 원칙을 지켜야 하는데, 이를 위해 아래 우선순위로 정렬했습니다.

// 유통기한 빠른 것 → 입고일 빠른 것 → 수량 배분
Comparator<InventoryGroupByLpnIdVO> inventoryPriority = Comparator
    .comparing(v -> v.getExpiredDate() == null ? 1 : 0)
    .thenComparing(v -> v.getExpiredDate() != null ? v.getExpiredDate() : LocalDate.MAX)
    .thenComparing(v -> v.getReceivedDate() != null ? v.getReceivedDate() : LocalDate.MAX);

배분은 LPN별 잔여수량 Map을 유지하면서 요청 수량이 소진될 때까지 순차 차감하는 방식입니다. 복수의 LPN에 걸쳐 한 품목이 나뉘어 있는 경우도 자연스럽게 처리됩니다.

부분 실패 보상 트랜잭션

재고 outbound는 SKU 단위로 병렬로 처리되는데, 일부 SKU가 실패해도 나머지 성공한 SKU는 정상 처리되어야 합니다. 이를 위해 실패한 SKU에 한해서만 ShipmentItem 상태를 이전으로 되돌리는 보상 로직을 구현했습니다.

// 실패한 SKU 목록
Set<String> failedSkuCodes = failures.stream()
    .map(x -> x.groupKey().skuCode())
    .collect(Collectors.toSet());

// 실패 SKU의 ShipmentItem만 롤백
List<SmRoomTcDispatchCompleteItemQRO> failedSkuItems = confirmableItems.stream()
    .filter(x -> failedSkuCodes.contains(x.getSkuCode()))
    .toList();
compensateShipmentItems(failedSkuItems);

// 성공 SKU는 계속 진행
List<SmRoomTcDispatchCompleteItemQRO> successItems = confirmableItems.stream()
    .filter(x -> !failedSkuCodes.contains(x.getSkuCode()))
    .toList();

미출 결재 자동 생성

출고수량 < 원주문수량인 경우(미출), 사유 코드와 함께 Approval 시스템에 결재 문서를 자동 생성합니다. 운영자가 별도로 결재 요청을 올리지 않아도 출고확정 시점에 자동으로 연동됩니다.

TC 잔여재고 → RSHP 자동 이동

TC 출고확정 후에는 "입고수량 - 출고수량" 에 해당하는 물량이 TC 로케이션에 남습니다. 이 잔여분은 RSHP(Receiving Shipment Hold Point) 로케이션으로 옮겨야 다음 날 작업에 영향을 주지 않습니다.

입고수량(100) - 출고수량(80) = 20 → TC → RSHP 자동 이동

이 계산과 이동도 출고확정 흐름의 마지막 단계로 자동 처리됩니다.


2. 인터페이스 송신 병목 개선 — 80% 처리시간 단축

문제 발견

출고확정이 완료된 이후 ERP(Oracle 레거시 시스템)로 출고 데이터를 인터페이스 송신하는 부분이 있습니다. 초기 구현은 아래와 같이 단순한 반복문이었습니다.

// AS-IS: N건 순차 처리
order = orderRepository.save(order);  // JPA dirty checking → N번 UPDATE

for (TeSmTcshpSndCO co : teSmTcshpSndCOS) {
    interfaceSendManager.sendAfterValid(targetTable, co, userCode);  // 1건씩 INSERT
}

Wave 1회에 처리하는 주문아이템이 수백 건 규모이다 보니, 건수가 늘어날수록 처리시간이 선형으로 증가했습니다.

개선 1 — Bulk UPDATE 전환

JPA dirty checking으로 개별 UPDATE하던 방식을 PostgreSQL의 UPDATE ... FROM (VALUES ...) 구문으로 변경했습니다.

-- 수백 건을 단 1번의 SQL로
UPDATE goods_dispatch_retailer_order_item
SET shipped_quantity = dt.shipped_quantity,
    insufficient_type = dt.insufficient_type,
    modified_at = dt.modified_at
FROM (
  VALUES
    (101, 80, NULL, '2025-06-05 09:00:00'::TIMESTAMP),
    (102, 50, 'STOCK_SHORTAGE', '2025-06-05 09:00:00'::TIMESTAMP),
    (103, 30, NULL, '2025-06-05 09:00:00'::TIMESTAMP)
    -- ... N건
) AS dt(id, shipped_quantity, insufficient_type, modified_at)
WHERE goods_dispatch_retailer_order_item.id = dt.id

BulkUpdateManager는 엔티티 클래스의 @Table, @Column 어노테이션을 리플렉션으로 읽어 SQL을 동적으로 생성합니다. 테이블명/컬럼명을 하드코딩하지 않아도 됩니다.

// 사용 방법
bulkUpdateManager.bulkUpdate(
    GoodsDispatchRetailerOrderItem.class,  // 엔티티 클래스 → 테이블명 자동 추론
    List.of("id"),                          // WHERE 조건 필드
    updateRows                              // 변경할 값들
);

개선 2 — 인터페이스 송신 병렬화

인터페이스 INSERT도 4개 스레드로 병렬 처리하도록 변경했습니다.

// TO-BE: 4 thread 병렬 전송
final ExecutorService executor = Executors.newFixedThreadPool(4);

List<CompletableFuture<Void>> futures = teSmTcshpSndCOS.stream()
    .map(co -> CompletableFuture.runAsync(
        () -> interfaceSendManager.sendAfterValid(targetTable, co, userCode),
        executor
    ))
    .toList();

CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
    .get(300, TimeUnit.SECONDS);

처리시간 개선 추정

Aurora PostgreSQL 환경 기준, DB INSERT/UPDATE RTT를 ~5~10ms로 가정했을 때:

처리 항목AS-ISTO-BE개선
OrderItem UPDATE (300건)300 × 5ms = 1.5초1번 SQL = ~50ms96% 감소
인터페이스 INSERT (300건)300 × 10ms = 3초300건 ÷ 4thread × 10ms = ~750ms75% 감소
합계~4.5초~800ms약 5.6배 단축

건수가 많아질수록 효과는 더 커집니다. Wave 규모가 500건이면 약 6배 단축입니다.


3. 협력사 기준 누락으로 인한 재고 오판단 버그

이 버그를 찾기까지 꽤 시간이 걸렸습니다. 현상은 분명한데 원인을 특정하기 어려운 케이스였습니다.

증상

TC 출고확정 후 가끔 아래 현상이 발생했습니다.

  • TC 로케이션에 남아 있어야 할 재고가 없어짐
  • RSHP로 이동되어야 할 물량이 처리되지 않음
  • 동일 상품인데 협력사가 다른 경우에 집중적으로 발생

원인 분석

재고를 집계할 때 key를 skuCode만으로 사용하고 있었습니다.

// AS-IS (버그): 상품코드만으로 재고 매핑
Map<String, Long> inventoryBySkuCode = tcInventoryList.stream()
    .collect(Collectors.groupingBy(
        InventoryGroupByLpnIdVO::getSkuCode,
        Collectors.summingLong(InventoryGroupByLpnIdVO::getTouchableQuantity)
    ));

TC에는 동일한 상품이 다른 협력사로 입고될 수 있습니다. 예를 들어 SKU001이 A협력사와 B협력사에서 각각 입고되는 경우, 두 재고는 완전히 다른 것으로 취급해야 합니다. 그런데 상품코드만으로 집계하면 두 재고가 합쳐지면서 배분 로직이 엉키게 됩니다.

상황:
  TC 재고 A협력사/SKU001 = 100개
  TC 재고 B협력사/SKU001 = 50개

출고 요청:
  B협력사/SKU001 = 60개

AS-IS 동작:
  SKU001 총재고 = 150개 → 출고 가능 판정 ✓
  BUT 배분 시 B협력사 재고 50개 소진 후 A협력사 재고 10개 추가 차감 ← 문제!
  → A협력사 재고 10개 영문도 모르게 소멸
  → B협력사 RSHP 이동 계산도 틀어짐

수정

재고 비교 단위에 협력사 코드를 포함하는 복합키를 도입했습니다.

// TO-BE: (센터, 협력사, 상품) 복합키
private record InventoryCompareKey(
    String centerCode,
    String vendorSupplierCode,  // ← 핵심 추가
    String skuCode
) {}

// 재고 집계 시 협력사 기준 엄격 적용
Map<InventoryCompareKey, List<InventoryGroupByLpnIdVO>> inventoryByKey =
    tcInventoryList.stream()
        .sorted(inventoryPriority)  // FIFO 정렬
        .collect(Collectors.groupingBy(x ->
            new InventoryCompareKey(
                x.getCenterCode(),
                x.getVendorSupplierCode(),  // 협력사 분리
                x.getSkuCode()
            )
        ));

출고 배분, TC→RSHP 이동 계산, 재고 부족 validation까지 모두 이 복합키 기준으로 동작하도록 일관되게 변경했습니다.

수정 후 동작:
  A협력사/SKU001 재고 = 100개 (별도 관리)
  B협력사/SKU001 재고 = 50개 (별도 관리)

출고 요청 B협력사/SKU001 = 60개:
  → B협력사 재고 50개로 부족 판정 → 명확한 오류 메시지 출력
  → A협력사 재고는 영향 없음 ✓

교훈

재고 시스템에서 "같은 상품"이라는 개념은 생각보다 훨씬 좁습니다. 물류 도메인에서는 동일 SKU라도 협력사, 입고일, 유통기한에 따라 완전히 다른 재고로 취급해야 하는 경우가 많습니다. 집계 단위를 설정할 때 도메인 전문가와 함께 "무엇이 같고 무엇이 다른가"를 명확히 정의하는 게 중요하다는 걸 이 버그를 통해 배웠습니다.


마무리

항목내용
프로젝트GS리테일 슈퍼마켓 WMS
역할TC 출고확정 시스템 신규 개발
기간2024년 하반기 ~ 2025년 상반기
기술 스택Java 17, Spring Boot 3, Spring Data JPA, Aurora PostgreSQL, CompletableFuture
주요 성과- 9단계 사전검증 + 보상 트랜잭션 출고확정 플로우 설계
- Bulk UPDATE + 병렬 인터페이스 송신으로 처리시간 ~82% 단축
- 협력사 복합키 도입으로 재고 오판단 버그 해결

실제 물류 현장에서 수십만 건의 상품이 오가는 시스템을 처음부터 설계하고 운영하면서, 성능 숫자 뒤에 있는 도메인 이해가 얼마나 중요한지 크게 느낀 프로젝트였습니다.

profile
우당탕탕

0개의 댓글