Files
document-haness/docs/clean-architecture-backend-template/tech-log-studio/grpc-and-streaming/case/case-a20-f010-grpcoutcomereplay.md
T
DongHyeonkaandClaude Fable 5.1 b25357c48a docs(clean-architecture-backend-template): fold analysis into final and re-select one topic
- 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>
2026-09-07 12:39:20 +09:00

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
key file
a20-f010-grpcoutcomereplay ../../../final/evidence/rendered/a20-f010-grpcoutcomereplay.svg
../../../final/evidence/raw/a20-f010-grpcoutcomereplay.txt
원본 분석 절은 final/document.md#a20#L664 이다.

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

재현 조건

  1. 클래스를 처음부터 끝까지 읽고 필드와 공개 메서드를 적는다.
  2. 맵 필드 이름이 나오는 줄을 전부 찾아 어떤 연산이 가해지는지 센다.
  3. 클래스와 필드의 한정자를 읽고 맵을 돌려주는 접근자가 있는지 본다.
  4. 줄이는 연산 이름을 여러 가지로 검색하고, 같은 검색을 다른 파일에 걸어 검색이 도는지 확인한다.
  5. 크기 상한을 넘는 응답을 넣어 거부 메시지를 받고, 그 뒤 크기가 그대로인지 본다.
  6. 서로 다른 키로 여럿 넣은 뒤 읽기와 덮어쓰기가 크기를 줄이는지 확인한다.
  7. 이 이름이 나오는 파일을 경로 제한 없이 찾고 소스 세트로 나눈다.
  8. 같은 패키지에서 이 저장소를 쓸 법한 클래스를 열어 실제로 부르는지 본다.
  9. 대조 구현을 찾아 자바독과 정리 메서드를 읽고, 같은 방식으로 그 클래스의 참조도 센다.
  10. 두 클래스가 같은 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:166assertThat(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.jsonfamily 키는 0 건이다. family 를 정하는 것은 각 디렉터리의 권위 문서다.

src/grpc/CLAUDE.md:1·:3grpc:* family 의 local authority 라고 적고, src/grpc-advanced/CLAUDE.md:3grpc-advanced:* family 의 local authority 라고 적는다. :7~:9 는 후자가 Stable 플랫폼이 의도적으로 제외한 능력을 담으며 디렉터리와 Gradle 접두사를 나눈 이유가 "Stable starter 가 advanced module 을 참조하면 build 가 실패한다" 는 불변 조건을 기계로 검증하기 위해서라고 적는다.

:15:74grpc:* 에서 grpc-advanced:* 로 가는 참조를 금지로 못박는다. registry 가 거부한다는 것이다.

그러므로 대조 구현을 이쪽으로 옮겨 오는 것은 지금 구조에서 허용되지 않는다.

원문과 갈리는 자리

원문은 store() 가 멱등 키를 요구하는 메서드가 커밋될 때마다 호출되므로 프로세스 수명 동안 커밋한 멱등 연산 수만큼 항목이 쌓인다고 적었다. main 에는 store 를 부르는 줄이 없고 같은 패키지의 인터셉터도 참조 반환에서 멈춘다.

원문은 비교 대상이 "같은 리프 안에" 있다고 적었다. GrpcClientMessageDeduplicatorgrpc-advanced-streaming 이고 이 클래스는 grpc-policy 이며, 두 디렉터리는 서로 다른 family 이고 이쪽에서 저쪽을 참조하는 것이 금지돼 있다.

크기 상한이 항목 하나만 제한한다는 것과 줄이는 경로가 없다는 것은 원문대로다.

등급에 대해

원본 분석의 등급은 P2 다. 그 등급을 떠받치던 인과 — 커밋마다 store 가 불려 쌓인다 — 는 이 리비전에서 성립하지 않는다. 부르는 코드가 없고, 같은 가족의 자매 클래스도 같은 상태이며, 자바독은 이 구현을 배포가 고를 수 있는 세 선택지 중 하나로 적는다.

이 기록은 등급을 새로 매기지 않는다. 상류가 P2 로 둔 근거와 여기서 확인한 반대 근거를 함께 남기고, 재감정은 상류의 몫으로 둔다.

확인하지 못한 것

프로세스를 오래 띄워 증가를 계측하지 않았다. 줄이는 코드의 부재와, 넣고 읽는 동안 수가 유지되는 것을 확인했다.

참조 계수는 이름 일치와 추적된 파일만 본다. 문자열로 조립하는 사용은 잡히지 않는다.

이 저장소를 인터셉터에 물린 채택자 코드는 보지 못했다.