콘텐츠로 이동
각자 데이터 저장소를 가진 다섯 개 서비스가 메시지를 전달하고, 중복 메시지는 흡수하며 잘못된 메시지는 별도 공간에 격리하는 어두운 3D 작업대
마이크로서비스로 분리하면 하나의 데이터베이스 트랜잭션이 사라지는 대신, 전달과 중복 처리의 책임이 명시적인 운영 계약이 됩니다.

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와 업무 결과를 기준으로 처리 상태를 판정할 수 있습니다.

각자 PostgreSQL을 소유하는 Meter, Usage, Billing, Invoice, Query 서비스가 Kafka Topic으로 Event를 주고받는 전체 MSA 구조
다섯 개 서비스는 데이터베이스를 공유하지 않습니다. 실선은 로컬 Outbox의 발행, 점선은 로컬 Inbox의 수신 경로이며, 아래 운영 보호선은 각 서비스가 장애 뒤에도 보존해야 할 기록을 보여 줍니다.
서비스소유하는 기준 데이터받는 입력내보내는 사실
Meter불변 가격 Version과 Command ReceiptActivatePriceCommandPriceActivated
Usage사용량 Fact와 승인된 가격 근거PriceActivated, AcceptUsageCommandUsageAccepted
Billing복제 가격 근거와 불변 ChargePriceActivated, UsageAcceptedChargeRated, AdjustmentPosted, BillingPeriodClosed
Invoice불변 Invoice Line과 원본 Event 연결ChargeRated, AdjustmentPostedInvoiceIssued, InvoiceCorrectionIssued
QueryInbox, 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가 비었다고 해서 과금 복구가 끝난 것은 아닙니다. 다음 순서를 모두 확인해야 합니다.

  1. 영향받은 Topic, Tenant, Aggregate Key와 Event ID 범위를 식별합니다.
  2. 각 서비스에서 Outbox backlog, 가장 오래된 Retry, Inbox 결과와 Quarantine 사유를 확인합니다.
  3. 일시적 장애는 기존 Outbox Row와 같은 Envelope로 재시도하고, 영구 오류는 건강한 Event와 분리합니다.
  4. DEFERRED Version Gap이 해소되고 모든 서비스의 Checkpoint가 목표 위치에 도달했는지 확인합니다.
  5. Usage Fact, Billing Charge, Invoice Line과 Query 합계를 원본 Event ID 기준으로 대조합니다.
  6. 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 4에서는 세 구조의 장애 대응을 하나의 운영 Runbook으로 묶습니다. 어떤 장애를 자동 재시도하고, 무엇을 격리하며, Replay·Rebuild·Adjustment 중 무엇을 선택하고, 어떤 증거로 트래픽 재개를 승인할지 설명합니다.

댓글

GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.