Skip to main content

왜 모든 데이터를 Event+State+Log로 통일했는가

 우리 팀에서는 하루에 한 번 이상 이런 대화가 오갔다.

"이 주문 상태가 왜 DVRP에서는 '배송중'인데 REINDEERS 본체에서는 '결제완료'로 남아있어요?"

"Document AI에서 생성된 인보이스가 Workflow에서 승인된 건 맞는데, 그게 실제 거래 시스템에 반영됐는지 어떻게 확인하죠?"

5개 플랫폼을 동시에 운영하는 팀이라면 이런 질문이 매일 나올 수밖에 없다. REINDEERS(거래), DVRP(물류), POP(생산), Document AI(문서), Workflow AI(업무 자동화). 각각 다른 도메인을 담당하지만, 결국 하나의 거래 흐름 위에서 동작하는 플랫폼들이다. 문제는 이 플랫폼들이 각자의 방식으로 데이터를 관리하고 있었다는 것이다.

이 문제가 단순한 개발자의 디버깅 고충에 그쳤다면, 우리는 지금처럼 데이터 모델을 처음부터 다시 설계하지 않았을 것이다. 우리가 Event+State+Log로 전부 밀어버린 진짜 이유는 따로 있다. REINDEERS·POP·DVRP는 지금 AI로 전환되는 구조 위에 올라서고 있다. 조직도 안에 사람과 AI Agent, 로봇이 같이 직원으로 등록되고, 사람은 전략과 방향을 결정하고, 반복 업무는 AI Agent가 실행한다. Agent가 실행을 하려면 "지금 이 주문이 어느 단계에 있고, 왜 그렇게 됐는가"를 사람의 도움 없이 스스로 읽을 수 있어야 한다. 플랫폼마다 데이터 포맷이 다르면 Agent가 읽을 수 없다. 그래서 모델 통일이 AI 전환의 전제 조건이었다.

Event Sourcing — Append-only 로그에서 State 재계산
Event Log (시간순)
E1
created
E2
paid
E3
shipped
E4
arrived
E5
received
E6
settled
Current State
= fold(E1, E2, ..., E6)
State는 삭제/수정되지 않는다. 과거 어느 시점도 Log만으로 재생 가능.

독립적으로 성장한 5개 플랫폼의 데이터 혼란

REINDEERS 생태계의 각 플랫폼은 서로 다른 시점에 개발되었다. REINDEERS 본체가 가장 먼저 만들어졌고, Document AI와 DVRP가 뒤따랐다. POP는 가장 마지막에 합류했다. 자연스럽게, 각 팀은 각자의 도메인에 최적화된 데이터 모델을 설계했다.

거래 시스템은 주문 상태를 단일 테이블의 상태 컬럼 하나로 관리했다. 물류 시스템은 배송 상태를 별도 이력 테이블에 누적했다. 문서 시스템은 문서 버전을 파일 시스템 기반으로 관리했고, 워크플로우 시스템은 각 단계를 JSON 구조로 직렬화해 저장했다. 각 팀의 선택은 그 도메인에 국한해서 보면 합리적이었지만, 플랫폼을 가로지르는 순간 서로 이야기가 통하지 않는 다섯 개의 섬이 되어 있었다.

이 상태에서 "고객사 A의 3월 15일 주문이 현재 어떤 상태인가"를 파악하려면, 최소 3개 데이터 저장소를 뒤져야 했다. 거래 저장소에서 주문 상태를 확인하고, 물류 저장소에서 배송 추적을 조회하고, 문서 저장소에서 인보이스 발행 여부를 확인해야 했다. 각 저장소의 시간 기준이 달랐고 (UTC, KST, ICT 혼재), 상태값의 의미도 미묘하게 달랐다.

디버깅은 악몽이었다. "왜 이 상태가 됐는가"를 추적하려면, 각 플랫폼의 로그를 별도로 뒤져야 했는데, 로그 포맷도 제각각이었다. 본체는 구조화된 JSON 로그를 남겼지만, 초기 DVRP는 텍스트 로그였고, 문서 시스템은 오브젝트 스토리지에 감사 로그를 별도로 저장했다.

그리고 결정적인 문제가 있었다. 플랫폼 간 이벤트 전파가 ad-hoc 방식이었다는 것이다. REINDEERS에서 주문이 확정되면 DVRP에 REST API를 직접 호출해서 배송 요청을 생성했다. 이 API 호출이 실패하면? 재시도 로직이 각 플랫폼마다 다르게 구현되어 있었고, 일부는 재시도 자체가 없었다. 데이터 정합성은 운에 맡기는 구조였다.

State, Event, Log라는 세 축으로의 통일

이 문제를 근본적으로 해결하려면 단순히 API를 개선하는 것으로는 부족했다. 데이터 모델 자체를 통일해야 했다. 우리가 내린 결론은 이렇다. 모든 엔티티는 세 가지 축으로 표현되어야 한다.

