Skip to content

[ADR] 단일 accounting writer와 unresolved liability·event 실패 정책 정의 #46

Description

@HuitaePark

배경

현재 회계 쓰기 책임이 여러 객체에 분산되어 있다.

  • Spring AI Advisor가 ledger 기록과 budget 누적을 각각 호출한다.
  • DefaultLedgerManager는 비용 계산 뒤 listener를 즉시 호출한다.
  • listener 예외가 뒤 listener와 사용자 응답까지 실패시킬 수 있다.
  • provider가 사용량을 보고하지 않거나 오류·취소된 경우 reservation을 해제할지 유지할지 계약이 없다.
  • durable outbox가 없는데도 “정확히 한 번 전달”로 표현하면 구현 가능한 보장보다 강한 주장이 된다.

이 상태에서 #36, #37, #39, #40을 병렬 구현하면 서로 다른 순서로 ledger, reservation, event를 쓸 위험이 있다.

결정

단일 회계 쓰기 주체

AccountingService 또는 동등한 단일 orchestration API를 reservation/accounting 상태의 유일한 writer로 둔다.

normalized actual usage
→ request의 pricing snapshot으로 actual cost 계산 1회
→ AccountingService.reconcile(...)
→ 원자적 상태 전이
→ newly transitioned인 경우 domain event 생성
→ listener에 best-effort 전달
  • Advisor는 provider lifecycle adapter이며 ledger와 reservation store를 각각 직접 쓰지 않는다.
  • LedgerManager, reservation store, notification과 metric adapter는 회계 순서를 각자 재구성하지 않는다.
  • 중복 callback은 동일 idempotency key와 fingerprint에서 같은 결과를 반환하고 금액을 다시 반영하지 않는다.

상태 전이

RESERVED → IN_FLIGHT
RESERVED → RELEASED              // provider dispatch 전 취소 또는 명시적 미호출
RESERVED → EXPIRED               // provider dispatch 전 reservation TTL 만료

IN_FLIGHT → COMMITTED            // actual usage 확정
IN_FLIGHT → RELEASED             // provider 미과금이 확인된 경우만
IN_FLIGHT → RECONCILIATION_REQUIRED

RECONCILIATION_REQUIRED → COMMITTED   // late actual usage 도착
RECONCILIATION_REQUIRED → WRITTEN_OFF // 명시적 운영 판단
  • RECONCILIATION_REQUIRED는 terminal 성공/실패가 아니라 미해결 회계 상태다.
  • provider 호출 가능성이 있는 IN_FLIGHT를 timeout만으로 RELEASED 또는 EXPIRED로 바꾸지 않는다.
  • late actual commit은 원자적이고 멱등이어야 한다.

미해결 부채

actual cost를 아직 알 수 없는 요청의 기존 reservation amount를 pendingReconciliationLiability로 유지한다.

effectiveUsage =
    committedCost
  + activeReservedCost
  + pendingReconciliationLiability

새 admission은 이 값을 사용한다. reconciliation 상태로 이동했다는 이유만으로 예산 여유가 되살아나면 안 된다.

통화

  • budget policy, reservation, committed cost, unresolved liability와 snapshot은 모두 currency를 보존한다.
  • 서로 다른 통화의 commit/reconcile은 CURRENCY_MISMATCH로 거부하며 상태를 바꾸지 않는다.
  • 자동 환율 변환은 MVP 범위 밖이다.

Event와 listener 실패 의미

  • 회계 상태 전이를 먼저 확정하고 event를 만든다.
  • 새 상태 전이에 대해서만 event dispatch를 한 번 시도한다.
  • MVP 보장은 “회계 상태 전이 exactly-once + 동일 프로세스 event dispatch at-most-once”다.
  • durable outbox가 없으므로 process crash를 포함한 exactly-once delivery 또는 재전송을 보장하지 않는다.
  • listener마다 독립적으로 예외를 격리하고 나머지 listener를 계속 실행한다.
  • listener 실패와 error handler 실패는 회계 상태를 rollback하거나 provider 응답을 실패시키지 않는다.
  • sanitized LedgerListenerErrorHandler와 bounded metric/log hook을 제공한다.
  • prompt, raw provider response와 API key는 기본 오류 신호에 포함하지 않는다.

구현 소유권

필수 검증

  • 동일 idempotency key의 중복 reconcile이 금액과 event를 중복 생성하지 않는다.
  • actual 미보고 시 liability가 admission 계산에 계속 포함된다.
  • late actual이 RECONCILIATION_REQUIRED → COMMITTED로 한 번만 반영된다.
  • provider 미과금 확인 없이 timeout만으로 liability가 해제되지 않는다.
  • 통화 불일치는 상태 무변경으로 실패한다.
  • 첫 listener 실패 뒤에도 다음 listener가 실행된다.
  • listener와 error handler 실패가 commit 결과와 provider 응답을 뒤집지 않는다.
  • crash-safe exactly-once delivery를 보장하지 않는다는 제한을 문서화한다.

Acceptance criteria

제외 범위

  • durable outbox와 process crash 후 event 재전송
  • Redis/JDBC production store
  • 운영자용 reconciliation UI
  • 자동 환율 변환
  • provider 청구서 대사

Source

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestmvpTokenPilot 0.1.0 MVP scope

    Type

    No type

    Projects

    Status
    Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions