Skip to main content

B2B 플랫폼의 결제·정산 구조 , 다국가·다통화 환경에서의 기술적 설계

돈이 움직이는 부분은 언제나 제일 어렵다. 코드를 짤 때도, 시스템을 설계할 때도, 운영을 할 때도. B2B 무역에서는 여기에 "다국가"와 "다통화"가 붙으면서 난이도가 한 단계 더 올라간다.

REINDEERS는 태국, 한국, 중국, 말레이시아를 오가는 거래를 처리한다. 바이어는 태국 바트(THB)로 결제하고, 공급사는 중국 위안(CNY)으로 받고 싶어한다. 중간에 포워더는 한국 원(KRW)으로 물류비를 청구한다. 여기에 미국 달러(USD)가 기준 통화로 들어간다. 한 건의 거래에 통화가 3~4개 관여하는 게 일상이다.

이 글에서는 REINDEERS가 이 다통화 결제와 정산을 어떤 구조로 설계했는지를 기술적으로 풀어본다. 현재 운영 중인 환율 스크래퍼부터, 원장(Ledger) 기반 회계 구조, 이벤트 드리븐 정산 파이프라인까지. 그리고 이 구조가 왜 장기적으로 "재무 Agent"와 "통관 Agent"가 조직도 안에서 자율적으로 일할 수 있는 기반이 되는지도 함께 이야기한다.

왜 단순한 결제 연동으로는 안 되는가

국내 커머스의 결제는 비교적 단순하다. PG사(결제대행사) 하나 붙이고, KRW 단일 통화로 결제받고, 정산일에 수수료 뺀 금액을 판매자에게 보내면 된다. 결제와 정산이 동일 통화이고 동일 법역이다.

국제 B2B는 전혀 다른 세계다. 몇 가지 현실적 문제를 나열해보면 이렇다.

통화 변환 시점의 문제. 바이어가 THB로 결제하는 시점과, 공급사가 CNY로 받는 시점 사이에 시차가 있다. 빠르면 며칠, 느리면 한 달이다. 이 사이에 환율이 움직인다. 누가 이 환율 리스크를 지는가? 견적 시점의 환율을 쓰는가, 결제 시점을 쓰는가, 정산 시점을 쓰는가? 이 하나의 결정이 거래 수익률을 좌우한다.

국가별 PG 인프라의 차이. 태국은 PromptPay와 은행 이체가 주류다. 한국은 계좌이체와 카드 결제가 혼재한다. 중국은 위챗페이, 알리페이, 그리고 전통적 은행 송금이 공존한다. 말레이시아는 FPX(Financial Process Exchange)가 있다. 각 나라의 PG를 별도로 연동해야 하고, 각각의 API 스펙이 다르고, 인증 방식이 다르고, 정산 주기가 다르다.

세금 구조의 차이. 태국은 VAT 7%, 한국은 VAT 10%, 중국은 증치세 13%(일반), 말레이시아는 SST(Sales and Services Tax) 체계다. 거래 유형에 따라 세율이 달라지고, 국제 거래에서의 영세율(zero-rate) 적용 여부도 국가별로 다르다. 인보이스에 세금을 어떻게 기재하느냐가 통관과 세무 신고에 직결된다.

다자간 정산. 한 건의 거래에 관여하는 당사자가 최소 3명이다. 바이어, 공급사, 포워더. 여기에 플랫폼(REINDEERS)의 수수료가 들어간다. 바이어가 낸 금액에서 플랫폼 수수료를 빼고, 공급사 대금과 포워더 대금을 각각 다른 통화로 정산해야 한다. 한 건의 입금에서 세 건의 출금이 나오는 구조다.

환율 관리: 자체 스크래퍼가 핵심이다

다통화 결제 시스템에서 가장 중요한 인프라는 환율 데이터다. 환율이 부정확하면 모든 계산이 틀어진다. 견적이 틀어지고, 정산이 틀어지고, 결국 누군가 손해를 본다.

REINDEERS는 4개국 환율을 자체 스크래핑한다. 외부 환율 API 서비스에 의존하지 않는다. 이유는 두 가지다. 하나는 비용인데, 실시간 환율 API는 호출 횟수에 따라 과금되고 우리 사용량에서는 무시할 수 없는 금액이다. 다른 하나는 통제권이다. 환율 데이터의 소스와 갱신 주기를 우리가 직접 관리해야 정산의 기준점이 명확해진다.

각국 중앙은행 또는 시중은행에서 직접 환율을 가져온다. 태국은 방콕은행(Bangkok Bank) API, 한국은 하나은행(KEB Hana Bank) AJAX API, 중국은 중국은행(Bank of China) 웹 스크래핑, 말레이시아는 메이뱅크(Maybank) 웹 스크래핑이다.

