사용량 과금 Part 4: 장애 탐지, 격리, 재처리와 정합성 검증

Part 1은 단일 PostgreSQL 원장에서 중복 수집과 재시작 가능한 마감을 다뤘습니다. Part 2는 Event Store에서 Projection을 다시 만드는 방법을, Part 3는 다섯 개 서비스가 Outbox와 Inbox로 부분 실패를 다루는 방법을 설명했습니다.
세 설계의 저장 구조는 다르지만 운영자가 답해야 할 질문은 같습니다. 어디까지 처리되었는가, 무엇이 기준 데이터로 남아 있는가, 어떤 재처리 방식이 안전한가, 그리고 무엇을 확인해야 정상화되었다고 말할 수 있는가입니다.
이 글은 앞선 세 예제에서 검증한 실패 경로를 하나의 운영 Runbook으로 묶습니다. 특정 모니터링 제품이나 자동 복구 프레임워크를 제안하지는 않습니다. 대신 장애를 탐지 → 범위 식별 → 분류 → 격리·복구 → 정합성 검증 → 재개 승인으로 다루는 기본 구조를 설명합니다.
먼저 “정상”을 처리량이 아니라 불변식으로 정의한다
섹션 제목: “먼저 “정상”을 처리량이 아니라 불변식으로 정의한다”요청 성공률과 Kafka Lag이 정상이어도 과금 결과가 정상이라는 보장은 없습니다. 과금 시스템의 정상 상태는 업무 불변식으로 정의해야 합니다.
| 확인 대상 | 정상 조건 | 이상 신호 |
|---|---|---|
| 수집 | 동일한 생산자 Event는 한 번만 금액에 반영됨 | 같은 ID·다른 Digest, Receipt 장기 점유 |
| 가격 | 발생 시점에 유효한 가격 근거가 존재함 | 가격 구간 누락·중첩, 승인되지 않은 Version 참조 |
| 마감 | Cursor 이전 Fact가 한 번씩 집계됨 | 실패 Batch, 오래된 Lease, Cursor 정체 |
| Event Store | Stream Version과 Hash Chain이 연속적임 | Version Gap, Hash 불일치, Upcast 실패 |
| Projection | ACTIVE Generation이 검증된 기준 위치까지 도달함 | FAILED Generation, Lag 증가, 합계 불일치 |
| 서비스 간 전달 | Outbox Event가 Inbox 판정을 거쳐 한 번만 반영됨 | 장기 RETRY_WAIT, DEFERRED, Quarantine 증가 |
| 재무 결과 | Usage, Charge, Invoice, Projection 합계가 원본 Event 연결로 설명됨 | 출처 없는 금액, 기존 Line 수정, 대사 차이 |
알람은 이 불변식을 관찰할 수 있게 구성합니다. 예를 들어 “오류 로그 발생”보다 oldestRetryAge,
projectionLag, quarantineCount, reconciliationDifference와 같은 신호가 복구 판단에 더 직접적입니다.
1. 장애 신호와 영향 범위를 함께 기록한다
섹션 제목: “1. 장애 신호와 영향 범위를 함께 기록한다”첫 알람만 보고 전체 재처리를 시작하면 정상 Tenant와 기간까지 건드립니다. 복구 단위를 먼저 고정해야 합니다.
RecoveryScope = { incidentId, detectedAt, design: LEDGER | EVENT_SOURCED | MICROSERVICES, tenantIds, billingPeriods, producerEventIds, aggregateKeys, streamVersionRange, projectionGeneration, topicPartitionsAndOffsets, observedHighWatermark}범위를 모를 때는 넓게 수정하지 말고 넓게 관찰합니다. 원본 Event ID에서 Usage Fact, Charge, Invoice Line과 Projection Row로 이어지는 연결을 조회하고, 마지막으로 검증된 Cursor·Stream Version·Checkpoint를 찾습니다. 이 값은 복구가 어디서 시작되어 어디까지 도달해야 하는지 정하는 기준선이 됩니다.
2. 실패를 세 종류로 분류한다
섹션 제목: “2. 실패를 세 종류로 분류한다”재시도 횟수만으로 분류해서는 안 됩니다. 동일한 예외도 남아 있는 기준 데이터에 따라 조치가 달라집니다.
| 분류 | 대표 사례 | 남아 있는 기준 데이터 | 허용된 복구 |
|---|---|---|---|
| 일시적 장애 | DB 연결, Broker 경로, Worker 중단 | Receipt, Outbox, Inbox, Checkpoint, Lease | 같은 작업 인계, 같은 Envelope 재전달, 저장 지점부터 재개 |
| 영구 계약 오류 | 지원하지 않는 Schema, 필수 필드 누락, 같은 ID·다른 Digest | 불변 원본 Event, 실패 사유, Quarantine, Redrive Audit | 건강한 흐름과 격리, 계약 수정, 동일 원본 Redrive |
| 정합성 오류 | Projection 합계 차이, 손상된 Snapshot, 잘못 확정된 금액 | Event Store, 원장 연결, 이전 Generation, Reconciliation Finding | Replay, Rebuild, append-only Adjustment |
일시적 장애를 격리만 하면 운영자 개입이 불필요하게 늘어납니다. 영구 계약 오류를 무한 재시도하면 정상 Event까지 막힙니다. 정합성 오류를 단순 재전달로 해결하려 하면 이미 생성된 잘못된 금액이 남습니다. 따라서 분류 결과를 복구 명령의 입력으로 남겨야 합니다.

