refactor: 문서 개선 중

This commit is contained in:
donghyeon-ka
2026-09-21 14:30:55 +09:00
parent c93cdea150
commit 805a18f486
1497 changed files with 525837 additions and 59152 deletions
@@ -30,7 +30,7 @@ outbox 리스가 만료 시각만 담고 최종 상태 쓰기가 메시지 식
- **fenced lease — 만료 시각만으로는 부족한 이유**
이 사례가 만든 개념이다.
- **CAS 튜플을 where 절에 전부 반복하고 update count를 답으로 쓴다**
결함의 수정 형태를 규칙으로 옮긴 것이다.
사건에서 사용한 owner token·revision 조건을 재사용 가능한 CAS 규칙으로 정리한다.
- **만료된 claim과 만료된 실행은 다르게 다뤄야 한다**
만료된 claim 과 만료된 실행을 갈라 다루는 판단이다.
@@ -80,7 +80,7 @@ AMBIGUOUS 는 청구 가능한 상태다. 확인된 메시지가 다시 발행
:::evidence key="a-lease-without-an-owner" alt="코드베이스에서 V2 마이그레이션이 더한 컬럼과 토큰 제약, 다섯 상태에서 여섯으로 바뀐 CHECK, 청구문이 소유자와 토큰을 쓰는 SET 절과 그 청구가 읽는 술어와 그에 맞춘 부분 인덱스, 최종 쓰기의 펜싱 술어와 0행을 보고한다는 주석, 릴레이가 부르는 다섯 전이, 펜싱 없는 옛 메서드의 선언과 그 자신의 javadoc 과 신세대 javadoc 의 폐기 문장과 @Deprecated 매치 수와 호출 수, 두 세대가 AMBIGUOUS 에서 남기는 컬럼의 차이, 그리고 파일서버 마이그레이션이 같은 결함을 다른 곳에서 지목하는 줄을 뽑은 출력. 옛 메서드의 javadoc 이 아직 안전을 주장하고 신세대 javadoc 이 그것을 폐기라 적는다는 것이 나란히 보인다." caption="V2 컬럼과 제약 · 청구 술어와 부분 인덱스 · 펜싱 술어와 0행 보고 · 옛 경로의 두 javadoc · AMBIGUOUS 에서의 세대 차이" zoom="true"
:::
토큰에는 음수가 아니라는 제약이 붙는다. 백필은 하지 않는다 — 기본값이 0 이고 첫 청구가 그것을 올리므로 정확성에 필요하지 않으며, 제약은 코드가 의존하는 불변식을 스키마에 남기려는 것이다.
토큰에는 음수가 아니라는 제약이 붙는다. 기본값이 0이고 첫 claim이 값을 증가시키므로 별도 backfill은 하지 않는다. 제약은 코드가 의존하는 불변식을 스키마에서도 검사하게 한다.
청구문이 토큰을 `o.lease_token + 1` 로 올린다. 청구를 내주는 바로 그 문장 안에서, 서버가 올린다. 그래서 같은 행을 두고 경쟁한 두 릴레이가 같은 번호를 받을 수 없다.
@@ -90,7 +90,7 @@ AMBIGUOUS 는 청구 가능한 상태다. 확인된 메시지가 다시 발행
최종 쓰기의 술어에 `lease_owner``lease_token` 이 들어간다. 지나간 획득의 쓰기는 걸릴 행이 없다.
그 자리 javadoc 이 0행을 어떻게 다루는지 적는다. 0행은 삼키지 않고 보고한다 — 지나간 쓰기가 있었다는 것은 이 작업자가 중복 발행을 만들었을 수 있다는 뜻이고, 그것이 운영자가 봐야 하는 사실이라는 것이다.
최종 갱신 메서드의 javadoc은 update count가 0일 때 이를 삼키지 않고 보고하도록 요구한다. 소유권을 잃은 뒤 쓰기를 시도했다면 중복 발행 가능성을 조사해야 하기 때문이다.
술어를 붙이는 SQL 문자열은 두 개이고, 그 둘을 만드는 헬퍼 둘이 다섯 전이의 최종 쓰기를 전부 처리한다.
@@ -104,11 +104,11 @@ AMBIGUOUS 는 청구 가능한 상태다. 확인된 메시지가 다시 발행
이 저장소의 프로덕션 코드는 청구도 전이 넷도 전부 신세대만 부른다. 아래는 지금 일어나는 일이 아니라 포트가 두 형태를 나란히 둔 결과다.
청구 메서드 인터페이스에 그대로 있다. 그 메서드 자신의 javadoc 아직 이렇게 적는다 — 단순 조회가 아니라 리스를 잡는 것이 여러 릴레이를 안전하게 만들고, 한 릴레이가 청구한 레코드는 리스가 만료될 때까지 다른 릴레이에 보이지 않으므로 같은 메시지가 두 프로세스에서 동시에 발행되지 않는다는 것이다.
claim 메서드 인터페이스에 남아 있다. 해당 javadoc은 단순 조회 대신 lease를 획득해, 한 relay가 claim한 레코드를 lease 만료 전까지 다른 relay가 다시 claim하지 못하게 한다고 설명한다.
이 사례가 반증한 문장이 그대로 있다.
폐기를 적은 것은 신세대 쪽 javadoc 이다. 옛 메서드는 토큰 없는 레코드를 돌려주므로 호출자가 자기 쓰기가 자기 청구에 속한다는 것을 증명할 수 없고, 조사 경로용으로 남기며 릴레이가 쓰기에는 폐기되었다는 것이다.
신규 API javadoc은 옛 메서드를 relay 경로에서 사용하지 말라고 명시한다. 옛 메서드는 fencing token 없는 레코드를 반환하므로 후속 쓰기가 현재 claim에 속하는지 증명할 수 없고, 조사 용도로만 남긴다.
그 문장은 산문이다. messaging 트리 전체에 `@Deprecated` 가 하나도 없다.
@@ -0,0 +1,73 @@
---
kind: CASE
slug: a-mark-that-meant-seen-not-projected
title: 「본 적 있는 위치」를 「투영이 끝난 위치」로 쓴 mark 가 재전달된 이벤트를 삼켰다
topic: owner-safe-state-machines
topicName: owner-safe 상태 기계 — 소유권을 SQL에 적기
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a-mark-that-meant-seen-not-projected
source:
- final/document.md#8-1
- final/document.md#a06
- final/document.md#4-2
- final/document.md#8-1 항목 9
- final/document.md#a06 §67
---
# 「본 적 있는 위치」를 「투영이 끝난 위치」로 쓴 mark 가 재전달된 이벤트를 삼켰다
이벤트 위치를 “관측했다”는 시점에 mark를 전진시키면 projection이 끝나기 전에 failover가 발생했을 때 재전달 이벤트를 이미 처리한 것으로 오인할 수 있다. 기존 분석은 한 번의 failover probe에서 이 손실 경로를 재현했다.
## 관계
- **CAS tuple과 update count**
상태 전이가 완료됐다는 조건을 owner와 revision으로 함께 검사하는 개념이다.
- **lease에는 owner가 있어야 한다**
worker가 바뀌는 경계에서 이전 소유자의 진행 상태를 그대로 신뢰하지 않는 사례다.
## 문제
mark가 이벤트를 읽은 직후 전진하고 실제 projection 반영은 그 뒤에 실행되면 두 상태 사이에 공백이 생긴다.
worker가 그 공백에서 중단되고 다른 worker가 mark부터 읽으면, 새 worker는 해당 위치를 이미 완료한 것으로 판단해 이벤트를 건너뛸 수 있다.
## 결론
“마지막으로 본 위치”와 “projection을 완료한 위치”는 같은 상태가 아니다. 완료 mark는 projection이 성공적으로 반영된 뒤에만 전진해야 한다.
기존 probe는 worker 하나와 한 번의 평범한 failover만 다뤘다. 여러 worker나 반복 resume에서 손실 폭이 어떻게 달라지는지는 확인하지 않았다.
## 검증 환경
sourceRevision : 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
확인 방식 : SSOT에 기록된 failover probe 결과
현재 source repository 재대조 : UNVERIFIABLE
## 재현 조건
1. 이벤트 위치 mark가 갱신되는 시점과 projection 반영 시점을 분리해 확인한다.
2. mark 전진 뒤 projection 완료 전에 worker를 중단한다.
3. 다른 worker가 같은 stream을 resume했을 때 해당 이벤트가 다시 처리되는지 확인한다.
4. 이번 검토에서는 기존 SSOT probe 결과만 사용했고 새 실행은 하지 않았다.
## 본문
<!-- body:start -->
## 필요한 상태는 세 개다
아직 보지 않은 위치, 본 적은 있지만 projection이 끝나지 않은 위치, projection까지 완료한 위치를 구분해야 한다.
두 상태만 두고 “봤다”를 “끝났다”로 사용하면 failover 중간 상태를 표현할 수 없다.
## mark는 완료 뒤에 전진한다
projection 반영과 mark 갱신이 하나의 owner-safe 전이로 묶이거나, 적어도 완료가 증명된 뒤 mark를 갱신해야 한다. 새 worker는 완료 mark만 resume 기준으로 사용한다.
## 확인하지 못한 것
기존 probe는 단일 worker의 한 번 failover다. 반복 failover와 다중 worker에서의 손실 폭은 측정하지 않았다.
<!-- body:end -->
@@ -58,7 +58,7 @@ IdempotencyCapabilityGuard 의 클래스 자바독(:12~:21)이 무엇이 틀렸
OpenJDK : 21.0.12
Spring Boot : 4.0.8
근거 : 저장소의 자바독이 사후 기록으로 남긴 회귀
확인 방식 : 가드 자바독의 사후 기록 확인, 필드와 생성자의 널 검사 유무 확인, 전제 검사 메서드의 세 검사와 실패 메시지 확인, 데이터소스를 정하는 메서드와 그 자바독 확인, 저장소를 만드는 자리 전수와 각각이 넘기는 값 확인, JdbcOperations 구현과 JdbcTemplate 하위 클래스 검색, 세 번째 검사의 메시지를 단언하는 시험 검색, 가드의 두 메서드를 부르는 자리 전수, 형제 어댑터 둘의 데이터소스 주입과 세 검사 형태 대조
확인 방식 : 가드 javadoc의 사후 기록 확인, 필드와 생성자의 null 검사 확인, 전제 검사 메서드의 세 검사와 실패 메시지 확인, DataSource 추론 메서드 확인, 저장소 생성 지점과 전달 값 확인, JdbcOperations 구현 검색, 세 번째 검사 테스트 검색, 가드 호출 지점 전수 확인, 형제 어댑터의 DataSource 주입 방식과 검사 비교
소스 수정 : x
## 재현 조건
@@ -68,26 +68,26 @@ Spring Boot : 4.0.8
3. 전제를 검사하는 메서드의 본문을 끝까지 읽고 검사가 몇 개인지, 각각 어떤 메시지로 실패하는지 적는다.
4. 데이터소스 검사에 붙은 조건을 읽고 그 값이 어디서 오는지 거슬러 올라간다.
5. 그 값을 정하는 메서드와 자바독을 읽는다.
6. 이 저장소를 만드는 자리를 전부 찾고 각각이 무엇을 넘기는지 확인한다.
6. 이 저장소를 생성하는 코드를 전부 찾고 각 생성자가 무엇을 넘기는지 확인한다.
7. 저장소에 그 인터페이스의 다른 구현이 있는지 찾는다.
8. 세 번째 검사가 던지는 메시지를 저장소에서 찾아 그것을 단언하는 시험이 있는지 본다.
9. 가드의 두 메서드를 부르는 자리를 전부 찾는다.
9. 가드의 두 메서드를 호출하는 코드를 전부 찾는다.
10. 형제인 outbox 와 inbox 어댑터가 데이터소스를 어떻게 받고 같은 세 검사를 어떤 형태로 거는지 확인한다.
## 본문
<!-- body:start -->
`IdempotencyCapabilityGuard` 는 멱등성 저장소가 SQL 돌리기 전에 만족해야 할 전제를 모아 둔 타입이다. 클래스 자바독이 이 타입이 따로 생긴 이유를 적는데, 전제가 서로 다른 세 질문이고 저장소가 세 번째를 틀리게 답했다는 것이다.
`IdempotencyCapabilityGuard`는 멱등성 저장소가 SQL을 실행하기 전에 세 전제를 검사한다. 클래스 javadoc은 기존 저장소가 그중 DataSource 소유권 질문을 잘못 검사해 별도 guard로 분리했다고 기록한다.
## 자바독이 남긴 사후 기록
:::evidence key="an-active-transaction-check-that-asked-the-wrong-question" alt="저장소 루트에서 돌린 정적 검색 출력 149줄. IdempotencyCapabilityGuard 의 클래스 자바독이 9번부터 23번 줄까지 원문 그대로 실려 세 질문과 틀린 답과 데이터소스가 둘일 때의 결과가 나온다. 이어서 필드 셋과 생성자가 28번부터 39번 줄까지 실리는데 jdbc 와 activeCapabilitySql 은 requireNonNull 을 지나고 dataSource 만 그대로 대입된다. requirePrimaryWriteTransaction 의 본문이 87번부터 109번 줄까지 나와 세 검사와 각각의 메시지가 보이고, 마지막 검사가 dataSource 가 널이 아닐 때만 hasResource 를 평가한다. 그 값을 정하는 dataSourceOf 가 JdbcTemplate 일 때만 getDataSource 를 돌려주고 아니면 널이라는 것과, 그 절충을 인정하는 자바독이 함께 나온다. 이 저장소를 만드는 자리 셋이 소스 세트별로 나오는데 프로덕션은 하나이고 그 자바독이 가드가 데이터소스를 식별하므로 다른 데이터소스의 트랜잭션은 통과할 수 없다고 약속한다. 유닛 시험은 mock 을 넘긴다. 저장소에 JdbcOperations 구현이나 JdbcTemplate 하위 클래스는 0 건이다. 세 번째 검사의 메시지를 찾으면 던지는 줄 하나만 나오고 시험은 없으며, 전제 시험 셋이 무엇을 단언하는지 이름으로 나온다. 마지막으로 가드를 부르는 열일곱 줄과 형제 어댑터 둘이 데이터소스를 생성자로 받아 requireNonNull 하는 것과 같은 세 검사를 거는 형태가 나온다." caption="세 질문과 틀린 답을 적은 자바독 · dataSource 만 널 검사를 지나지 않는 생성자 · 지금의 세 검사와 마지막에 붙은 널 조건 · 그 값을 정하는 dataSourceOf · 조립 자리 셋과 프로덕션 자바독의 약속 · JdbcOperations 구현 0 · 세 번째 검사를 덮는 시험 0 · 형제 둘의 생성자 주입과 세 검사 — 149줄 · exit 0" zoom="true"
:::evidence key="an-active-transaction-check-that-asked-the-wrong-question" alt="저장소 루트에서 돌린 정적 검색 출력 149줄. IdempotencyCapabilityGuard 의 클래스 자바독이 9번부터 23번 줄까지 원문 그대로 실려 세 질문과 틀린 답과 데이터소스가 둘일 때의 결과가 나온다. 이어서 필드 셋과 생성자가 28번부터 39번 줄까지 실리는데 jdbc 와 activeCapabilitySql 은 requireNonNull 을 지나고 dataSource 만 그대로 대입된다. requirePrimaryWriteTransaction 의 본문이 87번부터 109번 줄까지 나와 세 검사와 각각의 메시지가 보이고, 마지막 검사가 dataSource 가 널이 아닐 때만 hasResource 를 평가한다. 그 값을 정하는 dataSourceOf 가 JdbcTemplate 일 때만 getDataSource 를 돌려주고 아니면 널이라는 것과, 그 절충을 인정하는 자바독이 함께 나온다. 이 저장소의 생성 지점 셋이 소스 세트별로 나오는데 프로덕션은 하나이고 그 자바독이 가드가 데이터소스를 식별하므로 다른 데이터소스의 트랜잭션은 통과할 수 없다고 약속한다. 유닛 시험은 mock 을 넘긴다. 저장소에 JdbcOperations 구현이나 JdbcTemplate 하위 클래스는 0 건이다. 세 번째 검사의 메시지를 찾으면 던지는 줄 하나만 나오고 시험은 없으며, 전제 시험 셋이 무엇을 단언하는지 이름으로 나온다. 마지막으로 가드를 부르는 열일곱 줄과 형제 어댑터 둘이 데이터소스를 생성자로 받아 requireNonNull 하는 것과 같은 세 검사를 거는 형태가 나온다." caption="세 질문과 틀린 답을 적은 자바독 · dataSource 만 널 검사를 지나지 않는 생성자 · 지금의 세 검사와 마지막에 붙은 널 조건 · 그 값을 정하는 dataSourceOf · 조립 자리 셋과 프로덕션 자바독의 약속 · JdbcOperations 구현 0 · 세 번째 검사를 덮는 시험 0 · 형제 둘의 생성자 주입과 세 검사 — 149줄 · exit 0" zoom="true"
:::
자바독 `:12`\~`:16` 은 세 질문을 나열한다. 스키마가 승인되었는가, 트랜잭션이 있는가, 그것이 이 저장소의 트랜잭션인가.
이어서 저장소가 세 번째를 틀리게 답했다고 적는다. 스레드에 활성 읽기 쓰기 트랜잭션이 있는지만 확인했는데 그 조건은 어느 데이터소스에서든 트랜잭션이 열려 있으면 참이고, 형제인 outboxinbox 어댑터는 `hasResource(dataSource)` 를 확인하며 그것이 실제로 중요한 질문이라는 것이다.
기존 코드는 스레드에 활성 read-write transaction이 있는지만 확인했다. 이 조건은 다른 DataSource의 transaction에도 참이 될 수 있다. 형제인 outbox·inbox 어댑터는 `hasResource(dataSource)`까지 검사해 현재 transaction이 같은 DataSource를 사용하고 있는지 확인한다.
`:18`\~`:21` 이 결과를 적는다. 데이터소스가 둘인 애플리케이션에서 다른 쪽의 트랜잭션 안에서 발행된 변경이 옛 검사를 통과했고, 이 저장소의 커넥션에서 트랜잭션 없이 실행됐으며, 원자적이어야 할 작업과 독립적으로 커밋됐다.
@@ -109,11 +109,11 @@ Spring Boot : 4.0.8
넣는 쪽은 `PostgreSqlOwnerSafeIdempotencyStore:91` 이다. 생성자가 `dataSourceOf(jdbc)` 로 값을 만드는데, `:105`\~`:109` 의 그 메서드는 `jdbc``JdbcTemplate` 이면 `template.getDataSource()` 를 돌려주고 아니면 널을 돌려준다.
그 자바독(`:98`\~`:104`)이 절충을 인정한다. `JdbcTemplate` 은 자기 데이터소스를 알지만 손으로 만든 `JdbcOperations` 는 모를 수 있고, 가드는 알 수 없는 데이터소스를 "검사할 수 없음" 으로 다루는데 그것은 outbox 어댑터의 정확한 검사보다 약하고 이전보다는 강하며 협력자를 정말로 식별할 수 없을 때의 정직한 답이라는 것이다.
해당 javadoc(`:98`\~`:104`) `JdbcTemplate`에서는 DataSource를 식별할 수 있지만 임의의 `JdbcOperations` 구현에서는 식별하지 못할 수 있음을 적는다. guard는 DataSource를 알 수 없을 때 이 검사를 생략하므로 outbox 어댑터보다 약한 보장이다.
## 그 널 경로가 실제로 열리는 곳
## DataSource 추론이 null이 될 수 있는 생성 경로
이 저장소를 만드는 자리는 셋이다.
이 저장소의 생성 지점은 셋이다.
프로덕션은 `PostgreSqlIdempotencyProviderConfig:60` 하나이고 `JdbcOperations` 빈을 받는다. 그 메서드의 자바독 `:55`\~`:56` 은 저장소의 트랜잭션 가드가 그것으로부터 자기 데이터소스를 식별하므로 다른 데이터소스에서 연 트랜잭션은 통과할 수 없다고 적는다.
@@ -135,11 +135,11 @@ Spring Boot : 4.0.8
갈리는 것은 데이터소스를 얻는 방법이다. 형제 둘은 생성자가 `DataSource` 를 직접 받고 `:158``:210``Objects.requireNonNull` 로 거른다. 널일 수 없으므로 널 가드가 필요 없다. 가드는 `JdbcOperations` 에서 추론하고, 추론이 실패하면 널이 된다.
## 가드를 부르는 자리
## 가드를 호출하는 메서드
`PostgreSqlOwnerSafeIdempotencyStore` 의 여섯 자리`:122`, `:176`, `:211`, `:255`, `:303`, `:360` 같은 클래스의 private `requirePrimaryWriteTransaction`(`:506`\~`:507`)을 부르고, 그 메서드가 `guard.requirePrimaryWriteTransaction` 으로 넘긴다. `requireActiveCapability` 를 부르는 자리`:123` 하나다.
`PostgreSqlOwnerSafeIdempotencyStore`의 여섯 호출 지점`:122`, `:176`, `:211`, `:255`, `:303`, `:360` 같은 클래스의 private `requirePrimaryWriteTransaction`(`:506`\~`:507`)을 부르고, 그 메서드가 `guard.requirePrimaryWriteTransaction`으로 넘긴다. `requireActiveCapability``:123`에서 한 번 호출한다.
## 원문과 갈리는 자리
## 원문과 다른 검사 순서
원문은 이 수정이 세 번째 질문을 형제와 같은 형태로 바꾼 것이라고 적었다. 바꾼 것이 아니라 더한 것이다. `:93` 의 옛 검사가 그대로 첫 줄에 있다.