# 통합 환율 핸들러 구조 (실제 운영 코드 기반)

class IntegratedExchangeRateHandler:
    def __init__(self):
        self.scrapers = {
            'thailand': ThailandExchangeRateScraper(),   # Bangkok Bank API
            'korea':    KoreaExchangeRateScraper(),       # Hana Bank AJAX
            'china':    ChinaExchangeRateScraper(),       # Bank of China HTML
            'malaysia': MalaysiaExchangeRateScraper(),    # Maybank HTML
        }

    def get_all_rates(self):
        results = {}
        failed = []

        for country, scraper in self.scrapers.items():
            rate = scraper.get_usd_exchange_rate()
            if rate:
                results[country] = {
                    "base_currency_code": "USD",
                    "target_currency_code": get_currency(country),
                    "reference": format_rate(rate["exchange_rate"]),
                    "source": rate["source"],
                    "source_url": rate["source_url"]
                }
            else:
                failed.append(country)

        return results, failed

각 스크래퍼에는 고유한 난이도가 있다. 방콕은행은 2단계 API 호출이 필요하다. 먼저 GetDateTimeLastUpdate 엔드포인트에서 최신 시퀀스 번호와 날짜를 받아오고, 그 값으로 Getfxrates/{date}/{sequence}/en을 호출한다. 시퀀스 번호가 불규칙적으로 바뀌기 때문에, 실패 시 [5, 6, 7, 8, 4, 3, 9, 10, 2, 1] 순서로 폴백을 시도한다.

하나은행은 AJAX POST 요청에 X-Requested-With: XMLHttpRequest 헤더가 필수다. 이게 없으면 일반 페이지 로딩으로 인식해서 HTML 전체를 내려준다. 중국은행은 100달러 기준으로 환율을 공시하기 때문에 100으로 나누는 변환이 필요하고, Maybank는 iPhone Safari User-Agent에 1~3초 랜덤 딜레이를 넣어야 봇 차단을 피할 수 있다.

# 방콕은행 2단계 환율 조회 (실제 구현)

class ThailandExchangeRateScraper:
    UPDATE_SEQUENCE = [5, 6, 7, 8, 4, 3, 9, 10, 2, 1]

    def get_usd_exchange_rate(self):
        # 1단계: 최신 시퀀스 번호 + 날짜 조회
        update_num, date_str = self.get_latest_update_info()

        if not update_num or not date_str:
            return None

        # 2단계: 해당 시퀀스의 환율 데이터 조회
        url = f"{self.base_url}/Getfxrates/{date_str}/{update_num}/en"
        response = requests.get(url, headers=self.headers, timeout=30)

        if response.status_code == 200:
            return self._parse_usd_rate(response.json())

        # 폴백: 다른 시퀀스 번호로 재시도
        for seq in self.UPDATE_SEQUENCE:
            if seq == update_num:
                continue
            url = f"{self.base_url}/Getfxrates/{date_str}/{seq}/en"
            response = requests.get(url, headers=self.headers, timeout=30)
            if response.status_code == 200:
                return self._parse_usd_rate(response.json())

        return None

# 중국은행 100→1 변환
class ChinaExchangeRateScraper:
    def _convert_100_to_1_dollar(self, value):
        rate = float(value.replace(',', '').strip()) / 100
        return str(round(rate, 4))  # 100 USD 기준 → 1 USD 기준

이 스크래퍼들은 NCP Cloud Functions에 올라가서 매일 정해진 시간에 실행된다. 성공/실패가 개별 국가 단위로 추적되고, 실패 시에만 Telegram으로 알림이 온다. 부분 실패를 허용하는 구조다. 태국 환율 조회가 실패해도 나머지 3개국은 정상적으로 업데이트된다.

환율 잠금(Rate Locking)과 거래 생명주기

환율은 매일 바뀐다. 그런데 B2B 거래는 견적부터 최종 결제까지 보통 2주에서 한 달이 걸린다. 이 기간 동안 환율이 움직이면 누가 차이를 부담하는가? 이 문제를 해결하는 게 환율 잠금이다.

REINDEERS에서 환율은 견적 확정(Quote Confirmed) 시점에 잠긴다. 바이어와 공급사가 가격 협상을 마치고 견적을 승인하면, 그 순간의 환율이 해당 거래의 기준 환율이 된다. 이후 실제 결제와 정산은 모두 이 잠긴 환율로 계산된다.

# 환율 잠금 데이터 구조