State: 지금 이 순간의 스냅샷

State는 엔티티의 현재 상태다. 관계형 DB에 저장되는 가변(mutable) 행 하나다. 주문의 현재 상태, 배송의 현재 위치, 문서의 현재 버전, 생산 공정의 현재 단계. "지금 이것의 상태가 뭔가"라는 질문에 즉시 답할 수 있어야 하기 때문에, State는 최신 값으로 갱신되는 단일 행이다.

// State 테이블 개념 구조 (의사코드)
order_state {
    order_id       : identifier (PK)
    tenant_id      : identifier
    status         : enum (draft | confirmed | shipped | delivered)
    current_data   : structured snapshot (amount, qty, owner, ...)
    version        : integer (낙관적 잠금용)
    updated_at     : timestamp
    updated_by     : identifier
    correlation_id : identifier (비즈니스 트랜잭션 추적용)
}

delivery_state {
    delivery_id    : identifier (PK)
    order_id       : identifier
    status         : enum
    location       : structured (lat/lng/region)
    carrier_info   : structured
    version        : integer
    updated_at     : timestamp
    correlation_id : identifier
}

document_state {
    document_id    : identifier (PK)
    doc_type       : enum
    status         : enum (draft | reviewing | approved | published)
    content_ref    : reference to content (object storage key)
    metadata       : structured
    version        : integer
    updated_at     : timestamp
    correlation_id : identifier
}

여기서 중요한 것은 correlation_id다. 이 값은 하나의 비즈니스 트랜잭션이 여러 플랫폼을 거칠 때 동일하게 유지된다. 주문 확인부터 배송 완료까지, 혹은 문서 생성부터 워크플로우 승인까지 하나의 correlation_id로 추적할 수 있다. 분산 시스템의 "왜 이렇게 됐는가"를 추적 가능하게 만드는 가장 단순하고 강력한 장치다.

Event: 무엇이 상태를 바꿨는가

Event는 State를 변경시킨 원인이다. 불변(immutable)이며, 한번 발행되면 수정하거나 삭제할 수 없다. Event는 두 가지 역할을 동시에 한다. 첫째, 해당 플랫폼 내에서 상태 변경의 기록이다. 둘째, 메시지 큐를 통해 다른 플랫폼에 전파되는 메시지다.

// Event 페이로드 개념 구조
{
    "event_id":        "evt_01HX9K2M...",
    "event_type":      "quote.confirmed",
    "entity_type":     "quote",
    "entity_id":       "qt_01HX8J3N...",
    "tenant_id":       "tn_reindeers_th",
    "correlation_id":  "cor_01HX7G5P...",
    "timestamp":       "2026-04-01T09:23:14.332Z",
    "version":         3,
    "actor": {
        "user_id":  "usr_01HW6F4M...",
        "role":     "buyer_admin",
        "platform": "reindeers"
    },
    "payload": {
        "previous_status": "draft",
        "new_status":      "confirmed",
        "quote_amount":    485000.00,
        "currency":        "THB",
        "items_count":     12,
        "supplier_id":     "sup_01HV5E3L...",
        "delivery_terms":  "CIF Bangkok"
    },
    "metadata": {
        "source_platform":  "reindeers",
        "api_version":      "2026-03",
        "idempotency_key":  "idk_01HX9K2M..."
    }
}

이벤트 네이밍은 {entity}.{action} 형식을 따른다. quote.confirmed, delivery.departed, document.approved, production.completed. 모든 플랫폼이 동일한 네이밍 규칙을 따르기 때문에, 이벤트 타입만 보고도 어떤 플랫폼에서 어떤 일이 일어났는지 파악할 수 있다.

idempotency_key는 중복 처리를 방지한다. 네트워크 문제로 같은 이벤트가 두 번 발행되더라도, 수신 측에서 이 키를 확인해서 이미 처리한 이벤트는 무시한다. 분산 시스템에서 exactly-once 처리를 보장하는 것은 불가능에 가깝지만, at-least-once delivery + idempotent consumer 패턴으로 사실상 동일한 효과를 얻는다.

Log: 전체 이력의 재구성

Log는 Event의 영구 저장소다. 모든 Event는 발행과 동시에 Log 테이블에 append-only로 기록된다. State가 "현재"를 보여준다면, Log는 "과거부터 현재까지의 모든 변화"를 보여준다.

// Event Log 테이블 개념 구조
event_log {
    log_id          : sequential integer (PK)
    event_id        : identifier (UNIQUE, 중복 방지)
    event_type      : string
    entity_type     : string
    entity_id       : identifier
    tenant_id       : identifier
    correlation_id  : identifier
    actor_id        : identifier
    actor_platform  : string
    payload         : structured (JSON-like)
    metadata        : structured
    created_at      : timestamp

    partition_by    : RANGE (created_at)
    unique_key      : event_id
}

// 월 단위 파티션을 자동 생성/아카이브

