--- kind: CONCEPT slug: commit-evidence-phases title: 커밋 증거 단계 — NOT_STARTED에서 UNKNOWN까지 topic: commit-ambiguity-as-a-result project: clean-architecture-backend-template status: 게시 전 sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 rootTreeNode: concept:commit-evidence-phases evidenceCapturedOn: 2026-09-01 assets: - key: commit-evidence-phases file: ../../../final/evidence/rendered/commit-evidence-phases.svg - key: commit-evidence-phases-diagram file: ../../../final/assets/diagrams/commit-evidence-phases.svg evidence: - ../../../final/evidence/raw/commit-evidence-phases.txt source: - 원본 분석 절은 final/document.md#3-2 · analysis/05 §3.4, §2.3 이다. --- # 커밋 증거 단계 — NOT_STARTED에서 UNKNOWN까지 트랜잭션이 어디까지 갔는지를 여섯 단계로 기록하고, COMMITTING 단계에서 관측된 실패만 completion-unknown 이 될 수 있게 하는 메커니즘이다. ## 관계 - **커밋 증거 프레임을 두 주인이 pop해서 바깥 트랜잭션의 실패가 익명이 됐다** 이 메커니즘의 저장 구조가 실제로 깨졌던 사례다. - **pg_terminate_backend가 57P01로 도착하고 커밋 레코드는 이미 WAL에 있었다** 이 단계 모델의 다른 절반인 SQLSTATE 판정이 넓어진 사례다. - **모르는 것은 성공도 실패도 아닌 세 번째 결과여야 한다** UNKNOWN 이 실제 상태인 이유를 규칙으로 옮긴 것이다. - **completion-unknown은 자동으로도 수동으로도 재시도하지 않는다** 이 모델의 UNKNOWN 을 어떻게 다룰지 정한 결정이다. ## 본문 트랜잭션이 어디까지 갔는지를 여섯 단계로 기록하는 메커니즘의 설명이다. `NOT_STARTED → ACTIVE → COMMITTING → COMMITTED | ROLLED_BACK | UNKNOWN`이고, **`COMMITTING` 단계에서 관측된 실패만** completion-unknown이 될 수 있다는 것이 전체 모델의 핵심이다. ## COMMITTING 에서만 갈리는 세 결과 :::evidence key="commit-evidence-phases-diagram" alt="COMMITTING 에서 COMMITTED 와 ROLLED_BACK 과 UNKNOWN 세 갈래가 나온다" caption="COMMITTING 에서만 갈리는 세 결과" zoom="false" ::: ## EvidenceAwareJpaTransactionManager 참조 위치 :::evidence key="commit-evidence-phases" alt="코드베이스에서 EvidenceAwareJpaTransactionManager 를 검색한 출력 3줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="EvidenceAwareJpaTransactionManager 코드베이스 검색 — 3줄 · exit 0" zoom="true" ::: ## 표식을 provider commit 직전에 찍는 이유 `mark(COMMITTING)`을 provider commit **직전**에 찍는다. 그 안에서 프로세스가 죽으면 마지막 기록이 "물어봤고 모른다"여야 하기 때문이다. ## 컨텍스트가 단일 슬롯이 아닌 이유 `ArrayDeque` 스택이다 — `REQUIRES_NEW`가 같은 스레드에서 바깥을 suspend한다. `ThreadLocal.withInitial`을 쓰지 않는 이유도 이 개념의 일부다 — 읽을 때마다 값을 설치하면 `clear()`가 방금 제거한 것을 다시 등록한다. :::note 없음 — 구조와 근거를 코드로 확인했다 ::: ## 여섯 단계 ```java public enum TransactionCompletionEvidence { /** No transaction was begun for this unit of work. */ NOT_STARTED, /** A transaction is open and statements are executing. */ ACTIVE, /** The commit has been handed to the provider and no result has come back yet. */ COMMITTING, /** The provider confirmed the commit. */ COMMITTED, /** The provider confirmed the rollback. */ ROLLED_BACK, /** The commit outcome could not be determined; reconciliation owns the resolution. */ UNKNOWN } ``` 전이는 `NOT_STARTED → ACTIVE → COMMITTING → COMMITTED | ROLLED_BACK | UNKNOWN`이다. 이 열거형의 javadoc이 모델의 핵심을 한 문장으로 적는다. > This is evidence, not a guess. `UNKNOWN` is a real, reportable state: it means the > driver could neither confirm the commit nor confirm the rollback, and the platform refuses to > collapse that into either. Only a failure observed while the phase is `COMMITTING` may > become `TransactionCompletionUnknownException`. 마지막 문장이 판정의 게이트다. SQLSTATE만으로는 부족하고 phase만으로도 부족하며, 둘의 교집합에서만 completion-unknown이 생긴다. ## 왜 프레임워크 없는 코어에 있는가 ```java /** *

The enum lives in the framework-free core rather than in the Spring transaction adapter * because the design types the exception's evidence field, and the core error contract may not * depend on the adapter. */ ``` 예외의 증거 필드가 이 타입으로 선언되어 있으므로, 이 타입이 어댑터에 있으면 코어 오류 계약이 어댑터에 의존하게 된다. ## 왜 스택인가 증거는 스레드에 묶인 `ArrayDeque` 스택으로 보관된다. 단일 슬롯이 아닌 이유는 `REQUIRES_NEW`가 같은 스레드에서 바깥 트랜잭션을 suspend하고 안쪽을 시작하기 때문이다. ```java /** *

The state is a stack rather than a single value because {@code REQUIRES_NEW} suspends an outer * transaction and begins an inner one on the same thread. With a single slot, the inner * transaction's commit would overwrite the outer transaction's phase, and a later commit failure on * the outer one would be classified against evidence that belongs to work that already finished. */ ``` 프레임의 소유권은 스택에서의 깊이로 식별한다. 내용은 phase 전이마다 바뀌지만 깊이는 바뀌지 않는다. ## withInitial을 쓰지 않는 이유 ```java /** * Plain, not {@code withInitial}. An initialising thread-local installs a value on every read, so * a read after the last frame was cleared re-registered exactly what {@code clear()} had removed. */ private static final ThreadLocal> FRAMES = new ThreadLocal<>(); ``` 이것도 개념의 일부다. 정리 경로가 아무리 정확해도 읽기 경로가 값을 되살리면 남은 프레임이 생긴다. :::note 남은 프레임은 프레임이 없는 것보다 나쁘다. 풀링된 요청 스레드가 낡은 COMMITTING을 무관한 작업으로 들고 가고, 플랫폼은 존재한 적 없는 트랜잭션에 대해 completion-unknown을 보고한다. :::