사용량 과금 Part 3: 마이크로서비스, Outbox와 Inbox

Part 1은 하나의 PostgreSQL에서 멱등성, 발생 시점 가격, 재시작 가능한 마감과 불변 원장을 다뤘습니다. Part 2는 Event Store를 기준 데이터로 삼고 Replay와 Projection 재구축을 운영하는 방법을 설명했습니다.
이제 가격, 사용량, 금액 산정, 청구서와 조회를 독립적으로 배포해야 한다고 가정하겠습니다. 데이터베이스를 다섯 개로 나누는 것만으로는 충분하지 않습니다. 이전에는 한 트랜잭션 안에서 함께 커밋하던 업무 결과와 다음 단계 전달을 더 이상 원자적으로 처리할 수 없기 때문입니다.
이 글은 bluetape4k-workshop의 사용량 과금 마이크로서비스 예제를
바탕으로 서비스별 소유권, Outbox·Inbox, at-least-once 전달, 순서 역전과 Poison Event 복구를 설명합니다.
목표는 end-to-end exactly-once를 주장하는 것이 아니라, 중복과 부분 실패를 식별하고 복구할 수 있는 기준
데이터를 각 서비스에 남기는 것입니다.
분리 기준은 기능 수가 아니라 데이터 소유권이다
섹션 제목: “분리 기준은 기능 수가 아니라 데이터 소유권이다”예제는 과금 흐름을 다섯 개 서비스로 나눕니다. 각 서비스는 자신의 PostgreSQL과 Integration Event Decoder를 소유하며 다른 서비스의 테이블을 직접 읽지 않습니다.
먼저 전체 배치 구조를 살펴보겠습니다. 다섯 개 서비스는 Java 25 런타임에서 Spring Boot 4 애플리케이션으로 각각 실행되고, Exposed Repository로 자신이 소유한 PostgreSQL만 변경합니다. Kafka는 서비스가 공개한 Event를 다음 서비스로 전달하지만 재무 기준 데이터를 소유하지 않습니다. 따라서 메시지 전달이 중단돼도 각 서비스는 로컬 데이터베이스에 남은 Inbox, Outbox와 업무 결과를 기준으로 처리 상태를 판정할 수 있습니다.

