--- kind: CASE slug: a11-f004-number title: 재생 가능으로 인증된 본문이 다른 바이트를 낸다 topic: http-client-and-resilience project: clean-architecture-backend-template status: 게시 전 sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 rootTreeNode: case:a11-f004-number evidenceCapturedOn: 2026-09-02 body: case-a11-f004-number.body.md assets: - key: a11-f004-number file: ../../../final/evidence/rendered/a11-f004-number.svg - key: a11-f004-number-bytes file: ../../../final/evidence/rendered/a11-f004-number-bytes.svg evidence: - ../../../final/evidence/raw/a11-f004-number.txt - ../../../final/evidence/raw/a11-f004-number-bytes.txt source: - 원본 분석 절은 final/document.md#a11#L249 이다. 등급은 P3 이다. 첫 분기가 `Number` 를 무조건 통과시킨다는 지적, 원자 패키지 여섯 타입이 그것을 상속하는 가변 타입이라는 열거, 그래서 성분에 원자 정수를 가진 레코드가 재생 가능으로 인증되고 호출자가 값을 올리면 같은 멱등 키로 다른 바이트가 나간다는 판정, 시간 상위 타입에도 같은 구멍이 있다는 관찰, 자바독 선언과 코드 범위가 어긋난다는 이유가 그 절에 있다. 수정 방향 둘 — 상자 원시형 여덟 종과 큰 정수·큰 십진수를 명시하거나 원자 패키지를 제외하는 것 — 도 그 절이 제시한 것이다. - 이 기록이 더한 것은 셋이다. 여섯 타입의 판정과, 요청 경로가 실제로 쓰는 변환기로 인코딩한 결과를 받아, 판정이 재생 가능인 채 바이트가 달라지는 것을 그대로 보였다. 선언에 없는 분기가 셋이고 그중 실제 구멍은 하나라는 것, 그리고 상자 원시형과 원자 계열 사이에 남는 두 타입이 자기를 바꾸지 않는다는 것을 확인했다. 그리고 그 test 넷의 입력을 전수 확인해 숫자는 상자 원시형만 들어간다는 것을 보였다 — 원본은 test 넷의 이름과 일반적 가변 객체를 잡는다는 한계까지만 적는다. --- # 재생 가능으로 인증된 본문이 다른 바이트를 낸다 깊은 불변성 검사의 첫 분기가 숫자 상위 타입을 무조건 통과시킨다. 원자 계열 여섯 타입이 그 상위 타입을 상속하는 가변 타입이다. 성분에 그런 카운터를 둔 요청을 요청 경로의 실제 변환기로 두 번 인코딩해, 인증은 유지되고 결과만 갈리는 것을 받았다. ## 관계 - **재시도 안전성은 증거에 기반해 판정한다** 이 검사가 존재하는 이유다. - **지워도 test가 초록인 검사가 셋이다** 저쪽은 실행되지 않는 분기이고, 이쪽은 실행되지만 선언보다 넓은 분기다. - **타입이 문서화한 불변식은 타입이 강제한다** 자바독이 선언한 범위를 코드가 지키지 않는다. ## 문제 요청 본문이 재시도에도 같은 바이트를 내는지 판정하는 검사가 있다. 그 검사의 자바독이 범위를 선언한다. 레코드와 열거형과 문자열과 상자 원시형과 불변 컬렉션 뷰는 재생 가능하고, 그 밖의 모든 것은 일회성으로 취급한다는 것이다. 값을 확실히 하려는 호출자에게는 바이트 배열 본문으로 굳히는 길을 안내한다. ## 결론 첫 분기가 그 선언보다 넓다. 선언에 없는 것이 셋 들어 있다. 숫자 상위 타입과 UUID 와 시간 상위 타입이다. UUID 는 불변이고 시간 쪽은 표준 구현체가 전부 불변이라, 남는 것은 숫자 상위 타입 한 줄이다. 상자 원시형과 원자 계열 사이에 남는 표준 타입은 큰 정수와 큰 십진수 둘뿐이고, 둘 다 자기 값을 바꾸지 않는다. 더하기를 불러도 원래 객체가 그대로인 것을 확인했다. 그래서 표준 라이브러리 안의 실제 구멍은 원자 계열 여섯이다. 원자 정수와 원자 긴 정수와 긴 덧셈기와 실수 덧셈기와 긴 누산기와 실수 누산기다. 여섯 전부 재생 가능으로 인증되는 것을 실행으로 확인했다. 성분에 원자 정수를 둔 레코드도 마찬가지다. 요청 경로가 실제로 쓰는 변환기로 그 본문을 두 번 인코딩하면 첫 번째는 attempt 가 1 이고 두 번째는 2 인데, 사이에서 판정은 재생 가능 그대로다. 재시도가 같은 멱등 키로 다른 바이트를 보낸다는 뜻이다. 그것이 이 검사가 존재하는 이유로 인용된 결과다. 검사를 고정하는 test 클래스의 자바독이 바로 그 문장을 적어 뒀다. test 넷이 보는 갈래는 따로 있다. 불변 레코드, 호출자가 쥔 컬렉션, 그것을 감싼 레코드, 들여다볼 수 없는 빈이다. 숫자는 상자 원시형만 들어간다. 같은 형태의 좁은 구멍이 시간 상위 타입에도 있다. 사용자 정의 구현은 불변이 아닐 수 있다. 다만 숫자 쪽이 훨씬 현실적이다. 요청 객체에 카운터를 두는 것은 드물지 않다. 판정은 P3 다. 도달하려면 원자 카운터를 요청 객체에 넣어야 하고, 검사 전체의 방향은 보수적이다. 호출자가 쥔 컬렉션은 그것을 감싼 레코드까지 일회성으로 떨어진다. 자바독은 이 검사의 계약이고, 계약과 구현이 어긋난 쪽은 구현이다. 수정은 둘 중 하나다. 상자 원시형 여덟 종과 큰 정수와 큰 십진수를 명시하거나, 원자 패키지를 제외하는 것이다. ## 검증 환경 OpenJDK : 21.0.12 확인 방식 : 검사 분기와 타입 계층 대조, 실행 탐침 소스 수정 : x ## 재현 조건 1. 깊은 불변성 검사의 자바독 선언을 읽는다. 2. 첫 분기의 허용 타입 목록을 확인하고 선언에 없는 것을 고른다. 3. 검사를 고정하는 test 넷이 무엇을 보는지 확인한다. 4. 기본 설정의 REST 클라이언트가 JSON 본문을 어느 변환기로 쓰는지 확인한다. 5. 큰 정수와 큰 십진수가 자기 값을 바꾸는지, 원자 정수는 바꾸는지 대조한다. 6. 원자 계열 여섯 타입을 각각 본문 값으로 넣어 판정을 받는다. 7. 성분에 원자 정수를 둔 레코드의 판정을 받고, 그 변환기로 인코딩한 뒤 카운터를 올려 다시 인코딩해 바이트를 비교한다. 8. 호출자가 쥔 컬렉션과 그것을 감싼 레코드의 판정을 대조한다. ## 본문 이 모듈은 본문을 인코딩하기 전에, 그 본문이 재시도에도 같은 바이트를 낼지 먼저 판정한다. ## 선언과 분기 :::evidence key="a11-f004-number" alt="본문 재생 가능성 검사의 자바독이 선언하는 범위, 깊은 불변성 판정의 첫 분기 전체, 그 검사를 고정하는 test 클래스의 javadoc, 그리고 그 test 넷의 이름과 표시 이름을 출력한 터미널 기록." caption="자바독은 레코드·열거형·문자열·상자 원시형·불변 컬렉션 뷰만 재생한다고 적고, 확실한 호출자에게는 값을 바이트 배열로 굳히라고 덧붙인다 · 첫 분기는 Number 를 무조건 통과시킨다 · test 클래스 javadoc 이 다른 바이트에 같은 멱등 키를 이 검사의 존재 이유로 적는다 — 41줄 · exit 0" zoom="true" ::: ```text *
The check is structural and conservative: records, enums, strings, boxed primitives and * immutable collection views replay; anything else is treated as one-shot, so the retry engine * refuses rather than gambling. A caller who knows better can freeze the value itself — serialize * it to a {@code byte[]} body — which states the guarantee instead of asserting it. ``` 확실한 호출자에게는 값을 바이트 배열 본문으로 굳히라고 적어 뒀다. 아래는 그 권고를 따르지 않은 값에 대한 이야기다. 판정의 첫 분기는 이렇다. ```java if (candidate instanceof String || candidate instanceof Number || candidate instanceof Boolean || candidate instanceof Character || candidate instanceof Enum> || candidate instanceof java.util.UUID || candidate instanceof java.time.temporal.Temporal) { return true; } ``` 선언에 없는 분기가 셋이다. `Number` 와 `UUID` 와 `Temporal`. `UUID` 는 불변이고 `Temporal` 은 표준 구현체가 전부 불변이라, 남는 것은 `Number` 한 줄이다. 상자 원시형만이 아니라 `Number` 를 상속하는 모든 타입이 들어온다. ## 넣어 보면 :::evidence key="a11-f004-number-bytes" alt="기본 설정의 REST 클라이언트가 JSON 본문을 쓸 때 고르는 변환기, 원자 계열 여섯 타입을 각각 본문 값으로 넣어 받은 판정, 큰 정수와 큰 십진수가 더하기 뒤에도 자기 값을 유지하는 것과 원자 정수가 바뀌는 것의 대조, 성분에 카운터를 둔 레코드의 판정과 그 변환기로 두 번 인코딩한 결과와 바이트 동일성 비교, 그리고 호출자가 쥔 컬렉션과 그것을 감싼 레코드의 판정을 출력한 터미널 기록." caption="요청 경로의 JSON 변환기는 Spring 의 JacksonJsonHttpMessageConverter · 원자 계열 여섯 타입이 전부 REPLAYABLE · 큰 정수와 큰 십진수는 더하기 뒤에도 자기 값 그대로 · 카운터를 둔 레코드는 판정이 그대로인 채 인코딩이 attempt 1 에서 2 로 바뀐다 — 35줄 · exit 0" zoom="true" ::: ```text [java.util.concurrent.atomic 의 Number 하위 타입] AtomicInteger Number 상속 true 판정 REPLAYABLE AtomicLong Number 상속 true 판정 REPLAYABLE LongAdder Number 상속 true 판정 REPLAYABLE DoubleAdder Number 상속 true 판정 REPLAYABLE LongAccumulator Number 상속 true 판정 REPLAYABLE DoubleAccumulator Number 상속 true 판정 REPLAYABLE ``` 여섯 다 값을 바꿀 수 있는 타입이고, 여섯 다 통과한다. `Number` 를 상속했다는 것 말고 공통점이 없다. 상자 원시형과 이 여섯 사이에 남는 표준 타입은 둘뿐이다. ```text [상자 원시형과 원자 계열 사이에 남는 둘] BigInteger add 뒤 자기 값 7 판정 REPLAYABLE BigDecimal add 뒤 자기 값 7.5 판정 REPLAYABLE AtomicInteger increment 뒤 자기 값 8 판정 REPLAYABLE ``` 둘은 더하기를 불러도 자기 값이 그대로다. 원자 정수는 7 이 8 이 된다. 같은 판정을 받는 세 타입 중 마지막 하나만 자기를 바꾼다. ## 판정은 그대로, 바이트는 다르다 요청 경로가 JSON 본문을 쓸 때 고르는 변환기부터 확인했다. ```text [요청 본문을 인코딩하는 변환기] RestClient 기본 목록 org.springframework.http.converter.json.JacksonJsonHttpMessageConverter ``` 그 변환기로 두 번 인코딩한 결과다. ```text [성분에 카운터를 둔 record] 판정 : REPLAYABLE 첫 인코딩 : {"id":"A-1","attempt":1} 다시 인코딩 : {"id":"A-1","attempt":2} 판정(그대로) : REPLAYABLE 같은 바이트인가 : false ``` 사이에 한 것은 카운터를 올린 것뿐이다. 재시도 엔진이 읽는 값은 두 번 다 재생 가능이고, 같은 값을 다시 인코딩한 결과는 다르다. ## 이 검사가 존재하는 이유 ```text *
Every {@code ObjectBody} used to report {@code REPLAYABLE}. A caller who reused a builder or * kept a reference to a list therefore got a retry that re-encoded the value as it was at retry * time — different bytes, same idempotency key, which is precisely what a replay must never * be. ``` 이 문장은 test 클래스 javadoc 에 있다. 그 test 넷은 다른 갈래를 본다. ```text 28: @DisplayName("a record of immutable components replays") 29: void anImmutableRecordReplays() { 41: @DisplayName("a value the caller can still mutate does not replay") 42: void aMutableValueIsOneShot() { 51: @DisplayName("a record wrapping a mutable component does not replay") 52: void aRecordWrappingMutableStateIsOneShot() { 66: @DisplayName("an uninspectable value is one-shot") 67: void anArbitraryBeanIsOneShot() { ``` 입력에는 컬렉션과 맵과 빈이 들어 있고, 숫자는 상자 원시형뿐이다. `Map.of("k", 1)` 의 1 과 `ImmutableOrder` 의 int 가 그것이다. `Number` 분기 자체는 밟히지만 가변 숫자 타입은 한 번도 들어가지 않는다. ## 검사가 잡는 쪽 ```text [검사가 잡는 쪽] ArrayList 자체 판정 ONE_SHOT ArrayList 를 감싼 record 판정 ONE_SHOT List.of 를 감싼 record 판정 REPLAYABLE ``` 컬렉션 쪽은 이름으로 불변 뷰인지 확인하고 감싼 레코드까지 따라간다. 원자 카운터를 요청 객체에 넣지 않는 한 이 구멍에 닿지 않는다. ## 확인하지 못한 것 재시도 엔진을 실제로 돌려 두 번째 시도가 그 바이트를 보내는지 관측하지 않았다. 확인한 것은 판정과 인코딩까지다. 사용자 정의 시간 구현으로 같은 구멍을 재현하지 않았다.