--- 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`.