배경
현재 회계 쓰기 책임이 여러 객체에 분산되어 있다.
- 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는 기본 오류 신호에 포함하지 않는다.
구현 소유권
필수 검증
Acceptance criteria
제외 범위
- durable outbox와 process crash 후 event 재전송
- Redis/JDBC production store
- 운영자용 reconciliation UI
- 자동 환율 변환
- provider 청구서 대사
Source
배경
현재 회계 쓰기 책임이 여러 객체에 분산되어 있다.
DefaultLedgerManager는 비용 계산 뒤 listener를 즉시 호출한다.이 상태에서 #36, #37, #39, #40을 병렬 구현하면 서로 다른 순서로 ledger, reservation, event를 쓸 위험이 있다.
결정
단일 회계 쓰기 주체
AccountingService또는 동등한 단일 orchestration API를 reservation/accounting 상태의 유일한 writer로 둔다.상태 전이
RECONCILIATION_REQUIRED는 terminal 성공/실패가 아니라 미해결 회계 상태다.IN_FLIGHT를 timeout만으로RELEASED또는EXPIRED로 바꾸지 않는다.미해결 부채
actual cost를 아직 알 수 없는 요청의 기존 reservation amount를
pendingReconciliationLiability로 유지한다.새 admission은 이 값을 사용한다. reconciliation 상태로 이동했다는 이유만으로 예산 여유가 되살아나면 안 된다.
통화
CURRENCY_MISMATCH로 거부하며 상태를 바꾸지 않는다.Event와 listener 실패 의미
LedgerListenerErrorHandler와 bounded metric/log hook을 제공한다.구현 소유권
필수 검증
RECONCILIATION_REQUIRED → COMMITTED로 한 번만 반영된다.Acceptance criteria
제외 범위
Source