사용량 과금 Part 1: 중복 수집부터 재시작 가능한 마감과 원장까지

API 호출 수에 단가를 곱하면 이번 달 청구액을 계산할 수 있습니다. 식만 보면 간단합니다.
하지만 운영 환경에서는 같은 사용량 이벤트가 여러 번 도착하고, 월중에 가격이 바뀌며, 마감 작업은 처리 도중 종료될 수 있습니다. 마감 뒤에 늦게 도착한 사용량도 사라져서는 안 됩니다. 이 상황에서 단순 합계 테이블을 계속 수정하면 “어떤 사용량에 어떤 가격을 적용했고, 재실행이 금액을 중복 반영하지 않았는가?”라는 질문에 답하기 어렵습니다.
이 글은 bluetape4k-workshop의 사용량 계량·과금 원장 예제를
바탕으로 중복 없는 수집, 발생 시점 가격, 재시작 가능한 마감, 불변 원장과 보정을 하나의 흐름으로 설명합니다.
과금 정확성의 경계를 먼저 정한다
섹션 제목: “과금 정확성의 경계를 먼저 정한다”대표 시나리오는 테넌트별 API 호출량을 월 단위로 청구하는 SaaS입니다. 수집기는 호출 10건이라는 사용량을 받고, 가격표는 특정 시점부터 단가를 바꾸며, 월말 마감은 대상 사용량을 원장에 기록한 뒤 청구서를 확정합니다.
이 예제가 다루는 범위는 다음과 같습니다.
- 같은 HTTP 요청과 같은 생산자 이벤트를 서로 다른 기준으로 중복 방지합니다.
- 사용량이 발생한 시점의 가격을 선택합니다.
- 고정된 마감 기준 시각과 Keyset Checkpoint로 중단된 작업을 재개합니다.
- 확정된 원장과 청구서를 수정하지 않고 별도 조정 항목으로 보정합니다.
세금, 결제 수단, 수납, 환율, 할인 조합, 구간·누진 요금제는 다루지 않습니다. 범용 과금 플랫폼을 제공하려는 예제도 아닙니다. 먼저 보존해야 할 정확성 경계를 실행 가능한 코드와 PostgreSQL 제약으로 고정하는 것이 목표입니다.
세 개의 시간이 서로 다른 질문에 답한다
섹션 제목: “세 개의 시간이 서로 다른 질문에 답한다”사용량 과금에서는 하나의 timestamp만으로 부족합니다.
| 시간 | 누가 정하는가 | 답하는 질문 |
|---|---|---|
occurredAt | 생산자가 보고하고 서비스가 허용 범위를 검증 | 사용량이 언제 발생했고 어느 가격 Version을 적용할 것인가? |
receivedAt | 수집 서비스의 Clock | 마감 Cutoff 전에 서버가 이 사용량을 받았는가? |
| Posting Period | 원장이 선택 | 이 금액을 어느 회계 기간에 반영할 것인가? |
예를 들어 6월 30일에 발생한 사용량이 7월 2일에 도착할 수 있습니다. 가격은 6월의 occurredAt으로 찾지만,
6월 마감 Cutoff 이후에 도착했다면 이미 확정된 6월 원장을 다시 열지 않습니다. 대신 현재 열려 있는 Posting
Period에 조정 항목으로 기록합니다. 시간마다 책임이 다르므로 하나를 다른 용도로 대신 쓰지 않습니다.
HTTP 재시도와 생산자 중복은 같은 문제가 아니다
섹션 제목: “HTTP 재시도와 생산자 중복은 같은 문제가 아니다”클라이언트가 응답을 받지 못해 같은 요청을 다시 보내는 경우와, 상류 시스템이 같은 사용량 이벤트를 다른 요청으로 다시 전송하는 경우는 식별 기준이 다릅니다. 예제는 두 경계를 겹쳐 사용합니다.
receipt = commandReceipts.acquire(tenant, operation, keyDigest, fingerprint)
when (receipt) { is Replay -> return receipt.savedResponse is Conflict -> throw IdempotencyConflict() is InProgress -> throw RetryLater(receipt.retryAfter) is Acquired -> { usage = usageEvents.appendIfAbsent(sourceSystem, sourceEventId, payloadDigest) commandReceipts.complete(receipt.ownerToken, responseFor(usage)) return responseFor(usage) }}| 경계 | 식별자 | 방지하는 문제 | 충돌의 의미 |
|---|---|---|---|
| HTTP Command Receipt | 테넌트·작업·Idempotency-Key Digest | 같은 명령의 재실행 | 같은 Key에 다른 요청 Fingerprint |
| 생산자 이벤트 고유 제약 | 테넌트·Source System·Source Event ID | 다른 HTTP 요청으로 온 같은 사용량의 중복 저장 | 같은 Source Event ID에 다른 Payload Digest |
Command Receipt는 완료 응답을 저장했다가 재생하고, 처리 중 소유자가 사라지면 Lease 만료 뒤 새 소유자 Token이 인계합니다. 생산자 이벤트는 데이터베이스의 고유 제약 조건으로 한 번만 저장됩니다. 두 장치를 함께 사용해야 HTTP 전달 방식과 업무 이벤트 정체성을 각각 보존할 수 있습니다.
가격은 발생 시점의 반개구간에서 찾는다
섹션 제목: “가격은 발생 시점의 반개구간에서 찾는다”가격 Version은 [effectiveFrom, effectiveTo) 반개구간으로 관리합니다.
v1: [2026-01-01T00:00Z, 2026-03-01T00:00Z) USD 0.10v2: [2026-03-01T00:00Z, ∞) USD 0.122026-03-01T00:00Z에 발생한 사용량은 v1의 끝과 v2의 시작이 겹치지 않으므로 정확히 v2를 선택합니다. 가격
선택은 occurredAt을 사용하고, 마감 대상 포함 여부는 receivedAt <= cutoffReceivedAt으로 판단합니다.
“마감할 때의 현재 가격”을 사용하면 과거 사용량의 금액이 재실행 시점에 따라 달라질 수 있습니다.