Log 테이블은 파티셔닝이 필수다. 5개 플랫폼의 모든 이벤트가 여기에 쌓이기 때문에, 파티셔닝 없이는 몇 달 만에 쿼리 성능이 심각하게 떨어진다. 우리는 시간 기반 range partition을 사용한다. 월별로 파티션을 분리하고, 3개월 이상 된 파티션은 콜드 스토리지로 이동시킨다. 다만 AI 학습용 데이터는 별도 파이프라인으로 추출해서 보관한다.

이 세 가지(State, Event, Log)의 관계를 정리하면 이렇다.

구분 성격 저장 변경 용도
State 현재 스냅샷 관계형 DB (mutable) Event에 의해 갱신 실시간 조회, API 응답
Event 변경 원인 메시지 큐 + Log 불변 (immutable) 플랫폼 간 전파, 트리거
Log 전체 이력 관계형 DB (append-only) 불변 (immutable) 감사, 디버깅, AI 학습

이벤트 발행과 상태 갱신의 원자성

가장 까다로운 부분은 Event 발행과 State 갱신의 원자성을 보장하는 것이다. State를 갱신하고 Event를 메시지 큐에 발행하는 두 작업이 항상 함께 성공하거나 함께 실패해야 한다. State는 갱신됐는데 Event가 발행되지 않으면, 다른 플랫폼은 변경 사실을 모른다. Event는 발행됐는데 State 갱신이 실패하면, 이벤트와 실제 상태가 불일치한다.

우리는 Transactional Outbox 패턴을 채택했다. Event를 메시지 큐에 직접 발행하는 대신, 데이터베이스 트랜잭션 안에서 State 갱신과 Event 기록을 함께 수행한다. 별도의 발행자(publisher) 프로세스가 outbox 테이블을 폴링해서 메시지 큐에 발행한다.

// Transactional Outbox 흐름 (의사코드)
begin transaction

  // 1) 상태 갱신 (낙관적 잠금)
  update order_state
    set status = 'confirmed',
        current_data = $snapshot,
        version = version + 1,
        updated_at = now(),
        correlation_id = $cid
    where order_id = $oid
      and version = $expectedVersion

  // 2) 같은 트랜잭션 안에서 outbox에 이벤트 기록
  insert into event_outbox
    (event_id, event_type, entity_type, entity_id,
     tenant_id, correlation_id, payload, metadata,
     published, created_at)
    values (...)

  // 3) 같은 트랜잭션 안에서 Log에도 기록
  insert into event_log (...) values (...)

commit

// 별도 publisher가 주기적으로 outbox를 읽어 메시지 큐에 발행
// 발행 성공 시 published = true 로 마킹

이 방식의 핵심은 "메시지 큐 발행 실패가 데이터 정합성에 영향을 주지 않는다"는 것이다. outbox 테이블에 이벤트가 기록된 이상, 발행자가 재시도를 통해 결국 브로커에 전달한다. State와 Event의 정합성은 데이터베이스 트랜잭션이 보장하고, 큐 전달은 outbox 발행자가 at-least-once로 보장한다.

낙관적 잠금(version 체크)도 의도적이다. 동시에 두 곳에서 같은 주문 상태를 변경하려 하면, 먼저 커밋한 쪽이 성공하고 나중 쪽은 version mismatch로 실패한다. 실패한 쪽은 최신 State를 다시 읽고 재시도한다. 비관적 잠금은 동시성이 높은 환경에서 병목이 되기 때문에 피했다.

메시지 큐를 통한 플랫폼 간 이벤트 전파

이벤트가 outbox에서 메시지 큐로 발행되면, 다른 플랫폼들이 이를 구독(subscribe)한다. 브로커는 Topic Exchange 모델을 지원하는 경량 AMQP 호환 브로커를 사용한다. 5개 플랫폼이 동시에 이벤트를 주고받는 환경에서 가벼운 브로커가 필요했고, 하나의 exchange 위에 여러 바인딩을 유연하게 걸 수 있는 모델이 이벤트 팬아웃에 잘 맞았다.

이벤트 타입이 라우팅 키가 되고, 각 플랫폼은 자신이 관심 있는 이벤트 패턴을 구독한다.

# 토폴로지 개요

Exchange: reindeers.events (type: topic, durable: true)
Routing key pattern: {platform}.{entity}.{action}

