WMS(Warehouse Management System)란?
물류센터에서 상품의 입고, 보관, 재고, 피킹, 포장, 출고를 관리하는 창고관리시스템

문제 상황
현재 주문 시스템은 다음 흐름으로 동작한다고 가정한다.
고객 결제 완료
→ 주문 서버가 메인 DB에 주문 상태 저장
→ 주문 서버가 곧바로 WMS API 호출
→ 물류센터 출고 지시
겉보기에는 단순하고 자연스러운 구조이다. 하지만 WMS 서버가 장애를 일으키면 문제가 발생한다.
고객 결제는 이미 완료되었고, 우리 DB에는 주문 상태도 저장되었다. 그런데 WMS API 호출이 실패하면 물류센터에는 출고 지시가 전달되지 않는다. 결과적으로 시스템 입장에서는 “결제 완료 주문”이지만, 물류센터 입장에서는 “존재하지 않는 출고 요청”이 되는 것이다.
이것이 배송 누락 사고의 본질이다.
단순히 MQ를 붙이면 해결될까?
가장 먼저 떠올릴 수 있는 개선안은 메시지 큐이다.
주문 서버
→ 주문 DB 저장
→ MQ에 출고 지시 메시지 발행
→ 컨슈머가 WMS API 호출
이 구조는 WMS 장애 격리에는 도움이 된다. WMS가 죽어 있어도 주문 서버는 MQ에 메시지만 넣고 끝낼 수 있다. WMS 장애가 주문 결제 처리 흐름을 직접 막지 않기 때문이다. 하지만 힌트에서 말한 진짜 문제는 여기에 있다.
메인 DB 저장과 메시지 큐 발행 순간에 서버가 다운되면 어떻게 되는가?
예를 들어 다음 상황이 발생할 수 있다.
1. 주문 DB 저장 성공
2. MQ 메시지 발행 직전 서버 다운
이 경우 DB에는 결제 완료 주문이 남아 있다.
하지만 MQ에는 출고 지시 메시지가 없다.
즉, 메시지 큐를 붙였는데도 배송 누락이 그대로 발생한다.
반대로 이런 상황도 있다.
1. MQ 메시지 발행 성공
2. 주문 DB 커밋 실패
이 경우 DB에는 없는 주문인데, 물류센터로 출고 지시가 나갈 수 있다.
결국 핵심은 이것이다.
DB 저장과 MQ 발행을 하나의 원자적 작업처럼 다뤄야 한다.
하지만 일반적인 DB 트랜잭션과 외부 MQ 발행은 하나의 트랜잭션으로 묶기 어렵다.
이 문제를 해결하기 위해 사용하는 대표적인 패턴이 Transactional Outbox Pattern이다.
해결 전략: Transactional Outbox Pattern
Transactional Outbox의 핵심 아이디어는 단순하다! MQ에 바로 메시지를 발행하지 않는다.
대신 메인 DB 안에 outbox 테이블을 만들고, 주문 저장과 출고 이벤트 저장을 같은 트랜잭션으로 처리한다.
주문 저장 + 출고 이벤트 저장 = 같은 DB 트랜잭션
이렇게 하면 주문이 저장되었는데 출고 이벤트가 없는 상태를 막을 수 있다.
개선된 전체 구조

각 컴포넌트의 역할은 다음과 같다
| 주문 서버 | 결제 완료 주문 저장, outbox 이벤트 저장 |
| Main DB | 주문 상태와 발행 대기 이벤트를 함께 저장 |
| Outbox Relay | outbox 테이블을 읽어 MQ로 이벤트 발행 |
| MQ | 출고 지시 이벤트를 안정적으로 보관 |
| Shipment Consumer | MQ 메시지를 소비하여 WMS API 호출 |
| WMS | 실제 물류 출고 처리 |
주문 생성 시 처리 흐름
주문 서버는 결제 완료 시 다음 작업을 하나의 DB 트랜잭션으로 처리한다.
BEGIN;
INSERT INTO orders (
order_id,
user_id,
order_status,
paid_at
) VALUES (
'ORDER-001',
10,
'PAID',
NOW()
);
INSERT INTO outbox_events (
event_id,
aggregate_type,
aggregate_id,
event_type,
payload,
status,
created_at
) VALUES (
'EVENT-001',
'ORDER',
'ORDER-001',
'ORDER_PAID',
'{"orderId":"ORDER-001"}',
'PENDING',
NOW()
);
COMMIT;
여기서 중요한 점은 orders 저장과 outbox_events 저장이 같은 트랜잭션이라는 점이다.
따라서 다음 두 상태만 가능하다.
- 주문 저장 성공 + outbox 이벤트 저장 성공
- 주문 저장 실패 + outbox 이벤트 저장 실패
다음 상태는 절대 발생하지 않는다
주문 저장 성공 + 출고 이벤트 없음
이 구조만으로도 배송 누락 문제의 핵심 원인을 제거할 수 있다~~
Outbox Relay의 역할
Outbox Relay는 outbox_events 테이블에서 PENDING 상태의 이벤트를 읽어 MQ로 발행하는 역할을 한다.
1. outbox_events에서 PENDING 이벤트 조회
2. MQ로 메시지 발행
3. 발행 성공 시 outbox_events 상태를 PUBLISHED로 변경
4. 발행 실패 시 다음 주기에 재시도
PENDING 예시는 다음과 같다
SELECT *
FROM outbox_events
WHERE status = 'PENDING'
ORDER BY created_at
LIMIT 100;
발행에 성공하면 상태를 바로 PUBLISHED로 변경한다!
UPDATE outbox_events
SET status = 'PUBLISHED',
published_at = NOW()
WHERE event_id = 'EVENT-001';
이때 Outbox Relay가 죽어도 문제없다. 왜냐하면 이벤트는 DB에 PENDING 상태로 남아 있기 때문이다.
Relay가 다시 살아나면 남아 있는 이벤트를 다시 읽어 MQ로 발행하면 된다.
그러면 WMS 장애가 발생하면 어떻게 되는가?
"WMS가 장시간 장애 상태라고 해도 주문 서버는 영향을 받지 않는다"
주문 서버 → DB 저장 성공
주문 서버 → outbox 이벤트 저장 성공
이후 Outbox Relay가 MQ에 메시지를 발행한다.
컨슈머가 WMS API를 호출하다 실패하면 메시지를 재시도하거나 DLQ로 보낼 수 있다.

