Files
llm-wiki/wiki/publish-blog/optional-adapter-config-contract-blog.md
T

9.0 KiB

title, source_type, status, confidence, tags, related_projects, last_reviewed, canonical_sources, audience, target_publish, status_label
title source_type status confidence tags related_projects last_reviewed canonical_sources audience target_publish status_label
Optional Adapter를 설정 계약으로 다루기 blog draft unknown
blog
ca-tmpl
integration
spring-boot
externalized-config
component-scan
ca-tmpl
backend-engineer draft

Optional Adapter를 설정 계약으로 다루기

@ConditionalOnProperty 하나만 붙이면 될 것 같지만, "꺼져 있어도 안전한가"까지 물으면 이야기가 달라집니다.

TL;DR

  • Optional adapter(Redis, Kafka, Slack 등)는 "있으면 쓰고 없으면 말고"로 접근하기 쉽지만, 실제로는 env key drift, 조건 없이 등록되는 bean, disabled 상태인데 조용히 흘러가는 코드 경로 같은 실패 모드를 만듭니다.
  • ca-tmpl은 이걸 세 개의 층으로 나눠서 다룹니다: env registry gate, bean gating(@ConditionalOnProperty + 정적 검사), startup fail-fast.
  • @ConditionalOnProperty는 bean 등록 조건은 보여주지만, 런타임에 실제로 어떤 property가 적용됐는지까지 증명하지는 않습니다. 이 한계를 인정하는 게 이 글의 핵심입니다.
  • 모든 provider(Kafka, Redis, Slack 등)의 완성을 주장하지 않습니다. "켜지고 꺼지는 실패 모드를 계약으로 드러내기 시작했다"까지만 말할 수 있습니다.

1. Optional adapter는 왜 조용히 무너지는가

Optional adapter는 처음엔 꽤 편해 보입니다. Redis가 있으면 cache adapter를 켜고, Kafka가 있으면 message broker adapter를 켜고, Slack이나 이메일 provider는 필요할 때만 붙이면 되니까요.

문제는 **"꺼져 있어도 정말 안전한가?"**라는 질문입니다. 실제로는 이런 일들이 조용히 쌓입니다.

  • env key가 .env에는 있는데 application.yml에서는 안 쓰이고 있거나
  • optional adapter의 bean이 아무 조건 없이 그냥 등록되거나
  • adapter가 disabled 상태인데 application layer가 그 adapter 패키지를 직접 import하고 있거나

이런 상태가 쌓이면 설정은 **계약이 아니라 "대충 이런 분위기"**가 되어버립니다. 누군가 .env에 값을 하나 빼먹어도 아무도 모르고, 배포 후에야 터지는 식이죠.

ca-tmpl은 이 문제를 @ConditionalOnProperty 하나로 끝내지 않았습니다. env registry, .env drift gate, @ConfigurationProperties, optional adapter package isolation, @ConditionalOnProperty 정적 규칙, startup failure exception을 각각 나눠뒀습니다.


2. 세 개의 층으로 보기

전체 구조를 표로 먼저 보겠습니다.

잡는 문제 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

하나씩 보겠습니다.


3. ① 설정 파일들이 서로 어긋나지 않게 — Env Registry Gate

설정값은 코드 밖에 있지만, 실제로는 코드의 실행 경로를 바꿉니다. APP_CACHE_REDIS_ENABLED=true가 들어오면 Redis cache backend가 생기고, app.messaging.broker=kafka가 들어오면 Kafka broker bean이 등록되는 식입니다.

이런 설정을 문서로만 관리하면 drift가 생깁니다. .env에만 남아있는 key, application.yml에만 있는 placeholder, 어디에도 등록 안 된 APP_ key가 조금씩 쌓이는 거죠.

ca-tmpl은 이 drift를 Gradle task로 막습니다. verifyEnvKeyssrc/.env, application.yml, docs/registries/env-keys.yaml 세 파일을 함께 읽습니다.

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")
}

이 task는 세 가지를 검사합니다.

  • application.yml의 required placeholder가 .env에 없으면 → 실패
  • .env의 key가 어떤 placeholder에도 안 쓰이면 → 실패
  • APP_ 접두사를 가진 key가 env registry에 등록 안 돼 있으면 → 실패

즉 설정 문서와 실제 boot 설정이 따로 움직이지 않도록 빌드 단계에서 묶어버립니다.