Bindings:
  DVRP queue:
    - reindeers.order.confirmed       → 배송 요청 생성
    - reindeers.order.cancelled       → 배송 취소
    - pop.production.completed        → 출고 준비 알림
    - workflow.approval.completed     → 승인 완료 건 처리

  Workflow AI queue:
    - reindeers.quote.confirmed       → 견적 승인 워크플로우 시작
    - reindeers.order.created         → 주문 검증 워크플로우
    - document.invoice.generated      → 인보이스 검토 워크플로우
    - pop.quality.failed              → 품질 이슈 에스컬레이션

  Document AI queue:
    - reindeers.order.confirmed       → 자동 인보이스 생성
    - dvrp.delivery.completed         → 배송 완료 문서 생성
    - workflow.document.requested     → 워크플로우에서 문서 요청

  POP queue:
    - reindeers.order.confirmed       → 생산 계획 반영
    - dvrp.material.arrived           → 원자재 입고 처리
    - workflow.production.approved    → 생산 승인

  AI Training pipeline:
    - *.*.* (전체 이벤트)              → 학습 데이터 수집

각 플랫폼은 자신에게 필요한 이벤트만 선택적으로 받는다. DVRP는 주문 확인과 생산 완료에만 관심이 있고, Document AI는 주문 확인과 배송 완료에만 관심이 있다. 이 구조 덕분에 새로운 플랫폼이 추가되어도 기존 플랫폼을 수정할 필요가 없다. 새 플랫폼이 자신의 큐를 만들고 원하는 이벤트 패턴을 바인딩하면 된다.

AI 학습 파이프라인은 와일드카드(*.*.*)로 전체 이벤트를 수신한다. 모든 플랫폼의 모든 이벤트가 동일한 스키마를 따르기 때문에, 하나의 ETL 파이프라인으로 전체 데이터를 처리할 수 있다. 이것이 데이터 모델 통일의 가장 큰 실질적 이점이었다.

실제 흐름: 견적 확인에서 배송까지

추상적인 설명보다 실제 흐름을 따라가보자. 태국의 바이어가 REINDEERS 플랫폼에서 견적을 확인하는 시점부터 최종 배송까지, Event+State+Log가 어떻게 동작하는지 추적한다.

Step 1: 견적 확인 (REINDEERS)

바이어가 견적서에서 "확인" 버튼을 클릭한다. REINDEERS 본체에서 다음이 일어난다.

// REINDEERS 내부 처리 (의사코드)
begin transaction
  update quote_state
    set status = 'confirmed',
        current_data.confirmed_at = now(),
        version = version + 1,
        correlation_id = 'cor_01HX7G5P...'
    where quote_id = 'qt_01HX8J3N...'
      and version = 2

  insert into event_outbox (event_id, event_type, payload, ...)
    values ('evt_01HX9K2M...', 'quote.confirmed', {
       quote_id, buyer_id, supplier_id,
       amount, currency, items, delivery_terms
    }, ...)

  insert into event_log (...) values (...)
commit

Step 2: 워크플로우 트리거 (Workflow AI)

quote.confirmed 이벤트가 메시지 큐를 통해 Workflow AI에 도달한다. Workflow AI는 해당 바이어의 설정에 따라 견적 승인 워크플로우를 시작한다. 이 워크플로우는 내부 승인 절차, 예산 확인, 공급사 신용 체크 등을 자동으로 수행한다.

// Workflow AI: quote.confirmed 핸들러 (의사코드)
handleQuoteConfirmed(event):
    (quote_id, buyer_id, amount, currency) = event.payload

    // 1) 바이어 설정에 따라 적용할 워크플로우 결정
    workflow = getWorkflowTemplate(buyer_id, 'quote_approval')

    if workflow is null:
        // 워크플로우 없음 → 바로 다음 단계로
        publishEvent({
            event_type: 'workflow.approval.auto_completed',
            correlation_id: event.correlation_id,
            payload: { quote_id, reason: 'no_workflow_configured' }
        })
        return

    // 2) 워크플로우 인스턴스 생성
    instance = createWorkflowInstance({
        template_id:     workflow.id,
        correlation_id:  event.correlation_id,
        trigger_event:   event.event_id,
        context:         { quote_id, buyer_id, amount, currency }
    })

    // 3) 금액 임계치에 따라 매니저 승인 or 자동 승인
    if amount >= 500000:
        requestApproval(instance, 'manager',
                        "견적 {quote_id}: {amount} {currency} 승인 요청")
    else:
        autoApprove(instance)

Workflow AI가 승인을 완료하면 workflow.approval.completed 이벤트를 발행한다. 이 이벤트의 correlation_id는 최초 quote.confirmed의 것과 동일하다.

Step 3: 주문 생성 및 배송 요청 (REINDEERS + DVRP)

workflow.approval.completed 이벤트를 받은 REINDEERS 본체는 자동으로 주문을 생성한다. 이때 order.confirmed 이벤트가 발행되고, DVRP가 이를 수신해서 배송 요청을 생성한다. 동시에 Document AI가 이를 수신해서 인보이스를 자동 생성한다. POP가 이를 수신해서 생산 계획에 반영한다.

하나의 견적 확인 액션이 4개 플랫폼에 동시에 반응을 일으킨다. 그런데 이 반응들은 서로를 직접 호출하지 않는다. 메시지 큐를 통한 느슨한 결합(loose coupling)이다. DVRP가 다운되어 있어도 REINDEERS의 주문 생성은 정상 진행된다. DVRP가 복구되면 큐에 쌓여있던 이벤트를 처리한다.

