--- kind: CONCEPT slug: transaction-result-algebra title: 트랜잭션 결과 대수 — 다섯 변형이 각각 답하는 질문 topic: commit-ambiguity-as-a-result project: clean-architecture-backend-template status: 게시 전 sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 rootTreeNode: concept:transaction-result-algebra evidenceCapturedOn: 2026-09-01 assets: - key: transaction-result-algebra file: ../../../final/evidence/rendered/transaction-result-algebra.svg evidence: - ../../../final/evidence/raw/transaction-result-algebra.txt source: - 원본 분석 절은 final/document.md#3-2 · analysis/03(소유 SSOT — TransactionResult는 application-core에 있다) · analysis/05 §3.2(SpringPolicyTransactionPort 쪽) 이다. --- # 트랜잭션 결과 대수 — 다섯 변형이 각각 답하는 질문 정책 기반 트랜잭션의 결과를 sealed interface 다섯 변형으로 표현한다. 각 변형이 호출자에게 허용하는 행동이 다르고, 그중 둘은 boolean 이나 예외로 표현할 수 없다. ## 관계 - **커밋 증거 단계 — NOT_STARTED에서 UNKNOWN까지** 이 결과를 만들어 내는 증거 모델이다. - **모르는 것은 성공도 실패도 아닌 세 번째 결과여야 한다** Indeterminate 변형이 그 규칙의 구현이다. - **위험한 조합은 정책이 아니라 생성자가 거부하게 만든다** 이 타입의 컴팩트 생성자가 그 규칙의 예다. ## 본문 `TransactionResult`가 sealed interface로 다섯 변형을 갖는 이유의 설명이다. | 변형 | 무엇을 주장하나 | |---|---| | `Committed` | 물리 커밋 확인 | | `Participating` | 바깥 트랜잭션에 참여 — **커밋을 주장하지 않는다** | | `DeterminateRollback` | 롤백 확인 | | `Indeterminate` | **replay 권한을 주지 않는다** | | `CommittedWithPostCommitFailure` | 커밋 후 후처리 실패 | ## 다섯 변형이 sealed 로 묶인 이유 :::evidence key="transaction-result-algebra" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true" ::: ## boolean이나 예외로 표현할 수 없는 두 상태 각 변형이 caller에게 허용하는 행동이 다르고, 특히 `Participating`과 `Indeterminate`가 그렇다. ## 물리 소유자일 때만 phase를 기록한다 `PhaseSentinel`이 `Ordered.HIGHEST_PRECEDENCE`로 등록된다. :::note 없음 ::: ## 다섯 변형 ```java /** * Outcome algebra for policy-based transactions. * *

A participant result never claims commit. An indeterminate result never grants replay * authority. */ public sealed interface TransactionResult { TransactionOutcome outcome(); ``` 두 문장짜리 javadoc이 이 타입의 전부를 말한다. 참여 결과는 커밋을 주장하지 않고, 불확정 결과는 재실행 권한을 주지 않는다. | 변형 | 답하는 질문 | 호출자가 할 수 있는 것 | |---|---|---| | `Committed` | 물리 커밋이 확인됐는가 | 후속 작업 진행 | | `Participating` | 바깥 트랜잭션에 참여했는가 | 아무것도 확정하지 않고 반환 | | `DeterminateRollback` | 확정적으로 롤백됐는가 | 재시도 또는 실패 보고 | | `Indeterminate` | 결과를 알 수 없는가 | 조정으로 넘김. 재실행 금지 | | `CommittedWithPostCommitFailure` | 커밋은 됐는데 이후가 실패했는가 | 커밋을 되돌리지 않고 운영 실패만 보고 | ## boolean으로 표현할 수 없는 둘 `Participating`은 이 호출이 바깥 트랜잭션 안에서 실행됐고 따라서 커밋 여부를 말할 위치에 있지 않다는 뜻이다. ```java record Participating(T value) implements TransactionResult { @Override public TransactionOutcome outcome() { return TransactionOutcome.PARTICIPATING_PENDING_OUTER; } } ``` 성공으로 접으면 커밋되지 않은 것을 커밋으로 보고하고, 실패로 접으면 정상 경로를 실패로 보고한다. `Indeterminate`는 마지막으로 관측된 phase와 조정 참조를 함께 들고 다닌다. ```java record Indeterminate( Optional operationId, TransactionPhase lastObservedPhase, Optional reconciliationReference) implements TransactionResult { public Indeterminate { ... if (operationId.isEmpty() && reconciliationReference.isPresent()) { throw new IllegalArgumentException("reconciliationReference requires a stable operationId"); } } ``` 컴팩트 생성자의 마지막 검사가 이 개념의 일부다. 조정 참조가 있는데 안정적인 operationId가 없는 값은 만들 수 없다 — 조정할 대상을 지목할 수 없는 조정 참조는 쓸모가 없기 때문이다. ## 다섯 번째 변형 ```java record CommittedWithPostCommitFailure( T value, Optional operationId, RuntimeException operationalFailure) implements TransactionResult { ``` 커밋은 확정됐고 그 이후의 무언가가 실패한 상태다. 이것을 실패로 접으면 호출자가 이미 커밋된 작업을 재시도하고, 성공으로 접으면 운영 실패가 사라진다. ## PhaseSentinel이 하는 일 `SpringPolicyTransactionPort`의 private 중첩 클래스 `PhaseSentinel`이 `TransactionSynchronization`으로 등록된다. 등록은 물리 소유자일 때만 일어나고, 우선순위는 `Ordered.HIGHEST_PRECEDENCE`다. 물리 소유자 조건이 중요하다. 참여 트랜잭션이 phase를 기록하면 바깥의 phase를 덮게 되고, 그것이 `Participating`이 커밋을 주장하지 않는다는 규칙을 코드 수준에서 깨는 경로다.