Files
document-haness/docs/clean-architecture-backend-template/tech-log-studio/commit-ambiguity-as-a-result/concept/concept-transaction-result-algebra.md
T
DongHyeonkaandClaude Fable 5.1 b25357c48a docs(clean-architecture-backend-template): fold analysis into final and re-select one topic
- analysis/·source-index·state.json 을 final/document.md 제2부·제3부로 접었다. SSOT 는 하나다
- 파일럿 — commit-ambiguity-as-a-result 를 새 기준으로 재선별. 후보 14 → 글감 5
  (PROMOTE 5 · MERGE_INTO 3 · KEEP_IN_SSOT 4 · 보류 2). 기록 5건을 다시 썼고 그림 1개를
  techviz 로 만들었다
- 재선별이 잡은 것: 제1부 §6.2·§11.1 이 자기 §13.2 와 어긋나 있었다(레인을 안 돌렸다 vs
  돌렸다) — 정정. 이미 답이 나와 있던 Question 을 HEAD 재실행 질문으로 다시 세웠다.
  Concept 이 인용한 코드가 SSOT 에 없어 뺐다
- candidateScope·sourceRepository 기록. 나머지 43개 주제는 재선별 대기(PENDING 905)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:39:20 +09:00

8.2 KiB

id, kind, slug, title, topic, topicName, project, status, studio, basisVersion, source, sourceRevision, assets
id kind slug title topic topicName project status studio basisVersion source sourceRevision assets
CONCEPT transaction-result-algebra 트랜잭션 결과 대수 — 다섯 변형이 각각 답하는 질문 commit-ambiguity-as-a-result 커밋 모호성 — 「모른다」를 결과로 유지하기 clean-architecture-backend-template 게시 전 Spring Boot 4.0.8 · Java 21 · 리비전 21234e38
final/document.md#3-2
final/document.md#a05
final/document.md#a05 §3.4
21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
key file
commit-evidence-phase-machine ../../../final/assets/tech-log-studio/commit-evidence-phase-machine.svg

트랜잭션 결과 대수 — 다섯 변형이 각각 답하는 질문

정책 기반 트랜잭션 실행기는 결과를 sealed interface 다섯 변형으로 돌려준다. 변형마다 호출자에게 허용하는 행동이 다르고, 그중 둘은 boolean 이나 예외로 표현되지 않는다. 어느 변형이 나올지는 커밋 증거 단계가 정한다.

관계

  • pg_terminate_backend 가 57P01 로 도착하고 커밋 레코드는 이미 WAL 에 있었다 이 대수의 Indeterminate 변형이 실제로 필요해진 사례다.
  • completion-unknown 은 자동으로도 수동으로도 재시도하지 않는다 Indeterminate 를 받은 호출자가 무엇을 해도 되는지 정한 결정이다.

본문

다섯 변형이 각각 답하는 질문

TransactionResult<T> 의 javadoc 두 문장이 이 타입의 전부를 말한다.

/**
 * Outcome algebra for policy-based transactions.
 *
 * <p>A participant result never claims commit. An indeterminate result never grants replay
 * authority.
 */
public sealed interface TransactionResult<T> {

  TransactionOutcome outcome();

참여 결과는 커밋을 주장하지 않고, 불확정 결과는 재실행 권한을 주지 않는다. 나머지 셋은 그 두 문장 사이를 채운다.

변형 답하는 질문 호출자가 할 수 있는 것
Committed 물리 커밋이 확인됐는가 후속 작업 진행
Participating 바깥 트랜잭션에 참여했는가 아무것도 확정하지 않고 반환
DeterminateRollback 확정적으로 롤백됐는가 재시도 또는 실패 보고
Indeterminate 결과를 알 수 없는가 조정으로 넘김. 재실행 금지
CommittedWithPostCommitFailure 커밋은 됐는데 이후가 실패했는가 커밋을 되돌리지 않고 운영 실패만 보고

boolean 으로 접히지 않는 두 변형

Participating 은 이 호출이 바깥 트랜잭션 안에서 실행됐고 커밋 여부를 말할 위치에 있지 않다는 뜻이다.

record Participating<T>(T value) implements TransactionResult<T> {