Step 4: correlation_id로 전체 추적

이제 처음의 질문으로 돌아가자. "이 주문이 왜 이 상태인가?" 단 하나의 correlation_id로 전체 흐름을 추적할 수 있다.

// correlation_id로 전체 이벤트 추적 (의사쿼리)
SELECT created_at, actor_platform, event_type, new_status
FROM event_log
WHERE correlation_id = 'cor_01HX7G5P...'
ORDER BY created_at

// 결과:
// 2026-04-01 09:23:14 | reindeers  | quote.confirmed             | confirmed
// 2026-04-01 09:23:15 | workflow   | workflow.instance.created   | started
// 2026-04-01 09:25:02 | workflow   | workflow.approval.completed | approved
// 2026-04-01 09:25:03 | reindeers  | order.confirmed             | confirmed
// 2026-04-01 09:25:04 | dvrp       | delivery.request.created    | pending
// 2026-04-01 09:25:04 | document   | invoice.generation.started  | generating
// 2026-04-01 09:25:05 | pop        | production.plan.updated     | scheduled
// 2026-04-01 09:25:08 | document   | invoice.generated           | completed
// 2026-04-01 09:30:00 | dvrp       | delivery.assigned           | assigned
// 2026-04-01 14:15:00 | dvrp       | delivery.departed           | in_transit
// 2026-04-03 11:20:00 | dvrp       | delivery.completed          | delivered
// 2026-04-03 11:20:01 | document   | delivery_note.generated     | completed

하나의 쿼리로, 견적 확인부터 배송 완료까지 5개 플랫폼에 걸친 전체 이력이 시간순으로 나타난다. 이전에는 이런 정보를 얻으려면 3~4개 데이터베이스를 직접 뒤지고, 각각의 로그 파일을 검색하고, 타임스탬프를 수동으로 맞춰봐야 했다. 지금은 쿼리 하나면 된다.

이벤트 소비자(Consumer)의 설계 원칙

이벤트를 발행하는 것보다 소비하는 것이 더 어렵다. 우리 팀이 겪으면서 확립한 소비자 설계 원칙을 정리한다.

멱등성(Idempotency) 보장

같은 이벤트가 두 번 도착해도 결과가 동일해야 한다. 네트워크 재전송, 브로커 재시작, 컨슈머 크래시 후 복구 등의 상황에서 이벤트 중복은 언제든 발생할 수 있다.

// 소비자 템플릿 (의사코드)
processEvent(event):
    if eventAlreadyProcessed(event.event_id):
        log.info("duplicate skipped", event)
        ack(event)
        return

    within single transaction:
        handleBusinessLogic(event)
        markAsProcessed(event.event_id)

    ack(event)

순서 보장과 그 한계

같은 엔티티에 대한 이벤트는 순서가 보장되어야 한다. order.confirmedorder.created보다 먼저 도착하면 안 된다. 우리는 entity_id를 큐의 파티션 키로 사용해서, 같은 엔티티의 이벤트가 같은 파티션에 들어가도록 한다. 같은 파티션 내에서는 FIFO가 보장된다.

다만 서로 다른 엔티티 간의 순서는 보장하지 않는다. order.confirmeddelivery.created 중 어느 것이 먼저 도착할지는 알 수 없다. 이벤트 소비자는 이에 대비해야 한다. 예를 들어 Document AI가 인보이스를 생성하려면 주문 정보가 필요한데, order.confirmed 이벤트가 아직 도착하지 않았을 수 있다. 이 경우 해당 이벤트를 잠시 보류하고, 나중에 재처리하는 로직이 필요하다.

Dead Letter Queue와 수동 복구

처리에 실패한 이벤트는 Dead Letter Queue(DLQ)로 이동한다. DLQ에 쌓인 이벤트는 Telegram 알림으로 운영팀에 통지되고, 운영 대시보드에서 원인을 파악한 후 재처리하거나 수동으로 보정한다.

DLQ 이벤트가 발생하는 주요 원인은 세 가지다. 첫째, 소비자 코드의 버그. 둘째, 의존하는 외부 서비스의 장애. 셋째, 잘못된 이벤트 데이터(발행 측 버그). 원인에 따라 대응이 다르기 때문에, DLQ 이벤트에는 실패 원인, 재시도 횟수, 마지막 에러 메시지가 함께 기록된다.

AI 학습 데이터로서의 Event Log

Event+State+Log 모델을 설계할 때 AI 학습은 부차적인 목표였다. 주된 목적은 크로스 플랫폼 디버깅과 데이터 정합성이었다. 그런데 모든 이벤트가 통일된 스키마로 Log에 쌓이기 시작하면서, 이 데이터가 AI에 얼마나 유용한지 체감하게 됐다.