{
    "transaction_id": "TXN-2026-05-00142",
    "locked_rates": {
        "THB_USD": {
            "rate": 0.02857,
            "inverse": 34.9983,
            "source": "Bangkok Bank",
            "locked_at": "2026-05-02T09:30:00+07:00",
            "locked_by": "quote_confirmation"
        },
        "CNY_USD": {
            "rate": 0.1389,
            "inverse": 7.1994,
            "source": "Bank of China",
            "locked_at": "2026-05-02T09:30:00+07:00",
            "locked_by": "quote_confirmation"
        }
    },
    "lock_valid_until": "2026-06-01T23:59:59+07:00",
    "lock_status": "active"
}

잠금 기간은 거래 유형에 따라 다르다. 일반 거래는 30일, 장기 계약(Long-term Contract)은 90일이다. 잠금 기간이 만료되면 시스템이 알림을 보내고, 새로운 환율로 재확정하거나 기존 환율을 연장하는 선택을 양측에게 요청한다.

중요한 건 환율 차이에서 발생하는 손익의 처리다. 견적 시점에 1 USD = 35.00 THB였는데, 실제 정산 시점에 1 USD = 35.50 THB라면 바트 기준으로 차이가 발생한다. 이 차이는 플랫폼이 흡수하지 않는다. 잠긴 환율로 계산된 금액이 양측에게 적용되고, 플랫폼과 실제 은행 간의 환율 차이는 별도의 환차 계정(Foreign Exchange Gain/Loss)에서 관리한다.

결제 흐름: 바이어에서 공급사까지

실제 결제 흐름을 하나의 거래로 따라가 보자. 태국 바이어가 중국 공급사에서 베어링을 구매하는 경우다.

바이어는 태국 현지 PG를 통해 THB로 결제한다. 현재 태국에서는 KBank(Kasikorn Bank) API를 통해 은행 이체를 처리한다. KBank API는 mTLS(상호 TLS) 인증을 사용하는데, 클라이언트 인증서로 API를 호출하는 방식이다. OAuth 2.0 토큰을 발급받고, 그 토큰으로 자금 이체 API를 호출한다.

# 결제 흐름 상태 머신

PAYMENT_STATES = {
    "initiated":       "바이어가 결제를 시작함",
    "pg_processing":   "PG사에서 처리 중",
    "pg_completed":    "PG사에서 입금 확인",
    "received":        "플랫폼 계좌에 입금 완료",
    "split_pending":   "분할 정산 대기 중",
    "split_completed": "분할 정산 완료",
    "settled":         "최종 정산 완료",
    "failed":          "결제 실패",
    "refunded":        "환불 처리됨"
}

# 결제 → 분할 정산 흐름
#
# 바이어 결제 (THB 3,500,000)
#   │
#   ├──→ 플랫폼 수수료  10%  (THB 350,000)
#   │
#   ├──→ 공급사 대금   85%  (→ CNY 변환 → ¥685,710)
#   │      환율: 잠긴 환율 THB/CNY 적용
#   │
#   └──→ 포워더 대금    5%  (→ USD 변환 → $5,000)
#          또는 별도 청구

바이어의 결제금이 플랫폼 계좌에 도착하면, 정산 엔진이 분할(split)을 시작한다. 분할 규칙은 거래별로 다르다. 기본적으로 플랫폼 수수료 10%를 공제하고, 나머지를 공급사와 포워더에게 배분한다. 포워더 대금은 물류비 비딩에서 확정된 금액이고, 나머지가 공급사 대금이다.

여기서 통화 변환이 일어난다. 공급사가 CNY로 받기를 원하면, 잠긴 환율로 THB를 CNY로 환산한 금액이 공급사의 정산 금액이 된다. 실제 송금은 플랫폼의 현지 은행 계좌에서 나가므로, 내부적으로는 THB → USD → CNY 또는 THB → CNY 직접 환전이 될 수 있다. 어떤 경로로 환전하든 공급사에게 도달하는 CNY 금액은 잠긴 환율 기준으로 고정된다.

원장(Ledger) 기반 회계 구조

금융 시스템에서 가장 오래되고 가장 신뢰할 수 있는 구조가 복식부기(double-entry bookkeeping)다. 모든 거래는 차변(debit)과 대변(credit)이 반드시 같아야 한다. 한쪽이 늘면 다른 쪽이 줄어든다. 이 원칙이 지켜지면 데이터가 꼬일 수가 없다.

