--- title: Optional Adapter를 설정 계약으로 다루기 source_type: blog status: verified confidence: high tags: [blog, ca-tmpl, config, adapter, conditional-on-property] related_projects: [ca-tmpl] last_reviewed: 2026-07-03 canonical_sources: - wiki/projects/ca-tmpl/config-and-adapter-templates audience: backend-engineer target_publish: status_label: ready --- # Optional Adapter를 설정 계약으로 다루기 ## Parent / 부모 (필수) - 핵심 canonical: [[wiki/projects/ca-tmpl/config-and-adapter-templates]] - 관련 개념 문서: [[wiki/concepts/config-and-adapter-templates]] - 일반 config/adapter template 비교. 현재 이 글의 구현 사실 근거는 verified project canonical에 둔다. ## 타깃 독자 / Target reader - 독자 profile: Spring Boot optional adapter와 env-driven 설정을 skeleton에 넣으려는 백엔드 엔지니어. - 이미 안다고 가정하는 것: `@ConfigurationProperties`, `@ConditionalOnProperty`, env var. - 처음 듣는다고 가정하는 것: adapter 추가를 설정값 하나가 아니라 registry, bean gating, static rule, startup fail-fast가 맞물린 계약으로 보는 방식. ## 도입 / Hook Optional adapter는 처음에는 편해 보입니다. Redis가 있으면 cache adapter를 켜고, Kafka가 있으면 message broker adapter를 켜고, Slack이나 email provider는 필요할 때만 붙이면 됩니다. 문제는 “꺼져 있어도 안전한가”입니다. env key가 `.env`에는 있는데 `application.yml`에서 안 쓰이거나, optional adapter bean이 조건 없이 등록되거나, disabled 상태인데 application layer가 adapter package를 직접 import하면 설정은 계약이 아니라 분위기가 됩니다. ca-tmpl은 이 문제를 `@ConditionalOnProperty` 하나로 끝내지 않았습니다. `APP_` env registry와 `.env` drift gate, `@ConfigurationProperties` settings, optional adapter package isolation, `@ConditionalOnProperty` annotation rule, startup failure exception을 나눠 두었습니다. 이 글은 optional adapter를 “있으면 쓰고 없으면 말고”가 아니라 “켜지는 조건과 실패 방식이 검증되는 계약”으로 만든 이유를 정리합니다. ## 본문 outline / Body outline 1. optional adapter의 흔한 실패 - env drift, ungated bean, hidden direct import. 2. 설정은 runtime contract다 - `.env`, `application.yml`, env registry를 같이 검증한다. 3. `@ConditionalOnProperty`의 역할과 한계 - bean 등록 조건은 보지만 runtime activation 전체를 증명하지는 않는다. 4. static isolation과 startup fail-fast - disabled adapter가 조용히 섞이지 않게 한다. 5. 구현된 것과 provider-specific template의 남은 범위를 분리한다. ## 본문 / Body 설정값은 코드 밖에 있지만, 실제로는 코드의 실행 경로를 바꿉니다. `APP_CACHE_REDIS_ENABLED=true`가 들어오면 Redis cache backend가 생기고, `app.messaging.broker=kafka`가 들어오면 Kafka broker bean이 등록됩니다. 이런 설정을 문서로만 관리하면 drift가 생깁니다. `.env`에만 남은 key, `application.yml`에만 있는 placeholder, registry에 등록되지 않은 `APP_` key가 조금씩 쌓입니다. ca-tmpl은 이 drift를 Gradle task로 막습니다. `verifyEnvKeys`는 `src/.env`, `application.yml`, `docs/registries/env-keys.yaml`을 함께 읽습니다. `application.yml`의 required placeholder가 `.env`에 없으면 실패하고, `.env` key가 어떤 placeholder에도 쓰이지 않으면 실패합니다. 또 `APP_` key는 env registry에 등록되어 있어야 합니다. 즉 설정 문서와 실제 boot 설정이 따로 움직이지 않게 빌드 단계에서 묶습니다. adapter activation은 Layer 1에서 Spring bean 조건으로 표현합니다. Redis cache backend는 `app.cache.redis.enabled=true`일 때만 `CacheBackend` bean을 제공합니다. Kafka broker도 `app.messaging.broker=kafka`일 때만 `MessageBroker` bean을 등록합니다. 이 방식의 장점은 adapter 구현이 중앙 router나 use case를 직접 수정하지 않아도 “내가 활성화되는 조건”을 자기 config에 선언할 수 있다는 점입니다. 하지만 `@ConditionalOnProperty`만으로는 충분하지 않습니다. ArchUnit은 런타임 property evaluation을 실행하지 않습니다. 대신 ca-tmpl은 정적 분석으로 두 가지를 봅니다. application layer가 optional adapter package를 import하지 않는지, optional adapter package 안의 `@Bean` method가 `@ConditionalOnProperty`를 갖고 있는지입니다. 이건 “현재 어떤 profile에서 bean이 켜졌는가”를 증명하는 것이 아니라, disabled-default를 우회할 수 있는 코드 구조를 막는 쪽입니다. Layer 3는 startup fail-fast입니다. required capability adapter가 꺼져 있거나 coordination bean이 없으면 `RequiredAdapterDisabledException` 계열 startup failure로 드러납니다. cache router나 messaging config처럼 중앙 binding 지점에서도 disabled backend binding이 조용한 no-op으로 흘러가지 않도록 설계합니다. skeleton에서 중요한 것은 “꺼져 있으면 아무 일도 하지 않는다”가 아니라, “꺼져 있는데 필요한 경로라면 빨리 실패한다”입니다. 이 결정을 그림으로 보면 세 층입니다. | 층 | 잡는 문제 | ca-tmpl 구현 범위 | |---|---|---| | Env registry gate | `.env` / `application.yml` / registry drift | `verifyEnvKeys` | | Bean gating | optional adapter bean이 조건 없이 등록되는 문제 | `@ConditionalOnProperty`, `DisabledAdapterArchitectureTest` | | Startup/runtime fail-fast | required adapter가 disabled인데 조용히 진행되는 문제 | startup failure exception, router/config guard | 이 글에서 조심해야 할 경계도 있습니다. ca-tmpl에는 env registry/gate와 optional adapter guard가 구현되어 있고 `./gradlew check`로 로컬 검증됐습니다. 반면 모든 provider-specific adapter template가 완성됐다고 말하면 안 됩니다. Kafka, Redis, Slack, Google Email 같은 표면이 일부 존재하더라도, “모든 외부 provider 전환을 검증했다”는 주장은 project canonical 범위를 넘습니다. 이 글의 결론은 “optional adapter를 완성했다”가 아니라 “optional adapter가 켜지고 꺼지는 실패 모드를 설정 계약으로 드러내기 시작했다”입니다. ## 코드 예제 / Code samples (있다면) ```groovy // 출처: [[wiki/projects/ca-tmpl/config-and-adapter-templates]] // 실제 파일: src/build.gradle, ca-tmpl @f6fbd4e196b4 tasks.register('verifyEnvKeys') { description = 'Verifies src/.env covers application.yml placeholders and every APP_ key is registered.' File envFile = file("${rootProject.projectDir}/.env") File appYml = file("${rootProject.projectDir}/app-bootstrap/src/main/resources/application.yml") File registryFile = file("${rootProject.projectDir}/../docs/registries/env-keys.yaml") } ``` ```java // 출처: [[wiki/projects/ca-tmpl/config-and-adapter-templates]] // 실제 파일: adapter-outbound/.../RedisCacheAdapterConfig.java, ca-tmpl @f6fbd4e196b4 @Bean @ConditionalOnProperty( name = "app.cache.redis.enabled", havingValue = "true", matchIfMissing = false) public CacheBackend redisCacheBackend(RedisClient redisClient) { return new RedisCacheStore(redisClient); } ``` ```java // 출처: [[wiki/projects/ca-tmpl/config-and-adapter-templates]] // 실제 파일: adapter-outbound/.../KafkaAdapterConfig.java, ca-tmpl @f6fbd4e196b4 @Bean @ConditionalOnProperty(name = "app.messaging.broker", havingValue = "kafka") public MessageBroker kafkaMessageBroker(KafkaSender sender, KafkaAdapterSettings settings) { if (settings.brokers().isEmpty()) { throw new IllegalStateException("app.messaging.broker=kafka requires a non-empty broker list"); } return new KafkaMessageBroker(sender); } ``` ```java // 출처: [[wiki/projects/ca-tmpl/config-and-adapter-templates]] // 실제 파일: app-bootstrap/.../DisabledAdapterArchitectureTest.java, ca-tmpl @f6fbd4e196b4 static final ArchRule APPLICATION_DOES_NOT_DEPEND_ON_OPTIONAL_ADAPTERS = noClasses() .that() .resideInAPackage("..application..") .should() .dependOnClassesThat() .resideInAnyPackage(OPTIONAL_ADAPTER_PACKAGES); ``` ## Sources / 근거 (canonical 인용 필수, derived layer 의무) - [[wiki/projects/ca-tmpl/config-and-adapter-templates]] - 이 글의 1차 canonical. env registry/gate, `@ConfigurationProperties`, optional adapter `@ConditionalOnProperty`, ArchUnit static guard, startup fail-fast, local verification, provider별 미완성 범위를 따른다. - [[wiki/concepts/config-and-adapter-templates]] - 관련 개념 문서. Spring Cloud Config, ConfigMap reload, Consul, Parameter Store, feature flag SaaS 같은 대안 비교 배경으로만 둔다. ## 사실 vs 의견 / Fact vs opinion 구분 - 사실: ca-tmpl에는 `verifyEnvKeys`, `docs/registries/env-keys.yaml`, 여러 `@ConfigurationProperties` settings, optional adapter `@ConditionalOnProperty` config, `DisabledAdapterArchitectureTest`, startup failure exception이 존재한다. 근거: [[wiki/projects/ca-tmpl/config-and-adapter-templates]] - 사실: `./gradlew check`가 2026-07-02 기준 통과했고, `verifyEnvKeys`와 optional adapter 관련 검증이 local/dev 범위에 포함된다. 근거: [[wiki/projects/ca-tmpl/config-and-adapter-templates]] - 사실: 모든 provider-specific adapter template와 모든 disabled runtime path가 완성됐다고 말하지 않는다. 근거: [[wiki/projects/ca-tmpl/config-and-adapter-templates]] - 의견: optional adapter는 silent noop보다 fail-fast 계약으로 두는 편이 skeleton 학습과 장애 분석에 더 유리하다. - 알지 못하는 것: 실제 환경에서 adapter on/off를 전환한 운영 경험, provider SDK별 production tuning. ## 답할 수 있는 범위 / Answer boundary - 자신 있게 답할 수 있는 후속 질문: - env key drift를 왜 build gate로 잡는가? - `@ConditionalOnProperty`는 어떤 문제를 해결하고 어떤 문제를 해결하지 못하는가? - optional adapter static isolation과 startup fail-fast가 왜 둘 다 필요한가? - 다음 글로 넘길 부분: - 특정 provider SDK별 timeout/retry/auth 설정. - runtime reload나 feature flag SaaS가 필요한 제품 단계. - 실제 환경에서 adapter toggle을 운영한 경험. ## 게시 체크리스트 / Publish checklist - [x] 모든 사실 주장에 canonical 링크 있음 - [x] 사실 vs 의견 분리 명시됨 - [x] 금지 마케팅 표현 없음 - [x] 코드 예제 출처 명시 - [x] 타깃 독자 가정과 톤 일치 - [x] `/lint` 통과 - [ ] 게시 URL 기록 (게시 후): ## Related / 관련 - 후속 글 후보: [[wiki/blog/ca-tmpl-runtime-container-health-migration-2026-07-02]] - 후속 글 후보: [[wiki/blog/ca-tmpl-data-layer-persistence-cache-outbound-2026-07-02]]