이 글은 누구를 위한 것인가
- 매장·창고·온라인 재고가 따로 놀아 오버셀이 매주 발생하는 커머스 운영자
- 옴니채널 통합 프로젝트를 시작했지만 어디부터 손대야 할지 막막한 백엔드 엔지니어
- "주문 가능 재고"의 정의가 팀마다 달라 회의가 길어지는 PM·기획자
들어가며
재고 관리는 한 줄로 요약된다. 지금 팔 수 있는 게 몇 개인가. 이 한 줄이 채널이 늘어날수록 답하기 어려워진다. 매장 POS는 어제 자정 기준, 창고 WMS는 5분 전, 온라인 스토어는 1분 전, 마켓플레이스는 캐시 때문에 10분 전 숫자를 들고 있다. 같은 SKU에 대해 네 가지 답이 동시에 존재한다.
옴니채널 재고 일관성은 이 네 답을 하나로 좁히는 작업이다. 모든 채널이 같은 순간에 같은 숫자를 쓸 수는 없다. 다만 허용 오차를 정의하고, 그 안에서 어떤 숫자가 권위 있는지를 설계하는 일이다.
여름 시즌 캠페인을 앞둔 5월은 이 작업을 손볼 마지막 시점이다. 6월에 손대면 캠페인 중에 동기화 사고가 난다.

1. 재고는 한 숫자가 아니다
옴니채널 재고를 한 숫자로 보려는 시도가 가장 자주 실패한다. 실제로는 다음 다섯 숫자가 동시에 존재한다.
| 숫자 | 정의 | 데이터 소스 |
|---|---|---|
| 실물 재고 (On-hand) | 매장·창고에 물리적으로 있는 수량 | WMS, POS 실측 |
| 예약 재고 (Reserved) | 주문은 들어왔지만 출고 전 | OMS |
| 이동 재고 (In-transit) | 창고 간 이동 중 | TMS |
| 입고 예정 (Inbound) | 발주는 됐지만 입고 전 | ERP |
| 가용 재고 (ATP) | 지금 판매 가능한 수량 | 위 네 항목으로 계산 |
ATP(Available To Promise)가 결국 판매 가능 숫자다. 공식은 단순하다.
ATP = On-hand - Reserved - 안전 재고 + (필요 시) In-transit
마켓플레이스 노출 수량은 ATP다. 매장 픽업 노출은 해당 매장 ATP다. ATP를 채널마다 따로 계산하면 같은 사고가 반복된다. ATP는 한 곳에서 계산해 모든 채널에 같은 값을 뿌려야 한다.
2. 권위 있는 소스 한 곳을 정한다
모든 채널이 ATP의 진짜 출처를 알아야 한다. 흔한 구조는 다음 셋이다.
- WMS 권위: 창고 시스템이 진실. POS는 매장 재고만 책임
- OMS 권위: 주문 관리 시스템이 모든 채널의 ATP를 단일 계산
- 별도 Inventory Service 권위: 재고만 다루는 마이크로서비스
세 번째가 새 시스템을 짓는 팀에게 권장된다. WMS·OMS는 기능이 너무 많아 재고 일관성만 보장하기 어렵다. Inventory Service는 다음 책임만 가진다.
- ATP 계산 (위 공식)
- 가용·예약 변경 이벤트 발행
- 채널별 노출 정책 적용 (안전 재고, 채널별 분배 비율)
- 오버셀 방지 가드
채널은 Inventory Service만 호출한다. WMS·POS·ERP는 Inventory Service에 상태 변경 이벤트만 보낸다.
3. 이벤트 기반 동기화 흐름
WMS ──[StockChanged]──┐
POS ──[SaleCompleted]─┤
ERP ──[PurchaseOrderReceived]─┤
▼
Inventory Service
(이벤트 처리 + ATP 재계산)
│
┌───────────────┼───────────────┐
▼ ▼ ▼
온라인 스토어 마켓플레이스 매장 키오스크
(캐시 무효화) (재고 푸시 API) (POS 갱신)
각 채널은 ATP를 직접 계산하지 않는다. Inventory Service가 발행하는 재고 변경 이벤트를 구독해 자기 캐시를 무효화한다. 동기화 지연은 발생하지만 동기화 방향이 한쪽이라 정합성이 깨지지 않는다.
CDC(Change Data Capture)를 쓰면 WMS·POS의 DB 변경을 자동으로 이벤트로 변환할 수 있다. Debezium, Maxwell 같은 도구가 흔하다. 직접 이벤트를 발행하도록 WMS를 수정하기 어려운 경우의 우회 경로다.
4. 마켓플레이스 푸시 vs 풀 정책
마켓플레이스는 보통 재고 푸시 API를 제공한다. 셀러가 변경 시점에 재고를 보낸다. 그러나 다음 두 가지 함정이 있다.
- API 한도: 분당 호출 수 제한. 대량 카탈로그는 변경분만 보내야 한다.
- 순서 보장: 빠른 변경이 연속되면 마지막 값이 덮어쓰여야 한다. 큐에 순서 보장이 없으면 옛 값이 마지막에 도착할 수 있다.
권장 패턴은 디바운스 + 마지막 값 보장이다.
# 채널별 디바운스 큐
queue = ChannelInventoryQueue(channel="marketplace_a")
@event_handler("inventory.atp_changed")
def on_atp_changed(event):
queue.upsert(
sku=event.sku,
atp=event.atp,
version=event.version,
)
# 별도 워커
@every(seconds=2)
def flush_queue():
batch = queue.drain_latest_per_sku() # SKU당 최신 값만
marketplace_api.bulk_update(batch)
2초 디바운스로 같은 SKU에 대한 N번 변경을 1번으로 압축한다. version 필드로 마켓플레이스 측에서 오래된 값을 거부할 수 있게 한다. (마켓플레이스가 버전을 안 받으면 셀러 측에서 마지막 push 시각을 기록해 역전 방지를 직접 처리한다.)
5. 오버셀 방지 가드 — 비관적·낙관적 락
ATP 1개 남은 상품이 동시에 두 채널에서 결제되면 어떻게 되는가. 가드 없이는 둘 다 성공한다. 둘 중 하나는 오버셀이다.
흔한 가드 두 가지다.
5.1 비관적 락 (Pessimistic)
BEGIN;
SELECT atp FROM inventory WHERE sku = 'ABC' FOR UPDATE;
-- atp >= quantity 이면 UPDATE
UPDATE inventory SET atp = atp - 1 WHERE sku = 'ABC';
COMMIT;
확실하지만 동시성 처리량이 떨어진다. 인기 상품 결제 폭주 시 락 대기로 응답이 늦어진다.
5.2 낙관적 락 (Optimistic)
UPDATE inventory
SET atp = atp - 1, version = version + 1
WHERE sku = 'ABC' AND version = $current_version AND atp >= 1;
-- affected_rows = 0 이면 충돌. 재시도.
대부분 시점에 충돌이 없으므로 처리량이 좋다. 충돌 시 재시도 한도를 두고, 한도 초과 시 사용자에게 "재고 부족" 알림을 즉시 띄운다.
플래시 세일·한정 수량은 비관적 락 + 짧은 *예약 시간(reserve)*을 결합한 패턴이 흔하다. 결제 시작 시 5분 예약, 결제 실패 시 자동 해제.