REINDEERS의 모든 금융 이벤트는 원장 엔트리(ledger entry)로 기록된다. 계좌 잔고를 직접 수정하는 게 아니라, 원장 엔트리를 추가하고 잔고는 엔트리의 합으로 계산한다. 잔고 = SUM(debit) - SUM(credit). 이렇게 하면 모든 변동의 이력이 남고, 특정 시점의 잔고를 재현할 수 있다.

# 원장 엔트리 구조

ledger_entry = {
    "entry_id": "LE-2026-05-00142-001",
    "transaction_id": "TXN-2026-05-00142",
    "timestamp": "2026-05-03T14:22:00Z",
    "event_type": "payment.received",
    "entries": [
        {
            "account": "buyer:BUY-TH-0042:payable",
            "debit":  Decimal("3500000.00"),
            "credit": Decimal("0"),
            "currency": "THB",
            "description": "바이어 결제금 수령"
        },
        {
            "account": "platform:escrow:thb",
            "debit":  Decimal("0"),
            "credit": Decimal("3500000.00"),
            "currency": "THB",
            "description": "에스크로 계좌 입금"
        }
    ],
    "metadata": {
        "exchange_rate_locked": {"THB_USD": "0.02857"},
        "po_number": "PO-2026-04-0088",
        "source_pg": "kbank",
        "pg_reference": "KB20260503142200"
    }
}

# 반드시 차변 합계 == 대변 합계
assert sum(e["debit"] for e in entries) == sum(e["credit"] for e in entries)

원장 엔트리에서 몇 가지 설계 결정을 설명해야 한다.

Decimal 타입 사용. 금액은 절대 float로 다루지 않는다. Python의 Decimal이든 DB의 NUMERIC이든, 고정 소수점 타입만 사용한다. 0.1 + 0.2 = 0.30000000000000004 같은 부동소수점 오류가 금액에서 발생하면 정산 불일치로 이어진다. 통화별 소수점 자릿수도 다르다. THB는 소수점 2자리, KRW은 정수, CNY는 소수점 2자리. 이 정밀도를 통화별로 관리한다.

이중 통화 저장. 모든 금액은 원래 통화(original currency)와 기준 통화(base currency, USD) 두 가지로 저장한다. 원래 통화는 실제 거래에 사용된 금액이고, 기준 통화는 통합 보고와 정산 비교에 사용된다. 적용된 환율도 함께 기록한다.

# 이중 통화 금액 구조

class MoneyAmount:
    """다통화 금액 표현 - 원본 통화 + 기준 통화(USD) 항상 쌍으로 저장"""

    def __init__(self, amount, currency, exchange_rate_to_usd):
        self.original_amount   = Decimal(str(amount))
        self.original_currency = currency
        self.exchange_rate     = Decimal(str(exchange_rate_to_usd))
        self.usd_amount        = self._to_usd()

    def _to_usd(self):
        if self.original_currency == "USD":
            return self.original_amount
        return (self.original_amount * self.exchange_rate).quantize(
            Decimal("0.01"), rounding=ROUND_HALF_UP
        )

    def to_dict(self):
        return {
            "original": {
                "amount": str(self.original_amount),
                "currency": self.original_currency
            },
            "base": {
                "amount": str(self.usd_amount),
                "currency": "USD"
            },
            "exchange_rate": str(self.exchange_rate),
            "rate_source": "reindeers_scraper"
        }

# 사용 예시
vendor_payment = MoneyAmount(
    amount=685710,
    currency="CNY",
    exchange_rate_to_usd=0.1389  # 잠긴 환율
)
# → original: ¥685,710.00 / base: $95,265.12

불변 원장. 원장 엔트리는 한번 기록되면 수정하지 않는다. 오류가 발생하면 역분개(reversal entry)를 추가한다. 잘못된 금액이 기록됐으면 그 엔트리를 삭제하는 게 아니라, 동일한 금액을 반대 방향으로 기록하는 새 엔트리를 만들고, 올바른 금액의 엔트리를 다시 추가한다. 이렇게 하면 감사 추적(audit trail)이 완벽하게 유지된다.

이벤트 드리븐 정산 파이프라인

정산은 단발적 작업이 아니라 이벤트 체인이다. 배송이 완료되면 정산이 시작되고, 여러 단계를 거쳐 최종 지급이 이루어진다. 이 흐름을 이벤트 드리븐으로 설계한 이유는 각 단계가 독립적으로 실패할 수 있고, 재시도가 필요하기 때문이다.

# 정산 이벤트 흐름

delivery.completed
    │
    ▼
settlement.initiated          ← 정산 프로세스 시작
    │
    ├──→ 거래 데이터 수집 (PO, DO, Invoice)
    ├──→ 수수료 계산 (commission 10%)
    ├──→ 통화 변환 (잠긴 환율 적용)
    └──→ 분할 금액 산출
    │
    ▼