  @Override
  public TransactionOutcome outcome() {
    return TransactionOutcome.PARTICIPATING_PENDING_OUTER;
  }
}

성공으로 접으면 커밋되지 않은 작업을 커밋으로 보고하고, 실패로 접으면 정상 경로를 실패로 보고하므로 두 방향 모두 사실과 어긋난다.

Indeterminate 는 마지막으로 관측된 단계와 조정 참조를 함께 들고 다닌다.

record Indeterminate<T>(
    Optional<OperationId> operationId,
    TransactionPhase lastObservedPhase,
    Optional<ReconciliationReference> reconciliationReference)
    implements TransactionResult<T> {

세 성분 가운데 조정 참조는 지금 채워지지 않는다. SpringPolicyTransactionPort 가 만드는 Indeterminate 는 두 생성 경로에서 모두 Optional.empty() 를 넣는다. 타입은 durable reconciliation reference 를 담을 수 있는데 이 어댑터가 그 값을 만들지 않아서, 조정으로 넘어간 쪽이 지목할 수 있는 것은 operationId 뿐이다.

값이 표현 불가능한 조합을 거부하는 기법은 실패 컨텍스트에 걸려 있다.

// JpaFailureContext
if (completionUnknown && retryable) {
  throw new IllegalArgumentException("completion unknown failures are never retryable");
}

Indeterminate 를 만드는 쪽과 그 결과를 재시도 정책에 넘기는 쪽이 서로 다른 타입이어서, 같은 불변식이 양쪽 생성자에 따로 서 있다.

커밋 증거 단계가 변형을 고른다

핵심 실행 루틴은 COMMIT_REQUESTED 를 관측한 뒤 provider commit 을 부르고, 돌아온 것을 두 번 물어 네 변형 중 하나를 고른다.

:::evidence key="commit-evidence-phase-machine" alt="COMMIT_REQUESTED 에서 provider commit 을 거쳐 Committed 와 CommittedWithPostCommitFailure 와 DeterminateRollback 과 Indeterminate 네 결과로 갈라지는 흐름도" caption="커밋 요청 뒤의 결과 판정" zoom="false" :::

lastObservedPhase 가 담는 값은 여섯 단계짜리 열거형이다.

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 판정과 단계 판정을 잇는다. SQLSTATE 만으로도 단계만으로도 completion-unknown 이 되지 않고, 둘의 교집합에서만 생긴다. 그래서 mark(COMMITTING) 은 provider commit 직전에 찍힌다 — 그 안에서 프로세스가 죽으면 마지막 기록이 「물어봤고 모른다」여야 한다.

증거를 단일 슬롯이 아니라 스택으로 두는 이유

/**
 * <p>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.
 */

프레임의 소유권은 스택에서의 깊이로 식별한다. 내용은 단계 전이마다 바뀌지만 깊이는 바뀌지 않는다.

읽기 경로에도 같은 제약이 걸린다.

/**
 * 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<Deque<TransactionEvidenceFrame>> FRAMES = new ThreadLocal<>();

정리 경로가 아무리 정확해도 읽기 경로가 값을 되살리면 프레임이 남는다. 남은 프레임은 프레임이 없는 것보다 나쁘다. 풀링된 요청 스레드가 낡은 COMMITTING 을 무관한 작업으로 들고 가고, 플랫폼은 존재한 적 없는 트랜잭션에 대해 completion-unknown 을 보고한다.

단계를 기록하는 쪽을 물리 소유자로 제한한다

SpringPolicyTransactionPort 의 private 중첩 클래스 PhaseSentinelTransactionSynchronization 으로 등록된다. 등록은 물리 소유자일 때만 일어나고 우선순위는 Ordered.HIGHEST_PRECEDENCE 다.

참여 트랜잭션이 단계를 기록하면 바깥의 단계를 덮어서, Participating 이 커밋을 주장하지 않는다는 규칙이 코드 수준에서 깨진다.

이 개념이 보장하지 않는 것

다섯 변형은 결과를 무엇으로 부를지 정하고, 그 결과가 실제로 무엇이었는지를 알아내지는 않는다. Indeterminate 를 받은 호출자는 커밋 여부를 여전히 모른다. 그 값이 하는 일은 모른다는 사실을 잃지 않고 조정으로 넘기는 것까지다.