콘텐츠로 이동

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

과금 이벤트의 장애를 탐지하고 격리한 뒤 세 복구 경로가 정합성 검증 관문으로 합류하는 어두운 3D 작업대
복구는 실패 작업을 다시 실행하는 것으로 끝나지 않습니다. 기준 데이터를 보존하고 금액과 진행 위치를 대조한 뒤에야 트래픽 재개를 승인할 수 있습니다.

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 StoreStream Version과 Hash Chain이 연속적임Version Gap, Hash 불일치, Upcast 실패
ProjectionACTIVE 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를 찾습니다. 이 값은 복구가 어디서 시작되어 어디까지 도달해야 하는지 정하는 기준선이 됩니다.

재시도 횟수만으로 분류해서는 안 됩니다. 동일한 예외도 남아 있는 기준 데이터에 따라 조치가 달라집니다.

분류대표 사례남아 있는 기준 데이터허용된 복구
일시적 장애DB 연결, Broker 경로, Worker 중단Receipt, Outbox, Inbox, Checkpoint, Lease같은 작업 인계, 같은 Envelope 재전달, 저장 지점부터 재개
영구 계약 오류지원하지 않는 Schema, 필수 필드 누락, 같은 ID·다른 Digest불변 원본 Event, 실패 사유, Quarantine, Redrive Audit건강한 흐름과 격리, 계약 수정, 동일 원본 Redrive
정합성 오류Projection 합계 차이, 손상된 Snapshot, 잘못 확정된 금액Event Store, 원장 연결, 이전 Generation, Reconciliation FindingReplay, Rebuild, append-only Adjustment

일시적 장애를 격리만 하면 운영자 개입이 불필요하게 늘어납니다. 영구 계약 오류를 무한 재시도하면 정상 Event까지 막힙니다. 정합성 오류를 단순 재전달로 해결하려 하면 이미 생성된 잘못된 금액이 남습니다. 따라서 분류 결과를 복구 명령의 입력으로 남겨야 합니다.

장애 탐지와 영향 범위 식별 뒤 세 실패 유형으로 분기하고 정합성 검증과 재개 승인으로 합류하는 수직 복구 절차
실패 유형마다 복구 수단은 다르지만 마지막 Gate는 같습니다. 기준 위치, 격리 상태, 금액 합계와 현재 소유권을 모두 검증해야 합니다.

대화형 시각 자료에서 세 과금 구조 비교하기

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와 보정을 구분한다”
수단사용하는 조건변경하는 대상보존해야 할 것
ReplayEvent 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를 통과해도 즉시 전체 트래픽을 열 필요는 없습니다.

  1. 대상 Tenant 또는 Partition의 소비를 제한된 속도로 재개합니다.
  2. 새로운 FAILED, DEFERRED, Quarantine과 Reconciliation Finding이 증가하지 않는지 확인합니다.
  3. Cursor 진행률과 Lag이 정상 범위로 수렴하는지 확인합니다.
  4. 금액 대사를 한 번 더 실행하고 복구 전후 Evidence를 Incident에 첨부합니다.
  5. 범위를 단계적으로 확대하고 최종 재개 시점을 기록합니다.

이 절차는 단순한 “서비스 재시작”보다 느려 보일 수 있습니다. 그러나 과금에서는 빠른 재개보다 잘못된 금액을 조용히 확산하지 않는 것이 중요합니다.

복구 과정에서 해서는 안 되는 조치

섹션 제목: “복구 과정에서 해서는 안 되는 조치”
금지 조치당장은 해결된 것처럼 보이는 이유장기 위험
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, 서비스 간 대사

“재처리할 수 있다”는 이유만으로 복구 가능한 시스템이 되지는 않습니다. 재처리 입력이 불변인지, 중복 효과를 막는지, 진행 위치를 되돌릴 수 있는지, 결과를 독립적으로 대조할 수 있는지가 함께 설계되어야 합니다.

네 편의 공통 원칙은 간단합니다. 과금의 원본 사실을 변경하지 않고, 각 처리 단계가 남긴 기준 데이터에서 작업을 재개하며, 독립적인 대사 결과로만 완료를 선언합니다. 구현 방식은 달라도 이 원칙을 지키면 장애가 발생했을 때 추측 대신 증거로 복구할 수 있습니다.

댓글

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