여기서 중요한 점은 WMS 장애가 주문 결제 흐름에 영향을 주지 않는다는 것이다. (장애 격리)
고객 결제와 출고 지시 처리가 비동기로 분리되었기 때문이다.
컨슈머는 반드시 멱등하게 만들어야 한다
MQ 기반 구조에서는 같은 메시지가 두 번 전달될 수 있다.
Outbox Relay가 MQ 발행에 성공했지만, DB 상태 업데이트 전에 죽는 경우를 생각해 볼 수 있다.
1. Relay가 MQ 발행 성공
2. outbox_events 상태를 PUBLISHED로 바꾸기 전 서버 다운
3. Relay 재시작
4. 같은 outbox 이벤트를 다시 MQ 발행
이 경우 컨슈머는 같은 출고 지시를 두 번 받을 수 있다.
따라서 컨슈머는 반드시 멱등성을 가져야 한다.
대표적인 방법은 event_id 또는 order_id 기준으로 처리 이력을 저장하는 것이다.
CREATE TABLE processed_messages (
event_id VARCHAR(64) PRIMARY KEY,
processed_at DATETIME NOT NULL
);
컨슈머는 메시지를 처리하기 전에 이미 처리한 이벤트인지 확인한다
1. event_id가 processed_messages에 있는지 확인
2. 이미 있으면 무시
3. 없으면 WMS API 호출
4. 성공 후 processed_messages에 event_id 저장
WMS API에도 가능하다면 idempotencyKey를 전달하는 것이 좋다
{
"orderId": "ORDER-001",
"idempotencyKey": "EVENT-001"
}
이렇게 하면 우리 컨슈머가 실수로 같은 요청을 보내더라도 WMS가 중복 출고를 막을 수 있다
그렇다면 주문 취소 문제는 어떻게 막을까?
심화 문제의 핵심은 아래와 같다 생각한다
1. 결제 완료 주문 발생
2. WMS 장애로 출고 메시지가 MQ에 오래 대기
3. 고객이 주문 취소
4. DB 주문 상태가 CANCELLED로 변경
5. WMS 복구
6. 과거 출고 지시 메시지가 처리됨
7. 취소된 주문이 출고됨
이 문제를 막으려면 컨슈머가 메시지 내용만 믿고 WMS를 호출하면 안 된다. MQ 메시지는 “과거 어느 시점의 이벤트”이다.
반면, 실제 출고 여부 판단은 “현재 주문 상태”를 기준으로 해야 한다. 따라서 컨슈머는 WMS API를 호출하기 전에 반드시 메인 DB의 최신 주문 상태를 확인해야 한다

