- 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>
213 lines
17 KiB
Markdown
213 lines
17 KiB
Markdown
---
|
|
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:
|
|
- 원본 분석 절은 final/document.md#a14#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. 플래그 값을 쓰는 시험의 두 단언과 그 메서드의 구현을 대조한다.
|
|
|
|
## 본문
|
|
|
|
<!-- body:start -->
|
|
|
|
열거형이 자기 규칙을 자바독에 적어 두었다.
|
|
|
|
```java
|
|
// WebAdvancedFeature.java:8-12
|
|
* <p>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
|
|
* <p>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 개는 보지 않았다. 확장자를 걸지 않고 다시 훑어도 게이트·바인딩이 있는 파일은 같은 셋뿐이었다.
|
|
|
|
<!-- body:end -->
|