--- kind: CASE slug: a14-f014-advanced title: 선언된 Advanced 능력 열둘 중 속성 이름을 읽는 코드가 있는 것은 둘이다 topic: web-inbound-and-http-surface project: clean-architecture-backend-template status: 게시 전 sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 evidenceCapturedOn: 2026-09-03 rootTreeNode: case:a14-f014-advanced body: case-a14-f014-advanced.body.md assets: - key: a14-f014-advanced file: ../../../final/evidence/rendered/a14-f014-advanced.svg - key: a14-f014-advanced-switches file: ../../../final/evidence/rendered/a14-f014-advanced-switches.svg evidence: - ../../../final/evidence/raw/a14-f014-advanced.txt - ../../../final/evidence/raw/a14-f014-advanced-switches.txt source: - 원본 분석 절은 analysis/14-adapter-inbound-web.md#L1267 이다. --- # 선언된 Advanced 능력 열둘 중 속성 이름을 읽는 코드가 있는 것은 둘이다 고급 능력 열거형이 상수를 열둘 선언하고 각 상수가 `backend.web.advanced.<이름>.enabled` 를 계산해 준다. 그 이름을 게이트나 바인딩으로 읽는 것은 두 접두사뿐이다. 나머지 열은 속성을 참으로 설정해도 그 값을 보는 코드가 없고, 그 열을 속성으로 구동하는 시험도 없다. ## 관계 - **그러나 R0 경계가 문서에만 있고 compile 경로에서 닫히지 않는다** 두 사례 모두 선언된 능력에 소비자가 없다. a09-f004 는 만들어진 제공자를 꺼내 쓰는 코드가 없고, 여기는 발행된 속성 이름을 읽는 코드가 없다. - **플랫폼 요청 컨텍스트가 서블릿에는 생산자가 없고, 리액티브에는 익명 액터로 고정되어 있다** 같은 리프의 P1 이고, 둘 다 시험이 손으로 만든 값 위에서 통과해 배포 경로의 공백이 드러나지 않았다. - **그 속성을 보일 수 없는 대역 위에서 통과한 테스트는 증인이 아니다** 레인이 나머지 열 능력을 돌리기는 하지만 그 시험들은 대상 클래스를 곧바로 만들어 부른다. 레인이 녹색이어도 배포가 그 능력을 켤 수 있다는 증거는 되지 않는다. - **선언 순서를 단언하는 테스트는 그 순서를 읽는 코드가 있을 때만 게이트다** 릴리스 시험이 단언하는 속성 이름을 읽는 코드가 없다. ## 문제 야간 레인과 릴리스 레인이 이 능력들을 돌린다. 배포가 그 능력들을 실제로 켤 수 있는지, 그리고 레인이 무엇을 확인하는지 봤다. ## 결론 열거형 자바독에 규칙이 적혀 있다. 능력 하나에 플래그 하나이고, advanced 라는 이름의 스위치 하나로 묶지 않는다. 하나로 묶으면 가상 스레드를 원한 배포가 XML 파서까지 받게 되기 때문이다. 세어 보면 상수가 열둘인데 원문은 열한 개로 적었다. 빠진 것은 OPENAPI_32 다. 각 상수가 propertyName() 으로 이름을 계산한다. 열거형을 실제로 불러 상수마다 그 이름을 만들고 main 소스 4,626 개를 훑으니, 걸리는 상수는 둘이고 나머지 열은 하나도 걸리지 않는다. 그 접두사가 main 에 나오는 여섯 줄은 성격이 다르다. 게이트나 바인딩이 셋, 이름을 만들기만 하는 것이 둘, 자바독이 하나다. 이름을 만들기만 하는 둘 중 하나는 열거형 자신이고, 다른 하나인 가상 스레드 프로파일은 virtual-threads 를 돌려주는데 열거형이 만드는 이름도 실제 게이트도 mvc-virtual-threads 다. 원문은 이 이름 불일치를 다른 절에서 P3 으로 따로 적었다. 읽히는 둘도 결과가 같지 않다. ndjson 을 켜면 기록기 빈이 둘 생기고, JSON_SEQUENCE 도 자기 이름 없이 이때 함께 켜진다. 다만 그 기록기가 내는 두 미디어 타입을 선언한 라우트가 main 에 하나도 없고, 기록기를 부르는 코드도 자기 패키지 밖에는 없다. mvc-virtual-threads 를 켜면 Boot 의 MVC 비동기 지원이 찾는 이름으로 실행기가 등록되는데, 출하 조립 경로에는 그 이름을 쓰는 빈이 이미 하나 더 있다. 이 스위치를 켜고 출하 조립을 띄운 시험이 없어서, 요청이 실제로 가상 스레드에서 처리되는지는 확인하지 못했다. 본문에 두 선언과 그 결과가 갈리는 조건을 적었다. 그래서 세는 기준을 밝혀야 한다. 게이트가 자기 이름을 읽는 상수는 둘, 스위치를 켜면 빈이 생기는 상수는 셋이다. 요청 동작까지 달라지는 상수는 이 검증 범위에서 확인하지 못했다. 원문이 적은 아홉은 첫째 기준을 열한 개 분모에 적용한 수다. 분모를 열둘로 고치면 같은 기준에서 열이 된다. 둘째 기준으로 세도 아홉이 나오기는 하는데 그 아홉은 다른 집합이다. 원문이 이름으로 적은 아홉에는 JSON_SEQUENCE 가 들어 있고 OPENAPI_32 가 빠져 있으며, 둘째 기준의 아홉은 그 반대다. 레인이 무엇을 확인하는지도 봤다. webAdvancedTest 는 시험 클래스를 이름으로 적지 않고 web-advanced 태그로 고른다. 그 태그가 붙은 클래스가 스물둘이고, 원문이 지목한 롤백 시험은 그중 하나다. 그 안의 일곱 중 속성을 참으로 두는 것은 없다. 속성 상태를 다루는 둘 가운데 하나는 값을 아예 두지 않은 경우를, 하나는 두 실제 이름을 거짓으로 둔 경우를 확인한다. 나머지 다섯은 픽스처와 승격 게이트 기본값을 쓴다. 같은 레인의 다른 시험은 두 실제 속성을 참으로 두고 만들어지는 빈을 확인한다. 나머지 열 능력에도 레인이 도는 단위 시험이 있다. 원문이 레인은 이 능력들을 전부 돌린다고 적은 것은 그대로 맞다. 다만 그 시험들은 대상 클래스를 곧바로 만들거나 빌드 정의를 읽어 단언하지, 속성을 두고 컨텍스트를 띄우지 않는다. 저장소 전체 시험에서 backend.web.advanced 로 시작하는 속성을 두는 자리는 mvc-virtual-threads 와 ndjson 뿐이다. 원문이 롤백 시험의 대상을 테스트 전용 값이라고 적은 대목도 다르다. 그 값을 쓰는 시험은 일곱 중 하나뿐이고, 그 시험은 빈 집합에 대해 참을 단언한 뒤 상수마다 한 원소 집합으로 같은 메서드를 부른다. 뒤쪽 비교는 그 메서드가 부르는 술어를 상대로 하므로 구현을 자기 자신과 맞춘다. 같은 리프의 §40.1 도 배포가 켤 수 없는 코드를 P1 로 적었다. 원문이 P1 근거를 따로 문장으로 적지는 않았지만, 그 절이 든 29 파일 안에는 이 리프의 유일한 프로덕션 요청 컨텍스트 생산자가 들어 있다고 원문이 굵게 표시해 두었다. 그러면 Stable 경로가 함께 빈다. 여기서는 열 능력이 모두 부가 기능이고 Stable 경로가 그중 무엇에도 기대지 않는다. 그래서 P2 다. 권고는 원문과 같다. 열거형이 이미 속성 이름을 계산하므로, 능력마다 그 이름으로 게이트된 설정을 두거나 아직 배선할 수 없는 상수를 열거형에서 빼면 된다. 릴리스 시험이 JSON_MERGE_PATCH.propertyName() 을 단언하고 있으니, 그 이름이 의미를 갖는다는 전제는 시험에 이미 들어가 있다. ## 검증 환경 OpenJDK : 21.0.12 확인 방식 : 열거형 상수를 실행으로 세고 각 속성 이름을 main 자바·yml·yaml·properties 4,626 개에서 검색, 레인의 선택 방식과 켜는 쪽·끄는 쪽 시험 본문 확인, 두 스위치가 만드는 빈과 그 이름의 경쟁 확인 소스 수정 : x ## 재현 조건 원문 근거는 분석 문서의 #L1267 절이다. 1. 열거형의 자바독과 상수를 읽고 수를 센다. 2. 열거형을 실제로 불러 상수마다 속성 이름을 만들고, main 소스에서 그 이름을 쓰는 파일을 찾는다. 3. backend.web.advanced 가 main 에 나오는 줄을 전부 뽑아 게이트·바인딩과 이름 생성과 자바독으로 가른다. 4. 두 스위치가 각각 어떤 빈을 만드는지 읽는다. 5. ndjson 이 만든 기록기가 내는 미디어 타입을 내는 라우트와, 그 기록기를 부르는 코드를 센다. 6. 가상 스레드 스위치가 등록하는 빈 이름을 app-bootstrap 에서 다시 찾고, 그 패키지가 컴포넌트 스캔 제외에 있는지 본다. 7. 레인 태스크가 무엇을 고르는지와 그 태그를 단 시험 클래스 수를 읽는다. 8. 롤백 시험 일곱의 본문과, 같은 레인에서 켜는 쪽을 단언하는 시험을 읽는다. 9. 플래그 값을 쓰는 시험의 두 단언과 그 메서드의 구현을 대조한다. ## 본문 열거형이 자기 규칙을 자바독에 적어 두었다. ```java // WebAdvancedFeature.java:8-12 *