예를 들어 컨슈머는 다음 조건을 만족할 때만 출고를 진행한다.
UPDATE orders
SET order_status = 'SHIPMENT_REQUESTING'
WHERE order_id = 'ORDER-001'
AND order_status = 'PAID';
이 쿼리의 결과가 중요하다.
| 업데이트 row 수 = 1 | 아직 결제 완료 상태이므로 출고 가능 |
| 업데이트 row 수 = 0 | 이미 취소되었거나 상태가 변경되었으므로 출고 불가 |
즉, 고객이 이미 주문을 취소했다면 주문 상태는 CANCELLED이다.
그러면 위 업데이트는 실패한다. 컨슈머는 이 경우 WMS API를 호출하지 않고 메시지를 종료 처리한다.
주문 취소도 이벤트로 남기는 것이 좋다
주문 취소 역시 단순히 DB 상태만 바꾸는 것보다 outbox 이벤트로 남기는 것이 좋다.
주문 취소 요청
→ orders 상태 CANCELLED 변경
→ outbox_events에 ORDER_CANCELLED 이벤트 저장
이 작업도 같은 트랜잭션으로 처리한다.
BEGIN;
UPDATE orders
SET order_status = 'CANCELLED',
cancelled_at = NOW()
WHERE order_id = 'ORDER-001'
AND order_status = 'PAID';
INSERT INTO outbox_events (
event_id,
aggregate_type,
aggregate_id,
event_type,
payload,
status,
created_at
) VALUES (
'EVENT-002',
'ORDER',
'ORDER-001',
'ORDER_CANCELLED',
'{"orderId":"ORDER-001"}',
'PENDING',
NOW()
);
COMMIT;
이렇게 하면 다른 시스템도 주문 취소 사실을 이벤트 기반으로 알 수 있다.
다만 이번 문제의 핵심 방어선은 “취소 이벤트 발행”이 아니다. 가장 중요한 것은 출고 컨슈머가 WMS 호출 전에 주문의 현재 상태를 다시 확인하는 것이다.
[최종 아키텍처 정리]

이 설계가 요구사항을 만족하는 이유
1. 정합성 보장
주문 저장과 outbox 이벤트 저장을 같은 DB 트랜잭션으로 처리한다.
따라서 결제 완료 주문이 DB에 저장되었는데 출고 이벤트가 사라지는 상황을 막을 수 있다.
또한 Outbox Relay가 실패하더라도 이벤트는 DB에 남아 있다.
시스템 복구 후 다시 발행할 수 있기 때문에 출고 지시 누락을 방지할 수 있다.
2. 장애 격리
주문 서버는 WMS API를 직접 호출하지 않는다. 출고 처리는 MQ와 컨슈머를 통해 비동기로 수행된다.
따라서 WMS가 장시간 장애 상태여도 주문 결제 처리 흐름은 영향을 받지 않는다.
3. 중복 처리 방어 (멱등성 확보)
MQ와 Outbox 구조에서는 중복 메시지가 발생할 수 있다.
이를 막기 위해 컨슈머에서 event_id 기반 처리 이력을 관리하고, WMS 호출 시 idempotencyKey를 전달한다.
이를 통해 같은 메시지가 여러 번 전달되더라도 중복 출고를 막을 수 있다.
4. 주문 취소 방어
출고 컨슈머는 메시지를 받았다고 바로 WMS를 호출하지 않는다. 항상 메인 DB의 최신 주문 상태를 확인한다
주문 상태가 이미 CANCELLED라면 해당 출고 메시지는 과거 메시지이므로 무시한다.
이를 통해 장애 복구 직후 오래된 출고 지시가 실행되는 문제를 막을 수 있다.
결론 및 느낀점
이 문제는 단순히 “WMS가 장애났을 때 재시도하면 된다” 수준의 문제가 아니다.
핵심은 주문 DB 저장과 출고 메시지 발행 사이에 존재하는 원자성 문제이다.
MQ만 도입하면 WMS 장애 격리는 가능하지만, DB 저장과 MQ 발행 사이에서 서버가 죽는 문제는 여전히 남는다.
따라서 다음 구조가 필요하다
주문 DB 저장
+ Outbox 이벤트 저장
+ Outbox Relay를 통한 MQ 발행
+ 컨슈머의 멱등 처리
+ 출고 직전 최신 주문 상태 검증
즉, 이 문제의 정답은 Transactional Outbox Pattern 기반의 이벤트 기반 아키텍처이다!
이 구조를 사용하면 WMS 장애가 발생해도 주문 서버는 안정적으로 결제를 처리할 수 있고, 복구 이후에도 출고 지시를 누락 없이 전달할 수 있다. 또한 취소된 주문에 대해 오래된 출고 메시지가 뒤늦게 처리되는 문제까지 방어할 수 있다.
간만에 아주 재밌는 설계 문제였다 ㅎㅎ
'SW 아키텍처' 카테고리의 다른 글
| [SW 아키텍처] 메시지 큐(MQ) 플로우 예시 (회원가입 웰컴 이메일 처리) (0) | 2026.04.29 |
|---|---|
| [SW 아키텍처] 메시지 큐(MQ) 실무 선택 기준 (Kafka, RabbitMQ, AWS SQS, Redis) (0) | 2026.04.25 |
| [SW 아키텍처] 메시지 큐(MQ)란 무엇일까 (0) | 2026.04.24 |
| [SW 아키텍처] 클라우드 기반 데이터 플랫폼 아키텍처 정리 (Snowflake, Redshift · SageMaker · Tableau) (0) | 2026.02.20 |
| [SW 아키텍처] 데이터 엔지니어링/파이프라인 구조 (L0 · L1 · L2 및 ETL) (0) | 2026.02.19 |