docs(clean-architecture-backend-template): 제1부가 채택한 것만 글감으로 남기고 다시 고른다

글감 1,001개 중 제1부(§3~§11) 앵커를 하나라도 가진 것은 112개뿐이었다. 나머지 889개는
제2부 모듈 분석 65편의 절 제목에서 나온 것이고, 그것이 재판정이 필요했던 이유다.

  주제      44 → 16   (43개가 독자 질문 없이 있었다. 지금은 전부 있다)
  글감   1,001 → 123  (제1부 앵커 112 + 제1부가 채택했는데 비어 있던 자리 11)
  후보      965 → 1,088 · PENDING 905 → 0
  error   3,042 → 0

내려온 889개는 후보 대장에 KEEP_IN_SSOT 로 남는다 — 버린 것이 아니라 분석에 남기고 독립
기록으로 만들지 않기로 한 것이다. 그 글감을 받치던 기록 파일 828개는 지웠다. 계약이 정본이고,
파일이 남아 있다는 이유로 계약에서 뺀 주제가 되살아나면 안 된다. 이력에는 그대로 있다 —
git checkout a0ca2bb -- <경로>.

제1부가 채택했는데 글감이 없던 자리 열하나를 채웠다: mongo high-water mark 가 재전달 이벤트를
삼킨 P1, admin plane 이 가드만 켜고 서비스는 켜지 않은 것과 그 짝인 결정, 실패 어휘 세 층과
SQLState 매트릭스 병합 규칙, 부하 아래에서만 새는 admission 경계, 발행 증거와 완료 판정의
분리, keyset·JSONB 결정 둘.