예를 들어, 주문 이벤트 로그를 분석하면 다음을 알 수 있다.

  • 특정 공급사의 견적에서 주문까지 전환율
  • 평균 배송 소요 시간 (루트별, 운송사별)
  • 문서 생성에서 승인까지 걸리는 시간 (바이어별, 문서 유형별)
  • 생산 계획 대비 실제 완료 시간의 편차
  • 가격 이상 패턴 (같은 제품의 가격이 갑자기 변동한 경우)

이 모든 분석이 하나의 event_log 테이블에서 나온다. 플랫폼별로 별도의 ETL 파이프라인을 만들 필요가 없다. event_type으로 필터링하고 payload의 구조화된 필드를 추출하면 된다.

// AI 학습 데이터 추출 개념 (의사쿼리)
// 공급사별 견적→주문 전환율, 평균 전환 소요시간

for each correlation_id over last 6 months:
    confirmed_at = earliest(quote.confirmed)
    ordered_at   = earliest(order.confirmed)
    supplier_id  = payload.supplier_id

group by supplier_id:
    total_quotes      = count(*)
    converted_orders  = count(ordered_at != null)
    conversion_rate   = converted_orders / total_quotes
    avg_hours         = avg(ordered_at - confirmed_at)

order by conversion_rate desc

이런 분석 결과가 Document AI의 견적서 자동 생성에 활용된다. 전환율이 높은 공급사의 견적서 패턴을 학습해서, 새로운 견적서를 생성할 때 참고한다. Workflow AI는 평균 승인 소요 시간 데이터를 기반으로 SLA 위반 예상 건을 사전 알림한다.

AI Agent가 같은 로그를 읽는다

같은 event_log를 사람 개발자만 보는 게 아니다. REINDEERS의 조직도 안에 등록된 AI Agent들도 이 로그를 읽는다. 조직도에서 직원을 등록할 때 '사람', 'AI Agent', '로봇' 중 선택할 수 있는 구조이기 때문에, Agent는 사람 직원과 동일한 권한 모델 위에서 데이터를 조회한다. 구매 Agent는 자기 담당 공급사의 quote.* 이벤트를 구독하고, 물류 Agent는 delivery.* 이벤트를 구독한다. CEO Agent는 와일드카드로 전체 이벤트의 집계 지표를 실시간으로 본다.

여기서 Event+State+Log 모델이 AI 전환에 왜 결정적인지가 드러난다. Agent가 "지난 3개월 동안 공급사 X가 제시한 가격의 분포"를 알고 싶다면, 범용 LLM처럼 그럴듯한 숫자를 지어내는 게 아니라 event_log에서 실제 quote.confirmed 이벤트를 조회하고 payload의 금액 필드를 집계한다. 모든 판단의 근거가 불변 로그 안에 있다. 이 점이 범용 AI 비서를 보조로 쓰는 것과, 조직도 안에 직원으로 등록된 Agent가 업무를 실행하는 것의 결정적 차이다. 근거 없는 결정은 에스컬레이션되고, 근거 있는 결정만 자동으로 실행된다.

AI Agent의 진화 4단계(Tool → Assistant → Agent Team → Autonomous Operator)로 보면, 지금 REINDEERS는 Tool에서 Assistant 단계로 넘어가는 중이다. Event+State+Log 위에서 Agent가 "어떤 결정을 왜 내렸는지"를 correlation_id로 추적할 수 있게 되면서, 사람의 예외 승인 범위가 조금씩 좁아지고 있다. Agent Team 단계(자율 실행, 예외만 사람)로 가는 길목에서 가장 먼저 필요한 것이 바로 이런 통합 로그다. 만약 데이터가 각 플랫폼에 흩어져 있었다면, Agent는 어떤 근거도 설명할 수 없었을 것이고, 그 상태에서 자율 실행을 허용하는 것은 불가능하다.

이벤트 재생(Replay)으로 과거 상태 복원

Log가 append-only이고 Event가 불변이라는 특성은 이벤트 소싱(Event Sourcing)의 핵심 이점을 제공한다. 특정 시점의 State를 재구성할 수 있다는 것이다.

물론 우리 시스템이 순수한 Event Sourcing은 아니다. State 테이블이 별도로 존재하고, 최신 상태를 직접 읽는다. 순수 Event Sourcing은 모든 조회에서 이벤트를 처음부터 재생해야 하기 때문에, 읽기 성능에 문제가 있다. 우리는 실용적인 중간 지점을 택했다. 평소에는 State에서 직접 읽고, 과거 상태가 필요하거나 디버깅이 필요할 때만 Log를 재생한다.

// 특정 시점의 주문 상태 재구성 (의사쿼리)
// "2026-04-02 15:00 시점에 이 주문의 상태가 뭐였지?"

SELECT event_type, new_status, full_snapshot, created_at
FROM event_log
WHERE entity_id = 'ord_01HX9M4P...'
  AND entity_type = 'order'
  AND created_at <= '2026-04-02 15:00:00+09'
