고객·공급사·포워딩이 '같은 화면'을 보게 되기까지
플랫폼을 오래 운영하다 보면, 기술보다 더 복잡해지는 것이 있다.
그것은 기능도 아니고, 트래픽도 아니다.
관계다.
REINDEERS 플랫폼 역시 마찬가지였다.
고객사, 공급사, 포워딩.
각각은 분명 자신의 역할을 충실히 수행하고 있었지만,
플랫폼 안에서는 항상 어딘가 어긋나 있었다.
고객사는 "견적을 요청했다"고 생각했고,
공급사는 "주문이 확정되지 않았다"고 말했으며,
포워딩은 "물류 조건이 확정되지 않았다"고 답했다.
문제는 누구의 잘못도 아니었다.
문제는 각자가 서로 다른 기준점에서 같은 주문을 바라보고 있었다는 것이었다.
같은 주문, 다른 해석 -- 기존 데이터 모델의 한계
초기의 구조에서 가장 큰 문제는
견적 - 주문 - 물류가 단선적인 흐름으로 설계되어 있었다는 점이다.
현실의 무역에서는 이 세 단계가 결코 직선으로 이어지지 않는다.
-
견적은 여러 번 바뀌고
-
주문은 조건부로 확정되며
-
물류는 생산과 일정에 따라 다시 재조정된다
하지만 시스템은 이를 "한 번 정해지면 끝나는 단계"로 취급했다.
그 결과, 주문 하나를 두고 상태 값은 맞는데 의미는 다른 상황이 반복해서 발생했다.
기존 데이터 모델을 구체적으로 보면 이렇다. Order라는 단일 엔티티가 견적부터 배송 완료까지의 모든 상태를 관리했다. status 필드 하나가 quoted, confirmed, in_production, shipped, delivered를 순차적으로 거치는 구조였다.
문제는 현실에서 하나의 PO에 대해 여러 번의 배송이 발생할 수 있다는 점이었다. 공급사가 생산 완료된 물량부터 분할 배송하는 경우, 기존 모델에서는 Order의 status를 shipped로 바꿔야 하는데, 아직 생산 중인 물량도 남아있었다. partially_shipped 같은 중간 상태를 추가하면 해결될 것 같지만, 이런 예외 상태가 쌓이면서 상태 머신은 점점 복잡하고 예측 불가능해졌다.
이 문제를 해결하기 위해, 우리는 기능을 추가하는 대신
구조를 다시 정의하기로 했다.
Draft라는 개념을 중심에 두다
재설계의 출발점은 단순했다.
"확정되지 않은 것은, 확정되지 않았다고 명확하게 표현하자."
그래서 모든 흐름의 중심에 Draft 개념을 두었다.
-
고객사의 요청은 Draft Purchase Order
-
Draft PO를 기반으로 Draft Delivery Order (하나의 PO에서 복수의 DO가 생성될 수 있다)
-
Draft DO를 기준으로 포워딩의 물류 견적 발행 (하나의 DO에 대해 복수의 FWD Quote가 경쟁한다)
기존의 단선적 흐름과 재설계된 흐름을 비교하면 이렇다.
| 기존 구조 | 재설계 구조 |
|---|---|
| Order (단일 엔티티) | Draft PO - (1:N) - Draft DO - (1:N) - FWD Quote |
| status 필드 1개로 전체 흐름 관리 | PO, DO, FWD 각각 독립 상태 머신 |
| 분할 배송 시 예외 처리 필요 | PO 1건에 DO 3건 생성 -- 각각 독립 배송 |
| 포워딩 견적 = Order에 종속 | FWD Quote는 DO에 종속 -- 경쟁 입찰 가능 |
| 확정/미확정 구분 모호 | Draft = 미확정, Confirmed = 확정 -- 명확한 분리 |
이 구조에서 중요한 점은
누구도 '확정된 것처럼 행동하지 않아도 된다는 것이다.
-
고객사는 여러 시나리오를 비교할 수 있고
-
공급사는 생산 가능 범위 안에서 조건을 제시하며
-
포워딩은 실제 실행 가능한 물류 조건으로 입찰한다
모든 참여자가 같은 상태, 같은 맥락, 같은 화면을 보게 된 순간이었다.
상태 머신 설계 -- PO, DO, FWD의 독립적 생애주기
재설계의 핵심은 각 엔티티가 독립적인 상태 머신을 가진다는 점이다. PO가 확정되었다고 DO가 자동으로 확정되지 않고, DO가 배송 중이라고 PO가 완료된 것도 아니다.
PO의 상태 흐름은 다음과 같다.
Draft PO States:
draft
-> pending_vendor (바이어가 공급사에 견적 요청)
-> confirmed (양측 합의, PO 확정)
-> in_production (공급사 생산 시작)
-> shipped (모든 DO가 출하 완료)
-> completed (모든 DO가 인도 완료 + 정산 완료)
-> cancelled (어느 단계에서든 취소 가능)
Allowed Transitions:
draft -> pending_vendor, cancelled
pending_vendor -> confirmed, draft, cancelled
confirmed -> in_production, cancelled
in_production -> shipped
shipped -> completed
DO의 상태 흐름은 PO와 독립적이다.
Draft DO States:
draft
-> pending_forwarding (물류 견적 요청 발행)
-> fwd_selected (포워더 선정 완료)
-> customs_clearance (통관 진행 중)
-> in_transit (운송 중)
-> delivered (인도 완료)
-> cancelled
Allowed Transitions:
draft -> pending_forwarding, cancelled
pending_forwarding -> fwd_selected, draft, cancelled
fwd_selected -> customs_clearance
customs_clearance -> in_transit
in_transit -> delivered
각 상태 전환은 MQ에 이벤트로 발행된다. trade.th.state_changed.po, trade.th.state_changed.do 형태의 라우팅 키로 전파되며, 관련 플랫폼이 이를 구독하여 자신의 화면을 갱신한다. 상태 전환의 유효성 검사는 반드시 서버 사이드에서 수행되고, 허용되지 않은 전환(예: draft에서 바로 shipped)은 거부된다.
상태 전환 로직의 핵심 구조는 다음과 같다.
# 상태 전환 유효성 검사 (pseudocode)
VALID_TRANSITIONS = {
"po": {
"draft": ["pending_vendor", "cancelled"],
"pending_vendor": ["confirmed", "draft", "cancelled"],
"confirmed": ["in_production", "cancelled"],
"in_production": ["shipped"],
"shipped": ["completed"],
},
"do": {
"draft": ["pending_forwarding", "cancelled"],
"pending_forwarding": ["fwd_selected", "draft", "cancelled"],
"fwd_selected": ["customs_clearance"],
"customs_clearance": ["in_transit"],
"in_transit": ["delivered"],
}
}
def transition_state(entity_type, entity, new_state, actor):
current = entity.state
allowed = VALID_TRANSITIONS[entity_type].get(current, [])
if new_state not in allowed:
raise InvalidTransitionError(
f"{entity_type} cannot transition from {current} to {new_state}"
)
# Optimistic locking -- version 충돌 시 재시도
updated = update_with_version_check(
entity_id=entity.id,
expected_version=entity.version,
new_state=new_state,
new_version=entity.version + 1
)
if not updated:
raise ConcurrentModificationError("Entity was modified by another request")
# 상태 변경 이벤트 발행
publish_event(
routing_key=f"trade.{entity.country}.state_changed.{entity_type}",
payload={
"entity_id": entity.id,
"previous_state": current,
"new_state": new_state,
"actor": actor,
"timestamp": now()
}
)
세 역할이 같은 데이터를 다르게 보는 방법
바이어, 공급사, 포워더는 동일한 PO/DO 데이터를 공유하지만, 각자에게 보이는 화면과 수행할 수 있는 액션은 다르다. 데이터를 복제하는 것이 아니라, 하나의 원본 데이터에 대해 역할별 뷰(view)를 제공하는 구조다.
| PO 상태 | 바이어가 보는 것 | 공급사가 보는 것 | 포워더가 보는 것 |
|---|---|---|---|
| draft | 편집 가능, 견적 요청 버튼 | 보이지 않음 | 보이지 않음 |
| pending_vendor | 대기 중 표시, 수정 불가 | 견적 응답 화면, 조건 입력 가능 | 보이지 않음 |
| confirmed | 확정 내역 조회, DO 생성 가능 | 생산 지시 수신, 일정 입력 | 보이지 않음 (DO가 생성되어야 노출) |
| in_production | 생산 진행률 조회 | 진행률 업데이트, 출하 예정일 입력 | DO 기준으로 물류 견적 준비 |
권한 모델은 상태에 종속된다. 같은 사용자라도 PO의 현재 상태에 따라 수행 가능한 액션이 달라진다. 바이어는 draft 상태에서만 PO를 수정할 수 있고, 공급사는 pending_vendor 상태에서만 견적 조건을 입력할 수 있다. 이 제약은 API 레벨에서 강제되며, 프론트엔드는 현재 상태와 사용자 역할에 따라 버튼의 활성화/비활성화를 결정한다.
동시 수정 문제 -- Optimistic Locking
바이어와 공급사가 동시에 같은 PO의 상태를 변경하려 할 때 어떻게 되는가. 예를 들어 바이어가 PO를 취소하려는 순간, 공급사가 견적을 확정하려는 경우다.
이 문제를 해결하기 위해 모든 상태 변경에 Optimistic Locking을 적용했다. 각 엔티티에는 version 필드가 있으며, 상태 변경 시 현재 version을 함께 전송한다. 서버는 DB에서 해당 엔티티의 현재 version과 비교하여, 일치할 때만 업데이트를 수행하고 version을 1 증가시킨다. 불일치하면 누군가 먼저 수정했다는 의미이므로, 클라이언트에 최신 상태를 다시 조회하라는 응답을 보낸다.
-- Optimistic Locking이 적용된 상태 전환 쿼리
UPDATE purchase_orders
SET state = $1,
version = version + 1,
updated_at = NOW(),
updated_by = $2
WHERE id = $3
AND version = $4
AND state = $5
RETURNING id, state, version;
RETURNING 절이 빈 결과를 반환하면 version 또는 state 조건이 맞지 않은 것이다. 이 경우 409 Conflict 응답과 함께 현재 엔티티 상태를 반환하여, 클라이언트가 최신 정보를 기반으로 다시 판단할 수 있도록 한다.
이벤트 순서 보장은 MQ 레벨에서 처리한다. 동일 거래의 이벤트는 correlation_id를 기반으로 같은 큐 파티션에 라우팅되므로, 소비자 측에서 순서가 뒤바뀔 일이 없다.
UI/UX 변경은 결과이지, 목적이 아니었다
최근 진행된 주문/견적 UI 개편은
디자인을 바꾸기 위한 작업이 아니었다.
구조가 바뀌면, 화면도 반드시 바뀌어야 했다.
이전의 UI는 "입력 화면"에 가까웠다면,
현재의 UI는 "의사결정 화면"에 가깝다.
-
지금 이 주문이 어디까지 확정되었는지 -- 상태 머신의 현재 위치가 시각적으로 표현된다
-
누가 다음 액션을 가져가야 하는지 -- 역할별 다음 행동이 명시적으로 안내된다
-
내가 바꿀 수 있는 것과 없는 것이 무엇인지 -- 현재 상태에서 편집 가능한 필드만 활성화된다
이 모든 정보가 한 화면 안에서 자연스럽게 읽히도록 재구성되었다.
기술적으로는 상태 의존성이 높은 영역이었고,
여러 번의 충돌과 재설계가 필요했다.
하지만 이 과정을 거치지 않고서는
플랫폼의 다음 단계를 이야기할 수 없었다.
왜 이 재설계가 DVRP의 전제 조건이었는가
DVRP(Dynamic Vehicle Routing Problem)는 배송 경로 최적화 엔진이다. 차량, 창고, 배송지, 시간 제약을 입력받아 최적의 배송 경로를 생성한다. 이 엔진이 동작하려면 한 가지 전제가 필요하다. "언제 배송이 시작되는가"를 시스템이 정확히 알아야 한다는 것이다.
기존 모델에서는 이 시점이 모호했다. Order의 status가 confirmed일 때 DVRP를 트리거해야 하는가, shipped일 때인가. 분할 배송이면 언제 첫 번째 배송이 시작되는가. Order 하나에 물류 조건이 명확히 분리되어 있지 않으니, DVRP에 넘겨줄 입력 데이터 자체를 정의할 수 없었다.
재설계 이후에는 명확해졌다.
- DVRP는 DO 단위로 동작한다. PO가 아니라 DO가 물리적 배송의 단위이기 때문이다.
- DVRP 트리거 시점은 DO가
fwd_selected상태로 전환될 때다. 포워더가 선정되어야 차량과 경로 배정이 가능하다. - DO에는 출발지(공급사 창고), 도착지(바이어 또는 지정 창고), 화물 정보(부피, 중량, 품목), 희망 인도일이 명확히 포함되어 있다. 이것이 곧 DVRP의 입력 파라미터가 된다.
기존 모델에서는 이 모든 정보가 Order에 흩어져 있었고, 배송별로 분리되지 않았다. Draft DO 개념이 도입됨으로써 각 배송 건의 물류 조건이 독립적으로 관리되고, DVRP가 개별 DO 단위로 정확한 경로 최적화를 수행할 수 있게 되었다.
AI는 '결정자'가 아니라 '보조자'로 들어온다
이번 구조 개편에서 AI는 전면에 등장하지 않는다.
대신 조용히, 그러나 핵심적인 지점에 배치되었다.
-
고객사 견적 요청 단계에서의 조건 정리 -- 누락된 필수 조건을 자동 감지하여 입력을 유도
-
공급사 상품 정보 기반 견적 보조 -- 과거 거래 데이터를 참조하여 견적 범위를 제안
-
포워딩 물류 견적 비교와 조건 추천 -- 복수의 FWD Quote를 다각도로 비교하는 비교표 생성
AI는 결정을 대신하지 않는다.
대신 결정을 하기 좋은 상태를 만들어준다.
상태 머신과 AI의 관계도 명확하다. AI의 제안은 상태 전환을 유발하지 않는다. 상태 전환은 반드시 사람의 액션에 의해서만 발생한다. AI는 사람이 액션을 수행하기 전에 필요한 정보를 정리하고, 수행 후에 결과를 검증하는 역할만 한다.
이 철학은 이후 DVRP, 재고, 배차, 물류 전반으로 그대로 이어진다.
그리고 이제, DVRP로 넘어간다
고객/공급사/포워딩의 관계 구조가 정리되면서
플랫폼은 자연스럽게 다음 질문에 도달했다.
"이 주문은, 실제로 어떻게 움직이는가?"
그 답이 바로 DVRP다.
현재 REINDEERS에서는
DVRP 베타(2026년 3월 예정)를 목표로 한 개발이 진행 중이다.
이제 주문은 문서로 끝나지 않는다.
재고, 창고, 트럭, 일정, 그리고 실제 이동까지 이어진다.
이번 관계 모델 재설계는
DVRP를 붙이기 위한 준비 작업이기도 했다.
다음 글에서는
무역 플랫폼과 3PL 엔진이 만나는 지점,
그리고 왜 DVRP를 독립 서비스가 아닌 플랫폼 코어로 설계했는지를 이야기하려 한다.
구조를 정리했으니,
이제 움직일 차례다.