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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-07 15:02:25 +09:00
co-authored by Claude Opus 5
parent a0ca2bb72a
commit 1f04117bbf
851 changed files with 5498 additions and 90638 deletions
@@ -1,111 +0,0 @@
---
kind: CASE
slug: a-cleanup-claim-without-fencing
title: claim이 소유자·토큰·만료를 기록하지 않아 죽은 worker의 항목이 영영 남았다
topic: fileserver-state-and-fencing
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a-cleanup-claim-without-fencing
evidenceCapturedOn: 2026-09-02
assets:
- key: a-cleanup-claim-without-fencing
file: ../../../final/evidence/rendered/a-cleanup-claim-without-fencing.svg
evidence:
- ../../../final/evidence/raw/a-cleanup-claim-without-fencing.txt
source:
- 컬럼 정의와 두 결과의 사후 기록은 `V3__fileserver_fenced_cleanup_lease.sql` 의 헤더와 두 COMMENT 에 있다. 토큰 대조와 널 만료 제외의 이유는 `FileserverCleanupRepository` 의 두 메서드 javadoc 에 있다. 원본 분석은 `final/document.md#a05` §79 가 이 스키마를 다룬다.
---
# claim이 소유자·토큰·만료를 기록하지 않아 죽은 worker의 항목이 영영 남았다
정리 항목의 청구가 상태만 바꾸고 소유자도 토큰도 리스 만료도 기록하지 않았다. 하나의 누락에서 회수되지 않는 항목과 덮어쓰기라는 두 결과가 나왔다.
## 관계
- **fenced lease — 만료 시각만으로는 부족한 이유**
이 사례가 같은 문제의 파일서버 판이다.
- **리스가 만료 시각만 기록하고 소유자를 기록하지 않아 최종 상태를 되돌릴 수 있었다**
같은 형태가 메시징 어댑터에서 나타난 사례다.
- **cleanup이 읽은 lease와 삭제 사이에 writer가 그 lease를 얻을 수 있었다**
같은 저장소의 다음 마이그레이션이 다룬 문제다.
## 문제
정리 작업은 파일을 물리적으로 지운 뒤 데이터베이스를 정산한다. 그 사이에 작업자가 죽을 수 있고, 여러 작업자가 같은 항목을 두고 겹칠 수 있다.
청구가 그 두 상황을 구별할 정보를 남기지 않았다. 상태를 진행 중으로 옮기는 것이 전부였다.
## 결론
수정은 컬럼 넷과 질의 둘이다.
컬럼은 청구 소유자, 청구 토큰, 리스 만료, 그리고 청구 세대 계수기다. 질의는 완료 갱신이 토큰으로 행을 찾게 만든 것과, 회수기가 만료된 청구를 오래된 것부터 가져오되 널 만료는 제외하게 만든 것이다.
두 결과가 어떻게 생겼고 각 컬럼이 무엇을 맡는지는 본문이 다룬다.
## 검증 환경
데이터베이스 : PostgreSQL
마이그레이션 도구 : Flyway
확인 방식 : V3 마이그레이션의 컬럼 정의와 정리 저장소 질의 두 개 확인, claim_fence 참조 전수 검색
소스 수정 : x
## 재현 조건
1. fileserver 의 V3 마이그레이션 헤더를 읽는다. 두 결과가 나란히 적혀 있다.
2. 추가된 컬럼 넷과 각각의 널 허용 여부를 확인한다.
3. 완료 갱신 질의가 무엇으로 행을 찾는지 확인한다.
4. 회수기 질의가 널 만료를 어떻게 다루는지 확인한다.
5. claim_fence 를 읽는 코드가 있는지 검색한다.
## 본문
<!-- body:start -->
정리 항목의 청구가 상태를 `IN_PROGRESS` 로 옮기고 그 외에는 아무것도 기록하지 않았다. 마이그레이션 헤더가 그 누락에서 나온 결과를 둘로 적는다.
## 하나는 아무도 손대지 않아서, 하나는 두 손이 겹쳐서
첫째는 회수되지 않는 항목이었다. 물리 삭제를 수행하고 데이터베이스를 정산하기 전에 죽은 작업자가 행을 진행 중 상태로 영원히 남겼다. 어떤 질의도 그 항목을 살아 있는 작업자가 지금 지우고 있는 항목과 구별할 수 없었다. 파일은 이미 사라졌는데 쿼터와 수명주기는 정산되지 않은 채 남았다.
둘째는 덮어쓰기였다. 완료 갱신이 정리 식별자만으로 행을 찾았다. 어떤 합리적 리스보다 오래 멈춰 있던 작업자가 깨어나, 그 사이 다른 작업자가 청구해 반쯤 진행한 항목 위에 완료를 쓸 수 있었다.
같은 누락에서 나왔지만 방향이 반대다.
## 컬럼 넷과 질의 둘
:::evidence key="a-cleanup-claim-without-fencing" alt="코드베이스에서 V3 마이그레이션의 컬럼 넷과 정리 저장소의 질의, 그리고 claim_fence 참조를 뽑은 출력 21줄. 완료 갱신이 토큰으로 행을 찾고 회수기가 널 만료를 제외한다는 것, claimFence 는 증가만 하고 그 엔티티의 getter 열셋 안에 없다는 것이 그 출력에 그대로 보인다." caption="V3 컬럼 넷 · 토큰 대조 · 널 만료 제외 · claimFence 참조 — 21줄 · exit 0" zoom="true"
:::
컬럼은 `claim_owner` · `claim_token` · `lease_until` · `claim_fence` 다. 앞의 셋은 널을 허용하고 넷째만 `NOT NULL DEFAULT 0` 이다. 헤더가 그 이유를 하나로 적는다 — 이 마이그레이션 이전에 청구된 항목이 계속 동작해야 하기 때문이다.
스키마만 바뀐 것이 아니다. 저장소의 질의 둘이 그 컬럼을 실제로 쓴다.
## 토큰이 덮어쓰기를 막는다
완료 갱신의 `where` 절에 `and c.claimToken = :token` 이 붙었다. 그 메서드의 javadoc 이 이전 상태를 적는다 — 예전에는 정리 식별자만으로 대조했고, 그래서 오래 멈춰 있던 작업자가 다른 작업자의 진행 중 항목 위에 완료를 쓸 수 있었다.
반환 타입이 `int` 다. 교체된 작업자는 예외를 받는 것이 아니라 **갱신 행 수 0** 을 받는다. 행을 못 찾는 것이 실패가 아니라 답이 되는 형태다.
## 널 만료를 회수기가 건너뛴다
회수기 질의는 진행 중이면서 만료가 지난 항목을 만료 순으로 가져온다. 조건에 `and c.leaseUntil is not null` 이 들어 있다.
그 메서드의 javadoc 이 이유를 적는다 — 널 리스는 펜싱이 생기기 전에 청구됐다는 뜻이고, 자동으로 넘겨받는 것은 아무도 상태를 기록하지 않은 작업에 대해 추측하는 일이다. 그 항목이 물리 삭제를 마쳤는지 시작도 안 했는지 알 방법이 없다. 그래서 운영자를 필요로 한다.
부분 인덱스가 그 질의 모양 그대로 만들어져 있다 — `lease_until` 에 걸리고 조건이 `status = 'IN_PROGRESS'` 다.
## 넷째 컬럼은 아직 소비자가 없다
`claim_fence` 는 청구할 때마다 1 씩 오른다. 저장소에서 그 이름이 나오는 곳은 그 증가 한 줄과 엔티티의 필드 선언뿐이다.
엔티티는 getter 열셋을 갖는다. `getClaimToken``getLeaseUntil` 은 있고 `claimFence` 의 getter 는 없다. 즉 값이 올라가기만 하고 어디에서도 읽히거나 비교되지 않는다.
마이그레이션은 이 컬럼에만 COMMENT 를 붙이지 않았다. 다른 둘에는 무엇을 위한 값인지 적혀 있다.
## 확인하지 못한 것
작업자를 죽여 진행 중 항목이 남는 것을 재현하지 않았다. 컨테이너 레인을 돌리지 않았다.
<!-- body:end -->
@@ -1,114 +0,0 @@
---
kind: CASE
slug: a-read-then-delete-race-on-the-upload-lease
title: cleanup이 읽은 lease와 삭제 사이에 writer가 그 lease를 얻을 수 있었다
topic: fileserver-state-and-fencing
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a-read-then-delete-race-on-the-upload-lease
evidenceCapturedOn: 2026-09-02
assets:
- key: a-read-then-delete-race-on-the-upload-lease
file: ../../../final/evidence/rendered/a-read-then-delete-race-on-the-upload-lease.svg
evidence:
- ../../../final/evidence/raw/a-read-then-delete-race-on-the-upload-lease.txt
source:
- 이 경합의 1차 기록은 `V4__fileserver_upload_terminal_state.sql` 의 헤더다. 분석 문서는 두 곳에서 이 사건을 다룬다. application-core 편 §11.2 가 취소와 정리의 경합을 청구가 만드는 경계로 정리하고, persistence-jpa 편 §83.3 이 이전 리뷰가 올린 소유자·토큰·최종 상태 부재를 V3·V4 와 현재 저장소 코드가 해소했다고 적는다. 같은 편 §79 의 마이그레이션 목록도 이 컬럼을 언급한다.
---
# cleanup이 읽은 lease와 삭제 사이에 writer가 그 lease를 얻을 수 있었다
정리 작업이 쓰기 리스를 읽어 없음을 확인하고 스테이징 바이트를 지웠다. 읽기와 삭제 사이에 쓰기 작업자가 바로 그 리스를 얻을 수 있었고, 정리가 지운 것은 업로드가 이어 쓰고 있던 객체였다.
## 관계
- **조건부 update로 행을 claim하고 읽은 값으로 판단하지 않는다**
이 사례에서 끌어낸 규칙이다.
- **CAS 튜플을 where 절에 전부 반복하고 update count를 답으로 쓴다**
같은 형태를 다른 저장소에서 다룬 규칙이다.
- **claim이 소유자·토큰·만료를 기록하지 않아 죽은 worker의 항목이 영영 남았다**
같은 어댑터의 앞선 마이그레이션이 다룬 문제다.
## 문제
정리 작업은 스테이징 바이트를 지우기 전에 그 업로드에 쓰기 리스가 걸려 있는지 확인했다. 리스가 없으면 아무도 쓰고 있지 않다고 판단하고 지웠다.
읽기와 삭제 사이에 쓰기 작업자가 그 리스를 얻을 수 있었다. 업로드가 끝났다는 표시가 데이터베이스에 없었기 때문이다.
쓰기 작업자의 획득 문장도 그것을 막지 못했다. 그 문장은 업로드의 만료와 리스만 확인했고, 취소되었는지 검증에 실패했는지는 확인하지 않았다.
## 결론
수정은 두 쪽이 함께 볼 상태 컬럼을 만드는 것이었다. 취소와 검증 실패 마무리가 정리를 큐에 넣는 같은 트랜잭션 안에서 세션을 최종 상태로 옮기고, 획득과 갱신과 오프셋 커밋은 활성 상태를 요구한다.
정리는 조금 전에 읽은 값으로 판단하는 대신 조건부 갱신으로 행을 청구한다. 0행이 돌아오면 삭제를 다음 주기로 넘긴다.
기본값이 활성으로 잡혀 있어 이 컬럼이 생기기 전 세션도 영향을 받지 않는다.
리스 반납 문장에는 이 조건이 없다. 클래스 javadoc 은 모든 쓰기 문장이 활성을 요구한다고 적는데, 그 문장 하나는 그렇지 않다.
## 검증 환경
데이터베이스 : PostgreSQL
마이그레이션 도구 : Flyway
확인 방식 : 마이그레이션 헤더의 사후 기록과 현재 구현의 조건절·트랜잭션 경계 확인
소스 수정 : x
## 재현 조건
1. 업로드 최종 상태 마이그레이션의 헤더를 읽는다. 경합 순서와 양쪽의 판단 근거가 적혀 있다.
2. 추가된 상태 컬럼과 CHECK 제약, 부분 인덱스를 확인한다.
3. 저장소의 쓰기·전이 문장을 전부 세고, 그중 몇 개가 활성 상태를 조건으로 거는지 확인한다.
4. 클래스 javadoc 의 주장과 각 문장의 실제 조건을 대조한다.
5. 최종화와 정리 큐잉을 감싸는 트랜잭션 람다를 두 경로에서 시작부터 끝까지 읽는다.
6. 정리가 청구 전에 무엇을 하는지, 청구에 실패하면 무엇을 하는지 확인한다.
## 본문
<!-- body:start -->
마이그레이션 헤더가 이 경합을 사후에 기록한다. 정리는 리스의 부재를 봤고, 쓰기의 획득 문장은 업로드의 만료와 리스를 봤다. 취소되었는지 검증에 실패했는지를 묻는 쪽은 없었다.
그래서 이미 삭제 예정인 바이트에 대해 획득 문장이 리스를 내주었다. 그 요청을 거를 근거를 갖고 있지 않았다.
## 상태 하나를 두 쪽이 같이 본다
:::evidence key="a-read-then-delete-race-on-the-upload-lease" alt="코드베이스에서 V4 마이그레이션이 더한 상태 컬럼과 CHECK 제약과 부분 인덱스, 이 저장소의 쓰기·전이 문장 여섯과 그중 활성 상태를 조건으로 거는 네 줄, 모든 쓰기 문장이 활성을 요구한다고 적은 클래스 javadoc 과 그 조건이 없는 리스 반납 문장, 최종화와 정리 청구가 요구하는 상태, 취소 경로와 검증 실패 경로의 트랜잭션 람다 전체, 그리고 정리가 세션을 먼저 읽고 있을 때만 청구하는 조건을 뽑은 출력. javadoc 의 주장과 리스 반납 문장의 조건이 어긋난다는 것이 그 출력에 나란히 보인다." caption="상태 컬럼과 제약 · 문장 여섯과 활성 조건 넷 · javadoc 과 어긋나는 반납 문장 · 두 경로의 트랜잭션 전체 · 정리의 읽기와 청구" zoom="true"
:::
V4 가 세션 테이블에 상태 컬럼을 더한다. 값은 활성과 최종 둘이고 CHECK 로 묶여 있다. 기본값이 활성이라 이 컬럼 이전의 세션은 그대로 동작한다.
같은 마이그레이션이 부분 인덱스도 만든다. 최종 상태인 행만 담고 리스 만료로 정렬한다. 청구 문장의 술어와 같은 조건이지만, 청구 자체는 업로드 아이디 단건 갱신이라 이 인덱스를 타지 않는다. 최종 상태 집합을 만료 순으로 훑을 때를 위한 모양이다.
## 쓰기 문장 넷 중 셋이 활성을 요구한다
리스 획득과 갱신과 오프셋 커밋의 조건절에 활성 상태가 들어갔다. 최종 상태로 옮겨진 세션에는 이 셋이 걸리지 않는다.
리스 반납 문장에는 없다. 업로드 아이디와 리스 토큰만 본다. 최종 상태로 옮겨진 뒤에도 진행 중이던 쓰기 작업자가 자기 리스를 놓을 수 있어야 하고, 반납은 리스 컬럼을 비울 뿐 바이트를 건드리지 않는다.
클래스 javadoc 은 모든 쓰기 문장이 활성을 요구한다고 적는다. 넷 중 셋이다.
최종화 문장 자체도 활성일 때만 성립한다. 이미 최종인 행을 다시 최종으로 옮기는 시도는 0행을 갱신한다.
## 최종화와 정리 큐잉이 한 트랜잭션 안에 있다
취소 경로와 검증 실패 경로가 각각 하나의 트랜잭션 안에서 세 가지를 함께 한다. 기록을 도달 불가로 옮기고, 세션을 최종 상태로 옮기고, 정리 요청을 큐에 넣는다.
셋이 함께 커밋되므로 큐에 항목이 있는데 세션은 아직 활성인 순간이 없다. 취소 경로의 주석이 그 이유를 적는다 — 큐잉만 하면 세션이 활성으로 남아, 정리가 지우려는 바로 그 바이트에 쓰기가 리스를 얻을 수 있었다는 것이다.
첫 번째 쓰기에도 이유가 붙어 있다. 기록이 먼저 도달 불가가 되어야 물리 작업이 예약되고, 둘이 함께 커밋되므로 파일이 도달 불가인데 회수할 것이 큐에 없는 상태가 남지 않는다.
## 정리는 읽은 값 대신 청구로 판단한다
정리 청구 문장은 최종 상태이고 리스가 없거나 만료된 행만 갱신한다. 그 한 문장이 리스가 걸려 있지 않다는 것을 증명하는 동시에 리스 컬럼을 비운다.
청구가 0행이면 지우지 않는다. 항목을 실패로 표시하고 사유를 남기고 결과를 건너뜀으로 돌려준다.
다만 청구는 세션 행이 있을 때만 걸린다. 조건이 존재 확인과 청구의 논리곱이라, 세션이 이미 사라진 항목은 청구를 거치지 않고 바로 지운다 — 지킬 리스를 가진 쓰기 작업자가 있을 수 없는 경우다.
## 확인하지 못한 것
읽기와 삭제 사이에서 리스를 가로채는 경합을 재현해 보지는 않았다. 경합 순서는 마이그레이션 헤더의 사후 기록이고, 수정의 현재 형태는 코드로 확인했다.
<!-- body:end -->