settlement.pending_approval   ← 정산 내역 검토 대기
    │
    ├──→ 운영팀 검토 (금액 > $10,000인 경우)
    │    또는 자동 승인 (금액 <= $10,000)
    │
    ▼
settlement.approved           ← 정산 승인됨
    │
    ├──→ vendor.payout.initiated    (공급사 지급 시작)
    ├──→ forwarder.payout.initiated (포워더 지급 시작)
    │
    ▼
settlement.completed          ← 모든 지급 완료
    │
    └──→ POP ERP 회계 모듈 동기화

각 이벤트는 LavinMQ(메시지 큐)를 통해 전달된다. REINDEERS의 모든 플랫폼은 Event+State+Log 데이터 모델로 연결되어 있고, 정산 이벤트도 이 구조를 따른다. 이벤트가 발행되면 구독하고 있는 서비스들이 각자의 역할을 수행한다.

예를 들어 settlement.approved 이벤트가 발행되면, 공급사 지급 서비스와 포워더 지급 서비스가 각각 받아서 독립적으로 처리한다. 공급사 지급이 실패해도 포워더 지급은 영향을 받지 않는다. 실패한 쪽만 재시도하면 된다.

# 정산 이벤트 처리

class SettlementEventHandler:
    def handle_delivery_completed(self, event):
        """배송 완료 → 정산 시작"""
        transaction = self.get_transaction(event["transaction_id"])
        locked_rates = transaction["locked_rates"]

        # 정산 내역 산출
        settlement = self._calculate_settlement(transaction, locked_rates)

        # 원장 엔트리 생성 (에스크로 → 각 당사자)
        entries = []

        # 1. 플랫폼 수수료
        commission = transaction["total_amount"] * Decimal("0.10")
        entries.append({
            "account": "platform:escrow:thb",
            "debit": commission,
            "credit": Decimal("0"),
            "currency": "THB"
        })
        entries.append({
            "account": "platform:revenue:commission",
            "debit": Decimal("0"),
            "credit": commission,
            "currency": "THB"
        })

        # 2. 공급사 대금 (THB → CNY 변환)
        vendor_thb = transaction["total_amount"] - commission - forwarder_amount
        vendor_cny = self._convert_currency(
            vendor_thb, "THB", "CNY", locked_rates
        )
        entries.append({
            "account": "platform:escrow:thb",
            "debit": vendor_thb,
            "credit": Decimal("0"),
            "currency": "THB"
        })
        entries.append({
            "account": f"vendor:{transaction['vendor_id']}:receivable",
            "debit": Decimal("0"),
            "credit": vendor_cny,
            "currency": "CNY"
        })

        # 3. 원장 기록 후 이벤트 발행
        self.ledger.record(entries, event_type="settlement.initiated")
        self.mq.publish("settlement.pending_approval", settlement)

    def _convert_currency(self, amount, from_curr, to_curr, locked_rates):
        """잠긴 환율로 통화 변환"""
        if from_curr == to_curr:
            return amount

        # THB → USD → CNY (이중 변환)
        key_from = f"{from_curr}_USD"
        key_to = f"{to_curr}_USD"

        usd_amount = amount * Decimal(str(locked_rates[key_from]["rate"]))
        target_amount = usd_amount / Decimal(str(locked_rates[key_to]["rate"]))

        return target_amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)

정산 주기와 배치 처리

정산은 건별로 즉시 이루어지지 않는다. 실무적으로 불가능하기도 하고, 비효율적이기도 하다. 매번 해외 송금을 하면 수수료가 거래 금액 대비 과도해진다. 그래서 파트너별로 정산 주기를 설정한다.

정산 주기 대상 조건 정산일
주간 소규모 공급사 월 거래 10건 미만 매주 금요일
격주 일반 공급사/포워더 월 거래 10~50건 1일, 15일
월간 대형 공급사 월 거래 50건 이상 익월 5일
즉시 긴급/특수 거래 수동 트리거 요청 후 24시간 내

정산일이 되면 배치 프로세스가 실행된다. 해당 파트너의 미정산 건들을 모아서 하나의 정산 묶음(settlement batch)으로 처리한다.

# 배치 정산 프로세스

