Files
document-haness/docs/clean-architecture-backend-template/tech-log-studio/web-inbound-and-http-surface/case/case-a14-f011-maxarrayelements.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

207 lines
18 KiB
Markdown

---
kind: CASE
slug: a14-f011-maxarrayelements
title: 배열 원소 상한은 선언만 되고, 파서가 그것을 대신할 수단은 이 팩토리에서만 비어 있다
topic: web-inbound-and-http-surface
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
evidenceCapturedOn: 2026-09-03
rootTreeNode: case:a14-f011-maxarrayelements
body: case-a14-f011-maxarrayelements.body.md
assets:
- key: a14-f011-maxarrayelements
file: ../../../final/evidence/rendered/a14-f011-maxarrayelements.svg
- key: a14-f011-maxarrayelements-converter
file: ../../../final/evidence/rendered/a14-f011-maxarrayelements-converter.svg
- key: a14-f011-maxarrayelements-parser
file: ../../../final/evidence/rendered/a14-f011-maxarrayelements-parser.svg
evidence:
- ../../../final/evidence/raw/a14-f011-maxarrayelements.txt
- ../../../final/evidence/raw/a14-f011-maxarrayelements-converter.txt
- ../../../final/evidence/raw/a14-f011-maxarrayelements-parser.txt
source:
- 원본 분석 절은 final/document.md#a14#L1006 이다.
---
# 배열 원소 상한은 선언만 되고, 파서가 그것을 대신할 수단은 이 팩토리에서만 비어 있다
JSON 프로파일이 한 배열에 받아들일 원소 수를 100,000 으로 선언한다. 그 값을 읽는 코드가 없다. 파서는 이 실패를 막을 상한을 두 가지 제공하고 이 저장소도 그중 하나를 여섯 자리에서 쓰는데, 요청 본문을 읽는 팩토리에서만 둘 다 비어 있다.
## 관계
- **저장 금지와 조건부 읽기가 같은 응답에 실리고, 둘을 이으라고 만든 이름은 아무도 읽지 않는다**
같은 리프의 다른 P2 다.
- **타입이 문서화한 불변식은 타입이 강제한다**
이 프로파일이 어기고 있는 규칙이다.
- **문서가 범위를 제한하면 그 제한을 누가 강제하는지 같이 적는다**
이 프로파일도 javadoc 으로 한도를 적어 두고 그것을 강제하는 자리를 두지 않았다.
- **"상한을 두고 읽는다"는 본문 핸들러가 전부 읽은 뒤에 자른다**
두 사례 모두 상한이 선언돼 있지만 힙에 올라가는 것을 막지는 못한다.
## 문제
JSON 프로파일이 중첩 깊이와 배열 원소 수와 문자열 길이 셋을 선언한다. 셋 다 실제로 걸리는지, 걸리지 않는다면 걸 수단이 있는지 확인했다.
## 결론
배열 원소 상한만 걸리지 않는다.
WebJsonProfile.maxArrayElements() 를 부르는 코드가 저장소 전체에 없다. 유계 팩토리는 깊이와 문자열 길이만 파서에 넘긴다.
먼저 이 팩토리가 실제 요청 경로에 있는지 확인했다. 매퍼 빈은 타입으로 주입될 뿐 이름으로 지목되는 자리가 없어서, 컨텍스트를 띄워 요청 본문을 읽는 컨버터가 무엇을 들고 있는지 직접 물었다. 그 컨버터가 든 매퍼의 제약이 유계 팩토리가 세우는 값과 일치했고, 이 저장소 자동설정만 뺀 대조군에서는 Jackson 기본값이 왔다. 이 팩토리는 요청 경로에 있다.
같은 이름의 필드가 요청 예산 타입에도 있다. 다만 값이 다르다. 프로덕션이 등록하는 예산의 배열 원소 수는 1,000 이다. 100,000 은 그 타입에서 플랫폼 절대 상한 상수로만 쓰이고, 그것을 읽는 자리는 두 곳이다. 깊이도 어긋난다. 예산은 32 를 담고 프로파일은 64 를 담는다.
예산에 담긴 값도 요청을 판정하는 데는 쓰이지 않는다. 생성자가 그 값을 검사하고 예산끼리 비교하는 술어도 이 필드를 보지만, 그 술어에 이르는 재정의 등록을 프로덕션에서 부르는 코드가 없다. 검증은 생성 시점에 한 번 돌고, 비교는 시험에서만 돈다.
예산을 강제하는 두 필터도 이 필드를 읽지 않는다. 둘이 재는 축의 수는 같다. 반응형 쪽은 흐르는 본문과 응답을 요청과 응답을 감싼 교환에서 잰다. 두 필터를 등록하는 자리는 각각 시험용 픽스처 하나씩이다.
여기서 원문과 갈린다.
원문은 파서에 해당 제약이 없으므로 파서 수준에서는 걸 수 없다고 적었다. 배열 원소 수만 놓고 보면 맞다. 그러나 Jackson 3 의 읽기 제약은 여섯 축을 받고, 그중 문서 길이와 토큰 수가 이 실패를 막는다. 이 팩토리는 넷을 쓰고 있다. 깊이와 문자열 길이는 이 코드가 정했고 이름 길이와 숫자 길이는 라이브러리 기본값이 남았다. 비어 있는 것은 문서 길이와 토큰 수 둘이다.
문서 길이는 이 저장소가 이미 쓰는 축이다. 여섯 자리에서 쓴다. 그러나 배포에서 실제로 만들어지는 것은 메시징 코덱 하나뿐이고, 나머지 다섯은 생성하는 자리가 시험뿐이다. 같은 어댑터의 두 자리는 시험에서만 불리고 백엔드 jar 도 runtimeClasspath 에 없다. 웹소켓 어댑터의 두 자리와 메시징 쪽 스키마 레지스트리도 main 클래스이지만 생성은 시험뿐이다. 토큰 수만 저장소 어디에서도 쓰이지 않는다.
프로브로 확인했다. 선언된 상한의 스무 배짜리 평평한 배열이 이 저장소의 팩토리에서는 끝까지 읽히고, 토큰 수 상한을 건 팩토리에서는 거부된다.
유계 팩토리의 자바독이 이 원칙을 먼저 적어 두었다. 중첩 폭탄은 보내기는 싸고 들고 있기는 비싸므로 도움이 되는 유일한 한계는 흐름 파서가 넘기기를 거부하는 한계라는 것이다. 깊이와 문자열 길이에는 그 원칙을 적용했다. 원소 수에는 쓸 수 있는 축이 둘 남아 있는데도 값만 선언했다.
권고도 원문과 다르다. 문서 길이 상한을 이 팩토리에도 걸면 된다. 다만 그대로 옮겨 붙일 수는 없다. 지금 이 팩토리에는 예산이 전달되지 않는다. 중간에 매퍼 팩토리가 한 겹 더 있어서, 넓혀야 할 서명이 둘이고 고쳐야 할 자동설정 메서드가 둘이다. 등록되는 예산이 하나뿐이라 지금은 고를 것도 없다. 토큰 수 상한을 함께 걸 수도 있지만 그 값은 배열 하나가 아니라 문서 전체에 걸리므로, 선언된 100,000 을 그대로 옮길 수는 없다.
파서를 손대지 않는다면 원문이 든 두 선택지가 남는다. 계약 규칙 쪽으로 옮기거나, 걸리지 않는 필드를 지워 걸리는 것처럼 읽히지 않게 하는 것이다.
판정은 P2 다. 이 리프는 두 런타임 컴포지션에 모두 올라 있고, 위에서 확인한 대로 JSON 본문을 읽는 컨버터가 이 팩토리의 매퍼를 든다. 크기 제약이 붙지 않은 컬렉션 필드는 상한 없이 실체화된다.
## 검증 환경
OpenJDK : 21.0.12
Jackson : 3.1.5
Spring Boot : 4.0.8
확인 방식 : 선언과 참조 전수 검색, 파서가 제공하는 축 전수 확인, 유계 팩토리에 넓은 배열 통과, 컨텍스트를 띄워 컨버터가 든 매퍼 확인
소스 수정 : x
## 재현 조건
원문 근거는 분석 문서의 #L1006 절이다.
1. JSON 프로파일의 세 상한 선언과 엄격 프로파일의 값을 읽는다.
2. maxArrayElements() 를 부르는 자리를 전수로 센다.
3. 요청 예산 타입의 같은 이름 필드와 그 검증·비교 자리, 그리고 프로덕션이 등록하는 값을 읽는다.
4. 재정의 등록과 절대 상한 술어를 부르는 자리를 전수로 센다.
5. 두 예산 필터가 읽는 필드와 그 필터를 등록하는 자리를 확인한다.
6. 유계 팩토리가 파서에 넘기는 값과 그 자바독을 읽는다.
7. 캐시에 있는 jackson-core jar 에서 읽기 제약 빌더가 받는 축을 전수로 뽑고, 그중 이 저장소가 이미 쓰는 축과 그 호출자를 센다.
8. 그 팩토리에 넓은 배열을 넣어 어디까지 읽는지 보고, 토큰 수 상한만 건 팩토리와 비교한다.
9. 자동설정을 올려 핸들러 어댑터의 JSON 컨버터가 든 매퍼의 제약을 읽고, 이 저장소 자동설정을 뺀 대조군과 비교한다.
## 본문
<!-- body:start -->
프로파일이 세 상한을 선언하고 각각을 문서화한다. 엄격 프로파일이 그 값을 준다.
```java
// WebJsonProfile.java:38-40
public static WebJsonProfile strict() {
return new WebJsonProfile(true, true, true, true, true, 64, 100_000, 1_048_576);
}
```
가운데 `100_000``maxArrayElements` 다.
## 선언한 자리와 읽는 자리
:::evidence key="a14-f011-maxarrayelements" alt="코드베이스 정적 검색 출력 145줄. 프로파일의 세 상한 선언과 엄격 프로파일 값, maxArrayElements 접근자를 부르는 자리 수, 예산 타입의 같은 이름 필드와 절대 상한 상수와 필드 순서와 프로덕션이 등록하는 값, 재정의 등록을 부르는 자리, 예산 목록을 만드는 자동설정 두 곳과 등록되는 예산 하나와 공개 팩토리 수, 두 예산 필터가 읽는 필드와 반응형이 감싼 교환에 넘기는 둘, 그 필터를 등록하는 두 자리, 유계 팩토리의 자바독과 파서에 거는 두 값, 유계 팩토리를 부르는 자리와 그것을 부르는 자동설정, Jackson 읽기 제약 빌더가 받는 여섯 축과 그중 이 저장소가 이미 쓰는 여섯 자리, 같은 어댑터의 두 자리가 받는 예산 타입, 그 여섯 자리를 실제로 만드는 곳 전부와 백엔드 잠금 구성, 표본 애플리케이션의 크기 제약이 차례로 보인다." caption="상한을 선언하는 자리와 그것을 읽는 자리 — 145줄 · exit 0" zoom="true"
:::
`WebJsonProfile.maxArrayElements()` 를 부르는 자리는 0 이다.
같은 이름의 필드가 `WebRequestBudget` 에도 있다. 다만 값이 다르다. 필드 순서로 보면 `standard()` 가 담는 배열 원소 수는 `1_000` 이고(`:92`), `100_000` 은 절대 상한 상수 `ABSOLUTE_ARRAY_ELEMENTS_MAX`(`:51`)로만 나타나며 그 상수를 쓰는 자리는 생성자의 상한 검사(`:85`)와 `withinPlatformMaximum()`(`:110`) 둘이다. 깊이도 갈린다. 예산은 `maxJsonDepth = 32`, 프로파일은 `maxDepth = 64` 다.
예산 쪽 값도 요청을 판정하지 않는다. 생성자가 양수인지(`:59`)와 절대 상한 이하인지(`:85`) 검사하고, 예산끼리 비교하는 술어가 이 필드를 보지만(`:123`), 그 술어에 이르는 `registerOverride` 를 부르는 자리는 시험 두 줄뿐이다. 프로덕션 자동설정이 부르는 것은 비교하지 않는 `register` 다.
예산을 강제하는 필터는 서블릿과 반응형 둘이고, 어느 쪽도 이 필드를 읽지 않는다. 두 필터가 재는 축은 다섯으로 같다. 서블릿은 다섯을 자기 파일에서 읽는다. 반응형은 넷을 자기 파일에서 읽는데, 그중 본문 바이트는 `:103` 이 선언된 Content-Length 만 먼저 보는 것이고 실제로 흐르는 바이트는 `:64` 가 만드는 `BoundedServerWebExchange` 가 잰다. 다섯째인 응답 바이트는 그 교환에서만 잰다. 그리고 두 필터를 등록하는 자리는 `src/testkit``src/webfluxContractTest` 의 픽스처 애플리케이션이다.
## 이 팩토리가 요청 경로에 있는가
자동설정은 이 팩토리로 `webStrictObjectMapper` 라는 `JsonMapper` 빈을 만든다. 그 빈을 이름이나 한정자로 받는 코드는 저장소에 없으므로, 컨텍스트를 올려 직접 물었다.
:::evidence key="a14-f011-maxarrayelements-converter" alt="JVM 프로브 출력 19줄. 컨텍스트에 있는 JsonMapper 빈과 그 읽기 제약, 핸들러 어댑터가 든 세 Jackson 컨버터와 각각의 읽기 제약, 엄격 프로파일이 세우는 제약, 그리고 이 저장소 자동설정만 뺀 대조군의 매퍼와 컨버터가 차례로 보인다." caption="핸들러 어댑터의 컨버터가 든 매퍼 — 19줄 · exit 0" zoom="true"
:::
컨텍스트에 `JsonMapper` 빈은 하나이고, 핸들러 어댑터의 `JacksonJsonHttpMessageConverter` 가 든 매퍼의 제약은 깊이 64 · 문자열 길이 1,048,576 이다. 유계 팩토리가 세우는 값과 같다.
마지막 묶음이 대조군이다. 이 저장소 자동설정만 빼고 같은 컨텍스트를 올리면 `jacksonJsonMapper` 가 오고, 컨버터가 드는 제약도 Jackson 기본값인 500 · 100,000,000 이 된다. 그 차이를 만드는 것이 이 자동설정이다.
이 지문은 엄격 프로파일에만 붙는 것은 아니다. 관대 프로파일도 같은 두 값을 갖는다. 여기서 확인되는 것은 컨버터가 유계 팩토리로 만든 매퍼를 든다는 것이고, 그 프로파일이 엄격인 이유는 `WebMvcPlatformSettings``strictJson` 기본값이 참이기 때문이다.
## 파서가 주는 축은 여섯이고 이 코드가 고르는 것은 둘이다
```java
// BoundedJsonFactory.java:33-39
JsonFactoryBuilder builder =
JsonFactory.builder()
.streamReadConstraints(
StreamReadConstraints.builder()
.maxNestingDepth(profile.maxDepth())
.maxStringLength(profile.maxStringBytes())
.build());
```
잠금 파일이 고정한 `jackson-core-3.1.5` 의 읽기 제약 빌더는 여섯을 받는다. 위의 둘 말고 `maxNameLength`, `maxNumberLength`, `maxDocumentLength`, `maxTokenCount` 가 더 있다. 앞의 둘은 라이브러리 기본값이 남으므로 실제로 비어 있는 축은 뒤의 둘이다.
그중 `maxDocumentLength` 는 이 저장소가 이미 여섯 자리에서 쓴다. 그중 main 자동설정이 만드는 것은 `JacksonMessageCodec:213` 하나로, `MessagingCoreAutoConfiguration:364` 가 그 팩토리를 부른다. 나머지 다섯은 이 검색에서 시험에서만 생성된다.
같은 어댑터에도 두 자리가 있다. `WebCborMapperFactory:55``WebXmlMapperFactory:74` 인데, 둘이 받는 것은 `CodecBudget` 이다. 요청 예산과 이름만 같은 별개 레코드이고, 이 팩토리들을 부르는 자리는 `WebCodecMapperTest` 뿐이며, 두 형식의 백엔드 잠금 항목에는 `runtimeClasspath` 가 없다. 웹소켓 어댑터의 `StrictWebSocketJsonCodec:64``WebSocketCborCodec:76` 도 main 클래스이지만 생성하는 자리는 시험뿐이다. 메시징 어댑터의 `LocalJsonSchemaRegistry:623` 도 마찬가지로 `new` 하는 자리가 시험뿐이고, 그것을 받는 `JsonSchemaIntegrationEventEncoder` 도 빈으로 등록되지 않는다. 선례로 삼기에는 다섯 다 배포에서 돌지 않는다.
저장소 어디에서도 쓰이지 않는 축은 `maxTokenCount` 하나다.
## 넓은 배열을 넣어 보면
:::evidence key="a14-f011-maxarrayelements-parser" alt="JVM 프로브 출력 22줄. 엄격 프로파일이 선언한 세 값, 유계 팩토리가 세운 파서의 실효 읽기 제약 여섯 항목과 그중 비어 있는 둘, 원소 이백만 개짜리 배열의 본문 바이트와 읽어 낸 토큰 수와 결과, 같은 본문을 토큰 수 상한만 건 파서에 넣었을 때의 거부 메시지가 보인다." caption="유계 팩토리가 넓은 배열을 어디까지 읽는가 — 22줄 · exit 0" zoom="true"
:::
원소 200 만 개짜리 평평한 배열을 그 팩토리에 넣었다. 본문은 4,000,001 바이트이고, 파서는 토큰 2,000,002 개를 끝까지 읽는다. 선언된 상한 100,000 의 스무 배다.
같은 본문을 토큰 수 상한만 1,000,000 으로 건 팩토리에 넣으면 상한을 넘었다는 메시지와 함께 거부된다.
## 자바독이 먼저 적어 둔 원칙
```
* A depth limit applied to a parsed tree has already paid for
* the tree; a nesting bomb is cheap to send and expensive to hold, so the only limit that helps is
* one the streaming parser refuses to exceed.
```
깊이와 문자열 길이는 이 원칙대로 걸었다. 원소 수는 형제 팩토리가 이미 쓰는 축을 놔둔 채 값만 선언했다.
## 옮길 자리
문서 길이 상한을 걸려면 유계 팩토리가 예산을 받아야 한다. 지금 서명은 프로파일만 받고(`BoundedJsonFactory:31`), 그것을 부르는 것은 `WebObjectMapperFactory:59` 하나이며, 그 클래스를 부르는 자동설정 메서드가 `WebMvcPlatformAutoConfiguration:135``WebFluxPlatformAutoConfiguration:129` 둘이다. 서명 둘과 호출부 둘을 함께 넓혀야 한다. 다만 같은 클래스의 `standard()`·`standardJsonMapper()`·`create()` 도 같은 `jsonMapper` 로 위임하므로, 서명을 넓히면 그 셋과 그 호출부도 함께 손대야 한다.
어느 예산을 쓸지는 오늘 문제가 아니다. 두 자동설정이 등록하는 것은 `standard` 하나뿐이고 `WebRequestBudget` 의 공개 팩토리도 하나다. 다만 목록이 프로파일 이름으로 키를 잡고 매퍼는 컨텍스트당 하나이므로, 배포가 프로파일을 더 등록하면 그때 정해야 한다.
토큰 수 상한도 쓸 수 있지만 배열 원소 수와 일대일은 아니다. 평평한 스칼라 배열에서만 원소 하나가 토큰 하나이고, `List<CreateWorkLogRequest>` 처럼 객체가 들어가면 원소당 토큰 수가 그 모양에 따라 달라진다. 같은 상한이 문서 안의 다른 토큰도 함께 세므로 선언된 100,000 을 그대로 옮길 수 없다.
파서 쪽을 손대지 않는다면 컬렉션 필드에 크기 제약을 요구하는 쪽이 남는다. 표본 애플리케이션이 이미 그렇게 한다.
```java
// WorkLogController.java:206-208
public record BatchCreateRequest(
@Valid
@Size(max = MAX_BATCH_SIZE, message = "batch may not exceed " + MAX_BATCH_SIZE + " items")
```
## 확인하지 못한 것
실제 HTTP 요청으로 힙 증가를 측정하지 않았다. 프로브가 확인한 것은 파서가 그 배열을 끝까지 읽는다는 것까지다.
프로브가 넣은 배열은 원소 200 만 개다. 원문이 든 1 억 개를 넣어 본 것은 아니다.
컨버터를 확인한 컨텍스트는 자동설정 넷만 올린 것이고 실제 애플리케이션 부팅이 아니다. 그 컨텍스트에는 JSON 매퍼 빈이 하나뿐이었다. 같은 컨텍스트에 CBOR·XML 컨버터가 함께 있는 것은 프로브가 시험 클래스패스로 돌기 때문이고, 그 두 형식의 jar 는 배포 클래스패스에 없다.
문서 길이나 토큰 수 상한을 이 저장소의 유계 팩토리에 실제로 걸어 회귀가 없는지 확인하지는 않았다.
<!-- body:end -->