docs(keycloak-session-store): import the session-storage lab as a new project

The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.

Follows the import procedure in README.md.

  source/     the originating repository verbatim — 78 documents, 28 SVGs,
              8 manifests, plus .source-revision recording the commit
  final/      the SSOT
    document.md   729 lines written from the 29 experiment documents, not
                  concatenated: what was predicted, what was measured, and
                  where the measurement itself was wrong
    evidence/raw    125 outputs, flattened to <experiment>__<file> because
                    the originals collided (01-baseline.txt appeared three
                    times) and the audit only globs the top level
    evidence/meta   one per raw file; command and exitCode are null and the
                    README says why rather than inventing them
    evidence/browser  22 captures
    assets/       three diagrams through techviz
    .techviz/     their VizSpecs

A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.

Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.

verify-pipeline.py passes. audit-records.py reports no issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-04 22:51:59 +09:00
co-authored by Claude Opus 5
parent 43bccd08a8
commit b2963105a8
5017 changed files with 372751 additions and 4943 deletions
@@ -0,0 +1,174 @@
---
kind: CASE
slug: a05-f020-inspect-claim
title: 만료를 보는 청구와 시각을 읽지 않는 조회가 같은 행을 다르게 답한다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a05-f020-inspect-claim
evidenceCapturedOn: 2026-09-02
assets:
- key: a05-f020-inspect-claim
file: ../../../final/evidence/rendered/a05-f020-inspect-claim.svg
- key: a05-f020-inspect-claim-postgres
file: ../../../final/evidence/rendered/a05-f020-inspect-claim-postgres.svg
evidence:
- ../../../final/evidence/raw/a05-f020-inspect-claim.txt
- ../../../final/evidence/raw/a05-f020-inspect-claim-postgres.txt
source:
- 원본 분석 절은 `analysis/05-adapter-outbound-persistence-jpa.md` §59.1 이다. 두 경로가 만료된 완료 행을 다르게 해석한다는 판정과 그 실행 탐침 값이 그 절에 있다. 같은 §59 의 나머지 한 군데는 §59.2 이고 별도 사례가 담당한다.
- 소비자 세 자리의 분기별 동작과 시험 범위, 레디스 구현과의 대조는 이 기록에서 확인했다.
---
# 만료를 보는 청구와 시각을 읽지 않는 조회가 같은 행을 다르게 답한다
멱등성 저장소의 청구 경로는 행을 잠근 뒤 데이터베이스 시각을 읽어 재생 유효 기간이 지난 완료 행을 인계로 보낸다. 조회 경로는 그 시각을 한 번도 읽지 않고 완료 상태에 응답이 있으면 재생으로 답한다. 실제 PostgreSQL 에서 만료 뒤 같은 행에 두 답이 나온다.
## 관계
- **만료된 claim과 만료된 실행은 다르게 다뤄야 한다**
같은 상태 기계의 만료 처리 규칙이다.
- **시간은 DB에서, 그리고 행을 잠근 다음에 읽는다**
두 경로가 같은 시각 기준을 써야 하는 이유다.
- **전이 다섯 중 넷은 공용 판정을 쓰고 완료만 손으로 비교한다**
같은 분석 절이 짚은 나머지 한 군데 재생 경계다.
## 문제
멱등성 저장소에는 두 진입 경로가 있다. 조회는 이 연산이 이미 처리됐는지 묻고, 청구는 지금 처리해도 되는지 묻는다.
같은 행에 대한 두 답이 갈리면 어느 쪽을 믿을지 정하는 규칙이 코드에 없다.
## 결론
청구가 데이터베이스 시각을 읽는 것은 행을 잠근 다음이고 한 번뿐이다. 그 시각으로 완료 상태이면서 재생 유효 기간이 지난 행을 먼저 걸러 인계로 보내고, 그 검사가 재생 응답 분기보다 앞에 있다.
조회 쪽에는 그 호출이 없다. replayUntil 은 응답에 실려 나갈 뿐 어디에서도 읽히지 않는다. 만료를 비교하는 헬퍼가 하나 있고, 그것을 부르는 것은 청구뿐이다. 조회가 쓰는 조회 SQL 에도 시간 술어가 없다.
실제 PostgreSQL 16 에 이 리프의 마이그레이션을 적용하고 재생 유효 기간 2초로 완료했다. 만료 시각 전 조회는 재생으로 답하고, 만료 뒤 조회도 같은 답과 같은 옛 응답을 준다. 같은 행을 다시 청구하면 인계가 돌아온다.
멱등성 실행기는 조회의 재생 결과를 세 곳에서 받고, 셋 다 청구나 시작이나 완료가 불확정으로 끝난 뒤의 복구 경로다. 그중 둘은 저장된 응답을 그대로 돌려주고 행동을 실행하지 않는다. 그 둘에서 만료가 소비자에게 번진다.
나머지 하나는 저장된 응답을 호출자가 방금 만든 결과와 비교하고 다르면 던진다. 그 경로는 호출자가 이미 실행한 뒤에만 닿고 돌려주는 값도 자기 결과이므로 이 문제가 번지지 않는다.
재생 창 만료를 짚는 시험은 없다. 통합 시험에 이름이 만료인 시험이 둘 있지만 둘 다 처리 임차를 25밀리초로 몰아 만든 것이고, 그 파일의 요청 헬퍼는 재생 유효 기간을 언제나 24시간으로 고정한다.
같은 계약의 레디스 구현에는 이 비교가 없다. 완료가 키에 재생 유효 기간을 그대로 만료로 걸어서, 창이 끝나면 해시가 사라지고 조회는 부재로 답한다.
## 검증 환경
OpenJDK : 21.0.12
데이터베이스 : PostgreSQL 16.15, 실제 실행
확인 방식 : 두 경로의 시각 참조 계수, 실제 PostgreSQL 에 마이그레이션 적용 후 만료 전후 호출, 소비자 세 자리 추적
소스 수정 : x
## 재현 조건
1. 청구 경로에서 데이터베이스 시각을 읽는 줄과 만료 판정 헬퍼를 찾고, 그것이 재생 응답 분기보다 앞인지 본다.
2. 조회 경로 전문에서 데이터베이스 시각 호출을 세고, replayUntil 이 어디에 쓰이는지 본다.
3. 조회가 쓰는 조회 SQL 에 시간 술어가 있는지 본다.
4. 실제 PostgreSQL 에 이 리프의 마이그레이션을 적용하고, 짧은 재생 유효 기간으로 완료한 뒤 만료 전후로 조회와 청구를 부른다.
5. 조회의 재생 결과를 받는 세 자리가 각각 무엇을 하는지 읽는다.
6. 통합 시험의 만료 시험이 무엇을 만료시키는지, 요청 헬퍼의 재생 유효 기간이 무엇인지 본다.
## 본문
<!-- body:start -->
청구는 행을 잠근 뒤 데이터베이스 시각을 한 번 읽는다. 조회는 그 호출이 0 이다.
## 청구는 만료를 먼저 본다
:::evidence key="a05-f020-inspect-claim" alt="청구 경로가 데이터베이스 시각을 읽고 만료된 완료 행을 먼저 걸러 내는 구간과 그 판정 헬퍼, 청구 본문의 시각 참조 수, 조회 경로 전문과 그 본문의 시각 참조 수와 replayUntil 이 쓰이는 자리, 만료 비교 헬퍼를 부르는 곳, 조회가 쓰는 조회 SQL, 조회 결과를 받는 세 자리, 그리고 출하 통합 시험의 만료 시험이 무엇을 만료시키는지와 요청 헬퍼의 재생 유효 기간을 출력한 터미널 기록." caption="청구는 databaseNow 1회와 만료 선분기 · 조회는 시각 0회, replayUntil 은 생성자 인자로만 · 만료 비교 헬퍼는 청구만 호출 · 조회 SQL 에 시간 술어 없음 · 만료 시험 둘은 처리 임차, 재생 창은 늘 24시간 — 95줄 · exit 0" zoom="true"
:::
```java
Instant dbNow = rows.databaseNow();
...
if (isExpiredCompleted(row, dbNow)) {
return resetClaim(request, row);
}
...
if (row.state() == IdempotencyState.COMPLETED && row.replayUntil() != null) {
return new IdempotencyClaimOutcome.CompletedReplay(
new StoredResponse(row.responsePayload()), row.replayUntil());
}
```
만료 판정이 재생 응답 분기보다 앞에 있다.
## 조회는 만료된 완료 행도 재생으로 답한다
```java
if (row.state() == IdempotencyState.COMPLETED && row.responsePayload() != null) {
return new IdempotencyInspection(
IdempotencyInspectionOutcome.COMPLETED_REPLAY,
...
Optional.ofNullable(row.replayUntil()));
}
```
`replayUntil` 은 응답 생성자에 한 번 실려 나갈 뿐 비교되지 않는다. 만료 비교를 하는 헬퍼는 이 파일에 하나뿐이고 청구만 부른다. 조회가 쓰는 조회 SQL 도 범위 해시와 레코드 버전만 술어로 쓴다.
## 실제 PostgreSQL 에서 두 답이 갈린다
:::evidence key="a05-f020-inspect-claim-postgres" alt="실제 PostgreSQL 컨테이너를 세우고 이 리프의 마이그레이션 세 스트림을 적용해 만들어진 테이블 목록, 그리고 재생 유효 기간 2초로 완료한 뒤 저장된 만료 시각과 만료 전 조회 결과, 만료 뒤 조회 결과와 그때 돌아온 응답, 같은 행에 대한 청구 결과를 출력한 터미널 기록." caption="PostgreSQL 16.15 에 마이그레이션 적용 · 재생 창 2초로 완료 · 만료 전 조회는 재생 · 만료 뒤 조회도 재생과 옛 응답 · 같은 행의 청구는 인계 — 12줄 · exit 0" zoom="true"
:::
```text
replay_until : 2026-09-02 04:35:51.753222+00
지금 : 2026-09-02 04:35:49.768587+00
만료 전 조회 : COMPLETED_REPLAY
지금 : 2026-09-02 04:35:53.285753+00
만료 후 조회 : COMPLETED_REPLAY 응답={"v":"OLD-RESPONSE"}
만료 후 청구 : TakenOverClaimed
```
만료 시각을 지난 뒤에도 조회는 같은 답과 같은 옛 응답을 준다. 같은 행에 대한 청구는 인계를 돌려준다.
## 조회 결과를 그대로 돌려주는 두 자리
멱등성 실행기는 조회의 재생 결과를 세 곳에서 받는다. 모두 청구나 시작이나 완료가 불확정으로 끝난 뒤의 복구 경로다. 그중 둘은 저장된 응답을 그대로 돌려준다.
```java
case COMPLETED_REPLAY -> codec.deserialize(requireResponse(inspection).payload());
```
두 경로 모두 행동을 실행하지 않는다. 그래서 재생 유효 기간이 지나 청구라면 인계했을 행에서도, 청구나 시작이 불확정으로 끝난 호출자는 만료된 이전 응답을 자기 답으로 받는다.
세 번째는 다르다.
```java
StoredResponse stored = requireResponse(inspection);
if (stored.payload().equals(codec.serialize(result))) {
yield result;
}
throw recovery("the completed response conflicts with the one this caller produced");
```
이 경로는 호출자가 이미 행동을 실행한 뒤에만 닿고, 돌려주는 값도 저장된 응답이 아니라 호출자 자신의 결과다. 만료가 번지는 자리는 앞의 둘이다.
## 재생 창 만료를 짚는 시험은 없다
통합 시험에 이름이 만료인 시험이 둘 있다. 만료된 청구를 인계하되 낡은 소유자는 시작하지 못한다는 것과, 만료된 실행 중 행은 조정을 요구한다는 것이다.
둘 다 처리 임차를 25밀리초로 몰아 만든 것이고, 그 파일의 요청 헬퍼는 재생 유효 기간을 언제나 24시간으로 고정한다. 만료 판정 헬퍼가 참이 되는 분기는 이 파일의 어느 시험도 밟지 않는다.
조회는 그 파일 전체에서 한 번 불리고, 포기 상태를 확인한다.
## 같은 계약의 레디스 구현에는 이 비교가 없다
완료가 키에 재생 유효 기간을 그대로 만료로 건다. JPA 쪽이 완료 SQL 에서 `replay_until` 로 적는 값과 같은 값이다. 창이 끝나면 해시가 사라지고 조회는 부재로 답한다.
시작과 갱신은 만료를 건드리지 않고, 실패 표시는 보존 기간을 건다.
## 고칠 방향
조회도 청구와 같은 데이터베이스 시각 기준을 써야 한다. 그리고 재생 유효 기간이 지난 뒤 조회가 무엇을 답하는지 고정하는 경계 시험이 있어야 한다.
## 확인하지 못한 것
두 답이 공존하는 창이 얼마나 지속되는지는 재지 않았다. 탐침에서 인계가 끝나면 조회는 다른 답으로 바뀌므로, 갈림은 먼저 도는 쪽이 상태를 바꿀 때까지다.
<!-- body:end -->
@@ -0,0 +1,197 @@
---
kind: CASE
slug: a05-f021-complete-replayttl
title: 전이 다섯 중 넷은 공용 판정을 쓰고 완료만 손으로 비교한다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a05-f021-complete-replayttl
evidenceCapturedOn: 2026-09-02
assets:
- key: a05-f021-complete-replayttl-postgres
file: ../../../final/evidence/rendered/a05-f021-complete-replayttl-postgres.svg
- key: a05-f021-complete-replayttl
file: ../../../final/evidence/rendered/a05-f021-complete-replayttl.svg
evidence:
- ../../../final/evidence/raw/a05-f021-complete-replayttl-postgres.txt
- ../../../final/evidence/raw/a05-f021-complete-replayttl.txt
source:
- 원본 분석 절은 `analysis/05-adapter-outbound-persistence-jpa.md` §59.2 다. 등급은 P2 이고, 만료 해석 불일치인 §59.1 은 형제 기록이 다룬다.
- 디지스트 정책 단위 시험이 이 값을 이미 고정한다는 것은 그 절이 짚는다. 레디스 구현의 같은 자리와 공용 헬퍼를 쓰는 전이 넷은 이 기록에서 확인했다.
---
# 전이 다섯 중 넷은 공용 판정을 쓰고 완료만 손으로 비교한다
이미 완료된 같은 연산의 재생 분기는 응답 다이제스트만 비교한다. 완료가 다이제스트에 넣어 열에 저장한 재생 창은 읽히지 않는다. 나머지 네 전이는 그 열을 비교하는 공용 헬퍼를 쓰고 다른 인자를 들고 온 재시도를 충돌로 돌려보낸다.
## 관계
- **transition digest가 "누가·언제"만 덮고 "무엇"을 덮지 않아 다른 전이를 같다고 보고했다**
같은 다이제스트가 무엇을 덮어야 하는지 다룬 사례다.
- **digest는 길이 프레이밍하고 버전을 붙인다**
이 다이제스트의 형식 규칙이다.
- **만료를 보는 청구와 시각을 읽지 않는 조회가 같은 행을 다르게 답한다**
같은 분석 절이 짚은 나머지 한 군데 재생 경계다.
## 문제
완료가 정하는 것은 둘이다. 응답으로 무엇을 남길지, 그리고 그것을 언제까지 재생할지다.
첫 완료는 둘 다 다이제스트에 넣는다. 두 번째 완료는 앞의 것만 본다.
## 결론
실제 PostgreSQL 16 에서 같은 연산과 같은 응답에 1시간과 9시간을 차례로 넣었다. 두 번째는 이미 완료된 같은 결과로 답하고, 행에는 첫 1시간이 남고, 전이 다이제스트도 그대로다.
값이 다르게 계산된다는 것은 이미 단위 시험이 고정하고 있다. 60000 과 90000 을 넣은 완료 다이제스트가 다르다는 시험이 같은 모듈에 있다. 값은 계산되고, 열에 저장되고, 시험으로 지켜진다. 그것을 읽지 않는 쪽이 재생 판정이다.
나머지 네 전이는 다르게 한다. 시작과 갱신과 실패 표시와 해제가 공용 헬퍼에 자기 다이제스트를 넘기고, 그 헬퍼가 행의 전이 다이제스트와 비교한다. 완료는 그 헬퍼를 부르지 않는다.
갱신 경로의 주석이 왜 그래야 하는지 적는다. 임대 유효 기간이 갱신이 결정한 것의 일부이므로 다이제스트에 들어가고, 그것이 없으면 다른 임대를 요청한 재시도가 이미 적용된 갱신으로 확인된다는 것이다.
레디스 구현도 같은 자리에서 멈춘다. 완료 재생에서 응답 페이로드만 비교하고, 전이 스크립트는 이미 목표 상태이면 재생 창을 다시 걸기 전에 반환한다. 레디스에는 인자 비교라는 개념 자체가 없다. 한 구현의 누락이 아니라 포트가 정하지 않은 자리다.
갈리는 조건은 좁다. 멱등성 실행기 쪽은 주입 시점의 재생 창을 끝까지 들고 간다. 창이 갈리는 조건은 둘이다. 설정 변경 뒤의 재시도이거나, 이 포트를 직접 부르는 별도 호출자다. 원본 분석이 이것을 만료 해석 불일치와 달리 P2 로 둔 자리도 거기다.
고치려면 주의가 필요하다. 헬퍼는 어긋났다는 답만 주고, 응답 때문인지 창 때문인지는 말하지 않는다. 완료가 지금 돌려주는 응답 충돌을 그대로 두려면, 헬퍼의 어긋남 판정 뒤에 다이제스트 비교를 한 번 더 넣어 두 경우를 갈라야 한다.
## 검증 환경
OpenJDK : 21.0.12
데이터베이스 : PostgreSQL 16.15, 실제 실행
확인 방식 : 다섯 전이의 재생 판정 경로 대조, 실제 PostgreSQL 에서 같은 응답에 두 재생 창으로 완료 호출, 형제 구현 대조
소스 수정 : x
## 재현 조건
1. 완료의 재생 분기와 첫 완료의 다이제스트 인자를 나란히 읽는다.
2. 같은 파일의 공용 재생 판정 헬퍼와 그것이 읽는 행 열, 그리고 그 열의 마이그레이션을 확인한다.
3. PostgreSQL 을 띄우고 청구와 시작을 거쳐 1시간으로 완료한 뒤, 같은 연산·같은 응답에 9시간으로 다시 완료한다.
4. 두 번째 결과와 행의 재생 창, 그리고 전이 다이제스트를 본다.
## 본문
<!-- body:start -->
완료는 두 가지를 정한다. 무엇을 응답으로 남길지, 그리고 그 응답을 언제까지 재생할지다.
## 같은 응답에 다른 창을 넣으면
:::evidence key="a05-f021-complete-replayttl-postgres" alt="실제 PostgreSQL 컨테이너를 세우고 이 리프의 마이그레이션 세 스트림을 적용한 뒤, 같은 연산과 같은 응답에 재생 창만 1시간과 9시간으로 바꿔 완료를 두 번 부르고 각 호출의 결과와 행에 저장된 재생 창, 그리고 전이 다이제스트가 그대로인지를 출력한 터미널 기록." caption="PostgreSQL 16.15 에 마이그레이션 적용 · 1시간으로 완료 뒤 저장 3600초 · 같은 응답에 9시간을 넣은 둘째 완료는 이미 완료된 같은 결과 · 저장은 3600초 그대로, 전이 다이제스트도 그대로 — 8줄 · exit 0" zoom="true"
:::
```text
첫 완료 (재생 창 1시간) : COMPLETED
저장된 재생 창(초) : 3600
둘째 완료 (재생 창 9시간) : ALREADY_COMPLETED_SAME_RESULT
저장된 재생 창(초) : 3600
전이 다이제스트 그대로인가 : true
```
두 번째 호출은 다른 인자를 들고 왔는데 같은 결과로 확인됐다.
## 두 번째 완료가 비교하는 것
:::evidence key="a05-f021-complete-replayttl" alt="이미 완료된 같은 연산의 재생 분기가 비교하는 값, 첫 완료가 전이 다이제스트에 넣는 인자와 그 위 주석, 같은 파일의 공용 재생 판정 헬퍼와 그것이 읽는 행 열과 그 열의 마이그레이션, 그 헬퍼를 쓰는 네 전이와 완료 본문에서의 호출 수, 재생 창이 다르면 완료 다이제스트가 다르다는 단위 시험, 갱신 경로의 주석, 출하 통합 시험이 덮는 인자 재생과 완료 호출 수, 그리고 레디스 구현의 완료 재생 분기와 전이 스크립트를 출력한 터미널 기록." caption="재생 분기는 응답 다이제스트만 비교 · 첫 완료는 재생 창을 다이제스트에 넣음 · 헬퍼는 행의 전이 다이제스트를 비교하고 그 열은 마이그레이션에 있음 · 그 헬퍼를 쓰는 전이 넷, 완료 0 · 창이 다르면 다이제스트가 다르다는 단위 시험 · 레디스도 응답만 비교 — 81줄 · exit 0" zoom="true"
:::
```java
if (row.state() == IdempotencyState.COMPLETED
&& "COMPLETE".equals(row.lastTransitionKind())
&& operationId.value().equals(row.lastTransitionOperationId())) {
return responseDigest.equals(row.responseDigest())
? IdempotencyCompleteOutcome.ALREADY_COMPLETED_SAME_RESULT
: IdempotencyCompleteOutcome.RESPONSE_CONFLICT;
}
```
## 첫 완료가 다이제스트에 넣는 것
```java
// The transition digest, not the response digest. Reusing the response digest here made
// two completions of different operations with identical payloads indistinguishable,
// and lost the replay window the completion also decided.
transitionDigest(
"COMPLETE",
operationId,
owner,
responseDigest,
Long.toString(replayTtl.toMillis())),
```
주석은 응답 다이제스트를 쓰던 때에 무엇을 잃었는지 과거형으로 적는다. 재생 창도 그 목록에 있다.
## 값이 다르다는 것은 이미 시험이 고정한다
같은 모듈의 단위 시험에 완료 다이제스트가 재생 창에 따라 달라진다는 것을 고정하는 시험이 있다.
```java
assertThat(IdempotencyDigestPolicy.of("COMPLETE", "op-1", "owner-1", 1, 3, "digest-a", "60000"))
.isNotEqualTo(
IdempotencyDigestPolicy.of("COMPLETE", "op-1", "owner-1", 1, 3, "digest-a", "90000"));
```
값은 계산되고, `last_transition_result_digest` 열에 저장되고, 시험으로 지켜진다. 재생 판정만 그것을 읽지 않는다.
## 공용 헬퍼와 그것을 쓰는 네 전이
```java
if (!transitionKind.equals(row.lastTransitionKind())
|| !operationId.value().equals(row.lastTransitionOperationId())) {
return ReplayVerdict.NOT_A_REPLAY;
}
return expectedDigest.equals(row.lastTransitionResultDigest())
? ReplayVerdict.SAME_ARGUMENTS
: ReplayVerdict.DIFFERENT_ARGUMENTS;
```
```text
182: switch (replayVerdict(row, "START", operationId, startDigest)) {
221: switch (replayVerdict(row, "RENEW", operationId, renewDigest)) {
319: switch (replayVerdict(row, transitionKind, operationId, failDigest)) {
366: switch (replayVerdict(row, "RELEASE", operationId, releaseDigest)) {
```
완료 본문에서 그것을 부르는 줄은 0 이다.
## 갱신 경로의 주석
```text
216: // The lease TTL is part of what a renewal decided, so it is part of the digest. Without it, a
217: // retry asking for a different lease was confirmed as the renewal already applied, and the
218: // caller went on believing it held the record for longer than the row says it does.
```
재생 창도 호출자가 나중에 읽는 지속 상태다.
## 출하 통합 시험이 덮는 인자 재생
같은 연산 식별자에 다른 보존 기간이 오면 충돌이라는 시험, 다른 임대가 오면 충돌이라는 시험, 같은 인자면 확인이라는 시험이 있다. 완료를 두 번 부르는 시험은 그 파일에 없다.
## 레디스 구현도 같은 자리에서 멈춘다
```java
case "ALREADY" ->
reply.payload().equals(response.payload())
? IdempotencyCompleteOutcome.ALREADY_COMPLETED_SAME_RESULT
: IdempotencyCompleteOutcome.RESPONSE_CONFLICT;
```
전이 스크립트는 이미 목표 상태이면 재생 창을 다시 걸기 전에 반환한다. 레디스에는 인자 비교라는 개념 자체가 없으므로, 이것은 한 구현의 누락이 아니라 포트가 정하지 않은 자리다.
## 언제 갈리는가
애플리케이션의 멱등성 실행기는 주입받은 재생 창 하나를 계속 쓴다. 창이 달라지려면 설정이 바뀐 뒤 재시도가 넘어오거나, 이 포트를 직접 부르는 다른 호출자가 있어야 한다.
## 고칠 방향
완료의 재생 분기도 완료 전이 다이제스트를 먼저 계산해 공용 헬퍼에 넘기면 창이 다른 호출을 걸러낼 수 있다.
다만 그 헬퍼의 판정은 응답이 달라서 어긋난 경우와 창이 달라서 어긋난 경우를 구분하지 않는다. 지금 완료가 돌려주는 응답 충돌을 유지하려면, 헬퍼가 어긋났다고 답한 뒤 응답 다이제스트를 한 번 더 비교해 두 답을 나눠야 한다.
## 확인하지 못한 것
첫 창이 남은 뒤 실제 재생 요청이 어떻게 처리되는지는 관측하지 않았다. 확인한 것은 두 번째 완료의 답과 행에 남은 값까지다.
<!-- body:end -->
@@ -0,0 +1,181 @@
---
kind: CASE
slug: a05-f025-filequotaservice-commit
title: 만료 조건이 연장에는 있고 확정에는 없다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a05-f025-filequotaservice-commit
evidenceCapturedOn: 2026-09-02
assets:
- key: a05-f025-filequotaservice-commit
file: ../../../final/evidence/rendered/a05-f025-filequotaservice-commit.svg
- key: a05-f025-filequotaservice-commit-postgres
file: ../../../final/evidence/rendered/a05-f025-filequotaservice-commit-postgres.svg
evidence:
- ../../../final/evidence/raw/a05-f025-filequotaservice-commit.txt
- ../../../final/evidence/raw/a05-f025-filequotaservice-commit-postgres.txt
source:
- 원본 분석 절은 `analysis/05-adapter-outbound-persistence-jpa.md` §81 이다. 등급은 P2 이고 판정 문구는 프로덕션 API 계약 결함이다. 확정 질의에 만료 조건이 없다는 판정과 그 실행 탐침, 그리고 게이트웨이의 정산 경로가 수정 경계라는 지적이 그 절에 있다.
- 포트의 네 메서드 중 확정을 부르는 프로덕션 호출자가 0 이라는 것, 저장소 인터페이스 javadoc 의 두 절이 어긋난다는 것, 정산 행의 만료 시각이 생성 시각과 같다는 것은 이 기록에서 확인했다.
---
# 만료 조건이 연장에는 있고 확정에는 없다
저장소 인터페이스의 javadoc 은 연장과 확정과 해제가 모두 예약이 아직 살아 있기를 요구하므로 만료로 회수된 예약은 되살릴 수 없다고 적는다. 확정 질의에는 만료 조건이 없고, 실제 PostgreSQL 에서 만료된 예약을 확정하면 1행이 바뀐다. 다만 그 확정 메서드를 부르는 프로덕션 호출자는 없다.
## 관계
- **조건부 update로 행을 claim하고 읽은 값으로 판단하지 않는다**
읽은 값으로 판단하지 말고 조건부 갱신의 결과로 판단하라는 규칙이다.
- **만료된 claim과 만료된 실행은 다르게 다뤄야 한다**
만료 처리를 갈라야 하는 이유다.
- **리스가 만료 시각만 기록하고 소유자를 기록하지 않아 최종 상태를 되돌릴 수 있었다**
같은 리프 계열의 만료 처리 사례다.
## 문제
JpaFileQuotaService 는 클래스 javadoc 에서 네 연산이 조건부 문장이므로 만료되거나 해제된 예약은 연장도 확정도 될 수 없다고 선언한다.
저장소 인터페이스 javadoc 쪽은 여기서 더 나아간다. 연장과 확정과 해제가 예약이 기대한 버전에서 아직 예약됨 상태이기를 요구하므로 만료로 회수된 예약은 되살릴 수 없다는 것이다.
## 결론
연장 질의에는 expiresAt > :now 가 있다. 확정 질의의 조건은 예약 식별자와 상태뿐이다. 만료와 해제 두 사유에 연장과 확정 두 연산을 곱한 네 조합 중 0행을 돌려주지 않는 것은 만료된 예약의 확정 하나다.
저장소 javadoc 의 다른 절반도 어긋난다. 세 질의 어느 시그니처에도 버전 파라미터가 없다.
만료된 사실이 어디에도 기록되지 않는다. 예약 상태 enum 이 만료됨을 선언해 두었는데 main 에서 그 값을 쓰지 않고, 낡은 예약을 정리 대상으로 삼는 코드도 없다. 만료된 예약은 계속 예약됨으로 남으므로 상태만 보는 질의는 둘을 구분할 방법이 없다.
실제 PostgreSQL 16 에서 만료 시각이 한 시간 전인 예약을 만들고 두 질의를 돌렸다. 연장은 0행, 확정은 1행이다. 그 행은 확정됨이 되고 바이트가 기록된다.
그 확정을 부르는 프로덕션 호출자는 없다. 포트의 네 메서드 중 main 코드가 부르는 것은 예약과 해제 둘뿐이다. 업로드 확정이 실제로 지나는 것은 별도 게이트웨이이고, 그 게이트웨이는 살아 있는 예약만 이 질의에 넘긴다. 그래서 결함은 포트 계약과 그 구현 쪽에 있다.
수정에는 경계가 있다. 살아 있는 예약이 없으면 게이트웨이는 사용량을 새 행으로 만들고 바로 확정한다. 그 행의 만료 시각이 생성 시각이므로, 확정 질의에 expiresAt > :now 를 무조건 붙이면 등호 하나 차이로 이 행만 걸린다. 조회와 확정이 같은 시각을 쓰기 때문에 살아 있는 예약 쪽은 걸리지 않는다.
걸렸을 때 나타나는 결과가 조용하다. 확정의 반환값을 게이트웨이가 받지 않아서, 행은 만들어지고 확정만 0행으로 끝난다. 남은 행은 만료된 예약됨이라 예약 합계에도 확정 합계에도 잡히지 않는다.
## 검증 환경
OpenJDK : 21.0.12
데이터베이스 : PostgreSQL 16.15, 실제 실행
확인 방식 : 두 질의의 조건 대조, 포트 호출자 계수, 실제 PostgreSQL 에 만료된 예약과 정산 행을 만들어 질의 실행
소스 수정 : x
## 재현 조건
1. 서비스와 저장소 인터페이스의 javadoc 을 나란히 읽는다.
2. 연장 질의와 확정 질의의 조건, 그리고 세 질의의 시그니처를 확인한다.
3. 예약 상태 enum 의 만료됨을 쓰는 코드를 센다.
4. 포트의 네 메서드를 부르는 main 소스 호출자를 각각 센다.
5. 실제 PostgreSQL 에 파일서버 마이그레이션을 적용하고 만료 시각이 과거인 예약을 만든다.
6. 두 질의를 그 행에 돌려 바뀐 행 수와 최종 상태를 본다.
7. 게이트웨이가 살아 있는 예약이 없을 때 만드는 행에 만료 조건을 붙인 확정을 돌려 본다.
## 본문
<!-- body:start -->
`JpaFileQuotaService` 는 클래스 javadoc 첫 문단에서 네 연산의 조건을 선언한다.
```text
Reservation, extension, commit, and release are conditional statements, so a reservation
that already expired or was released can never be extended or committed.
```
## 연장에는 있고 확정에는 없는 조건
:::evidence key="a05-f025-filequotaservice-commit" alt="서비스와 저장소 인터페이스의 javadoc, 세 질의 시그니처의 버전 파라미터 수, 연장 질의와 확정 질의 전문, 예약 상태 enum 의 만료됨을 쓰는 코드와 낡은 예약을 정리 대상으로 넣는 코드 수, 포트의 네 메서드를 부르는 프로덕션 호출자 수와 실제 호출 두 줄, 프로덕션 확정이 지나는 게이트웨이와 그 조회 조건, 살아 있는 예약이 없을 때 만드는 행, 그리고 그 행을 만드는 생성자의 인자 순서를 출력한 터미널 기록." caption="두 javadoc 의 선언 · 세 질의에 버전 파라미터 0 · 연장에는 만료 조건, 확정에는 없음 · EXPIRED 를 쓰는 코드 0 · 포트 호출자는 예약과 해제뿐, 확정 0 · 게이트웨이는 살아 있는 예약만 넘김 — 93줄 · exit 0" zoom="true"
:::
같은 리프의 저장소 인터페이스 javadoc 은 한 걸음 더 나간다.
```text
* <p>Extend, commit, and release all require the reservation to still be {@code RESERVED} at the
* expected version, so a reservation reclaimed by expiry cannot be resurrected.
```
두 절이 다 어긋난다. 세 질의 어느 시그니처에도 버전 파라미터가 없고, 만료로 회수됐어야 할 예약을 되살리는 것이 바로 확정 질의다.
연장 질의에는 만료 조건이 있다.
```sql
where q.reservationId = :reservationId
and q.status = 'RESERVED'
and q.expiresAt > :now
```
확정 질의의 조건은 둘뿐이다.
```sql
where q.reservationId = :reservationId
and q.status = 'RESERVED'
```
만료는 상태로 남지 않는다. 예약 상태 enum 에 `EXPIRED` 가 선언되어 있지만 그 값을 쓰는 main 코드가 없고, 낡은 예약을 정리 대상으로 넣는 코드도 없다. 만료된 예약은 계속 `RESERVED` 다.
## 만료된 예약에서 0행과 1행이 갈린다
:::evidence key="a05-f025-filequotaservice-commit-postgres" alt="실제 PostgreSQL 컨테이너에 파일서버 마이그레이션을 적용해 쿼터 예약 테이블의 상태와 만료 열을 확인하고, 만료 시각이 한 시간 전인 예약에 저장소의 연장 질의와 확정 질의를 각각 돌려 바뀐 행 수와 최종 상태를 본 결과, 그리고 게이트웨이가 만드는 정산 행과 같은 모양의 행에 만료 조건을 붙인 확정을 돌린 결과를 출력한 터미널 기록." caption="PostgreSQL 16.15 에 파일서버 마이그레이션 적용 · 만료된 예약에 연장 0행, 확정 1행 · 결과는 COMMITTED 600 · 만료 시각이 생성 시각인 행에 조건을 붙이면 0행 — 11줄 · exit 0" zoom="true"
:::
```text
만료된 예약을 하나 만든다 (expires_at = 한 시간 전)
연장 질의가 바꾼 행 : 0
확정 질의가 바꾼 행 : 1
결과 행 : status=COMMITTED committed_bytes=600
```
저장소 질의만 놓고 보면 두 javadoc 이 금지한 전이가 그대로 일어난다.
## 다만 그 질의에 만료된 행을 넘기는 호출자가 없다
포트의 네 메서드 중 main 소스가 부르는 것은 둘이다.
```text
DefaultUploadApplicationService.java:128 quotaService.reserve(scope, reservationBytes, uploadPolicy.reservationTtl());
DefaultUploadApplicationService.java:157 quotaService.release(created.reservation());
```
확정과 연장은 0곳이다. 업로드 확정이 실제로 지나는 것은 `JpaQuotaCommitGateway` 이고, 그 게이트웨이는 살아 있는 예약을 먼저 조회한다. 그 조회에 만료 조건이 이미 들어 있다.
```sql
and q.status = 'RESERVED'
and q.expiresAt > :now
```
그래서 이 결함은 포트 계약과 그 구현에 있고, 오늘의 업로드 경로에서 관측되는 사건은 아니다.
## 정산 행의 만료 시각은 생성 시각이다
게이트웨이는 살아 있는 예약이 없으면 사용량을 새 행으로 만들어 곧바로 확정한다. 업로드가 유효 기간보다 오래 걸렸더라도 실제로 저장된 바이트를 적게 세지 않기 위한 경로다.
```java
QuotaReservationEntity settled =
new QuotaReservationEntity(
UUID.randomUUID(), scope.type(), scope.value(), actualBytes, now, "RESERVED", now);
reservations.save(settled);
reservations.commit(settled.getReservationId(), actualBytes, now);
```
생성자의 다섯째 인자가 만료 시각이고 거기 들어간 값이 `now` 다. 저장과 확정이 같은 `now` 를 쓰므로 이 행은 `expiresAt > :now` 를 등호 하나 차이로 통과하지 못한다.
같은 이유로 살아 있는 예약을 확정하는 쪽은 엄격한 조건을 붙여도 통과한다. 조회가 이미 같은 `now` 로 걸러 냈기 때문이다. 걸리는 것은 정산 행 하나다.
```text
게이트웨이의 정산 행은 expires_at = now 로 만들어진다
만료 조건을 붙인 확정이 그 행을 바꾼 수 : 0
```
걸렸을 때 결과는 조용하다. 게이트웨이는 확정의 반환값을 받지 않으므로 행은 만들어지고 확정만 0행이 된다. 남은 행은 만료된 `RESERVED` 라서 예약 합계는 만료 조건에 걸려 세지 않고, 확정 합계는 상태가 달라 세지 않는다. 저장된 바이트가 장부 어디에도 잡히지 않는다.
## 고칠 방향
`commit` 하나가 두 의미를 겸하고 있다. 저장소에 만료 조건을 건 확정 문과 걸지 않은 정산 문을 따로 두고, 포트도 확정과 정산으로 나눈다. 지금은 정산이 확정과 같은 문을 쓰기 때문에 조건 하나를 고치면 다른 쪽이 깨진다.
## 확인하지 못한 것
정산 행이 예약됨으로 남았을 때 회수되는지는 확인하지 않았다. 낡은 예약을 정리 대상으로 넣는 코드가 없다는 것까지만 봤다.
<!-- body:end -->
@@ -0,0 +1,171 @@
---
kind: CASE
slug: a05-f027-maximum-attempts
title: reclaimExpiredClaim이 MAXIMUM_ATTEMPTS를 보지 않는다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a05-f027-maximum-attempts
evidenceCapturedOn: 2026-09-02
assets:
- key: a05-f027-maximum-attempts
file: ../../../final/evidence/rendered/a05-f027-maximum-attempts.svg
- key: a05-f027-maximum-attempts-reclaim
file: ../../../final/evidence/rendered/a05-f027-maximum-attempts-reclaim.svg
evidence:
- ../../../final/evidence/raw/a05-f027-maximum-attempts.txt
- ../../../final/evidence/raw/a05-f027-maximum-attempts-reclaim.txt
source:
- 원본 분석 절은 `analysis/05-adapter-outbound-persistence-jpa.md` §82.1 이다. 등급은 P2 이고, 회수 문장에 종료 조건이 없다는 판정과 리스 만료를 최대값보다 많이 반복한 탐침, 그리고 회수가 배치 시작에 먼저 불린다는 관찰이 그 절에 있다.
- 회수 질의 javadoc 의 원문, 저장소가 `attempt` 를 한 번도 비교하지 않는다는 것, 회수 래퍼가 시계를 다시 읽어 같은 배치의 재청구를 막는다는 것은 이 기록에서 덧붙였다.
---
# reclaimExpiredClaim이 MAXIMUM_ATTEMPTS를 보지 않는다
회수 질의의 javadoc 은 매번 죽는 작업자의 항목도 정상 실패와 같은 재시도 예산에 묶이며 영원히 회수되지는 않는다고 적는다. 다섯 줄 아래 질의는 시도를 올리기만 하고 그 예산을 걸지 않는다. 실제 PostgreSQL 에서 아홉 번 반복하면 시도가 아홉이 되고 상태는 여전히 청구 가능한 실패다.
## 관계
- **만료된 claim과 만료된 실행은 다르게 다뤄야 한다**
만료 처리를 갈라야 하는 이유다.
- **fenced lease — 만료 시각만으로는 부족한 이유**
만료 시각만으로는 회수한 항목의 소유자를 가릴 수 없다고 적은 문서다.
- **재시도 가능성은 멱등성과 실패 범주를 함께 봐야 정해진다**
재시도 예산이 어디서 강제되는지의 문제다.
## 문제
큐의 계약은 클래스 javadoc 에 있다. 계속 실패하는 항목은 영원히 재시도되는 대신 결국 포기된다.
그 계약이 크래시 경로까지 덮는다는 것은 회수 질의 javadoc 이 명시한다. 항목은 대기 중이 아니라 실패로 돌아오고 시도 계수가 오르므로, 매번 죽는 작업자의 항목도 곧바로 실패하는 항목과 같은 예산에 묶이며 영원히 회수되지는 않는다는 것이다.
## 결론
그 문장 다섯 줄 아래 질의에는 예산이 없다. 저장소가 attempt 에 하는 일은 두 자리에서 하나씩 올리는 것뿐이고, 한 번도 비교하지 않는다.
예산을 끊는 코드가 있는 곳은 한 군데다. 정상 실패 경로가 다음 시도를 여덟과 비교해 포기 상태로 넘긴다.
회수 대상 선정 질의에도 시도 한계 조건이 빠져 있다. 그 조회가 고르는 것은 리스가 만료된 진행 중 행이다.
실제 PostgreSQL 16 에서 청구 문장과 회수 문장을 아홉 번 반복했다. 회차마다 시도가 하나씩 올라 아홉이 되고, 상태는 매번 실패다.
되풀이의 속도는 느리다. 리스가 10분이라 청구된 항목은 그동안 처리 대상 조회에 보이지 않고, 회수된 뒤에도 곧바로 돌아오지 않는다. 배치는 시각을 한 번 잡아 회수와 청구에 같이 쓰는데, 회수 래퍼는 그 시각 대신 시계를 다시 읽어 다음 시도 시각에 넣는다. 처리 대상 조회가 그 시각을 넘지 않은 행만 고르므로 회수된 항목은 다음 배치로 넘어간다.
굶주림이 아니라 종료가 없다는 것이 문제다. 시도가 아홉이 되고 열이 되어도 종료 상태로 가지 않는다.
되풀이가 유지되려면 작업자가 매번 예외를 던지지 않고 죽어야 한다. 예외를 던지면 정리 서비스가 그것을 잡아 정상 실패 경로로 보내고, 시도가 이미 여덟을 넘었으므로 그 한 번에 포기로 끝난다.
## 검증 환경
OpenJDK : 21.0.12
데이터베이스 : PostgreSQL 16.15, 실제 실행
확인 방식 : 저장소가 attempt 에 하는 일 전수 확인, 예산 전환 지점 계수, 실제 PostgreSQL 에서 청구와 회수 반복
소스 수정 : x
파일서버 플랫폼 스위치와 정리 스위치가 모두 참인 배포에서 프로덕션 경로다. 고정 지연 스케줄러가 배치를 돌리고, 정리 서비스의 세 지점이 정상 실패 경로를 실제로 탄다. 두 스위치의 출하 기본값은 거짓이다.
## 재현 조건
1. 회수 질의의 javadoc 과 그 아래 질의를 나란히 읽는다.
2. 저장소 전체에서 attempt 가 나오는 줄을 전부 뽑는다. 올리는 두 자리와 javadoc 뿐이다.
3. 정리 항목 행에 포기 상태를 쓰는 코드를 코드베이스에서 센다.
4. 회수 대상을 고르는 조회와 다시 청구되는 조회의 조건을 확인한다.
5. 회수 래퍼가 다음 시도 시각에 넣는 값이 배치가 잡아 둔 시각인지 확인한다.
6. 실제 PostgreSQL 에 마이그레이션을 적용하고 청구와 회수를 아홉 번 반복해 시도와 상태를 본다.
## 본문
<!-- body:start -->
정리 큐가 스스로 적어 둔 계약은 계속 실패하는 항목이 영원히 재시도되는 대신 결국 포기된다는 것이다.
회수 질의의 javadoc 은 그 계약이 크래시 경로에도 적용된다고 못박는다.
```text
* <p>The item comes back as FAILED rather than PENDING, and its attempt counter advances. An item
* whose worker dies every time is then bounded by the same retry budget as one that fails
* outright, instead of being reclaimed forever.
```
## 그 아래 다섯 줄에 예산이 없다
:::evidence key="a05-f027-maximum-attempts" alt="회수 질의의 javadoc 과 질의 전문, 저장소 전체에서 attempt 가 나오는 줄, 정리 항목에 포기 상태를 쓰는 코드와 정상 실패 경로의 비교, 회수 대상을 고르는 조회와 다시 청구되는 조회의 조건, 회수 래퍼가 다음 시도 시각에 넣는 값과 배치가 시각을 한 번 잡는 구간과 리스 길이, 정상 실패 경로가 불리는 지점, 그리고 스케줄러 배선과 두 스위치의 출하 기본값을 출력한 터미널 기록." caption="회수 javadoc 은 같은 예산에 묶인다고 적음 · 저장소는 attempt 를 두 자리에서 올리기만 함 · ABANDONED 전환은 markFailed 한 곳 · 회수 대상 조회에도 한계 없음 · 회수 래퍼는 시계를 다시 읽음 · 리스 10분 · 두 스위치 기본값 false — 113줄 · exit 0" zoom="true"
:::
저장소가 `attempt` 에 하는 일은 두 자리에서 하나씩 올리는 것뿐이다.
```text
68: c.attempt = c.attempt + 1, ← 정상 실패 정산
121: c.attempt = c.attempt + 1, ← 크래시 회수
```
한 번도 비교하지 않는다. 예산을 끊는 코드는 코드베이스에 한 군데다.
```java
boolean exhausted = item.attempt() + 1 >= MAXIMUM_ATTEMPTS;
```
회수 대상을 고르는 조회에도 시도 한계가 없다. 그 조회는 리스가 만료된 진행 중 행만 고른다.
## 실제 PostgreSQL 에서 아홉 회차
:::evidence key="a05-f027-maximum-attempts-reclaim" alt="실제 PostgreSQL 컨테이너에 마이그레이션을 적용한 뒤 저장소의 청구 문장과 회수 갱신 문장을 아홉 번 반복하며 회차마다 시도 횟수와 상태와 마지막 오류 코드를 출력한 터미널 기록. 회수 대상을 고르는 조회는 실행하지 않았고 시계 출처만 데이터베이스로 바꿨다는 단서가 함께 적혀 있다." caption="PostgreSQL 16.15 · 최대 시도 상수 8 · 청구와 회수 갱신을 9회 반복 · 시도는 1부터 9까지 오르고 상태는 매번 FAILED · 회수 대상 조회는 태우지 않음 — 15줄 · exit 0" zoom="true"
:::
```text
최대 시도 횟수 상수 : 8
...
9 회차 후: 시도 9 상태 FAILED 마지막 오류 CLAIM_LEASE_EXPIRED
```
## 되풀이는 느리다. 다만 끝나지 않는다
되돌아간 실패는 청구가 다시 받는 상태다. 다만 관문이 하나 더 있다.
```sql
where c.status in ('PENDING', 'FAILED')
and c.nextAttemptAt <= :now
order by c.nextAttemptAt asc
```
배치는 시각을 한 번 잡아 회수와 청구에 같이 쓴다. 그런데 회수 래퍼는 그 시각 대신 시계를 다시 읽어 넘긴다.
```java
reclaimed +=
items.reclaimExpiredClaim(
abandoned.getCleanupId(), abandoned.getClaimToken(), clock.instant());
```
그래서 회수된 항목의 다음 시도 시각은 배치가 잡아 둔 시각보다 뒤이고, 그 배치의 청구 조회에서 탈락한다. 서비스 주석은 회수를 먼저 도는 이유로 회수된 항목이 같은 배치에서 곧바로 대상이 된다는 것을 들지만, 실제로는 다음 배치에 가서야 대상이 된다.
리스도 10분이다. 청구된 항목은 그동안 처리 대상 조회에 보이지 않는다. 그리고 회수 경로에는 백오프가 없다. 다음 시도 시각을 회수 시각으로 그냥 되돌린다. 주기를 정하는 것은 백오프가 아니라 리스다.
javadoc 이 일어나지 않게 하겠다고 적은 상황은 성공하지 못하는 항목이 매 배치의 자리를 차지하는 것이다. 여기서 일어나는 것은 그보다 느리다. 문제는 굶주림이 아니라 끝나지 않는 것이다.
## 되풀이가 유지되는 조건
작업자가 매번 예외를 던지지 않고 죽어야 한다. 예외를 던지면 정리 서비스가 그것을 잡아 정상 실패 경로로 보낸다.
```java
} catch (RuntimeException failure) {
markFailed(item, "CLEANUP_ATTEMPT_FAILED", now);
```
시도가 이미 여덟을 넘었으므로 그 한 번에 포기로 끝난다. 예산 우회가 이어지려면 작업자가 조용히 사라져야 한다.
## 이 경로는 배선되어 있다
스케줄러가 고정 지연으로 배치를 부르고, 정상 실패 경로도 정리 서비스의 세 지점에서 실제로 불린다. 파일서버 플랫폼 스위치와 정리 스위치의 출하 기본값은 둘 다 거짓이므로, 둘 다 켠 배포에서 프로덕션 경로다.
## 고칠 방향
두 경로가 같은 예산을 봐야 한다. 회수 문장이 증가 후 값을 검사해 한계에서 포기로 넘기는 것이 가장 작은 변경이고, 저장소가 다음 상태를 호출자에게서 받는 쪽이 더 곧다. 그 경우 비교 교환이 토큰과 시도를 함께 봐야 회수와 정산이 같은 행을 두고 엇갈리지 않는다.
회귀는 두 경로를 섞어도 총합이 예산을 넘으면 반드시 포기로 끝나는지를 고정해야 한다.
## 확인하지 못한 것
실제 작업자 크래시로 재현하지 않았다. 탐침은 저장소의 세 질의 중 청구와 회수 갱신 둘만 네이티브 SQL 로 옮겨 반복했고, 회수 대상을 고르는 조회는 실행하지 않았다. 그 조회에도 시도 한계가 없다는 것은 질의를 읽어 확인했다.
<!-- body:end -->
@@ -0,0 +1,40 @@
---
kind: CONCEPT
slug: adapter-inbound-graphql-c12
title: 기계가 읽는 매니페스트와 사람이 읽는 등급표가 다르게 답한다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-inbound-graphql-c12
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-inbound-graphql-c12
file: ../../../final/evidence/rendered/adapter-inbound-graphql-c12.svg
evidence:
- ../../../final/evidence/raw/adapter-inbound-graphql-c12.txt
source:
- 원본 분석 절은 analysis/16-adapter-inbound-graphql.md#L867 이다.
module: adapter-inbound-graphql
---
# 기계가 읽는 매니페스트와 사람이 읽는 등급표가 다르게 답한다
`GraphQlStableCapabilityManifest.STABLE``SIGNED_CURSOR_CONNECTION`이 들어 있는데 `CLAUDE.md` 등급표는 cursor 서명을 `modelled`(요청 경로에 없음)로 매긴다.
## 본문
<!-- body:start -->
`GraphQlStableCapabilityManifest.STABLE``SIGNED_CURSOR_CONNECTION`이 들어 있다. `CLAUDE.md` 등급표는 cursor 서명을 `modelled`(요청 경로에 없음)로 매긴다.
## GraphQlStableCapabilityManifest 참조 위치
:::evidence key="adapter-inbound-graphql-c12" alt="코드베이스에서 GraphQlStableCapabilityManifest 를 검색한 출력 11줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GraphQlStableCapabilityManifest 코드베이스 검색 — 11줄 · exit 0" zoom="true"
:::
## 용도가 달라도 답이 갈리는 것은 남는다
두 목록의 용도가 다르다 — 매니페스트는 `requireStable(capability)`로 **릴리스 게이트가 소비하는 기계 판정**이고, 등급표는 사람이 읽는 공시다. 그러나 같은 능력에 대해 하나는 "Stable에서 지원"이라 하고 하나는 "요청 경로에 없음"이라 한다. §29.2.
<!-- body:end -->
@@ -0,0 +1,40 @@
---
kind: CONCEPT
slug: adapter-inbound-graphql-c13
title: 릴리스 게이트의 미배선은 정상이고 작성기의 호출자 부재는 다르다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-inbound-graphql-c13
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-inbound-graphql-c13
file: ../../../final/evidence/rendered/adapter-inbound-graphql-c13.svg
evidence:
- ../../../final/evidence/raw/adapter-inbound-graphql-c13.txt
source:
- 원본 분석 절은 analysis/16-adapter-inbound-graphql.md#L873 이다.
module: adapter-inbound-graphql
---
# 릴리스 게이트의 미배선은 정상이고 작성기의 호출자 부재는 다르다
`release` 9개 파일 전부 autoconf=0이다. 릴리스 게이트는 빌드·릴리스 시점 도구이므로 런타임 미배선이 정상이다.
## 본문
<!-- body:start -->
`release` 9개 파일 전부 autoconf=0이다. 릴리스 게이트는 런타임 컴포넌트가 아니라 빌드·릴리스 시점 도구이므로 정상이다.
## GraphQlReleaseReportWriter 참조 위치
:::evidence key="adapter-inbound-graphql-c13" alt="코드베이스에서 GraphQlReleaseReportWriter 를 검색한 출력 4줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GraphQlReleaseReportWriter 코드베이스 검색 — 4줄 · exit 0" zoom="true"
:::
## 작성기에 호출자가 없다는 것은 별개 사실이다
`GraphQlReleaseReportWriter`(57)는 main_other=0 · test=1로, 게이트 결과를 기록할 작성기에 호출자가 없다. CLAUDE.md가 그 상태를 명시한다 — "`graphqlPerformanceTest` 레인이 자리를 예약, **증거 없으면 릴리스 게이트가 거부**". 즉 게이트는 CI 레인에서 호출되도록 설계됐다.
<!-- body:end -->
@@ -0,0 +1,40 @@
---
kind: CONCEPT
slug: adapter-inbound-web-c09
title: 캐시 헤더를 소유하는 것은 24줄 상수 두 개다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-inbound-web-c09
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-inbound-web-c09
file: ../../../final/evidence/rendered/adapter-inbound-web-c09.svg
evidence:
- ../../../final/evidence/raw/adapter-inbound-web-c09.txt
source:
- 원본 분석 절은 analysis/14-adapter-inbound-web.md#L864 이다.
module: adapter-inbound-web
---
# 캐시 헤더를 소유하는 것은 24줄 상수 두 개다
배선된 `CacheControlFilter``@Component`라 컴포넌트 스캔이 잡고 모든 응답에 `no-store`를 붙인다. 프로파일·지시자·`Vary` 규칙을 갖춘 `cache` 패키지 4파일 310 LOC은 배선되지 않았다.
## 본문
<!-- body:start -->
배선된 쪽은 `CacheControlFilter`다. `@Component`이므로 컴포넌트 스캔이 잡고 Spring이 `Filter` 빈을 체인에 넣는다. **모든 응답에 `no-store`를 붙인다.** 배선되지 않은 것은 `cache` 패키지 4개 파일 310 LOC이고, `web.cache.` 패키지를 참조하는 파일이 자기 패키지 밖에 **0개**다.
## 분석 원문의 두 벌 비교
:::evidence key="adapter-inbound-web-c09" alt="분석 문서 analysis/14-adapter-inbound-web.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/14-adapter-inbound-web.md 발췌 — 15줄" zoom="true"
:::
## 소유자를 지목하는 주석과 실제 소유자
`SecurityConfig:80`이 Spring Security의 기본 캐시 헤더 작성기를 끄면서 그 이유를 적는다 — "`CacheControlFilter` **owns the cache header policy**". 소유자는 24줄짜리 상수 두 개이고, 프로파일·지시자·`Vary` 규칙을 갖춘 310줄은 소유하지 않는다.
<!-- body:end -->
@@ -0,0 +1,40 @@
---
kind: CONCEPT
slug: adapter-inbound-web-c16
title: 선언된 능력 열하나 중 켤 수 있는 것은 둘이다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-inbound-web-c16
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-inbound-web-c16
file: ../../../final/evidence/rendered/adapter-inbound-web-c16.svg
evidence:
- ../../../final/evidence/raw/adapter-inbound-web-c16.txt
source:
- 원본 분석 절은 analysis/14-adapter-inbound-web.md#L1217 이다.
module: adapter-inbound-web
---
# 선언된 능력 열하나 중 켤 수 있는 것은 둘이다
`advanced/**` 전체에서 프로덕션 `@Configuration`은 셋이고 실제 `@ConditionalOnProperty` 접두사는 둘이다. 나머지 아홉에는 프로퍼티도, `@Configuration`도, 빈도 없다.
## 본문
<!-- body:start -->
`advanced/**` 전체에서 프로덕션 `@Configuration`은 셋이고(`MvcStreamingExecutorConfiguration` · `VirtualThreadMvcConfiguration` · `VirtualThreadSettings`) 실제 `@ConditionalOnProperty` 접두사는 둘이다.
## WebAdvancedFeature 참조 위치
:::evidence key="adapter-inbound-web-c16" alt="코드베이스에서 WebAdvancedFeature 를 검색한 출력 5줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="WebAdvancedFeature 코드베이스 검색 — 5줄 · exit 0" zoom="true"
:::
## 프로퍼티도 Configuration도 빈도 없는 아홉
`WEBFLUX_BLOCKING_BRIDGE` · `JSON_MERGE_PATCH` · `JSON_PATCH` · `SSE` · `JSON_SEQUENCE` · `FUNCTIONAL_WEBFLUX` · `CBOR` · `XML` · `RATELIMIT_DRAFT_HEADERS`. §36.1.
<!-- body:end -->
@@ -0,0 +1,36 @@
---
kind: CONCEPT
slug: adapter-inbound-websocket-c05
title: 릴리스 시점 도구라서 런타임 미배선이 정상이다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-inbound-websocket-c05
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-inbound-websocket-c05
file: ../../../final/evidence/rendered/adapter-inbound-websocket-c05.svg
evidence:
- ../../../final/evidence/raw/adapter-inbound-websocket-c05.txt
source:
- 원본 분석 절은 analysis/17-adapter-inbound-websocket.md#L420 이다.
module: adapter-inbound-websocket
---
# 릴리스 시점 도구라서 런타임 미배선이 정상이다
`advanced/release/AdvancedPromotionGate`(120)와 `release/WebSocketStableReleaseGate`(106)가 각각 Advanced 승격과 Stable 릴리스를 판정한다. 둘 다 main 참조 0이고 테스트만 있다.
## 본문
<!-- body:start -->
`advanced/release/AdvancedPromotionGate`(120)와 `release/WebSocketStableReleaseGate`(106, §11)가 각각 Advanced 승격과 Stable 릴리스를 판정한다. 둘 다 main 참조 0이고 테스트만 있다 — 릴리스 시점 도구이므로 런타임 미배선이 정상이다.
## 분석 원문의 판정
:::evidence key="adapter-inbound-websocket-c05" alt="분석 문서 analysis/17-adapter-inbound-websocket.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/17-adapter-inbound-websocket.md 발췌 — 15줄" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,49 @@
---
kind: CONCEPT
slug: adapter-outbound-cache-redis-c08
title: 한쪽에만 적용되는 변경이 구조적으로 불가능하다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-cache-redis-c08
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-cache-redis-c08
file: ../../../final/evidence/rendered/adapter-outbound-cache-redis-c08.svg
- key: adapter-outbound-cache-redis-c08-diagram
file: ../../../final/assets/diagrams/adapter-outbound-cache-redis-c08.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-cache-redis-c08.txt
source:
- 원본 분석 절은 analysis/10-adapter-outbound-cache-redis.md#L456 이다.
module: adapter-outbound-cache-redis
---
# 한쪽에만 적용되는 변경이 구조적으로 불가능하다
`ValueOperationRequests`의 javadoc이 불변식을 적고, 두 구현이 같은 builder를 생성자에서 만들면서 그것을 구조로 보장한다.
## 본문
<!-- body:start -->
`ValueOperationRequests`의 javadoc이 불변식을 적는다 — "Both the blocking and the reactive string operations call exactly these methods, so **a change to a permit, a budget, an encoding, or a command choice cannot apply to one API and not the other.**"
## 두 모델이 공유하는 빌더
:::evidence key="adapter-outbound-cache-redis-c08-diagram" alt="공유 request builder 에서 동기 표면과 반응형 표면으로 각각 화살표가 나가고 화살표에 같은 요청 객체가 붙은 구조" caption="두 모델이 공유하는 빌더" zoom="false"
:::
구조가 그것을 보장한다. `LettuceRedisValueOperations``LettuceReactiveRedisValueOperations`는 둘 다 생성자에서 `new ValueOperationRequests(gateway, context, counters)`를 만들고, 차이는 `SyncRedisCommandExecutor` vs `ReactiveRedisCommandExecutor` 하나뿐이다.
## ValueOperationRequests 참조 위치
:::evidence key="adapter-outbound-cache-redis-c08" alt="코드베이스에서 ValueOperationRequests 를 검색한 출력 10줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ValueOperationRequests 코드베이스 검색 — 10줄 · exit 0" zoom="true"
:::
## 열한 계열 전부에서 확인했다
각 메서드는 `executor.execute(requests.xxx(...))` 한 줄이고, reactive 쪽은 그 위에 `flatMap`/`then` 같은 형태 변환만 얹는다. Value·Hash·List·Set·SortedSet·Key·Geo·Bitmap·Stream·HyperLogLog·PubSub 모두 sync와 reactive 양쪽이 같은 `*OperationRequests`를 생성한다(`162-...` §8.2).
<!-- body:end -->
@@ -0,0 +1,60 @@
---
kind: CONCEPT
slug: adapter-outbound-cache-redis-c10
title: 뒤 단계일수록 비싸도록 검증 순서를 고정했다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-cache-redis-c10
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-cache-redis-c10
file: ../../../final/evidence/rendered/adapter-outbound-cache-redis-c10.svg
- key: adapter-outbound-cache-redis-c10-diagram
file: ../../../final/assets/diagrams/adapter-outbound-cache-redis-c10.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-cache-redis-c10.txt
source:
- 원본 분석 절은 analysis/10-adapter-outbound-cache-redis.md#L560 이다.
module: adapter-outbound-cache-redis
---
# 뒤 단계일수록 비싸도록 검증 순서를 고정했다
`CommandPolicyGuard`의 javadoc이 순서와 이유를 적는다 — 각 단계가 다음 단계보다 싸므로 명백히 부적격한 명령은 인코딩도 전송도 하기 전에 거절된다.
## 본문
<!-- body:start -->
javadoc이 순서와 그 이유를 적는다 — "Validation order is fixed and **each step is cheaper than the one after it**, so an obviously inadmissible command is refused before anything is encoded or sent."
```text
capability → risk/permit provenance → namespace → slot → request budget
→ connection lane → timeout/retry → invocation → reply budget → translation → telemetry
```
## 고정된 검증 순서
:::evidence key="adapter-outbound-cache-redis-c10-diagram" alt="capability 와 위험 등급과 namespace 와 slot 과 요청 예산이 왼쪽에서 오른쪽으로 이어지는 구조" caption="고정된 검증 순서" zoom="false"
:::
각 단계가 구체적이다. `requireReachable``BLOCKED`거나 `access == NONE`이면 거부하고 R3/R4를 애플리케이션 경로에서 배제한다. `requireCapability`는 명령의 최소 버전을 프로브된 서버 버전과 대조한다. `requireNamespace`는 모든 키의 네임스페이스를 확인하고 렌더까지 수행한다. `requireSameSlot`은 Cluster에서 두 개 이상 슬롯이면 `RedisCrossSlotException`**서버를 부르기 전에** 던진다. `effectiveTimeout`은 블로킹 명령이 유한한 server block을 선언하지 않으면 거부하고, 설정 상한을 넘으면 거부하며, 통과하면 `BLOCKING_MARGIN`(2초)을 더한다.
## CommandPolicyGuard 참조 위치
:::evidence key="adapter-outbound-cache-redis-c10" alt="코드베이스에서 CommandPolicyGuard 를 검색한 출력 26줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="CommandPolicyGuard 코드베이스 검색 — 26줄 · exit 0" zoom="true"
:::
## 죽은 중복을 지운 기록
`validateReply(...)`가 있었고 아무도 부르지 않았다.
> "Two mechanisms for one rule, with the more visible one dead, is worse than one: a reader finds the guard's method, assumes replies are bounded during admission, and writes an operation that never bounds its own. **Admission cannot do this job anyway.** The guard runs before the command is sent, so the only reply size available to it is the estimate the request declared. The authority has to sit where the bytes actually arrive."
## 발화하지 못하던 조건을 떼어낸 기록
다중 키 permit 검사가 advanced permit 검사와 한 조건으로 접혀 있었고, "둘 다 없음"이 위에서 이미 던지므로 다중 키 절은 도달 불가였다 — "set algebra over any number of keys was admitted on an advanced permit alone." 지금은 `request.keys().size() > 1 && request.multiKeyPermit().isEmpty()`가 독립 조건이다. test `rejectsAMultiKeyCommandCarryingOnlyAnAdvancedPermit``aSingleKeyAdvancedCommandStillNeedsNoMultiKeyPermit`가 양쪽을 고정한다.
<!-- body:end -->
@@ -0,0 +1,53 @@
---
kind: CONCEPT
slug: adapter-outbound-fileserver-c03
title: stateRevision은 정확히 하나씩만 증가한다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-fileserver-c03
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-fileserver-c03
file: ../../../final/evidence/rendered/adapter-outbound-fileserver-c03.svg
- key: adapter-outbound-fileserver-c03-diagram
file: ../../../final/assets/diagrams/adapter-outbound-fileserver-c03.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-fileserver-c03.txt
source:
- 원본 분석 절은 analysis/08-adapter-outbound-fileserver.md#L184 이다.
module: adapter-outbound-fileserver
---
# stateRevision은 정확히 하나씩만 증가한다
`validateOperationTransition`이 여섯 가지를 순서대로 강제하고, 두 종착 상태에는 후속 전이가 없다.
## 본문
<!-- body:start -->
`validateOperationTransition`이 여섯 가지를 순서대로 강제한다: requestFingerprint 불변 → 불변 identity 10개 필드 불변 → `stateRevision` 감소 금지 → 동일 revision 다른 내용 금지 → **정확히 +1** 증가 → 인접 전이 행렬.
## 두 종착 상태
:::evidence key="adapter-outbound-fileserver-c03-diagram" alt="비terminal 상태에서 PUBLISHED 로 가는 실선 화살표와 QUARANTINED 로 가는 점선 화살표가 있고 두 종착 상태에서 나가는 화살표는 없는 구조" caption="두 종착 상태" zoom="false"
:::
행렬은 README가 적은 사슬과 일치하고, 모든 비terminal 상태에서 `QUARANTINED`로만 이탈할 수 있으며 `PUBLISHED`·`QUARANTINED`는 후속 전이가 없다(`case PUBLISHED, QUARANTINED -> false`).
## 분석 원문의 전이 검사
:::evidence key="adapter-outbound-fileserver-c03" alt="분석 문서 analysis/08-adapter-outbound-fileserver.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/08-adapter-outbound-fileserver.md 발췌 — 15줄" zoom="true"
:::
## 봉인 이후 얼어붙는 사실
`requireSealedFactsUnchanged`가 byteSize·rowCount·columnCount·sha256·formulaMitigatedCount·sealedAt을 고정하고, `MANIFEST_PUBLISHED` 이후에는 `manifestDigest`, `REFERENCE_PUBLISHED` 이후에는 `referenceDigest`도 고정된다.
## 같은 레코드 재기록을 전이가 아니라 복구로 본다
`current.equals(candidate)`는 전이가 아니라 **복구(repair)**로 취급된다 — 부모 디렉터리를 다시 force하고 정확 read-back만 수행한다. 크래시 후 같은 레코드를 다시 쓰는 재시도가 conflict가 되지 않게 하는 처리이고, `parentForcedCallbackFailureIsRepairedByIdenticalOperationRetry`가 이를 고정한다.
<!-- body:end -->
@@ -0,0 +1,44 @@
---
kind: CONCEPT
slug: adapter-outbound-fileserver-c04
title: 배타성이 필요한 쪽에만 OS 락을 둔다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-fileserver-c04
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-fileserver-c04
file: ../../../final/evidence/rendered/adapter-outbound-fileserver-c04.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-fileserver-c04.txt
source:
- 원본 분석 절은 analysis/08-adapter-outbound-fileserver.md#L192 이다.
module: adapter-outbound-fileserver
---
# 배타성이 필요한 쪽에만 OS 락을 둔다
한 클래스 안에 락이 두 종류다. 얼핏 비대칭으로 보이지만 각자의 커밋 방식이 다르다.
## 본문
<!-- body:start -->
한 클래스 안에 락이 두 종류다. 얼핏 비대칭으로 보이지만 각자의 커밋 방식이 다르다 — 배타성이 필요한 쪽(replace)에는 OS 락을 두고, 원시연산 자체가 배타적인 쪽(create-link)에는 JVM 스트라이프만 둔 것이다.
## SecureRandom 참조 위치
:::evidence key="adapter-outbound-fileserver-c04" alt="코드베이스에서 SecureRandom 를 검색한 출력 39줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="SecureRandom 코드베이스 검색 — 39줄 · exit 0" zoom="true"
:::
## root 미포함이 배타 누락으로 이어지지 않는 이유
후자의 root 미포함은 **과잉 직렬화** 방향이라(다른 root의 같은 fileId가 같은 스트라이프를 공유) 배타 누락으로는 이어지지 않고, `fileId``SecureRandom` 16바이트라 실질 충돌도 없다. 결함이 아니라 설계로 기록한다.
## 충돌 경로가 닫히는 방법
`createLink`가 충돌하면 임시 파일을 정확히 지우고, 기존 레코드를 읽어 identity와 내용 동등성을 확인한 뒤 같으면 repair, 다르면 `CONFLICT`다. `concurrentCrossInstanceImmutableCollisionIsNeverClassifiedAsStorage`가 그 분류를 고정한다.
<!-- body:end -->
@@ -0,0 +1,49 @@
---
kind: CONCEPT
slug: adapter-outbound-fileserver-c08
title: 잔여 경로 연산을 선언하고 identity 검사로 감싼다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-fileserver-c08
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-fileserver-c08
file: ../../../final/evidence/rendered/adapter-outbound-fileserver-c08.svg
- key: adapter-outbound-fileserver-c08-diagram
file: ../../../final/assets/diagrams/adapter-outbound-fileserver-c08.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-fileserver-c08.txt
source:
- 원본 분석 절은 analysis/08-adapter-outbound-fileserver.md#L558 이다.
module: adapter-outbound-fileserver
---
# 잔여 경로 연산을 선언하고 identity 검사로 감싼다
`LocalPersistentPayloadOperations`의 클래스 javadoc이 무엇이 서술자 상대이고 무엇이 아닌지를 앞에서 밝히고, 전수 검사가 그 서술과 일치한다.
## 본문
<!-- body:start -->
`LocalPersistentPayloadOperations`의 클래스 javadoc이 무엇이 서술자 상대이고 무엇이 아닌지를 앞에서 밝힌다. 전수 검사가 그 서술과 일치한다(`147-...` §8.2).
## 선언된 것과 선언되지 않은 것
:::evidence key="adapter-outbound-fileserver-c08-diagram" alt="구현 경계 안에 fileKey 와 소유자 권한과 FileStore 재확인이 들어 있고 서술자 상대 대응물 없음이 경계 밖 빗금 상자로 놓인 구조" caption="선언된 것과 선언되지 않은 것" zoom="false"
:::
이 파일의 `Files.*` 호출은 정확히 그 셋 — `Files.createLink`(\:941), `Files.createDirectory`(\:802), 그리고 force/stat/FileStore 조회 — 뿐이고, 각각 앞뒤로 `fileKey`·소유자·권한·FileStore 재확인이 붙는다. JDK가 `linkat`/`mkdirat`를 노출하지 않으므로 서술자 상대 대응물이 없고, 그 사실을 숨기는 대신 적었다.
## LocalPersistentPayloadOperations 참조 위치
:::evidence key="adapter-outbound-fileserver-c08" alt="코드베이스에서 LocalPersistentPayloadOperations 를 검색한 출력 8줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="LocalPersistentPayloadOperations 코드베이스 검색 — 8줄 · exit 0" zoom="true"
:::
## 같은 문제를 한 번은 정직하게 다룬 대비
**이것이 §32와의 차이다.** 여기서는 잔여 경로 연산이 (a) 문서에 선언되고 (b) identity 검사로 감싸인다. `AtomicMoveContentPublisher`의 발행 rename은 (a) 어디에도 선언되지 않고 (b) 같은 모듈이 "a precheck could only ever approximate"라고 적은 사전검사 하나로만 보호된다.
<!-- body:end -->
@@ -0,0 +1,44 @@
---
kind: CONCEPT
slug: adapter-outbound-httpclient-c02
title: 회전이 틈으로 관측되지 않게 만든 순서와 두 누수 이력
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-httpclient-c02
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-httpclient-c02
file: ../../../final/evidence/rendered/adapter-outbound-httpclient-c02.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-httpclient-c02.txt
source:
- 원본 분석 절은 analysis/11-adapter-outbound-httpclient.md#L112 이다.
module: adapter-outbound-httpclient
---
# 회전이 틈으로 관측되지 않게 만든 순서와 두 누수 이력
`ClientRuntimeRegistry`는 교체본을 먼저 발행하고 이전 세대를 나중에 드레인한다. 과거 누수 두 건이 코드와 주석에 남아 있다.
## 본문
<!-- body:start -->
"A swap publishes the replacement first and drains the predecessor afterwards, so a rotation is never observable as a gap." `acquire`는 관측한 세대가 예약 직전에 draining으로 넘어가면 **새로 발행된 세대에 대해 재시도**한다.
## ClientRuntimeRegistry 참조 위치
:::evidence key="adapter-outbound-httpclient-c02" alt="코드베이스에서 ClientRuntimeRegistry 를 검색한 출력 24줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ClientRuntimeRegistry 코드베이스 검색 — 24줄 · exit 0" zoom="true"
:::
## 아무도 열거하지 않아서 보이지 않던 누수
교체된 세대가 `runtimes`에서 빠지고 스케줄된 drain 작업만 소유하게 되어, 그 작업이 발화하기 전에 레지스트리가 닫히면 "leaked the whole generation — and the resource-bound suite could not see it, because nothing enumerated it." 지금은 `retired` 집합이 추적한다.
## 실패가 클수록 더 많이 새던 종료
`close()``forEach`로 닫다가 첫 예외에서 멈춰 "a single misbehaving pool left every remaining connection, thread and socket open — **shutdown leaked more the worse the failure was.**" 지금은 전부 닫고 실패를 suppressed로 모은다.
<!-- body:end -->
@@ -0,0 +1,53 @@
---
kind: CONCEPT
slug: adapter-outbound-httpclient-c04
title: 뒤 규칙이 앞 규칙의 금지를 되돌리지 못한다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-httpclient-c04
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-httpclient-c04
file: ../../../final/evidence/rendered/adapter-outbound-httpclient-c04.svg
- key: adapter-outbound-httpclient-c04-diagram
file: ../../../final/assets/diagrams/adapter-outbound-httpclient-c04.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-httpclient-c04.txt
source:
- 원본 분석 절은 analysis/11-adapter-outbound-httpclient.md#L305 이다.
module: adapter-outbound-httpclient
---
# 뒤 규칙이 앞 규칙의 금지를 되돌리지 못한다
`DefaultRetryEligibilityEngine.decide`의 javadoc이 규칙이다 — 싼 절대 차단이 먼저 오고, 그다음 모호성, 그다음 상태·실패별 규칙이 온다.
## 본문
<!-- body:start -->
`DefaultRetryEligibilityEngine.decide`의 javadoc이 규칙이다 — "The order is the point. Cheap absolute blockers come first (attempts, budget, replayability, first byte, deadline, draining), then ambiguity, then status- and failure-specific rules. **A later rule can never re-enable something an earlier rule forbade.**"
## 재시도 결정의 순서
:::evidence key="adapter-outbound-httpclient-c04-diagram" alt="절대 차단 여섯과 영구 실패 범주와 증거와 상태별 규칙이 왼쪽에서 오른쪽으로 이어지는 구조" caption="재시도 결정의 순서" zoom="false"
:::
절대 차단 여섯이 먼저다 — 시도 수 소진 · 예산 소진 · 본문 재생 불가 · **첫 바이트 전달됨** · 런타임 draining · 남은 deadline이 최소 시도 예산 이하. 그다음 영구 실패 범주, 그다음 증거, 그다음 상태/실패별 규칙이다.
## DefaultRetryEligibilityEngine 참조 위치
:::evidence key="adapter-outbound-httpclient-c04" alt="코드베이스에서 DefaultRetryEligibilityEngine 를 검색한 출력 14줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="DefaultRetryEligibilityEngine 코드베이스 검색 — 14줄 · exit 0" zoom="true"
:::
## HTTP 메서드가 결정에 들어 있지 않다
`RetryContext.safelyIdempotent()`가 이 모듈의 D-09를 구현한다 — HTTP 메서드는 `RetryContext`**아예 없다**("so a POST with a registered idempotency key and a GET against a non-idempotent RPC endpoint are both handled correctly instead of by method-name folklore"). 키 기반 멱등성은 **키가 실제로 전송됐는지**까지 요구한다. test `aKeyThatWasNeverSentDoesNotMakeARepeatSafe`가 그것을 고정한다.
## 존중한 Retry-After를 자르지 않는 이유
`Retry-After`는 남은 deadline 안에 들어갈 때만 존중된다(`allowWithin`), 그리고 존중된 `Retry-After``maxBackoff`로 잘리지 **않는다** — 잘라 버리면 업스트림이 요청한 대기보다 일찍 다시 두드리게 되기 때문이다.
<!-- body:end -->
@@ -0,0 +1,48 @@
---
kind: CONCEPT
slug: adapter-outbound-messaging-c03
title: 열린 타입은 페이로드로 받지 않는다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-messaging-c03
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-messaging-c03
file: ../../../final/evidence/rendered/adapter-outbound-messaging-c03.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-messaging-c03.txt
source:
- 원본 분석 절은 analysis/12-adapter-outbound-messaging.md#L237 이다.
module: adapter-outbound-messaging
---
# 열린 타입은 페이로드로 받지 않는다
`ContractCatalogCompiler`가 정확한 record 타입 토큰으로부터 불변 카탈로그를 만들고, test 이름이 무엇을 거부하는지 전부 적는다.
## 본문
<!-- body:start -->
`ContractCatalogCompiler`**정확한 record 타입 토큰**으로부터 불변 카탈로그를 만들고, test 이름이 무엇을 거부하는지 전부 적는다 — 중복 stable/schema/payload 신원, 음수 버전, 잘못된 payload kind, null·공백·중복·반사 불일치 성분 순서, 서술자 누락, **payload 버전 사이의 logical destination 드리프트**.
## ContractCatalogCompiler 참조 위치
:::evidence key="adapter-outbound-messaging-c03" alt="코드베이스에서 ContractCatalogCompiler 를 검색한 출력 18줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ContractCatalogCompiler 코드베이스 검색 — 18줄 · exit 0" zoom="true"
:::
## 그래프가 닫혀 있어야 스냅샷할 수 있다
`recursivelyFreezesOnlyTheClosedDeclaredGenericPayloadGraph` / `rejectsOpenRawWildcardMapJsonTreeInterfaceAndGenericRecordGraphs` — 열린 타입(raw·wildcard·`Map`·JSON 트리·인터페이스·제네릭 record 그래프)을 페이로드로 받지 않는다. 봉투 작성기가 shape을 따라 스냅샷할 수 있으려면 그래프가 닫혀 있어야 한다(§11).
## 접근자를 정확히 한 번만 부르는 이유
`snapshotsEveryContributionAccessorExactlyOnceIncludingAStatefulSchemaHash` / `statefulDescriptorCannotBypassCrossVersionLogicalDestinationDrift` — 기여 접근자를 **정확히 한 번만** 호출한다. 가변 서술자가 검사와 저장 사이에 값을 바꿔 규칙을 우회하는 경로를 닫는다.
## 반사가 밖으로 새지 않는다
`compiledContractUsesOnlyAStaticPublicCompositionBridgeWithoutReflectionLeak` — 컴파일된 계약이 반사를 밖으로 새게 하지 않는다.
<!-- body:end -->
@@ -0,0 +1,49 @@
---
kind: CONCEPT
slug: adapter-outbound-objectstorage-c05
title: revision은 건너뛰지도 되돌아가지도 못한다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-objectstorage-c05
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-objectstorage-c05
file: ../../../final/evidence/rendered/adapter-outbound-objectstorage-c05.svg
- key: adapter-outbound-objectstorage-c05-diagram
file: ../../../final/assets/diagrams/adapter-outbound-objectstorage-c05.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-objectstorage-c05.txt
source:
- 원본 분석 절은 analysis/09-adapter-outbound-objectstorage.md#L244 이다.
module: adapter-outbound-objectstorage
---
# revision은 건너뛰지도 되돌아가지도 못한다
`ObjectOperationStateMachine`이 publication·scan·reference·direct-grant·multipart 다섯 계열의 전이를 각각 switch로 적고, terminal 처리가 계열마다 명시적이다.
## 본문
<!-- body:start -->
`ObjectOperationStateMachine`이 publication·scan·reference·direct-grant·multipart 다섯 계열의 전이를 각각 switch로 적는다. terminal 처리가 계열마다 명시적이다.
## 어디서든 들어가고 나올 수 없는 분기
:::evidence key="adapter-outbound-objectstorage-c05-diagram" alt="정상 사슬의 어느 단계에서 EXPIRED 와 ABORTED 와 FAILED 와 CORRUPT 네 상자로 화살표가 나가고 네 상자가 모두 빗금으로 표시된 구조" caption="어디서든 들어가고 나올 수 없는 분기" zoom="false"
:::
뒤 둘은 **branch 전이**(EXPIRED/ABORTED/FAILED/CORRUPT)를 별도로 허용해, 정상 사슬 어디서든 실패로 빠질 수 있되 terminal에서는 나올 수 없게 한다.
## ObjectOperationStateMachine 참조 위치
:::evidence key="adapter-outbound-objectstorage-c05" alt="코드베이스에서 ObjectOperationStateMachine 를 검색한 출력 16줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ObjectOperationStateMachine 코드베이스 검색 — 16줄 · exit 0" zoom="true"
:::
## revision을 건너뛰거나 되돌릴 수 없다
`requireNextRevision(current, next)``next == current + 1`을 강제한다. `ObjectOperationStateMachineTest`가 셋을 이름으로 고정한다: `publicationFollowsScanFreeAndScanRequiredPaths`, `terminalOutOfOrderAndStaleRevisionTransitionsFailClosed`, `independentStateFamiliesDoNotImplyEachOther`.
<!-- body:end -->
@@ -0,0 +1,46 @@
---
kind: CONCEPT
slug: adapter-outbound-objectstorage-c07
title: durable record가 비밀을 담지 않는다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-objectstorage-c07
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-objectstorage-c07
file: ../../../final/evidence/rendered/adapter-outbound-objectstorage-c07.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-objectstorage-c07.txt
source:
- 원본 분석 절은 analysis/09-adapter-outbound-objectstorage.md#L420 이다.
module: adapter-outbound-objectstorage
---
# durable record가 비밀을 담지 않는다
`DirectTransferSessionRecord`의 javadoc 한 줄이 이 sub-scope의 출발점이다 — "Durable non-secret direct-transfer session state; bearer material is deliberately absent."
## 본문
<!-- body:start -->
durable record가 **비밀을 담지 않는다**는 것이 출발점이다. `DirectTransferSessionRecord`의 한 줄 javadoc이 그것이다 — "Durable non-secret direct-transfer session state; bearer material is deliberately absent." 저장되는 것은 generation·제약 다이제스트·서명 시각·만료·credential revision·reference revision뿐이고, presigned URI와 서명 헤더는 **process-local 캐시**에만 남는다.
프로세스가 재시작하면 이미 발급된 grant는 재현되지 않고 `"issued direct grant bearer material is unavailable after process restart"`로 명시적으로 실패한다 — 조용히 새로 서명해서 두 번째 bearer를 만드는 대신이다.
## DirectTransferSessionRecord 참조 위치
:::evidence key="adapter-outbound-objectstorage-c07" alt="코드베이스에서 DirectTransferSessionRecord 를 검색한 출력 10줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="DirectTransferSessionRecord 코드베이스 검색 — 10줄 · exit 0" zoom="true"
:::
## CAS가 고정하는 순서
`SESSION_RESERVED → GRANT_PREPARED → GRANT_ISSUED → DATA_UPLOADED`이고, test 이름이 그 순서를 그대로 못박는다 — `preparedCasPrecedesSigningAndIssuedCasPrecedesReturningTheBearerGrant`. 서명 **전에** prepared가 durable해야 하고, bearer를 **반환하기 전에** issued가 durable해야 한다.
## 완료를 지평이 지날 때까지 거부한다
multipart 쪽에서 가장 흥미로운 것은 완료 시점의 **admission drain**이다. `DirectMultipartCompletionVerifier.requireAdmissionDrained`는 provider가 "controlled ingress가 비었다"고 권위 있게 말해 주지 않으면, `마지막 grant 만료 + 검증된 시계 오차 + 최대 in-flight 지평` 이 지나기 전에는 완료를 거부한다. 이미 발급된 part PUT이 아직 날아가고 있을 수 있기 때문이다. test 이름이 `completionHorizonRejectsWhileIssuedPartRequestsMayStillArrive``lateGrantAdmissionRejectsAfterCompletionFence`로 양방향 모두 고정한다.
<!-- body:end -->
@@ -0,0 +1,48 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c01
title: 내부 소비자 부재와 public 계약 필요성을 따로 묻는다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c01
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c01.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c01.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L70 이다.
module: adapter-outbound-persistence-jpa
---
# 내부 소비자 부재와 public 계약 필요성을 따로 묻는다
`api/**`는 public top-level production type이 정확히 49개이고 committed baseline과 일치한다. 이 package에는 Spring/JPA annotation이 하나도 없다.
## 본문
<!-- body:start -->
public top-level production type도 정확히 49개다. `docs/architecture/jpa-api-surface.txt`의 committed API baseline 역시 `api` namespace에서 49개를 기록하고 있어 현재 이름 목록 drift는 없다. Gradle `verifyJpaApiSurface`가 이 surface의 추가/삭제를 fail-closed로 검증한다. 이 package에는 Spring/JPA/Repository/Entity/Configuration annotation이 하나도 없다.
## JpaEntityNotFoundException 참조 위치
:::evidence key="adapter-outbound-persistence-jpa-c01" alt="코드베이스에서 JpaEntityNotFoundException 를 검색한 출력 3줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="JpaEntityNotFoundException 코드베이스 검색 — 3줄 · exit 0" zoom="true"
:::
## annotation이 없다는 것의 의미
JPA adapter 안에 위치하지만 **API vocabulary 자체는 Spring bean discovery나 JPA mapping으로 활성화되지 않는다.** 실제 composition은 `app-bootstrap` 및 implementation package가 소유한다. `api/**`는 implementation package와 달리 의도적으로 외부 adopter surface다.
## 참조 0을 dead로 읽지 않는 이유
구분해야 하는 질문이 둘이다 — repository 내부 production consumer가 있는가, 그리고 public library contract로 존재할 이유가 있는가. `JpaEntityNotFoundException`은 현재 repository production에서 자신을 제외한 참조 파일이 0개다. 하지만 이 한 사실만으로 dead type이라고 판정하지 않았다. external API surface는 repository 내부에서 직접 생성되지 않더라도 adopter가 catch/translate하는 계약일 수 있기 때문이다.
## 반대 방향도 성립하지 않는다
public API라는 이유로 내부 invariant 결함까지 "미사용이라 안전"으로 넘기지는 않는다. `SignedJsonCursorCodec`처럼 codec 자체가 public contract이고 자기 encode/decode algebra가 불일치하면 repository 내부 consumer 유무와 무관하게 API defect다.
<!-- body:end -->
@@ -0,0 +1,51 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c03
title: 위험한 shape 자체를 표현하기 어렵게 만든다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c03
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c03
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c03.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c03.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L208 이다.
module: adapter-outbound-persistence-jpa
---
# 위험한 shape 자체를 표현하기 어렵게 만든다
error hierarchy의 핵심은 예외 class를 많이 만든 것이 아니라 provider-specific signal을 bounded semantic category로 변환하는 것이다.
## 본문
<!-- body:start -->
error hierarchy의 핵심은 "예외 class를 많이 만든 것"이 아니라 provider-specific signal을 bounded semantic category로 변환하는 것이다. 대표 category는 serialization failure, deadlock, optimistic conflict, lock not available, connection unavailable, timeout 계열, unique/FK/not-null/check constraint, entity not found, schema mismatch, data corruption, completion unknown이다. 이 category는 뒤의 retry policy/metric이 SQLSTATE/provider message를 직접 해석하지 않게 하는 중간 vocabulary다.
## JpaFailureContext 참조 위치
:::evidence key="adapter-outbound-persistence-jpa-c03" alt="코드베이스에서 JpaFailureContext 를 검색한 출력 31줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="JpaFailureContext 코드베이스 검색 — 31줄 · exit 0" zoom="true"
:::
## 표현 자체를 막는 네 불변식
`JpaFailureContext`가 가지는 정보는 operation, SQLSTATE/constraint, attempt, retryability, completion-unknown, elapsed, trace 등으로 제한된다.
- arbitrary identifier는 그대로 담지 않고 bounded/redacted form으로 축약
- malformed SQLSTATE는 `redacted`, absent SQLSTATE는 sentinel로 표현
- completion unknown과 retryable=true를 동시에 표현할 수 없음
- completion-unknown factory는 항상 automatic retry를 차단하는 형태를 만든다
즉 "exception이 발생한 뒤 로그에서 실수하지 말자"보다 앞선 위치에서 **failure context가 위험한 shape 자체를 표현하기 어렵게** 만든다.
## 메시지가 provider cause를 복사하지 않는다
base exception message는 provider cause message를 그대로 복사하지 않고 category + bounded context로 만든다. dedicated test도 provider cause에 email marker를 넣었을 때 top-level exception message에 노출되지 않는 것을 검증한다. 동시에 raw `Throwable cause`는 보존한다.
<!-- body:end -->
@@ -0,0 +1,53 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c06
title: 설정으로 completion unknown을 다시 살릴 수 없다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c06
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c06
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c06.svg
- key: adapter-outbound-persistence-jpa-c06-diagram
file: ../../../final/assets/diagrams/adapter-outbound-persistence-jpa-c06.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c06.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L462 이다.
module: adapter-outbound-persistence-jpa
---
# 설정으로 completion unknown을 다시 살릴 수 없다
`RetryProfile`의 retryable category allowlist가 타입에서 좁혀져 있고, `COMPLETION_UNKNOWN`을 넣으면 constructor가 즉시 거부한다.
## 본문
<!-- body:start -->
`TransactionProfile`은 name·propagation·isolation·timeout·readOnly·retryProfile을 결합한다. write profile은 positive timeout이 필수다. read-only는 zero timeout을 "connection default" 의미로 허용한다. 지원 propagation을 REQUIRED / MANDATORY / REQUIRES_NEW로 좁혀 SUPPORTS/NESTED/NOT_SUPPORTED/NEVER처럼 "실제로 transaction 안에 있는가"를 흐리는 mode를 surface에서 제거했다. isolation 역시 PostgreSQL에서 의미가 겹치는 READ_UNCOMMITTED를 expose하지 않는다.
## allowlist에 들어갈 수 있는 범주
:::evidence key="adapter-outbound-persistence-jpa-c06-diagram" alt="allowlist 경계 안에 직렬화 실패와 낙관적 충돌과 락 획득 실패가 들어 있고 COMPLETION_UNKNOWN 이 경계 밖 빗금 상자로 놓인 구조" caption="allowlist 에 들어갈 수 있는 범주" zoom="false"
:::
retryable category allowlist는 serialization failure, optimistic conflict, lock not available, connection unavailable 계열로 제한된다. `COMPLETION_UNKNOWN`을 넣으면 constructor가 즉시 거부한다. Unique constraint 같은 ineligible category도 거부한다. 즉 failure translator가 retryability를 판단하고, profile이 category allowlist를 가진다고 해서 "어떤 failure도 설정으로 retry 가능하게" 만들 수 없다.
## RetryDecision 참조 위치
:::evidence key="adapter-outbound-persistence-jpa-c06" alt="코드베이스에서 RetryDecision 를 검색한 출력 40줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="RetryDecision 코드베이스 검색 — 40줄 · exit 0" zoom="true"
:::
## 모르겠음과 즉시 한 번 더를 구분한다
RETRY_FULL_TRANSACTION을 포함한 세 가지 중 retry만 non-zero delay를 가질 수 있다. 이 분리 덕분에 completion unknown이 `delay=0 retry`처럼 표현되지 않는다.
## reason에 길이 상한이 없다
`RetryDecision.reason`은 javadoc상 bounded diagnostic/low-cardinality-safe string으로 설명된다. 그러나 constructor는 non-null/nonblank만 확인하고 길이/형식 상한은 없다. runtime constructor probe에서는 100,000-character reason도 accepted됐다. 다만 실제 `JpaRetryObservation`은 decision.reason을 metric tag로 사용하지 않는다.
<!-- body:end -->
@@ -0,0 +1,40 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c07
title: 이름 목록만 고정하고 의미는 고정하지 않는다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c07
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c07
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c07.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c07.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L639 이다.
module: adapter-outbound-persistence-jpa
---
# 이름 목록만 고정하고 의미는 고정하지 않는다
`verifyJpaApiSurface --rerun-tasks`가 통과했다. 이 task가 증명하는 것은 public type names가 committed baseline과 동일하다는 것뿐이다.
## 본문
<!-- body:start -->
`verifyJpaApiSurface --rerun-tasks`가 통과했다. 이 task가 증명하는 것은 **public type names가 committed baseline과 동일하다**는 것이다.
## 분석 원문의 실행 기록
:::evidence key="adapter-outbound-persistence-jpa-c07" alt="분석 문서 analysis/05-adapter-outbound-persistence-jpa.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/05-adapter-outbound-persistence-jpa.md 발췌 — 15줄" zoom="true"
:::
## 이 게이트가 증명하지 않는 것
method semantics나 constructor invariant까지 ABI/API compatibility를 검증하는 것은 아니다.
<!-- body:end -->
@@ -0,0 +1,40 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c08
title: 구현 클래스를 샘플링하지 않고 51개를 전부 읽었다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c08
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c08
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c08.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c08.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L721 이다.
module: adapter-outbound-persistence-jpa
---
# 구현 클래스를 샘플링하지 않고 51개를 전부 읽었다
transaction 29 + failure 3 production과 19 test, 합계 51개 source/test를 FULL_READ했다.
## 본문
<!-- body:start -->
모든 51개 source/test를 FULL_READ했다. 이 scope에서는 implementation class를 샘플링하지 않고 transaction state machine, retry budget, Spring mapping, failure translation, root wiring, consumer reachability까지 연결했다.
## 분석 원문의 범위 선언
:::evidence key="adapter-outbound-persistence-jpa-c08" alt="분석 문서 analysis/05-adapter-outbound-persistence-jpa.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/05-adapter-outbound-persistence-jpa.md 발췌 — 15줄" zoom="true"
:::
## 이 sub-scope가 던진 질문
transaction을 여는 코드가 아니라 **commit 결과를 언제 확정하는가, 어떤 failure만 replay하는가, completion-unknown을 어떤 evidence로 남기는가**다.
<!-- body:end -->
@@ -0,0 +1,53 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c09
title: 같은 leaf 안에 트랜잭션 모델이 둘 있다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c09
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c09
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c09.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c09.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L737 이다.
module: adapter-outbound-persistence-jpa
---
# 같은 leaf 안에 트랜잭션 모델이 둘 있다
application-core가 소유한 canonical boundary와 이 leaf의 `api/**`가 소유한 boundary가 동시에 존재한다.
## 본문
<!-- body:start -->
현재 persistence-jpa에는 transaction을 표현하는 두 계열이 동시에 존재한다.
```text
PolicyTransactionPort / TransactionPort
-> SpringTransactionPort (@Component)
-> SpringPolicyTransactionPort
-> PlatformTransactionManager
```
A의 어휘는 `TransactionRequest`, `TransactionPolicyId`, `CallBudget`, `TransactionResult`, `TransactionOutcome`, `OperationId`, `TransactionPhase`, `ReconciliationReference`다. 이 모델은 application-core가 소유한다 — use case가 outbound adapter type을 import하지 않아도 transaction policy와 uncertain outcome을 표현할 수 있다.
## TransactionRequest 참조 위치
:::evidence key="adapter-outbound-persistence-jpa-c09" alt="코드베이스에서 TransactionRequest 를 검색한 출력 33줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="TransactionRequest 코드베이스 검색 — 33줄 · exit 0" zoom="true"
:::
## B는 bean graph에 있고 호출자는 없다
B의 어휘는 `PersistenceOperationName`, `TransactionProfile`, `RetryProfile`, `JpaPersistenceException`, `TransactionCompletionEvidence`다. `JpaPlatformRuntimeAutoConfiguration``PlatformTransactionManager`가 있으면 `SpringJpaTransactionExecutor` bean을 만들고, 그 executor가 있으면 `FullTransactionRetryCoordinator` bean도 만든다. 따라서 source tree 수준에서는 B가 단순 historical class가 아니라 **현재 runtime bean graph에도 포함되는 구현**이다. 그러나 repository production call search에서는 `FullTransactionRetryCoordinator.execute(...)`를 실제 business/application code가 호출하는 경로가 확인되지 않았다.
## 공존 자체는 결함이 아니다
반대로 application-core transaction port는 sample/use-case/composition에서 canonical contract로 사용된다. `api/**`는 intended external surface이므로 fork/application이 B를 programmatically 사용할 수 있다.
<!-- body:end -->
@@ -0,0 +1,52 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c12
title: commit exception을 rollback으로 가정하지 않는다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c12
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c12
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c12.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c12.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L832 이다.
module: adapter-outbound-persistence-jpa
---
# commit exception을 rollback으로 가정하지 않는다
`PolicyTransactionPort.inTransaction(...)`은 단순 예외 기반 wrapper가 아니다. commit 호출 전에 phase를 `COMMIT_REQUESTED`로 올리고 Spring `TransactionSynchronization` sentinel로 실제 callback을 관찰한다.
## 본문
<!-- body:start -->
`PolicyTransactionPort.inTransaction(...)`은 단순 예외 기반 wrapper가 아니다. 결과는 최소 `Committed`, `CommittedWithPostCommitFailure`, `Participating`, `DeterminateRollback`, `Indeterminate`를 구분한다. 핵심은 **commit exception = rollback**으로 가정하지 않는 것이다.
## SpringPolicyTransactionPort 참조 위치
:::evidence key="adapter-outbound-persistence-jpa-c12" alt="코드베이스에서 SpringPolicyTransactionPort 를 검색한 출력 5줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="SpringPolicyTransactionPort 코드베이스 검색 — 5줄 · exit 0" zoom="true"
:::
## commit failure가 갈리는 세 갈래
commit 호출 전에 phase를 `COMMIT_REQUESTED`로 올리고, Spring `TransactionSynchronization` sentinel로 실제 callback을 관찰한다. commit에서 exception이 발생해도 이렇게 갈린다.
1. `afterCommit()`이 이미 확인됐으면 `CommittedWithPostCommitFailure`
1. rollback callback/`UnexpectedRollbackException`/replay-candidate가 확인되면 `DeterminateRollback`
1. 그 외에는 `Indeterminate`
즉 연결 끊김 같은 애매한 exception을 "rollback이겠지"라고 간주하지 않는다.
## replay가 일어나는 조건 전부
`Indeterminate`는 retry 대상이 아니다. replay 조건은 모두 만족해야 한다 — policy가 `COMMAND_SERIALIZABLE_REPLAY_SAFE`, 현재 attempt가 physical transaction owner, attempt < configured max, 현재 스레드가 인터럽트되지 않음, 결과가 `DeterminateRollback`, failure가 40001 serialization 또는 40P01 deadlock replay candidate.
따라서 commit ack를 못 받은 상태는 replay되지 않는다. 이 점은 뒤에서 다룰 JPA public API completion-evidence wiring gap의 중요한 mitigation이다 — **현재 canonical application path는 completion evidence infrastructure가 없어도 불확정 commit을 자동 재실행하지 않는다.**
<!-- body:end -->
@@ -0,0 +1,55 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c13
title: 이미 소비한 시간을 트랜잭션 계층이 다시 주지 않는다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c13
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c13
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c13.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c13.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L879 이다.
module: adapter-outbound-persistence-jpa
---
# 이미 소비한 시간을 트랜잭션 계층이 다시 주지 않는다
application policy path는 timeout을 `TransactionDefinition.setTimeout()` 하나로 끝내지 않는다. CallBudget admission이 connection pool을 빌리기 전부터 시작한다.
## 본문
<!-- body:start -->
application policy path는 timeout을 단순히 `TransactionDefinition.setTimeout()` 하나로 끝내지 않는다. `ca-skeleton.jpa.transaction` settings는 transaction/resource-budget defaults를 가진다.
- duration positive
- duration <= 1 day
- retry max attempts 1..5
- statement timeout <= transaction timeout
- lock timeout < statement timeout
- completion/acquisition/action margin hierarchy
즉 runtime에서 무한 retry나 무한 transaction timeout을 property 하나로 열 수 없게 hard cap을 둔다.
## RetryProfile 참조 위치
:::evidence key="adapter-outbound-persistence-jpa-c13" alt="코드베이스에서 RetryProfile 를 검색한 출력 14줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="RetryProfile 코드베이스 검색 — 14줄 · exit 0" zoom="true"
:::
이 점은 API `RetryProfile.maxAttempts`가 upper bound를 갖지 않는 것과 대비된다. canonical application path는 실제 deployment settings에서 최대 5회를 강제한다.
## 풀에서 기다린 시간이 다시 주어지지 않는다
CallBudget admission은 connection pool을 빌리기 **전**부터 시작한다. transaction을 열 가치가 있으려면 남은 budget이 최소한을 감당해야 하고, begin 후에는 실제 남은 budget으로 Spring whole-transaction timeout, statement timeout, lock timeout, idle-in-transaction timeout을 정한다. 따라서 pool에서 오래 기다린 요청이 "원래 5초 timeout이었으니 DB에서 다시 5초"를 받지 않는다.
## backoff도 budget을 본다
canonical path의 retry backoff도 CallBudget-aware다. 다음 attempt를 시작하기 전에 jitter delay, 다음 acquisition reserve, 다음 최소 transaction/action margin을 모두 감당할 수 있는지 확인한다.
<!-- body:end -->
@@ -0,0 +1,40 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c17
title: SQLSTATE만으로는 구분할 수 없어 phase가 필요하다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c17
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c17
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c17.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c17.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L1074 이다.
module: adapter-outbound-persistence-jpa
---
# SQLSTATE만으로는 구분할 수 없어 phase가 필요하다
`CommitFailureClassifier`를 generic SQLSTATE translator 대신 commit call 내부에서만 적용한다.
## 본문
<!-- body:start -->
completion unknown 후보는 SQLSTATE 40003, connection class 08*, admin shutdown / crash / cannot-connect-now 계열, transport break cause다.
## CommitFailureClassifier 참조 위치
:::evidence key="adapter-outbound-persistence-jpa-c17" alt="코드베이스에서 CommitFailureClassifier 를 검색한 출력 18줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="CommitFailureClassifier 코드베이스 검색 — 18줄 · exit 0" zoom="true"
:::
## 같은 SQLSTATE가 phase에 따라 다른 뜻이 된다
중요한 건 이 classifier를 generic SQLSTATE translator 대신 **commit call 내부에서만** 적용한다는 것이다. connection reset이 query 실행 중 발생했다면 connection unavailable일 수 있지만, provider에게 COMMIT을 보낸 후 reset됐다면 "commit됐는지 모름"이다. SQLSTATE만으로 이 둘을 구분할 수 없고 transaction phase가 필요하다. PostgreSQL classifier source도 이 이유를 직접 설명한다.
<!-- body:end -->
@@ -0,0 +1,44 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c19
title: runbook이 지목하는 지표를 만드는 호출이 없다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c19
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c19
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c19.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c19.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L1210 이다.
module: adapter-outbound-persistence-jpa
---
# runbook이 지목하는 지표를 만드는 호출이 없다
`JpaTransactionObservation.recordCompletionUnknown(...)` 구현은 존재하지만 production에서 그것을 부르는 곳이 없다.
## 본문
<!-- body:start -->
`JpaTransactionObservation.recordCompletionUnknown(...)` 구현은 존재한다. 하지만 production에서 참조 수를 세면 이렇다 — `JpaObservabilityAutoConfiguration` construction = 0, `JpaTransactionObservation.recordCompletionUnknown(...)` call = 0, `recordCommitted`/`recordRolledBack`/`recordTimedOut` call도 0.
## JpaObservabilityAutoConfiguration 참조 위치
:::evidence key="adapter-outbound-persistence-jpa-c19" alt="코드베이스에서 JpaObservabilityAutoConfiguration 를 검색한 출력 2줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="JpaObservabilityAutoConfiguration 코드베이스 검색 — 2줄 · exit 0" zoom="true"
:::
## 기본 listener도 비어 있다
`JpaPlatformRuntimeAutoConfiguration`이 만드는 default `RetryEventListener`도 empty implementation이며, `JpaObservabilityAutoConfiguration`을 통해 metric listener로 합성하지 않는다.
## runbook 신호의 생성 근거를 찾지 못했다
runbook의 `jpa.transaction.completion.unknown` signal은 현재 source wiring으로는 생성 근거를 찾지 못했다. 이 observability factory 전체의 reachability 문제는 later observation/baseline capability sub-scope에서 다시 exhaustive하게 확인한다. 여기서는 completion-unknown path의 cross-scope evidence로만 기록한다.
<!-- body:end -->
@@ -0,0 +1,44 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c30
title: Querydsl은 production runtimeClasspath에 들어오지 않는다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c30
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c30
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c30.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c30.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L2087 이다.
module: adapter-outbound-persistence-jpa
---
# Querydsl은 production runtimeClasspath에 들어오지 않는다
`QuerydslJpaSupport`는 Advanced opt-in으로 설계돼 있고, lockfile에서 Querydsl은 compileClasspath와 test/integration/performance classpath에만 나타난다.
## 본문
<!-- body:start -->
`QuerydslJpaSupport`는 Advanced opt-in으로 설계돼 있다. lockfile에서 Querydsl은 compileClasspath와 test/integration/performance classpath에는 나타나지만 production `runtimeClasspath` configuration에는 포함되지 않는다.
## QuerydslJpaSupport 참조 위치
:::evidence key="adapter-outbound-persistence-jpa-c30" alt="코드베이스에서 QuerydslJpaSupport 를 검색한 출력 3줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="QuerydslJpaSupport 코드베이스 검색 — 3줄 · exit 0" zoom="true"
:::
## helper가 두는 제한
따라서 JPA leaf를 사용하는 것만으로 Querydsl runtime dependency가 Stable deployment에 따라오는 구조는 아니다. `QuerydslJpaSupport`도 bounded page size <= 500, null predicate는 explicit unbounded opt-in 없으면 거부, 등록된 `QueryName`을 Hibernate comment hint로 적용이라는 제한을 둔다.
## 미채택 상태로 기록한다
현재 production consumer는 확인되지 않았다. 이는 Advanced opt-in helper의 미채택 상태로 기록하며 dead-code defect로 단정하지 않는다.
<!-- body:end -->
@@ -0,0 +1,40 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c34
title: 게이트 통과가 오히려 검증기의 한계를 보여 준다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c34
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c34
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c34.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c34.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L2523 이다.
module: adapter-outbound-persistence-jpa
---
# 게이트 통과가 오히려 검증기의 한계를 보여 준다
fresh `verifyJpaReleaseGateTasks`가 성공했다. 이 성공은 오히려 validator limitation의 evidence다.
## 본문
<!-- body:start -->
fresh `verifyJpaReleaseGateTasks`도 성공했다. 이 success는 오히려 validator limitation의 evidence다 — task semantic coverage/tag를 검사하지 않기 때문이다.
## 분석 원문의 판정
:::evidence key="adapter-outbound-persistence-jpa-c34" alt="분석 문서 analysis/05-adapter-outbound-persistence-jpa.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/05-adapter-outbound-persistence-jpa.md 발췌 — 15줄" zoom="true"
:::
## 무엇을 검사하지 않는가
task semantic coverage/tag를 검사하지 않는다. 그래서 registry가 지목한 producer task가 실제로 그 대상 테스트를 실행하지 않아도 게이트가 통과한다.
<!-- body:end -->
@@ -0,0 +1,54 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c35
title: 40003이 UNKNOWN으로 강등되면서 조정 경로도 함께 사라진다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c35
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c35
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c35.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c35.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L2632 이다.
module: adapter-outbound-persistence-jpa
---
# 40003이 UNKNOWN으로 강등되면서 조정 경로도 함께 사라진다
`PostgreSqlFailureClassifier`의 SQLSTATE 분류는 맞지만 `PostgreSqlExceptionTranslator.translate()``COMPLETION_UNKNOWN`을 일반 `UNKNOWN`으로 강등한다.
## 본문
<!-- body:start -->
`PostgreSqlFailureClassifier`는 PostgreSQL SQLSTATE를 bounded `FailureCategory`로 분류한다. serialization failure, deadlock, lock-not-available, constraint family, timeout, connection failure, schema/data 문제를 문자열 메시지가 아니라 SQLSTATE/structured server field 기준으로 다루는 방향은 적절하다. constraint 이름도 server error field에서 꺼내 catalog로 번역하므로 localized message parsing에 의존하지 않는다.
## PostgreSqlFailureClassifier 참조 위치
:::evidence key="adapter-outbound-persistence-jpa-c35" alt="코드베이스에서 PostgreSqlFailureClassifier 를 검색한 출력 11줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="PostgreSqlFailureClassifier 코드베이스 검색 — 11줄 · exit 0" zoom="true"
:::
## 번역기가 completionUnknown을 항상 false로 만든다
현재 `PostgreSqlExceptionTranslator.translate()`는 classifier 결과가 `COMPLETION_UNKNOWN`이어도 `JpaFailureContext``completionUnknown`을 항상 `false`로 만들고, switch에서 `COMPLETION_UNKNOWN``UNKNOWN`과 함께 일반 `JpaPersistenceException(FailureCategory.UNKNOWN, ...)`으로 강등한다. 직접 probe에서 SQLSTATE `40003`은 다음처럼 변환됐다.
```text
type=JpaPersistenceException
category=UNKNOWN
sqlState=40003
completionUnknown=false
retryable=false
```
## 조정 경로까지 함께 사라진다
여기서 단순 진단 정보만 사라지는 것이 아니다. 현재 `DefaultJpaRetryPolicy``TransactionCompletionUnknownException` 또는 `FailureCategory.COMPLETION_UNKNOWN`을 가장 먼저 검사해 `RECONCILE`로 보낸다. 그런데 실제 translator를 통과시키면 그 분기에 닿지 못한다.
**재실행은 막지만, commit 결과를 확인해야 하는 reconciliation 경로도 잃는다.** fail-closed라는 이유로 안전하다고 볼 수 없는 이유다. commit이 실제로 적용됐는지 알 수 없는 상태를 terminal failure로 바꾸면 caller는 설계된 recovery protocol을 실행할 근거를 잃는다.
<!-- body:end -->
@@ -0,0 +1,59 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c36
title: 만료된 COMPLETED row를 inspect와 claim이 다르게 읽는다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c36
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c36
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c36.svg
- key: adapter-outbound-persistence-jpa-c36-diagram
file: ../../../final/assets/diagrams/adapter-outbound-persistence-jpa-c36.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c36.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L2669 이다.
module: adapter-outbound-persistence-jpa
---
# 만료된 COMPLETED row를 inspect와 claim이 다르게 읽는다
`PostgreSqlOwnerSafeIdempotencyStore`의 owner/CAS 구조 자체는 강하지만, `inspect()``replayUntil` 만료를 보지 않는다.
## 본문
<!-- body:start -->
`PostgreSqlOwnerSafeIdempotencyStore`는 row lock, owner token, attempt, state revision, operation id와 transition digest를 결합해 claim/renew/fail/complete를 보호한다. `renew``markFailed`는 동일 operation id replay에서도 semantic argument를 digest에 넣어 `SAME_ARGUMENTS``DIFFERENT_ARGUMENTS`를 분리한다. 이 구조 자체는 강하다. 현재 revision에서는 `PostgreSqlIdempotencyProviderConfig`가 이 store를 production provider로 실제 생성하므로 아래는 dormant helper 문제가 아니다.
## 같은 row를 읽는 두 경로
:::evidence key="adapter-outbound-persistence-jpa-c36-diagram" alt="만료된 COMPLETED row 에서 inspect 와 claim 두 상자로 화살표가 나가고 화살표에 서로 다른 결과가 붙은 구조" caption="같은 row 를 읽는 두 경로" zoom="false"
:::
`inspect()`는 row가 `COMPLETED`이고 response payload가 있으면 `replayUntil`이 이미 지난 값인지 확인하지 않고 무조건 `COMPLETED_REPLAY`를 반환한다. 반면 claim path는 DB time과 expiry를 보고 만료된 row를 takeover 가능 상태로 처리한다. 실제 PostgreSQL 16에서 replay TTL 25ms로 완료한 뒤 50ms를 기다린 probe 결과는 이렇다.
```text
expiredInspect.outcome=COMPLETED_REPLAY
expiredInspect.replayUntil=<already expired>
expiredInspect.claimAfterExpiry=TakenOverClaimed
```
## PostgreSqlOwnerSafeIdempotencyStore 참조 위치
:::evidence key="adapter-outbound-persistence-jpa-c36" alt="코드베이스에서 PostgreSqlOwnerSafeIdempotencyStore 를 검색한 출력 14줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="PostgreSqlOwnerSafeIdempotencyStore 코드베이스 검색 — 14줄 · exit 0" zoom="true"
:::
## introspection 문제가 아니라 lifecycle 문제인 이유
`IdempotencyExecutorV2`는 reconciliation에서 `COMPLETED_REPLAY`를 실제 저장 응답 반환 신호로 사용한다. 따라서 이 불일치는 **만료 후 새 실행이 허용된 시점에도 이전 응답을 reconciliation 결과로 반환할 수 있는 lifecycle correctness 문제**다.
## 같은 계약의 다른 구현은 만료로 수명을 끝낸다
JPA 설계 문서가 동일 Idempotency V2 contract를 구현한다고 참조하는 Redis state machine도 `COMPLETED -> [*] : replay TTL expires`로 수명을 끝낸다. JPA `inspect()`만 이 만료를 무시한다.
<!-- body:end -->
@@ -0,0 +1,44 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c38
title: SQL 식별자를 호출자가 조립하지 않는다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c38
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c38
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c38.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c38.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L2780 이다.
module: adapter-outbound-persistence-jpa
---
# SQL 식별자를 호출자가 조립하지 않는다
native write와 COPY는 caller가 임의 SQL identifier를 조립하도록 두지 않고 registered statement/name boundary를 사용한다. 값은 JDBC parameter 또는 COPY stream으로 전달된다.
## 본문
<!-- body:start -->
native write와 COPY는 caller가 임의 SQL identifier를 조립하도록 두지 않고 registered statement/name boundary를 사용한다. 값은 JDBC parameter 또는 COPY stream으로 전달된다.
## 분석 원문의 경계 확인
:::evidence key="adapter-outbound-persistence-jpa-c38" alt="분석 문서 analysis/05-adapter-outbound-persistence-jpa.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/05-adapter-outbound-persistence-jpa.md 발췌 — 15줄" zoom="true"
:::
## 각 경로가 두는 경계
COPY에는 format/size bound와 transaction requirement가 있고, work claiming은 등록된 queue definition과 PostgreSQL `FOR UPDATE ... SKIP LOCKED` 경계를 사용한다. JSON path/query support와 range query support도 registry/typed value boundary를 두고 실제 값은 bind한다. constraint translation 역시 structured SQLSTATE/server fields를 사용한다.
## 이번 sub-scope의 결론
이 영역의 새로운 SQL-injection/runtime-wiring defect는 확인되지 않았다.
<!-- body:end -->
@@ -0,0 +1,47 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c39
title: vendor migration 아홉을 실제 PostgreSQL에서 적용했다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c39
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c39
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c39.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c39.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L2805 이다.
module: adapter-outbound-persistence-jpa
---
# vendor migration 아홉을 실제 PostgreSQL에서 적용했다
9개 migration을 모두 읽고 실제 PostgreSQL probe에서 Flyway가 전부 validate/apply했다.
## 본문
<!-- body:start -->
다음 9개 migration을 모두 읽었다. 확인한 경계는 이렇다.
- idempotency owner/state/replay/transition metadata의 persisted shape
- outbox claim/delivery/index shape
- integer advisory/row-lock support table과 expiry extension
- capability schema registry adoption/widening
- request hash `char`/`varchar` drift 보정
- durable operation / live-event log schema
## 분석 원문의 확인 목록
:::evidence key="adapter-outbound-persistence-jpa-c39" alt="분석 문서 analysis/05-adapter-outbound-persistence-jpa.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/05-adapter-outbound-persistence-jpa.md 발췌 — 15줄" zoom="true"
:::
## 실제 적용 결과와 남긴 범위
real PostgreSQL probe에서 Flyway는 vendor 9 migrations를 모두 validate/apply했다. 이번 sub-scope에서 migration 순서, 현재 schema 제약, index 선언 자체로 승격할 신규 defect는 확인하지 못했다. capability-specific migration의 완전한 cross-stream adoption은 각 owning capability scope에서 다시 본다.
<!-- body:end -->
@@ -0,0 +1,41 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c40
title: 재현에 쓴 레인과 복원까지 남긴 기록
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c40
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c40
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c40.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c40.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L2883 이다.
module: adapter-outbound-persistence-jpa
---
# 재현에 쓴 레인과 복원까지 남긴 기록
`postgresqlIdempotencyIntegrationTest` 레인에서 두 경계를 실제로 재현했고, 임시 테스트는 실행 후 source에서 복원했다.
## 본문
<!-- body:start -->
`postgresqlIdempotencyIntegrationTest` 레인에서 두 경계를 실제로 재현했다.
- `complete()`가 replay TTL을 바꿨을 때의 false-same replay 재현
- 만료된 COMPLETED row의 `inspect()`/`claim()` lifecycle 불일치 재현
원본은 `evidence/raw/066-postgresql-idempotency-replay-boundary-probe.txt`에 있고, 실행 결과는 BUILD SUCCESSFUL이다. 임시 테스트는 실행 후 source에서 복원했다.
## 분석 원문의 실행 기록
:::evidence key="adapter-outbound-persistence-jpa-c40" alt="분석 문서 analysis/05-adapter-outbound-persistence-jpa.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/05-adapter-outbound-persistence-jpa.md 발췌 — 15줄" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,46 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c41
title: 보류한 항목과 보류한 이유
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c41
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c41
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c41.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c41.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L2913 이다.
module: adapter-outbound-persistence-jpa
---
# 보류한 항목과 보류한 이유
이번 scope에서 확인했으나 finding으로 승격하지 않은 항목들이다.
## 본문
<!-- body:start -->
이번 scope에서 확인했으나 finding으로 승격하지 않은 항목이다.
- registered native write/COPY의 SQL/value boundary
- work-claim `SKIP LOCKED` 기본 구조
- structured SQLSTATE/constraint-name 추출
- array/json helper의 bounded value handling
- vendor migration 9개의 현재 적용 순서/문법
## 분석 원문의 보류 목록
:::evidence key="adapter-outbound-persistence-jpa-c41" alt="분석 문서 analysis/05-adapter-outbound-persistence-jpa.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/05-adapter-outbound-persistence-jpa.md 발췌 — 15줄" zoom="true"
:::
## 보류 이유가 따로 있는 항목
polling outbox cutover sentinel의 transition별 반복 검사 차이는, claim 자체가 immutable sentinel을 요구하고 current evidence만으로 stale claim이 cutover를 우회한다고 입증되지 않아 보류했다.
<!-- body:end -->
@@ -0,0 +1,42 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c43
title: H2 idempotency와 V2 owner 필드
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c43
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c43
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c43.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c43.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L3119 이다.
module: adapter-outbound-persistence-jpa
---
# H2 idempotency와 V2 owner 필드
`H2IdempotencyClaimRepository`의 MERGE/takeover는 V2 owner/transition field를 초기화하지 않는다. baseline `IdempotencyRecordEntity`가 V1 field만 매핑하고 owner-safe V2는 PostgreSQL capability stream으로 분리되어 있으므로, 서로 다른 schema generation의 필드를 H2 V1이 reset하지 않는 것은 현재 계약 위반이 아니다.
## 본문
<!-- body:start -->
## H2 baseline이 소유하는 필드
`H2IdempotencyClaimRepository`의 MERGE/takeover 경로에는 V2 owner/transition field 초기화가 없다. 그러나 baseline `IdempotencyRecordEntity` 자체가 V1 field만 매핑한다.
## H2IdempotencyClaimRepository 참조 위치
:::evidence key="adapter-outbound-persistence-jpa-c43" alt="코드베이스에서 H2IdempotencyClaimRepository 를 검색한 출력 4줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="H2IdempotencyClaimRepository 코드베이스 검색 — 4줄 · exit 0" zoom="true"
:::
## PostgreSQL V2와 분리된 activation contract
owner-safe V2는 PostgreSQL capability stream으로 분리되어 별도 activation contract를 가진다. H2 baseline 경로와 PostgreSQL V2 경로가 서로 다른 schema generation을 소유하므로, H2 V1이 V2 field를 reset하지 않는 동작은 현재 계약과 충돌하지 않는다.
<!-- body:end -->
@@ -0,0 +1,40 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c46
title: FIFO 정산은 기록된 적응이고 전제가 빠진 것이 문제다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c46
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c46
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c46.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c46.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L3350 이다.
module: adapter-outbound-persistence-jpa
---
# FIFO 정산은 기록된 적응이고 전제가 빠진 것이 문제다
reservation row가 upload id와 연결되지 않아 `JpaQuotaCommitGateway`가 scope의 가장 오래된 live reservation부터 정산하는 것은 `docs/fileserver/design-deviations.md`에 명시적으로 기록된 adaptation이다.
## 본문
<!-- body:start -->
reservation row가 upload id와 연결되지 않아 `JpaQuotaCommitGateway`가 scope의 가장 오래된 live reservation부터 정산하는 것은 `docs/fileserver/design-deviations.md`에 명시적으로 기록된 adaptation이다. row identity와 실제 upload identity가 1\:1이 아닌 것 자체는 현재 설계 계약이다.
## JpaQuotaCommitGateway 참조 위치
:::evidence key="adapter-outbound-persistence-jpa-c46" alt="코드베이스에서 JpaQuotaCommitGateway 를 검색한 출력 5줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="JpaQuotaCommitGateway 코드베이스 검색 — 5줄 · exit 0" zoom="true"
:::
## 전제가 실제로 없다는 것은 따로 올렸다
그 문서가 전제로 둔 aggregate byte enforcement가 실제로 없다는 점은 §78의 별도 P1 finding으로 올렸다.
<!-- body:end -->
@@ -0,0 +1,40 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c47
title: 만료 직후 takeover 전 창은 보이지만 계약이 그것을 금지하지 않는다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c47
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c47
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c47.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c47.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L3354 이다.
module: adapter-outbound-persistence-jpa
---
# 만료 직후 takeover 전 창은 보이지만 계약이 그것을 금지하지 않는다
cleanup settlement query는 claim token을 fence하고 reaper takeover가 token을 교체한다. lease expiry 직후 아직 takeover 전인 worker가 settle할 수 있는 window는 보인다.
## 본문
<!-- body:start -->
cleanup settlement query는 claim token을 fence하고 reaper takeover가 token을 교체한다. lease expiry 직후 아직 takeover 전인 worker가 settle할 수 있는 window는 보이지만, 새 owner가 생긴 뒤 stale worker가 상태를 덮어쓰는 race는 token CAS가 막는다.
## 분석 원문의 판정
:::evidence key="adapter-outbound-persistence-jpa-c47" alt="분석 문서 analysis/05-adapter-outbound-persistence-jpa.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/05-adapter-outbound-persistence-jpa.md 발췌 — 15줄" zoom="true"
:::
## finding으로 올리지 않은 이유
durable-operation과 달리 현재 계약만으로 "expiry 순간부터 절대 settle 금지"라고 확정할 충분한 근거가 없다.
<!-- body:end -->
@@ -0,0 +1,44 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c48
title: 레지스트리는 V4에 멈춰 있고 매핑은 V10까지 의존한다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c48
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c48
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c48.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c48.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L3401 이다.
module: adapter-outbound-persistence-jpa
---
# 레지스트리는 V4에 멈춰 있고 매핑은 V10까지 의존한다
Notification JPA capability는 production opt-in path로 실제 composition된다. 그런데 schema 레지스트리 revision이 V4에서 멈춰 있고 현재 매핑은 V5~V10에서 추가된 column/constraint에 의존한다.
## 본문
<!-- body:start -->
Notification JPA capability는 production opt-in path로 실제 composition된다. `PersistenceJpaRootAutoConfiguration``NotificationJpaPersistenceFacade`를 import한다.
## PersistenceJpaRootAutoConfiguration 참조 위치
:::evidence key="adapter-outbound-persistence-jpa-c48" alt="코드베이스에서 PersistenceJpaRootAutoConfiguration 를 검색한 출력 10줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="PersistenceJpaRootAutoConfiguration 코드베이스 검색 — 10줄 · exit 0" zoom="true"
:::
## 조립되는 것
facade가 `NotificationJpaPersistenceConfig`를 import하고 entity/repository/store bean을 조립한다. application-side worker/config가 recipient lease, reconciliation, provider-event ledger, admin operation store를 실제 소비한다.
## 레지스트리 revision이 V4에서 멈춰 있다
`NotificationSchemaActivation`은 capability registry를 읽어 startup activation을 검사한다. schema stream은 V1\~V10까지 진화했지만 registry는 V4에서 `jpa-notification-platform-v4`, `feature_revision=4`, `INSTALLED_INACTIVE`를 기록한 뒤 더 이상 revision을 올리지 않는다. 반면 current Java mapping과 SQL은 V5\~V10에서 추가된 column/constraint에 실제 의존한다. 이 drift가 §88의 startup false-positive를 만든다.
<!-- body:end -->
@@ -0,0 +1,40 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c50
title: 이 scope에서는 cross-tenant bypass를 확정하지 못했다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c50
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c50
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c50.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c50.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L3565 이다.
module: adapter-outbound-persistence-jpa
---
# 이 scope에서는 cross-tenant bypass를 확정하지 못했다
`TenantBoundRepositoryGuard`와 tenant-qualified repository method가 존재하고, 이번 68-file owning scope에서 즉시 재현 가능한 cross-tenant bypass는 확정하지 못했다.
## 본문
<!-- body:start -->
`TenantBoundRepositoryGuard`와 tenant-qualified repository method가 존재하고, 이번 68-file owning scope에서 즉시 재현 가능한 cross-tenant bypass는 확정하지 못했다.
## TenantBoundRepositoryGuard 참조 위치
:::evidence key="adapter-outbound-persistence-jpa-c50" alt="코드베이스에서 TenantBoundRepositoryGuard 를 검색한 출력 1줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="TenantBoundRepositoryGuard 코드베이스 검색 — 1줄 · exit 0" zoom="true"
:::
## 이 기록이 주장하지 않는 범위
tenant-sensitive lookup이 전부 완전하다고 corpus 전체 결론을 내리지는 않았다. 별도 inbound/application authorization 조합은 cross-scope 단계가 소유한다.
<!-- body:end -->
@@ -0,0 +1,40 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c52
title: schema 값을 statement text에 붙이지 않고 set_config로 바인딩한다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c52
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c52
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c52.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c52.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L3780 이다.
module: adapter-outbound-persistence-jpa
---
# schema 값을 statement text에 붙이지 않고 set_config로 바인딩한다
`SchemaTenantRegistry`가 unquoted PostgreSQL identifier shape를 제한하고, connection provider는 schema 값을 bound `set_config`로 적용하며 release 시 neutral `pg_catalog`로 reset한다.
## 본문
<!-- body:start -->
`SchemaTenantRegistry`는 unquoted PostgreSQL identifier shape를 제한하고, connection provider는 schema 값을 statement text에 직접 붙이지 않고 bound `set_config`로 적용하며 release 시 neutral `pg_catalog`로 reset한다.
## SchemaTenantRegistry 참조 위치
:::evidence key="adapter-outbound-persistence-jpa-c52" alt="코드베이스에서 SchemaTenantRegistry 를 검색한 출력 14줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="SchemaTenantRegistry 코드베이스 검색 — 14줄 · exit 0" zoom="true"
:::
## 보장하지 않는 범위
별도 failure-in-reset / pool-implementation semantics까지 corpus 전체 보장은 하지 않는다. 다만 현재 happy-path isolation contract를 뒤집을 evidence는 없었다.
<!-- body:end -->
@@ -0,0 +1,40 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c54
title: 이름이 말하는 것과 반환하는 것이 다른 helper
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c54
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c54
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c54.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c54.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L4017 이다.
module: adapter-outbound-persistence-jpa
---
# 이름이 말하는 것과 반환하는 것이 다른 helper
두 public helper는 defining file 밖 exact FQN reference가 0이다. `PostgreSqlContractExtension.serverVersion()`은 이름과 달리 database의 `SHOW server_version`이 아니라 Docker image + container id를 반환한다.
## 본문
<!-- body:start -->
두 public helper는 defining file 밖 exact FQN reference가 0이다. 특히 `PostgreSqlContractExtension.serverVersion()`은 이름과 달리 database의 `SHOW server_version`이 아니라 Docker image + container id를 반환한다.
## CommitAmbiguityProxy 참조 위치
:::evidence key="adapter-outbound-persistence-jpa-c54" alt="코드베이스에서 CommitAmbiguityProxy 를 검색한 출력 4줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="CommitAmbiguityProxy 코드베이스 검색 — 4줄 · exit 0" zoom="true"
:::
## 실제로 server version을 읽는 쪽은 따로 있다
현재 integration support는 별도 `JpaPlatformContractSupport.serverVersion()`로 실제 server version을 읽는다. 따라서 잘못된 current evidence로 분류하지 않고 dead/unadopted helper로 기록한다.
<!-- body:end -->
@@ -0,0 +1,40 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c55
title: 정규식 파서를 통과해도 Gradle이 다시 파싱한다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c55
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c55
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c55.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c55.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L4021 이다.
module: adapter-outbound-persistence-jpa
---
# 정규식 파서를 통과해도 Gradle이 다시 파싱한다
Java testkit parser 자체는 정규식 기반이라 일반 JSON parser가 아니다. 그러나 실제 registry 파일은 root Gradle `verifyJpaReleaseGateTasks`에서 `JsonSlurper`로 다시 parse되고 real task graph까지 resolve한다.
## 본문
<!-- body:start -->
Java testkit parser 자체는 정규식 기반이라 일반-purpose JSON parser가 아니다. 그러나 실제 registry 파일은 root Gradle `verifyJpaReleaseGateTasks`에서 `JsonSlurper`로 다시 parse되고 real task graph까지 resolve한다.
## JpaReleaseManifest 참조 위치
:::evidence key="adapter-outbound-persistence-jpa-c55" alt="코드베이스에서 JpaReleaseManifest 를 검색한 출력 19줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="JpaReleaseManifest 코드베이스 검색 — 19줄 · exit 0" zoom="true"
:::
## 별도 finding으로 중복 승격하지 않은 이유
malformed JSON을 Java regex parser 하나가 받아들일 가능성만으로 release fail-open을 별도 finding으로 중복 승격하지 않는다.
<!-- body:end -->
@@ -0,0 +1,44 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c57
title: registry에서 task graph 한 방향만 검사한다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c57
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c57
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c57.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c57.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L4306 이다.
module: adapter-outbound-persistence-jpa
---
# registry에서 task graph 한 방향만 검사한다
`config/jpa/release-registry.json`의 gate는 6개이고 모두 blocking이다. pool lane은 registry에도 support-matrix에도 `JpaReleaseGate.required()`에도 없는데 `jpaPlatformReleaseGate`가 그것을 의존한다.
## 본문
<!-- body:start -->
`config/jpa/release-registry.json`의 gate는 6개이고 모두 blocking이다. pool lane은 registry에도, `docs/jpa/support-matrix.md` §Release gates 6행에도, testkit `JpaReleaseGate.required()`에도 없다(세 곳 모두 grep exit=1). 그런데 `jpaPlatformReleaseGate``dependsOn jpaPlatformPoolContractTest`를 갖고, root `jpaReleaseGate`가 그것을 다시 의존한다.
## 분석 원문의 세 곳 확인
:::evidence key="adapter-outbound-persistence-jpa-c57" alt="분석 문서 analysis/05-adapter-outbound-persistence-jpa.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/05-adapter-outbound-persistence-jpa.md 발췌 — 15줄" zoom="true"
:::
## 검사되지 않는 반대 방향
`verifyJpaReleaseGateTasks`는 registry → task graph 한 방향만 검사한다 — registry의 각 gate가 실제 `Test` task로 resolve되는가. 반대 방향, 즉 release gate에 들어 있는 lane이 registry에 있는가는 어디서도 검사되지 않는다.
## 결함으로 올리지 않은 이유
`jpaPlatformReleaseGate`의 주석이 밝힌 집계 기준은 "documented gate가 검증되지 않은 채 통과하게 만드는 lane"이고, pool lane은 문서화된 gate를 뒷받침하지 않으므로 기준상 registry에 없는 것이 일관적이다. 다만 그 결과로 `jpaPlatformReleaseGate`에서 pool lane 의존을 지워도 어떤 verifier도 반응하지 않고, 남는 실행 경로는 nightly workflow 한 줄뿐이다. 이 lane의 release gate 소속만은 아무 계약도 보호하지 않는다. P3.
<!-- body:end -->
@@ -0,0 +1,48 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c58
title: JVM 공유를 설계 근거로 내세웠지만 소비자 31곳 어디에도 없다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c58
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c58
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c58.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c58.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L4541 이다.
module: adapter-outbound-persistence-jpa
---
# JVM 공유를 설계 근거로 내세웠지만 소비자 31곳 어디에도 없다
`JpaPlatformContractSupport`의 클래스 javadoc이 말하는 컨테이너 수명과 실제 사용이 다르다. 실제 사용은 per class이고 JVM 수준 공유 인스턴스나 static holder는 없다.
## 본문
<!-- body:start -->
클래스 javadoc은 컨테이너를 JVM 수준에서 공유한다고 말한다. 실제 사용은 정확히 그 "per class"다. `JpaPlatformContractSupport.start()` 호출 지점은 31곳이고 대부분 `@BeforeAll`에서 시작해 `@AfterAll`에서 `close()`한다. JVM 수준 공유 인스턴스나 static holder는 없다.
## 분석 원문의 실측
:::evidence key="adapter-outbound-persistence-jpa-c58" alt="분석 문서 analysis/05-adapter-outbound-persistence-jpa.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/05-adapter-outbound-persistence-jpa.md 발췌 — 15줄" zoom="true"
:::
## 87번 기동하고 3분 10초에 끝난다
`StablePostgreSqlMatrixContractTest`는 test마다, `JpaPlatformContractSupportOwnershipTest`는 test마다(5개) 컨테이너를 새로 띄운다. 한 번의 전체 tag lane 통과에 PostgreSQL 컨테이너가 87번 기동한다(`115-integration-lane-original-verification.txt`, XML의 Testcontainers 로그 집계). 그럼에도 5개 lane 전체가 3분 10초에 끝났으므로 비용 주장이 무너지는 수준은 아니다.
## 기록하는 이유는 서술과 구현의 불일치다
클래스가 자기 설계 근거로 내세운 "JVM 공유"가 소비자 31곳 어디에서도 성립하지 않는다. P3.
## 같은 클래스의 다른 서술은 사실이다
multi-version 선택을 fail-closed로 거부하는 것(`start()``selected.size() != 1`이면 예외), 그리고 "the CI matrix fans out"은 `jpa-release.yml`(16/17/18), `jpa-pr.yml`(16/18), `jpa-nightly.yml``-Pjpa.matrix.versions`로 실제 fan-out하는 것으로 확인된다. `JpaPlatformContractSupportOwnershipTest`가 지키는 pool 소유권(호출당 새 pool을 만들어 참조를 잃던 과거 결함)도 실제 assertion으로 고정돼 있다.
<!-- body:end -->
@@ -0,0 +1,49 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-mongo-c04
title: 취소된 stream은 성공도 실패도 기록하지 않는다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-mongo-c04
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-mongo-c04
file: ../../../final/evidence/rendered/adapter-outbound-persistence-mongo-c04.svg
- key: adapter-outbound-persistence-mongo-c04-diagram
file: ../../../final/assets/diagrams/adapter-outbound-persistence-mongo-c04.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-mongo-c04.txt
source:
- 원본 분석 절은 analysis/06-adapter-outbound-persistence-mongo.md#L648 이다.
module: adapter-outbound-persistence-mongo
---
# 취소된 stream은 성공도 실패도 기록하지 않는다
`DefaultReactiveMongoExecutor`의 javadoc이 blocking 경로가 공짜로 얻는 것과 여기서 직접 배치해야 하는 것을 대비한다. 그 배치의 결과로 취소된 stream이 `result=unknown` 버킷에 남는다.
## 본문
<!-- body:start -->
`DefaultReactiveMongoExecutor`의 javadoc이 blocking 경로가 공짜로 얻는 것과 여기서 직접 배치해야 하는 것을 대비한다.
## 반응형 경로가 직접 배치한 것
:::evidence key="adapter-outbound-persistence-mongo-c04-diagram" alt="실행기 경계 안에 관측 범위와 조립 후 timeout 과 Reactor Context 이동 세 상자가 나란히 들어 있는 구조" caption="반응형 경로가 직접 배치한 것" zoom="false"
:::
observation scope를 Reactor 자원(`Mono.using`/`Flux.using`)으로 두어 완료·오류·**취소** 모두에서 닫는다. HTTP 클라이언트 연결 해제가 취소를 일으키므로 취소가 흔한 경우다. timeout은 조립된 publisher에 적용한다 — 구독 전에 적용하면 "람다를 만드는 데 걸린 시간"을 재게 된다. context는 Reactor Context로 옮긴다(`ReactiveMongoContextKeys`) — 체인은 operator 경계마다 스레드를 바꾸므로 구독 시점의 `ThreadLocal`은 driver 응답 시점에 이미 없다.
## DefaultReactiveMongoExecutor 참조 위치
:::evidence key="adapter-outbound-persistence-mongo-c04" alt="코드베이스에서 DefaultReactiveMongoExecutor 를 검색한 출력 8줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="DefaultReactiveMongoExecutor 코드베이스 검색 — 8줄 · exit 0" zoom="true"
:::
## result=unknown 버킷이 두 가지를 함께 담는다
`executeMany(...)`는 성공을 `doOnComplete`로 기록하므로 **취소된 stream은 success도 failure도 기록하지 않는다.** observation은 `close()`되고 초기 tag(`result=unknown`, `failureCategory=none`)로 한 번 계수된다. 취소가 흔한 경로라는 점을 감안하면 이는 의도된 분류로 보이지만, `result=unknown` bucket이 "취소"와 "관측 시작 직후 예외"를 함께 담는다는 사실은 계약에 없다. P3/기록.
<!-- body:end -->
@@ -0,0 +1,52 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-mongo-c06
title: 더 자주 갱신해도 고쳐지지 않는 문제라서 fencing을 얹는다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-mongo-c06
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-mongo-c06
file: ../../../final/evidence/rendered/adapter-outbound-persistence-mongo-c06.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-mongo-c06.txt
source:
- 원본 분석 절은 analysis/06-adapter-outbound-persistence-mongo.md#L868 이다.
module: adapter-outbound-persistence-mongo
---
# 더 자주 갱신해도 고쳐지지 않는 문제라서 fencing을 얹는다
`MongoMigrationLock.fence()`의 javadoc이 lease 만료와 보유자 정지가 다르다는 것을 적는다 — 첫 runner는 갱신했어야 할 그 순간에 돌고 있지 않다.
## 본문
<!-- body:start -->
`MongoMigrationLock.fence()`의 javadoc이 이 sub-scope에서 가장 정확한 문장을 담고 있다.
> A lease expiring is not the same as its holder stopping. A runner paused inside a long `execute` — a stop-the-world pause, a stalled network write — loses the lease on the server while its thread is still alive and still writing… **Refreshing more often does not fix that: the first runner is not running at the moment it would refresh.**
그래서 lease 위에 monotonic fencing token을 얹고, `MongoCollectionMigrationLock.tryAcquire`가 그 token을 **lease를 부여하는 같은 조건부 update 안에서 서버가 증가**시킨다("A token handed out anywhere else could be handed out twice"). `held()`는 owner 이름이 같아도 fence가 다르면 false를 반환한다 — 프로세스가 재시작했거나 운영자가 owner 문자열을 재사용한 경우다.
## MongoCollectionMigrationLock 참조 위치
:::evidence key="adapter-outbound-persistence-mongo-c06" alt="코드베이스에서 MongoCollectionMigrationLock 를 검색한 출력 15줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MongoCollectionMigrationLock 코드베이스 검색 — 15줄 · exit 0" zoom="true"
:::
## heartbeat이 migration에게 넘겨진 이유
`matchedCount`를 쓰는 이유(같은 값을 다시 쓰면 `modifiedCount`가 0이라 소유권 판정이 뒤집힌다)도 두 곳에 적혀 있다. `MongoMigrationHeartbeat`은 이미 고쳐진 결함의 산물이다 — runner가 `execute`**반환된 뒤에** 한 번만 refresh했으므로, 40분짜리 `execute`는 35분 동안 만료된 lease를 들고 있었고 그 사이 두 번째 runner가 정당하게 획득해 같은 migration을 동시에 돌렸다. 이제 heartbeat이 migration에게 넘겨진다 — batch 경계를 아는 것은 migration뿐이기 때문이다.
## 첫 checkpoint가 전부 거부되던 두 결함
`MongoCollectionMigrationLedger.saveCheckpoint`에는 **두 개의** 결함 이력이 주석으로 남아 있다. upsert 하나로는 "매치할 게 없었다"와 "fence filter가 배제했다"를 구분할 수 없어 *모든 migration의 첫 checkpoint*가 "a newer migration runner owns the lease"로 거부됐고, 동시에 진짜 배제 경로는 unique index의 duplicate-key로 죽어 그 문장을 만드는 분기가 **도달 불가**였다. 지금은 replace-then-insert로 두 경우를 분리한다.
## rollback이 없는 것도 명시적 결정이다
"A rollback method implies the reverse operation is always safe and always possible, and for a backfill that dropped a column's old values it is neither." 실패한 production 변경은 forward-fix migration으로 고친다.
<!-- body:end -->
@@ -0,0 +1,44 @@
---
kind: CONCEPT
slug: adapter-outbound-support-c04
title: 직접 참조 0이지만 broad component scan으로 도달한다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-support-c04
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-support-c04
file: ../../../final/evidence/rendered/adapter-outbound-support-c04.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-support-c04.txt
source:
- 원본 분석 절은 analysis/04-adapter-outbound-support.md#L318 이다.
module: adapter-outbound-support
---
# 직접 참조 0이지만 broad component scan으로 도달한다
`OutboundSupportConfig``@Configuration`이며 `FailOpenDependencyLogger` bean 하나만 제공한다. support 밖 production Java에서 명시적으로 참조하는 파일은 0개지만 현재 active scanned path다.
## 본문
<!-- body:start -->
`OutboundSupportConfig``@Configuration`이며 `FailOpenDependencyLogger` bean 하나만 제공한다. 별도 master property condition은 없다 — support 자체를 optional capability로 취급하지 않고, 실제 provider/client capability의 on/off를 sibling adapter가 소유하게 하려는 구조다.
## OutboundSupportConfig 참조 위치
:::evidence key="adapter-outbound-support-c04" alt="코드베이스에서 OutboundSupportConfig 를 검색한 출력 3줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="OutboundSupportConfig 코드베이스 검색 — 3줄 · exit 0" zoom="true"
:::
## 참조 0인데도 unwired가 아닌 이유
`OutboundSupportConfig`를 support 밖 production Java에서 명시적으로 참조하는 파일은 0개다. 그러나 실제 composition root `CaSkeletonApplication``dev.caskeleton.adapter`를 broad component scan한다. `AUTO_CONFIGURED_PACKAGES` exclusion에는 messaging/notification/persistence 등은 들어가지만 support package는 포함되지 않으므로 support config는 broad component scan으로 도달한다. registry도 support runtime membership을 `app-bootstrap`으로 선언하고 `app-bootstrap/build.gradle`이 support project를 직접 `implementation`한다. 따라서 이 configuration은 현재 **active scanned path**다.
## master switch 누락으로 판정하지 않은 이유
support config 자체에는 `@ConditionalOnProperty`가 없고 `@ConditionalOnMissingBean`만 있다. support는 provider/client를 생성하지 않고 logger bean 하나만 default로 제공한다. 실제 messaging/notification/httpclient 등은 자기 capability root에서 activation을 소유한다. support README와 config javadoc 모두 이 비대칭을 의도적으로 설명한다.
<!-- body:end -->
@@ -0,0 +1,64 @@
---
kind: CONCEPT
slug: application-core-c04
title: 효과가 없었다고 증명할 수 없으면 자동 재시도 권한을 주지 않는다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:application-core-c04
evidenceCapturedOn: 2026-09-01
assets:
- key: application-core-c04
file: ../../../final/evidence/rendered/application-core-c04.svg
- key: application-core-c04-diagram
file: ../../../final/assets/diagrams/application-core-c04.svg
evidence:
- ../../../final/evidence/raw/application-core-c04.txt
source:
- 원본 분석 절은 analysis/03-application-core.md#L118 이다.
module: application-core
---
# 효과가 없었다고 증명할 수 없으면 자동 재시도 권한을 주지 않는다
idempotency V2와 inbox와 outbox 셋이 각각 응답을 잃은 구간을 상태로 보존한다.
## 관계
- **legacy storage/notification compatibility surface의 제거 조건 추적**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
초기 contract는 scope + request fingerprint로 claim/replay를 제공하고, same key/different fingerprint를 conflict로 분리한다. completed result는 replay하고 in-flight는 bounded poll한다. 이 버전은 "DB operation의 효과가 이미 발생했지만 응답만 잃은 상태"를 충분히 표현하지 못한다.
## 응답을 잃은 구간을 맡는 세 장치
:::evidence key="application-core-c04-diagram" alt="응답을 잃은 구간에서 멱등 기록과 인박스와 아웃박스 세 상자로 화살표가 나가고 화살표마다 담당 방식이 붙은 구조" caption="응답을 잃은 구간을 맡는 세 장치" zoom="false"
:::
V2는 owner-safe CAS handle에 scope/token/attempt/revision/claimOperationId를 넣고 stale owner mutation을 거부한다. processing-start를 durable하게 확인하기 전에는 body를 실행하지 않으며 claim/start/completion의 unknown result는 inspect/reconcile 대상으로 남긴다.
## 증명할 수 없는 것을 재시도 근거로 쓰지 않는다
processing start 이후 ordinary RuntimeException은 효과가 없다고 증명할 수 없으므로 `EFFECT_UNKNOWN_ABANDONED` 쪽으로 분류되고 자동 replay 권한을 주지 않는다. 명시적인 `RetryableNoEffect`만 안전 재시도 근거로 취급한다.
## RetryableNoEffect 참조 위치
:::evidence key="application-core-c04" alt="코드베이스에서 RetryableNoEffect 를 검색한 출력 4줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="RetryableNoEffect 코드베이스 검색 — 4줄 · exit 0" zoom="true"
:::
## bounded 하게 만든 것들
scope digest는 versioned keyed digest + operation code로 정규화되고 raw identity는 외부 surface에서 제거된다. lease/replay TTL과 owner token grammar도 bounded다.
**Historical evidence.** V2 contract가 인접한 package에 중복 복제돼 구현체들이 서로 다른 nominal type을 참조한 문제가 있었고, singular contract를 유지하는 regression test가 존재한다.
## inbox 상태 모델
Inbox contract는 same-store 처리와 owner-safe receive/process state를 모델링한다. `RECEIVED -> PROCESSING -> COMPLETED/RETRYABLE/DEAD` 상태를 가지고 ACK는 handler transaction commit 이후에만 가능하다. expired owner가 늦게 결과를 기록하는 것을 owner token/attempt/revision/operation identity로 막는다.
<!-- body:end -->
@@ -0,0 +1,57 @@
---
kind: CONCEPT
slug: application-core-c05
title: efficiency lock이 correctness authority를 대체하지 않는다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:application-core-c05
evidenceCapturedOn: 2026-09-01
assets:
- key: application-core-c05
file: ../../../final/evidence/rendered/application-core-c05.svg
evidence:
- ../../../final/evidence/raw/application-core-c05.txt
source:
- 원본 분석 절은 analysis/03-application-core.md#L152 이다.
module: application-core
---
# efficiency lock이 correctness authority를 대체하지 않는다
`CacheAsideExecutor`는 결과 종류를 명시적으로 구분하고, lease와 lock은 `EFFICIENCY_ONLY`로 자기 보증 등급을 스스로 적는다.
## 관계
- **legacy storage/notification compatibility surface의 제거 조건 추적**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
`CacheAsideExecutor`는 fresh/negative hit, hard miss, stale, incompatible schema, provider unavailable을 명시적으로 구분한다. source load에는 key-local single-flight와 global source bulkhead를 함께 적용한다. in-flight key 수, waiter 수, source concurrency, admission wait, load deadline이 모두 bounded다.
## CacheAsideExecutor 참조 위치
:::evidence key="application-core-c05" alt="코드베이스에서 CacheAsideExecutor 를 검색한 출력 22줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="CacheAsideExecutor 코드베이스 검색 — 22줄 · exit 0" zoom="true"
:::
## stale을 돌려주는 조건과 돌려주지 않는 조건
stale value는 hard expiry 이전이며 **classified transient failure**일 때만 fallback될 수 있다. permanent failure에는 stale을 반환하지 않는다.
## invalidate 직후의 resurrect race를 막는 방법
source load 중 invalidation이 발생하면 lookup 때 캡처한 `CacheWriteCondition`이 더 이상 일치하지 않아 이전 source result의 refill을 거부한다. 이는 invalidate 직후 늦게 끝난 source load가 stale value를 resurrect하는 race를 막는다.
## refresh 조정은 correctness lock이 아니다
optional distributed refresh coordination은 soft lease로 한 pod만 refresh하도록 하지만 correctness lock은 아니다. owner는 lease 획득 후 cache를 재확인해 다른 pod가 이미 fill했다면 source를 호출하지 않는다. claim 결과가 indeterminate이면 **동일 attempt token으로 한 번만 재시도**한다. contender는 stale이 아직 valid하면 즉시 stale을 반환할 수 있다.
## single-flight가 leader를 영원히 두지 않는 방법
`CacheSingleFlight`는 waiter timeout/interruption을 보존하고 완료된 flight를 제거한다. leader가 영원히 남아 key bound를 점유하지 않도록 monotonic deadline 이후 abandoned flight를 opportunistic reap한다.
<!-- body:end -->
@@ -0,0 +1,57 @@
---
kind: CONCEPT
slug: application-core-c09
title: 메타데이터와 물리 내용이 원자적이지 않다는 사실을 숨기지 않는다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:application-core-c09
evidenceCapturedOn: 2026-09-01
assets:
- key: application-core-c09
file: ../../../final/evidence/rendered/application-core-c09.svg
evidence:
- ../../../final/evidence/raw/application-core-c09.txt
source:
- 원본 분석 절은 analysis/03-application-core.md#L200 이다.
module: application-core
---
# 메타데이터와 물리 내용이 원자적이지 않다는 사실을 숨기지 않는다
fileserver의 핵심 contract는 metadata transaction과 filesystem/object I/O가 원자적이지 않다는 사실을 숨기지 않고 recovery model을 두는 것이다.
## 관계
- **legacy storage/notification compatibility surface의 제거 조건 추적**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
fileserver는 application-core 안의 가장 큰 독립 orchestration 중 하나다. 핵심 contract는 "metadata transaction과 filesystem/object I/O가 원자적이지 않다"는 사실을 숨기지 않고 recovery model을 두는 것이다.
## 거절이 부작용을 남기지 않게 하는 순서
upload admission은 authorization을 quota/storage admission보다 먼저 수행해 denial이 side-effect-free이도록 한다. reservation metadata/session은 DB transaction에서 만들지만 staging physical object는 외부 작업이므로 실패 시 compensation/reconciliation 대상이 된다.
## AmbiguousCompletionException 참조 위치
:::evidence key="application-core-c09" alt="코드베이스에서 AmbiguousCompletionException 를 검색한 출력 26줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="AmbiguousCompletionException 코드베이스 검색 — 26줄 · exit 0" zoom="true"
:::
## writer가 fencing token을 쓰는 이유
writer는 one-writer lease + fencing token을 사용한다. stale token은 append/finalize를 진행할 수 없고 takeover는 새 token을 만든다. append 시 metadata offset과 physical length가 다르면 자동 repair하지 않고 conflict로 중단한다.
## finalize의 검사 순서와 READY의 지위
finalize는 declared length, server-computed digest, optional client digest를 순서대로 검사한다. client digest는 server digest를 대체하지 않는다. 이후 VERIFYING으로 이동하고 verifier가 publish 승인해야 READY가 된다. READY가 유일한 public/downloadable state다.
## publish 뒤 메타데이터가 실패하면 재시도가 아니다
publish physical success 뒤 READY metadata transaction이 실패하면 결과는 단순 retryable failure가 아니라 `AmbiguousCompletionException`과 recovery queue로 간다. physical publish가 이미 발생했을 수 있기 때문이다. cancel/cleanup race를 막기 위해 cleanup claim은 writer/cleaner barrier를 형성하며 stale cleanup claim을 reclaim하는 경로가 실제로 호출된다.
<!-- body:end -->
@@ -0,0 +1,49 @@
---
kind: CONCEPT
slug: domain-core-c01
title: 도메인 내용이 아니라 도메인 모델링 계약을 소유한다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:domain-core-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: domain-core-c01
file: ../../../final/evidence/rendered/domain-core-c01.svg
- key: domain-core-c01-diagram
file: ../../../final/assets/diagrams/domain-core-c01.svg
evidence:
- ../../../final/evidence/raw/domain-core-c01.txt
source:
- 원본 분석 절은 analysis/01-domain-core.md#L68 이다.
module: domain-core
---
# 도메인 내용이 아니라 도메인 모델링 계약을 소유한다
현재 `domain-core`에는 WorkLog 같은 실제 aggregate가 없다. 실제 샘플 aggregate/value object/event는 `sample-portfolio`에 있다.
## 본문
<!-- body:start -->
현재 `domain-core`에는 WorkLog 같은 실제 aggregate가 없다. 실제 샘플 aggregate/value object/event는 `sample-portfolio`에 있다.
## 이 모듈이 실제로 소유한 것
:::evidence key="domain-core-c01-diagram" alt="모듈 경계 안에 식별자 추상화와 모델링 표식 두 상자가 들어 있고 sample-portfolio 의 aggregate 가 경계 밖 점선 상자로 놓인 구조" caption="이 모듈이 실제로 소유한 것" zoom="false"
:::
이 모듈에 남은 production surface는 두 종류다 — **식별자 추상화**(`ResourceId`, `IdFactory`)와 **모델링 표식**(`@ValueObject`, `@AggregateRoot`, `@DomainEvent`).
## ResourceId 참조 위치
:::evidence key="domain-core-c01" alt="코드베이스에서 ResourceId 를 검색한 출력 2줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ResourceId 코드베이스 검색 — 2줄 · exit 0" zoom="true"
:::
## 정책이 허용하는 범위와 현재 내용의 차이
"business concepts, entities, value objects…"를 둘 수 있는 계층이라는 정책과 달리, 현재 snapshot의 실제 contents는 skeleton 전반에서 사용할 **domain-layer contract/marker**에 가깝다. 이는 현재 source에 대한 관찰이며, 향후 실제 production domain type이 이 module에 추가되지 않는다는 뜻은 아니다.
<!-- body:end -->
@@ -0,0 +1,55 @@
---
kind: CONCEPT
slug: grpc-advanced-resilience-c01
title: 헤지는 성공했을지도 모르는 호출에 대해 일어난다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:grpc-advanced-resilience-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-advanced-resilience-c01
file: ../../../final/evidence/rendered/grpc-advanced-resilience-c01.svg
- key: grpc-advanced-resilience-c01-diagram
file: ../../../final/assets/diagrams/grpc-advanced-resilience-c01.svg
evidence:
- ../../../final/evidence/raw/grpc-advanced-resilience-c01.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-advanced-resilience.md#L58 이다.
module: grpc-advanced-resilience
---
# 헤지는 성공했을지도 모르는 호출에 대해 일어난다
헤징 예산은 토큰 버킷이다. 헤지 하나가 `round(1/ratio)` 토큰을 쓰고, 완료된 호출 하나가 토큰 하나를 돌려준다.
## 본문
<!-- body:start -->
토큰 버킷이다. 헤지 하나가 `round(1/ratio)` 토큰을 쓰고, 완료된 호출 하나가 토큰 하나를 돌려준다. 상한이 조용한 구간 뒤의 폭주를 제한한다.
## 헤지가 허용되는 조건
:::evidence key="grpc-advanced-resilience-c01-diagram" alt="첫 시도와 예산 확인과 토큰 소비와 헤지 시도가 왼쪽에서 오른쪽으로 이어지는 구조" caption="헤지가 허용되는 조건" zoom="false"
:::
비율 상한이 0.5 이고 그 근거가 적혀 있다.
> "a hedging ratio above 0.5 means more than half of all calls are duplicated, which is a load decision rather than a latency one"
## 재시도 예산보다 급한 이유
> "A retry happens after a failure; a hedge happens on a call that might have succeeded, so a fleet that hedges without a budget doubles its backend load in the steady state and doubles it again the moment latency rises."
## 이 기록이 다루는 파일 범위
:::evidence key="grpc-advanced-resilience-c01" alt="코드베이스에서 파일 목록을 만든 출력 16줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 16줄 · exit 0" zoom="true"
:::
## 소비가 비교 후 교체 루프다
이 가족에서 원자성을 제대로 다룬 몇 안 되는 곳이다.
<!-- body:end -->
@@ -0,0 +1,55 @@
---
kind: CONCEPT
slug: grpc-codegen-c01
title: 손으로 적은 요구 목록은 갱신이 멈추는 쪽이다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:grpc-codegen-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-codegen-c01
file: ../../../final/evidence/rendered/grpc-codegen-c01.svg
- key: grpc-codegen-c01-diagram
file: ../../../final/assets/diagrams/grpc-codegen-c01.svg
evidence:
- ../../../final/evidence/raw/grpc-codegen-c01.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-codegen.md#L104 이다.
module: grpc-codegen
---
# 손으로 적은 요구 목록은 갱신이 멈추는 쪽이다
`GrpcConsumerFixture.fromJavaSource`가 릴리스된 소비자의 자바 소스에서 요구 사항 셋을 기계적으로 유도한다.
## 본문
<!-- body:start -->
`GrpcConsumerFixture.fromJavaSource` 가 릴리스된 소비자의 자바 소스에서 요구 사항 셋을 기계적으로 유도한다.
> "a hand-written requirement list is a second copy of what the client already says and the copy is the one that stops being updated."
## 소비자 소스에서 유도하는 셋
:::evidence key="grpc-codegen-c01-diagram" alt="소비자 자바 소스에서 생성 패키지와 서비스 경로와 메서드 경로 세 상자로 화살표가 나가고 화살표마다 유도 규칙이 붙은 구조" caption="소비자 소스에서 유도하는 셋" zoom="false"
:::
```text
생성 자바 패키지 = fixture 클래스가 import 하는 패키지 중 접미가 맞는 것
서비스 = <Name>Grpc import → <proto package>.<Name>
메서드 = stub.<name>( 호출 → <service>/<UpperCamelName>
```
## GrpcConsumerFixture 참조 위치
:::evidence key="grpc-codegen-c01" alt="코드베이스에서 GrpcConsumerFixture 를 검색한 출력 26줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcConsumerFixture 코드베이스 검색 — 26줄 · exit 0" zoom="true"
:::
## 근사라는 것과 보고를 합치지 않는다는 것
그것이 컴파일의 근사라는 것과, 근사인 이유(ADR-GRPC-002)를 함께 적는다. `breaksAgainst` 는 세 종류를 따로 보고한다 — 서비스 경로, 메서드 경로, 자바 패키지. 하나의 개수로 합치지 않는다.
<!-- body:end -->
@@ -0,0 +1,49 @@
---
kind: CONCEPT
slug: grpc-discovery-c01
title: 위험한 조합은 정책이 아니라 생성자가 거부한다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:grpc-discovery-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-discovery-c01
file: ../../../final/evidence/rendered/grpc-discovery-c01.svg
- key: grpc-discovery-c01-diagram
file: ../../../final/assets/diagrams/grpc-discovery-c01.svg
evidence:
- ../../../final/evidence/raw/grpc-discovery-c01.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-discovery.md#L85 이다.
module: grpc-discovery
---
# 위험한 조합은 정책이 아니라 생성자가 거부한다
`GrpcResolverProfile``GrpcKubernetesProfile`의 정규 생성자가 일곱 조합을 아예 만들 수 없게 하고, 나머지는 검증기가 잡는다.
## 본문
<!-- body:start -->
`GrpcResolverProfile` 정규 생성자가 네 조합을 아예 만들 수 없게 한다 — 주소 0 이하, 단일 엔드포인트 리졸버에 복수 주소, 음수 갱신 주기, DNS 인데 갱신 주기 0. `GrpcKubernetesProfile` 정규 생성자는 셋을 막는다 — 메시 라우팅에 in-process 재시도 소유자, 긴 스트림인데 재접속 예산 0, 긴 스트림인데 배수 유예 0.
## 생성자가 막는 것과 검증기가 잡는 것
:::evidence key="grpc-discovery-c01-diagram" alt="정규 생성자 경계 안에 세 종류의 조합이 들어 있고 검증기가 잡는 조합이 경계 밖 점선 상자로 놓인 구조" caption="생성자가 막는 것과 검증기가 잡는 것" zoom="false"
:::
두 겹의 역할 분담이 이 저장소의 다른 곳에 적힌 규칙과 같다 — 위험한 조합은 정책이 아니라 생성자가 거부하게 만든다.
## GrpcResolverProfile 참조 위치
:::evidence key="grpc-discovery-c01" alt="코드베이스에서 GrpcResolverProfile 를 검색한 출력 16줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcResolverProfile 코드베이스 검색 — 16줄 · exit 0" zoom="true"
:::
## 검증기 규칙이 일부 조합에서만 발화하는 이유
`MESH` + `GRPC_PLATFORM` 은 생성자가 먼저 던지므로(둘 다 in-process 재시도) 검증기까지 오지 않고, `MESH` + `NONE` 이나 `K8S_VIP` + `SERVICE_MESH` 는 생성자를 통과해 검증기가 잡는다. 도달 불가 분기가 아니라 역할 분담이다.
<!-- body:end -->
@@ -0,0 +1,40 @@
---
kind: CONCEPT
slug: grpc-operation-ledger-jpa-c01
title: record를 지나지 않는 쓰기가 있어서 제약을 DB에도 둔다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:grpc-operation-ledger-jpa-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-operation-ledger-jpa-c01
file: ../../../final/evidence/rendered/grpc-operation-ledger-jpa-c01.svg
evidence:
- ../../../final/evidence/raw/grpc-operation-ledger-jpa-c01.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-operation-ledger-jpa.md#L53 이다.
module: grpc-operation-ledger-jpa
---
# record를 지나지 않는 쓰기가 있어서 제약을 DB에도 둔다
마이그레이션 헤더가 왜 애플리케이션 검사가 아니라 제약인지 적는다. 마이그레이션·백필·지원 스크립트가 쓴 행은 record를 지나지 않기 때문이다.
## 본문
<!-- body:start -->
마이그레이션 헤더가 왜 애플리케이션 검사가 아니라 제약인지 적는다. 커밋 행이 결과를 반드시 갖는다는 검사를 자바 record 와 DB 양쪽에 둔 이유도 적혀 있다 — 마이그레이션·백필·지원 스크립트가 쓴 행은 record 를 지나지 않는다.
## 이 기록이 다루는 파일 범위
:::evidence key="grpc-operation-ledger-jpa-c01" alt="코드베이스에서 파일 목록을 만든 출력 3줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 3줄 · exit 0" zoom="true"
:::
## 전용 Flyway 위치를 쓰는 이유
`db/migration/grpc`를 쓴다. gRPC 플랫폼을 채택하지 않은 배포가 이 테이블을 만들도록 강요받지 않기 위해서다.
<!-- body:end -->
@@ -0,0 +1,46 @@
---
kind: CONCEPT
slug: grpc-operation-ledger-jpa-c02
title: 같은 신원의 두 번째 청구가 같은 행을 겨냥한다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:grpc-operation-ledger-jpa-c02
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-operation-ledger-jpa-c02
file: ../../../final/evidence/rendered/grpc-operation-ledger-jpa-c02.svg
evidence:
- ../../../final/evidence/raw/grpc-operation-ledger-jpa-c02.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-operation-ledger-jpa.md#L76 이다.
module: grpc-operation-ledger-jpa
---
# 같은 신원의 두 번째 청구가 같은 행을 겨냥한다
기본 키는 유니크 제약의 세 컬럼을 이어 붙인 파생값이다. 복합 쪽이 원자성을 주고 파생 키가 조회에 단일 컬럼 기본 키를 준다.
## 본문
<!-- body:start -->
기본 키는 유니크 제약의 세 컬럼을 이어 붙인 파생값이다.
```java
public String storageKey() {
return callerFingerprint + "|" + method.canonical() + "|" + idempotencyKeyHash;
}
```
## GrpcOperationIdentity 참조 위치
:::evidence key="grpc-operation-ledger-jpa-c02" alt="코드베이스에서 GrpcOperationIdentity 를 검색한 출력 19줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcOperationIdentity 코드베이스 검색 — 19줄 · exit 0" zoom="true"
:::
## 이중 저장의 근거와 그 결과
엔티티 javadoc 이 그 이중 저장을 설명한다 — 복합 쪽이 원자성을 주고, 파생 키가 조회에 단일 컬럼 기본 키를 준다. 그래서 같은 신원의 두 번째 청구는 **같은 기본 키 행**을 겨냥한다. §17.1 이 그 사실에서 나온다.
<!-- body:end -->
@@ -0,0 +1,47 @@
---
kind: CONCEPT
slug: grpc-operation-ledger-jpa-c03
title: 서수 컬럼은 값이 끼어들면 모든 행을 조용히 바꾼다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:grpc-operation-ledger-jpa-c03
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-operation-ledger-jpa-c03
file: ../../../final/evidence/rendered/grpc-operation-ledger-jpa-c03.svg
- key: grpc-operation-ledger-jpa-c03-diagram
file: ../../../final/assets/diagrams/grpc-operation-ledger-jpa-c03.svg
evidence:
- ../../../final/evidence/raw/grpc-operation-ledger-jpa-c03.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-operation-ledger-jpa.md#L108 이다.
module: grpc-operation-ledger-jpa
---
# 서수 컬럼은 값이 끼어들면 모든 행을 조용히 바꾼다
`IN_PROGRESS`에서만 전이할 수 있고 커밋은 결과 참조가 비면 거부한다. 상태 컬럼은 `EnumType.STRING`이다.
## 본문
<!-- body:start -->
`IN_PROGRESS` 에서만 전이할 수 있다(`requireInProgress`). 커밋은 결과 참조가 비면 거부한다.
## 원장의 전이
:::evidence key="grpc-operation-ledger-jpa-c03-diagram" alt="IN_PROGRESS 에서 COMMITTED 로 가는 실선 화살표와 FAILED 로 가는 점선 화살표가 있고 두 종착 상태에서 나가는 화살표는 없는 구조" caption="원장의 전이" zoom="false"
:::
## EnumType 참조 위치
:::evidence key="grpc-operation-ledger-jpa-c03" alt="코드베이스에서 EnumType 를 검색한 출력 25줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="EnumType 코드베이스 검색 — 25줄 · exit 0" zoom="true"
:::
## 상태 컬럼을 문자열로 두는 이유
`EnumType.STRING` 을 쓰는 이유가 javadoc 에 있다 — 서수 컬럼은 열거형에 값이 끼어들면 저장된 모든 행을 조용히 다른 값으로 만든다.
<!-- body:end -->
@@ -0,0 +1,46 @@
---
kind: CONCEPT
slug: grpc-spring-boot-starter-c02
title: 한 번에 전부 모아 실패한다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:grpc-spring-boot-starter-c02
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-spring-boot-starter-c02
file: ../../../final/evidence/rendered/grpc-spring-boot-starter-c02.svg
evidence:
- ../../../final/evidence/raw/grpc-spring-boot-starter-c02.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-spring-boot-starter.md#L87 이다.
module: grpc-spring-boot-starter
---
# 한 번에 전부 모아 실패한다
시작 검증기가 다섯 갈래 규칙을 담고, 어긴 것을 하나씩 던지지 않고 한 번에 모아 실패한다 — "so a deployment learns the whole list in one restart."
## 본문
<!-- body:start -->
javadoc 이 선정 기준을 적고, 규칙이 다섯 갈래다.
- **전송·보안** — production 전송이 아니면 거부, 배포 환경에서 TLS 미사용·trust-all·반사 전체 공개 거부
- **실행기** — 큐 용량 1 미만(무제한) 거부, 풀 크기 양수 요구
- **메서드** — 단항인데 사용 가능한 마감이 0, 명시적 재시도가 멱등 프로파일과 모순, 멱등 키 필수인데 원장 비활성, Stable 범위 밖 RPC 종류
- **채널** — Stable 스킴 요구, 두 재시도 소유자가 동시에 in-process 재시도
- **고급 격리** — Stable 스타터가 advanced 의존을 끌면 위반
## 이 기록이 다루는 파일 범위
:::evidence key="grpc-spring-boot-starter-c02" alt="코드베이스에서 파일 목록을 만든 출력 4줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 4줄 · exit 0" zoom="true"
:::
## 하나씩 던지지 않는 이유
그리고 한 번에 전부 모아 실패한다 — "so a deployment learns the whole list in one restart."
<!-- body:end -->
@@ -0,0 +1,46 @@
---
kind: CONCEPT
slug: grpc-testkit-c01
title: 런북을 나중에 쓰면 처음 만나는 사람이 새벽에 알아내야 한다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:grpc-testkit-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-testkit-c01
file: ../../../final/evidence/rendered/grpc-testkit-c01.svg
evidence:
- ../../../final/evidence/raw/grpc-testkit-c01.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-testkit.md#L102 이다.
module: grpc-testkit
---
# 런북을 나중에 쓰면 처음 만나는 사람이 새벽에 알아내야 한다
릴리스 게이트가 문서 부재를 차단 사유로 삼는다. 차단 사유가 다섯 갈래다.
## 본문
<!-- body:start -->
릴리스 게이트가 문서 부재를 차단 사유로 삼는 근거가 적혀 있다.
> "A platform whose failure modes are `COMPLETION_UNKNOWN` and a stream that needs a full resync is a platform whose on-call has to be told what to do about them; shipping the behaviour and writing the runbook afterwards means the first person to meet it is the one who has to work it out at three in the morning."
## 차단 사유 다섯 갈래
호환성 표의 누락 결과, 생산되지 않은 증거 등급, 스키마 발행 거부, 런북 부재, 결정 기록 부재, 지원 표 부재.
## 이 기록이 다루는 파일 범위
:::evidence key="grpc-testkit-c01" alt="코드베이스에서 파일 목록을 만든 출력 25줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 25줄 · exit 0" zoom="true"
:::
## 거절 메시지가 담는 것
`requireCertified` 는 능력이 이번 릴리스가 낸 증거로 인증되지 않으면 던지고, 메시지에 실제로 돈 등급을 나열한다.
<!-- body:end -->
@@ -0,0 +1,49 @@
---
kind: CONCEPT
slug: messaging-admin-api-c06
title: 실행 코드가 없어서 동시성 계약이 전부 문서다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-admin-api-c06
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-admin-api-c06
file: ../../../final/evidence/rendered/messaging-admin-api-c06.svg
- key: messaging-admin-api-c06-diagram
file: ../../../final/assets/diagrams/messaging-admin-api-c06.svg
evidence:
- ../../../final/evidence/raw/messaging-admin-api-c06.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-admin-api.md#L588 이다.
module: messaging-admin-api
---
# 실행 코드가 없어서 동시성 계약이 전부 문서다
이 리프에 실행 코드가 없으므로 동시성 계약은 전부 인터페이스 문서로 표현되어 있고, 강제는 구현 리프의 몫이다.
## 본문
<!-- body:start -->
이 리프에 실행 코드가 없으므로 동시성 계약은 전부 **인터페이스 문서로 표현**되어 있고, 강제는 구현 리프의 몫이다.
## 타입이 지는 것과 구현이 지는 것
:::evidence key="messaging-admin-api-c06-diagram" alt="리프 경계 안에 불변 타입과 펜싱 토큰 하한이 들어 있고 오래된 토큰 거절이 경계 밖 점선 상자로 놓인 구조" caption="타입이 지는 것과 구현이 지는 것" zoom="false"
:::
펜싱 토큰의 하한이 타입으로 강제된다. `begin`/`checkpoint` 의 javadoc 이 각각 던져야 할 조건을 명시한다 — `begin` 은 "already completed, or another runtime holds a live lease", `checkpoint` 는 "the lease has been taken over by a newer token". 즉 **오래된 토큰의 쓰기를 거절하는 것이 구현 의무**로 문서화되어 있다.
## DestructiveOperationGuard 참조 위치
:::evidence key="messaging-admin-api-c06" alt="코드베이스에서 DestructiveOperationGuard 를 검색한 출력 19줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="DestructiveOperationGuard 코드베이스 검색 — 19줄 · exit 0" zoom="true"
:::
## 공유해도 안전한 이유와 수명주기 훅
이 리프의 모든 타입은 record 이거나 불변 final 클래스다. `DestructiveOperationGuard``final boolean` 하나만 갖고, `HmacApprovalVerifier` 는 키를 clone 해 보관한다. 수명주기 훅은 없다 — `MessagingAdminDurabilityValidator`(스타터, `InitializingBean`)가 유일한 기동 시점 훅이며 이 리프 밖이다.
<!-- body:end -->
@@ -0,0 +1,48 @@
---
kind: CONCEPT
slug: messaging-admin-api-c07
title: 승인이 처음에는 데이터였고 지금은 타입이다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-admin-api-c07
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-admin-api-c07
file: ../../../final/evidence/rendered/messaging-admin-api-c07.svg
evidence:
- ../../../final/evidence/raw/messaging-admin-api-c07.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-admin-api.md#L802 이다.
module: messaging-admin-api
---
# 승인이 처음에는 데이터였고 지금은 타입이다
javadoc이 커밋 로그를 대신한다. 여섯 개의 "이전에는 이랬다" 기록이 전부 같은 결함 계열을 가리킨다 — 자기 자신을 근거로 삼는 주장.
## 본문
<!-- body:start -->
`messaging-testkit` 과 마찬가지로 이 리프도 **javadoc 이 커밋 로그를 대신한다**. 여섯 개의 "이전에는 이랬다" 기록이 있고, 전부 같은 결함 계열을 가리킨다: **자기 자신을 근거로 삼는 주장.**
| 위치 | 기록된 과거 결함 |
|---|---|
| `VerifiedApproval.java:9-13` | "…a plain `AdminApproval` record with a public constructor, so 'this plan was approved' was a claim the caller made about itself." |
| `ApprovalGrant.java:11-14` | "`AdminApproval` carried a ticket, an approver, and a window. Nothing in it said which operation… the audit trail recorded a ticket that proved nothing about what was executed." |
| `PlanDigest.java:12-16` | "The operator who got a redrive of one dead-letter destination approved could execute a redrive of a different one with the same ticket, and every audit record would look correct." |
| `AdminOperationJournal.java:10-14` | "…a `ConcurrentHashMap` registered by the starter as the default… both are worse than having no store at all because the map made the platform look protected." |
| `AdminOperationState.java:5-9` | "…recorded one fact — 'this approval was claimed' — and recorded it before any work happened." |
## 이 기록이 다루는 파일 범위
:::evidence key="messaging-admin-api-c07" alt="코드베이스에서 파일 목록을 만든 출력 25줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 25줄 · exit 0" zoom="true"
:::
## 일곱 기록이 하나의 이야기다
**승인이 처음에는 데이터였고, 지금은 타입이다.** 커밋 로그 자체는 정보가 없다.
<!-- body:end -->
@@ -0,0 +1,53 @@
---
kind: CONCEPT
slug: messaging-admin-runtime-c03
title: 리드라이브는 재개하고 리플레이는 처음부터 다시 읽는다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-admin-runtime-c03
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-admin-runtime-c03
file: ../../../final/evidence/rendered/messaging-admin-runtime-c03.svg
- key: messaging-admin-runtime-c03-diagram
file: ../../../final/assets/diagrams/messaging-admin-runtime-c03.svg
evidence:
- ../../../final/evidence/raw/messaging-admin-runtime-c03.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-admin-runtime.md#L163 이다.
module: messaging-admin-runtime
---
# 리드라이브는 재개하고 리플레이는 처음부터 다시 읽는다
리드라이브는 `lease.resumeFrom()`과 체크포인트 콜백을 실행 측에 넘기지만 리플레이는 넘기지 않는다. `ReplayService.replay(...)` 시그니처에 `resumeFrom`이 없다.
## 본문
<!-- body:start -->
검사 순서가 요점이다. 승인이 아직 창 안인지, 계획 승인 이후 토폴로지가 바뀌지 않았는지, 승인이 이미 실행되지 않았는지를 순서대로 보고 그다음에야 무엇이 움직인다. 저널 항목은 작업 **전에** 쓴다 — 나중에 쓰면 첫 실행이 도는 중에 두 번째 실행이 시작되는 창이 생기고, 그것이 저널이 막으려는 이중 리드라이브다.
## 재개가 붙는 쪽과 붙지 않는 쪽
:::evidence key="messaging-admin-runtime-c03-diagram" alt="실행 경계 안에 리드라이브가 들어 있고 리플레이가 경계 밖 빗금 상자로 놓인 구조" caption="재개가 붙는 쪽과 붙지 않는 쪽" zoom="false"
:::
**리플레이와 리드라이브의 비대칭이 하나 있다.** 리드라이브는 `lease.resumeFrom()` 과 체크포인트 콜백을 실행 측에 넘기지만, 리플레이는 넘기지 않는다. `ReplayService.replay(...)` 시그니처에 `resumeFrom` 이 없다(`ReplayService.java:53-54`). 즉 리플레이는 리스를 받지만 재개하지 않는다 — 죽으면 처음부터 다시 읽는다. 클래스 javadoc 의 "a retry continues the same operation instead of either redoing it" 은 리드라이브에만 해당한다.
## DestructiveOperationGuard 참조 위치
:::evidence key="messaging-admin-runtime-c03" alt="코드베이스에서 DestructiveOperationGuard 를 검색한 출력 19줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="DestructiveOperationGuard 코드베이스 검색 — 19줄 · exit 0" zoom="true"
:::
## 격리 리플레이가 dry run인 척으로 통과한다
`DestructiveOperationGuard.authorize``dryRun` 이 참이면 즉시 반환한다(`DestructiveOperationGuard.java:54-56`). 즉 격리 리플레이는 "승인 불필요" 가 아니라 "dry run 인 척" 으로 통과한다. 감사 이벤트는 그 구분을 남긴다 — `approval.map(VerifiedApproval::ticket).orElse("isolated")`(`:77`) — 그러나 guard 쪽에는 남지 않는다. §17 P3.
## 세 수정 중 세 번째의 인덱스 계산
세 가지 실패를 고쳤다고 javadoc 이 적고, 세 수정이 코드에 있다. 발행 → 확인 → 정산 순서가 이 리프의 핵심 불변식이다. **세 번째 수정 — 재개 — 는 인덱스 계산이 틀렸다.** §12.1(a)에서 상술한다.
<!-- body:end -->
@@ -0,0 +1,53 @@
---
kind: CONCEPT
slug: messaging-admin-runtime-c06
title: 펜싱 토큰이 세 지점에서 작동한다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-admin-runtime-c06
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-admin-runtime-c06
file: ../../../final/evidence/rendered/messaging-admin-runtime-c06.svg
- key: messaging-admin-runtime-c06-diagram
file: ../../../final/assets/diagrams/messaging-admin-runtime-c06.svg
evidence:
- ../../../final/evidence/raw/messaging-admin-runtime-c06.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-admin-runtime.md#L493 이다.
module: messaging-admin-runtime
---
# 펜싱 토큰이 세 지점에서 작동한다
트랜잭션 경계 없이 `ConcurrentHashMap.compute(...)`로 키 단위 원자성을 얻고, 펜싱 토큰이 인수·쓰기·clamp 세 지점에서 작동한다.
## 본문
<!-- body:start -->
트랜잭션 경계 없음 — `InMemoryAdminOperationJournal``ConcurrentHashMap.compute(...)` 로 키 단위 원자성을 얻는다(`:44, :198`). `begin` 의 검사-후-갱신 전체가 `compute` 람다 안에 있어 두 복제본이 동시에 `begin` 해도 하나만 성공한다. `AdminOperationJournalTest.twoReplicasRacingProduceExactlyOneLease` 가 그것을 검증한다.
## 펜싱 토큰이 동작하는 세 지점
:::evidence key="messaging-admin-runtime-c06-diagram" alt="저널 경계 안에 인수 시 토큰 증가와 쓰기 시 토큰 대조와 clamp 로 단조성 유지 세 상자가 나란히 들어 있는 구조" caption="펜싱 토큰이 동작하는 세 지점" zoom="false"
:::
인수 시 `existing.leaseToken() + 1`(`:110`), 쓰기 시 토큰 대조(`:206`), 그리고 clamp 로 인한 단조성(`:128, :147, :167`). `aRuntimeThatLostItsLeaseCannotWriteOverTheSuccessor` 가 세 가지를 한 번에 확인한다 — 낡은 리스의 `complete(30)` 이 거절되고 기록은 45·STARTED 로 남는다.
## InMemoryAdminOperationJournal 참조 위치
:::evidence key="messaging-admin-runtime-c06" alt="코드베이스에서 InMemoryAdminOperationJournal 를 검색한 출력 6줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="InMemoryAdminOperationJournal 코드베이스 검색 — 6줄 · exit 0" zoom="true"
:::
## 불변 필드와 외부화된 시간
`RedriveService`·`ReplayService`·`DefaultMessagingAdminService` 는 모두 불변 필드만 갖는다. `clock``Supplier<Instant>` 로 주입받아 시간도 외부화되어 있다.
## 수명주기 훅이 없다
이 리프의 어떤 클래스도 `InitializingBean`·`SmartLifecycle` 을 구현하지 않는다 — 이것이 §17 첫 항목의 직접 원인이다.
<!-- body:end -->
@@ -0,0 +1,40 @@
---
kind: CONCEPT
slug: messaging-admin-runtime-c07
title: 크래시와 복제본을 고려하지 않은 admin 평면
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-admin-runtime-c07
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-admin-runtime-c07
file: ../../../final/evidence/rendered/messaging-admin-runtime-c07.svg
evidence:
- ../../../final/evidence/raw/messaging-admin-runtime-c07.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-admin-runtime.md#L772 이다.
module: messaging-admin-runtime
---
# 크래시와 복제본을 고려하지 않은 admin 평면
javadoc이 이력을 대신한다. 다섯 개의 "이전에는 이랬다"가 전부 분산 실행의 실패를 가리킨다.
## 본문
<!-- body:start -->
이 리프도 javadoc 이 이력을 대신한다. 다섯 개의 "이전에는 이랬다" 가 있고 전부 **분산 실행의 실패**를 가리킨다. 다섯이 하나의 이야기다 — **크래시와 복제본을 고려하지 않은 admin 평면**. 고친 결과가 리스·펜싱·체크포인트·per-item 경계다.
## 이 기록이 다루는 파일 범위
:::evidence key="messaging-admin-runtime-c07" alt="코드베이스에서 파일 목록을 만든 출력 12줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 12줄 · exit 0" zoom="true"
:::
## 마지막 두 항목이 이어지는 곳
"재시도가 처음부터 다시 시작하는" 문제를 고치려고 `resumeFrom` 을 도입했고, 도입한 지점의 인덱스 계산이 실패분을 고려하지 않았다 — §12.1(a). 커밋 로그는 정보가 없다(4개, messaging 전체 공통).
<!-- body:end -->
@@ -0,0 +1,54 @@
---
kind: CONCEPT
slug: messaging-claim-check-c01
title: 위협은 실패가 아니라 잘못된 성공이다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-claim-check-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-claim-check-c01
file: ../../../final/evidence/rendered/messaging-claim-check-c01.svg
- key: messaging-claim-check-c01-diagram
file: ../../../final/assets/diagrams/messaging-claim-check-c01.svg
evidence:
- ../../../final/evidence/raw/messaging-claim-check-c01.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-claim-check.md#L57 이다.
module: messaging-claim-check
---
# 위협은 실패가 아니라 잘못된 성공이다
브로커 한계를 넘는 payload를 객체 저장소에 두고 메시지는 참조만 나른다. 위협 모델은 "decode perfectly into the wrong object"다.
## 관계
- **leaf의 각 public 클래스는 자기 레인에 테스트를 갖는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **같은 튜닝 값이 두 계층에 있으면 어느 쪽이 이기는지 정한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
**Claim Check 패턴** — 브로커 한계를 넘는 payload를 객체 저장소에 두고 메시지는 참조만 나른다. `messaging-reliability-api``ClaimCheckReference`(storageKey·sizeBytes·sha256·expiresAt)를 값 타입으로 쓰고, 이 leaf가 그것을 만들고 검증하는 동작을 소유한다.
## payload가 지나는 경로
:::evidence key="messaging-claim-check-c01-diagram" alt="payload 와 객체 저장소와 참조와 메시지가 왼쪽에서 오른쪽으로 이어지고 화살표마다 무엇이 넘어가는지 붙은 구조" caption="payload 가 지나는 경로" zoom="false"
:::
## ClaimCheckReference 참조 위치
:::evidence key="messaging-claim-check-c01" alt="코드베이스에서 ClaimCheckReference 를 검색한 출력 33줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ClaimCheckReference 코드베이스 검색 — 33줄 · exit 0" zoom="true"
:::
## 이 리프의 위협 모델
경계 진술이 두 클래스에 있다. **"decode perfectly into the wrong object"**가 이 leaf의 위협 모델이다 — 실패가 아니라 잘못된 성공.
<!-- body:end -->
@@ -0,0 +1,47 @@
---
kind: CONCEPT
slug: messaging-claim-check-c02
title: 싣고 쓰지 않는다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-claim-check-c02
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-claim-check-c02
file: ../../../final/evidence/rendered/messaging-claim-check-c02.svg
evidence:
- ../../../final/evidence/raw/messaging-claim-check-c02.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-claim-check.md#L85 이다.
module: messaging-claim-check
---
# 싣고 쓰지 않는다
`ClaimCheckStore`의 production 구현이 0이고 세 타입 생성이 leaf 밖에서 0건인데, `runtime_memberships``["app-bootstrap"]`이다.
## 관계
- **leaf의 각 public 클래스는 자기 레인에 테스트를 갖는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **같은 튜닝 값이 두 계층에 있으면 어느 쪽이 이기는지 정한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
들어오는 것: `messaging-core-api`(api), `messaging-reliability-api`(api). 나가는 것: `messaging-spring-boot-starter``allowed_dependencies`에 포함된다.
## ClaimCheckStore 참조 위치
:::evidence key="messaging-claim-check-c02" alt="코드베이스에서 ClaimCheckStore 를 검색한 출력 6줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ClaimCheckStore 코드베이스 검색 — 6줄 · exit 0" zoom="true"
:::
## 배선은 없는데 아티팩트에는 실린다
**배선: 없다.** `ClaimCheckStore`의 production 구현이 0이고(유일한 구현은 테스트의 `FakeStore`), `ClaimCheckPublisher`·`ClaimCheckResolver`·`ClaimCheckPolicy` 생성이 leaf 밖에서 0건이다. 그런데 **`runtime_memberships``["app-bootstrap"]`이다.** starter closure를 통해 배포 아티팩트에 실린다. `messaging-cloudevents`와 같은 조합이다 — **싣고 쓰지 않는다**(`analysis/messaging/messaging-cloudevents.md` §12.1).
<!-- body:end -->
@@ -0,0 +1,64 @@
---
kind: CONCEPT
slug: messaging-claim-check-c03
title: 보존이 생성자 불변식이고 삭제를 부르는 쪽이 없다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-claim-check-c03
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-claim-check-c03
file: ../../../final/evidence/rendered/messaging-claim-check-c03.svg
- key: messaging-claim-check-c03-diagram
file: ../../../final/assets/diagrams/messaging-claim-check-c03.svg
evidence:
- ../../../final/evidence/raw/messaging-claim-check-c03.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-claim-check.md#L124 이다.
module: messaging-claim-check
---
# 보존이 생성자 불변식이고 삭제를 부르는 쪽이 없다
`ClaimCheckPolicy`는 보존이 브로커 보존 + 전체 재시도·DLQ 창을 넘지 않으면 생성자가 거부한다. 그런데 `ClaimCheckStore.delete`를 부르는 쪽이 이 리프에 없다.
## 관계
- **leaf의 각 public 클래스는 자기 레인에 테스트를 갖는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **같은 튜닝 값이 두 계층에 있으면 어느 쪽이 이기는지 정한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
보존이 생성자 불변식이다. `retention``brokerRetention.plus(maxRedeliveryWindow)`보다 짧으면 `CLAIM_CHECK_RETENTION_TOO_SHORT`로 던진다. javadoc이 이유를 적는다.
> "A claim check object deleted while its message is still deliverable turns a large message into an undeliverable one — the consumer fetches, gets nothing, and the message dead-letters for a reason that has nothing to do with the message."
**이것이 `messaging-reliability-api`의 `InboxRepository.purgeProcessedBefore` javadoc이 요구하고 강제하지 않는 것과 같은 형태의 규칙인데, 이쪽은 생성자가 강제한다.**
## 이 리프가 하는 것과 하지 않는 것
:::evidence key="messaging-claim-check-c03-diagram" alt="리프 경계 안에 오프로드와 참조 전달과 무결성 검증이 들어 있고 보존 sweep 이 경계 밖 빗금 상자로 놓인 구조" caption="이 리프가 하는 것과 하지 않는 것" zoom="false"
:::
**`ClaimCheckStore.delete`가 이 leaf에서 호출되지 않는다.** 인터페이스에 선언돼 있고 publisher가 의도적으로 안 부른다("Nothing here deletes on failure") — `REJECTED``AMBIGUOUS`를 구분할 수 없는 시점에 삭제하면 모호한 발행의 payload를 지운다. 보존 sweep이 부를 것을 전제하는데 그 sweep이 이 leaf에 없다.
## InboxRepository 참조 위치
:::evidence key="messaging-claim-check-c03" alt="코드베이스에서 InboxRepository 를 검색한 출력 20줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="InboxRepository 코드베이스 검색 — 20줄 · exit 0" zoom="true"
:::
## 기본값이 주는 여유
`defaults()`가 브로커 1일 보존 + 1일 재시도 경로에 대해 3일 보존을 준다 — 요구치(2일)보다 1일 여유. `DEFAULT_THRESHOLD_BYTES = 262,144` = 1 MiB의 1/4이고 javadoc이 그렇게 부른다.
## 복사와 검사 순서
오프로드된 메시지는 payload를 **아예 갖지 않는다**. `Offloaded` record가 양방향 방어 복사를 한다(생성자 `payload.clone()`, 접근자 `payload.clone()`) — `EncodedMessage`(schema-api)·`OutboxRecord`(reliability-api)와 같은 패턴이다. 크기 검사가 digest보다 먼저인 것이 합리적이다 — 크기 불일치는 SHA-256 계산 없이 즉시 판정된다. `sha256(byte[])``HexFormat.of().formatHex(...)`**소문자** hex를 만들고 `ClaimCheckReference`의 정규식이 `[a-f0-9]{64}`이므로 두 쪽이 맞는다. `verify`가 검증된 payload의 **복사본**을 반환한다.
<!-- body:end -->
@@ -0,0 +1,49 @@
---
kind: CONCEPT
slug: messaging-claim-check-c04
title: 두 경로 모두 production에서 호출되지 않는다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-claim-check-c04
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-claim-check-c04
file: ../../../final/evidence/rendered/messaging-claim-check-c04.svg
evidence:
- ../../../final/evidence/raw/messaging-claim-check-c04.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-claim-check.md#L260 이다.
module: messaging-claim-check
---
# 두 경로 모두 production에서 호출되지 않는다
발행과 소비 두 경로가 구현돼 있지만 production 호출자가 없다(§12.1).
## 관계
- **leaf의 각 public 클래스는 자기 레인에 테스트를 갖는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **같은 튜닝 값이 두 계층에 있으면 어느 쪽이 이기는지 정한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
**발행**`publisher.offload(encodedPayload)` → 문턱 이하면 인라인 → 초과면 `store.put``Offloaded(빈 바이트, reference)`.
**소비**`resolver.resolve(inline, reference, now)` → reference 없으면 인라인 → 만료 확인 → `store.get` → null이면 NOT_FOUND → `guard.verify`(만료·크기·digest) → `_MISMATCH``ClaimCheckIntegrityException`.
## ClaimCheckIntegrityException 참조 위치
:::evidence key="messaging-claim-check-c04" alt="코드베이스에서 ClaimCheckIntegrityException 를 검색한 출력 8줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ClaimCheckIntegrityException 코드베이스 검색 — 8줄 · exit 0" zoom="true"
:::
## 두 경로 다 production 호출자가 없다
§12.1.
<!-- body:end -->
@@ -0,0 +1,47 @@
---
kind: CONCEPT
slug: messaging-claim-check-c05
title: 만료와 불일치를 다른 카테고리로 가른다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-claim-check-c05
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-claim-check-c05
file: ../../../final/evidence/rendered/messaging-claim-check-c05.svg
evidence:
- ../../../final/evidence/raw/messaging-claim-check-c05.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-claim-check.md#L270 이다.
module: messaging-claim-check
---
# 만료와 불일치를 다른 카테고리로 가른다
분류가 두 단계로 정확하다 — 만료·부재는 운영 문제(`PERMANENT_BUSINESS`), 크기·digest 불일치는 오염(`POISON_MESSAGE`).
## 관계
- **leaf의 각 public 클래스는 자기 레인에 테스트를 갖는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **같은 튜닝 값이 두 계층에 있으면 어느 쪽이 이기는지 정한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
**분류가 두 단계로 정확하다.** 만료·부재는 운영 문제(`PERMANENT_BUSINESS`), 크기·digest 불일치는 오염(`POISON_MESSAGE`). 두 예외 클래스와 두 카테고리가 그 구분을 담는다.
## ClaimCheckIntegrityGuard 참조 위치
:::evidence key="messaging-claim-check-c05" alt="코드베이스에서 ClaimCheckIntegrityGuard 를 검색한 출력 11줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ClaimCheckIntegrityGuard 코드베이스 검색 — 11줄 · exit 0" zoom="true"
:::
## 환경 문제를 메시지 실패로 만들지 않는다
`ClaimCheckIntegrityGuard.sha256``NoSuchAlgorithmException``IllegalStateException("Java runtime does not provide SHA-256")`으로 감싼다 — 복구 불가능한 환경 문제이므로 메시지 실패가 아니다.
<!-- body:end -->
@@ -0,0 +1,51 @@
---
kind: CONCEPT
slug: messaging-claim-check-c06
title: MessageDigest를 호출마다 새로 만드는 것이 옳다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-claim-check-c06
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-claim-check-c06
file: ../../../final/evidence/rendered/messaging-claim-check-c06.svg
evidence:
- ../../../final/evidence/raw/messaging-claim-check-c06.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-claim-check.md#L286 이다.
module: messaging-claim-check
---
# MessageDigest를 호출마다 새로 만드는 것이 옳다
`ClaimCheckPublisher`·`ClaimCheckResolver`는 final 필드만 갖는 불변 객체이고, `MessageDigest.getInstance("SHA-256")`은 호출마다 새 인스턴스를 만든다.
## 관계
- **leaf의 각 public 클래스는 자기 레인에 테스트를 갖는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **같은 튜닝 값이 두 계층에 있으면 어느 쪽이 이기는지 정한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
`ClaimCheckPublisher`·`ClaimCheckResolver`는 final 필드만 갖는 불변 객체다. `ClaimCheckIntegrityGuard`는 상태가 없고 `ClaimCheckResolver`가 인스턴스를 필드로 하나 만든다.
## ClaimCheckPublisher 참조 위치
:::evidence key="messaging-claim-check-c06" alt="코드베이스에서 ClaimCheckPublisher 를 검색한 출력 12줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ClaimCheckPublisher 코드베이스 검색 — 12줄 · exit 0" zoom="true"
:::
## 호출마다 새로 만드는 것이 옳은 이유
`MessageDigest.getInstance("SHA-256")`**호출마다** 새 인스턴스를 만든다 — `MessageDigest`는 스레드 안전하지 않으므로 이것이 옳다. 재사용했다면 동시 호출이 서로의 상태를 오염시킨다.
## 계약에 적히지 않은 요구
`ClaimCheckStore` 구현의 스레드 안전성 요구는 인터페이스 javadoc에 없다.
<!-- body:end -->
@@ -0,0 +1,56 @@
---
kind: CONCEPT
slug: messaging-claim-check-c07
title: 테스트에서는 드물고 부하에서는 일상인 것
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-claim-check-c07
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-claim-check-c07
file: ../../../final/evidence/rendered/messaging-claim-check-c07.svg
evidence:
- ../../../final/evidence/raw/messaging-claim-check-c07.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-claim-check.md#L436 이다.
module: messaging-claim-check
---
# 테스트에서는 드물고 부하에서는 일상인 것
이 leaf의 javadoc은 이전 결함을 서술하지 않고 막으려는 사고를 서술한다.
## 관계
- **leaf의 각 public 클래스는 자기 레인에 테스트를 갖는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **같은 튜닝 값이 두 계층에 있으면 어느 쪽이 이기는지 정한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
이 leaf의 javadoc은 이전 결함을 서술하지 않는다 — 대신 **막으려는 사고**를 서술한다.
| 위치 | 막으려는 것 |
|---|---|
| `ClaimCheckPublisher` | 발행 후 저장 순서 → 존재하지 않는 객체의 참조를 소비자가 받음. "rare in a test and routine under load" |
| `ClaimCheckPublisher` | 실패 시 즉시 삭제 → 모호한 발행의 payload를 지움 |
| `ClaimCheckResolver` | 검증 없는 fetch → 잘못된 키가 완벽히 디코딩되는 다른 객체를 반환 |
| `ClaimCheckResolver` | fetch 후 만료 확인 → sweep이 늦은 저장소에서 오설정이 숨음 |
| `ClaimCheckPolicy` | 짧은 보존 → 메시지와 무관한 이유로 dead-letter |
| `ClaimCheckIntegrityException` | 만료와 불일치를 한 진단으로 합침 |
## 이 기록이 다루는 파일 범위
:::evidence key="messaging-claim-check-c07" alt="코드베이스에서 파일 목록을 만든 출력 6줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 6줄 · exit 0" zoom="true"
:::
## 같은 형태가 반복되는 곳
**"rare in a test and routine under load"**가 이 저장소 전반의 주제다 — `messaging-observability`의 카디널리티, `messaging-security`의 회전 경합, `messaging-transport-spi`의 자원 누수가 같은 형태다.
<!-- body:end -->
@@ -0,0 +1,47 @@
---
kind: CONCEPT
slug: messaging-cloudevents-c07
title: 상태가 없다는 사실이 javadoc에는 적혀 있지 않다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-cloudevents-c07
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-cloudevents-c07
file: ../../../final/evidence/rendered/messaging-cloudevents-c07.svg
evidence:
- ../../../final/evidence/raw/messaging-cloudevents-c07.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-cloudevents.md#L321 이다.
module: messaging-cloudevents
---
# 상태가 없다는 사실이 javadoc에는 적혀 있지 않다
`DefaultCloudEventMapper`는 필드가 상수 하나뿐이고 모든 메서드가 인자만 쓴다. 스레드 안전하지만 그 사실이 문서에 없다.
## 관계
- **왕복이라 부르는 테스트는 무엇을 보존하지 않는지도 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **문서가 범위를 제한하면 그 제한을 누가 강제하는지 같이 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
`DefaultCloudEventMapper`**상태가 없다** — 필드가 `SPEC_CONTENT_TYPE_FALLBACK` 상수 하나뿐이고 모든 메서드가 인자만 쓴다. 스레드 안전하다.
## DefaultCloudEventMapper 참조 위치
:::evidence key="messaging-cloudevents-c07" alt="코드베이스에서 DefaultCloudEventMapper 를 검색한 출력 2줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="DefaultCloudEventMapper 코드베이스 검색 — 2줄 · exit 0" zoom="true"
:::
## 그 사실이 문서에 없다
스레드 안전하다는 사실이 javadoc에 적혀 있지 않다. `CloudEventExtensions`는 상수 홀더이고 private 생성자를 갖는다. `CloudEventBuilder`는 호출마다 새로 만들어진다.
<!-- body:end -->
@@ -0,0 +1,53 @@
---
kind: CONCEPT
slug: messaging-core-api-c01
title: 무엇이 아닌가가 무엇인가만큼 중요하다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-core-api-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-core-api-c01
file: ../../../final/evidence/rendered/messaging-core-api-c01.svg
- key: messaging-core-api-c01-diagram
file: ../../../final/assets/diagrams/messaging-core-api-c01.svg
evidence:
- ../../../final/evidence/raw/messaging-core-api-c01.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-core-api.md#L70 이다.
module: messaging-core-api
---
# 무엇이 아닌가가 무엇인가만큼 중요하다
85개 타입 중 실행 가능한 로직은 넷뿐이고, `build.gradle` 의존 블록은 비어 있으며 `java.*`와 자기 패키지 밖 import가 0개다.
## 본문
<!-- body:start -->
이 leaf는 **브로커 중립 공개 계약**을 소유한다. 여기에는 구현이 거의 없다 — 85개 타입 중 인터페이스 11개, enum 12개, record 46개, 유틸리티 final class 5개, 예외 26개이고, 실행 가능한 로직은 `UuidV7.next()`, `WireSafeText.require`, `MessageHeaders.validateAndCopy`, 그리고 record 생성자의 검증뿐이다.
## 경계가 그어진 방향
:::evidence key="messaging-core-api-c01-diagram" alt="리프 경계 안에 논리 목적지 이름과 완료 단계 계약이 들어 있고 물리 주소와 프레임워크 어휘가 경계 밖 빗금 상자로 놓인 구조" caption="경계가 그어진 방향" zoom="false"
:::
`build.gradle`의 의존 블록은 비어 있고, `src/main/java` 전체에서 `java.*`와 자기 패키지 밖 import는 **0개**다(`evidence/raw/269` §F). Spring도, Kafka·AMQP 클라이언트도, Reactor도 없다. 이것은 우연이 아니라 원래 계획이 명시한 제약이고, 현재 소스에서 재측정해도 참이다.
## WireSafeText 참조 위치
:::evidence key="messaging-core-api-c01" alt="코드베이스에서 WireSafeText 를 검색한 출력 11줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="WireSafeText 코드베이스 검색 — 11줄 · exit 0" zoom="true"
:::
## 브로커 쪽으로 그은 선
`MessageDestination`은 논리 이름·카탈로그 타입·payload 클래스만 갖고 topic/exchange/queue/subject를 갖지 않는다(`destination/MessageDestination.java:9-11`). `DestinationName`의 패턴 `[a-z0-9][a-z0-9.-]{0,159}``:``/`와 공백을 배제해서 `topic://orders` 같은 물리 주소를 논리 이름으로 밀어 넣는 것을 생성자에서 막는다(`destination/DestinationName.java:16`). 주석이 이유를 적는다 — "otherwise the physical mapping owned by the destination profile could be bypassed from application code."
## 프로그래밍 모델 쪽으로 그은 선
핵심 계약은 `CompletionStage`다. blocking facade(`BlockingMessagePublisher`)는 인터페이스만 여기 두고 구현을 다른 모듈로 밀어냈으며, Reactor facade는 아예 없다(`publish/MessagePublisher.java:10-11`).
<!-- body:end -->
@@ -0,0 +1,46 @@
---
kind: CONCEPT
slug: messaging-core-api-c03
title: 예약 네임스페이스를 봉투 필드와 헤더 전용으로 쪼갠다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-core-api-c03
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-core-api-c03
file: ../../../final/evidence/rendered/messaging-core-api-c03.svg
evidence:
- ../../../final/evidence/raw/messaging-core-api-c03.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-core-api.md#L123 이다.
module: messaging-core-api
---
# 예약 네임스페이스를 봉투 필드와 헤더 전용으로 쪼갠다
`MessageEnvelope<T>`가 중심이고 나머지 11개가 그 필드 타입이다. `ReservedHeaders`의 23개 이름을 `CanonicalEnvelopeHeaders`가 둘로 쪼갠다.
## 본문
<!-- body:start -->
`MessageEnvelope<T>`가 중심이고 나머지 11개가 그 필드 타입이다. 봉투는 불변이고 네 가지 파생 메서드가 있다 — `withPayload`, `withContentType`, `withTenant`, `withHeaders`. 넷 다 `messageId`를 복사한다.
> "Encoding, decoding, Claim Check offloading, and DLQ forwarding all need this, and every one of them must keep `messageId()` intact — which is exactly what this method guarantees by construction" (`MessageEnvelope.java:80-82`)
## UuidV7 참조 위치
:::evidence key="messaging-core-api-c03" alt="코드베이스에서 UuidV7 를 검색한 출력 20줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="UuidV7 코드베이스 검색 — 20줄 · exit 0" zoom="true"
:::
## 예약 네임스페이스가 둘로 쪼개진다
`ReservedHeaders`는 23개 이름 상수와 `msg.` **prefix 전체**를 소유한다. `CanonicalEnvelopeHeaders`는 그 예약 네임스페이스를 둘로 쪼갠다 — 봉투 필드가 이미 갖고 있는 15개(`ENVELOPE_FIELDS`)와, 봉투에 대응 필드가 없어서 헤더로만 이동할 수 있는 나머지 8개(`REDRIVE_ID`, `REDRIVE_COUNT`, `RETRY_ATTEMPT`, `FIRST_FAILURE_AT`, `LAST_FAILURE_AT`, `FAILURE_CATEGORY`, `FAILURE_CODE`, `ORIGIN_DESTINATION`).
## 목적지 쪽 어휘
`MessageDestination<T>`, `DestinationName`, `DestinationKind`(7), `MessagingCapabilities`(boolean 12), `DestinationCapabilities`, `ConfirmationRequirement`(3), `CapabilityRegistry`.
<!-- body:end -->
@@ -0,0 +1,48 @@
---
kind: CONCEPT
slug: messaging-core-api-c07
title: 동시성 지점이 하나뿐이다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-core-api-c07
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-core-api-c07
file: ../../../final/evidence/rendered/messaging-core-api-c07.svg
evidence:
- ../../../final/evidence/raw/messaging-core-api-c07.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-core-api.md#L463 이다.
module: messaging-core-api
---
# 동시성 지점이 하나뿐이다
트랜잭션 개념은 선언으로만 등장하고 DB 트랜잭션은 이 리프가 만지지 않는다. 동시성 지점은 `UuidV7.STATE` 하나다.
## 본문
<!-- body:start -->
트랜잭션 개념이 이 leaf에는 두 가지 형태로만 등장하고 둘 다 **선언**이다 — `MessagingCapabilities.brokerTransaction`, `ProcessingGuarantee.BROKER_TRANSACTIONAL`("Atomicity holds only inside the transaction scope the broker itself defines"), `ExternalSideEffectGuarantee.INBOX_TRANSACTIONAL`("An Inbox row and the side effect commit inside the same database transaction"). DB 트랜잭션은 이 leaf가 만지지 않는다.
## MessagingCapabilities 참조 위치
:::evidence key="messaging-core-api-c07" alt="코드베이스에서 MessagingCapabilities 를 검색한 출력 39줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MessagingCapabilities 코드베이스 검색 — 39줄 · exit 0" zoom="true"
:::
## 유일한 동시성 지점
`UuidV7.STATE`(`AtomicLong`) 하나다. `updateAndGet`이 CAS 루프이므로 다중 스레드에서도 각 호출이 서로 다른 packed state를 얻는다. `RANDOM`(`SecureRandom`)은 thread-safe다. `MessageHeaders`는 생성 시 `LinkedHashMap`에 복사하고 `Collections.unmodifiableMap`으로 감싸 반환하므로 공유 안전하다.
## 최대 예순넷이라 실용상 문제가 아닌 선형 탐색
`find(String)``values.entrySet().stream()` 선형 탐색이다 — 최대 64개이므로 실용상 문제는 아니지만 hot path에서 반복 호출되면 O(n)이다.
## 도달 불가능한 수명주기 필드
수명주기 개념은 `DeliveryContext.shutdownRequested`뿐이고, javadoc이 목적을 적는다 — "during a graceful drain the platform stops creating new retry attempts, and a long-running handler that can wind down early shortens the drain instead of being cancelled at the deadline." **이 필드는 production에서 도달 불가능하다**(§12.1).
<!-- body:end -->
@@ -0,0 +1,40 @@
---
kind: CONCEPT
slug: messaging-core-api-c08
title: 검사가 없었던 게 아니라 잘못된 단위로 되어 있었다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-core-api-c08
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-core-api-c08
file: ../../../final/evidence/rendered/messaging-core-api-c08.svg
evidence:
- ../../../final/evidence/raw/messaging-core-api-c08.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-core-api.md#L734 이다.
module: messaging-core-api
---
# 검사가 없었던 게 아니라 잘못된 단위로 되어 있었다
코드 주석이 보존한 실패 이력이 이 leaf의 가장 밀도 높은 사료다. 13개 이상의 wire 경계 결함을 한 번에 정리한 흔적이다.
## 본문
<!-- body:start -->
이 leaf를 건드린 커밋은 4개다. 최초 커밋 메시지는 **24개 leaf**라고 적었고 현재 registry의 messaging leaf는 **25개**다. 이후 커밋에서 하나가 늘었다는 뜻이며, 커밋 메시지는 그 시점의 사실이므로 drift로 분류하지 않는다.
## 이 기록이 다루는 파일 범위
:::evidence key="messaging-core-api-c08" alt="코드베이스에서 파일 목록을 만든 출력 25줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 25줄 · exit 0" zoom="true"
:::
## 코드 주석이 보존한 실패 이력
**코드 주석이 보존한 실패 이력**이 이 leaf의 가장 밀도 높은 사료다. 전부 "예전에는 이랬고 그래서 무엇이 깨졌다"를 현재 코드가 직접 적어 둔 것이다. 이 목록 자체가 이 leaf의 성격을 말한다 — **13개 이상의 wire 경계 결함을 한 번에 정리한 흔적**이고, 대부분이 "검사가 없었다"가 아니라 "검사가 잘못된 단위(문자 vs 바이트, 정확일치 vs 세그먼트, 이름목록 vs prefix)로 되어 있었다"이다.
<!-- body:end -->
@@ -0,0 +1,47 @@
---
kind: CONCEPT
slug: messaging-inbox-jdbc-postgresql-c04
title: action 예외가 예약까지 함께 롤백시킨다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-inbox-jdbc-postgresql-c04
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-inbox-jdbc-postgresql-c04
file: ../../../final/evidence/rendered/messaging-inbox-jdbc-postgresql-c04.svg
evidence:
- ../../../final/evidence/raw/messaging-inbox-jdbc-postgresql-c04.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-inbox-jdbc-postgresql.md#L319 이다.
module: messaging-inbox-jdbc-postgresql
---
# action 예외가 예약까지 함께 롤백시킨다
수신 처리와 실패와 보존 세 경로가 있고, 실패 경로에서 롤백이 예약도 함께 되돌린다.
## 관계
- **컬럼 폭은 애플리케이션 검증과 짝을 이룬다**
같은 분석 리프에서 끌어낸 규칙이다.
- **같은 안전 규칙은 한 공식과 한 강제 시점을 갖는다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
**수신 처리**`handleOnce(name, delivery, action)``consumer.runOnce(messageId, name, now, () -> { action.apply(delivery); return APPLIED; })` → runner가 트랜잭션 열기 → `repository.reserve(...)` → 세 검사 → `INSERT … ON CONFLICT DO NOTHING` → 1행이면 부작용 실행, 0행이면 `duplicate()` → 커밋 → `HandleResult.success()`.
**실패** — action 예외 → `ActionFailedException` → runner가 롤백(예약도 함께) → `HandleResult.Retry("INBOX_ACTION_FAILED")`.
**보존**`cleanupJob.runOnce(now)``policy.cutoff(now)` → 무제한 DELETE 1회 → 두 번째 호출 0 → 종료.
## ActionFailedException 참조 위치
:::evidence key="messaging-inbox-jdbc-postgresql-c04" alt="코드베이스에서 ActionFailedException 를 검색한 출력 4줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ActionFailedException 코드베이스 검색 — 4줄 · exit 0" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,47 @@
---
kind: CONCEPT
slug: messaging-inbox-jdbc-postgresql-c05
title: 일시적 인프라 문제가 재시도 불가로 분류된다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-inbox-jdbc-postgresql-c05
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-inbox-jdbc-postgresql-c05
file: ../../../final/evidence/rendered/messaging-inbox-jdbc-postgresql-c05.svg
evidence:
- ../../../final/evidence/raw/messaging-inbox-jdbc-postgresql-c05.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-inbox-jdbc-postgresql.md#L329 이다.
module: messaging-inbox-jdbc-postgresql
---
# 일시적 인프라 문제가 재시도 불가로 분류된다
SQL 실패 셋이 전부 `MessagingConfigurationException`이고 그 카테고리는 `CONFIGURATION`, `retryable = false`다. 그런데 `SQLException`의 원인은 대부분 일시적 인프라 문제다.
## 관계
- **컬럼 폭은 애플리케이션 검증과 짝을 이룬다**
같은 분석 리프에서 끌어낸 규칙이다.
- **같은 안전 규칙은 한 공식과 한 강제 시점을 갖는다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
**SQL 실패 셋이 전부 `MessagingConfigurationException`이다.** 그 예외의 카테고리는 `CONFIGURATION`이고 `retryable = false`다. 그런데 `SQLException`의 원인은 대부분 **일시적 인프라 문제**(연결 끊김, 데드락, 타임아웃)다. 즉 재시도 가능한 실패가 재시도 불가로 분류된다. §17.
## MessagingConfigurationException 참조 위치
:::evidence key="messaging-inbox-jdbc-postgresql-c05" alt="코드베이스에서 MessagingConfigurationException 를 검색한 출력 19줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MessagingConfigurationException 코드베이스 검색 — 19줄 · exit 0" zoom="true"
:::
## 분류가 정확한 하나
`INBOX_ACTION_FAILED``TRANSIENT_INFRASTRUCTURE`/`retryable = true`이고 예외가 아니라 `HandleResult`로 흐른다.
<!-- body:end -->
@@ -0,0 +1,60 @@
---
kind: CONCEPT
slug: messaging-inbox-jdbc-postgresql-c06
title: 커넥션 조회가 새 커넥션을 열기 전에 검사가 돌아야 한다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-inbox-jdbc-postgresql-c06
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-inbox-jdbc-postgresql-c06
file: ../../../final/evidence/rendered/messaging-inbox-jdbc-postgresql-c06.svg
- key: messaging-inbox-jdbc-postgresql-c06-diagram
file: ../../../final/assets/diagrams/messaging-inbox-jdbc-postgresql-c06.svg
evidence:
- ../../../final/evidence/raw/messaging-inbox-jdbc-postgresql-c06.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-inbox-jdbc-postgresql.md#L346 이다.
module: messaging-inbox-jdbc-postgresql
---
# 커넥션 조회가 새 커넥션을 열기 전에 검사가 돌아야 한다
`DataSourceUtils.getConnection`은 활성 트랜잭션에 묶인 커넥션이 있으면 그것을 주고 없으면 새로 연다. 그래서 `requireActiveTransaction`이 먼저 도는 것이 필수다.
## 관계
- **컬럼 폭은 애플리케이션 검증과 짝을 이룬다**
같은 분석 리프에서 끌어낸 규칙이다.
- **같은 안전 규칙은 한 공식과 한 강제 시점을 갖는다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
**이 leaf의 주제 자체가 트랜잭션이다.** `DataSourceUtils.getConnection`은 활성 트랜잭션에 묶인 커넥션이 있으면 그것을 주고, 없으면 새로 연다.
## 커넥션 조회보다 앞선 검사
:::evidence key="messaging-inbox-jdbc-postgresql-c06-diagram" alt="트랜잭션 검사와 커넥션 조회와 INSERT 가 왼쪽에서 오른쪽으로 이어지는 구조" caption="커넥션 조회보다 앞선 검사" zoom="false"
:::
그래서 `requireActiveTransaction`**먼저** 도는 것이 필수다 — 없으면 새 커넥션이 열리고 자동 커밋된다. 그것이 §4.1의 이전 결함이다.
## DataSourceUtils 참조 위치
:::evidence key="messaging-inbox-jdbc-postgresql-c06" alt="코드베이스에서 DataSourceUtils 를 검색한 출력 15줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="DataSourceUtils 코드베이스 검색 — 15줄 · exit 0" zoom="true"
:::
## 트랜잭션에 참여하지 않는 세 메서드
`isProcessed`와 두 `purge*``dataSource.getConnection()`을 직접 쓴다 — 트랜잭션에 참여하지 않는다. javadoc이 그것을 명시한다("The no-argument overload is provided only for retention sweeps and read-only queries").
## 동시성 원시 요소가 DB에 있다
Java 쪽에 락이나 원자 변수가 없다. 수명주기 참여 없음 — `InboxCleanupJob`을 스케줄링하는 것은 starter다.
<!-- body:end -->
@@ -0,0 +1,51 @@
---
kind: CONCEPT
slug: messaging-kafka-c02
title: 느린 메시지 하나가 그 파티션의 워터마크를 붙든다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-kafka-c02
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-kafka-c02
file: ../../../final/evidence/rendered/messaging-kafka-c02.svg
- key: messaging-kafka-c02-diagram
file: ../../../final/assets/diagrams/messaging-kafka-c02.svg
evidence:
- ../../../final/evidence/raw/messaging-kafka-c02.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-kafka.md#L87 이다.
module: messaging-kafka
---
# 느린 메시지 하나가 그 파티션의 워터마크를 붙든다
오프셋 커밋은 집합이 아니라 워터마크다. 동시 처리에서 오프셋이 순서 없이 끝나므로 연속 구간만 커밋한다.
## 본문
<!-- body:start -->
오프셋 커밋은 집합이 아니라 워터마크다.
> "A Kafka offset commit is a watermark, not a set: committing offset 13 declares that everything below it is done. With concurrent handlers, offsets finish out of order — 10 and 12 may complete while 11 is still running — and committing 13 at that moment would silently discard 11."
## 완료 표시가 커밋으로 가는 갈림
:::evidence key="messaging-kafka-c02-diagram" alt="완료 표시 맵에서 연속 구간과 빈 자리 뒤 구간으로 화살표가 나가고 빈 자리 뒤 구간만 빗금으로 표시된 구조" caption="완료 표시가 커밋으로 가는 갈림" zoom="false"
:::
그 대가도 적혀 있다 — 느린 메시지 하나가 그 파티션의 워터마크를 붙든다. 그것이 옳은 교환이라는 근거는 대안이 메시지를 잃는다는 것이고, 지연은 소비자 랙으로 보인다는 것이다.
## 이 기록이 다루는 파일 범위
:::evidence key="messaging-kafka-c02" alt="코드베이스에서 파일 목록을 만든 출력 25줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 25줄 · exit 0" zoom="true"
:::
## 워커 없는 오프셋을 되돌리는 경로
> "A delivered offset with no worker behind it holds the contiguous watermark back forever: nothing will ever complete it, so the partition stops committing while continuing to consume."
<!-- body:end -->
@@ -0,0 +1,64 @@
---
kind: CONCEPT
slug: messaging-kafka-share-experimental-c03
title: register가 아무것도 등록하지 않고 활성을 돌려준다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-kafka-share-experimental-c03
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-kafka-share-experimental-c03
file: ../../../final/evidence/rendered/messaging-kafka-share-experimental-c03.svg
- key: messaging-kafka-share-experimental-c03-diagram
file: ../../../final/assets/diagrams/messaging-kafka-share-experimental-c03.svg
evidence:
- ../../../final/evidence/raw/messaging-kafka-share-experimental-c03.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-kafka-share-experimental.md#L131 이다.
module: messaging-kafka-share-experimental
---
# register가 아무것도 등록하지 않고 활성을 돌려준다
`register(...)`는 프로파일 검증만 하고 `isActive() == true`인 객체를 반환한다. sink를 저장하지 않으므로 어떤 메시지도 전달되지 않는다.
## 관계
- **허용 의존 목록은 상한이므로 미사용을 잡지 않는다 — 그것을 잡으려면 별도 검사가 필요하다**
같은 분석 리프에서 끌어낸 규칙이다.
- **leaf의 각 public 클래스는 자기 레인에 테스트를 갖는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **에러 메시지가 지시하는 설정은 그 설정을 읽는 코드와 함께 존재해야 한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **구성 오류는 한 예외 타입과 안정 코드로 보고한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
`KafkaShareProfile`은 다섯 필드이고 생성자가 `shareGroup` 공백과 `maxDeliveryCount < 1`을 거절한다. `maxDeliveryCount`가 javadoc에서 "how many times a record may be re-acquired before it is released"라고 정의된다 — Share Group의 재획득 한계다. **이 필드를 읽는 코드가 이 leaf에 없다.**
## register가 실제로 하는 것
:::evidence key="messaging-kafka-share-experimental-c03-diagram" alt="등록기 경계 안에 프로파일 검증이 들어 있고 소비자 생성과 sink 저장이 경계 밖 빗금 상자로 놓인 구조" caption="register 가 실제로 하는 것" zoom="false"
:::
`TransportConsumerSpec``(DestinationProfile profile, Function<TransportDelivery, CompletionStage<Void>> sink)`이고, `sink`가 플랫폼이 전달마다 부르는 콜백이다. 그 sink가 저장되지 않으므로 **어떤 메시지도 전달되지 않는다.** Kafka 소비자도 만들어지지 않는다 — `kafka-clients`를 import하는 코드가 없다. 즉 `register(...)`**아무것도 등록하지 않고** `isActive() == true`인 객체를 반환한다. §17.
## MessagingCapabilityUnavailableException 참조 위치
:::evidence key="messaging-kafka-share-experimental-c03" alt="코드베이스에서 MessagingCapabilityUnavailableException 를 검색한 출력 37줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MessagingCapabilityUnavailableException 코드베이스 검색 — 37줄 · exit 0" zoom="true"
:::
## 두 거절의 예외 타입이 다르다
첫째는 `MessagingCapabilityUnavailableException`(카테고리 `CONFIGURATION`, 안정 코드 있음), 둘째는 `IllegalArgumentException`(코드 없음). 둘 다 설정 오류인데 하나만 플랫폼 실패 어휘를 쓴다. §17.
## 에러 메시지가 지목하는 키를 읽는 코드가 없다
에러 메시지가 프로퍼티 키를 직접 적는다 — `backend.messaging.experimental.kafka-share=true`. 그 키를 읽는 코드가 이 저장소에 없다(§12.4).
<!-- body:end -->
@@ -0,0 +1,55 @@
---
kind: CONCEPT
slug: messaging-kafka-share-experimental-c06
title: 닫을 자원이 없어서 두 번 닫아도 무해하다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-kafka-share-experimental-c06
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-kafka-share-experimental-c06
file: ../../../final/evidence/rendered/messaging-kafka-share-experimental-c06.svg
evidence:
- ../../../final/evidence/raw/messaging-kafka-share-experimental-c06.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-kafka-share-experimental.md#L258 이다.
module: messaging-kafka-share-experimental
---
# 닫을 자원이 없어서 두 번 닫아도 무해하다
`ShareRegistration.active``AtomicBoolean`이고 `close()``set(false)`이므로 멱등이다. 수명주기 참여가 없다.
## 관계
- **허용 의존 목록은 상한이므로 미사용을 잡지 않는다 — 그것을 잡으려면 별도 검사가 필요하다**
같은 분석 리프에서 끌어낸 규칙이다.
- **leaf의 각 public 클래스는 자기 레인에 테스트를 갖는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **에러 메시지가 지시하는 설정은 그 설정을 읽는 코드와 함께 존재해야 한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **구성 오류는 한 예외 타입과 안정 코드로 보고한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
`ShareRegistration.active``AtomicBoolean`이다. `close()``set(false)`이고 CAS가 아니므로 두 번 닫아도 무해하다(멱등).
## ShareRegistration 참조 위치
:::evidence key="messaging-kafka-share-experimental-c06" alt="코드베이스에서 ShareRegistration 를 검색한 출력 3줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ShareRegistration 코드베이스 검색 — 3줄 · exit 0" zoom="true"
:::
## 상태를 갖는 것과 갖지 않는 것
`KafkaShareProfileValidator`·`KafkaShareWorkQueueCapability`는 상태가 없다. `KafkaShareGroupRegistrar`는 validator 참조 하나만 갖는다.
## 수명주기 참여가 없는 이유
`TransportConsumerRegistration``AutoCloseable`이지만 이 구현은 닫을 자원을 갖지 않는다.
<!-- body:end -->
@@ -0,0 +1,54 @@
---
kind: CONCEPT
slug: messaging-observability-c01
title: seam은 중립이고 구현만 벤더에 묶인다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-observability-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-observability-c01
file: ../../../final/evidence/rendered/messaging-observability-c01.svg
- key: messaging-observability-c01-diagram
file: ../../../final/assets/diagrams/messaging-observability-c01.svg
evidence:
- ../../../final/evidence/raw/messaging-observability-c01.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-observability.md#L52 이다.
module: messaging-observability
---
# seam은 중립이고 구현만 벤더에 묶인다
메트릭·추적·감사 셋이 같은 제약 아래 있다 — 경계가 알려진 값만 나간다. `MessagingObservation` 인터페이스 자체는 Micrometer를 모른다.
## 관계
- **타입이 문서화한 불변식은 타입이 강제한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
이 leaf는 **"메시징이 무엇을 밖으로 내보내도 되는가"**를 소유한다. 메트릭·추적·감사 셋이 여기 있고, 셋 다 같은 제약 아래 있다 — **경계가 알려진 값만 나간다.**
## seam과 구현의 결합 범위
:::evidence key="messaging-observability-c01-diagram" alt="관측 seam 경계 안에 중립 인터페이스가 들어 있고 벤더에 결합된 구현이 경계 밖 점선 상자로 놓인 구조" caption="seam 과 구현의 결합 범위" zoom="false"
:::
Micrometer를 `api`로 선언한 이유가 build.gradle에 있고 `src/messaging/CLAUDE.md:40-43`의 vendor `api` 게이트를 통과한다. 다만 `MessagingObservation` 인터페이스 자체는 Micrometer를 모른다 — 벤더는 `MessagingMetrics` 한 클래스에만 나타난다. 즉 **seam은 중립이고 구현만 벤더에 묶인다.**
## MessagingObservation 참조 위치
:::evidence key="messaging-observability-c01" alt="코드베이스에서 MessagingObservation 를 검색한 출력 9줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MessagingObservation 코드베이스 검색 — 9줄 · exit 0" zoom="true"
:::
## 의존이 하나뿐인 것도 의도적이다
`messaging-core-api` 하나다. `MessagingTracer``TraceContext`·`MessageHeaders`를 쓰고 `DefaultMessagingObservationConvention``PublishCompletion`·`FailureCategory`를 쓴다. policy나 transport는 필요 없다.
<!-- body:end -->
@@ -0,0 +1,45 @@
---
kind: CONCEPT
slug: messaging-observability-c06
title: Set 인스턴스를 락으로 쓰지만 외부 경합이 없다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-observability-c06
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-observability-c06
file: ../../../final/evidence/rendered/messaging-observability-c06.svg
evidence:
- ../../../final/evidence/raw/messaging-observability-c06.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-observability.md#L363 이다.
module: messaging-observability
---
# Set 인스턴스를 락으로 쓰지만 외부 경합이 없다
트랜잭션이 없고, 이 leaf는 messaging family에서 `messaging-transport-spi` 다음으로 동시성이 조밀하다.
## 관계
- **타입이 문서화한 불변식은 타입이 강제한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
트랜잭션 없음. 이 leaf는 messaging family에서 `messaging-transport-spi` 다음으로 동시성이 조밀하다. `MessagingRedactor`·`MessagingTracer`·`DefaultMessagingObservationConvention`은 상태가 없고 `MessagingTags`는 불변 record다.
## MessagingRedactor 참조 위치
:::evidence key="messaging-observability-c06" alt="코드베이스에서 MessagingRedactor 를 검색한 출력 13줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MessagingRedactor 코드베이스 검색 — 13줄 · exit 0" zoom="true"
:::
## Set 인스턴스를 락으로 쓰는 것이 안전한 이유
**`synchronized(values)``Set` 인스턴스를 락으로 쓴다.** 그 `Set``ConcurrentHashMap.newKeySet()`이고 외부에 노출되지 않으므로(`observed` 맵이 private) 외부 락 경합은 없다. 차원별로 락이 분리되는 효과도 있다.
<!-- body:end -->
@@ -0,0 +1,45 @@
---
kind: CONCEPT
slug: messaging-observability-c07
title: 경계는 예산을 정확히 소비할 때만 경계다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-observability-c07
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-observability-c07
file: ../../../final/evidence/rendered/messaging-observability-c07.svg
evidence:
- ../../../final/evidence/raw/messaging-observability-c07.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-observability.md#L597 이다.
module: messaging-observability
---
# 경계는 예산을 정확히 소비할 때만 경계다
세 이력 중 세 번째가 가장 무겁다 — 경계가 있었는데 기본 차원만 보호했고 진단 값은 그 밖이었다.
## 관계
- **타입이 문서화한 불변식은 타입이 강제한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
세 번째가 가장 무겁다 — **경계가 있었는데 기본 차원만 보호했고 진단 값은 그 밖이었다.** 현재는 값이 태그가 되지 않고 키만 별도 guard 차원(`"diagnostic"`)을 통과한다.
## 이 기록이 다루는 파일 범위
:::evidence key="messaging-observability-c07" alt="코드베이스에서 파일 목록을 만든 출력 9줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 9줄 · exit 0" zoom="true"
:::
## 앞 두 이력이 말하는 같은 주제
첫 두 개는 같은 주제의 두 형태다 — **경계는 예산을 정확히 소비할 때만 경계다.** `messaging-policy`의 슬롯 누수 방지, `messaging-transport-spi``endWork` clamp와 같은 계열이고 각 leaf §13이 소유한다.
<!-- body:end -->
@@ -0,0 +1,53 @@
---
kind: CONCEPT
slug: messaging-outbox-jdbc-postgresql-c05
title: 동시성 제어가 전부 데이터베이스에 있다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-outbox-jdbc-postgresql-c05
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-outbox-jdbc-postgresql-c05
file: ../../../final/evidence/rendered/messaging-outbox-jdbc-postgresql-c05.svg
- key: messaging-outbox-jdbc-postgresql-c05-diagram
file: ../../../final/assets/diagrams/messaging-outbox-jdbc-postgresql-c05.svg
evidence:
- ../../../final/evidence/raw/messaging-outbox-jdbc-postgresql-c05.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-outbox-jdbc-postgresql.md#L486 이다.
module: messaging-outbox-jdbc-postgresql
---
# 동시성 제어가 전부 데이터베이스에 있다
두 가지 커넥션 획득 방식이 공존하고, Java 쪽에는 락이 없다.
## 본문
<!-- body:start -->
**두 가지 커넥션 획득 방식이 공존한다.**
## 커넥션을 얻는 두 방식
:::evidence key="messaging-outbox-jdbc-postgresql-c05-diagram" alt="커넥션 획득에서 릴레이와 저널 두 상자로 화살표가 나가고 화살표에 독립 커넥션과 호출자 트랜잭션이 붙은 구조" caption="커넥션을 얻는 두 방식" zoom="false"
:::
릴레이 연산이 비즈니스 트랜잭션에 합류하면 안 되므로 `withConnection` 의 선택은 타당하다. 다만 그 판단이 주석으로 남아 있지 않고, 같은 리프의 저널은 반대 방식을 쓴다. §17 P3.
## OutboxRelayWorker 참조 위치
:::evidence key="messaging-outbox-jdbc-postgresql-c05" alt="코드베이스에서 OutboxRelayWorker 를 검색한 출력 17줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="OutboxRelayWorker 코드베이스 검색 — 17줄 · exit 0" zoom="true"
:::
## 동시성 원시 요소가 전부 DB에 있다
`FOR UPDATE SKIP LOCKED`(청구), 서버측 토큰 증가, 펜싱 술어, `ON CONFLICT DO NOTHING`, 복합 기본키. Java 쪽에 락이 없다.
## 수명주기
`OutboxRelayWorker` 는 데몬 스레드 1개, `setExecuteExistingDelayedTasksAfterShutdownPolicy(false)`, `start()` 멱등, `stop(deadline)` 드레인 후 실패 시 `shutdownNow()`. 셋 다 근거 주석이 있다(`:79-88`, `:92`, `:121-122`).
<!-- body:end -->
@@ -0,0 +1,58 @@
---
kind: CONCEPT
slug: messaging-policy-c01
title: 모순은 부팅 실패여야 한다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-policy-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-policy-c01
file: ../../../final/evidence/rendered/messaging-policy-c01.svg
- key: messaging-policy-c01-diagram
file: ../../../final/assets/diagrams/messaging-policy-c01.svg
evidence:
- ../../../final/evidence/raw/messaging-policy-c01.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-policy.md#L56 이다.
module: messaging-policy
---
# 모순은 부팅 실패여야 한다
이 leaf는 "이 목적지는 무엇을 약속하는가"를 소유한다. 브로커를 만지지 않고 벤더 의존성이 0이며, 브로커 어댑터가 따라야 할 판단을 미리 계산한다.
## 관계
- **구성 오류는 한 예외 타입과 안정 코드로 보고한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **저장소 밖 문서를 절 번호로 인용하지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **부팅 경로의 알고리즘 복잡도는 문서화한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
이 leaf는 **"이 목적지는 무엇을 약속하는가"**를 소유한다. 브로커를 만지지 않고 벤더 의존성이 0이며, 대신 브로커 어댑터가 따라야 할 판단을 미리 계산한다.
## 이 리프가 독점하는 것
:::evidence key="messaging-policy-c01-diagram" alt="리프 경계 안에 물리 주소와 프로파일 판단이 들어 있고 브로커 클라이언트가 경계 밖 빗금 상자로 놓인 구조" caption="이 리프가 독점하는 것" zoom="false"
:::
경계 규칙 하나가 leaf 전체를 관통한다 — **모순은 부팅 실패여야 한다.**
## 이 기록이 다루는 파일 범위
:::evidence key="messaging-policy-c01" alt="코드베이스에서 파일 목록을 만든 출력 25줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 25줄 · exit 0" zoom="true"
:::
## 두 리프가 같은 경계를 양쪽에서 지킨다
두 번째 경계는 **물리 주소의 격리**다. `messaging-core-api``DestinationName``:``/`를 정규식으로 막고(그쪽 §4), 이 leaf가 물리 주소를 독점한다.
<!-- body:end -->
@@ -0,0 +1,67 @@
---
kind: CONCEPT
slug: messaging-policy-c03
title: 거절이 전송 전에 끝나는 것이 설계의 핵심이다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-policy-c03
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-policy-c03
file: ../../../final/evidence/rendered/messaging-policy-c03.svg
- key: messaging-policy-c03-diagram
file: ../../../final/assets/diagrams/messaging-policy-c03.svg
evidence:
- ../../../final/evidence/raw/messaging-policy-c03.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-policy.md#L135 이다.
module: messaging-policy
---
# 거절이 전송 전에 끝나는 것이 설계의 핵심이다
`DestinationProfileValidator.validate`가 15가지 모순을 순서대로 거절하고, `admit`은 네 단계를 전송 전에 통과시킨다.
## 관계
- **구성 오류는 한 예외 타입과 안정 코드로 보고한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **저장소 밖 문서를 절 번호로 인용하지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **부팅 경로의 알고리즘 복잡도는 문서화한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
`DestinationProfileValidator.validate`가 프로파일 하나에 대해 15가지 모순을 순서대로 거절한다. 11번과 12번이 짝이다 — 전자는 목적지 수준 동시성(`orderingScope == DESTINATION && consumer.concurrency > 1`), 후자는 순서 단위 안 동시성(`isOrdered() && maxInFlightPerOrderingUnit > 1`). 둘 다 있어야 "순서 보장"이 실제로 성립한다.
## 사이클 탐색이 전역 집합이 아니라 경로를 쓰는 이유
`Edge` enum이 `RETRY``DEAD_LETTER` 둘을 갖고, `walk`가 두 간선을 동시에 따라간다. **`onPath`가 전역 방문 집합이 아니라 현재 경로다.** 각 분기마다 `new LinkedHashSet<>(onPath)`로 복사하므로 형제 분기가 서로의 방문 기록을 오염시키지 않는다. 다이아몬드(A→C, B→C)는 사이클이 아니고, 그것을 사이클로 판정하면 정상 구성이 부팅에 실패한다. 테스트가 두 경우를 각각 붙든다 — `aMixedEdgeCycleIsRejected`(retry/DLQ 교대 사이클 거절)와 `aSharedDeadLetterIsNotACycle`(다이아몬드 허용). 미등록 목적지도 여기서 잡힌다 — `anUnregisteredRetryDestinationIsRejected`.
## 어디에도 기록되지 않은 비용
매 분기마다 `onPath``path`를 복사하므로 시간·공간이 경로 수에 지수적이다. 목적지 수가 수십 개인 정상 구성에서는 문제가 없지만, 이 성질이 어디에도 기록되지 않았다 — §17의 P3.
## admit의 검사 순서
:::evidence key="messaging-policy-c03-diagram" alt="payload 검사와 종료 여부와 목적지 슬롯과 전역 semaphore 가 왼쪽에서 오른쪽으로 이어지는 구조" caption="admit 의 검사 순서" zoom="false"
:::
1. `payloadGuard.checkPayload` → 초과면 `MessageTooLargeException`
1. `acceptingNewWork` 확인 → 종료 중이면 `MessageBackpressureException("SHUTTING_DOWN")`
1. `reserve(destination)` — 목적지별 CAS 루프 → 초과면 `DESTINATION_IN_FLIGHT_LIMIT_EXCEEDED`
1. `limiter.tryAcquire()` — 프로세스 전역 semaphore, 유한 대기 → 실패면 목적지 슬롯 **반납 후** `IN_FLIGHT_LIMIT_EXCEEDED`
## MessageTooLargeException 참조 위치
:::evidence key="messaging-policy-c03" alt="코드베이스에서 MessageTooLargeException 를 검색한 출력 12줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MessageTooLargeException 코드베이스 검색 — 12줄 · exit 0" zoom="true"
:::
**거절이 모호하지 않은 것이 설계의 핵심**이다 — 두 거절 모두 전송 전에 일어난다.
<!-- body:end -->
@@ -0,0 +1,57 @@
---
kind: CONCEPT
slug: messaging-policy-c06
title: 제거 실패는 안전한 방향의 경합이다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-policy-c06
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-policy-c06
file: ../../../final/evidence/rendered/messaging-policy-c06.svg
evidence:
- ../../../final/evidence/raw/messaging-policy-c06.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-policy.md#L451 이다.
module: messaging-policy
---
# 제거 실패는 안전한 방향의 경합이다
동시성 지점은 `MessagingAdmissionController``InFlightLimiter` 둘이고, `release`에 미세한 경합이 있지만 안전한 방향이다.
## 관계
- **구성 오류는 한 예외 타입과 안정 코드로 보고한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **저장소 밖 문서를 절 번호로 인용하지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **부팅 경로의 알고리즘 복잡도는 문서화한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
동시성 지점은 `MessagingAdmissionController``InFlightLimiter` 둘이다. `reserve`의 CAS 루프는 `AtomicInteger.updateAndGet`으로 쓸 수 있었지만 조건부 실패(`return false`)가 필요해서 직접 루프를 돈다.
## MessagingAdmissionController 참조 위치
:::evidence key="messaging-policy-c06" alt="코드베이스에서 MessagingAdmissionController 를 검색한 출력 22줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MessagingAdmissionController 코드베이스 검색 — 22줄 · exit 0" zoom="true"
:::
## release의 경합이 안전한 방향인 이유
`getAndUpdate`로 감소한 뒤 `computeIfPresent`로 0인 항목을 제거하는데, 그 사이에 다른 스레드가 `computeIfAbsent`로 같은 키를 만들고 증가시킬 수 있다. 그러면 `computeIfPresent`의 람다가 `value.get() == 0`을 보지 못해 제거하지 않는다 — 안전한 방향의 경합이다(누수가 아니라 제거 실패). 반대 순서였다면 살아 있는 카운터를 지울 수 있었다.
## 상태가 없거나 불변인 것들
`DefaultRetryDecisionEngine`·`BackoffCalculator`·`DeadLetterOrchestrator`·`DeadLetterEnvelopeFactory`·`DestinationProfileValidator`는 전부 상태가 없거나 불변이다. `BackoffCalculator`의 기본 생성자가 `ThreadLocalRandom`을 쓰므로 스레드 안전하다.
## 수명주기 참여는 하나뿐이다
`stopAcceptingNewWork()` 하나이고, `MessagingShutdownLifecycle`(starter)이 종료 1단계에서 부른다(`messaging-transport-spi` §12.1 참조).
<!-- body:end -->
@@ -0,0 +1,49 @@
---
kind: CONCEPT
slug: messaging-policy-c09
title: 반납이 획득보다 많으면 제한이 사라진다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-policy-c09
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-policy-c09
file: ../../../final/evidence/rendered/messaging-policy-c09.svg
evidence:
- ../../../final/evidence/raw/messaging-policy-c09.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-policy.md#L700 이다.
module: messaging-policy
---
# 반납이 획득보다 많으면 제한이 사라진다
이 leaf의 주석은 이전 결함보다 왜 이 형태여야 하는가를 더 많이 적는다. 이전 상태를 직접 서술하는 것은 셋이다.
## 관계
- **구성 오류는 한 예외 타입과 안정 코드로 보고한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **저장소 밖 문서를 절 번호로 인용하지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **부팅 경로의 알고리즘 복잡도는 문서화한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
이 leaf의 주석은 이전 결함보다 **왜 이 형태여야 하는가**를 더 많이 적는다. 그중 이전 상태를 직접 서술하는 것은 셋이다.
## GracefulShutdownCoordinator 참조 위치
:::evidence key="messaging-policy-c09" alt="코드베이스에서 GracefulShutdownCoordinator 를 검색한 출력 14줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GracefulShutdownCoordinator 코드베이스 검색 — 14줄 · exit 0" zoom="true"
:::
## 세 번째와 다섯 번째가 같은 형태다
**반납이 획득보다 많으면 제한이 사라진다.** `messaging-transport-spi``GracefulShutdownCoordinator.endWork` clamp와 `DefaultMessagingRuntimeRegistry`의 "정확히 한 번 close"도 같은 계열이고, 그 leaf §13이 소유한다. 저장소 전체에서 반복되는 주제다.
<!-- body:end -->
@@ -0,0 +1,58 @@
---
kind: CONCEPT
slug: messaging-reliability-api-c01
title: Outbox 하나로는 부족하다는 것을 타입이 직접 말한다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-reliability-api-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-reliability-api-c01
file: ../../../final/evidence/rendered/messaging-reliability-api-c01.svg
- key: messaging-reliability-api-c01-diagram
file: ../../../final/assets/diagrams/messaging-reliability-api-c01.svg
evidence:
- ../../../final/evidence/raw/messaging-reliability-api-c01.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-reliability-api.md#L51 이다.
module: messaging-reliability-api
---
# Outbox 하나로는 부족하다는 것을 타입이 직접 말한다
이 leaf는 effectively-once 처리의 계약을 소유한다. 구현이 없고 13개 중 인터페이스 5개, record 5개, enum 3개다.
## 관계
- **계약만 담는 leaf도 계약의 거절 조건은 자기 레인에서 검증한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **record의 `equals`를 좁히면 이유를 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **호출 컨텍스트가 계약이면 그 컨텍스트를 검증할 수단을 함께 정한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
이 leaf는 **effectively-once 처리의 계약**을 소유한다. 구현이 없다 — 13개 중 인터페이스 5개, record 5개, enum 3개이고 실행 가능한 로직은 record 생성자 검증과 `isExpired`/`expiredAt` 술어 정도다. 벤더 의존성 0, 저장소 기술 중립이다.
## 이 리프가 담는 세 메커니즘
:::evidence key="messaging-reliability-api-c01-diagram" alt="리프 경계 안에 Outbox 와 Inbox 와 Claim Check 세 상자가 나란히 들어 있는 구조" caption="이 리프가 담는 세 메커니즘" zoom="false"
:::
**Outbox** — dual-write 문제의 답. **Inbox** — 소비 측 중복 제거. **Claim Check** — 브로커 밖 payload 참조.
## OutboxRecord 참조 위치
:::evidence key="messaging-reliability-api-c01" alt="코드베이스에서 OutboxRecord 를 검색한 출력 10줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="OutboxRecord 코드베이스 검색 — 10줄 · exit 0" zoom="true"
:::
## 타입이 자기 한계를 직접 말한다
셋의 관계를 `OutboxRecord`가 명시한다. **Outbox 하나로는 부족하다는 것을 타입의 javadoc이 직접 말한다.** 이 저장소에서 반복되는 "보장을 과대 진술하지 않는다"의 예다.
<!-- body:end -->
@@ -0,0 +1,49 @@
---
kind: CONCEPT
slug: messaging-reliability-api-c02
title: Outbox에 행을 쓰는 진입점이 없다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-reliability-api-c02
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-reliability-api-c02
file: ../../../final/evidence/rendered/messaging-reliability-api-c02.svg
evidence:
- ../../../final/evidence/raw/messaging-reliability-api-c02.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-reliability-api.md#L92 이다.
module: messaging-reliability-api
---
# Outbox에 행을 쓰는 진입점이 없다
구현 leaf가 셋 있고 전부 배선되는데, `ReliableMessagePublisher`는 구현도 소비자도 0이다.
## 관계
- **계약만 담는 leaf도 계약의 거절 조건은 자기 레인에서 검증한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **record의 `equals`를 좁히면 이유를 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **호출 컨텍스트가 계약이면 그 컨텍스트를 검증할 수단을 함께 정한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
들어오는 것: `messaging-core-api`(api) 하나. 나가는 것: `messaging-outbox-jdbc-postgresql`, `messaging-inbox-jdbc-postgresql`, `messaging-claim-check`, `messaging-spring-boot-starter`.
## ReliableMessagePublisher 참조 위치
:::evidence key="messaging-reliability-api-c02" alt="코드베이스에서 ReliableMessagePublisher 를 검색한 출력 1줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ReliableMessagePublisher 코드베이스 검색 — 1줄 · exit 0" zoom="true"
:::
## 구현 leaf는 셋 다 배선되는데 진입점이 없다
**구현 leaf가 셋 있고 전부 배선된다.** `ReliableMessagePublisher`는 구현도 소비자도 0이다(§12.1). Outbox에 행을 쓰는 애플리케이션 측 진입점인데, 그 진입점이 없다. 이 leaf 자체는 Spring 주석을 갖지 않는다.
<!-- body:end -->
@@ -0,0 +1,67 @@
---
kind: CONCEPT
slug: messaging-reliability-api-c03
title: 이 enum의 의미가 저장소 규칙 하나의 존재 이유다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-reliability-api-c03
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-reliability-api-c03
file: ../../../final/evidence/rendered/messaging-reliability-api-c03.svg
- key: messaging-reliability-api-c03-diagram
file: ../../../final/assets/diagrams/messaging-reliability-api-c03.svg
evidence:
- ../../../final/evidence/raw/messaging-reliability-api-c03.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-reliability-api.md#L143 이다.
module: messaging-reliability-api
---
# 이 enum의 의미가 저장소 규칙 하나의 존재 이유다
`OutboxLease`의 fencing token이 이 leaf에서 가장 중요한 안전 장치이고, `OutboxStatus.FAILED`의 의미가 애플리케이션 쪽 동명 enum과 반대다.
## 관계
- **계약만 담는 leaf도 계약의 거절 조건은 자기 레인에서 검증한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **record의 `equals`를 좁히면 이유를 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **호출 컨텍스트가 계약이면 그 컨텍스트를 검증할 수단을 함께 정한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
`OutboxLease`가 이 leaf에서 가장 중요한 안전 장치이고, 이전 결함이 javadoc에 통째로 있다.
> "The port used to take a `MessageId` for every terminal transition, so a write said which row to change and nothing about which claim it belonged to. A relay that stalled past its lease could still record `AMBIGUOUS` over the `PUBLISHED` another relay had already written, and the row became claimable again — one message, published twice, by a system whose whole purpose is to publish it once.
> The token is the part that makes staleness detectable. It increases on every claim, so a superseded relay holds a number the row no longer has and its update matches zero rows."
`token < 1`을 거절하는 이유도 적혀 있다 — "a claim's token starts at 1; 0 is the value of a row nobody has claimed". `expiredAt(now)``!now.isBefore(expiresAt)`다.
## 청구된 행이 갈리는 네 종착
:::evidence key="messaging-reliability-api-c03-diagram" alt="IN_FLIGHT 에서 PUBLISHED 와 AMBIGUOUS 와 FAILED 와 EXHAUSTED 네 상자로 화살표가 나가는 구조" caption="청구된 행이 갈리는 네 종착" zoom="false"
:::
`PENDING``IN_FLIGHT``PUBLISHED` / `AMBIGUOUS` / `FAILED` / `EXHAUSTED`. **두 쌍의 구분이 각각 이유를 갖는다.** `OutboxRepository.markExhausted`의 javadoc이 같은 말을 반복한다 — "The first needs a fix, the second a redrive."
## OutboxRepository 참조 위치
:::evidence key="messaging-reliability-api-c03" alt="코드베이스에서 OutboxRepository 를 검색한 출력 35줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="OutboxRepository 코드베이스 검색 — 35줄 · exit 0" zoom="true"
:::
## 이름이 같고 의미가 반대인 enum
**`FAILED`의 의미가 애플리케이션 쪽 동명 enum과 반대다.** `CleanArchitectureTest.APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM``.because(...)`가 그것을 ArchUnit 규칙의 근거로 든다 — "its `OutboxStatus.FAILED` means the opposite of the legacy `OutboxEventStatus.FAILED`, so the two models cannot be mixed by name without inverting retryable and terminal." 즉 **이 enum의 의미가 저장소 규칙 하나의 존재 이유다.**
## 정산하면 안 되는 경우를 상수가 들고 있다
`safeToSettle` 플래그가 상수에 붙어 있다. 세 번째의 javadoc이 결론을 적는다 — "Do *not* settle. The other transaction may still roll back, and this delivery is the only remaining copy."
<!-- body:end -->
@@ -0,0 +1,49 @@
---
kind: CONCEPT
slug: messaging-reliability-api-c04
title: Outbox에 행을 쓰는 진입점만 구현이 없다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-reliability-api-c04
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-reliability-api-c04
file: ../../../final/evidence/rendered/messaging-reliability-api-c04.svg
evidence:
- ../../../final/evidence/raw/messaging-reliability-api-c04.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-reliability-api.md#L338 이다.
module: messaging-reliability-api
---
# Outbox에 행을 쓰는 진입점만 구현이 없다
세 실행 경로가 있고 그중 Outbox 쓰기의 진입점만 구현이 없다.
## 관계
- **계약만 담는 leaf도 계약의 거절 조건은 자기 레인에서 검증한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **record의 `equals`를 좁히면 이유를 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **호출 컨텍스트가 계약이면 그 컨텍스트를 검증할 수단을 함께 정한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
**Outbox 쓰기** — 애플리케이션 트랜잭션 안에서 `ReliableMessagePublisher.addToOutbox(...)``OutboxRepository.append(record)`. 이 진입점의 구현이 없다(§12.1).
**Outbox 릴레이**`claimBatch(owner, size, lease, now, maxAttempts)``List<OutboxLease>` → 각 lease에 대해 발행 → 결과에 따라 `markPublished`/`markAmbiguous`/`markExhausted`/`markFailed`(lease 기반) → `APPLIED`면 정상, `STALE_LEASE`면 다른 릴레이가 가져감.
**Inbox**`handleOnce(consumerName, delivery, action)` → 한 트랜잭션 안에서 `reserve(messageId, consumerId, now)` → true면 `action.apply(delivery)` → 커밋.
## 이 기록이 다루는 파일 범위
:::evidence key="messaging-reliability-api-c04" alt="코드베이스에서 파일 목록을 만든 출력 13줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 13줄 · exit 0" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,49 @@
---
kind: CONCEPT
slug: messaging-reliability-api-c05
title: 실패를 예외가 아니라 상태와 반환값으로 표현한다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-reliability-api-c05
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-reliability-api-c05
file: ../../../final/evidence/rendered/messaging-reliability-api-c05.svg
evidence:
- ../../../final/evidence/raw/messaging-reliability-api-c05.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-reliability-api.md#L348 이다.
module: messaging-reliability-api
---
# 실패를 예외가 아니라 상태와 반환값으로 표현한다
이 leaf는 `MessagingException`을 하나도 던지지 않는다. `IllegalArgumentException`을 던지는 곳은 record 생성자 여섯뿐이다.
## 관계
- **계약만 담는 leaf도 계약의 거절 조건은 자기 레인에서 검증한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **record의 `equals`를 좁히면 이유를 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **호출 컨텍스트가 계약이면 그 컨텍스트를 검증할 수단을 함께 정한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
**이 leaf는 `MessagingException`을 하나도 던지지 않는다.** 실패를 상태와 반환값으로 표현한다. `IllegalArgumentException`을 던지는 곳은 record 생성자 여섯이다 — 전부 호출자의 프로그래밍 오류다.
## MessagingException 참조 위치
:::evidence key="messaging-reliability-api-c05" alt="코드베이스에서 MessagingException 를 검색한 출력 36줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MessagingException 코드베이스 검색 — 36줄 · exit 0" zoom="true"
:::
## 예외가 롤백 신호인 자리
`TransactionalMessageAction.apply``throws Exception`이다 — javadoc: "rolling back both it and the inbox reservation". 즉 예외가 롤백 신호이고, 그 처리는 구현 leaf가 소유한다.
<!-- body:end -->
@@ -0,0 +1,58 @@
---
kind: CONCEPT
slug: messaging-reliability-api-c06
title: 전부 트랜잭션 계약인데 코드에는 트랜잭션이 없다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-reliability-api-c06
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-reliability-api-c06
file: ../../../final/evidence/rendered/messaging-reliability-api-c06.svg
- key: messaging-reliability-api-c06-diagram
file: ../../../final/assets/diagrams/messaging-reliability-api-c06.svg
evidence:
- ../../../final/evidence/raw/messaging-reliability-api-c06.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-reliability-api.md#L366 이다.
module: messaging-reliability-api
---
# 전부 트랜잭션 계약인데 코드에는 트랜잭션이 없다
이 leaf 전체가 트랜잭션 계약이지만 코드에는 트랜잭션이 없다 — 전부 javadoc이 요구하는 규약이고, 마지막 하나만 타입이 강제한다.
## 관계
- **계약만 담는 leaf도 계약의 거절 조건은 자기 레인에서 검증한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **record의 `equals`를 좁히면 이유를 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **호출 컨텍스트가 계약이면 그 컨텍스트를 검증할 수단을 함께 정한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
**이 leaf 전체가 트랜잭션 계약이다.** 그런데 코드에는 트랜잭션이 없다 — 전부 javadoc이 요구하는 규약이다. 마지막 하나만 타입이 강제한다.
## 타입이 강제하는 것과 규약으로 남는 것
:::evidence key="messaging-reliability-api-c06-diagram" alt="리프 경계 안에 fencing token 이 들어 있고 트랜잭션 경계가 경계 밖 점선 상자로 놓인 구조" caption="타입이 강제하는 것과 규약으로 남는 것" zoom="false"
:::
동시성 원시 요소는 하나 — **fencing token**. 그것이 `OutboxLease.token`이고 검사는 구현의 SQL `WHERE`에 있다(§12.1).
## OutboxLease 참조 위치
:::evidence key="messaging-reliability-api-c06" alt="코드베이스에서 OutboxLease 를 검색한 출력 10줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="OutboxLease 코드베이스 검색 — 10줄 · exit 0" zoom="true"
:::
## 상태를 가진 클래스가 없다
모든 record가 불변이다.
<!-- body:end -->
@@ -0,0 +1,49 @@
---
kind: CONCEPT
slug: messaging-reliability-api-c07
title: 전달 경로가 메시지 내용을 바꾸면 그것은 전달이 아니다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-reliability-api-c07
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-reliability-api-c07
file: ../../../final/evidence/rendered/messaging-reliability-api-c07.svg
evidence:
- ../../../final/evidence/raw/messaging-reliability-api-c07.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-reliability-api.md#L610 이다.
module: messaging-reliability-api
---
# 전달 경로가 메시지 내용을 바꾸면 그것은 전달이 아니다
javadoc이 세 개의 서로 다른 결함을 보존한다. 첫 둘이 같은 사건의 두 측면이다.
## 관계
- **계약만 담는 leaf도 계약의 거절 조건은 자기 레인에서 검증한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **record의 `equals`를 좁히면 이유를 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **호출 컨텍스트가 계약이면 그 컨텍스트를 검증할 수단을 함께 정한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
이 leaf의 javadoc은 **세 개의 서로 다른 결함**을 보존한다. 첫 둘이 같은 사건의 두 측면이다 — fencing token(감지 수단)과 반환값(감지 결과의 전달 수단). 둘 다 있어야 stale lease가 관측된다.
## 이 기록이 다루는 파일 범위
:::evidence key="messaging-reliability-api-c07" alt="코드베이스에서 파일 목록을 만든 출력 13줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 13줄 · exit 0" zoom="true"
:::
## 세 번째의 마지막 문장
이 저장소에서 가장 날카로운 진술 중 하나다 — **"which makes the publish path — direct, polling or CDC — part of the message's meaning."** 전달 경로가 메시지 내용을 바꾸면 그것은 더 이상 전달이 아니다.
<!-- body:end -->

Some files were not shown because too many files have changed in this diff Show More