마감은 한 번에 끝내는 작업이 아니라 재개 가능한 과정이다
섹션 제목: “마감은 한 번에 끝내는 작업이 아니라 재개 가능한 과정이다”마감을 시작할 때 Billing Period는 OPEN에서 CLOSING으로 바뀌고 cutoffReceivedAt이 한 번 고정됩니다.
작업자는 기본 200건씩 (occurredAt, usageEventId) Keyset 뒤를 읽습니다.
transaction { batch = usageEvents.findAfter( checkpoint = run.checkpoint, cutoff = run.cutoffReceivedAt, limit = 200, )
for (usage in batch) { price = prices.findAt(usage.occurredAt) ?: markUnpriced(usage) ledger.appendChargeOnce(usage, price) }
closeRuns.advanceCheckpoint(run.id, batch.lastKey)}appendChargeOnce()와 Checkpoint 갱신은 같은 트랜잭션에서 커밋됩니다. 커밋 전에 프로세스가 종료되면 원장과
Checkpoint가 모두 되돌아갑니다. 커밋 직후 응답을 남기지 못하고 종료되면 다음 실행이 같은 구간을 다시 볼 수
있지만, 원장의 Source 고유 키가 금액 중복을 막습니다. 즉, 작업의 재실행은 허용하되 금액의 중복 반영은
허용하지 않습니다.
읽을 대상이 더 없고 가격을 찾지 못한 사용량도 0건일 때만 Close Run이 READY_TO_FINALIZE로 전이합니다.
가격 구간이 비어 있으면 FAILED_VALIDATION에 멈춰 누락 사실을 남깁니다. 운영자가 가격 구간을 명시적으로
복구한 뒤 같은 고정 Cutoff로 다시 시작하므로, 오류를 숨긴 채 일부 금액만 청구하지 않습니다.
청구서 확정은 원장과 출처 연결을 함께 봉인한다
섹션 제목: “청구서 확정은 원장과 출처 연결을 함께 봉인한다”청구서를 만들 때는 Posting Period의 원장 항목을 Meter, 가격 Version, Entry Type별 Line으로 묶고 각 항목이 어느 Line에 포함됐는지 Provenance를 저장합니다. 확정 트랜잭션은 다음 등식을 검사합니다.
sum(invoice lines) == invoice total == sum(linked ledger entries)검사가 끝나면 Invoice, Line, Provenance, Billing Period와 Close Run 상태를 함께 확정합니다. 완료된 금액을
제자리에서 수정하는 범용 save나 delete 대신 추가 전용 Repository 계약을 사용하므로, 청구 결과와 그
근거를 함께 추적할 수 있습니다.
늦은 사용량은 확정 기간을 다시 열지 않는다
섹션 제목: “늦은 사용량은 확정 기간을 다시 열지 않는다”마감 Cutoff 뒤에 도착한 사용량도 occurredAt에 해당하는 가격으로 계산합니다. 다만 이미 FINALIZED인
Service Period를 수정하지 않고, 서버의 현재 시각을 포함하는 유일한 OPEN Posting Period에 양수 금액의
DEBIT_ADJUSTMENT를 추가합니다.
환불이나 감액도 음수 금액으로 저장하지 않습니다. 금액은 양수로 두고 방향을 CREDIT_ADJUSTMENT로
표현하며, relatedOriginalEntryId로 원본 원장 항목을 연결합니다. 금액 부호와 업무 의미를 여러 계층에서
다르게 해석하지 않도록 Entry Type을 명시적으로 사용한 것입니다.
장애 복구는 남아 있는 기준 데이터에서 시작한다
섹션 제목: “장애 복구는 남아 있는 기준 데이터에서 시작한다”장애가 발생했을 때 모든 과정을 처음부터 다시 실행하는 방식은 안전하지 않습니다. 이 예제는 완료된 Receipt, 수집된 사용량, 원장 항목, 마감 Cutoff와 Checkpoint를 기준 데이터로 남기고, 실패 지점에 따라 재개 범위를 결정합니다.
| 실패 경계 | 탐지 신호 | 자동 보호 | 운영자의 복구 | 복구 완료 조건 |
|---|---|---|---|---|
| 응답 유실 또는 처리 중 소유자 중단 | Receipt가 IN_PROGRESS이고 Lease 만료 | 새 Owner Token만 takeover 허용 | 같은 Idempotency-Key와 Fingerprint로 재시도 | 저장된 응답이 재생되고 업무 결과가 한 건만 존재 |
| 같은 생산자 Event ID에 다른 내용 도착 | Payload Digest 충돌 | 고유 제약과 Digest 비교로 저장 거부 | 상류 시스템의 ID 재사용 또는 Payload 변경 조사 | 원본 Event ID와 Payload의 대응 관계 확인 |
| 발생 시점의 가격 구간 누락 | Close Run이 FAILED_VALIDATION, unpriced count 증가 | 일부 청구서 확정 차단 | 누락된 가격 구간을 명시적으로 추가하고 같은 Cutoff로 재개 | unpriced count가 0이고 Run이 READY_TO_FINALIZE |
| Batch 커밋 전후 Worker 중단 | Checkpoint 정체 또는 Lease 만료 | 트랜잭션 Rollback과 원장 원본 고유 키 | 마지막 Checkpoint 이후부터 다시 실행 | Checkpoint가 마지막 대상까지 도달하고 원장 중복이 없음 |
| 확정 이후 금액 오류 발견 | Reconciliation Finding 또는 고객 이의 | 확정 원장·청구서 수정 차단 | 원본과 연결된 DEBIT_ADJUSTMENT 또는 CREDIT_ADJUSTMENT 추가 | 원장·청구서·Provenance 합계가 다시 일치하고 Finding 종료 |
복구 순서는 다음과 같이 고정합니다.
- 실패한 테넌트, 기간, 명령 또는 Close Run을 식별합니다.
- Receipt, 사용량 Event, 원장, Cutoff와 Checkpoint 중 어디까지 커밋됐는지 확인합니다.
- 남아 있는 기준 데이터를 수정하지 않고 허용된 takeover, 재시작 또는 Adjustment를 선택합니다.
- 중복 방지 제약, 가격 Version, Checkpoint와 금액 합계를 다시 검증합니다.
- 미해결 Reconciliation Finding이 없을 때만 복구 완료를 선언합니다.
Receipt 상태를 임의로 완료 처리하거나, Checkpoint를 앞으로 이동하거나, 확정된 원장 항목을 수정·삭제해서는 안 됩니다. 이러한 조치는 처리 재개처럼 보이지만, 중복과 누락을 판별할 근거를 제거합니다.
언제 이 구조를 선택할 것인가
섹션 제목: “언제 이 구조를 선택할 것인가”| 조건 | 이 구조가 주는 이점 | 추가로 필요한 결정 |
|---|---|---|
| 요청·이벤트가 중복 전달될 수 있음 | 재생 응답과 업무 이벤트의 고유 제약을 분리 | Receipt 보존 기간과 Lease 정책 |
| 시간에 따라 가격이 바뀜 | 발생 시점 가격을 재현 | 할인·누진·통화 변환 규칙 |
| 마감이 한 트랜잭션에 끝나지 않음 | 고정 Cutoff와 Checkpoint로 재개 | 운영 SLA와 재시도·경보 정책 |
| 확정 결과를 감사해야 함 | 원장·청구서·Provenance를 함께 보존 | 세금·수납·회계 시스템 연계 |
이 접근은 모든 과금 문제를 해결하지 않습니다. 다만 단순 합계에서 출발한 서비스가 중복, 시간, 재시작과 확정 후 보정이라는 운영 조건을 만났을 때 어떤 불변식을 먼저 세워야 하는지 보여 주는 기준선이 됩니다.
시리즈에서 이어서 볼 내용
섹션 제목: “시리즈에서 이어서 볼 내용”- 사용량 과금 Part 1: 중복 수집부터 재시작 가능한 마감과 원장까지
- 사용량 과금 Part 2: Event Sourcing, Replay와 Projection 운영
- 사용량 과금 Part 3: 마이크로서비스, Outbox와 Inbox
- 사용량 과금 Part 4: 장애 탐지, 격리, 재처리와 정합성 검증
- 통합 대화형 시각 자료
Part 2에서는 현재 상태만 저장하는 원장형 모델에서 Domain Event, Aggregate Replay, Snapshot과 Projection 재구축으로 이동할 때 얻는 이점과 늘어나는 운영 비용을 살펴봅니다.
구현 코드와 자료 살펴보기
섹션 제목: “구현 코드와 자료 살펴보기”- 사용량 계량·과금 원장 예제 README: 전체 상태 모델과 실행·검증 방법을 설명합니다.
BillingCloseService.kt: 고정 Cutoff, Keyset Batch, 원장 추가와 Checkpoint 갱신을 구현합니다.InvoiceService.kt: 청구서 Line과 Provenance를 만들고 합계 불변식을 검증합니다.MeteringEndToEndIntegrationTest.kt: 중복 수집, 마감, 청구서 확정, 늦은 사용량과 가격 누락 복구를 하나의 수명주기로 검증합니다.- 사용량 과금 구조의 발전 단계 시각 자료: 원장형, Event Sourcing, 마이크로서비스 구조를 같은 시나리오로 비교합니다.
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.