6. 채널별 분배 정책
ATP 100개를 모든 채널에 그대로 노출하면, 한 채널에서 한꺼번에 팔려 다른 채널이 무방비로 오버셀된다. 분배 정책이 필요하다.
| 정책 | 설명 | 적합 상황 |
|---|---|---|
| 채널별 고정 비율 | 온라인 70% / 매장 30% | 카테고리별 채널 강도가 다른 경우 |
| 우선순위 + 잔여 | 자사몰 우선, 잔여만 마켓플레이스 | 자사 충성 고객 보호 |
| 동적 재분배 | 판매 속도에 따라 매시간 재분배 | 시즌·시간대 변동 큰 카테고리 |
| 안전 재고 + 풀 풀링 | 안전 재고만 빼고 모두 공통 풀 | 표준 정책, 단순 |
대부분 사이트는 4번이 안전 출발점이다. 안전 재고 510개를 빼고 나머지를 모든 채널에 같은 ATP로 노출한다. 1번3번은 시즌·카테고리별로 별도 운영이 필요하면 점진 도입한다.
7. 매장 픽업·매장 출고 — 매장 단위 ATP
옴니채널의 진짜 복잡도는 매장 단위 ATP에서 온다. "강남점 픽업 가능 1개"는 강남점 ATP다. 전사 ATP 100개와 다르다.
매장 단위 ATP를 다루려면 다음이 필요하다.
- 매장별 재고 마스터 (SKU × 매장 = 행 한 개)
- 매장 간 이동 재고 표시 (강남 → 잠실 이동 중)
- 픽업 예약 만료 정책 (24시간 미수령 시 ATP 복구)
- 매장 영업 시간 외 픽업 차단
매장 반경 검색과 결합하면 사용자가 "내 위치 5km 이내 픽업 가능 매장"을 본다. 이때 노출 매장의 ATP가 1개라도 동기화 지연 5분이면 5분 안에 두 사람이 동시에 예약하는 사고가 난다. 매장 단위 ATP는 비관적 락을 권한다. 매장 픽업 거래량이 전체에서 차지하는 비율이 높지 않아 락 대기 부담이 적다.
8. 운영 체크리스트
분기 한 번 점검할 항목이다.
- ATP 계산 공식이 코드 한 곳에만 있다
- 모든 채널이 Inventory Service만 호출한다
- WMS·POS·ERP는 이벤트만 발행한다
- 마켓플레이스 push에 디바운스 + 버전이 적용돼 있다
- 오버셀 방지 가드(비관적/낙관적 락)가 결제 트랜잭션에 명시돼 있다
- 채널별 분배 정책이 코드가 아닌 설정에서 변경 가능하다
- 매장 단위 ATP는 별도 테이블과 별도 락 정책을 갖는다
- 동기화 지연 p99가 모니터링 대시보드에 있다
- 오버셀 발생 시 자동 사과·환불 + 운영자 알림 흐름이 있다
- 분기마다 재고 실측 대비 시스템 차이를 측정한다
마지막 항목이 가장 자주 빠진다. 시스템 ATP와 실측이 ±2% 이상 벌어지면 동기화 어딘가가 깨져 있다. 사고가 터지기 전에 차이가 먼저 보인다.
마무리
옴니채널 재고는 기술 문제보다 정의 문제가 크다. ATP가 무엇인지, 누가 계산하는지, 채널이 어떻게 그 값을 받는지의 합의가 먼저다. 합의가 명확하면 구현은 위 패턴 안에서 풀린다.
여름 시즌이 시작되기 전에 ATP 계산 코드가 몇 곳에 있는지부터 세 보길 권한다. 두 곳 이상이면 그게 다음 사고의 근원이다. 한 곳으로 모으는 작업은 한 분기를 통째로 쓸 가치가 있다.