class SettlementBatchProcessor:
    def run_batch(self, partner_id, settlement_date):
        """파트너의 미정산 건들을 배치로 처리"""

        # 1. 미정산 건 조회
        pending = self.get_pending_settlements(partner_id, settlement_date)

        if not pending:
            return None

        # 2. 배치 생성
        batch = {
            "batch_id": generate_batch_id(),
            "partner_id": partner_id,
            "settlement_date": settlement_date,
            "line_items": [],
            "total_by_currency": {}
        }

        # 3. 건별 집계
        for settlement in pending:
            line_item = {
                "transaction_id": settlement["transaction_id"],
                "po_number": settlement["po_number"],
                "do_number": settlement["do_number"],
                "amount": settlement["payout_amount"],
                "currency": settlement["payout_currency"],
                "original_amount": settlement["original_amount"],
                "original_currency": settlement["original_currency"],
                "exchange_rate": settlement["locked_rate"],
                "commission_deducted": settlement["commission"]
            }
            batch["line_items"].append(line_item)

            # 통화별 합산
            curr = settlement["payout_currency"]
            if curr not in batch["total_by_currency"]:
                batch["total_by_currency"][curr] = Decimal("0")
            batch["total_by_currency"][curr] += settlement["payout_amount"]

        # 4. 정산 명세서 생성
        batch["statement"] = self._generate_statement(batch)

        return batch

정산 명세서(statement)에는 각 건의 원본 PO 번호, DO 번호, 금액, 적용 환율, 공제된 수수료가 모두 기재된다. 파트너가 받은 금액이 어떤 거래에서 얼마가 왔는지를 추적할 수 있어야 한다. 추적 불가능한 정산은 분쟁의 시작이다.

대사(Reconciliation): 맞지 않는 숫자를 찾아내기

정산 시스템에서 가장 중요하면서 가장 지루한 작업이 대사다. PG에서 입금됐다고 알려준 금액과, 실제 은행 계좌에 들어온 금액이 일치하는지. 공급사에게 보냈다고 기록된 금액이 실제로 출금됐는지. 이 확인 작업이 빠지면 장부만 예쁘고 실제 돈은 엉뚱한 곳에 있는 상황이 생긴다.

# 대사 프로세스

class ReconciliationEngine:
    def reconcile_daily(self, date):
        """일일 대사: PG 기록 vs 원장 vs 은행 명세"""

        # 1. PG 입금 기록
        pg_records = self.fetch_pg_transactions(date)

        # 2. 원장의 payment.received 이벤트
        ledger_records = self.fetch_ledger_entries(
            date, event_type="payment.received"
        )

        # 3. 은행 명세 (bank statement)
        bank_records = self.fetch_bank_statement(date)

        # 4. 3자 매칭
        results = {
            "matched": [],
            "pg_only": [],        # PG에만 있고 원장에 없음
            "ledger_only": [],    # 원장에만 있고 PG에 없음
            "amount_mismatch": [] # 있지만 금액이 다름
        }

        pg_by_ref = {r["reference"]: r for r in pg_records}
        ledger_by_ref = {r["pg_reference"]: r for r in ledger_records}

        for ref, pg in pg_by_ref.items():
            if ref in ledger_by_ref:
                ledger = ledger_by_ref[ref]
                if pg["amount"] == ledger["amount"]:
                    results["matched"].append(ref)
                else:
                    results["amount_mismatch"].append({
                        "reference": ref,
                        "pg_amount": pg["amount"],
                        "ledger_amount": ledger["amount"],
                        "diff": pg["amount"] - ledger["amount"]
                    })
            else:
                results["pg_only"].append(ref)

        for ref in ledger_by_ref:
            if ref not in pg_by_ref:
                results["ledger_only"].append(ref)

        # 불일치 발견 시 알림
        if results["pg_only"] or results["ledger_only"] or results["amount_mismatch"]:
            self.alert_operations_team(results)

        return results

대사는 매일 자동으로 실행된다. 정상적인 날에는 모든 건이 "matched"에 들어가고 아무 일도 일어나지 않는다. 불일치가 발견되면 운영팀에 알림이 가고, 운영팀이 원인을 조사한다. 흔한 원인은 PG 정산 지연(PG에서 D+1에 처리하는 경우), 환율 반올림 차이(1~2원 차이), 그리고 가끔 발생하는 이중 입금이나 부분 입금이다.

분쟁과 조정(Dispute & Adjustment)

B2B 거래에서 분쟁은 일상이다. 배송된 물건의 품질이 기대와 다르거나, 수량이 부족하거나, 배송이 늦어서 바이어가 손해를 봤거나. 이런 경우 전액 정산이 아닌 조정(adjustment)이 필요하다.

정산 조정은 원래 정산 건에 대한 수정이 아니라, 별도의 원장 엔트리로 처리한다. 불변 원장 원칙 때문이다. 원래 정산은 그대로 두고, "정산 조정" 이벤트를 새로 만든다.

