79 lines
6.7 KiB
Markdown
79 lines
6.7 KiB
Markdown
---
|
|
title: error / webmvctest-nested-springbootconfiguration-context-pollution-2026-06-01
|
|
source_type: error-note
|
|
status: raw
|
|
related_branches: [feature-operational-error-observability-foundation]
|
|
related_projects: [ca-skeleton]
|
|
tags: [error, ca-skeleton, spring-boot-test, webmvctest, springbootconfiguration, component-scan, mockmvc, testing]
|
|
created: 2026-06-01
|
|
status_label: resolved
|
|
---
|
|
|
|
# error: webmvctest-nested-springbootconfiguration-context-pollution-2026-06-01
|
|
|
|
> Layer: `raw/errors/` — 실제 발생한 오류 / 막힘 / 트러블슈팅의 원석. canonical 정제 전 raw 후보이며, `wiki/blog/` 또는 `wiki/troubleshooting/` 직접 생성 근거가 아니다.
|
|
|
|
## Parent / 부모
|
|
|
|
- [[raw/branch-notes/feature-operational-error-observability-foundation]] — Phase C2 구현 중 새 계약 테스트(`EnvelopeMetaContractTest`)를 `app-bootstrap` 에 추가하다가 발생.
|
|
|
|
## 증상
|
|
|
|
Phase C2 (envelope `meta` 객체 + `error.category` 도입) 구현 후 `./gradlew test` 에서 `app-bootstrap:test` 만 5건 실패. 두 부류:
|
|
|
|
1. **신규 `EnvelopeMetaContractTest` (3건)** — 전부 컨텍스트 로드 단계 실패:
|
|
- `problemdetails_is_pinned_off()` → `org.springframework.boot.context.properties.bind.BindException` → `IllegalStateException at Assert.java:101`
|
|
- 나머지 2건도 `DefaultCacheAwareContextLoaderDelegate` 컨텍스트 로드 실패.
|
|
2. **기존 `OperationalContractRuntimeTest` (2건)** — Phase C2 이전엔 통과하던 회귀:
|
|
- `operationalContractBeans_areWiredIntoTheApplicationContext()` → `AssertionError: [EnvelopeBodyAdvice must be component-scanned ...] Expecting actual not to be empty` (즉 `EnvelopeBodyAdvice` 빈이 컨텍스트에 없음)
|
|
- `envelopeAdvice_wrapsRawControllerBody()` → `PathNotFoundException` on `$.success` (응답이 envelope 로 안 감싸짐)
|
|
|
|
`shared-contract` / `adapter-web` / `sample-portfolio` 의 자체 슬라이스 테스트는 전부 통과 — 문제는 `app-bootstrap` 컨텍스트에 국한.
|
|
|
|
## 조사 단계 / Investigation log
|
|
|
|
정적 추론으로는 "왜 `@RestControllerAdvice` 가 컴포넌트 스캔에서 빠지나"가 안 풀려, **격리 실험**으로 좁혔다 (working tree 미커밋 상태였으므로 비파괴적 진단 사용):
|
|
|
|
1. `git stash -u` 로 전체 변경 임시 제거 → 원본(`c36b764`)에서 `OperationalContractRuntimeTest` 실행 → **BUILD SUCCESSFUL**. → 내 변경이 회귀 원인 확정. `git stash pop` 으로 복원.
|
|
2. yaml 2개(`application.yml`/`application-test.yml`)만 `git stash push -- <files>` 로 격리 → 여전히 실패. → **config 무관, Java 변경이 원인**.
|
|
3. 신규 `EnvelopeMetaContractTest.java` 만 `/tmp` 로 `mv` 한 뒤 `OperationalContractRuntimeTest` 실행 → **BUILD SUCCESSFUL**. → **`EnvelopeMetaContractTest` 의 존재 자체가 같은 패키지의 다른 테스트를 오염**시킨다고 확정.
|
|
|
|
## 근본 원인 / Root cause
|
|
|
|
신규 `EnvelopeMetaContractTest` 가 `OperationalContractRuntimeTest` 와 **동일 패키지** (`dev.caskeleton.bootstrap.runtime`) 에 있으면서, 내부에 **nested `@SpringBootConfiguration static class TestBootstrap`** 를 선언했다.
|
|
|
|
- `@WebMvcTest` 는 명시적 config 가 없으면 `AnnotatedClassFinder(SpringBootConfiguration.class)` 로 테스트 클래스 패키지에서 `@SpringBootConfiguration` 을 찾아 컨텍스트 소스로 삼는다. `OperationalContractRuntimeTest` 는 원래 `CaSkeletonApplication` (`@SpringBootApplication`, `scanBasePackages="dev.caskeleton"`) 을 찾아 그 컴포넌트 스캔으로 `EnvelopeBodyAdvice`/`GlobalExceptionHandler` 를 등록했다.
|
|
- 같은 패키지에 **두 번째 `@SpringBootConfiguration`** (`EnvelopeMetaContractTest.TestBootstrap`) 가 생기자 config 탐지가 교란됐다. `TestBootstrap` 은 `@EnableAutoConfiguration` 만 있고 `scanBasePackages` 가 없어, 그 컨텍스트에는 `EnvelopeBodyAdvice` 가 스캔되지 않는다 → 빈 부재 + 응답 미-wrap.
|
|
- 별개로 `EnvelopeMetaContractTest` 자신의 `BindException` 은, 그 슬라이스가 test 프로파일 없이 **production `application.yml`** 을 로드해 `${OIDC_ISSUER_URI}` 등 미해소 placeholder 가 `@Validated` settings 의 `Assert.state(...)` 를 깨뜨린 것.
|
|
|
|
핵심 교훈: **`@SpringBootConfiguration`(또는 nested 형태)을 다른 Spring Boot 테스트와 같은 패키지에 두면, 그 패키지의 config 자동 탐지를 조용히 오염**시킬 수 있다. 컴파일/실행은 되지만 *다른* 테스트의 컨텍스트가 바뀐다.
|
|
|
|
## 해결 / Resolution
|
|
|
|
`EnvelopeMetaContractTest` 를 폐기하고, 검증 대상 3개 컴포넌트(`EnvelopeBodyAdvice`/`GlobalExceptionHandler`/`RequestLoggingFilter`)가 **모두 `adapter-web` 소속**이라는 점에 착안해 **`adapter-web` 의 standalone MockMvc 통합 테스트**(`EnvelopeMetaIntegrationTest`)로 재작성:
|
|
|
|
```java
|
|
mvc = MockMvcBuilders.standaloneSetup(new Probe())
|
|
.addFilter(new RequestLoggingFilter())
|
|
.setControllerAdvice(new EnvelopeBodyAdvice(), new GlobalExceptionHandler())
|
|
.build();
|
|
```
|
|
|
|
- Spring 컨텍스트가 없으므로 production placeholder 바인딩도, `@SpringBootConfiguration` 오염도, security 필터 체인도 없다.
|
|
- 필터가 실제로 돌아 snake_case MDC 를 채우므로 `meta.requestId`/`meta.traceId` 가 채워진 채로 success/5xx 두 경로를 검증.
|
|
- `problemdetails.enabled=false` 의 env-property 단언은 standalone 에서 불가 → 폐기. ProblemDetail 금지는 이미 ArchUnit `no_problem_detail_usage` 규칙 + `application.yml` 핀이 커버.
|
|
|
|
검증: `./gradlew check` (전 모듈 test + `verifyCleanArchitectureDependencies` + ArchUnit `CleanArchitectureTest`) **BUILD SUCCESSFUL**.
|
|
|
|
## 회고 / Lessons (재발 방지)
|
|
|
|
- Spring Boot 슬라이스 테스트(`@WebMvcTest` 등)의 **nested `@SpringBootConfiguration` 은 같은 패키지의 다른 테스트와 충돌**할 수 있다. 슬라이스가 자기만의 config 가 필요하면 (a) 전용 패키지로 분리하거나 (b) `@ContextConfiguration` 으로 명시 지정하거나 (c) 애초에 컨텍스트 없는 standalone MockMvc 를 쓴다.
|
|
- adapter 컴포넌트만으로 검증 가능한 계약은 **`app-bootstrap` 풀 컨텍스트가 아니라 해당 adapter 모듈의 standalone 테스트**가 더 견고하고 빠르다 (placeholder/security 부담 없음).
|
|
- 미커밋 상태에서 회귀 원인 격리는 `git stash -u` / `git stash push -- <files>` / 파일 `mv` 의 **비파괴 실험**이 가장 확실 — 정적 추론보다 한 번의 격리 실행이 빠르다.
|
|
|
|
## 관련
|
|
|
|
- [[raw/branch-notes/feature-operational-error-observability-foundation]]
|
|
- [[raw/interviews/operational-error-envelope-and-observability-foundation]]
|
|
- [[raw/blog-topics/operational-error-envelope-meta-category-migration-2026-06-01]]
|