ORDER BY created_at DESC
LIMIT 1

이 기능은 고객 분쟁 해결에 특히 유용하다. "3일 전에 주문 상태가 분명 '배송중'이었는데 지금은 '취소됨'으로 바뀌어 있다"는 클레임이 들어오면, Log에서 해당 시점의 정확한 상태와 누가 무엇을 변경했는지를 즉시 확인할 수 있다.

트레이드오프: 솔직하게

이 구조가 모든 문제를 해결해주지는 않는다. 우리가 실제로 겪고 있는 트레이드오프를 정리한다.

Eventual Consistency

메시지 큐를 통한 이벤트 전파는 비동기다. REINDEERS에서 주문이 확인된 시점과 DVRP에서 배송 요청이 생성되는 시점 사이에 수 초에서 수 분의 지연이 발생할 수 있다. 대부분의 B2B 거래에서 이 수준의 지연은 문제가 되지 않지만, 사용자가 "방금 주문을 확인했는데 배송 화면에 안 보여요"라고 문의하는 상황은 실제로 발생한다.

우리의 대응은 두 가지다. 첫째, UI에서 이벤트 전파 상태를 표시한다. "배송 정보가 동기화 중입니다"라는 상태를 보여준다. 둘째, 중요한 상태 조회는 correlation_id로 event_log를 직접 확인해서 최신 이벤트 기준으로 상태를 보정한다.

스토리지 비용

모든 이벤트를 Log에 영구 보관하면 스토리지가 빠르게 증가한다. 현재 5개 플랫폼에서 하루 평균 수천 건의 이벤트가 발생한다. 각 이벤트의 payload는 수백 바이트에서 수 킬로바이트다. 월 단위로 기가바이트 수준이고, 서비스가 성장하면 테라바이트까지 갈 수 있다.

대응 방법은 파티셔닝과 계층화다. 최근 3개월 데이터는 핫 스토리지(SSD)에, 이전 데이터는 콜드 스토리지(HDD 또는 오브젝트 스토리지)에 보관한다. AI 학습에 필요한 데이터는 별도 데이터 웨어하우스로 추출한다. 원본 로그는 법적 보존 기간(보통 5~7년)까지 보관하되, 압축해서 저장한다.

팀 규율

솔직히 이게 가장 어려운 트레이드오프다. 5개 플랫폼의 모든 개발자가 동일한 이벤트 스키마 규칙을 따라야 한다. 새로운 이벤트 타입을 추가할 때 네이밍 규칙을 따라야 하고, payload에 반드시 포함해야 하는 필드(correlation_id, tenant_id 등)를 빠뜨리면 안 된다.

우리는 이벤트 스키마 레지스트리를 운영한다. 새로운 이벤트 타입을 추가하려면 스키마를 먼저 등록하고, CI에서 스키마 검증을 통과해야 한다. 스키마에 맞지 않는 이벤트는 발행 자체가 거부된다.

# 이벤트 스키마 정의 (개념 예시)
event_type: order.confirmed
version: "2026-03"
description: "주문이 확정되었을 때 발행"

required_envelope_fields:
  - event_id          # 자동 생성
  - event_type
  - entity_type       # 'order'
  - entity_id
  - tenant_id
  - correlation_id    # 비즈니스 트랜잭션 추적
  - timestamp         # ISO 8601, UTC
  - actor             # 실행자 정보

payload:
  required:
    - previous_status
    - new_status       # must be 'confirmed'
    - order_amount     # >= 0
    - currency         # [THB, KRW, CNY, MYR, USD]
    - buyer_id
    - supplier_id
  optional:
    - items_count
    - delivery_terms

스키마 레지스트리와 CI 검증이 기술적으로는 잘 동작한다. 하지만 결국 사람의 문제다. 바쁜 일정에 쫓기면 "일단 이벤트부터 발행하고 스키마는 나중에 등록하자"는 유혹이 생긴다. 이걸 막는 것은 기술이 아니라 팀 문화다. 우리 팀에서는 이벤트 스키마 리뷰를 코드 리뷰와 동일한 수준으로 다룬다.

왜 Event Sourcing 전체를 채택하지 않았는가

여기까지 읽으면 "그냥 Event Sourcing 전체를 도입하면 되지 않느냐"는 의문이 들 수 있다. 우리도 처음에 그렇게 생각했다. 그리고 시도했다가 철회했다.

순수 Event Sourcing에서는 State 테이블이 없다. 현재 상태는 항상 이벤트를 처음부터 재생해서 계산한다. 이 방식은 이론적으로 우아하지만, 실무에서 심각한 문제를 만든다.

첫째, 읽기 성능. 주문 목록 화면에서 100개 주문의 현재 상태를 보여주려면, 각 주문의 이벤트를 모두 재생해야 한다. 주문 하나에 이벤트가 평균 15개라면, 1,500개 이벤트를 재생해야 한다. Snapshot을 사용하면 완화되지만, snapshot 관리 자체가 또 다른 복잡성을 만든다.