# 정산 조정 처리

{
    "adjustment_id": "ADJ-2026-05-00023",
    "original_settlement_id": "STL-2026-05-00142",
    "reason": "partial_quality_issue",
    "description": "베어링 100개 중 15개 규격 불일치, 부분 환불 합의",
    "adjustment_entries": [
        {
            "account": "vendor:VND-CN-0018:receivable",
            "debit":  "14250.00",
            "credit": "0",
            "currency": "CNY",
            "description": "공급사 환불 , 15개 x ¥950"
        },
        {
            "account": "buyer:BUY-TH-0042:payable",
            "debit":  "0",
            "credit": "102600.00",
            "currency": "THB",
            "description": "바이어 환불 , 잠긴 환율 적용"
        }
    ],
    "approved_by": "ops_manager_01",
    "approved_at": "2026-05-15T10:30:00Z"
}

조정의 통화 변환도 원래 거래의 잠긴 환율을 사용한다. 환불 금액이 현재 환율로 계산되면 어느 한쪽이 환율 차이만큼 이득이나 손해를 보게 되기 때문이다. 거래의 모든 금융 처리는 처음부터 끝까지 동일한 환율 기준으로 진행되어야 한다.

세금과 인보이스

다국가 거래에서 세금 처리는 그 자체로 하나의 시스템이다. 간단히 정리하면 이렇다.

국가 세금 유형 세율 특이사항
태국 VAT 7% 수출 시 영세율(0%) 적용 가능
한국 VAT 10% 전자세금계산서 의무 발행
중국 증치세 13% 수출 환급(退税) 제도 별도 존재
말레이시아 SST 6~10% Sales Tax / Service Tax 이원 구조

인보이스 생성은 거래 확정 시점에 자동으로 이루어진다. 각 국가의 세법에 맞는 필수 기재 항목이 다르기 때문에, 국가별 인보이스 템플릿을 별도로 관리한다. 한국은 전자세금계산서 형식을 따라야 하고, 태국은 Tax Invoice에 VAT 등록번호가 반드시 들어가야 한다.

국제 거래에서 까다로운 부분은 원천징수세(Withholding Tax)다. 태국에서 해외 공급사에 대금을 지급할 때 원천징수가 발생할 수 있고, 세율과 적용 여부가 거래 유형과 양국 간 조세조약에 따라 달라진다. 이 계산을 자동화하려면 조세조약 데이터베이스가 필요하고, 현재는 운영팀이 수동으로 확인 후 시스템에 입력하는 반자동 구조다. 완전 자동화는 세무 전문가의 검증을 거친 후 단계적으로 적용할 예정이다.

POP ERP 연계: 정산 데이터가 회계가 되는 순간

REINDEERS 플랫폼에서 발생한 정산 데이터는 POP의 회계 모듈로 자동 동기화된다. POP는 REINDEERS의 다섯 번째 플랫폼으로, MES+ERP+WMS를 통합한 SaaS다.

정산이 완료되면 settlement.completed 이벤트가 발행되고, POP의 회계 모듈이 이 이벤트를 구독한다. 이벤트에 포함된 원장 엔트리를 POP의 계정과목 체계에 매핑하여 회계 전표를 자동 생성한다.

# POP 회계 모듈 연동 흐름

settlement.completed 이벤트 수신
    │
    ├──→ 원장 엔트리 → POP 계정과목 매핑
    │     "platform:revenue:commission"  → 수수료 수익 (4100)
    │     "vendor:*:receivable"          → 매입채무 (2100)
    │     "buyer:*:payable"              → 매출채권 (1200)
    │     "platform:escrow:*"            → 에스크로 자산 (1150)
    │
    ├──→ 회계 전표 자동 생성
    │     차변: 매입채무 ¥685,710
    │     대변: 에스크로 자산(THB) ₩환산액
    │     차변: 수수료 수익 ₩350,000
    │
    └──→ 매출채권/매입채무 원장 업데이트
          바이어별 미수금 현황
          공급사별 미지급금 현황

이 연동의 핵심은 데이터가 한 번만 입력된다는 점이다. 거래가 REINDEERS에서 발생하면 그 데이터가 정산을 거쳐 POP의 회계까지 자동으로 흐른다. 경리 담당자가 같은 숫자를 다시 입력할 필요가 없다. 입력 오류도 없고, 실시간으로 매출/매입이 반영된다.

설계에서 배운 것들

결제와 정산 시스템을 만들면서 몇 가지를 뼈저리게 배웠다.

