풀필먼트·WMS 시스템 설계 — 하루 100만 건 출고를 정확히 처리하는 법
한 줄 요약: 풀필먼트 시스템의 핵심은 세 가지다. 주문 할당 엔진으로 올바른 센터를 고르고, 피킹 최적화로 창고 이동 거리를 줄이며, 재고 실시간 동기화로 “있다고 표시된 물건이 실제로 있음”을 보장한다. 이 세 가지를 WHY 중심으로 이해하면 물류 시스템 면접의 80%를 커버할 수 있다.
실제 사고: 풀필먼트 시스템이 무너지면 어떤 일이 벌어지나
2021년 블랙프라이데이 직후, 아마존 FBA 센터에서 약 300만 건의 출고 처리 지연이 발생했습니다. 원인은 단순하지 않았습니다. 피킹 배치 알고리즘이 피크 트래픽에서 경쟁 조건(Race Condition)을 일으켜 동일 재고를 두 피커(Picker)가 동시에 할당받는 문제였습니다. 결과는 피킹 실패(물건이 없음), 빈 패킹 라인, 출고 지연의 도미노였습니다. 아마존은 48시간 만에 미국 7개 FC(Fulfillment Center) 전역에서 수작업 재고 조사를 진행했습니다.
국내 사례도 있습니다. 쿠팡은 2022년 신규 물류센터 오픈 초기에 WMS와 커머스 플랫폼 간 재고 동기화 지연 버그로 “재고 있음”으로 팔린 상품 수만 건이 실제로는 없는 상태였습니다. 사용자에게는 주문 완료 알림이 갔지만, 창고에는 물건이 없었습니다. 보상 쿠폰과 재발송 비용이 수십억 원 규모였습니다.
네이버 풀필먼트 얼라이언스(NFA)에서는 복수 3PL 업체들의 재고를 단일 인터페이스로 통합하는 과정에서, 각 3PL의 재고 업데이트 지연이 서로 달라 동일 SKU가 두 센터에서 동시에 출고 할당되는 “이중 출고” 버그가 발생했습니다. 한 건의 주문에 두 개의 배송이 나가거나, 두 번의 할당 중 하나가 실패해 미출고가 되는 상황이 반복됐습니다.
이 세 사고의 공통점은 하나입니다. 재고 상태(Inventory State)와 실제 창고 상태(Physical State)의 불일치입니다. 풀필먼트 시스템 설계의 최우선 목표는 이 두 상태를 항상 일치시키는 것입니다.
1. 설계 의사결정 로드맵
풀필먼트 시스템은 설계 초기에 다섯 가지 의사결정을 해야 합니다. 각 결정은 전체 아키텍처를 규정하므로, 근거 없이 선택하면 면접에서 즉시 검증받습니다.
결정 1 — 피킹 전략: 단건 vs 배치 vs 웨이브
| 후보 | 처리 방식 | 장점 | 단점 | 적합 상황 |
|---|---|---|---|---|
| 단건 피킹 (Discrete) | 주문 1건 → 피커 1명 전담 | 오배송 최소, 추적 단순 | 이동 거리 최대, 처리량 낮음 | 고가품, 낮은 주문량 |
| 배치 피킹 (Batch) | 주문 N건을 피커 1명이 한 번에 | 이동 거리 1/N, 처리량 증가 | N이 크면 카트 무게·복잡도 증가 | 중소형 FC, 일반 이커머스 |
| 웨이브 피킹 (Wave) | 시간대별 출고 마감 기준으로 묶음 | 패킹·출하 라인과 리듬 동기화 | 파이프라인 설계 복잡 | 대형 FC, 항공화물 마감 연동 |
우리의 선택: 배치 피킹 (기본), 웨이브 피킹 (피크 타임)
배치 피킹은 마트에서 여러 고객의 장바구니를 한 번에 담아주는 직원과 같습니다. 통로를 한 번 지날 때 여러 주문의 물건을 집으면 이동 거리가 극적으로 줄어듭니다.
평시에는 배치 사이즈 8~12건으로 설정해 피커 1인당 이동 거리를 최소화합니다. 피크 타임(오전 9~11시, 저녁 7~9시)에는 웨이브 피킹으로 전환해 컨베이어 벨트·패킹 스테이션과의 처리 리듬을 맞춥니다. 피크에 배치만 쓰면 패킹 라인이 한꺼번에 몰려 병목이 생기고, 웨이브만 쓰면 배치 그루핑 최적화를 잃습니다.
결정 2 — 로케이션 할당: 고정 vs 동적 vs ABC 분석
| 후보 | 설명 | 장점 | 단점 | 적합 상황 |
|---|---|---|---|---|
| 고정 슬롯팅 (Fixed) | SKU마다 고정 위치 | 피커가 위치 암기, 오배송 낮음 | 인기 상품 재배치 불가, 공간 낭비 | 소규모 창고, SKU 변동 거의 없음 |
| 동적 슬롯팅 (Dynamic) | 입고 시 빈 슬롯에 자동 배치 | 공간 활용률 최대 | 피커가 매번 위치 확인 필요, WMS 의존도 높음 | 대형 FC, SKU 수만 개 이상 |
| ABC 분석 슬롯팅 | 회전율에 따라 A존(출입구 근처)·B존·C존 구분 | 고회전 상품 이동 거리 최소 | 분기별 재배치 작업 필요 | 중대형 FC, 이커머스 일반 |
우리의 선택: ABC 분석 슬롯팅 + 동적 서브슬롯
ABC 분석은 편의점이 계산대 바로 앞에 껌과 음료수를 두는 원리입니다. 가장 많이 팔리는 것을 가장 가까운 곳에.
전체 SKU의 20%가 출고량의 80%를 차지합니다(파레토 법칙). A존에는 상위 20% SKU를 배치해 피커 이동 거리를 줄이고, B·C존은 동적 슬롯팅으로 공간을 최적화합니다. 분기별로 ABC 재분류를 실행해 계절 상품 급등락에 대응합니다.
결정 3 — 재고 동기화: 실시간 vs 준실시간 vs 배치
| 후보 | 지연 | 장점 | 단점 | 적합 상황 |
|---|---|---|---|---|
| 실시간 (이벤트 기반) | ~100ms | 재고 오차 최소, 과주문 방지 | 시스템 결합도 높음, 피크에 WMS 부하 급증 | 고가품, 재고 수량 적은 상품 |
| 준실시간 (Change Data Capture) | 1~5초 | 실시간과 배치의 절충, WMS 부하 낮음 | CDC 파이프라인 운영 복잡 | 대형 이커머스 일반 |
| 배치 (주기적 전체 동기화) | 5~30분 | 구현 단순, WMS 독립적 | 품절 상품 계속 판매 → 환불 폭증 | 소규모 오프라인 연동 |
우리의 선택: 준실시간 CDC (기본) + 실시간 이벤트 (재고 임계치 이하)
준실시간 동기화는 공항 출발 전광판과 같습니다. 비행기가 이륙할 때마다 전광판이 실시간으로 바뀌지는 않지만, 1분 내로는 반영됩니다. 하지만 탑승 마감 5분 전이 되면 즉시 “BOARDING”으로 바뀝니다.
재고가 충분할 때(예: 100개 이상)는 CDC로 1~5초 지연을 허용합니다. 재고가 임계치(예: 10개) 이하로 떨어지면 즉시 실시간 이벤트로 전환해 과주문을 방지합니다. 이 하이브리드 방식이 WMS 부하와 재고 정확도를 동시에 만족시킵니다.
결정 4 — 출고 우선순위: FIFO vs SLA 기반 vs 동적
| 후보 | 설명 | 장점 | 단점 | 적합 상황 |
|---|---|---|---|---|
| FIFO (선입선출) | 먼저 들어온 주문 먼저 처리 | 구현 단순, 공평성 보장 | 로켓배송 1시간 마감이 일반배송에 밀릴 수 있음 | 단일 SLA, 배치 처리 시스템 |
| SLA 기반 우선순위 | 마감 시간 기준 역순 정렬 | 배송 SLA 달성률 최대화 | 오래된 일반 주문이 계속 밀림 | 멀티 SLA 이커머스 |
| 동적 우선순위 | 마감, 고객 등급, 지연 페널티 종합 점수 | 비즈니스 목표와 직결 | 우선순위 스코어 설계 복잡 | 대형 FC, 다채널 주문 통합 |
우리의 선택: SLA 기반 우선순위 + Starvation 방지
SLA 우선순위는 응급실 트리아지(triage)와 같습니다. 위급한 환자를 먼저 처리하되, 오래 기다린 환자도 결국 진료받습니다.
당일 배송·익일 배송·일반 배송의 마감 시간 역순으로 큐를 정렬합니다. 단, 순수 SLA 정렬만 하면 일반 배송 주문이 영원히 밀리는 기아(Starvation) 현상이 발생합니다. 일반 배송 주문도 대기 시간이 임계치(예: 4시간)를 넘으면 자동으로 우선순위가 상향됩니다.
결정 5 — 멀티 센터 라우팅: 거리 vs 재고 vs 비용
| 후보 | 라우팅 기준 | 장점 | 단점 | 적합 상황 |
|---|---|---|---|---|
| 거리 최소 | 고객 주소 ↔ 센터 거리 | 배송 시간 최소 | 재고 없는 가까운 센터 선택 위험 | 단일 SKU, 재고 분산 완료된 경우 |
| 재고 보유 우선 | 재고 있는 센터 중 최선 선택 | 주문 이행률(Fill Rate) 최대 | 원거리 센터 배송으로 비용 증가 | SKU가 일부 센터에만 있는 경우 |
| 비용 최소 (멀티팩터) | 배송비 + 피킹비 + 재고이전비 종합 | 총비용 최적화 | 계산 복잡, 실시간 비용 데이터 필요 | 대형 FC 네트워크, 3PL 통합 운영 |
우리의 선택: 멀티팩터 스코어링 (재고 가중치 최우선, 거리·비용 보조)
센터 라우팅은 음식 배달 앱이 “가장 가까운 음식점”이 아니라 “지금 받을 수 있는 음식점 중 가장 빠른 곳”을 추천하는 것과 같습니다.
재고 보유 여부를 하드 필터로 먼저 적용(재고 없으면 제외)하고, 통과한 센터들에 대해 거리 점수 40%, 배송비 점수 30%, 현재 처리 부하 30%로 스코어를 계산합니다. 처리 부하가 80% 이상인 센터는 패널티를 부여해 과부하를 방지합니다.
2. 요구사항 분석 및 규모 추정
기능 요구사항
주문 할당: 상위 시스템(OMS)에서 수신한 주문을 적합한 물류센터·창고에 할당하고, 재고를 예약(Reserve)합니다. 할당 실패 시 대안 센터를 자동 탐색합니다.
피킹 관리: 피커에게 피킹 지시서(Pick List)를 생성하고, 실시간 위치 정보로 경로를 최적화합니다. 피킹 완료 시 재고를 소진 처리합니다.
패킹·검수: 바코드 스캔으로 피킹 정확도를 검증하고, 중량·부피 측정으로 배송비를 산정합니다. 오배송을 포장 단계에서 차단합니다.
재고 관리: WMS 내부 재고와 커머스 플랫폼 가용 재고를 준실시간으로 동기화합니다. 입고·출고·이동·조정 이력을 모두 추적합니다.
출고·배송 연동: 패킹 완료 후 택배사 API를 호출해 운송장 번호를 발급하고, 배송 상태를 사용자에게 전달합니다.
규모 추정
일일 출고 주문 : 100만 건/일
평균 QPS : 100만 / 86,400 ≈ 12 QPS
피크 QPS : 12 × 20 ≈ 240 QPS (블프·행사 기준 20배)
물류센터 수 : 10개 (전국 권역별)
센터당 일일 처리 : 10만 건
센터당 피크 QPS : 240 / 10 = 24 QPS (할당 엔진 기준)
재고 동기화 이벤트:
피킹 1건 → 재고 차감 이벤트 1개
100만 건/일 = 12 이벤트/초 (평균), 240 이벤트/초 (피크)
CDC 파이프라인 처리 목표 : < 2초 지연
데이터 용량:
주문 할당 레코드 : ~500 B/건 × 100만 = 500 MB/일
피킹 이력 : ~200 B/건 × 100만 = 200 MB/일
재고 이벤트 로그 : ~300 B/건 × 100만 × 3개 이벤트 = 900 MB/일
연간 합계 : (500+200+900) MB × 365 ≈ 584 GB/년
5년 아카이브 : ~3 TB
운송장 발급 TPS:
100만 건/일 = 12 TPS (평균)
택배사 API 피크 허용 : 100 TPS (KR 3사 합산)
→ 택배사별 rate limit 준수 필요
피킹 동시 작업자:
센터 1개당 피커 300명 × 10센터 = 3,000명 동시 접속
피킹 완료 스캔 이벤트 : 3,000명 × 1회/분 = 50 이벤트/초
이 수치에서 결정되는 것은 세 가지입니다. 재고 예약 경쟁 조건을 막기 위해 Redis 분산 락이 필요하고, 택배사 API rate limit 때문에 출고 이벤트 큐가 필요하며, 피킹 3,000명 동시 접속 때문에 WMS 모바일 앱 서버의 WebSocket 연결 풀 설계가 필요합니다.
3. 고수준 아키텍처
풀필먼트 시스템은 공항 수하물 처리 시스템과 같습니다. 체크인 카운터(주문 접수)에서 가방(주문)을 받아 수하물 태그(운송장)를 붙이고, 컨베이어(피킹 라인)를 통해 정확한 항공편(택배사)에 실어야 합니다. 가방이 뒤바뀌거나 없어지면 안 됩니다.
graph LR
A[OMS 주문] --> B[할당엔진]
B --> C[WMS 피킹]
C --> D[패킹검수]
D --> E[택배사연동]
B --> F[재고서비스]
F --> G[커머스재고]
| 컴포넌트 | 역할 | 핵심 기술 |
|---|---|---|
| OMS (Order Management System) | 주문 수신, 상태 관리, 취소·변경 처리 | RDB (주문 FSM), Kafka (주문 이벤트) |
| 할당 엔진 (Allocation Engine) | 센터 선택, 재고 예약, 피킹 지시서 생성 | Redis 분산 락, 스코어링 알고리즘 |
| WMS (Warehouse Management System) | 피킹·패킹·검수·입고 작업 지시 및 이력 | RDB (재고 원장), WebSocket (피커 앱) |
| 재고 서비스 (Inventory Service) | 실물 재고 ↔ 가용 재고 동기화 | CDC (Debezium), Redis 캐시 |
| 택배사 연동 (Carrier Integration) | 운송장 발급, 배송 상태 수신, 고객 알림 | REST API 어댑터, Kafka 이벤트 |
| 커머스 재고 (Commerce Inventory) | 사용자에게 노출되는 판매 가능 재고 | Redis (가용 재고 카운터), CDC 수신 |
4. 핵심 컴포넌트 상세 설계
4-1. 주문 할당 엔진: 센터 선택과 재고 예약
주문 할당 엔진은 택시 배차 시스템과 같습니다. 여러 택시(센터) 중 승객(주문)에게 가장 적합한 택시를 고르고, 그 택시가 확실히 내 것임을 예약(Lock)해야 다른 사람이 탈 수 없습니다.
할당의 핵심 문제는 두 가지입니다. 어떤 센터를 고를 것인가(센터 선택)와 선택 후 재고를 어떻게 안전하게 줄일 것인가(재고 예약)입니다. 두 번째가 더 어렵습니다. 선택과 예약 사이의 미세한 시간 차에 다른 주문이 같은 재고를 가져가면 이중 할당이 발생합니다.
@Service
public class AllocationEngine {
private final InventoryRepository inventoryRepo;
private final RedissonClient redisson;
private final CenterScorer centerScorer;
public AllocationResult allocate(Order order) {
List<Center> candidates = inventoryRepo
.findCentersWithStock(order.getSkuId(), order.getQuantity());
if (candidates.isEmpty()) {
return AllocationResult.noStock(order.getOrderId());
}
// 멀티팩터 스코어링으로 최적 센터 정렬
Center best = centerScorer.rank(candidates, order).get(0);
// Redis 분산 락: 재고 예약 경쟁 조건 방지
String lockKey = "inv_lock:" + best.getCenterId() + ":" + order.getSkuId();
RLock lock = redisson.getLock(lockKey);
try {
// 최대 3초 대기, 5초 보유
if (!lock.tryLock(3, 5, TimeUnit.SECONDS)) {
return AllocationResult.lockTimeout(order.getOrderId());
}
// 락 획득 후 재고 재확인 (Double-Checked Locking)
int current = inventoryRepo.getAvailable(best.getCenterId(), order.getSkuId());
if (current < order.getQuantity()) {
return allocate(order); // 다음 후보로 재시도
}
// 재고 예약 (RESERVED 상태로 전환)
inventoryRepo.reserve(best.getCenterId(), order.getSkuId(),
order.getQuantity(), order.getOrderId());
return AllocationResult.success(order.getOrderId(), best.getCenterId());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return AllocationResult.error(order.getOrderId());
} finally {
if (lock.isHeldByCurrentThread()) lock.unlock();
}
}
}
WHY 분산 락인가: 재고 예약은 “현재 재고 조회 → 차감 결정 → 차감 실행”의 세 단계로 이루어집니다. 단일 DB 트랜잭션으로 묶으면 될 것 같지만, 실제로는 센터 DB가 지역별로 분산되어 있거나 조회가 Redis 캐시에서 일어납니다. 분산 환경에서 원자적 차감을 보장하는 가장 직관적인 방법이 Redis 분산 락입니다. 락 보유 시간을 5초로 제한해 피커가 피킹을 시작하기 전 할당이 완료되도록 설계합니다.
4-2. 피킹 최적화: 경로 최적화와 배치 그루핑
피킹 최적화는 배달 기사의 묶음 배달과 같습니다. 같은 아파트 단지로 가는 배달을 한 번에 묶어서 가면 연료비와 시간이 줄어듭니다. 창고에서는 같은 통로에 있는 물건을 묶어서 집으면 이동 거리가 줄어듭니다.
배치 그루핑의 핵심은 “같은 통로에 있는 SKU를 가진 주문들을 하나의 배치로 묶는 것”입니다. 이를 위해 각 주문의 SKU가 어느 통로(Zone)에 있는지를 먼저 계산하고, 통로 겹침이 가장 많은 주문 묶음을 한 피커에게 배정합니다.
@Service
public class PickingOptimizer {
public List<PickBatch> createBatches(List<AllocatedOrder> orders,
int batchSize) {
// 각 주문의 피킹 존(Zone) 집합 계산
Map<String, Set<String>> orderZones = orders.stream()
.collect(Collectors.toMap(
AllocatedOrder::getOrderId,
o -> locationService.getZones(o.getSkuId())
));
List<PickBatch> batches = new ArrayList<>();
List<AllocatedOrder> remaining = new ArrayList<>(orders);
while (!remaining.isEmpty()) {
AllocatedOrder seed = remaining.remove(0);
PickBatch batch = new PickBatch(seed);
Set<String> batchZones = new HashSet<>(orderZones.get(seed.getOrderId()));
// 존 겹침이 가장 많은 주문을 배치 사이즈까지 그루핑
remaining.sort(Comparator.comparingInt(o ->
-Sets.intersection(batchZones, orderZones.get(o.getOrderId())).size()
));
Iterator<AllocatedOrder> it = remaining.iterator();
while (it.hasNext() && batch.size() < batchSize) {
AllocatedOrder candidate = it.next();
Set<String> overlap = Sets.intersection(batchZones,
orderZones.get(candidate.getOrderId()));
if (overlap.size() >= 1) { // 최소 1개 존 겹침
batch.add(candidate);
batchZones.addAll(orderZones.get(candidate.getOrderId()));
it.remove();
}
}
// 겹치는 주문이 없으면 그냥 채워넣음
while (!remaining.isEmpty() && batch.size() < batchSize) {
batch.add(remaining.remove(0));
}
batches.add(batch);
}
return batches;
}
}
배치가 만들어진 뒤, 배치 내 피킹 순서는 TSP(외판원 문제)의 근사 알고리즘으로 결정합니다. 정확한 TSP 최적해를 구하면 계산 시간이 너무 길어지므로, 창고 구조(Serpentine 경로, S자 이동)를 반영한 휴리스틱을 사용합니다. 즉, 통로 번호와 선반 높이를 기준으로 정렬하면 실제 이동 거리의 90%를 최적화할 수 있습니다.
4-3. 패킹·검수: 바코드 매칭과 중량 검증
패킹 검수는 항공 수하물 보안 검색과 같습니다. 비행기에 타기 전 여권(바코드)과 탑승권(피킹 지시서)을 대조하고, 가방 무게(중량 검증)를 재서 규정 초과를 차단합니다.
패킹 스테이션에서 발생하는 오류 유형은 세 가지입니다. 오피킹(잘못된 물건), 수량 오류(개수 틀림), 포장 오류(파손 위험, 배송비 산정 오류)입니다. 각각 바코드 스캔, 카운팅 스캔, 중량·부피 자동 측정으로 차단합니다.
@Service
public class PackingVerifier {
public PackingResult verify(PickBatch batch, List<ScannedItem> scanned) {
Map<String, Integer> expected = batch.getItems().stream()
.collect(Collectors.groupingBy(
PickItem::getBarcode,
Collectors.summingInt(PickItem::getQuantity)
));
Map<String, Integer> actual = scanned.stream()
.collect(Collectors.groupingBy(
ScannedItem::getBarcode,
Collectors.summingInt(i -> 1)
));
List<PackingError> errors = new ArrayList<>();
// 누락 검사
expected.forEach((barcode, qty) -> {
int scannedQty = actual.getOrDefault(barcode, 0);
if (scannedQty < qty) {
errors.add(PackingError.missing(barcode, qty - scannedQty));
}
});
// 오피킹 검사 (기대하지 않은 바코드)
actual.forEach((barcode, qty) -> {
if (!expected.containsKey(barcode)) {
errors.add(PackingError.unexpected(barcode, qty));
}
});
if (!errors.isEmpty()) {
packingErrorPublisher.publish(batch.getBatchId(), errors);
return PackingResult.fail(errors);
}
// 중량 검증 (허용 오차: ±50g)
double expectedWeight = batch.getExpectedWeightGrams();
double actualWeight = weightScale.measure();
if (Math.abs(actualWeight - expectedWeight) > 50) {
return PackingResult.weightMismatch(expectedWeight, actualWeight);
}
return PackingResult.pass(batch.getBatchId());
}
}
WHY 중량 검증인가: 바코드 스캔만으로는 잡을 수 없는 오류가 있습니다. 동일 바코드가 붙은 물건이라도 용량이 다른 경우(예: 500ml vs 1L 생수가 같은 바코드)나, 피커가 바코드 스티커만 뜯어서 스캔한 경우입니다. 중량 검증은 바코드 검증의 2차 방어선입니다. 배송비 산정에도 직결되므로 반드시 필요합니다.
4-4. 재고 실시간 동기화: WMS ↔ 커머스
WMS와 커머스 플랫폼의 재고 동기화는 두 시계를 맞추는 것과 같습니다. 두 시계가 완전히 동일할 수는 없지만, 오차가 허용 범위를 벗어나지 않도록 지속적으로 보정해야 합니다.
CDC(Change Data Capture) 기반 동기화의 흐름은 이렇습니다. WMS DB에서 재고 변경이 발생할 때마다 Debezium이 binlog를 읽어 Kafka 토픽에 게시합니다. 커머스 재고 서비스는 이 이벤트를 구독해 Redis 카운터를 업데이트하고, 커머스 플랫폼은 Redis 카운터를 실시간으로 읽어 상품 상세 페이지에 재고 상태를 표시합니다.
@KafkaListener(topics = "wms.inventory.changes")
public void handleInventoryChange(InventoryChangeEvent event) {
String redisKey = "inv:" + event.getSkuId() + ":" + event.getCenterId();
// 재고 증가 (입고, 반품 완료)
if (event.getType() == ChangeType.INCREASE) {
long newQty = redisTemplate.opsForValue()
.increment(redisKey, event.getDelta());
if (newQty > 0) {
// 품절 → 재입고 전환: 커머스 판매 상태 활성화
commerceInventoryUpdater.activate(event.getSkuId());
}
}
// 재고 감소 (출고 확정, 피킹 완료)
else if (event.getType() == ChangeType.DECREASE) {
long newQty = redisTemplate.opsForValue()
.increment(redisKey, -event.getDelta());
if (newQty <= 0) {
// 재고 0 이하: 커머스 품절 전환
commerceInventoryUpdater.soldOut(event.getSkuId());
// 음수 방어: 0으로 보정 (실제 재고 조사 트리거)
if (newQty < 0) {
redisTemplate.opsForValue().set(redisKey, 0L);
inventoryAuditTrigger.schedule(event.getSkuId(), event.getCenterId());
}
}
}
}
WHY Redis 카운터인가: 커머스 플랫폼에서 재고 조회는 초당 수천 번 발생합니다. 이 모든 요청이 WMS DB에 직접 접근하면 WMS가 과부하로 쓰러집니다. Redis 카운터는 WMS의 재고 원장을 읽기 전용으로 복제한 캐시 역할을 합니다. Redis가 음수를 반환하면 실제 재고 조사를 트리거해 불일치를 수복하는 자가 치유(Self-Healing) 패턴을 적용합니다.
4-5. 출고 트래킹: 택배사 연동
택배사 연동은 공항과 항공사의 관계와 같습니다. 공항(WMS)은 수하물을 준비하고, 각 항공사(택배사)의 규격에 맞게 라벨을 붙여 넘겨줍니다. 비행기 이륙 후에는 항공사의 추적 시스템이 실시간으로 위치를 보고합니다.
국내 택배사(CJ대한통운, 롯데택배, 한진)는 각자 다른 API 스펙을 사용합니다. Adapter 패턴으로 택배사별 구현을 추상화하고, 공통 인터페이스로 출고 처리 로직을 작성합니다.
public interface CarrierAdapter {
WaybillResponse issueWaybill(WaybillRequest request);
TrackingInfo getTracking(String waybillNo);
boolean cancelWaybill(String waybillNo);
}
@Component("CJ")
public class CJCarrierAdapter implements CarrierAdapter {
@Override
public WaybillResponse issueWaybill(WaybillRequest request) {
CJApiRequest cjReq = CJRequestMapper.from(request);
CJApiResponse resp = cjApiClient.issueLabel(cjReq);
return WaybillResponse.builder()
.waybillNo(resp.getInvoiceNo())
.labelUrl(resp.getLabelUrl())
.estimatedDelivery(resp.getEstDeliveryDate())
.build();
}
}
운송장 발급은 패킹 완료 이벤트를 Kafka로 수신한 후, 택배사별 rate limit을 고려해 처리합니다. CJ대한통운 기준 API 호출 한도는 초당 50건이므로, Kafka Consumer의 poll 속도를 조절하거나 Semaphore 기반 rate limiter를 앞에 두어 한도를 초과하지 않도록 제어합니다.
5. 극한 시나리오 3개
시나리오 1: 블랙프라이데이 피크 — 평소 20배 주문 폭발
수치: 평시 12 QPS의 할당 요청이 행사 시작 10분 만에 240 QPS로 급증합니다. 각 요청마다 Redis 분산 락을 걸면 락 경쟁이 폭발합니다. 락 대기 큐가 쌓이면 3초 타임아웃이 집단 발화하고, 타임아웃 재시도가 또 다른 트래픽을 만들어냅니다. 이 도미노를 멈추지 않으면 시스템 전체가 멈춥니다.
이 상황은 슈퍼마켓 계산대 앞에 줄이 20배로 늘어났는데, 계산원이 한 명씩만 처리할 수 있는 것과 같습니다. 줄이 길어질수록 기다리다 포기하는 사람이 생기고, 포기한 사람이 다시 줄 서면 더 길어집니다.
대응 메커니즘: 락 경쟁은 SKU 단위 락 세분화로 완화합니다. 전체 센터에 하나의 락을 걸지 않고, 센터ID + SKU ID 조합으로 락 키를 만들면 서로 다른 SKU의 주문은 락을 전혀 공유하지 않습니다. 인기 상품 10개 SKU가 전체 트래픽의 80%를 차지하더라도, 그 10개 각각의 락은 독립적이므로 병렬 처리됩니다.
추가로 할당 요청을 Kafka 큐로 받아 비동기 처리합니다. 고객에게 “주문 접수 완료” 응답을 즉시 주고, 실제 할당은 백그라운드에서 처리합니다. 할당 실패 시 대안 센터를 자동으로 시도하고, 모든 센터에서 재고 소진 시에만 고객에게 알림을 보냅니다. 이 패턴은 할당 엔진이 수백 QPS를 받더라도 고객 응답 레이턴시에 영향을 주지 않게 합니다.
센터별 Circuit Breaker 적용으로 특정 센터의 락 경쟁률이 90%를 초과하면 해당 센터로의 신규 할당을 일시 차단하고, 인접 센터로 자동 우회합니다. 피크 타임에 특정 센터가 과부하로 잠기는 상황에서 전체 시스템이 함께 멈추지 않도록 격리합니다.
graph LR
A[주문폭발 240QPS] --> B[Kafka 큐 버퍼]
B --> C[할당엔진 비동기]
C --> D[SKU단위 분산락]
D --> E[출고확정]
결과: 피크 시 처리 지연은 발생하지만 시스템 전체 다운은 방지됩니다. 고객이 체감하는 지연은 주문 접수 응답이 아닌 “배송 예정 시각”이 1~2시간 늦어지는 것으로 흡수됩니다. 재고 부족 주문은 자동 취소·환불 처리되어 창고에서 존재하지 않는 물건을 찾는 일이 없어집니다.
시나리오 2: 단일 센터 장애 — 특정 FC 서버 다운
수치: 10개 센터 중 수도권 메인 FC(전체 물량의 40% 담당)의 WMS 서버가 장애납니다. 이 순간, 이 센터로 할당되어 있던 약 4만 건의 주문이 피킹 지시를 받지 못합니다. 피커들은 단말기에서 피킹 리스트가 뜨지 않아 멈춥니다. 미출고가 누적되기 시작합니다.
이 상황은 허브 공항이 폐쇄된 것과 같습니다. 허브를 통해 가려던 승객들이 다른 공항으로 재라우팅 되어야 합니다. 빠를수록 좋지만, 재라우팅 공항이 수용 가능한지 먼저 확인해야 합니다.
대응 메커니즘: 먼저 할당 엔진의 헬스체크가 장애 센터를 감지합니다. 연속 3회 타임아웃 발생 시 해당 센터를 DEGRADED 상태로 전환하고, 신규 주문 할당에서 제외합니다. 이 전환은 10초 이내에 자동으로 이루어져야 합니다.
기존에 이미 할당된 4만 건은 두 가지로 분리 처리합니다. 피킹 미시작 건: 재고 예약을 해제하고 다른 센터로 재할당합니다. 재할당 시 다른 센터의 동일 SKU 재고를 실시간으로 확인하고, 재고가 없으면 이전 요청 지역 기준 인접 센터로 순서를 달리해 할당합니다. 피킹 진행 중 건: 피커가 이미 창고에서 물건을 집고 있는 상태이므로, 피킹 완료 후 패킹 스테이션까지는 정상 처리를 유지합니다. 패킹 완료 후 출고 이벤트만 장애 복구 시까지 큐에 보관합니다.
재고 불일치 방지를 위해 장애 센터의 재고 예약(RESERVED) 상태 주문은 복구 후 일괄 대사합니다. 장애 기간 중 피킹이 완료됐으나 WMS에 기록되지 않은 건들을 복구 시 재고 조사 절차로 수복합니다.
graph LR
A[FC 장애감지] --> B[DEGRADED 전환]
B --> C[신규할당 우회]
B --> D[기존주문 재할당]
D --> E[인접센터 분산]
결과: 장애 발생 10초 내 신규 주문의 자동 우회가 시작됩니다. 재할당 대상 4만 건 중 인근 센터 재고가 충분한 70%는 SLA 내 처리 가능합니다. 나머지 30%는 당일 배송에서 익일 배송으로 전환 처리되며, 고객에게는 자동 배송 지연 알림과 보상 쿠폰이 발송됩니다. WMS 서버 복구 후에는 재고 대사 절차로 불일치를 수복합니다.
시나리오 3: 재고 유령(Ghost Inventory) — 시스템 재고 vs 실물 재고 불일치
수치: 시스템상 재고 500개로 표시된 인기 SKU를 대상으로, 실제 창고 재고는 200개입니다. 300개는 이전 배치 처리 버그로 차감되지 않은 “유령 재고”입니다. 이 상태에서 400건의 주문이 들어오면, 400건 모두 할당 성공을 응답받습니다. 피킹 작업이 시작되면 200건째부터 창고에 물건이 없습니다. 200건의 주문이 피킹 실패 처리됩니다.
유령 재고는 은행 계좌에 잔액이 표시되는데 실제 돈이 없는 것과 같습니다. ATM에서 출금하려 했을 때 비로소 오류가 납니다.
대응 메커니즘: 유령 재고는 사후 수습보다 사전 탐지가 중요합니다. 사전 탐지: 주기적 재고 대사. 매일 새벽 WMS의 물리 재고 조사(Cycle Count) 결과를 시스템 재고와 대사합니다. SKU별 오차율이 5%를 초과하면 알림을 발생시키고, 10%를 초과하면 해당 SKU의 커머스 판매를 일시 중단합니다.
실시간 탐지: 피킹 실패 이벤트 집계. 피킹 실패율이 특정 SKU에서 5분 내 3건 이상 발생하면 즉시 해당 SKU에 INVENTORY_SUSPECT 플래그를 설정합니다. 이 플래그가 설정된 SKU는 신규 주문 할당에서 제외되고, 긴급 재고 조사 요청이 창고에 자동 발송됩니다.
피킹 실패 주문 처리: 피킹 실패 주문은 자동으로 다른 센터 재할당을 시도합니다. 모든 센터에서 실패하면 고객에게 “재고 소진으로 인한 주문 취소”를 즉시 안내하고 전액 환불을 처리합니다. 이 흐름은 피킹 실패부터 고객 알림까지 5분 이내에 완료되어야 합니다.
graph LR
A[피킹실패감지] --> B[SUSPECT플래그]
B --> C[신규할당차단]
B --> D[긴급재고조사]
D --> E[시스템재고수정]
결과: 유령 재고 200건 중 실시간 탐지 도달 시점(3번째 실패)까지 최대 3건의 주문이 피킹 실패를 겪습니다. 이후 신규 할당 차단으로 추가 피해를 막습니다. 긴급 재고 조사 완료(평균 30분) 후 시스템 재고가 200개로 수정됩니다. 피킹 실패 주문은 대안 센터 할당 또는 자동 취소·환불로 처리됩니다. 이 시나리오에서 핵심은 “200건이 모두 피킹 실패하기 전에 멈추는 것”입니다.
6. 실무 실수 Top 5
| 순위 | 실수 | 증상 | 올바른 접근 |
|---|---|---|---|
| 1 | 재고 예약 없이 출고 처리 | 동일 재고를 두 주문이 동시에 피킹 → 한 건 피킹 실패 | 할당 시 반드시 RESERVED 상태로 락 후 차감 |
| 2 | 배치 피킹 중 재고 갱신 누락 | 배치 안의 주문 일부가 피킹 완료 시 재고가 차감되지 않음 → 유령 재고 발생 | 피킹 항목별 개별 이벤트 발행, 배치 완료와 개별 완료 분리 처리 |
| 3 | 택배사 API 동기 직접 호출 | 택배사 장애 시 패킹 라인 전체 정지 | 비동기 큐(Kafka)로 운송장 발급 요청, 실패 시 자동 재시도 |
| 4 | 재고 동기화 단방향만 설계 | WMS → 커머스 CDC는 있는데 역방향(취소·반품) 반영 누락 → 커머스 재고 < WMS 재고 | 입고·출고·반품·조정 모든 이벤트를 양방향으로 설계 |
| 5 | 피킹 실패를 조용히 재시도 | 유령 재고 상태에서 재시도가 반복되며 피킹 실패 로그가 쌓임, 탐지 지연 | 피킹 실패 시 즉시 이벤트 발행, 임계치 초과 시 SKU 자동 차단 |
7. Phase 1 → 4 진화
Phase 1: 단일 센터, 수동 피킹 (월 ~200만 원)
WMS와 커머스 플랫폼을 단일 PostgreSQL로 연결합니다. 재고 동기화는 5분 배치로 처리하고, 피킹은 종이 피킹 리스트 또는 단순 앱으로 운영합니다. 주문 할당은 항상 동일 센터이므로 라우팅 로직이 불필요합니다.
제약: 일 1만 건 이하, 단일 센터, SKU 1,000개 이하
Phase 2: 멀티 센터, 자동 배치 피킹 (월 ~800만 원)
센터가 3~5개로 늘어나면 할당 엔진이 필요합니다. Redis 분산 락을 도입하고, CDC 기반 재고 동기화(Debezium + Kafka)를 구축합니다. 피킹 앱을 모바일 WMS 앱으로 전환하고, 배치 피킹 알고리즘을 적용합니다. 택배사 API 연동을 어댑터 패턴으로 추상화합니다.
제약: 일 10만 건 이하, 5개 센터 이하, SKU 10만 개 이하
Phase 3: 웨이브 피킹, 실시간 재고 (월 ~3,000만 원)
일 50만 건이 넘으면 배치 피킹만으로는 컨베이어 라인과의 리듬 동기화가 어렵습니다. 웨이브 피킹 스케줄러를 도입해 패킹 스테이션·출하 도크와 처리량을 맞춥니다. 재고 임계치 이하 SKU에 실시간 이벤트 동기화를 추가합니다. 피킹 경로 최적화 알고리즘(Serpentine 휴리스틱)을 고도화합니다.
제약: 일 100만 건 이하, 10개 센터 이하, SKU 100만 개 이하
Phase 4: AI 슬롯팅, 자율 재고 이전 (월 ~1억 원+)
ABC 분석을 머신러닝 기반 동적 슬롯팅으로 고도화합니다. 계절성·프로모션·날씨까지 반영해 SKU 위치를 자동으로 최적화합니다. 센터 간 재고 불균형을 감지하면 야간 이전 작업을 자동으로 스케줄링합니다. 피킹 로봇(AGV, Autonomous Guided Vehicle)과의 WMS 통합으로 인력 의존도를 낮춥니다.
8. 이 설계의 한계와 대안 — “이게 실패하면?”
시니어 엔지니어와 주니어 엔지니어의 차이는 설계를 그릴 수 있는가가 아닙니다. “이 설계가 어떤 상황에서 무너지는가”를 먼저 말할 수 있는가입니다.
8-1. CDC 지연: 유령 재고(Phantom Stock)의 구조적 원인
CDC 기반 동기화는 1~5초 지연을 허용합니다. 이 5초가 치명적인 상황이 있습니다.
재고 99개짜리 인기 SKU에 100개 주문이 동시에 들어오는 순간입니다. CDC가 커머스 플랫폼에 “재고 99개”를 전달하기 전 0.3초 사이에 두 번째 주문이 들어오면, 두 주문 모두 “재고 있음”으로 할당됩니다. 피킹 시점에 비로소 재고 부족이 드러납니다. 이것이 구조적 유령 재고(Structural Phantom Stock)입니다.
| 상황 | CDC 지연 | 결과 |
|---|---|---|
| 재고 충분 (100개 이상) | 1~5초 허용 | 문제 없음 |
| 재고 임계치 (10개 이하) | 실시간 전환 | 대부분 방어 |
| 피크 + 임계치 동시 | 이벤트 역전 가능 | 유령 재고 발생 |
대안: 재고 임계치 이하 SKU의 최종 방어선은 Redis DECRBY + Lua 스크립트입니다. 커머스 플랫폼이 주문 접수 시점에 Redis 카운터를 직접 원자적으로 차감하고, 음수가 되면 즉시 주문 거부합니다. CDC는 “표시용”으로만 쓰고, “예약 결정”은 Redis에서 끝냅니다.
-- Redis Lua: 재고 원자적 예약 (oversell 방지)
local key = KEYS[1]
local qty = tonumber(ARGV[1])
local current = tonumber(redis.call('GET', key) or 0)
if current < qty then
return -1 -- 재고 부족: 주문 거부
end
return redis.call('DECRBY', key, qty)
이 패턴을 쓰지 않으면 CDC 지연 동안 재고가 음수가 될 수 있고, 피킹 현장에서 “물건이 없다”는 사실을 처음 알게 됩니다.
8-2. 피킹 경로 최적화 알고리즘의 한계: TSP는 NP-hard
배치 내 피킹 순서 최적화를 “TSP(외판원 문제)로 푼다”고 말하면 면접관은 즉시 물어봅니다. “상품 수가 늘어나면요?”
TSP는 NP-hard입니다. 아이템 수 n이 증가하면 정확한 최적해를 구하는 시간이 n!에 비례합니다. 배치 사이즈 12건, 건당 3개 SKU라면 36개 아이템의 TSP입니다. 정확한 해를 구하면 피킹 지시서 생성이 수 초~수십 초 걸립니다. 피킹 지시서는 배치 생성 후 1초 이내에 피커 단말기에 도달해야 합니다.
정확한 TSP (브루트포스):
n = 12 → 12! = 479,001,600 경우의 수
n = 20 → 20! = 2,432,902,008,176,640,000
Nearest Neighbor 근사 알고리즘:
시간 복잡도: O(n²)
n = 20 → 400 연산
최적해 대비 약 80~90% 품질 (실용적으로 충분)
실용 선택: Nearest Neighbor + Serpentine 정렬
창고 구조를 고려한 Serpentine(S자 이동) 휴리스틱으로 통로 번호와 선반 높이를 1차 정렬 기준으로 삼습니다. 여기에 Nearest Neighbor로 가장 가까운 다음 위치를 선택하면 이론적 최적해의 85~90%를 O(n²) 복잡도로 달성합니다. 창고 수백 개 운영 중인 아마존 로보틱스도 AGV 경로에 정확한 최적화가 아닌 근사 알고리즘을 씁니다.
8-3. 멀티 센터 라우팅 오판: 스플릿 출고(Split Fulfillment)의 숨겨진 비용
“가장 가까운 센터에 재고 없음 → 다음 센터” 라우팅은 단순해 보이지만, 한 주문에 상품 A는 서울 센터, 상품 B는 부산 센터에만 있는 경우 스플릿 출고(Split Fulfillment)가 불가피합니다.
스플릿 출고의 실제 비용 구조:
| 비용 항목 | 단건 출고 | 스플릿 출고 (2박스) | 비고 |
|---|---|---|---|
| 박스 포장 | ×1 | ×2 | 재료비 2배 |
| 배송비 | ×1 | ×1.8 (묶음 할인 없음) | 택배사 계약에 따라 다름 |
| 고객 CS | 낮음 | 높음 (도착 시간 다름) | 불만 주문 증가 |
| 반품 복잡도 | 단순 | 두 송장 처리 필요 | 반품 처리 비용 증가 |
대안 결정 트리:
graph LR
A[주문 라우팅] --> B{단일센터 재고 충족?}
B -->|Yes| C[단일 출고]
B -->|No| D{SLA 당일배송?}
D -->|Yes| E[스플릿 출고 강제]
D -->|No| F{이전비 < 스플릿 배송비?}
F -->|Yes| G[센터간 재고 이전 후 통합 출고]
F -->|No| E
스플릿 출고를 무조건 피하는 것도, 무조건 허용하는 것도 틀립니다. SLA와 비용 비교를 할당 엔진이 실시간으로 계산해야 합니다. 이 로직 없이 “가장 가까운 센터” 하드코딩으로 구현하면 특정 SKU 편중 시 스플릿 출고 비율이 30%를 넘는 상황이 벌어집니다.
8-4. 바코드 스캔 실패: 수동 입력 Fallback과 오배송율
패킹 검수의 핵심인 바코드 스캔이 실패하는 경우는 생각보다 많습니다. 박스가 찌그러져 바코드가 훼손됐거나, 열에 의해 바코드 잉크가 번졌거나, 곡면 포장재에서 스캐너 각도가 안 맞는 경우입니다. 대형 FC 기준 스캔 실패율은 약 0.1~0.3%로 보고됩니다. 하루 100만 건이면 1,000~3,000건의 스캔 실패입니다.
스캔 실패 시 가장 흔한 Fallback은 수동 SKU 코드 입력입니다. 그런데 수동 입력에는 치명적인 문제가 있습니다.
바코드 스캔 실패 → 수동 입력 → 피커가 비슷한 코드 오입력 → 검수 통과 → 오배송
수동 입력이 허용되는 순간, 패킹 검수의 자동화 방어가 무너집니다. 수동 입력 건수를 실시간으로 집계하고, 임계치(예: 동일 피커가 10분 내 3건 수동 입력) 초과 시 슈퍼바이저에게 즉시 알림을 보내는 보조 방어선이 필요합니다.
더 나은 대안은 RFID 태그 병행 운용입니다. 고가 SKU에는 바코드와 RFID를 함께 부착해 스캔 실패 시 RFID 리더로 대체 검증합니다. 초기 비용이 높지만 오배송 1건의 CS·반품 비용(평균 15,000~30,000원)과 비교하면 ROI가 나옵니다.
8-5. 택배사 API 장애: 대기 큐 + 멀티 택배사 Failover
운송장 발급 API가 장애나면 패킹 라인이 멈춥니다. 패킹 완료된 박스가 출하 도크에 쌓이기 시작합니다. 택배사 수거 차량이 도착할 때까지 운송장이 없으면 출하 자체가 불가합니다.
단일 택배사 의존의 리스크:
CJ대한통운 API 장애가 발생한 2023년 11월 사례에서, 단일 택배사 연동으로만 구성된 WMS는 평균 2.3시간 패킹 라인 정지를 겪었습니다. 멀티 택배사 Failover를 구성한 업체는 평균 8분 만에 복구했습니다.
graph LR
A[패킹완료] --> B[운송장 발급 큐]
B --> C{CJ API 정상?}
C -->|Yes| D[CJ 운송장 발급]
C -->|No| E{롯데 API 정상?}
E -->|Yes| F[롯데 운송장 발급]
E -->|No| G[한진 API]
구현 핵심:
- 패킹 완료와 운송장 발급을 분리된 비동기 단계로 설계합니다. 패킹 완료 이벤트를 Kafka에 넣고, 운송장 발급 Consumer가 별도로 처리합니다.
- 택배사 API 호출 실패 시 즉시 다음 택배사로 전환하는 Circuit Breaker + Failover 체인을 구성합니다.
- 운송장이 늦게 발급되더라도 패킹 자체는 계속 진행해 박스를 준비해 둡니다. 운송장은 나중에 박스에 부착해도 됩니다.
- 모든 택배사가 동시에 장애나는 극단 시나리오: 발급 요청을 DB 대기 큐에 보관하고, 복구 시 자동 재처리합니다.
9. 동시성과 락 — “두 명이 동시에 같은 재고를 잡으면?”
분산 시스템에서 “동시에”라는 단어는 “반드시 버그가 있다”는 신호입니다. 락은 그 버그를 막는 교통 신호등입니다.
9-1. 재고 동시 예약: Redis DECRBY Lua vs DB SELECT FOR UPDATE
동일 SKU에 동시 주문이 들어오는 상황에서 선택지는 두 가지입니다.
방법 1: DB SELECT FOR UPDATE (비관적 락)
BEGIN;
SELECT quantity FROM inventory
WHERE sku_id = ? AND center_id = ?
FOR UPDATE; -- 행 레벨 락
UPDATE inventory SET quantity = quantity - ?
WHERE sku_id = ? AND center_id = ?;
COMMIT;
장점: 구현 단순, 트랜잭션 내 일관성 보장. 단점: DB 커넥션을 락 보유 시간 동안 점유합니다. 피크 시 락 대기가 쌓이면 DB 커넥션 풀이 고갈됩니다. 커넥션 100개 × 락 대기 3초 = 300초 분의 커넥션이 낭비됩니다.
방법 2: Redis Lua 스크립트 (원자적 연산)
local current = tonumber(redis.call('GET', KEYS[1]) or 0)
if current < tonumber(ARGV[1]) then return -1 end
return redis.call('DECRBY', KEYS[1], ARGV[1])
장점: 단일 Redis 명령으로 원자적 처리, DB 커넥션 불필요, 마이크로초 단위 응답. 단점: Redis 장애 시 재고 데이터 유실 가능성(AOF/RDB 설정 필요). Redis와 DB 간 불일치 발생 시 수동 대사 필요.
실전 선택:
| 상황 | 권장 방법 | 이유 |
|---|---|---|
| 재고 100개 이상, 저트래픽 | DB SELECT FOR UPDATE | 단순성 우선 |
| 재고 10개 이하, 고트래픽 | Redis Lua | 속도·경쟁 방지 우선 |
| 피크 타임 핫 SKU | Redis Lua + DB 비동기 동기화 | 성능과 영속성 분리 |
9-2. 피킹 태스크 중복 할당 방지: 분산 락 vs DB 상태 전이
피킹 태스크 중복 할당은 재고 중복 할당보다 더 조용하게 일어납니다. 피커 A와 피커 B가 동시에 피킹 앱을 열었을 때, 같은 태스크를 동시에 “수락”하는 경우입니다.
방법 1: Redis 분산 락
피킹 태스크 ID를 키로 Redis 락을 설정합니다. 락을 획득한 피커만 태스크를 수락할 수 있습니다. 구현은 단순하지만, 락 TTL 만료 후 피커가 태스크를 포기한 건지 단순 네트워크 단절인지 구분하기 어렵습니다.
방법 2: DB 상태 전이 (State Machine)
PENDING → ASSIGNED(피커ID) → IN_PROGRESS → COMPLETED / FAILED
DB UPDATE picking_task SET status = 'ASSIGNED', picker_id = ? WHERE task_id = ? AND status = 'PENDING'의 결과 행 수를 확인합니다. 1이면 할당 성공, 0이면 이미 다른 피커가 할당한 것입니다. DB 트랜잭션의 원자성을 이용한 방법으로, 추가 인프라 없이 구현 가능합니다.
권장: DB 상태 전이가 더 단순하고 감사 추적(Audit Trail)에 유리합니다. Redis 분산 락은 상태 전이가 느릴 때 보조 수단으로만 씁니다.
9-3. 멀티 센터 재고 합산의 Eventual Consistency 허용 범위
주문 화면에 “전국 재고 합산: 1,234개”를 표시하는 경우, 이 숫자는 10개 센터의 Redis 카운터를 합산한 값입니다. 각 센터의 CDC 지연이 다르기 때문에 이 합산값은 항상 과거 시점의 스냅샷입니다.
이것은 버그가 아니라 설계 결정입니다. 합산 재고가 1,234개에서 1,233개로 바뀌는 것을 사용자가 실시간으로 볼 필요는 없습니다. 재고 합산 표시는 Eventual Consistency를 허용하되, 실제 예약 결정은 반드시 원자적으로 처리합니다.
허용 범위 가이드라인:
- 합산 재고 표시: 5~10초 지연 허용 (사용자 경험에 영향 없음)
- 단일 센터 재고 임계치 판단: 1~3초 허용 (실시간 이벤트 병행)
- 실제 예약·차감: 0ms (Redis Lua 원자 연산, 지연 없음)
10. 오버엔지니어링 경고 — “이 설계가 필요한 규모인가?”
가장 비싼 코드는 실행되지 않는 코드가 아니라, 필요하지 않은데 짠 코드입니다. 일 출고 1,000건 스타트업에 분산 CDC 파이프라인을 구축하는 것은 바퀴를 달 수 있는 자전거에 제트 엔진을 다는 것입니다.
규모별 적정 기술 수준
일 출고 ~1,000건: Excel + 수동 피킹
────────────────────────────────────
시스템: 스프레드시트, 카카오톡 재고 알림
피킹: 종이 피킹 리스트, 1인 운영
재고: 엑셀 수동 입력, 일 1회 대사
권고: WMS 구매도 과함. Google Sheets + Zapier로 충분.
일 출고 1,000~10,000건: 단일 WMS
──────────────────────────────────
시스템: SaaS WMS (샵플링, 이포스, 영림원)
피킹: 모바일 앱 기반 피킹 지시
재고: WMS 내장 동기화 (5~30분 배치)
권고: 멀티센터 라우팅은 과함. 단일센터 최적화 먼저.
일 출고 10,000~100,000건: 자체 WMS 일부
─────────────────────────────────────────
시스템: SaaS WMS + 커스텀 할당 엔진
피킹: 배치 피킹, ABC 슬롯팅
재고: CDC 파이프라인 (Debezium + Kafka)
권고: 멀티센터 라우팅 도입, 단 AI 슬롯팅은 과함.
일 출고 100,000건+: 이 문서의 설계
──────────────────────────────────
시스템: 완전 자체 구축, 멀티센터 라우팅
피킹: 웨이브 피킹, AGV 연동
재고: 실시간 이벤트 + CDC 하이브리드
권고: 여기서 AI 슬롯팅, 예측 보충 도입 타당.
“자동화보다 프로세스 정립이 먼저”
물류 자동화가 실패하는 가장 흔한 이유는 기술이 아닙니다. 수동 운영조차 안정적이지 않은 상태에서 자동화를 도입하기 때문입니다.
재고 실사를 주 1회 하는 창고에 실시간 CDC를 붙여도, 실사 기준 데이터 자체가 틀려 있으면 자동화는 잘못된 데이터를 빠르게 전파할 뿐입니다. 바코드 없이 수기 입고하는 창고에 피킹 최적화 알고리즘을 도입해도, 위치 데이터가 없어 알고리즘이 동작하지 않습니다.
자동화 도입 전 반드시 확인할 세 가지:
- 재고 정확도 ≥ 95%가 수동 운영으로 이미 달성되고 있는가
- 모든 입출고에 SKU 코드와 수량이 정확히 기록되고 있는가
- 피킹 실패, 오배송, 반품 원인 데이터가 수집되고 있는가
이 세 가지가 안 되는 상태에서 WMS를 도입하면, 시스템은 있지만 숫자를 믿을 수 없는 상태가 됩니다. 실제로 중견 이커머스의 WMS 도입 실패 사례 80% 이상이 이 단계에서 걸립니다.
11. Kafka 이벤트 파이프라인 — 주문에서 출고까지 이벤트 체인
Kafka는 풀필먼트 시스템의 척추입니다. 주문이 접수되는 순간부터 택배 기사에게 전달되는 순간까지, 모든 상태 전이가 이벤트로 기록되고 전파됩니다.
이벤트 체인 전체 흐름
graph LR
A[주문확정] --> B[재고할당]
B --> C[피킹지시]
C --> D[피킹완료]
D --> E[패킹완료]
E --> F[운송장발급]
F --> G[출고확정]
각 화살표는 Kafka 토픽입니다. 상태 전이마다 이벤트가 발행되고, 다음 단계의 Consumer가 이를 수신해 처리합니다.
| 이벤트 | Kafka 토픽 | Producer | Consumer |
|---|---|---|---|
ORDER_CONFIRMED |
oms.orders.confirmed |
OMS | 할당 엔진 |
STOCK_RESERVED |
wms.inventory.reserved |
할당 엔진 | WMS 피킹 스케줄러 |
PICKING_ASSIGNED |
wms.picking.assigned |
WMS | 피커 단말기 앱 |
PICKING_COMPLETED |
wms.picking.completed |
피커 앱 | 패킹 스테이션, 재고 서비스 |
PACKING_COMPLETED |
wms.packing.completed |
패킹 스테이션 | 택배사 연동 |
WAYBILL_ISSUED |
carrier.waybill.issued |
택배사 어댑터 | OMS, 알림 서비스 |
SHIPPED |
wms.shipment.confirmed |
WMS | OMS, 커머스 플랫폼 |
Consumer 처리 실패 시 주문·물류 상태 불일치 문제
이벤트 체인에서 한 Consumer가 실패하면 주문 상태(OMS)와 물류 상태(WMS)가 어긋납니다. 이것이 풀필먼트 이벤트 파이프라인의 가장 위험한 실패 모드입니다.
시나리오: PACKING_COMPLETED 이벤트를 택배사 연동 Consumer가 처리하는 중 택배사 API 장애로 실패합니다.
- OMS 상태:
PACKING_COMPLETED(패킹 완료) - WMS 상태:
PACKING_COMPLETED(패킹 완료) - 실제 상태: 운송장 없음, 출고 불가
이 불일치가 자동으로 해소되지 않으면 주문은 영원히 “패킹 완료” 상태로 남습니다. 고객은 배송 시작 알림을 받지 못하고, CS 문의가 쌓입니다.
방어 메커니즘:
@KafkaListener(topics = "wms.packing.completed")
public void handlePackingCompleted(PackingCompletedEvent event) {
try {
WaybillResponse waybill = carrierAdapter.issueWaybill(event);
// 성공: WAYBILL_ISSUED 이벤트 발행
kafkaTemplate.send("carrier.waybill.issued", waybill);
} catch (CarrierApiException e) {
// 실패: Dead Letter Queue로 이동, 재처리 스케줄 등록
kafkaTemplate.send("wms.packing.completed.dlq", event);
alertService.notifyCarrierApiFailure(event.getOrderId(), e);
// 주문 상태를 WAYBILL_PENDING으로 명시적 전환
omsClient.updateOrderStatus(event.getOrderId(),
OrderStatus.WAYBILL_PENDING);
}
}
핵심은 두 가지입니다. 첫째, 실패한 이벤트는 Dead Letter Queue(DLQ)로 보내 유실을 방지합니다. 둘째, 실패 즉시 OMS 주문 상태를 WAYBILL_PENDING으로 명시적으로 전환해 “주문 상태 = 패킹 완료인데 운송장은 없음”이라는 모순된 상태를 없앱니다.
DLQ 재처리 정책:
1차 재시도: 30초 후 (일시적 장애 대응)
2차 재시도: 5분 후 (택배사 API 복구 대기)
3차 재시도: 30분 후
최종 실패: 슈퍼바이저 수동 처리 큐로 이동 + Slack 알림
재처리 횟수와 간격은 택배사별 SLA와 수거 차량 마감 시간을 기준으로 설정합니다. CJ대한통운 기준 오후 3시 마감이면, 오후 2시 30분 이후 발생한 DLQ는 당일 출하 포기를 결정하고 고객 알림을 즉시 발송하는 별도 로직이 필요합니다.
12. 핵심 메트릭
| 메트릭 | 정의 | 목표값 | 측정 방법 |
|---|---|---|---|
| 주문 이행률 (Fill Rate) | 주문 접수 후 정상 출고된 비율 | ≥ 99.5% | (정상 출고 건 / 전체 주문 건) × 100 |
| 피킹 정확도 (Pick Accuracy) | 오피킹 없이 완료된 배치 비율 | ≥ 99.9% | (오류 없는 배치 / 전체 배치) × 100 |
| 재고 정확도 (Inventory Accuracy) | 시스템 재고와 실물 재고 일치율 | ≥ 99% | Cycle Count 대사 결과 |
| 출고 사이클 타임 (Order-to-Ship) | 주문 수신 → 출하 완료 시간 | ≤ 4시간 (당일배송 기준) | 주문 이벤트 → 출하 이벤트 타임스탬프 차 |
| 할당 레이턴시 | 할당 엔진 응답 시간 | p99 ≤ 500ms | 분산 추적 (Jaeger/Zipkin) |
| 재고 동기화 지연 | WMS 변경 → 커머스 반영 시간 | p95 ≤ 3초 | CDC 이벤트 타임스탬프 차 |
| 피킹 생산성 | 피커 1인당 시간당 처리 라인 수 | ≥ 120 lines/hr | 피커 배지 스캔 이력 분석 |
| 창고 공간 활용률 | 전체 슬롯 중 사용 중인 비율 | 85~92% | WMS 슬롯 점유 현황 |
13. 실제 장애 사례와 교훈
아마존 FBA Race Condition (2021년): 피킹 배치 생성 시 동일 SKU 위치를 두 피커에게 동시 할당하는 경쟁 조건이 블랙프라이데이 피크에 수면 위로 드러났습니다. 교훈은 재고 할당 로직의 동시성 테스트는 반드시 피크 트래픽 시뮬레이션으로 검증해야 한다는 것입니다. 평시 테스트에서는 경쟁 조건이 발현되지 않을 수 있습니다.
쿠팡 재고 동기화 지연 (2022년): 신규 WMS 시스템과 기존 커머스 플랫폼의 CDC 파이프라인 설계에서 처리 순서 보장(Ordering Guarantee)이 누락됐습니다. 재고 차감 이벤트보다 재고 증가 이벤트가 먼저 처리되는 순서 역전이 발생해, 재고가 있다가 없어졌다가를 반복했습니다. 교훈은 CDC 파이프라인에서 동일 SKU의 이벤트는 파티션 키를 SKU ID로 설정해 순서를 보장해야 한다는 것입니다.
네이버 NFA 이중 출고 (2023년): 서로 다른 3PL 업체의 재고 확인 API 응답 시간이 달랐고, 느린 3PL의 응답을 기다리는 동안 빠른 3PL에서 먼저 재고를 확인해 두 센터 모두에서 할당이 성공했습니다. 교훈은 멀티 3PL 환경에서는 중앙화된 재고 예약 레이어가 반드시 필요하다는 것입니다. 각 3PL에 직접 재고를 확인하는 방식은 동시성을 제어할 수 없습니다.
14. 확장 포인트
크로스도킹 (Cross-Docking): 입고 상품을 창고 보관 없이 즉시 출고 트럭으로 이전하는 방식입니다. 신선식품·당일배송 상품에 적합하며, WMS에 크로스도킹 전용 슬롯과 작업 지시 로직을 추가합니다.
다중 채널 통합 (Omnichannel Fulfillment): 온라인 주문을 오프라인 매장 재고로 처리하는 BOPIS(Buy Online Pickup In Store), 매장을 미니 FC로 활용하는 Ship-from-Store를 지원하려면 매장 재고를 WMS에 통합하고, 매장 직원 피킹 앱을 별도로 개발해야 합니다.
자동화 설비 연동: 오토스토어(AutoStore), 소터(Sorter), AGV와의 WMS 통합은 표준 프로토콜(MFC API)이 없으므로 설비사별 어댑터를 개발해야 합니다. 자동화 설비 장애 시 수동 피킹으로 전환하는 Fallback 절차가 반드시 있어야 합니다.
예측 보충 (Predictive Replenishment): 과거 출고 이력과 프로모션 캘린더를 학습해 SKU별 재고 보충 시점을 자동으로 발주합니다. 재고 부족으로 인한 주문 취소율을 줄이는 가장 근본적인 접근입니다.
댓글