Concept 17개에 basis-version 을 채우고, 계약 제목과 기록 제목이 갈라져 있던 23건을 기록 쪽에
맞췄다. candidateScope 에 excludedAnchorPattern 을 적어 제2부 앵커만 가진 글감이 다시 올라올
수 없게 한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-07 15:02:25 +09:00
co-authored by Claude Opus 5
parent a0ca2bb72a
commit 1f04117bbf
851 changed files with 5498 additions and 90638 deletions
@@ -1,116 +0,0 @@
---
kind: CASE
slug: a-digest-that-covered-who-but-not-what
title: transition digest가 "누가·언제"만 덮고 "무엇"을 덮지 않아 다른 전이를 같다고 보고했다
topic: owner-safe-state-machines
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a-digest-that-covered-who-but-not-what
evidenceCapturedOn: 2026-09-02
assets:
- key: a-digest-that-covered-who-but-not-what
file: ../../../final/evidence/rendered/a-digest-that-covered-who-but-not-what.svg
evidence:
- ../../../final/evidence/raw/a-digest-that-covered-who-but-not-what.txt
source:
- 이 사례의 사실은 분석 문서가 아니라 다이제스트 정책 클래스의 javadoc — 고친 쪽이 남긴 사후 기록 — 과 현재 구현에서 왔다. 후보 원장이 지정한 `final/document.md#a05` 의 절 번호는 그 파일에 존재하지 않는다. 완료 쪽이 아직 열려 있다는 것은 같은 문서 §59.2 이고, 위에 인용한 탐침 값이 그 절에 있다.
---
# transition digest가 "누가·언제"만 덮고 "무엇"을 덮지 않아 다른 전이를 같다고 보고했다
전이 다이제스트가 전이 종류와 연산 식별자와 소유자와 시도와 상태 리비전을 덮었다. 누가 언제 했는지는 덮고 무엇을 했는지는 덮지 않아서, 결말이 다른 전이가 같은 값을 냈다.
## 관계
- **digest는 길이 프레이밍하고 버전을 붙인다**
이 사례가 만든 규칙이다.
- **CAS 튜플을 where 절에 전부 반복하고 update count를 답으로 쓴다**
같은 저장소의 소유자 안전 규칙이다.
- **complete()의 replay 판정이 replayTtl 변경을 무시한다**
이 다이제스트가 실제 판정에 쓰이지 않는 경로다.
## 문제
다이제스트가 덮던 다섯 성분은 전부 누가 몇 번째로 어느 상태에서 했는지를 말한다. 무엇을 했는지를 말하는 성분이 없었다.
그래서 재시도 가능한 실패와 포기한 실패가 같은 값을 냈고, 응답이 다른 두 완료와 보존 기간이 다른 두 완료도 그랬다.
## 결론
다이제스트를 비교한 재생 판정이 세 쌍을 같은 전이로 결론지었다. 세 쌍에서 갈린 것은 호출자의 행동이 달라지는 자리뿐이었다.
수정은 두 층으로 왔다. 호출부마다 의미 인자를 넘기게 했고, 실패 쪽은 전이 종류 자체를 성향별로 갈랐다.
이어 붙이는 방식도 바뀌었다. 성분마다 앞에 길이를 적고, 버전 상수를 다이제스트 입력에 넣는다.
다만 완료 쪽 재생 분기는 이 다이제스트를 비교하지 않는다. 그래서 응답이 같고 재생 창만 다른 두 완료는 지금도 같은 결과로 판정된다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : 다이제스트 정책과 다섯 호출부, 완료 재생 분기 확인
소스 수정 : x
## 재현 조건
1. 다이제스트 정책의 javadoc 에서 덮던 성분과 충돌한 쌍을 읽는다.
2. 전이 다이제스트를 부르는 다섯 지점에서 각각 무엇을 넘기는지 확인한다.
3. 실패 쪽 전이 종류가 어떻게 갈라지는지 확인한다.
4. 이어 붙이기가 각 성분 앞에 적는 길이와, 해시가 실제로 먹는 바이트를 확인한다.
5. 완료 재생 분기가 무엇을 비교하는지 확인한다.
## 본문
<!-- body:start -->
전이 다이제스트가 덮던 것은 전이 종류와 연산 식별자와 소유자 토큰과 시도와 상태 리비전이었다.
## 결말을 가르는 성분이 하나도 없었다
:::evidence key="a-digest-that-covered-who-but-not-what" alt="코드베이스에서 전이 다이제스트가 덮던 다섯 성분과 그때 같은 값을 내던 쌍을 적은 javadoc, 지금 다섯 호출부가 실제로 넘기는 의미 인자, 각 성분 앞에 적는 길이와 해시가 먹는 바이트, 버전 상수, 그리고 완료 재생 분기가 비교하는 값을 뽑은 출력 49줄. 완료 재생 분기가 전이 다이제스트가 아니라 연산 식별자와 응답 다이제스트만 본다는 것이 그 출력에 그대로 보인다." caption="덮던 다섯과 충돌한 쌍 · 호출부별 의미 인자 · 길이 프레이밍과 해시 입력 · 완료 재생 분기가 비교하는 값 — 49줄 · exit 0" zoom="true"
:::
다섯은 전부 누가 몇 번째로 어느 상태에서 했는지를 말한다. 결말에 가장 가까운 것이 전이 종류인데, 그때는 실패가 성향과 무관하게 하나의 `FAIL` 이었다.
javadoc 이 같은 값을 내던 쌍 셋을 든다. 재시도 가능한 실패와 포기한 실패, 응답이 다른 두 완료, 보존 기간이 다른 두 완료다.
세 쌍이 달랐던 부분은 호출자가 실제로 행동을 바꾸는 부분뿐이었다. 다이제스트를 비교한 재생 판정은 그 셋을 전부 같은 전이라고 답했다.
## 무엇을 넘길지는 호출부가 정한다
지금은 다섯 호출부가 각자 인자를 넘긴다.
갱신은 처리 임차 유효 기간을 넘긴다. 완료는 응답 다이제스트와 재생 유효 기간을 넘기고, 그 자리 주석이 이유를 적는다 — 응답 다이제스트를 전이 다이제스트 대신 쓰면 같은 응답을 낸 서로 다른 연산의 완료가 구별되지 않고, 완료가 함께 결정하는 재생 창도 잃는다.
실패는 성향과 보존 기간을 넘긴다. 그리고 전이 종류 자체가 `FAIL_RETRYABLE``FAIL_ABANDONED` 로 갈린다. 첫 번째 쌍은 두 겹으로 갈라진 셈이다.
시작과 해제는 아무것도 넘기지 않는다. javadoc 이 예시로 든 코덱 신원은 지금 어느 호출부도 넘기지 않는다.
## 이어 붙이는 방식이 구별을 잃게 할 수 있다
성분은 전부 가변 폭 텍스트다. 그중 소유자 토큰은 문자 집합을 이 플랫폼이 정하지 않는다 — 소유자 타입이 강제하는 것은 1자 이상 128자 이하와 공백 불가뿐이다.
구분자로 이으면 서로 다른 성분 목록이 하나의 문자열로 렌더링될 수 있다. 그래서 각 성분 앞에 길이를 적고 콜론을 찍은 뒤 값을 적는다.
적는 길이는 자바 문자 수다. 해시가 먹는 것은 UTF-8 바이트다. 같은 저장소의 메시징 파티션 키는 같은 목적에 UTF-8 바이트 수를 쓰고, 비 ASCII 골든 벡터로 그 선택을 고정한다.
## 버전 상수는 다이제스트 입력 안에 있다
상수 값은 2 이고, 이어 붙이기가 그 값을 `v2` 로 맨 앞에 쓴다. 형식이 바뀌면 값이 바뀌므로 옛 형식으로 계산된 다이제스트와 섞이지 않는다.
저장소 이력에는 이 파일이 지금 형태 그대로 커밋 하나에 들어와 있다. 값이 언제 몇 번 올랐는지는 남아 있지 않다.
## 완료 쪽은 아직 이 다이제스트를 보지 않는다
정책이 고쳐진 것과 그 정책이 판정에 쓰이는 것은 다르다.
이미 완료된 동일 연산의 재생 분기는 전이 다이제스트를 비교하지 않는다. 상태가 COMPLETED 인지, 마지막 전이 종류가 COMPLETE 인지, 연산 식별자가 같은지를 보고, 그다음 응답 다이제스트만 비교한다.
그래서 응답은 같고 재생 유효 기간만 다른 두 완료가 같은 결과로 판정된다. 분석 문서가 실제 PostgreSQL 탐침으로 그것을 확인했다 — 첫 호출에 한 시간, 두 번째 호출에 아홉 시간을 넘겼는데 두 번째가 같은 결과로 판정됐고, 저장된 창은 3600초 그대로였다.
## 확인하지 못한 것
수정 이전의 충돌을 실행으로 재현하지 않았다. 지금 정책이 지켜지는지는 다이제스트 정책 전용 테스트가 고정한다 — 성향 차이, 응답 차이, 재생 창 차이, 길이 프레이밍, 그리고 널 인자와 인자 없음의 구분이다. 완료 쪽이 아직 열려 있다는 것은 분석 문서의 실측 탐침이 확인했다.
<!-- body:end -->
@@ -1,99 +0,0 @@
---
kind: CASE
slug: expired-claim-versus-expired-execution
title: 만료된 CLAIMED는 takeover하고 만료된 EXECUTING은 조정을 요구하도록 갈랐다
topic: owner-safe-state-machines
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:expired-claim-versus-expired-execution
evidenceCapturedOn: 2026-09-01
assets:
- key: expired-claim-versus-expired-execution
file: ../../../final/evidence/rendered/expired-claim-versus-expired-execution.svg
evidence:
- ../../../final/evidence/raw/expired-claim-versus-expired-execution.txt
source:
- 원본 분석 절은 final/document.md#a05 §10.1, §10.4 이다.
---
# 만료된 CLAIMED는 takeover하고 만료된 EXECUTING은 조정을 요구하도록 갈랐다
리스가 만료된 두 상태를 같이 다루면 안 된다. 청구만 하고 실행하지 않은 소유자는 밀어내도 되지만, 실행을 시작한 소유자는 무엇을 했는지 알 수 없으므로 조정으로 넘긴다.
## 관계
- **만료된 claim과 만료된 실행은 다르게 다뤄야 한다**
이 사례에서 끌어낸 규칙이다.
- **모르는 것은 성공도 실패도 아닌 세 번째 결과여야 한다**
조정으로 넘기는 판정이 그 규칙의 적용이다.
- **fenced lease — 만료 시각만으로는 부족한 이유**
리스 만료를 다루는 맥락이다.
## 문제
리스가 만료되면 다른 작업자가 그 레코드를 가져갈 수 있어야 한다. 그러지 않으면 죽은 작업자의 레코드가 영원히 막힌다.
문제는 만료된 소유자가 무엇을 하다가 만료됐는지에 따라 안전한 처리가 다르다는 점이다.
## 결론
청구 결정 트리가 두 상태를 갈라 다르게 답한다.
같은 소유자 토큰이면 연산 충돌로 답한다
상태가 CLAIMED 이고 리스가 지났으면 청구를 재설정한다. 즉 가져간다
상태가 재시도 가능 실패면 마찬가지로 재설정한다
상태가 EXECUTING 이고 리스가 지났으면 복구 필요로 답한다
상태가 포기됨이면 복구 필요로 답한다
그 외에는 진행 중으로 답하고 재시도 시각을 준다
CLAIMED 는 자리를 잡았지만 아직 아무것도 실행하지 않은 상태다. 그 소유자를 밀어내도 외부 효과가 없다.
EXECUTING 은 실행을 시작한 상태다. 그 소유자가 무엇을 어디까지 했는지 이 저장소는 모른다. 밀어내고 다시 실행하면 그 작업이 두 번 일어날 수 있다.
그래서 EXECUTING 만료는 자동 처리 대상이 아니라 조정 대상이다. 결과 타입이 복구 필요라는 별도 값을 갖고, 그 값이 시도 번호를 함께 들고 간다.
같은 구별이 해제 경로에도 있다. 소유자 튜플이 다르면 소유자 아님으로 답하고, 상태가 이미 EXECUTING 이면 실행이 시작되었음으로 답하며, CLAIMED 가 아니면 연산 충돌로 답한다. 해제는 아직 실행하지 않은 청구에 대해서만 허용된다.
같은 형태가 인박스 어댑터에도 있다.
## 검증 환경
OpenJDK : 21.0.12
데이터베이스 : PostgreSQL
확인 방식 : 결정 트리와 그 javadoc 확인
소스 수정 : x
## 재현 조건
1. 소유자 안전 멱등성 저장소의 청구 결정 트리를 읽는다.
2. CLAIMED 만료와 EXECUTING 만료가 각각 어떤 결과를 내는지 확인한다.
3. 해제 경로의 상태 검사를 확인한다.
4. 인박스 어댑터의 같은 형태를 비교한다.
## 본문
<!-- body:start -->
만료된 lease를 일률적으로 takeover하면 이미 실행이 시작된 작업을 blind retry하게 된다.
## 이 저장소는 상태로 나눈다
만료된 `CLAIMED``resetClaim`으로 takeover하고, 만료된 `EXECUTING``abandonExpiredExecution`으로 `ABANDONED`에 넣고 `RecoveryRequired`를 반환한다.
## RecoveryRequired 참조 위치
:::evidence key="expired-claim-versus-expired-execution" alt="코드베이스에서 RecoveryRequired 를 검색한 출력 10줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="RecoveryRequired 코드베이스 검색 — 10줄 · exit 0" zoom="true"
:::
## inbox도 같은 축을 쓴다
`RECEIVED`(takeover 가능)와 `PROCESSING`(→ DEAD, recovery-required)로 나눈다. 즉 "claim만 했다"와 "실행에 들어갔다"가 만료 시 다른 결론을 낳는다.
## 확인하지 못한 것
만료된 EXECUTING 이 실제로 조정 큐로 흘러가 사람이 처리하는 경로를 따라가지 않았다. 확인한 것은 저장소가 그 상태를 별도 결과로 답한다는 것이다.
컨테이너 레인 미실행
<!-- body:end -->
@@ -1,56 +0,0 @@
---
kind: PROJECT_DECISION
slug: state-machines-carry-no-stereotype
title: 상태 기계 구현은 Spring stereotype을 갖지 않는다
topic: owner-safe-state-machines
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: decision:state-machines-carry-no-stereotype
decisionStatus: ADOPTED
decidedOn: 2026-08-30
source:
- src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/idempotency
- src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/config/JpaAdapterComponentsConfig.java
- final/document.md#a05
---
# 상태 기계 구현은 Spring stereotype을 갖지 않는다
## 결정문
소유자 안전 상태 기계 구현 클래스에는 컴포넌트 스캔 대상이 되는 애너테이션을 붙이지 않고, 조립은 명시적으로 한다.
## 판단 이유
이 클래스들은 어떤 데이터소스와 어떤 트랜잭션 경계에 묶이는지가 정확해야 한다. 스캔으로 들어오면 그 결정이 스캔 범위와 조건에 흩어진다.
같은 리프에서 그 흩어짐이 실제로 문제가 된 사례가 있다. 활성 트랜잭션 검사가 어느 데이터소스인지를 묻지 않아, 다른 데이터소스의 트랜잭션 안에서 발행된 변경이 이 저장소의 커넥션에 대해 트랜잭션 밖에서 커밋됐다.
명시적 조립은 그 결정을 한 자리에 모은다. 어떤 데이터소스가 어떤 저장소에 들어가는지가 코드로 보인다.
그리고 스캔되지 않으면 조건을 반복할 필요도 없다. 능력이 꺼진 배포에서 이 클래스들이 후보가 되는 일 자체가 없다.
## 영향
감수하는 것
조립 코드를 사람이 써야 한다. 새 상태 기계를 추가하면 조립 지점도 함께 고쳐야 한다.
조립을 잊으면 그 상태 기계가 없는 채로 배포된다. 스캔은 그 실수를 자동으로 막아 주지만 명시 조립은 그렇지 않다.
얻는 것
데이터소스와 트랜잭션 경계가 조립 지점에서 명시된다.
능력이 꺼진 배포에서 이 클래스들이 조건 평가 대상이 되지 않는다.
## 근거
- **활성 트랜잭션 검사가 data source를 묻지 않아 다른 커넥션에서 커밋됐다**
조립 결정이 흩어졌을 때의 결과다.
- **꺼짐은 조건의 반복이 아니라 구조여야 한다**
같은 계열의 조립 원칙이다.
- **Bean 애너테이션이 있다는 것은 조립 증거가 아니다**
명시 조립을 확인할 때 쓰는 규칙이다.
@@ -1,58 +0,0 @@
---
kind: REFERENCE
slug: digest-must-be-length-framed-and-versioned
title: digest는 길이 프레이밍하고 버전을 붙인다
topic: owner-safe-state-machines
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:digest-must-be-length-framed-and-versioned
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# digest는 길이 프레이밍하고 버전을 붙인다
## 목적
다이제스트가 서로 다른 입력에 대해 같은 값을 내거나, 구성이 바뀐 뒤 옛 값과 비교되는 것을 막는다.
## 규칙
1. 무엇을 덮는지가 정책이다
다이제스트가 빠뜨린 입력은 두 개의 다른 대상이 같은 값을 낼 수 있는 입력이다. 그 목록은 구현 세부가 아니라 정책이므로 자기 타입을 갖는다.
2. 결과를 덮는다
누가 언제 했는지만 덮으면 무엇을 했는지가 다른 두 전이가 같아진다.
3. 길이 프레이밍한다
구성 요소가 가변 길이 텍스트이고 그중 하나라도 이 플랫폼이 제약할 수 없는 값이면, 구분자로 이었을 때 서로 다른 목록이 한 문자열로 렌더링될 수 있다.
4. 버전을 붙이고 구성이 바뀌면 올린다
저장된 다이제스트가 구성 경계를 넘어 비교되지 않게 한다.
5. 비교 실패를 조용히 처리하지 않는다
버전이 다르면 같다고도 다르다고도 결론 내리지 않는다.
## 적용 조건
재생 판정이나 중복 판정에 쓰이는 모든 다이제스트
멱등성 키와 요청 지문
## 예외
캐시 키처럼 충돌이 성능 문제일 뿐 정확성 문제가 아닌 경우는 이 규칙이 과하다.
## 예시
전이 다이제스트가 전이 종류와 연산과 소유자와 시도와 리비전만 덮어, 재시도 가능한 실패와 포기한 실패가 같은 값을 냈다. 서로 다른 응답을 담은 두 완료도 마찬가지였다.
소유자 토큰은 이 플랫폼이 형식을 제약하는 값이 아니므로 길이 프레이밍이 필요하다.
## 관계
- **transition digest가 누가와 언제만 덮고 무엇을 덮지 않아 다른 전이를 같다고 보고했다**
이 규칙을 만든 사례다.
- **서명된 커서의 구조와 검증 순서**
같은 계열의 형식 결정을 다룬다.
@@ -1,58 +0,0 @@
---
kind: REFERENCE
slug: expired-claim-and-expired-execution-differ
title: 만료된 claim과 만료된 실행은 다르게 다뤄야 한다
topic: owner-safe-state-machines
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:expired-claim-and-expired-execution-differ
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# 만료된 claim과 만료된 실행은 다르게 다뤄야 한다
## 목적
리스 만료를 한 가지로 처리해, 실행을 시작했던 소유자의 작업을 두 번 수행하는 것을 막는다.
## 규칙
1. 만료된 청구는 인계한다
자리를 잡았지만 아직 아무것도 실행하지 않은 소유자를 밀어내도 외부 효과가 없다.
2. 만료된 실행은 조정으로 넘긴다
그 소유자가 무엇을 어디까지 했는지 알 수 없다. 다시 실행하면 그 작업이 두 번 일어날 수 있다.
3. 조정 결과는 별도 값이어야 한다
성공이나 실패로 접으면 그 구별이 사라진다. 결과 타입에 세 번째 변형이 필요하다.
4. 해제도 같은 구별을 따른다
실행이 시작된 청구는 해제할 수 없다. 해제는 아직 실행하지 않은 청구에만 허용한다.
5. 재시도 가능 실패는 인계 대상이다
그 상태는 이미 결과가 확정된 것이므로 새 소유자가 처음부터 시작해도 된다.
## 적용 조건
청구와 실행을 별도 상태로 갖는 모든 상태 기계
멱등성 저장소와 인박스와 아웃박스
## 예외
실행이 외부 효과를 남기지 않는 것이 구조적으로 보장되면 두 상태를 같이 다뤄도 된다. 그 보장을 적어 둔다.
## 예시
청구 결정 트리가 만료된 청구는 재설정하고 만료된 실행은 복구 필요로 답한다. 복구 필요 결과는 시도 번호를 함께 들고 간다.
해제 경로는 이미 실행이 시작된 경우 실행 시작됨으로 답하고 해제하지 않는다.
## 관계
- **만료된 CLAIMED는 takeover하고 만료된 EXECUTING은 조정을 요구하도록 갈랐다**
이 규칙을 만든 사례다.
- **모르는 것은 성공도 실패도 아닌 세 번째 결과여야 한다**
세 번째 규칙이 기대는 상위 규칙이다.