환율은 하나의 시점에 고정해야 한다. 처음에는 "더 공정하려면 각 단계마다 당일 환율을 써야 하지 않을까"라고 생각했다. 실수였다. 견적 시점과 정산 시점의 환율이 다르면, 양쪽 모두 "내가 손해 보는 거 아니야?"라고 생각한다. 잠긴 환율은 양쪽에게 예측 가능성을 준다. 예측 가능성은 공정성보다 비즈니스에서 더 중요한 가치다.

원장은 append-only가 답이다. 수정 가능한 원장은 결국 문제를 만든다. "이 금액이 원래 얼마였는데 언제 바뀐 거지?" 같은 질문에 답할 수 없게 된다. 역분개가 귀찮아 보여도 감사 추적의 가치가 그 비용을 압도적으로 넘는다.

정밀도(precision)는 비용이 아니라 투자다. 금액 계산에서 반올림 차이 1원은 사소해 보인다. 하지만 이게 한 달에 수천 건씩 쌓이면 대사에서 차이가 난다. 처음부터 Decimal 타입을 쓰고, 통화별 정밀도를 정의하고, 반올림 규칙을 통일하는 데 드는 노력은 나중에 야근을 줄여준다.

부분 실패를 기본 전제로 설계해야 한다. PG가 죽을 수 있다. 환율 스크래퍼가 실패할 수 있다. 은행 API가 타임아웃 날 수 있다. 각 단계가 독립적으로 실패하고, 독립적으로 재시도할 수 있어야 한다. 이벤트 드리븐이 이걸 자연스럽게 해결한다. 실패한 이벤트만 다시 발행하면 된다.

현재 이 구조 위에서 25,000건 이상의 거래가 처리되었다. 완벽하지는 않다. 원천징수세 자동화는 아직 반자동이고, 일부 국가의 PG 연동은 진행 중이다. 하지만 원장 기반 구조와 이벤트 드리븐 정산이라는 뼈대가 잡혀 있으면, 나머지는 붙여나갈 수 있다는 걸 경험으로 확인했다.

재무·통관 Agent가 들어올 자리

마지막으로 이 구조가 장기적으로 어디로 가는지 덧붙인다. REINDEERS·POP·DVRP는 지금 AI로 전환되는 구조 위에 올라서고 있다. 직원을 등록할 때 '사람', 'AI Agent', '로봇' 중에서 선택할 수 있고, 사람은 전략과 방향을 결정하고 실제 업무는 조직도 안의 AI Agent가 수행한다. 결제·정산 도메인에서 이 전환이 먼저 일어나기 좋은 이유가 있다. 원장 기반 구조는 모든 숫자의 근거가 이벤트로 남아 있고, 모든 이벤트는 append-only로 보존된다. AI Agent가 "왜 이 정산 금액이 이렇게 나왔는가"를 설명할 때 근거로 쓸 수 있는 데이터가 설계 단계부터 전부 구조화되어 있다.

우리가 그리는 그림에서 재무 Agent는 매일 자동으로 바이어 입금을 확인하고, 환율 잠금 테이블을 참조해서 공급사·포워더별 분할 정산을 계산하고, 국가별 세금 규칙을 적용한다. 원천징수세가 걸리는 거래처럼 판단이 필요한 건은 바로 실행하지 않고 사람에게 올린다. 통관 Agent는 거래별 세금·관세 정보를 Document AI와 주고받으며 서류를 자동 생성한다. 이 모든 행동은 현재 글에서 설명한 원장 구조 위에서 같은 "원장 엔트리 → 이벤트 → 회계 전표" 파이프라인을 따른다. Agent가 새 규칙을 만드는 게 아니라, 이미 있는 규칙을 사람 대신 집행하는 것이다.

AI Agent 진화 4단계(Tool → Assistant → Agent Team → Autonomous Operator)로 보면, 결제·정산 영역은 현재 Tool에서 Assistant로 넘어가는 지점에 있다. 사람이 결재 버튼을 누르기 직전까지의 계산은 이미 자동이다. 다음 단계는 "월 예산 한도 내에서, 과거 거래 이력과 동일한 패턴이면 자동 승인"이다. 그 다음은 재무 Agent가 예외만 사람에게 올리는 구조다. 이 로드맵의 안전장치는 이 글에서 계속 말한 두 가지다. append-only 원장과 이벤트 기반 감사 추적. 이 두 가지가 없으면 Agent가 돈을 다루는 영역에 들어올 수 없다. 그래서 결제·정산 설계는 AI 전환의 선행 조건이고, 역으로 AI 전환이 이 설계를 가장 크게 활용하게 될 영역이기도 하다.

관련 글

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