--- 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: - 원본 분석 절은 analysis/14-adapter-inbound-web.md#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 컨버터가 든 매퍼의 제약을 읽고, 이 저장소 자동설정을 뺀 대조군과 비교한다. ## 본문 프로파일이 세 상한을 선언하고 각각을 문서화한다. 엄격 프로파일이 그 값을 준다. ```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` 처럼 객체가 들어가면 원소당 토큰 수가 그 모양에 따라 달라진다. 같은 상한이 문서 안의 다른 토큰도 함께 세므로 선언된 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 는 배포 클래스패스에 없다. 문서 길이나 토큰 수 상한을 이 저장소의 유계 팩토리에 실제로 걸어 회귀가 없는지 확인하지는 않았다.