3. 건강한 흐름을 유지한 채 실패 범위를 격리한다
섹션 제목: “3. 건강한 흐름을 유지한 채 실패 범위를 격리한다”격리는 실패 데이터를 지우는 작업이 아닙니다. 정상 흐름과 섞이지 않게 분리하면서 조사와 재처리에 필요한 증거를 보존하는 작업입니다.
- 실패 Batch는
FAILED와 마지막 Cursor를 남기고 다음 소유자가 인계할 수 있게 합니다. - 손상되거나 Upcast할 수 없는 Event는 Projection Generation을
FAILED로 두고 기존 ACTIVE Generation을 유지합니다. - Poison Event는 Tenant, Event ID, Digest, Schema Version과 실패 사유를 Quarantine에 기록합니다.
- 순서가 뒤바뀐 Event는
DEFERRED로 두고 기대 Version이 도착하기 전까지 업무 효과를 만들지 않습니다. - Reconciliation 차이는 Finding으로 남기고 자동 보정과 운영자 승인을 구분합니다.
격리 기록에는 원본을 복사해 임의로 수정한 Payload보다 불변 원본을 가리키는 식별자와 Digest가 필요합니다. 그래야 계약 수정 후 같은 원본을 Redrive했다는 사실을 증명할 수 있습니다.
4. 남아 있는 기준 데이터에 따라 복구 수단을 선택한다
섹션 제목: “4. 남아 있는 기준 데이터에 따라 복구 수단을 선택한다”일시적 장애: 새 작업을 만들지 않고 기존 작업을 인계한다
섹션 제목: “일시적 장애: 새 작업을 만들지 않고 기존 작업을 인계한다”val work = repository.claimExpiredOrPending(scope, ownerToken)
when (work.state) { PENDING, RETRY_WAIT, CLAIMED_WITH_EXPIRED_LEASE -> { executeFrom(work.checkpoint) repository.complete(work.id, ownerToken) } COMPLETED -> return work.savedResult else -> keepIsolated(work)}Owner Token과 Fencing Token은 늦게 돌아온 이전 Worker가 현재 작업을 완료 처리하지 못하게 합니다. Broker 전송은 새 Event를 만들지 않고 기존 Outbox Envelope를 다시 보냅니다. HTTP 응답 유실은 같은 Idempotency Key와 Fingerprint로 저장 결과를 재사용합니다.
영구 계약 오류: 계약을 수정한 뒤 불변 원본을 Redrive한다
섹션 제목: “영구 계약 오류: 계약을 수정한 뒤 불변 원본을 Redrive한다”1. Quarantine 사유와 원본 Digest를 확인한다.2. Decoder, Upcaster 또는 생산자 계약을 수정하고 회귀 테스트를 통과시킨다.3. 외부 Retained Source에서 동일한 원본 Envelope를 읽는다.4. 운영자, 사유, Incident ID를 포함한 Redrive Audit을 기록한다.5. Inbox가 기존 Quarantine과 새 판정을 연결하도록 다시 전달한다.6. 같은 오류가 반복되지 않고 정상 Event의 진행 위치도 유지되는지 확인한다.Quarantine Row의 Payload를 직접 수정해 다시 처리하면 원본과 복구 결과의 연결이 끊깁니다. 새 Event ID를 임의로 발급하는 방식도 기존 중복 판정과 감사 이력을 우회하므로 피해야 합니다.
정합성 오류: Replay, Rebuild와 보정을 구분한다
섹션 제목: “정합성 오류: Replay, Rebuild와 보정을 구분한다”| 수단 | 사용하는 조건 | 변경하는 대상 | 보존해야 할 것 |
|---|---|---|---|
| Replay | Event Store가 유효하고 Aggregate 상태만 다시 계산하면 됨 | 메모리 상태 또는 새 Snapshot | 원본 Event 순서와 Hash |
| Projection Rebuild | 조회 모델이 손상되었거나 Handler가 바뀜 | 새 Projection Generation | 기존 ACTIVE Generation, Checkpoint, 실패 Generation |
| Append-only Adjustment | 이미 확정된 Charge·Invoice 금액을 바로잡아야 함 | 새 Adjustment와 Correction Line | 기존 금액, 원본 Event 연결, 보정 사유 |
Event Store Hash가 깨졌다면 Replay를 계속하지 않습니다. 먼저 이력을 복구하거나 신뢰 가능한 백업 범위를 확정해야 합니다. 반대로 Event Store가 정상인데 Projection만 다르면 원본 Event를 수정할 이유가 없습니다. 새 Generation을 만들고 검증한 뒤 원자적으로 ACTIVE를 전환합니다.
5. 정합성 검증은 복구 절차의 마지막 작업이 아니라 승인 조건이다
섹션 제목: “5. 정합성 검증은 복구 절차의 마지막 작업이 아니라 승인 조건이다”복구 명령이 성공했다는 응답은 “명령이 실행되었다”는 뜻일 뿐입니다. 재개 승인은 서로 다른 저장 경계가 같은 업무 사실을 설명하는지 대조한 뒤 내려야 합니다.
val evidence = RecoveryEvidence( sourceHighWatermark = source.highWatermark(scope), processedCursor = consumers.minimumCursor(scope), unresolvedQuarantine = quarantine.countOpen(scope), ledgerAmount = ledger.sum(scope), projectionAmount = projection.sum(scope), invoiceAmount = invoices.sum(scope), currentFencingToken = leases.currentToken(scope), observedLag = consumers.maxLag(scope),)
require(evidence.processedCursor >= evidence.sourceHighWatermark)require(evidence.unresolvedQuarantine == 0L)require(evidence.ledgerAmount == evidence.projectionAmount)require(evidence.ledgerAmount == evidence.invoiceAmount)require(evidence.currentFencingToken == recoveryCommand.fencingToken)require(evidence.observedLag <= recoverySlo.maxLag)대사는 가능한 한 원본 Event ID와 가격 Version 단위로 수행합니다. 총액만 같으면 서로 다른 오류가 상쇄될 수
있습니다. Usage Fact → Charge → Invoice Line → Projection 연결을 먼저 확인하고, 그 결과를 기간 총액으로
집계해야 합니다.
6. 한 번에 정상화하지 않고 단계적으로 재개한다
섹션 제목: “6. 한 번에 정상화하지 않고 단계적으로 재개한다”복구 Gate를 통과해도 즉시 전체 트래픽을 열 필요는 없습니다.
- 대상 Tenant 또는 Partition의 소비를 제한된 속도로 재개합니다.
- 새로운
FAILED,DEFERRED, Quarantine과 Reconciliation Finding이 증가하지 않는지 확인합니다. - Cursor 진행률과 Lag이 정상 범위로 수렴하는지 확인합니다.
- 금액 대사를 한 번 더 실행하고 복구 전후 Evidence를 Incident에 첨부합니다.
- 범위를 단계적으로 확대하고 최종 재개 시점을 기록합니다.
이 절차는 단순한 “서비스 재시작”보다 느려 보일 수 있습니다. 그러나 과금에서는 빠른 재개보다 잘못된 금액을 조용히 확산하지 않는 것이 중요합니다.
복구 과정에서 해서는 안 되는 조치
섹션 제목: “복구 과정에서 해서는 안 되는 조치”| 금지 조치 | 당장은 해결된 것처럼 보이는 이유 | 장기 위험 |
|---|---|---|
| Outbox·Quarantine Row 삭제 | Backlog와 오류 수가 감소함 | 전달 누락과 원인 증거 소실 |
| Checkpoint·Cursor 강제 전진 | Lag이 즉시 0에 가까워짐 | 처리하지 않은 Event를 영구 건너뜀 |
실패 작업을 수동 COMPLETED 처리 | Worker가 다음 작업으로 이동함 | 실제 결과와 상태가 불일치 |
| 기존 Charge·Invoice Line 수정 | 현재 총액이 맞아 보임 | 당시 판단과 보정 이력을 설명할 수 없음 |
| Snapshot 검증 생략 | Replay 시간이 단축됨 | 손상되거나 오래된 상태를 기준으로 채택 |
| 새 Event ID로 Poison Event 재발행 | Inbox 중복 판정을 피함 | 원본과 재처리 결과의 감사 연결 단절 |
운영 도구도 이 금지 규칙을 우회할 수 없게 해야 합니다. 조회와 복구 명령을 분리하고, 복구 명령에는 Incident ID, 운영자, 사유, 대상 범위와 Fencing Token을 요구하는 편이 안전합니다.
세 설계 중 무엇을 선택하더라도 복구 비용은 사라지지 않는다
섹션 제목: “세 설계 중 무엇을 선택하더라도 복구 비용은 사라지지 않는다”| 설계 | 복구가 단순한 영역 | 추가로 운영할 영역 |
|---|---|---|
| 단일 PostgreSQL 원장 | 하나의 트랜잭션과 명확한 Cursor | 장기 Batch, Receipt 인계, 기간별 대사 |
| Event Sourcing | 불변 Event에서 상태와 Projection 재구축 | Snapshot 검증, Upcaster, Generation 전환, Replay 비용 |
| 마이크로서비스 | 서비스별 독립 배포와 장애 격리 | Outbox·Inbox, Lag, Schema 계약, Quarantine, 서비스 간 대사 |
“재처리할 수 있다”는 이유만으로 복구 가능한 시스템이 되지는 않습니다. 재처리 입력이 불변인지, 중복 효과를 막는지, 진행 위치를 되돌릴 수 있는지, 결과를 독립적으로 대조할 수 있는지가 함께 설계되어야 합니다.
시리즈 마무리
섹션 제목: “시리즈 마무리”- 사용량 과금 Part 1: 중복 수집부터 재시작 가능한 마감과 원장까지
- 사용량 과금 Part 2: Event Sourcing, Replay와 Projection 운영
- 사용량 과금 Part 3: 마이크로서비스, Outbox와 Inbox
- 사용량 과금 Part 4: 장애 탐지, 격리, 재처리와 정합성 검증
- 통합 대화형 시각 자료
네 편의 공통 원칙은 간단합니다. 과금의 원본 사실을 변경하지 않고, 각 처리 단계가 남긴 기준 데이터에서 작업을 재개하며, 독립적인 대사 결과로만 완료를 선언합니다. 구현 방식은 달라도 이 원칙을 지키면 장애가 발생했을 때 추측 대신 증거로 복구할 수 있습니다.
구현 코드와 자료 살펴보기
섹션 제목: “구현 코드와 자료 살펴보기”- 원장형 사용량 과금 예제: Receipt, 가격 Version, 마감 Cursor와 append-only 보정 구조를 설명합니다.
- Event Sourcing 사용량 과금 예제: Event Hash, Snapshot, Projection Generation과 Replay 복구 경계를 설명합니다.
- 마이크로서비스 통합 예제: 다섯 개 서비스의 Outbox·Inbox, Poison Event, 재시작과 Broker 경로 복구를 설명합니다.
ReconciliationPostgresIntegrationTest.kt: 원장과 파생 결과의 차이를 Finding으로 남기는 경계를 검증합니다.ProjectionRecoveryPostgresIntegrationTest.kt: 실패 Generation을 노출하지 않고 검증된 Generation만 ACTIVE로 전환하는지 검증합니다.RestartIntegrationTest.kt: 프로세스 재시작 뒤 PostgreSQL의 Inbox·Outbox와 Checkpoint에서 처리를 재개하는지 검증합니다.PoisonIntegrationTest.kt: 영구 계약 오류를 격리하면서 정상 Event 처리를 유지하고 Redrive Audit을 남기는지 검증합니다.
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.