4. ② Adapter가 켜지는 조건을 코드로 선언하기 — Bean Gating

Adapter activation은 Spring bean 조건으로 표현합니다. Redis cache backend는 app.cache.redis.enabled=true일 때만 등록됩니다.

@Bean
@ConditionalOnProperty(
    name = "app.cache.redis.enabled",
    havingValue = "true",
    matchIfMissing = false)
public CacheBackend redisCacheBackend(RedisClient redisClient) {
  return new RedisCacheStore(redisClient);
}

Kafka broker도 마찬가지입니다. 게다가 조건을 통과해도 설정값 자체가 비어있으면 즉시 실패하도록 되어 있습니다.

@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);
}

이 방식의 장점은, adapter 구현체가 중앙 router나 use case 코드를 직접 건드리지 않고도 "내가 활성화되는 조건"을 자기 config 안에 스스로 선언할 수 있다는 점입니다.

하지만 @ConditionalOnProperty만으로는 부족하다

여기서 중요한 인정이 필요합니다. ArchUnit은 런타임 property evaluation을 실행하지 않습니다. 즉 "지금 이 profile에서 이 bean이 실제로 켜져 있는가"를 증명하는 도구가 아니에요.

대신 ca-tmpl은 정적 분석으로 두 가지만 확인합니다.

  1. application layer가 optional adapter 패키지를 import하지 않는지
  2. optional adapter 패키지 안의 @Bean 메서드가 @ConditionalOnProperty를 갖고 있는지
static final ArchRule APPLICATION_DOES_NOT_DEPEND_ON_OPTIONAL_ADAPTERS =
    noClasses()
        .that()
        .resideInAPackage("..application..")
        .should()
        .dependOnClassesThat()
        .resideInAnyPackage(OPTIONAL_ADAPTER_PACKAGES);

이건 "현재 어떤 profile에서 bean이 켜졌는가"를 증명하는 게 아니라, disabled-default를 우회할 수 있는 코드 구조 자체를 막는 쪽입니다. 증명과 방지는 다른 문제이고, ca-tmpl이 하는 건 후자입니다.


5. ③ 꺼져 있는데 필요한 경로라면 빨리 실패하기 — Startup Fail-fast

세 번째 층은 startup 시점의 fail-fast입니다. required capability adapter가 꺼져 있거나 coordination bean이 없으면 RequiredAdapterDisabledException 계열의 startup failure로 드러납니다.

cache router나 messaging config 같은 중앙 binding 지점에서도, disabled backend로의 binding이 조용한 no-op으로 흘러가지 않도록 설계되어 있습니다.

여기서 skeleton이 지키려는 원칙은 이겁니다: "꺼져 있으면 아무 일도 하지 않는다"가 목표가 아니라, "꺼져 있는데 필요한 경로라면 빨리 실패한다"가 목표입니다. 조용히 무시되는 것과 시작하자마자 명확하게 실패하는 것은 운영 관점에서 완전히 다른 경험입니다.


6. 아직 말할 수 없는 것들

ca-tmpl에는 env registry/gate와 optional adapter guard가 구현되어 있고, ./gradlew check로 로컬 검증됐습니다. 여기까지는 분명하게 말할 수 있습니다.

하지만 모든 provider-specific adapter template가 완성됐다고 말하면 안 됩니다. Kafka, Redis, Slack, Google Email 같은 표면이 일부 존재하더라도, "모든 외부 provider 전환을 검증했다"는 주장은 이 프로젝트가 실제로 확인한 범위를 넘어섭니다.

그래서 이 글의 결론은 **"optional adapter를 완성했다"가 아니라, "optional adapter가 켜지고 꺼지는 실패 모드를 설정 계약으로 드러내기 시작했다"**입니다.


마무리

Optional adapter를 다루는 방식은 결국 "런타임에 신뢰할 수 있는 상태만 켜지게 하려면 무엇을 정적으로, 무엇을 시작 시점에 확인해야 하는가"의 문제입니다.

ca-tmpl은 이 질문에 세 개의 층으로 답했습니다 — 설정 파일 간의 drift를 빌드에서 막고, bean이 켜지는 조건을 코드에 선언하고 우회 경로를 정적으로 막고, 그래도 필요한 게 꺼져 있으면 시작 시점에 실패시키는 것. 이 셋 중 하나만 있었다면 여전히 구멍이 남았을 겁니다.