Skip to main content

관계 모델을 다시 설계하다

고객·공급사·포워딩이 '같은 화면'을 보게 되기까지

플랫폼을 오래 운영하다 보면, 기술보다 더 복잡해지는 것이 있다.

그것은 기능도 아니고, 트래픽도 아니다.

관계다.

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를 독립 서비스가 아닌 플랫폼 코어로 설계했는지를 이야기하려 한다.

구조를 정리했으니,

이제 움직일 차례다.


관련 글

Popular posts from this blog

Reindeers Workflow: B2B 파트너 업무 효율과 자동화를 위한 워크플로우 플랫폼

B2B 국제 무역에서 하나의 거래가 완료되기까지 관여하는 시스템과 사람의 수는 예상보다 훨씬 많다. 견적 요청에서 시작해 공급사 선정, 발주, 포워딩 비딩, 통관 서류 준비, 출하, 배송, 정산까지 — 각 단계마다 서로 다른 담당자가 서로 다른 도구에서 수작업을 반복한다. 이 현장에서 반복적으로 발생하는 비효율은 분명하다. 바이어가 견적을 확정하면 공급사에게 이메일이나 메신저로 직접 통보해야 하고, 결제가 완료되면 수동으로 정산 시트에 옮기면서 1~3일이 소요된다. 출하 후에는 선적 정보를 기반으로 CI, PL, CO를 수동 생성하며 누락이 발생하고, 배송 완료 후 공급사/포워더 정산을 수작업으로 대조하면서 오차가 누적된다. ERP, 이메일, 스프레드시트, CRM에 같은 데이터를 반복 입력하는 것도 일상이다. 이 문제들의 공통점은 명확하다. "이벤트가 발생했을 때 후속 작업이 자동으로 실행되지 않는다" 는 것이다. 견적이 확정되었다는 '사실'은 시스템에 기록되지만, 그 사실이 다음 단계의 업무를 자동으로 트리거하지는 않는다. Reindeers Workflow는 이 문제를 해결하기 위해 만들어졌다. 단순히 "자동화 도구를 제공한다"가 아니라, REINDEERS 플랫폼에서 발생하는 실제 거래 이벤트를 기반으로 후속 업무가 자동 실행되는 구조를 만드는 것이다. REINDEERS 플랫폼과의 연결: 거래 이벤트가 워크플로우를 트리거한다 Reindeers Workflow의 가장 중요한 차별점은 범용 자동화 도구가 아니라 REINDEERS 본 플랫폼의 거래 이벤트에 직접 연결 된다는 것이다. REINDEERS에서 발생하는 핵심 거래 이벤트가 MQ(Message Queue)를 통해 워크플로우의 트리거가 된다. 거래 이벤트 트리거되는 워크플로우 실행 내용 quote.confirmed 공...

레인디어스, Buybly로 동남아시아 산업자재 시장 혁신

B2B 오픈마켓 REINDEERS, 한국 기업의 글로벌 진출을 돕다 레인디어스, 머신러닝 기반의 산업자재 매칭 솔루션으로 경쟁력 강화 김명훈 레인디어스 대표 산업자재 시장의 복잡성과 유통장벽은 많은 기업들에게 큰 도전 과제가 되어왔다. 특히 동남아시아 시장 진출을 원하는 한국의 산업자재 제조사들은 현지의 불투명한 거래 환경과 물류 문제로 어려움을 겪어왔다. 이러한 상황에서 레인디어스의 REINDEERS 플랫폼은 새로운 기회를 제시하고 있다. REINDEERS는 B2B 오픈마켓으로, 한국 기업들이 손쉽게 동남아시아 시장에 진출할 수 있도록 지원하며, 유통의 복잡성을 해결하는 혁신적인 솔루션으로 주목받고 있다. 이러한 변화의 중심에는 레인디어스 대표가 있다. 그는 지난 9년간 태국에서의 경험을 바탕으로 고객의 pain point를 해결하기 위해 REINDEERS를 개발했다. 이번 인터뷰를 통해 그의 비전과 경영 철학, 그리고 REINDEERS가 어떻게 산업자재 시장을 변화시키고 있는지에 대해 깊이 있는 이야기를 나누게 되었다. 김명훈 레인디어스 대표 -.소개 레인디어스는 국내 산업자재 제조사들이 동남아시아 시장에 쉽게 진출할 수 있도록 돕는 B2B 오픈마켓인 REINDEERS를 운영하고 있다. 해외 시장 진출에서 가장 큰 장애물인 유통, 물류, 무역의 장벽을 해결해주는 것이 이 플랫폼의 핵심이다. REINDEERS는 단순한 거래 플랫폼이 아니라, 산업자재 구매와 공급 과정을 간소화하고 최적화하는 One-Stop 솔루션으로 자리 잡았다. 레인디어스의 서비스는 REINDEERS와 Enterprise Solution(ERP, POP, WMS)으로 구성되어 있다. 이 솔루션은 동남아시아 현지의 고객사와 공급사에 맞춤형으로 제공되며, 산업현장의 선진화를 이끌어낸다. 기업 운영과 생산 관리, 재고 관리를 전산화해 이익을 극대화하는 데 기여하고 있다. REINDEERS는 산업현장에서 획득한 Raw data를 활용해 인공지능 분석을 통해 발주 ...

JD 플랫폼 매니저 (Platform Manager )

🇰🇷 플랫폼 매니저 (운영 / 글로벌 B2B & AI Agent 기반 자동화 플랫폼) 회사명: (주)레인디어스 | REINDEERS Co., Ltd. 근무지: 서울 / 방콕 (Hybrid 가능) 고용형태: 정규직 (계약-전환형 가능) 회사 소개 REINDEERS는 산업자재 및 무역 중심의 글로벌 B2B 플랫폼을 운영하는 기술 기반 기업입니다. 한국, 태국, 말레이시아, 중국 4개 주요 아시아 시장에서 견적–발주–물류(3PL)–통관–정산–재고관리(WMS)를 통합 관리하는 시스템을 제공합니다. REINDEERS는 POP과 DVRP를 AI로 전환되는 구조로 설계하고 있습니다. 사람은 전략과 방향을 결정하고, 실제 업무는 AI Agent가 실행하는 구조입니다. 조직도에 직원을 등록할 때 사람, AI Agent, 로봇 중에서 선택할 수 있으며, 같은 워크플로우와 같은 권한 체계로 협업합니다. CEO Agent가 전사 전략과 자원 배분을 총괄하고, 구매·생산·영업·물류·재무·통관 Agent가 각 부서 업무를 자율적으로 실행합니다. REINDEERS는 운영 중심의 플랫폼 관리 전문가를 찾습니다. 본 포지션은 플랫폼의 운영·유지·관리·발전·확장을 담당하며, 사람 담당자와 AI Agent, 그리고 향후 합류할 로봇 작업자가 같은 조직도 안에서 협업하는 환경을 관리하는 역할을 맡습니다. (※ 개발 업무를 직접 수행하지 않으며, 개발팀 및 AI Agent 팀과 협업해 개선을 주도합니다.) 이 포지션이 일하는 환경 REINDEERS는 POP과 DVRP를 "조직도 기반 AI 법인" 구조로 설계하고 있습니다. 외부 AI 도구를 연결하는 방식이 아니라, AI Agent가 회사 조직 구조에 직접 통합되어 있습니다. 플랫폼 매니저는 이 Agent들이 정상적으로 작동하는지 모니터링하고, 예외 상황에 대한 승인과 에스컬레이션을 처리하며, 사람 운영자와 AI Agent 간의 협업 경계를 정의하는 역할을 합니다. 현재는 Tool 단계(사...