- analysis/·source-index·state.json 을 final/document.md 제2부·제3부로 접었다. SSOT 는 하나다 - 파일럿 — commit-ambiguity-as-a-result 를 새 기준으로 재선별. 후보 14 → 글감 5 (PROMOTE 5 · MERGE_INTO 3 · KEEP_IN_SSOT 4 · 보류 2). 기록 5건을 다시 썼고 그림 1개를 techviz 로 만들었다 - 재선별이 잡은 것: 제1부 §6.2·§11.1 이 자기 §13.2 와 어긋나 있었다(레인을 안 돌렸다 vs 돌렸다) — 정정. 이미 답이 나와 있던 Question 을 HEAD 재실행 질문으로 다시 세웠다. Concept 이 인용한 코드가 SSOT 에 없어 뺐다 - candidateScope·sourceRepository 기록. 나머지 43개 주제는 재선별 대기(PENDING 905) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
14 KiB
kind, slug, title, topic, project, status, sourceRevision, rootTreeNode, evidenceCapturedOn, body, assets, evidence, source
| kind | slug | title | topic | project | status | sourceRevision | rootTreeNode | evidenceCapturedOn | body | assets | evidence | source | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| CASE | a20-f010-grpcoutcomereplay | GrpcOutcomeReplay 의 맵에 put·get·size 뿐이고 부르는 코드도 없다 | grpc-and-streaming | clean-architecture-backend-template | 게시 전 | 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 | case:a20-f010-grpcoutcomereplay | 2026-09-04 | case-a20-f010-grpcoutcomereplay.body.md |
|
|
|
GrpcOutcomeReplay 의 맵에 put·get·size 뿐이고 부르는 코드도 없다
maxInlineBytes 는 응답 하나의 바이트 길이만 제한한다. storedOutcomes 에 가해지는 연산이 put·get·size 셋뿐이라 수를 줄이는 경로가 없다. 다만 store 를 부르는 프로덕션 코드가 없다. 자바독은 이 구현을 배포가 골라 쓰는 여러 방식 가운데 하나로 소개한다.
관계
- GrpcStreamAdmission 의 호출자 맵이 줄지 않고 main 소비자는 0 이다
같은
grpc-policy리프에 지우는 경로가 없는 맵이 하나 더 있다.GrpcStreamAdmission은 거절된 승인도computeIfAbsent를 먼저 지나 항목을 남기고,GrpcOutcomeReplay는 넣는 코드 자체가 없다. - 체크포인트 전진이 ConcurrentMap 위의 확인 후 쓰기다
그 기록은
GrpcClientMessageDeduplicator의 체크포인트 맵을 다루는데 크기가 아니라 갱신의 원자성을 본다. 크기 쪽에서는 같은 클래스가endSession에 정리 메서드를 두고 있어 이 저장소와 갈린다. - 조립 결함을 판정하려면 조립하는 쪽을 먼저 읽어야 한다
자료구조만으로는 유계인지까지만 말할 수 있고, 이 저장소가 실제로 자라는지는
store를 부르는 코드를 찾은 뒤에야 판정된다.
문제
멱등 연산의 응답을 담는 저장소가 ConcurrentMap 하나로 되어 있다.
그 맵의 크기를 무엇이 제한하고, 무엇이 그것을 채우는지 확인했다.
결론
제한하는 값은 항목 하나의 크기뿐이다. :41 이 maxInlineBytes 를 넘는 응답을 IllegalArgumentException 으로 돌려보내면서 객체 참조 뒤에 두라고 안내한다. 통과한 것은 clone() 사본으로 들어간다(:49).
수를 줄이는 코드는 없다. storedOutcomes 가 나오는 네 줄이 선언과 put(:49)과 get(:54)과 size(:60)이고, 클래스가 final 이며 필드가 private final 이고 맵을 돌려주는 접근자가 없어 밖에서도 줄일 수 없다. 줄이는 연산 이름 열여덟도 0 건이다.
프로브로 확인했다. 서로 다른 키 천 개를 넣으면 size 가 1001 이고, 그 천 개를 모두 읽어도 같은 키로 다시 써도 1001 이다.
그 값을 보고 판단하는 프로덕션 코드도 없다. size() 를 읽는 자리는 GrpcIdempotencyInterceptorTest:166 의 단언 하나다.
넣는 코드도 없다. 이 이름이 나오는 파일은 자기 파일과 그 시험과 구현 계획 문서 하나이고, 자기 파일 밖 main 참조가 0 이다. 같은 패키지의 GrpcIdempotencyInterceptor:99 조차 참조만 돌려주고 이 저장소를 부르지 않는다.
대조 구현에 같은 잣대를 대면 GrpcClientMessageDeduplicator 도 main 참조가 0 이고 endSession 을 부르는 코드도 없다. 두 클래스의 차이는 도는지 여부가 아니라 자바독과 API 설계에 있다.
두 클래스가 속한 family 도 다르다. src/grpc-advanced/CLAUDE.md:15 와 :74 가 grpc:* 에서 grpc-advanced:* 를 참조하는 것을 금지하고 registry 가 거부한다고 적는다.
원본 분석의 등급은 P2 인데 그것을 떠받치던 인과가 이 리비전에서 성립하지 않는다. 이 기록은 새로 매기지 않고 양쪽 근거를 함께 남긴다.
검증 환경
OpenJDK : 21.0.12 확인 방식 : 클래스 62 줄 전수 확인, storedOutcomes 가 나오는 줄 전수와 클래스·필드 한정자 확인, 줄이는 연산 이름 열여덟 검색과 대조 파일 검색, 크기 상한의 거부와 저장·조회·덮어쓰기 뒤의 크기를 프로브로 관측, 이 이름이 나오는 파일 전수와 소스 세트 분리, 같은 패키지 인터셉터의 재생 판정 확인, 대조 클래스의 자바독과 정리 메서드와 그 클래스의 main 참조 계수, modules.json 의 family 키 검색과 두 권위 문서의 family 선언 및 참조 금지 확인 소스 수정 : x
재현 조건
- 클래스를 처음부터 끝까지 읽고 필드와 공개 메서드를 적는다.
- 맵 필드 이름이 나오는 줄을 전부 찾아 어떤 연산이 가해지는지 센다.
- 클래스와 필드의 한정자를 읽고 맵을 돌려주는 접근자가 있는지 본다.
- 줄이는 연산 이름을 여러 가지로 검색하고, 같은 검색을 다른 파일에 걸어 검색이 도는지 확인한다.
- 크기 상한을 넘는 응답을 넣어 거부 메시지를 받고, 그 뒤 크기가 그대로인지 본다.
- 서로 다른 키로 여럿 넣은 뒤 읽기와 덮어쓰기가 크기를 줄이는지 확인한다.
- 이 이름이 나오는 파일을 경로 제한 없이 찾고 소스 세트로 나눈다.
- 같은 패키지에서 이 저장소를 쓸 법한 클래스를 열어 실제로 부르는지 본다.
- 대조 구현을 찾아 자바독과 정리 메서드를 읽고, 같은 방식으로 그 클래스의 참조도 센다.
- 두 클래스가 같은 family 인지 registry 와 각 디렉터리의 권위 문서에서 확인한다.
본문
GrpcOutcomeReplay 는 62 줄짜리 클래스이고 내부는 ConcurrentMap<String, byte[]> storedOutcomes 하나(:18)다. 클래스 자바독 :10~:14 는 원장과 이 저장소를 나눈 이유를 적는다. 원장 행은 작아야 하고 업무 트랜잭션 안에서 쓰이는데 응답은 클 수 있고 누가 실제로 재시도할 때만 필요하다는 것이다. 그래서 원장은 참조만 저장하고, 그 참조를 배포가 고른 대상 — 작은 인라인 저장소, 객체 저장소, 커밋된 자원의 재조회 — 에 대해 이 클래스가 해석한다. 여기 있는 구현이 그중 인라인 저장소 쪽이다.
storedOutcomes 에 가해지는 연산 넷
:::evidence key="a20-f010-grpcoutcomereplay" alt="저장소 루트에서 돌린 정적 검색과 /tmp/probe5 에서 컴파일해 돌린 프로브를 합친 출력 166줄. GrpcOutcomeReplay.java 62 줄이 통째로 실린다. 이어서 storedOutcomes 라는 이름이 나오는 네 줄이 선언과 put 과 get 과 size 로 나오고, 클래스가 final 이며 필드가 private final 이라는 두 줄이 붙는다. 줄이는 연산 이름 열여덟 가지를 통틀어 0 건이고, 같은 검색을 대조 파일에 걸면 0 이 아니다. 프로브가 16 바이트를 넣고 17 바이트를 거부당한 메시지를 그대로 내며, 서로 다른 키 천 개를 넣은 뒤 size 가 1001 이고 모두 replay 해도 같은 키로 덮어써도 그대로임을 보인다. 이 이름이 나오는 파일이 셋으로 나열되고 자기 파일 밖 main 참조가 0 이며 추적되지 않는 변경도 없고 java 밖 파일이 하나다. 같은 패키지의 인터셉터가 재생을 판정하는 열여섯 줄이 실리는데 그 파일은 이 타입을 0 번 부른다. 대조 클래스는 grpc-advanced-streaming 리프에 있고 자바독 두 문단과 endSession 세 줄이 나오며 그 클래스의 자기 파일 밖 main 참조도 0 이다. 마지막으로 modules.json 에 family 키가 0 건이라는 것과 두 CLAUDE.md 가 각각 다른 family 의 권위 문서임을 밝히는 대목, 그리고 Stable 이 advanced 를 참조하는 것이 registry 에서 거부된다는 두 줄이 나온다." caption="클래스 62줄 전체 · 맵에 가해지는 연산 넷과 final·private final · 줄이는 이름 열여덟 0 과 대조 검색 · 거부 메시지와 단조 증가하는 size · 이름이 나오는 파일 셋과 main 참조 0 · 같은 패키지 인터셉터의 재생 판정 · 대조 클래스도 main 참조 0 · 두 family 의 경계와 참조 금지 — 166줄 · exit 0" zoom="true" :::
storedOutcomes 라는 이름은 네 줄에 나온다. 선언(:18), store 안의 put(:49), replay 안의 get(:54), size() 의 return(:60)이다. 이 맵에 가해지는 연산은 그 셋이 전부다.
밖에서 줄일 수도 없다. 클래스가 final(:16)이고 필드가 private final 이며 맵을 돌려주는 접근자가 없다.
이름으로도 확인했다. remove·clear·removeIf·computeIfPresent·merge·entrySet·keySet·evict·expire·TTL·Duration·Instant·maximumSize·Caffeine·LinkedHashMap·WeakReference·SoftReference·Cleaner 열여덟을 통틀어 0 건이고, 같은 검색을 대조 파일에 걸면 0 이 아니다.
maxInlineBytes(:19)는 store 안에서 serializedResponse.length 와 비교되고(:41), 그보다 큰 응답은 IllegalArgumentException 으로 거부되며 메시지가 객체 참조 뒤에 두라고 적는다. 통과한 응답은 serializedResponse.clone()(:49)으로 복사돼 들어간다.
넣으면 줄지 않는다
프로브로 확인했다. 16 바이트를 넣으면 size 가 1 이고, 17 바이트는 거부되며 거부 뒤에도 size 는 그대로다.
서로 다른 키 천 개를 넣으면 1001 이 된다. 그 천 개를 모두 replay 해도 1001 이고, 같은 키로 천 번 덮어써도 1001 이다. 읽기도 덮어쓰기도 수를 줄이지 않고, 늘어나는 축은 서로 다른 참조 문자열의 개수다.
size() 의 반환값을 읽는 프로덕션 코드는 없다. 읽는 자리는 GrpcIdempotencyInterceptorTest:166 의 assertThat(replay.size()).isEqualTo(1) 하나다.
store 를 부르는 main 코드가 없다
이 이름이 나오는 파일은 셋이다. 자기 파일, GrpcIdempotencyInterceptorTest, 그리고 구현 계획 문서 하나(두 줄)다. 자기 파일 밖 main 참조는 0 이고, 추적되지 않는 변경도 0 이다.
같은 패키지에 인터셉터가 있는데 그것도 이 타입을 부르지 않는다. GrpcIdempotencyInterceptor:98~:107 은 원장 기록이 COMMITTED 이면 GrpcIdempotencyDecision.replay(record.outcomeReference()) 로 참조만 돌려준다. 참조를 만들고 소비하는 쪽은 main 에 있고, 그 참조를 이 저장소에 넣는 코드만 없다.
자바독이 적은 세 선택지 가운데 어느 것을 고를지는 배포의 몫이므로, 이 리비전에서 인라인 저장소가 배선되지 않은 것 자체는 설계와 어긋나지 않는다.
대조 구현에 같은 잣대를 대면
GrpcClientMessageDeduplicator 의 자바독 :11~:14 는 본 키를 집합으로 들면 세션 수명 동안 경계 없이 자라고, 그것이 답하는 "본 적이 있는가" 는 필요한 질문이 아니며, 필요한 질문인 "적용됐는가" 는 단조 증가하는 적용 순번이 상수 공간으로 답하고 집합과 달리 프로세스 재시작도 견딘다고 적는다. endSession:110~:112 가 체크포인트를 지우고 그 세션 접두사를 가진 재생 가능 결과를 지운다.
그런데 그 클래스도 자기 파일 밖 main 참조가 0 이고, endSession 을 부르는 main 코드도 없다. 이 기록이 이 저장소에 댄 잣대 — 부르는 코드가 없으면 오늘 일어나지 않는다 — 를 그대로 대면 두 클래스는 같은 상태다.
그러므로 이것은 도는 코드 둘의 대비가 아니라 자바독과 API 설계의 대비다. 한쪽은 무한 증가를 설계 문제로 이름 붙이고 정리 메서드를 두었고, 한쪽은 그런 자바독도 그런 메서드도 없다.
두 클래스는 다른 family 다
modules.json 에 family 키는 0 건이다. family 를 정하는 것은 각 디렉터리의 권위 문서다.
src/grpc/CLAUDE.md:1·:3 이 grpc:* family 의 local authority 라고 적고, src/grpc-advanced/CLAUDE.md:3 이 grpc-advanced:* family 의 local authority 라고 적는다. :7~:9 는 후자가 Stable 플랫폼이 의도적으로 제외한 능력을 담으며 디렉터리와 Gradle 접두사를 나눈 이유가 "Stable starter 가 advanced module 을 참조하면 build 가 실패한다" 는 불변 조건을 기계로 검증하기 위해서라고 적는다.
:15 와 :74 가 grpc:* 에서 grpc-advanced:* 로 가는 참조를 금지로 못박는다. registry 가 거부한다는 것이다.
그러므로 대조 구현을 이쪽으로 옮겨 오는 것은 지금 구조에서 허용되지 않는다.
원문과 갈리는 자리
원문은 store() 가 멱등 키를 요구하는 메서드가 커밋될 때마다 호출되므로 프로세스 수명 동안 커밋한 멱등 연산 수만큼 항목이 쌓인다고 적었다. main 에는 store 를 부르는 줄이 없고 같은 패키지의 인터셉터도 참조 반환에서 멈춘다.
원문은 비교 대상이 "같은 리프 안에" 있다고 적었다. GrpcClientMessageDeduplicator 는 grpc-advanced-streaming 이고 이 클래스는 grpc-policy 이며, 두 디렉터리는 서로 다른 family 이고 이쪽에서 저쪽을 참조하는 것이 금지돼 있다.
크기 상한이 항목 하나만 제한한다는 것과 줄이는 경로가 없다는 것은 원문대로다.
등급에 대해
원본 분석의 등급은 P2 다. 그 등급을 떠받치던 인과 — 커밋마다 store 가 불려 쌓인다 — 는 이 리비전에서 성립하지 않는다. 부르는 코드가 없고, 같은 가족의 자매 클래스도 같은 상태이며, 자바독은 이 구현을 배포가 고를 수 있는 세 선택지 중 하나로 적는다.
이 기록은 등급을 새로 매기지 않는다. 상류가 P2 로 둔 근거와 여기서 확인한 반대 근거를 함께 남기고, 재감정은 상류의 몫으로 둔다.
확인하지 못한 것
프로세스를 오래 띄워 증가를 계측하지 않았다. 줄이는 코드의 부재와, 넣고 읽는 동안 수가 유지되는 것을 확인했다.
참조 계수는 이름 일치와 추적된 파일만 본다. 문자열로 조립하는 사용은 잡히지 않는다.
이 저장소를 인터셉터에 물린 채택자 코드는 보지 못했다.