콘텐츠로 이동

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

사용량 이벤트가 가격 타임라인과 재시작 가능한 마감 작업을 거쳐 원장과 봉인된 청구서로 이어지는 어두운 3D 작업대
사용량 과금의 어려움은 곱셈이 아니라 중복, 시간, 재시작과 확정 이후의 보정에 있습니다.

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.10
v2: [2026-03-01T00:00Z, ∞) USD 0.12

2026-03-01T00:00Z에 발생한 사용량은 v1의 끝과 v2의 시작이 겹치지 않으므로 정확히 v2를 선택합니다. 가격 선택은 occurredAt을 사용하고, 마감 대상 포함 여부는 receivedAt <= cutoffReceivedAt으로 판단합니다. “마감할 때의 현재 가격”을 사용하면 과거 사용량의 금액이 재실행 시점에 따라 달라질 수 있습니다.

사용량 수집, 발생 시점 가격 선택, 재시작 가능한 마감, 원장, 청구서와 조정 항목의 흐름
수집, 가격, 마감, 원장, 청구서가 하나의 PostgreSQL 트랜잭션 경계에서 각기 다른 불변식을 담당합니다.

대화형 시각 자료에서 원장형 과금 흐름 살펴보기

마감은 한 번에 끝내는 작업이 아니라 재개 가능한 과정이다

섹션 제목: “마감은 한 번에 끝내는 작업이 아니라 재개 가능한 과정이다”

마감을 시작할 때 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 상태를 함께 확정합니다. 완료된 금액을 제자리에서 수정하는 범용 savedelete 대신 추가 전용 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 종료

복구 순서는 다음과 같이 고정합니다.

  1. 실패한 테넌트, 기간, 명령 또는 Close Run을 식별합니다.
  2. Receipt, 사용량 Event, 원장, Cutoff와 Checkpoint 중 어디까지 커밋됐는지 확인합니다.
  3. 남아 있는 기준 데이터를 수정하지 않고 허용된 takeover, 재시작 또는 Adjustment를 선택합니다.
  4. 중복 방지 제약, 가격 Version, Checkpoint와 금액 합계를 다시 검증합니다.
  5. 미해결 Reconciliation Finding이 없을 때만 복구 완료를 선언합니다.

Receipt 상태를 임의로 완료 처리하거나, Checkpoint를 앞으로 이동하거나, 확정된 원장 항목을 수정·삭제해서는 안 됩니다. 이러한 조치는 처리 재개처럼 보이지만, 중복과 누락을 판별할 근거를 제거합니다.

조건이 구조가 주는 이점추가로 필요한 결정
요청·이벤트가 중복 전달될 수 있음재생 응답과 업무 이벤트의 고유 제약을 분리Receipt 보존 기간과 Lease 정책
시간에 따라 가격이 바뀜발생 시점 가격을 재현할인·누진·통화 변환 규칙
마감이 한 트랜잭션에 끝나지 않음고정 Cutoff와 Checkpoint로 재개운영 SLA와 재시도·경보 정책
확정 결과를 감사해야 함원장·청구서·Provenance를 함께 보존세금·수납·회계 시스템 연계

이 접근은 모든 과금 문제를 해결하지 않습니다. 다만 단순 합계에서 출발한 서비스가 중복, 시간, 재시작과 확정 후 보정이라는 운영 조건을 만났을 때 어떤 불변식을 먼저 세워야 하는지 보여 주는 기준선이 됩니다.

Part 2에서는 현재 상태만 저장하는 원장형 모델에서 Domain Event, Aggregate Replay, Snapshot과 Projection 재구축으로 이동할 때 얻는 이점과 늘어나는 운영 비용을 살펴봅니다.

댓글

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