One flag per capability, not one for "advanced". They have nothing in common operationally: * virtual threads change how every request is scheduled, streaming changes how long a response * holds a connection, XML adds a parser with a decades-long history of entity-expansion attacks. A * single switch would make those one decision, and a deployment that wanted the first would be * given the third. ``` 능력 하나에 플래그 하나이고, 그 이름은 상수가 직접 계산한다. ## 선언된 상수와 그 이름이 나오는 곳 :::evidence key="a14-f014-advanced" alt="코드베이스 정적 검색 출력 172줄. 열거형 자바독의 규칙과 선언된 상수 수와 목록, propertyName 이 이름을 만드는 방식, main 에 그 접두사가 나오는 여섯 줄과 그중 게이트·바인딩 셋과 이름을 만들기만 하는 둘, 가상 스레드 설정이 등록하는 실행기 이름과 app-bootstrap 이 같은 이름을 선언하는 자리와 스캔 제외 여부, ndjson 설정이 만드는 두 기록기와 그 미디어 타입을 내는 라우트 수, 레인이 태그로 고른다는 빌드 정의와 그 태그를 단 클래스 수, 켜는 쪽을 단언하는 시험이 쓰는 속성과 확인하는 빈, 롤백 시험 일곱의 이름과 속성 상태를 다루는 둘과 플래그 시험 전문, 롤백 값 타입을 부르는 자리 다섯, 레인 둘과 속성 이름을 단언하는 시험이 차례로 보인다." caption="선언된 능력과 그 이름을 다루는 자리 — 172줄 · exit 0" zoom="true" ::: 상수는 열두 개다. 원문은 열한 개로 적었다. `backend.web.advanced` 가 main 에 나오는 줄은 여섯이다. 게이트나 바인딩이 셋인데 `mvc-virtual-threads` 를 `VirtualThreadSettings:17` 이 바인딩하고 `VirtualThreadMvcConfiguration:33` 이 게이트하며, `ndjson` 은 `MvcStreamingExecutorConfiguration:37` 이 게이트한다. 둘은 이름을 만들기만 하고 하나는 자바독이다. ## 상수마다 이름을 만들어 찾아보면 :::evidence key="a14-f014-advanced-switches" alt="JVM 프로브 출력 19줄. 열거형을 불러 만든 상수 열둘과 각각이 계산한 속성 이름, 그 이름을 쓰는 main 파일 이름, 그리고 그런 파일이 없는 상수의 수가 표로 보인다." caption="상수별 속성 이름과 그 이름이 나오는 main 파일 — 19줄 · exit 0" zoom="true" ::: 열거형을 불러 상수마다 이름을 만들고 main 소스 4,626 개에서 찾았다. 걸리는 것은 `MVC_VIRTUAL_THREADS` 와 `NDJSON` 둘이다. ## ndjson 은 기록기를 만들지만 소비자가 없다 ```java // MvcStreamingExecutorConfiguration.java:72,84 return new MvcStreamWriter(encoder, StreamFraming.NDJSON); ... return new MvcStreamWriter(encoder, StreamFraming.JSON_SEQUENCE); ``` 기록기 빈이 둘 생긴다. `JSON_SEQUENCE` 가 자기 이름 없이 함께 켜지는 것이 이 자리다. 다만 `application/x-ndjson` 이나 `application/json-seq` 를 내는 라우트가 main 전체에 0 이고, `MvcStreamWriter` 를 부르는 main 코드도 `advanced` 패키지 자신 말고는 없다. ## 가상 스레드 실행기는 이름을 두고 겹친다 ```java // VirtualThreadMvcConfiguration.java:60-66 *