| 서비스 | 소유하는 기준 데이터 | 받는 입력 | 내보내는 사실 |
|---|---|---|---|
| Meter | 불변 가격 Version과 Command Receipt | ActivatePriceCommand | PriceActivated |
| Usage | 사용량 Fact와 승인된 가격 근거 | PriceActivated, AcceptUsageCommand | UsageAccepted |
| Billing | 복제 가격 근거와 불변 Charge | PriceActivated, UsageAccepted | ChargeRated, AdjustmentPosted, BillingPeriodClosed |
| Invoice | 불변 Invoice Line과 원본 Event 연결 | ChargeRated, AdjustmentPosted | InvoiceIssued, InvoiceCorrectionIssued |
| Query | Inbox, Projection, Checkpoint, Quarantine, Redrive Audit | 네 서비스의 공개 Event | 테넌트 요약과 운영자 복구 정보 |
Meter가 가격의 원본을 소유하지만 Billing은 산정할 때 Meter 데이터베이스를 동기적으로 조회하지 않습니다.
PriceActivated를 받아 자신의 PostgreSQL에 가격 근거를 저장한 뒤 그 근거로만 금액을 계산합니다. 이 선택은
일시적인 네트워크 장애가 전체 과금 트랜잭션을 묶지 않게 하지만, 복제 근거의 지연과 누락을 관찰할 책임을
추가합니다.
서비스 내부의 Inbox·Outbox 상태와 장애 복구 절차는 대화형 시각 자료에서 단계별로 확인할 수 있습니다.
업무 결과와 Outbox는 한 트랜잭션으로 커밋한다
섹션 제목: “업무 결과와 Outbox는 한 트랜잭션으로 커밋한다”서비스가 로컬 업무 결과를 저장한 다음 Kafka 전송에 실패하면 “업무는 성공했지만 다음 서비스는 모르는” 상태가 됩니다. 반대로 Kafka를 먼저 전송한 다음 로컬 트랜잭션이 Rollback되면 존재하지 않는 업무 결과가 외부로 전파됩니다.
Outbox는 로컬 업무 결과와 발행할 Event를 같은 PostgreSQL 트랜잭션에 기록해 이 간격을 닫습니다.
transaction { receipt = commandReceipts.acquire(command.idempotencyKey, command.fingerprint) if (receipt is Replay) return receipt.savedResult
priceVersion = prices.append(command) envelope = PriceActivated.from(priceVersion) outbox.append(envelope, state = PENDING) commandReceipts.complete(receipt.ownerToken, resultFor(priceVersion))}Publisher는 PENDING Row를 Lease와 Owner Token으로 Claim하고 Kafka에 보낸 뒤 같은 Token으로만
PUBLISHED를 기록합니다. 전송이 실패하면 RETRY_WAIT로 이동합니다. Kafka가 Event를 받은 직후 프로세스가
종료되면 로컬 상태는 아직 CLAIMED일 수 있으므로 Lease 만료 뒤 같은 Event를 다시 보냅니다. 중복 전달은
오류가 아니라 이 설계의 정상 복구 경로입니다.
Inbox는 중복과 충돌을 서로 다르게 판정한다
섹션 제목: “Inbox는 중복과 충돌을 서로 다르게 판정한다”Consumer는 업무 테이블을 수정하기 전에 (tenantId, eventId, payloadDigest)로 Inbox를 판정합니다.
transaction { decision = inbox.decide( tenantId = envelope.tenantId, eventId = envelope.eventId, payloadDigest = envelope.payloadDigest, aggregateVersion = envelope.aggregateVersion, )
when (decision) { APPLIED -> { localEffect.apply(envelope) outbox.append(nextEvent(envelope)) } DUPLICATE -> Unit DEFERRED -> scheduleAfterMissingVersion(envelope) QUARANTINED -> recordPermanentFailure(envelope) }}같은 Event ID와 같은 Digest가 다시 오면 DUPLICATE입니다. 이전 결과를 성공으로 반환하거나 Offset을 진행해도
금액은 한 번만 반영됩니다. 같은 ID에 다른 Digest가 오면 전혀 다른 상황입니다. 생산자가 같은 정체성을 다른
내용에 재사용했거나 전달 중 Payload가 바뀌었으므로 QUARANTINED로 격리해야 합니다.
| Inbox 결과 | 의미 | 후속 처리 |
|---|---|---|
APPLIED | 처음 본 유효 Event이며 필요한 선행 근거가 있음 | 로컬 효과와 다음 Outbox Event를 함께 커밋 |
DUPLICATE | 같은 ID·같은 Digest 또는 이미 반영한 이전 Version | 새 금액을 만들지 않고 정상 종료 |
DEFERRED | 가격 근거나 이전 Aggregate Version이 아직 없음 | 누락된 선행 Event 이후 재시도 |
QUARANTINED | 같은 ID·다른 Digest 또는 영구 계약 오류 | 건강한 흐름과 분리하고 운영자 조사 대상으로 기록 |
순서가 뒤바뀌면 추측하지 않고 기다린다
섹션 제목: “순서가 뒤바뀌면 추측하지 않고 기다린다”Kafka Partition은 같은 Key의 순서를 보장할 수 있지만, 재발행·Topic 변경·운영 실수까지 모든 경로의 순서를
대신 증명하지는 않습니다. Billing 예제는 Aggregate Version 2가 Version 1보다 먼저 오면 현재 상태를 추측해서
산정하지 않고 DEFERRED로 남깁니다.
누락된 선행 Event가 도착하면 같은 Inbox 기록을 다시 판정해 처리합니다. Version Gap을 건너뛰거나 현재 가격으로
임의 산정하면 이후 Event가 도착해도 어떤 금액이 기준인지 판별하기 어렵습니다. 따라서 aggregateKey, 기대
Version, 수신 Version과 지연 시간을 함께 관찰해야 합니다.
일시적 장애와 영구 계약 오류는 다른 경로로 처리한다
섹션 제목: “일시적 장애와 영구 계약 오류는 다른 경로로 처리한다”모든 예외를 Kafka 재전달에 맡기면 처리할 수 없는 Event 하나가 Partition을 계속 막습니다. 반대로 모든 실패를 격리하면 일시적인 데이터베이스 장애도 운영자의 수동 개입을 기다리게 됩니다.
예제는 실패를 다음과 같이 나눕니다.
| 실패 | 분류 | 자동 보호 | 복구 방식 |
|---|---|---|---|
| Kafka 전송 실패, 일시적 DB 오류 | 일시적 장애 | Outbox를 RETRY_WAIT로 보존하거나 예외를 전파해 재전달 | 같은 Event와 로컬 기준 데이터로 재시도 |
| 서비스 프로세스 재시작 | 일시적 장애 | PostgreSQL의 Inbox·Outbox·가격 근거·Checkpoint 보존 | Lease 만료와 저장 상태에서 처리 재개 |
| Aggregate Version Gap | 순서 대기 | DEFERRED로 금액 산정 차단 | 선행 Version 도착 뒤 재판정 |
| 지원하지 않는 Event Type·Schema, 필수 필드 누락 | 영구 계약 오류 | Event를 Quarantine하고 독립적인 정상 Event는 계속 처리 | Decoder·Upcaster 또는 생산자 계약 수정 후 Redrive |
| 같은 Event ID·다른 Digest | 정합성 충돌 | 업무 효과와 Offset 진행을 분리해 격리 | 원본 Retained Source와 생산자 기록 조사 |
Query 서비스의 Redrive API는 원본 Event를 고치거나 새로 만드는 기능이 아닙니다. 운영자, Correlation ID와 요청 사유를 감사 기록으로 남깁니다. 실제 재발행은 외부 Retained Source에서 불변 원본 Envelope를 읽어 수행해야 합니다. 그래야 “문제를 해결했다”는 이유로 재무 사실을 바꾸는 일을 막을 수 있습니다.
복구 완료는 전달 성공이 아니라 전체 경계의 정합성으로 판정한다
섹션 제목: “복구 완료는 전달 성공이 아니라 전체 경계의 정합성으로 판정한다”Broker 연결이 회복되고 Outbox가 비었다고 해서 과금 복구가 끝난 것은 아닙니다. 다음 순서를 모두 확인해야 합니다.
- 영향받은 Topic, Tenant, Aggregate Key와 Event ID 범위를 식별합니다.
- 각 서비스에서 Outbox backlog, 가장 오래된 Retry, Inbox 결과와 Quarantine 사유를 확인합니다.
- 일시적 장애는 기존 Outbox Row와 같은 Envelope로 재시도하고, 영구 오류는 건강한 Event와 분리합니다.
DEFERREDVersion Gap이 해소되고 모든 서비스의 Checkpoint가 목표 위치에 도달했는지 확인합니다.- Usage Fact, Billing Charge, Invoice Line과 Query 합계를 원본 Event ID 기준으로 대조합니다.
- Quarantine과 Redrive Audit이 종료되고 신규 Lag가 정상 범위일 때만 복구 완료를 선언합니다.
Outbox Row를 삭제해 Backlog를 0으로 만들거나, Inbox 상태를 수동으로 APPLIED로 바꾸거나, 기존 Charge와
Invoice Line을 수정해서는 안 됩니다. 보정이 필요하면 AdjustmentPosted와 새 Invoice Correction Line을
추가해 원본 Event 연결을 보존합니다.
마이크로서비스 분리는 마지막 선택지에 가깝다
섹션 제목: “마이크로서비스 분리는 마지막 선택지에 가깝다”독립 배포, 소유권과 확장 경계가 실제로 필요하지 않다면 Part 1의 단일 PostgreSQL 원장이 더 단순합니다. Event의 과거 상태 재생과 여러 Projection이 핵심이지만 서비스 분리가 필요하지 않다면 Part 2 구조가 적합할 수 있습니다.
다섯 개 서비스 구조를 선택하면 Topic, Lag, Schema 호환성, Outbox·Inbox, Quarantine, Redrive와 서비스별 Reconciliation을 지속해서 운영해야 합니다. 이 비용은 분산 시스템이라는 이름으로 자동 정당화되지 않습니다. 팀과 조직이 데이터 소유권을 실제로 분리하고 각 경계를 독립적으로 배포·복구해야 할 때만 선택해야 합니다.
시리즈에서 이어서 볼 내용
섹션 제목: “시리즈에서 이어서 볼 내용”- 사용량 과금 Part 1: 중복 수집부터 재시작 가능한 마감과 원장까지
- 사용량 과금 Part 2: Event Sourcing, Replay와 Projection 운영
- 사용량 과금 Part 3: 마이크로서비스, Outbox와 Inbox
- 사용량 과금 Part 4: 장애 탐지, 격리, 재처리와 정합성 검증
- 통합 대화형 시각 자료
Part 4에서는 세 구조의 장애 대응을 하나의 운영 Runbook으로 묶습니다. 어떤 장애를 자동 재시도하고, 무엇을 격리하며, Replay·Rebuild·Adjustment 중 무엇을 선택하고, 어떤 증거로 트래픽 재개를 승인할지 설명합니다.
구현 코드와 자료 살펴보기
섹션 제목: “구현 코드와 자료 살펴보기”- 사용량 과금 마이크로서비스 통합 예제 README: 다섯 개 서비스의 소유권, 단계적 추출, 장애 시나리오와 운영 의미를 설명합니다.
- 서비스 경계 다이어그램 원본: 이 글의 전체 MSA 다이어그램이 따르는 서비스·데이터베이스·Kafka 경계를 확인할 수 있습니다.
MeterCommandService.kt: 가격 Version, Command Receipt와 Outbox를 하나의 로컬 트랜잭션으로 기록합니다.BillingInboxService.kt: 중복, Digest 충돌, Version Gap과 로컬 Charge 생성을 판정합니다.QueryRecoveryService.kt: 원본 Event를 수정하지 않는 감사 가능한 Redrive 요청 경계를 보여 줍니다.BrokerPathRecoveryIntegrationTest.kt: Toxiproxy로 단일 Broker TCP 경로를 끊고 같은 Outbox Row가 연결 복구 뒤 전달되는지 검증합니다.PoisonIntegrationTest.kt: 영구 계약 오류의 격리, 정상 Event 처리 지속과 Redrive Audit을 검증합니다.
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.