The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.
Follows the import procedure in README.md.
source/ the originating repository verbatim — 78 documents, 28 SVGs,
8 manifests, plus .source-revision recording the commit
final/ the SSOT
document.md 729 lines written from the 29 experiment documents, not
concatenated: what was predicted, what was measured, and
where the measurement itself was wrong
evidence/raw 125 outputs, flattened to <experiment>__<file> because
the originals collided (01-baseline.txt appeared three
times) and the audit only globs the top level
evidence/meta one per raw file; command and exitCode are null and the
README says why rather than inventing them
evidence/browser 22 captures
assets/ three diagrams through techviz
.techviz/ their VizSpecs
A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.
Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.
verify-pipeline.py passes. audit-records.py reports no issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
18 KiB
kind, slug, title, topic, project, status, sourceRevision, evidenceCapturedOn, rootTreeNode, body, assets, evidence, source
| kind | slug | title | topic | project | status | sourceRevision | evidenceCapturedOn | rootTreeNode | body | assets | evidence | source | |||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| CASE | a14-f011-maxarrayelements | 배열 원소 상한은 선언만 되고, 파서가 그것을 대신할 수단은 이 팩토리에서만 비어 있다 | web-inbound-and-http-surface | clean-architecture-backend-template | 게시 전 | 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 | 2026-09-03 | case:a14-f011-maxarrayelements | case-a14-f011-maxarrayelements.body.md |
|
|
|
배열 원소 상한은 선언만 되고, 파서가 그것을 대신할 수단은 이 팩토리에서만 비어 있다
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 절이다.
- JSON 프로파일의 세 상한 선언과 엄격 프로파일의 값을 읽는다.
- maxArrayElements() 를 부르는 자리를 전수로 센다.
- 요청 예산 타입의 같은 이름 필드와 그 검증·비교 자리, 그리고 프로덕션이 등록하는 값을 읽는다.
- 재정의 등록과 절대 상한 술어를 부르는 자리를 전수로 센다.
- 두 예산 필터가 읽는 필드와 그 필터를 등록하는 자리를 확인한다.
- 유계 팩토리가 파서에 넘기는 값과 그 자바독을 읽는다.
- 캐시에 있는 jackson-core jar 에서 읽기 제약 빌더가 받는 축을 전수로 뽑고, 그중 이 저장소가 이미 쓰는 축과 그 호출자를 센다.
- 그 팩토리에 넓은 배열을 넣어 어디까지 읽는지 보고, 토큰 수 상한만 건 팩토리와 비교한다.
- 자동설정을 올려 핸들러 어댑터의 JSON 컨버터가 든 매퍼의 제약을 읽고, 이 저장소 자동설정을 뺀 대조군과 비교한다.
본문
프로파일이 세 상한을 선언하고 각각을 문서화한다. 엄격 프로파일이 그 값을 준다.
// 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 기본값이 참이기 때문이다.
파서가 주는 축은 여섯이고 이 코드가 고르는 것은 둘이다
// 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 을 그대로 옮길 수 없다.
파서 쪽을 손대지 않는다면 컬렉션 필드에 크기 제약을 요구하는 쪽이 남는다. 표본 애플리케이션이 이미 그렇게 한다.
// 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 는 배포 클래스패스에 없다.
문서 길이나 토큰 수 상한을 이 저장소의 유계 팩토리에 실제로 걸어 회귀가 없는지 확인하지는 않았다.