Named {@code applicationTaskExecutor} because that is the bean Spring Boot's MVC async * support looks for. A differently named bean is created, is never used, and leaves the container * default in place — which is the failure mode where the whole capability is switched on and * nothing changes. */ @Bean("applicationTaskExecutor") @ConditionalOnMissingBean(name = "applicationTaskExecutor") ``` Boot 의 MVC 비동기 지원이 찾는 이름이라 이것을 잡으면 요청 처리가 달라진다. 그런데 같은 이름을 선언하는 빈이 출하 조립 경로에 하나 더 있다. ```java // AsyncExecutorConfig.java:19,25,40-42 @Configuration public static final String EXECUTOR_BEAN_NAME = "applicationTaskExecutor"; @Bean(name = EXECUTOR_BEAN_NAME) @Primary ThreadPoolTaskExecutor applicationTaskExecutor( ``` 조건이 붙어 있지 않고, `bootstrap.async` 는 컴포넌트 스캔 제외 정규식에 없다. 가상 스레드 선언에는 그 이름이 없을 때만 만들라는 조건이 붙어 있어서, 결과는 두 설정이 등록되는 순서가 정한다. 자바독이 든 실패는 빈 이름을 다르게 지었을 때다. 여기 겹침은 그 자바독이 시킨 이름을 그대로 따라서 생긴 것이라 자바독이 예상한 형태가 아니다. 어느 순서가 적용되는지는 확인하지 못했고, 두 순서의 결과도 같지 않다. 비동기 설정이 먼저면 가상 스레드 쪽이 만들어지지 않고, 반대면 같은 이름을 두 번 선언하는 것이 되는데 이 저장소는 빈 정의 덮어쓰기를 켜 두지 않았다. ## 레인이 고르는 범위와 그 안의 단언 ```groovy // build.gradle:210-212 useJUnitPlatform { includeTags 'web-advanced' } ``` `web-advanced` 태그를 단 시험 클래스가 스물둘이다. 롤백 시험은 그 하나다. 일곱 중 `withPropertyValues` 를 부르는 것은 `:67` 하나이고, `mvc-virtual-threads` 와 `ndjson` 을 `false` 로 적는다. `:49` 는 값을 아예 두지 않은 채로 두 설정을 등록한다. `:103` `:119` `:134` 는 픽스처를 손으로 만들고, `:146` 은 승격 게이트의 기본값을 읽는다. 남은 `:86` 은 아래의 플래그 시험이다. 같은 레인의 `MvcAdvancedConfigurationTest` 는 켜는 쪽을 단언한다. `backend.web.advanced.ndjson.enabled=true` 로 두고 기록기 빈 둘을 확인하고, 가상 스레드 쪽도 참으로 두고 실행기 빈을 확인한다. 다만 그 컨텍스트에는 가상 스레드 설정만 등록되어 있어서 위의 이름 경쟁은 재현되지 않는다. ## 플래그 값을 쓰는 시험 ```java // WebAdvancedRollbackIT.java:87-97 (줄바꿈과 .as(..) 를 줄인 발췌) · WebAdvancedFeatureFlags.java:44-46 assertThat(WebAdvancedFeatureFlags.none().stableBehaviourPreserved()).isTrue(); for (WebAdvancedFeature feature : WebAdvancedFeature.values()) { boolean preserved = WebAdvancedFeatureFlags.of(feature).stableBehaviourPreserved(); assertThat(preserved).isEqualTo(!feature.affectsUnrelatedRequests()); ... public boolean stableBehaviourPreserved() { return enabled.stream().noneMatch(WebAdvancedFeature::affectsUnrelatedRequests); } ``` 앞의 단언은 빈 집합에 대해 참을 확인하므로 자기 비교가 아니다. 뒤의 반복은 한 원소 집합에 `noneMatch(affectsUnrelatedRequests)` 를 돌린 결과를 `!feature.affectsUnrelatedRequests()` 와 맞추므로, 구현을 그 구현이 부르는 술어와 비교한다. `WebAdvancedFeatureFlags` 는 main 타입이지만 부르는 자리 다섯이 모두 시험이다. 레인 둘이 이 시험들을 돌린다. `web-advanced-nightly.yml:46` 과 `web-advanced-release.yml:53` 이 `webAdvancedTest` 를 부른다. `WebAdvancedReleaseTest:48` 은 `JSON_MERGE_PATCH.propertyName()` 을 단언한다. ## 확인하지 못한 것 속성을 참으로 설정하고 애플리케이션을 띄우지 않았다. 이름을 읽는 조건이 없다는 것까지 확인했다. 오류나 경고가 나지 않는다는 것도 코드에서 읽은 것이다. 가상 스레드 스위치를 켠 채로 출하 조립을 띄워 두 설정 중 어느 쪽 실행기가 남는지 확인하지 못했다. 켜는 쪽을 단언하는 시험은 가상 스레드 설정만 등록한 컨텍스트를 쓰므로 이 경쟁을 재현하지 않는다. 프로브가 찾은 것은 속성 이름 문자열이 main 소스에 나오는지다. 문자열을 조립해 읽는 코드가 있다면 걸리지 않는다. 반대로 자바독에 이름이 적혀 있기만 해도 걸리는데, 이 코드베이스에서 주석만으로 걸린 파일은 없었다. 가상 스레드 쪽 두 파일은 각각 바인딩과 게이트로 걸린다. 프로브가 훑은 것은 main 의 자바·yml·yaml·properties 4,626 개다. 같은 자리의 gradle·imports·sql 등 88 개는 보지 않았다. 확장자를 걸지 않고 다시 훑어도 게이트·바인딩이 있는 파일은 같은 셋뿐이었다.