Files
llm-wiki/raw/branch-notes/feature-integration-adapter-templates.md
T

413 lines
47 KiB
Markdown

---
title: branch / feature-integration-adapter-templates
source_type: branch-note
status: raw
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-009
kind: project-work-item
project: ca-skeleton-operational-contract
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-009
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-EVENT-BROKER-001@1]
refines: []
overrides: []
depends_on: []
contract_packet: 1
branch: feature-integration-adapter-templates
parent_branch:
related_projects: [ca-skeleton]
governing_docs:
- "[[raw/project-notes/ca-skeleton-operational-contract]]"
tags: [branch, ca-skeleton, adapter, kafka, redis, notification]
created: 2026-05-21
target_merge:
status_label: in-progress
contract_packet_sha256: 1442f6124a72b8a5b62b10f02f014af447a26ecf28849e0baa7ca41a87de8703
---
# branch: feature-integration-adapter-templates
> Layer: `raw/branch-notes/` — Kafka/Redis/Slack/Google Email 같은 선택형 adapter template와 실패 계약을 정의합니다.
<!-- section-id: branch-parent -->
## 부모 (필수)
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
> ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT 역할. 본 feature branch 는 project-note 의 운영 계약 중 해당 영역 (§<관련 섹션>) 의 결정/근거/금지 사항을 정제한다.
<!-- GENERATED: branch-contract:start -->
<!-- section-id: branch-contract-packet -->
## 브랜치 계약 패킷
- **생성 시 프로젝트 개정**: `1`
- **패킷 스키마**: `contract_packet: 1`
- **완료 조건**: optional adapter template가 core broker abstraction을 침범하지 않는다
<!-- section-id: inherited-project-decisions -->
### 상속한 프로젝트 결정
| Decision Ref | Project Summary | Branch Application | Source |
|---|---|---|---|
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-EVENT-BROKER-001@1` | core는 broker-agnostic outbox를 제공하고 Kafka는 optional adapter로 둔다 | Kafka를 포함한 optional adapter의 활성화·격리 template에 적용한다 | [[raw/project-notes/ca-skeleton-operational-contract]] |
<!-- section-id: branch-local-decisions -->
### 브랜치 지역 결정
| Decision ID | Decision | Relation | Supporting Claims | Status |
|---|---|---|---|---|
| D1 | optional adapter를 disabled-default module로 제공한다 | `local` | [[raw/project-notes/ca-skeleton-operational-contract]] | `proposed` |
| D2 | ConditionalOnProperty로 bean 등록을 제어한다 | `local` | [[raw/project-notes/ca-skeleton-operational-contract]] | `proposed` |
| D3 | ArchUnit으로 application의 disabled adapter 의존을 검사한다 | `local` | [[raw/project-notes/ca-skeleton-operational-contract]] | `proposed` |
| D4 | disabled adapter 호출은 fail-fast 처리한다 | `local` | [[raw/project-notes/ca-skeleton-operational-contract]] | `proposed` |
| D5 | Java SPI 대안을 채택하지 않는다 | `local` | [[raw/project-notes/ca-skeleton-operational-contract]] | `proposed` |
| D6 | Profile 기반 adapter toggle을 채택하지 않는다 | `local` | [[raw/project-notes/ca-skeleton-operational-contract]] | `proposed` |
| D7 | runtime feature flag와 startup adapter toggle을 분리한다 | `local` | [[raw/project-notes/ca-skeleton-operational-contract]] | `proposed` |
| D8 | plugin architecture는 template 범위에서 제외한다 | `local` | [[raw/project-notes/ca-skeleton-operational-contract]] | `proposed` |
| D9 | required/optional 분류 owner와 fail-open/closed 정책 owner를 분리한다 | `local` | [[raw/project-notes/ca-skeleton-operational-contract]] | `proposed` |
<!-- section-id: declared-overrides -->
### 선언한 예외
| Override ID | Overrides | Reason | Approval | Status |
|---|---|---|---|---|
없음.
<!-- GENERATED: branch-contract:end -->
<!-- section-id: branch-goal -->
## 목표
선택형 adapter를 모두 기본 dependency로 탑재하면 skeleton이 무거워집니다. 대신 adapter별 실패 계약과 optional template를 제공하여 붙였을 때 같은 방식으로 실패하고 관측되게 합니다.
- 이슈:
- PR:
<!-- section-id: branch-scope -->
## 범위
### 포함 범위
- Kafka adapter contract 문서.
- Redis adapter contract 문서.
- Slack notification adapter contract 문서.
- Google Email adapter contract 문서.
- common adapter logging/error contract.
- optional module 또는 sample 분리 기준.
### 제외 범위
- 실제 Kafka/Redis/Slack/Google Email 운영 인프라 구성.
- provider-specific business workflow.
- 모든 adapter 기본 활성화.
## 근거 (필수, 최소 1개+)
> 본 branch의 결정 근거. 상세 비교는 §외부 근거 / 대안 조사 (있다면) 참조.
| Source | 정당화하는 결정 |
|---|---|
| [[raw/official-docs/adapter-spring-boot-autoconfig-custom-starter]] | Spring Boot AutoConfiguration + `@ConditionalOnProperty` / `@ConditionalOnBooleanProperty` (3 |
| [[raw/official-docs/adapter-java-spi-serviceloader]] | `META-INF/services`; on/off 표현 불가 + DI 미통합 + default constructor 강제 |
| [[raw/company-tech-blogs/adapter-togglz-ff4j-feature-toggle-library]] | runtime toggle; adapter on/off가 아닌 runtime branching 도구라 시맨틱 차이 |
| [[raw/official-docs/archunit-conditional-on-property-3-layer-pattern]] | 참조 |
## 외부 근거 / 대안 조사 (2026-05-22 — Group G-I: Integration Adapter Templates)
본 branch의 optional module + Spring `@ConditionalOnProperty` + ArchUnit 3-layer detection + `AdapterDisabledException` fail-fast 결정에 대한 외부 source.
- **채택 결정 (Spring Boot AutoConfiguration + `@ConditionalOnProperty`)**:
- [[raw/official-docs/adapter-spring-boot-autoconfig-custom-starter]] — Spring Boot AutoConfiguration + `@ConditionalOnProperty` / `@ConditionalOnBooleanProperty` (3.5.0+) + `AutoConfiguration.imports`
- **검토한 대안**:
- **대안 1: Java SPI / ServiceLoader** — [[raw/official-docs/adapter-java-spi-serviceloader]] (`META-INF/services`; on/off 표현 불가 + DI 미통합 + default constructor 강제)
- **대안 2: Spring `@Profile` based** — boolean 시맨틱 부재, profile 조합 복잡도 증가
- **대안 3: Feature flag library (FF4J / Togglz)** — [[raw/company-tech-blogs/adapter-togglz-ff4j-feature-toggle-library]] (runtime toggle; adapter on/off가 아닌 runtime branching 도구라 시맨틱 차이)
- **대안 4: Plugin architecture (OSGi-style)** — Java 진영 deprecated, ca-tmpl scope 외
- **비교 핵심**: Spring `@ConditionalOnProperty`는 Layer 1만 공식 cover. Layer 2(ArchUnit `noClasses().that().resideInAPackage("..application..").should().dependOnClassesThat().resideInAPackage("..adapters.{disabled}..")`)와 Layer 3(`AdapterDisabledException`)는 ca-tmpl 자체 contract. **보강 후보**: ArchUnit source 별도 필요. SPI는 on/off 표현 불가 + DI 미통합으로 ca-tmpl 결정과 정면 충돌. Togglz/FF4J는 startup-time toggle이 아닌 runtime branching이라 시맨틱 다름 — feature flag service와 adapter on/off는 분리 영역.
**후속 보강 (2026-05-22)**: ArchUnit Layer 2의 정적 검사 가능 범위 평가. [[raw/official-docs/archunit-conditional-on-property-3-layer-pattern]] 참조.
## TODO
> TODO drained 2026-05-22 — Kafka/Redis/Slack/Google Email adapter 별 정책, common logging/error contract, optional module vs sample 분리는 "결정 사항" / "Adapter Template Defaults" / "테스트 계약" 표에 반영됨. 잔존 TODO 없음.
## Work Item Contract
각 TODO는 아래 판정 단위로 재작성되어야 canonical 승급 가능합니다. TODO가 단순히 `기준 작성`으로 남아 있으면 이 branch는 완료로 보지 않습니다.
| field | required | rule |
| --- | --- | --- |
| Decision | yes | 구현자가 선택해야 하는 기본값 |
| Allowed | yes | 허용되는 예외와 조건 |
| Forbidden | yes | 절대 금지되는 구현/문서 상태 |
| Required registry update | conditional | error/env/header/log/metric/capability 변경 시 필수 |
| Required contract test | yes | 계약 위반 시 실패해야 하는 테스트 |
| Failure condition | yes | review/build에서 실패로 판정할 상태 |
| Canonical extraction target | yes | `wiki/projects` 승급 위치 |
## 진행 중 메모
- cache miss는 장애가 아닙니다.
- notification failure는 core use case 실패 여부를 adapter별로 명시해야 합니다.
## 결정 사항 (decisions)
- 2026-05-21: 선택형 adapter는 기본 탑재가 아니라 optional template 기준.
- 2026-05-22: Kafka/Redis/Slack/Google Email은 기본 dependency가 아니며 disabled env가 기본.
- 2026-05-22: Kafka retry/DLQ는 background-job branch vocabulary를 소비하고, outbox core는 Kafka를 강제하지 않음.
- 2026-05-22: adapter 배포 형태는 optional module 기본, sample source set은 문서/fixture 전용일 때만 허용.
- 2026-05-22: required vs optional dependency 분류 SSOT는 runtime-health-lifecycle-contract. 본 branch는 각 adapter의 fail-open/closed 정책과 enable/disable 메커니즘 owns. 두 branch는 양방향 cross-link.
- 2026-05-22: disabled adapter detection 메커니즘 = 2-layer 검출.
- Layer 1 (startup, runtime): Spring `@ConditionalOnProperty(name="app.adapter.{adapterName}.enabled", havingValue="true")` 적용. flag false 시 adapter bean 등록 X. ApplicationContext에 해당 bean 0개 verify.
- Layer 2 (build, static): archetype smoke test `DisabledAdapterArchitectureTest`가 application 시작 시 `APP_ADAPTER_{ADAPTER}_ENABLED=false`인 상태에서 해당 adapter package의 class import가 use case path에 등장하면 fail. 측정 방법: ArchUnit `noClasses().that().resideInAPackage("..application..").should().dependOnClassesThat().resideInAPackage("..adapters.{disabled-adapter}..")` (when disabled).
- Layer 3 (runtime, fail-fast): disabled adapter의 use case path가 invoke되면 `AdapterDisabledException` throw + log `error.code=REQUIRED_ADAPTER_DISABLED` (`migration-startup`의 startup validation과 동일).
consumer branches는 본 결정을 consume only. adapter 추가 시 `env-keys.yaml``APP_ADAPTER_{NAME}_ENABLED` row 추가 필수.
- 2026-05-22: ArchUnit Layer 2의 정적 검사는 'adapter 후보 클래스가 `@ConditionalOnProperty` annotation을 가짐'까지만 보장. runtime active 여부는 Layer 3 (`AdapterDisabledException`)에 위임. (status: needs-confirmation, fitness function 도입 결정 코드 단계 보류)
## Adapter Template Defaults
| adapter | default state | owner contract |
| --- | --- | --- |
| Kafka | disabled optional module | outbox + background retry/DLQ |
| Redis | disabled optional module | cache consistency |
| Slack | disabled optional module | notification failure policy |
| Google Email | disabled optional module | notification failure policy |
| common | dependency log/error mapper required | foundation registry |
## 결정-근거 매핑
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시. company-tech-blog 증거는 `company-case-study` 로 표기하며 공식 best practice 로 승격하지 않음.
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|---|---|---|---|---|---|
| D1 | optional adapter (Kafka/Redis/Slack/Google Email) 는 기본 dependency 아님, disabled env 기본, optional module 형태로 배포 | adapter 가 *선택형* (core use case 가 강제하지 않음) 일 때 이 결정. core 가 강제하는 required adapter (예: DB) 면 disabled-default 적용 안 함 → required 분류는 `runtime-health-lifecycle-contract` 가 owns (D9) | `raw/official-docs/adapter-spring-boot-autoconfig-custom-starter.md#SBAC-C1`, `raw/official-docs/adapter-spring-boot-autoconfig-custom-starter.md#SBAC-C2`, `raw/official-docs/adapter-spring-boot-autoconfig-custom-starter.md#SBAC-C5` | `official-vendor-doc` (Spring Boot AutoConfiguration + namespace 분리 공식 권고) | optional module vs sample source set 의 운영 구분 (배포 artifact 관리 부담) |
| D2 | Layer 1 — Spring `@ConditionalOnProperty(name="app.{domain}.{adapter}.enabled", havingValue="true", matchIfMissing=false)` adapter bean 등록 제어, ApplicationContext bean count = 0 검증 | startup-time 활성/비활성을 boolean property 로 표현할 때 이 결정. runtime 중 동적 toggle (gradual rollout) 이 필요하면 feature flag 영역 (D7 배제 근거 참조) — 다른 메커니즘 | `raw/official-docs/adapter-spring-boot-autoconfig-custom-starter.md#SBAC-C1`, `raw/official-docs/adapter-spring-boot-autoconfig-custom-starter.md#SBAC-C3`, `raw/official-docs/adapter-spring-boot-autoconfig-custom-starter.md#SBAC-C4` | `official-vendor-doc + official-reference` (Spring 공식 — Boolean 시맨틱은 3.5.0+ `@ConditionalOnBooleanProperty` 권장) | 3.5.0 미만 baseline 이면 `havingValue="true"` 명시 + `matchIfMissing=false` 정확 표현 필요. ApplicationContext bean count 검증 패턴 자체는 Spring 공식 verification 패턴 아님 (`SBAC-C1~C5` Usage Boundaries 참조). **ENV_KEY_DRIFT**: property 는 `app.adapter.{name}` 이 아니라 도메인 namespace (`app.cache.redis`/`app.messaging.kafka`/`app.notification.{slack,google-email}`) — §Audit & Findings A1 |
| D3 | Layer 2 — ArchUnit `noClasses().that().resideInAPackage("..application..").should().dependOnClassesThat().resideInAPackage("..adapters.{disabled}..")` 정적 검사 | 빌드 시점에 disabled adapter package 가 application layer import 경로에 등장하면 fail 시키고 싶을 때 이 결정. 단 'disabled' 는 runtime config 평가라 정적 검사로 완전 보장 불가 → runtime 보장은 Layer 3 (D4) | `raw/official-docs/archunit-conditional-on-property-3-layer-pattern.md#AUCP-C1`, `raw/official-docs/archunit-conditional-on-property-3-layer-pattern.md#AUCP-C4`, `raw/official-docs/archunit-conditional-on-property-3-layer-pattern.md#AUCP-C5` | `official-vendor-doc + engineering-blog` (AUCP-C5 는 Building Evolutionary Architectures 서적 — engineering-blog 강도) | ArchUnit Layer 2 의 정적 검사는 'adapter 후보 클래스가 `@ConditionalOnProperty` annotation 을 가짐' 까지만 보장 — runtime active 여부는 Layer 3 위임 (branch 자체 명시) |
| D4 | Layer 3 — disabled adapter 의 use case path 가 invoke 되면 `AdapterDisabledException` throw + fail-fast (silent no-op / timeout 대기 금지) | Layer 1(bean 미등록)·Layer 2(정적) 를 우회해 disabled adapter 가 runtime 에 실제 호출되는 경우의 *최후 방어선*. 정상 경로는 Layer 1 에서 bean 자체가 없어 호출 불가 | UNSUPPORTED_DECISION (cited official-doc 중 fail-fast adapter exception 패턴 직접 인용 없음 — ca-tmpl 자체 contract). **error code 재사용은 미정**`REQUIRED_ADAPTER_DISABLED``feature-migration-startup-contract` owns + startup-exit(72) 시맨틱 → runtime 재사용 적정성 검토 필요 (§Audit & Findings A2) | n/a | [[raw/branch-notes/feature-migration-startup-contract]] 와 cross-link 필요 (startup validation 의 동등 패턴). runtime 전용 error code 신규 제안 여부 미결 |
| D5 | (대안 비교) Java SPI / ServiceLoader 배제 — on/off 표현 불가 + DI 미통합 + default constructor 강제 | N/A (배제된 대안 — 채택된 D2 의 반례) | `raw/official-docs/adapter-java-spi-serviceloader.md#SPI-C1`, `raw/official-docs/adapter-java-spi-serviceloader.md#SPI-C2`, `raw/official-docs/adapter-java-spi-serviceloader.md#SPI-C3`, `raw/official-docs/adapter-java-spi-serviceloader.md#SPI-C5` | `official-vendor-doc` (Oracle Java Tutorial — classpath 존재 = 활성, property 게이팅 부재) | JPMS (Java 9+) `provides...with...` + Java 9+ `provider()` static method 통합 시맨틱은 본 SPI source 범위 밖 |
| D6 | (대안 비교) Spring `@Profile` 배제 — boolean 시맨틱 부재, 다중 활성/비활성 표현 복잡 | N/A (배제된 대안 — 채택된 D2 의 반례) | UNSUPPORTED_DECISION (cited raw 중 `@Profile` vs `@ConditionalOnProperty` 정확 비교 source 부재 — [[raw/official-docs/adapter-spring-boot-autoconfig-custom-starter]] 의 Usage Boundaries 가 "정확한 우선순위·결합 시맨틱 미증명" 명시). trade-off: 배제 사유는 'profile 은 환경 묶음용, adapter on/off 는 직교 축' 이라는 설계 판단 — 강한 외부 인용 없이 채택 가능 | n/a | Spring `@Profile` Javadoc 별도 fetch 필요 (rejected alt — depth-blocking 아님) |
| D7 | (대안 비교) Feature flag library (FF4J / Togglz) 배제 — runtime branching 도구, adapter on/off 와 시맨틱 차이 | startup-time on/off 면 D2. runtime gradual rollout / A-B 가 필요하면 feature flag 가 더 적합 — 두 영역 분리 (이 branch scope 밖) | `raw/company-tech-blogs/adapter-togglz-ff4j-feature-toggle-library.md#TOGGLZ-FF4J-C1`, `raw/company-tech-blogs/adapter-togglz-ff4j-feature-toggle-library.md#TOGGLZ-FF4J-C2`, `raw/company-tech-blogs/adapter-togglz-ff4j-feature-toggle-library.md#TOGGLZ-FF4J-C3`, `raw/company-tech-blogs/adapter-togglz-ff4j-feature-toggle-library.md#TOGGLZ-FF4J-C5` | `company-case-study` (vendor 공식 페이지 — best practice 승격 금지) | runtime toggle 자체가 adapter 비활성보다 더 적합한 시나리오 (예: gradual rollout) 가 ca-tmpl 에 등장할 가능성 — feature flag 와 adapter on/off 의 분리 영역 명시 필요 |
| D8 | (대안 비교) Plugin architecture (OSGi-style) 배제 — Java 진영 deprecated, ca-tmpl scope 외 | N/A (배제된 대안 — 채택된 D2 의 반례) | UNSUPPORTED_DECISION (OSGi deprecation 의 1차 official source 미인용 — cited raw 에 OSGi 직접 source 없음). trade-off: 배제 사유는 'classpath modular plugin 은 ca-tmpl 단일 배포 모델과 불일치' 라는 scope 판단 — 외부 인용 없이 채택 가능 | n/a | Eclipse Foundation OSGi 또는 JBoss Modules official status source 보강 필요 (rejected alt — depth-blocking 아님) |
| D9 | required vs optional dependency 분류 SSOT 는 `runtime-health-lifecycle-contract`, 본 branch 는 fail-open/closed 정책 owner | adapter 가 *required* (없으면 app 못 뜸) 인지 *optional* 인지 분류는 D9 가 위임받은 SSOT 가 결정. 본 branch 는 각 optional adapter 가 *없을 때* 어떻게 실패/degrade 하는지(fail-open vs fail-closed) 만 owns | UNSUPPORTED_DECISION (분리 자체는 ca-tmpl 자체 contract — 두 branch 간 cross-link 가정) | n/a | 양방향 cross-link 확인 + runtime-health branch 의 Decision Evidence Map 와 정합성 검증 |
## 구현 가이드
> *결정 (Decisions)* 이 "*무엇*" 이라면, 본 §는 "*어디에 어떻게*" 의 사전 명세. 다음 구현자가 되묻지 않고 코드를 작성할 수 있는 수준.
>
> **구현 현황 (2026-06-09 ca-tmpl `src/` grep 결과)**: kafka/redis/slack/email adapter 모듈은 `src/` 에 **존재하지 않음** (현존 adapter 모듈 = `adapter-identifier`·`adapter-outbound`·`adapter-persistence`·`adapter-web`). `AdapterDisabledException`·`@ConditionalOnProperty` adapter wiring 도 코드 부재 → 본 § 의 Java 측 명세는 **전량 `planned`**. **유일하게 landed 된 것은 `docs/registries/env-keys.yaml` 의 enable 키 5개** (documented-only — registry row 만 존재).
>
> **구현 완료 (2026-06-09, branch `feature/integration-adapter-templates`)**: 위 `planned` 항목 **전량 구현 + locally-verified**. 패키징 결정: 신규 Gradle 모듈이 아니라 **기존 `adapter-outbound` 모듈의 `cache`/`messaging`/`notification` 패키지에 template 으로 landing** (빈 package + `.gitkeep` 가 이미 그 용도로 존재했고, Gradle matrix·ArchUnit 가 `..adapter.outbound..` 를 이미 커버하므로 신규 모듈 오버헤드 회피). heavy SDK(spring-kafka/lettuce/slack/mail) 미추가 — 각 adapter 는 `KafkaSender`/`RedisClient`/`SlackClient`/`GoogleEmailClient` **integration seam(interface)** 만 제공하고 실제 client 는 fork 한 프로젝트가 구현 (§목표/WHY "skeleton 경량 유지"). landed:
> - shared-contract: `OperationalError.ADAPTER_DISABLED`(INTERNAL/500/retryable=false) + `AdapterDisabledException` (A2 해소 — startup `REQUIRED_ADAPTER_DISABLED` 재사용 안 함, runtime 전용 신규 코드 owner=본 branch).
> - adapter-outbound: `support/`(OutboundCorrelation, OutboundDependencyLogger, OutboundSupportConfig) + adapter 4종 = port + seam + fail-open 구현 + disabled sentinel + `@ConditionalOnProperty` config (+ Kafka 는 `KafkaAdapterSettings` brokers 검증). build.gradle 에 `spring-boot-autoconfigure`+`slf4j-api` 추가.
> - adapter-web: `GlobalExceptionHandler` 가 `AdapterDisabledException`→`ADAPTER_DISABLED` 매핑.
> - app-bootstrap: `DisabledAdapterArchitectureTest`(Layer 2: 격리 + `@Bean` gating) 신규, `CleanArchitectureTest` B7 rule 을 `@Configuration` factory 제외로 scoping, `application.yml` `app.*` block.
> - registries/env: `error-codes.yaml` ADAPTER_DISABLED row, `src/.env` 5개 키.
> - 검증: `:shared-contract:test`·`:adapter-outbound:test`·`:adapter-web:test`·`:app-bootstrap:test`·`verifyCleanArchitectureDependencies`·`verifyEnvKeys`·`verifyPublicPathSnapshot` 모두 PASS. ca-architect-sentinel PASS.
### 1. Adapter enable/disable env 키 계약 (FACT — env-keys.yaml landed)
> **Trace**: D1 (disabled-default optional) + D2 (Layer 1 boolean property). Supporting: `SBAC-C1`/`SBAC-C2`/`SBAC-C5`.
> **증거 등급**: `documented-only` (env-keys.yaml row 존재, Java adapter 코드 부재).
>
> - **UNSUPPORTED_IMPL_DECISION**: 없음 — 아래 키·기본값·validation·required_test 는 모두 `ca-tmpl/docs/registries/env-keys.yaml` 의 *기존 값* 재사용 (invent 아님).
| adapter | env key (registry SSOT) | Spring property | default | validation | required_test (registry) | owner_branch |
|---|---|---|---|---|---|---|
| Redis | `APP_CACHE_REDIS_ENABLED` | `app.cache.redis.enabled` | `false` | `boolean_strict` | `adapter-contract:redis-disabled-default` | feature-integration-adapter-templates |
| Kafka | `APP_MESSAGING_KAFKA_ENABLED` | `app.messaging.kafka.enabled` | `false` | `boolean_strict` | `adapter-contract:kafka-disabled-default` | feature-integration-adapter-templates |
| Kafka brokers | `APP_MESSAGING_KAFKA_BROKERS` | `app.messaging.kafka.brokers` | `null` | `csv_of_host_port_when_kafka_enabled` | `adapter-contract:kafka-brokers-when-enabled` | feature-integration-adapter-templates |
| Slack | `APP_NOTIFICATION_SLACK_ENABLED` | `app.notification.slack.enabled` | `false` | `boolean_strict` | `adapter-contract:slack-disabled-default` | feature-integration-adapter-templates |
| Google Email | `APP_NOTIFICATION_GOOGLE_EMAIL_ENABLED` | `app.notification.google-email.enabled` | `false` | `boolean_strict` | `adapter-contract:google-email-disabled-default` | feature-integration-adapter-templates |
> ⚠️ 본 branch 의 prose/결정에 등장하는 일반화 패턴 `APP_ADAPTER_{NAME}_ENABLED` / `app.adapter.{name}.enabled` 는 **registry 에 landed 된 실제 키와 불일치** (도메인 namespace 사용). 신규 adapter 추가 시에도 `APP_ADAPTER_*` 가 아니라 도메인 prefix (`APP_CACHE_*`/`APP_MESSAGING_*`/`APP_NOTIFICATION_*`) 를 따른다. → §Audit & Findings A1.
### 2. Layer 1 — Spring `@ConditionalOnProperty` bean 게이팅 (planned)
> **Trace**: D2. Supporting: `SBAC-C1`(ConditionalOnProperty 존재)·`SBAC-C3`(default 누락 시 미매칭)·`SBAC-C4`(3.5.0+ Boolean 변형).
> **증거 등급**: `planned` (코드 부재).
>
> - **UNSUPPORTED_IMPL_DECISION**:
> - adapter bean package 명 (`dev.caskeleton.adapter.{messaging.kafka|cache.redis|notification.slack|notification.googleemail}`) — 코드 미존재, 현존 adapter 모듈 명명 관행(`dev.caskeleton.adapter.*`) 에서 추정. trade-off: 모듈 경계가 코드로 확정되면 정합 필요.
> - "ApplicationContext bean count = 0 검증" 패턴 — Spring 공식 verification 패턴 아님 (`SBAC` Usage Boundaries). trade-off: disabled 상태 정합성을 startup 테스트로 직접 assert 하려는 ca-tmpl 자체 선택.
각 adapter auto-config 클래스에 `@ConditionalOnProperty(name="app.{domain}.{adapter}.enabled", havingValue="true", matchIfMissing=false)` 부착. `matchIfMissing=false` 명시 의무 — env 누락 시 *disabled* 가 기본 (D2 Open Risk). Boolean baseline 이 Spring Boot 3.5.0+ 면 `@ConditionalOnBooleanProperty` 로 치환 가능 (`SBAC-C4`; baseline 버전은 §Claims To Verify 미확정 항목).
### 3. Layer 2 — ArchUnit 정적 격리 규칙 (planned)
> **Trace**: D3. Supporting: `AUCP-C1`·`AUCP-C4`·`AUCP-C5`.
> **증거 등급**: `planned` (rule 코드 부재).
>
> - **UNSUPPORTED_IMPL_DECISION**: rule 클래스 명 / 배치 모듈 — `app-bootstrap` 의 기존 `CleanArchitectureTest` 패키지 관행에서 추정 (`src/app-bootstrap/.../architecture/`). trade-off: 실제 ArchUnit suite 배치는 코드 확정 시 정합.
```text
noClasses().that().resideInAPackage("..application..")
.should().dependOnClassesThat().resideInAPackage("..adapter.{disabled-adapter}..")
```
정적 검사가 보장하는 범위는 'application layer 가 특정 adapter package 를 import 하지 않음' 까지. 'disabled' 라는 runtime config 조건은 정적으로 완전 평가 불가 (`AUCP-C5` Usage Boundaries) → runtime 보장은 §4 (Layer 3).
### 4. Layer 3 — runtime fail-fast `AdapterDisabledException` (planned, error code 미정)
> **Trace**: D4 (UNSUPPORTED_DECISION — ca-tmpl 자체 contract).
> **증거 등급**: `planned` — `src/` grep 결과 `AdapterDisabledException` **부재**.
>
> - **UNSUPPORTED_IMPL_DECISION**:
> - 예외 클래스 명 `AdapterDisabledException` — 코드 부재, 명명은 임의. trade-off: shared-contract 예외 계층과 정합 필요.
> - **error code 재사용 `REQUIRED_ADAPTER_DISABLED`** — 이 코드는 `feature-migration-startup-contract` owns + **startup-exit(72) / INTERNAL 500** 시맨틱 (error-codes.yaml L830). runtime invoke-path 예외에 재사용하는 것이 적정한지 **미결** → §Audit & Findings A2. trade-off: 재사용 시 코드 1개로 startup·runtime 두 lifecycle 을 표현(혼란) vs 신규 runtime 코드 추가(registry 증식).
정상 경로에서는 Layer 1 이 bean 자체를 등록하지 않으므로 disabled adapter 는 *호출 불가*. 본 Layer 는 Layer 1·2 를 우회한 호출의 최후 방어선 — silent no-op / timeout 대기 금지, 즉시 throw.
### 5. Per-adapter 실패 계약 (planned — owner branch 와 분담)
> **Trace**: D9 (본 branch 는 fail-open/closed 정책 owner). 진행 중 메모("cache miss 는 장애 아님", "notification failure 는 adapter별 core 실패 여부 명시") 의 구체화.
> **증거 등급**: `planned`.
>
> - **결정 (D9 도출, 본 branch owns)**: **notification adapter (Slack/Google Email) 는 fail-open 기본**. notification 은 skeleton 에서 use case 의 *부수 효과(side-effect)* 로 모델링되므로, 전송 실패가 core use case 의 HTTP 응답을 실패(5xx)로 만들지 않는다 — 실패는 correlationId + 실패 metric 으로 관측되고 응답은 core 결과를 따른다.
> - **UNSUPPORTED_IMPL_DECISION**:
> - notification fail-open 기본값 자체 — 외부 source 가 prescribe 한 값 아님(설계 판단). trade-off: fail-open 이면 알림 유실이 무음(관측에만 의존) vs fail-closed 면 알림 실패가 핵심 API 에러로 표출되어 사용자 경험 저하. skeleton 은 "알림은 부수효과" 가정을 택함.
> - **OUT_OF_BRANCH_SCOPE**: notification 이 *primary outcome* 인 use case(예: "비밀번호 재설정 메일 발송" 자체가 목적) 는 도메인 특화 — 해당 use case 가 전송을 동기 + fail-closed 로 호출하는 결정은 도메인 branch 몫(skeleton 범위 밖). 본 contract 는 default(fail-open)만 owns.
> - correlationId 부착 메커니즘 / PII redaction glob 패턴 — 코드·정책 source 부재. trade-off: 아래는 *정책 의도* 이며 메커니즘은 구현 시 확정.
| adapter | enabled 시 실패 정책 | fail-open/closed | 분담 owner |
|---|---|---|---|
| Kafka | publish 실패 시 correlationId 부착 + outbox/retry 로 위임 | core use case 는 outbox commit 으로 성공 (fail-open) | retry/DLQ vocab → [[raw/branch-notes/feature-background-job-async-contract]], outbox → [[raw/branch-notes/feature-domain-event-outbox-contract]] |
| Redis | unavailable 시 `cache-miss` 로 graceful degrade (INTERNAL 로 뭉개지 금지) | fail-open (cache miss = 정상) | cache 일관성 → [[raw/branch-notes/feature-cache-consistency-contract]] |
| Slack / Google Email | 전송 실패 시 correlationId + 실패 metric 으로 관측, provider body/PII 는 log 미등장 | **fail-open (기본)** — notification 실패 ≠ core use case 실패 (5xx 미승격). primary-outcome use case 의 fail-closed 는 OUT_OF_BRANCH_SCOPE | 본 branch owns (default), 도메인별 override 는 도메인 branch |
## 엣지·실패·의존
> R4(깊이 게이트) 캡처용. 정상 경로 외 *구현 중 부딪힐* 실패/엣지/다른 계약 의존.
- **실패·엣지 경로**:
- **env 누락 vs `false` vs `true`**: `@ConditionalOnProperty(matchIfMissing=false)` 로 누락=disabled 가 기본. `boolean_strict` validation 이 `true`/`false` 외 값 거부 (registry). 3-case bean count 검증 필요 (§Claims).
- **disabled adapter runtime 호출**: Layer 1 우회 시 `AdapterDisabledException` fail-fast — timeout 대기 금지 (D4).
- **Kafka enabled + brokers 누락**: `csv_of_host_port_when_kafka_enabled` validation 이 startup 에서 차단해야 함 (`APP_MESSAGING_KAFKA_BROKERS`).
- **Redis unavailable (enabled)**: cache-miss degrade, 응답 200 유지, INTERNAL 승격 금지.
- **notification provider 실패**: PII log 누출 0, core use case 실패 전파 여부 adapter별 명시.
- **다른 계약 의존**:
- [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] — required vs optional dependency 분류 SSOT (D9). 그 분류가 바뀌면 본 branch 의 disabled-default 적용 대상이 바뀜.
- [[raw/branch-notes/feature-migration-startup-contract]] — `REQUIRED_ADAPTER_DISABLED` error code + startup-exit(72) owner. D4 의 error code 재사용 결정은 이 계약에 의존 (§Audit A2).
- [[raw/branch-notes/feature-cache-consistency-contract]] — Redis endpoint 키 (`APP_CACHE_REDIS_HOST/PORT`, owner) + cache 일관성 정책. 본 branch 는 enable 토글만 owns.
- [[raw/branch-notes/feature-domain-event-outbox-contract]] + [[raw/branch-notes/feature-background-job-async-contract]] — Kafka retry/DLQ vocabulary. outbox core 는 Kafka 를 강제하지 않음 (D 결정 2026-05-22).
## Audit & Findings
> ca-tmpl ground truth (registry/code) 대조에서 발견한 drift. **사용자 작성 결정 영역이므로 자동 rewrite 하지 않고 정합 권고만** 기록.
- **A1 — ENV_KEY_DRIFT (`APP_ADAPTER_{NAME}_ENABLED` → 도메인 namespace)**:
- 발견: 본 branch 결정/prose (§결정 사항 disabled adapter detection, D2) 는 일반화 키 `APP_ADAPTER_{NAME}_ENABLED` / `app.adapter.{name}.enabled` 를 사용. 그러나 `env-keys.yaml` 에 **실제 landed 된 키**는 도메인 namespace — `APP_CACHE_REDIS_ENABLED`, `APP_MESSAGING_KAFKA_ENABLED`, `APP_NOTIFICATION_SLACK_ENABLED`, `APP_NOTIFICATION_GOOGLE_EMAIL_ENABLED` (모두 `owner_branch: feature-integration-adapter-templates`).
- 권고: 구현·`@ConditionalOnProperty` 는 §구현 가이드 §1 표의 도메인 namespace 키를 SSOT 로 사용. prose 의 `APP_ADAPTER_*` 일반화는 abstract placeholder 로만 취급하고, 신규 adapter 도 도메인 prefix 를 따른다.
- **A2 — CODE_OWNERSHIP/SEMANTIC drift (`REQUIRED_ADAPTER_DISABLED` 재사용)**:
- 발견: D4/Layer 3 는 runtime invoke-path 예외 로그에 `error.code=REQUIRED_ADAPTER_DISABLED` 를 적었으나, 이 코드는 `error-codes.yaml` L830 에서 **`owner_branch: feature-migration-startup-contract`** + category `INTERNAL`/500 + `runbook://startup/required-adapter-disabled`**startup-time** (exit 72, "disabled required adapter 로 app 이 뜨면 실패") 시맨틱.
- 권고: (1) runtime fail-fast 는 startup validation 과 lifecycle 이 다르므로 startup 코드 재사용은 의미 충돌 가능. (2) 선택지 — startup-only 로 유지하고 runtime 은 별도 코드 신규 제안(owner=본 branch) 하거나, migration-startup branch 와 합의해 코드 의미를 명시적으로 두 lifecycle 로 확장. 결정 전까지 D4 의 error code 는 `미정`.
## 검증해야 할 주장
> 공식 문서나 사례는 근거지만, 내 프로젝트에서의 동작을 자동으로 보장하지 않는다.
| Claim | Why uncertain | How to verify | Status |
|---|---|---|---|
| `@ConditionalOnProperty(havingValue="true", matchIfMissing=false)` 가 ca-tmpl 의 "기본 disabled" 의도를 정확히 표현 | `SBAC-C3` 가 "by default property must be present AND not equal to false" — env 가 누락된 경우 `matchIfMissing=false` 명시 의무 | local 통합 테스트로 (1) env 누락 (2) `false` (3) `true` 3-case 에서 bean count 검증 | `planned` |
| 3.5.0+ 에서 `@ConditionalOnBooleanProperty` 가 동등 시맨틱을 더 명시적으로 표현 | `SBAC-C4` 가 since 3.5.0 — ca-tmpl baseline 의 Spring Boot 버전 확인 필요 | `gradle/libs.versions.toml` 또는 `build.gradle.kts` 의 Spring Boot 버전 확인 후 적용 | `needs-confirmation` |
| ArchUnit Layer 2 rule 이 disabled adapter 의 use case path import 를 실제로 catch | `AUCP-C1` PREDICATE/CONDITION 모델로 가능하지만 - "when disabled" 조건은 runtime config 평가 — ArchUnit 의 정적 검사 한계 (AUCP-C5 Usage Boundaries) | `APP_ADAPTER_KAFKA_ENABLED=false` 상태에서 violating PR 만들어 ArchUnit rule fail 확인 | `needs-confirmation` |
| Layer 3 `AdapterDisabledException` + log code `REQUIRED_ADAPTER_DISABLED` 가 actual runtime 에서 trigger | D4 UNSUPPORTED_DECISION — ca-tmpl 자체 contract | adapter aspect + exception throw + log assertion 통합 테스트 | `planned` |
| disabled adapter 가 runtime path 에서 호출 시 fail-fast (timeout 대기 금지) | shutdown 정책 ([[raw/branch-notes/feature-outbound-http-client-baseline]]) 과 정합 — 적용 시점 확인 필요 | shutdown phase 통합 테스트 + thread state assertion | `needs-confirmation` |
| Kafka publish failure 에 correlationId 가 항상 부착 | Kafka adapter contract — correlationId propagation 메커니즘 자체 검증 필요 | Kafka producer interceptor + log assertion 통합 테스트 | `planned` |
| Redis unavailable 시 `cache-miss` 로 graceful degrade (INTERNAL 으로 뭉개지지 않음) | Redis adapter contract — fail-open/closed 정책 명시 필요 | Redis container down + cache read 통합 테스트 + 응답 200 OK + cache miss metric 확인 | `planned` |
| notification provider body/PII 가 log 에 등장하지 않음 | Slack/Google Email adapter — payload redaction policy 검증 필요 | grep 으로 payload pattern (`@gmail.com` 등) log 검출 contract test | `planned` |
| feature flag (FF4J/Togglz) 와 adapter on/off 의 분리 영역 시각화 | D7 — runtime toggle vs startup toggle 의 운영 혼동 가능 | architecture decision record 작성 + 면접 시 답변 가능한 경계 명시 | `planned` |
- disabled adapter가 runtime path에서 호출되면 실패.
- Kafka publish failure에 correlationId가 없으면 실패.
- Redis unavailable이 degrade 가능 여부 없이 INTERNAL로 뭉개지면 실패.
- notification provider body/PII가 log에 남으면 실패.
- optional adapter가 core startup에 필수 dependency가 되면 실패.
## 관심사 커버리지 (coverage-auditor 2026-06-09)
> governing doc: [[raw/project-notes/ca-skeleton-operational-contract]] (§11 Optional Adapters + §9 Env-driven + §25 SSOT Owner Map + Group G-I). 기준: `rules/coverage-gate.md`. 판정: **Covered (missing 0)**.
> 상태: `covered-here`(이 브랜치 결정) / `delegated`(다른 owner 브랜치) / `missing`(아무도 안 맡음 → Blocking).
| 관심사 | 상태 | owner | 심각도 | 근거 |
|--------|------|-------|--------|------|
| 선택형 adapter disabled-default 정책 | covered-here | — | — | D1 (SBAC-C1/C2/C5) |
| Adapter enable/disable env 키 계약 (5개) | covered-here | — | — | D2 + §구현 가이드 §1 — env-keys.yaml landed (owner=본 branch) |
| Layer 1 `@ConditionalOnProperty` bean 게이팅 | covered-here | — | — | D2 + §구현 §2 (planned) |
| Layer 2 ArchUnit 정적 격리 | covered-here | — | — | D3 + §구현 §3 (AUCP-C1/C4/C5, planned) |
| Layer 3 runtime fail-fast | covered-here | — | — | D4 + §구현 §4 (planned, error code A2 미결) |
| fail-open/closed per adapter (Kafka/Redis) | covered-here | — | — | D9 + §구현 §5 (둘 다 fail-open) |
| fail-open/closed per adapter (Slack/Google Email) | covered-here | — | — | D9 + §구현 §5 — **fail-open 기본** 결정 완료 |
| common adapter logging/error contract | covered-here | — | — | §범위 In-scope + Adapter Template Defaults common row (MDC dependency key SSOT 는 log-management consume) |
| optional module vs sample 패키징 기준 | covered-here | — | — | D1 (optional module 기본, sample = 문서/fixture 전용) |
| required vs optional dependency 분류 SSOT | delegated | [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] | OK | D9 — 분류 SSOT 위임. cross-link: [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] |
| `REQUIRED_ADAPTER_DISABLED` error code (startup lifecycle) | delegated | [[raw/branch-notes/feature-migration-startup-contract]] | Should-fix | error-codes.yaml L830 owner. runtime 재사용 적정성은 §Audit A2 에서 미결 — [[raw/branch-notes/feature-migration-startup-contract]] 와 합의 필요 |
| Kafka retry/DLQ vocabulary | delegated | [[raw/branch-notes/feature-background-job-async-contract]] | OK | §구현 §5 위임 링크 존재 |
| Redis cache endpoint 키 (HOST/PORT) | delegated | [[raw/branch-notes/feature-cache-consistency-contract]] | OK | env-keys.yaml owner + §엣지 의존 링크 존재 |
## 마주친 문제
- **2026-06-09 B7 ArchUnit 충돌**: optional adapter 의 `@ConditionalOnProperty` `@Bean` factory method 가 port 타입(`MessagePublisher`/`CacheStore`/… — `..adapter.outbound..` 거주)을 반환하자 `outbound_adapter_method_returns_only_domain_or_primitives`(B7) 가 9건 위반. B7 은 adapter *응답* method 의 external type 누출을 막는 rule 이지 DI factory 가 자기 port 타입을 반환하는 것을 막는 rule 이 아님 → B7 을 `@Configuration` 클래스 제외로 scoping. 상세: [[raw/errors/archunit-b7-configuration-bean-factory-return-type-2026-06-09]].
- **2026-06-16 messaging broker-SPI 전환 후 주석 drift 정리**: 메시징이 `app.messaging.kafka.enabled` + 단일 `KafkaMessagePublisher` 구조에서 `app.messaging.broker=<brokerId>` + `MessageBroker` SPI(`KafkaMessageBroker`) + broker-agnostic 바인딩 데코레이터(`OutboundMessagePublisher` fail-open / `OutboxMessagePublishAdapter` fail-closed) + disabled sentinel 쌍(`DisabledMessagePublisher`/`DisabledOutboxMessagePublisher`)으로 리팩터된 뒤, JavaDoc/주석이 옛 구조를 가리키는 drift 6건을 정리(코드 동작 무변경, 주석 only). 수정: `MessagePublisher`(JavaDoc 를 adapter-local fail-open 으로 재서술 — "a use case holds this port" 삭제, use-case-facing durable 경로는 application-core `OutboxMessagePublishPort` 임을 명시), `MessagingConfig`·`MessagingSettings`(깨진 `{@link DisabledMessaging}` → 실제 `Disabled*` 쌍), `kafka/KafkaSender`(`KafkaMessagePublisher``KafkaMessageBroker` + 바인딩 데코레이터), application-core `OutboxMessagePublishPort`(adapter 클래스명 제거 → "general fail-open messaging publisher" 로 일반화), `CleanArchitectureTest` 주석 예시(`KafkaAdapterConfig#kafkaMessagePublisher``MessagingConfig#messagePublisher`). 검증: `:adapter-outbound:compileJava :application-core:compileJava :app-bootstrap:compileTestJava` BUILD SUCCESSFUL.
- **NOTE_DRIFT**: 본 노트의 env-key 표(`app.messaging.kafka.enabled`/`APP_MESSAGING_KAFKA_ENABLED`, L163-164)와 D2 예시 ENV_KEY_DRIFT 항목(L130)은 broker-SPI 전환 *전* 네이밍이라 현재 코드(`app.messaging.broker`)와 어긋남 — broker-SPI 리팩터(사용자 작업, 본 세션에서 미캡처)가 정합시켜야 할 영역. 본 작업 범위는 코드 주석 only 이므로 노트 표는 자동 rewrite 하지 않음(§Audit & Findings 의 "자동 rewrite 하지 않고 정합 권고만" 정책과 동일).
- **2026-06-16 `OutboundDependencyLogger``FailOpenDependencyLogger` 리네임 (책임-명확화 리팩터)**: 공통 의존성 로거의 이름이 "Outbound*" 라 HTTP 까지 포괄하는 공통 로거로 오독될 소지가 있었음. 실제로는 cache/messaging/notification **fail-open optional adapter 전용**(WARN, 관측-only)이고 HTTP 경로는 hard failure 를 ERROR 로 올리는 별도 `httpclient/OutboundHttpDependencyLogger` 임. 두 로거를 합치지 않는다는 판단은 유지(레벨·필드·error-code 정책 상이)하고 이름만 기존 `FailOpen*` 컨벤션(`FailOpenCacheStore`/`FailOpenNotificationProvider`)에 맞춰 변경. 범위: 타입 토큰 17개 .java + `@Bean` 메서드 `outboundDependencyLogger()``failOpenDependencyLogger()`(타입 주입이라 안전 — resource/Qualifier by-name 참조 0 확인) + 클래스 JavaDoc 도입부 fail-open 강조 + `adapter-outbound/CLAUDE.md` L38 + `LogMaskingPatterns` JavaDoc 참조. 동작 무변경. 검증: `:adapter-outbound:test` 175/175 PASS, `:app-bootstrap:compileJava` BUILD SUCCESSFUL.
- **보류 (리뷰 권고/판단대로)**: ① `DependencyLogFields` 공통 상수/포매터 추출 — 두 로거의 필드셋·레벨 정책이 달라 효익 적고 리뷰도 "중복 조금이 정책 섞임보다 낫다"며 helper "정도만 고려" 권고 → 보류. ② `OutboundHttpClient` 의 classify+outcome+log 흐름을 `OutboundHttpCallObserver`/`FailureHandler` 로 추출 — 리뷰가 "필수 아님, 과하게 쪼개면 처음 보는 사람이 더 힘듦" 명시 → 보류(스켈레톤 가독성·회귀 위험). ③ `TraceContextPropagationInterceptor` FORK LANDMINE 주석 docs/runbook 이관 — 해당 경고는 "이 파일을 고쳐 실 tracer 를 붙이는 사람"이 직접 봐야 하는 load-bearing 안전 정보(sampled=00 강제 + 인터셉터가 OTel 계측보다 먼저 등록되어 race 를 이김)라 in-file 유지 권고, 이관 시 누락 위험 → 보류(사용자 확인 시 in-place 압축만 검토).
- **2026-06-16 cache 패키지 `core/` 분리 (하이브리드) + 문서 drift 정리**: cache 가 한 폴더에 SPI/router/settings/config/fail-open/exception 다 모여 있어, messaging/notification `core/` 컨벤션과 맞춰 공통 계약·정책만 분리. 이동(전부 public → **가시성 변경 0, encapsulation-neutral**, httpclient resilience/diagnostics 와 동일 패턴): `CacheStore`·`CacheBackend`·`CacheBackendException`·`CacheStoreRouter`·`FailOpenCacheStore``cache/core/`; `CacheRouterConfig`·`CacheBindingSettings` 는 root 유지; `cache/redis/` 불변. import: redis 파일들의 기존 `cache.*` import 를 `cache.core.*` 로 path 정정, root `CacheRouterConfig` 엔 신규 추가, 외부 테스트 3개(`OptionalAdapterBeanGatingTest`/`DisabledAdapterSentinelTest`/`RedisCacheStoreTest`)도 path 정정. `adapter-outbound/CLAUDE.md` cache 경로 갱신(CacheRouterConfig 만 root 유지).
- **문서 drift 2건 동시 정리**: `redis/RedisClient` 주석("RedisCacheStore 가 fail-open 적용" → 실제는 중앙 `FailOpenCacheStore` 데코레이터가 `CacheBackendException` 을 cache-miss 로 downgrade); `core/CacheStore` 메서드 javadoc("for the Redis binding" → 모든 backend, 중앙 데코레이터); `application.yml` optional-adapter 주석("disabled → fail-fast sentinel" 일반화가 cache/notification 엔 부정확 → **messaging=Disabled\* sentinel bean, cache/notification=router(`CacheStoreRouter`/`RoutingNotifier`) unbound fail-fast** 로 구분 명시).
- 검증: cache 스코프 테스트 **32/32**, `CleanArchitectureTest` **49/49** PASS, 모듈 컴파일 0 에러. 가드레일 무영향(`..adapter.outbound..` 재귀 패턴이 `cache.core` 자동 커버).
## 묶음
<!-- GENERATED: sources:start -->
- [[raw/company-tech-blogs/adapter-togglz-ff4j-feature-toggle-library]]
- [[raw/official-docs/adapter-java-spi-serviceloader]]
- [[raw/official-docs/adapter-spring-boot-autoconfig-custom-starter]]
- [[raw/official-docs/archunit-conditional-on-property-3-layer-pattern]]
- [[raw/official-docs/outbound-openfeign-declarative-client]]
- [[raw/official-docs/outbound-spring-restclient-baseline]]
<!-- GENERATED: sources:end -->
<!-- GENERATED: interviews:start -->
- [[raw/interviews/optional-adapter-3-layer-disabled-detection-2026-06-09]]
<!-- GENERATED: interviews:end -->
<!-- GENERATED: errors:start -->
- [[raw/errors/archunit-b7-configuration-bean-factory-return-type-2026-06-09]]
<!-- GENERATED: errors:end -->
<!-- GENERATED: blog-topics:start -->
- [[raw/blog-topics/spring-conditional-on-property-optional-adapter-template-2026-06-09]]
<!-- GENERATED: blog-topics:end -->
> 본 feature branch 는 더 이상 leaf 아님 — 2026-06-09 실 구현으로 errors / interview / blog-topic 파생 자료 누적.
### 오류 기록 (본 feature 작업 중 발생)
- [[raw/errors/archunit-b7-configuration-bean-factory-return-type-2026-06-09]] — B7 이 `@Configuration` `@Bean` factory 의 port-타입 반환을 오탐, rule scoping 으로 해소.
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
- [[raw/interviews/optional-adapter-3-layer-disabled-detection-2026-06-09]] — startup(`@ConditionalOnProperty` bean-gating) / build(ArchUnit 정적 격리 + `@Bean` gating) / runtime(`AdapterDisabledException` fail-fast) 3계층 disabled-adapter 검출과 각 계층의 보장·한계, fail-open vs fail-closed, runtime 전용 error code 신설(A2) 근거.
### Blog topics (이 작업에서 나올 수 있는 글감)
- [[raw/blog-topics/spring-conditional-on-property-optional-adapter-template-2026-06-09]] — heavy SDK 없이 `@ConditionalOnProperty` + integration seam + disabled sentinel 로 "선택형 어댑터 템플릿" 을 경량 skeleton 에 싣는 패턴.
## 관련 일일 노트
> 이 브랜치를 작업한 날짜들. 양방향 nav 유지.
- 2026-06-09 — Layer 1/2/3 + per-adapter fail-open 계약 실 구현 및 locally-verified.
- 2026-06-16 — messaging broker-SPI 전환 후속 코드 주석 drift 6건 정리(동작 무변경). 위 §마주친 문제 2026-06-16 참조.
## 완료 후 정리
- PR 링크:
- 리뷰 메모:
- 머지 결과 / 배포 환경:
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
- `actually-implemented` 항목: 3-layer disabled-adapter 검출(Layer1 `@ConditionalOnProperty` bean-gating / Layer2 `DisabledAdapterArchitectureTest` 격리+gating / Layer3 `AdapterDisabledException`); per-adapter fail-open 계약(Kafka publish→correlationId+outbox 위임, Redis unavailable→cache-miss, Slack/Email→관측+무PII); common `OutboundDependencyLogger`; A2 runtime 전용 `ADAPTER_DISABLED` error code.
- `locally-verified` 항목: 위 전부 — `:shared-contract:test`/`:adapter-outbound:test`/`:adapter-web:test`/`:app-bootstrap:test` + `verifyCleanArchitectureDependencies`/`verifyEnvKeys`/`verifyPublicPathSnapshot` PASS, ca-architect-sentinel PASS.
- `prod-verified` 항목: (없음 — 미배포)
- **추출하지 않을 항목** (planned / documented-only / abandoned): 실제 broker/cache/provider 운영 연동(integration seam 구현은 fork 프로젝트 몫 — OUT_OF_BRANCH_SCOPE); primary-outcome notification 의 fail-closed override(도메인 branch 몫).