둘째, 쿼리 복잡성. "현재 상태가 'shipped'인 모든 주문을 찾아라"는 단순한 조건이 Event Sourcing에서는 매우 비효율적인 연산이 된다.

셋째, 팀 학습 비용. 우리 팀은 5개 플랫폼을 동시에 개발하는 소규모 팀이다. 전체 팀이 Event Sourcing의 패턴(aggregate, projection, snapshot, upcasting 등)을 완벽하게 이해하고 올바르게 적용하려면 상당한 시간이 필요하다.

그래서 우리는 실용적인 하이브리드를 택했다. State 테이블은 유지하되, 모든 상태 변경에 반드시 Event와 Log를 동반시킨다. 이벤트 재생은 디버깅이나 특수 상황에서만 사용한다. 순수 Event Sourcing의 이론적 우아함보다, 5개 플랫폼의 모든 팀이 일관되게 적용할 수 있는 실용성을 선택한 것이다.

모니터링과 운영

이벤트 기반 시스템의 운영에서 가장 중요한 것은 "지금 정상인가"를 빠르게 판단할 수 있는 메트릭이다.

우리가 모니터링하는 핵심 지표는 다음과 같다.

지표 정상 범위 알림 조건
Outbox lag (미발행 이벤트 수) 0~10 50 이상 5분 지속
Consumer lag (미처리 이벤트 수) 0~100 500 이상 10분 지속
DLQ 이벤트 수 0 1건 이상 발생 시 즉시
이벤트 전파 지연 (p95) < 3초 10초 초과
State-Event 불일치 (정합성 체크) 0 1건 이상

State-Event 불일치 체크는 매 시간 배치로 실행된다. State 테이블의 현재 상태와 event_log의 마지막 이벤트의 상태를 비교해서, 불일치가 있으면 알림을 보낸다. 이 불일치가 발생했다는 것은 Transactional Outbox 패턴 어딘가에 버그가 있다는 의미이기 때문에, 최우선으로 조사한다.

// 정합성 검증 개념 (의사쿼리)
// 각 엔티티의 최신 이벤트 상태와 현재 State가 일치하는가?

latest_events = for each entity (order):
    pick the most recent event_log row in last 24h
    whose event_type ends with .confirmed / .cancelled / .completed

report rows where latest_event.new_status != order_state.status

1년 뒤, 달라진 것

Event+State+Log 모델을 도입하고 약 1년이 지났다. 처음 이야기했던 "왜 이 상태가 이렇게 됐는지 모르겠다"는 질문은 거의 사라졌다. correlation_id 하나로 전체 흐름을 추적할 수 있기 때문이다.

가장 큰 변화는 크로스 플랫폼 디버깅의 속도다. 이전에는 문제 원인을 찾는 데 30분에서 1시간이 걸렸다. 여러 DB에 접속하고, 로그 파일을 뒤지고, 타임스탬프를 맞추고. 지금은 event_log 쿼리 한 번이면 5분 안에 원인을 특정한다.

두 번째 변화는 새로운 플랫폼 연동의 용이성이다. POP를 나중에 추가했지만, 기존 4개 플랫폼의 코드를 수정할 필요가 없었다. POP가 자신의 큐를 만들고, 필요한 이벤트를 구독하고, 자신의 이벤트를 발행하면 됐다. 이벤트 스키마만 따르면 통합이 자동으로 이루어진다.

세 번째 변화는 AI 학습 파이프라인의 단순화다. 5개 플랫폼의 데이터를 하나의 ETL로 처리할 수 있게 되면서, AI 모델 학습 주기가 짧아졌다. 이전에는 각 플랫폼의 데이터를 별도로 추출하고 정규화하는 데만 며칠이 걸렸다.

물론 완벽하지는 않다. Eventual consistency로 인한 사용자 혼란은 여전히 가끔 발생하고, 이벤트 스키마 관리는 팀이 성장하면서 점점 더 엄격한 거버넌스가 필요해지고 있다. 스토리지 비용도 예상보다 빠르게 증가하고 있어서, 데이터 보존 정책을 더 세밀하게 다듬어야 한다.

그럼에도, 5개 플랫폼의 데이터를 하나의 모델로 통일한 것은 REINDEERS 아키텍처에서 가장 임팩트가 큰 결정 중 하나였다. "왜 이렇게 됐는가"를 추적할 수 있다는 것, 그리고 그 추적이 플랫폼 경계를 넘어서도 끊기지 않는다는 것. 그것이 이 구조의 핵심 가치다. 그리고 이 구조가 없었다면, 조직도 안에 등록된 AI Agent가 사람 대신 업무를 실행하는 다음 단계는 시작도 하지 못했을 것이다.

관련 글

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 단계(사...