Files
llm-wiki/raw/errors/webmvctest-slice-configprops-enum-placeholder-no-default-2026-06-20.md

68 lines
3.9 KiB
Markdown

---
title: error / webmvctest-slice-configprops-enum-placeholder-no-default-2026-06-20
source_type: error-note
status: raw
related_branches: [feature-test-taxonomy-fixture-contract, feature-rate-limit-idempotency-contract]
related_projects: [ca-skeleton]
tags: [error, ca-skeleton, spring-boot, configuration-properties, webmvctest, env-placeholder, enum-binding]
created: 2026-06-20
status_label: resolved
---
# error: webmvctest-slice-configprops-enum-placeholder-no-default-2026-06-20
> Layer: `raw/errors/` — 작업 중 마주친 단일 실패·트러블슈팅 기록. 원본은 raw에 영구 보관한다.
## Parent / 부모
- [[raw/branch-notes/feature-test-taxonomy-fixture-contract]] — test-taxonomy 작업 중 `./gradlew test` 전체 스위트가 RED 인 것을 발견하면서 root-cause.
## 증상 / Symptom
`./gradlew :app-bootstrap:test` 전체 실행 시 `OperationalContractRuntimeTest` 2건 실패(나머지는 통과). 예외 체인:
```
IllegalStateException: Failed to load ApplicationContext (@WebMvcTest(CaSkeletonApplication.class))
└ UnsatisfiedDependencyException
└ ConfigurationPropertiesBindException
└ BindException
└ ConversionFailedException
└ IllegalArgumentException (LenientObjectToEnumConverterFactory.java:93)
```
상세 메시지:
```
Failed to bind properties under 'ca-skeleton.rate-limit.client-ip-mode'
to dev.caskeleton.adapter.web.ratelimit.RateLimitClientIpMode
Failed to convert String -> RateLimitClientIpMode for value [${APP_RATE_LIMIT_CLIENT_IP_MODE}]
```
## 근본 원인 / Root cause
`application.yml` 의 placeholder 가 **기본값 없이** 선언됨:
```yaml
ca-skeleton:
rate-limit:
client-ip-mode: ${APP_RATE_LIMIT_CLIENT_IP_MODE} # ← :default 없음
```
값은 `src/.env``APP_RATE_LIMIT_CLIENT_IP_MODE=remote-addr-only` 에만 존재. `bootRun` 은 working dir 가 `src/``.env` 를 읽지만, **`./gradlew test``.env` 를 안 읽는다**. 그래서 `@WebMvcTest(CaSkeletonApplication.class)` 슬라이스가 `@ConfigurationPropertiesScan` 으로 `RateLimitProperties` 를 eager 바인딩할 때 placeholder 가 미해석 리터럴 `${...}` 로 남고, enum(`REMOTE_ADDR_ONLY`/`FORWARDED_HEADERS_TRUSTED`) 변환에 실패 → context load 실패.
함정: `RateLimitSettings` record 의 compact constructor 에 `if (clientIpMode == null) clientIpMode = REMOTE_ADDR_ONLY;` null-default 가 있으나, **미해석 placeholder 는 null 이 아니라 non-null 쓰레기 문자열**이라 생성자 도달 전 변환 단계에서 터진다 → null-coalescing default 는 이 케이스를 못 막는다.
레지스트리(`docs/registries/env-keys.yaml`)는 이 키를 `required: false`, `default: remote-addr-only` 로 선언 — 즉 application.yml 이 레지스트리 의도와 어긋나 있었다(`${VAR}` = required 형식인데 레지스트리는 optional).
## 해결 / Fix
`application.yml` 에 레지스트리가 선언한 기본값을 인코딩:
```yaml
client-ip-mode: ${APP_RATE_LIMIT_CLIENT_IP_MODE:remote-addr-only}
```
이러면 `.env` 없이도 슬라이스 부팅, 그리고 `verifyEnvKeys`(“`${VAR}`=required, `${VAR:default}`=optional”)가 레지스트리 `required:false` 와 정합. 검증: `:app-bootstrap:test --tests '*OperationalContractRuntimeTest'` PASS + `verifyEnvKeys: OK`.
## 교훈 / Lesson
- enum/타입 `@ConfigurationProperties` 를 eager 바인딩하는 슬라이스 테스트(`@WebMvcTest(App.class)` 류)가 있으면, **그 키의 application.yml placeholder 는 반드시 `:default` 를 가져야** `.env` 없는 test/CI 에서 부팅된다.
- `required:false` + `default` 를 레지스트리에 적었다면 application.yml 도 `${VAR:default}` 로 맞춰야 한다(verifyEnvKeys 게이트와 정합).
- 미해석 placeholder 는 null 이 아니므로 record/생성자 null-default 로는 못 막는다.
- pre-existing 여부 입증법: `git stash push -u` 로 작업 전부 제거 → clean HEAD 에서 동일 실패 재현 → `git stash pop`.