--- title: branch / feature-notification-provider-spi source_type: branch-note status: raw branch: feature-notification-provider-spi parent_branch: related_projects: [ca-skeleton] tags: [branch, ca-skeleton, adapter-outbound, notification, spi, extensibility, refactoring, multi-provider, routing] created: 2026-06-16 target_merge: status_label: in-progress id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-054 kind: project-work-item project: ca-skeleton-operational-contract work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-054 inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MODULE-LAYOUT-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-RESILIENCE-001@1] refines: [] overrides: [] depends_on: [WI-CA-SKELETON-OPERATIONAL-CONTRACT-053, WI-CA-SKELETON-OPERATIONAL-CONTRACT-049] contract_packet: 1 contract_packet_sha256: b96b0985426cc2f8b11495fac621f016a2f588595e898aa65f890e2abe3ac986 --- # branch: feature-notification-provider-spi (multi-provider registry iteration) > Layer: `raw/branch-notes/` — 단일 브랜치의 TODO·결정·진행 기록. git 작업은 `develop` 브랜치에서 수행. ## 부모 (필수) - **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]] 선택 (관련 형제 branch): - [[raw/branch-notes/feature-messaging-multibroker-router]] — 본 작업이 이식한 동일 패턴(SPI+레지스트리+라우터+fail-open). cache 패턴의 notification 이식. - `chore-repo-wide-refactor-review` — 별도 branch-note가 남지 않은 당시 adapter-outbound 리뷰 작업 식별자. notification provider-binary 결함의 발견 맥락으로만 보존한다. ## 브랜치 계약 패킷 - **생성 시 프로젝트 개정**: `1` - **패킷 스키마**: `contract_packet: 1` - **완료 조건**: notification provider SPI·routing·failure contract와 test가 명시된다 ### 상속한 프로젝트 결정 | Decision Ref | Project Summary | Branch Application | Source | |---|---|---|---| | `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MODULE-LAYOUT-001@1` | Gradle multi-module에서 domain-core·application-core·adapter-*·shared-contract·app-bootstrap·sample-portfolio 책임을 분리한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] | | `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-RESILIENCE-001@1` | Resilience4j가 default이며 Spring Retry는 simple blocking retry에만 허용한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] | ### 브랜치 지역 결정 > 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다. | Decision ID | Decision | Relation | Supporting Claims | Status | |---|---|---|---|---| ### 선언한 예외 | Override ID | Overrides | Reason | Approval | Status | |---|---|---|---|---| ## 목표 notification 을 단일-provider 바이너리 플래그(`app.notification..provider=`) 에서 **제네릭 `NotificationPort` + `(channel, route)` 키 레지스트리 + 외부 routes 바인딩** 으로 전환. cache(`CacheStoreRouter`)·messaging 패턴의 notification 이식. - 채널별 분리 포트(`EmailNotifier`/`SlackNotifier`)·단일 selector 폐기. - 멀티-provider fan-out, 채널/route 기반 라우팅(설정만으로), 부팅 시 일관성 검증. - `Notification` 값 타입을 `adapter-outbound` → `application-core`로 이동(CA HARD-STOP #3). 설계 스펙: `ca-tmpl/docs/superpowers/plans/2026-06-16-notification-multi-provider-registry.md`. ## 범위 ### 포함 범위 - `application-core`: `Channel` enum, `Notification` record(app-core로 이동), `NotificationPort` interface. - `adapter-outbound/notification/`: `NotificationProvider` SPI, `FailOpenNotificationProvider`, `RoutingNotifier`, `NotificationRoutesSettings`, `NotificationConfig`(완전 재작성). - `googleemail/GoogleEmailProvider`(→ `NotificationProvider`), `GoogleEmailNotificationAdapterConfig`(enable flag = `app.notification.google-email.enabled`). - `slack/SlackWebhookProvider`(→ `NotificationProvider`), `SlackNotificationAdapterConfig`(enable flag = `app.notification.slack-webhook.enabled`). - `SlackClient`, `GoogleEmailClient` seam 임포트를 app-core `Notification`으로 교체. - 삭제: `EmailNotifier`, `SlackNotifier`, `OutboundEmailNotifier`, `OutboundSlackNotifier`, `DisabledEmailNotifier`, `DisabledSlackNotifier`, `EmailProvider`, `SlackProvider`, `EmailNotificationSettings`, `SlackNotificationSettings`, `adapter-outbound/.../notification/Notification.java`. - 테스트 갱신: `NotificationAdapterTest`, `OptionalAdapterBeanGatingTest`, `DisabledAdapterSentinelTest`, `RoutingNotifierTest`. ### 제외 범위 - ~~폴더 이름 변경(`googleemail`→`email/google`, `slack`→`slack/webhook`)~~ → **후속 패스에서 완료** (§Decisions·§검증 참조). - 실제 AWS SES 등 신규 provider 구현. - fallback 체인·우선순위·비동기 fan-out. - use case 에서 `NotificationPort` 호출(`@UseCaseCapability` 미요구). ## 근거 (필수) | Source | 정당화하는 결정 | |---|---| | `cache/CacheStoreRouter`, `CacheRouterConfig`, `CacheBindingSettings` (기존 코드) | D2 — 동형 패턴을 notification 에 이식하는 직접 근거 | | `adapter-outbound/CLAUDE.md` | D4 — No disabled sentinel; Layer 3 fail-fast in router | | plan §6 레이어 순서 | D1 — application-core 먼저, adapter 나중 | ## TODO - [x] `application-core`: `Channel`, `Notification`, `NotificationPort`, `NotificationPortContractTest` — 등급: `actually-implemented`, `locally-verified` - [x] `adapter-outbound`: `NotificationProvider`, `FailOpenNotificationProvider`, `RoutingNotifier`, `RoutingNotifierTest` — 등급: `actually-implemented`, `locally-verified` - [x] `NotificationRoutesSettings` (`@ConfigurationProperties("app.notification")`) — 등급: `actually-implemented` - [x] `NotificationConfig` 재작성 (ObjectProvider + FailOpen 중앙 래핑 + RoutingNotifier 빈) — 등급: `actually-implemented` - [x] `GoogleEmailProvider`, `GoogleEmailNotificationAdapterConfig` 마이그레이션 — 등급: `actually-implemented` - [x] `SlackWebhookProvider`, `SlackNotificationAdapterConfig` 마이그레이션 — 등급: `actually-implemented` - [x] `GoogleEmailClient`, `SlackClient` seam: 임포트 app-core `Notification`으로 교체 — 등급: `actually-implemented` - [x] 구 파일 11개 삭제 — 등급: `actually-implemented` - [x] 테스트 4개 갱신 (`NotificationAdapterTest`, `OptionalAdapterBeanGatingTest`, `DisabledAdapterSentinelTest`, `RoutingNotifierTest`) — 등급: `actually-implemented`, `locally-verified` - [x] `RoutingNotifier` silent empty catch 제거: registry 타입을 `FailOpenNotificationProvider`로 변경 — 등급: `actually-implemented`, `locally-verified` ## 결정 사항 - 2026-06-16: `RoutingNotifier` registry를 `Map>`로 타입화해 fan-out 루프의 try/catch 제거 / 이유: 빈 catch 블록은 quality gate에서 차단되며 FailOpenNotificationProvider.send()가 throws 선언 없음 → 컴파일러가 예외 불가 증명 / 검토한 대안: `NotificationProvider`로 유지 + try/catch(empty) — 타입 안전성 부족, quality gate 차단 / 근거: 코드 내 FailOpenNotificationProvider.send() 시그니처 - 2026-06-16: 활성화 플래그 key 를 `app.notification.google-email.enabled` / `app.notification.slack-webhook.enabled` 로 통일 (cache의 `app.cache.redis.enabled` 패턴 미러) / 이유: provider id 를 플래그 이름에 직접 반영해 `enabled` flag → providerId 명확성 / 이전 설계(`app.notification.email.provider=google-email`) 폐기 - 2026-06-16: `Notification` 값 타입 app-core 이동 / 이유: use case가 port 인자를 구성할 때 adapter 타입 import 금지(CA HARD-STOP #3) / 대안: adapter에 유지 → HARD-STOP 위반 - 2026-06-16 (follow-up): 폴더를 `/` 구조로 이동 (`googleemail`→`email/google`, `slack/*`→`slack/webhook`) / 이유: 사용자가 채널/기술 분리 구조를 명시 선호 + 1차 패스가 남긴 빈 타겟 폴더 잔재 정리 / 영향: package 선언 6개, `OptionalAdapterBeanGatingTest` import 4개, `DisabledAdapterArchitectureTest` 패키지 패턴(`googleemail..`→`email..`; `slack..`는 webhook 하위 포함이라 무변경), `adapter-outbound/CLAUDE.md` 예시 경로 / 검증: 603/603 green / 클래스명 중복(`email.google.GoogleEmailProvider`)은 cosmetic churn 회피로 보류 - 2026-06-16 (follow-up): `RoutingNotifier` 생성자를 private 헬퍼 3개(`buildRegistry`/`validateRoutes`/`immutableRoutesCopy`)로 추출 + route 검증의 `channelRegistry` 룩업을 channel 루프로 호이스팅 / 이유: 가독성(생성자 3관심사 분리) + 최내곽 루프 중복 룩업 제거 / 사용자 피드백: `forEach` 람다 중첩이 오히려 덜 읽힌다 → **명시적 for문 유지**, 람다 검증 미적용 / 성능: 무변(생성자 1회·O(전체 route 항목), 3중 중첩은 자료구조 깊이 반영일 뿐) / 행동 보존: `RoutingNotifierTest` 13/13 green ## 결정-근거 매핑 | Decision ID | Decision | 선택 조건 | Supporting Claims | Evidence Strength | Open Risk | |---|---|---|---|---|---| | D1 | application-core에 `Channel`+`Notification`+`NotificationPort` 배치 | use case가 port를 호출하는 경우 필수; adapter 타입 leak 금지(HARD-STOP #3) | HARD-STOP #3 (CA rule), plan §2 | `official-rule` | 없음 | | D2 | cache 패턴 동형 이식 (`CacheStoreRouter`/`CacheRouterConfig`/`CacheBindingSettings` → `RoutingNotifier`/`NotificationConfig`/`NotificationRoutesSettings`) | 동일 선택 메커니즘(외부 설정 키 → provider) 필요 | 기존 cache 구현 코드(code-evidence) | `code-evidence` | relaxed binding이 Channel enum key를 `email`→`EMAIL`로 정확히 변환하는지 Spring Boot 3.4 동작 확인 필요 | | D3 | `RoutingNotifier`가 `FailOpenNotificationProvider` 타입으로 registry 보유 (no try/catch) | FailOpenNotificationProvider.send()가 throws 선언 없음 → 컴파일러 증명 가능 | FailOpenNotificationProvider 코드 시그니처 | `code-evidence + locally-verified` | 없음 | | D4 | per-channel Disabled* sentinel 제거; 미바인딩 route → RoutingNotifier AdapterDisabledException | cache D4 계약과 동형 | adapter-outbound/CLAUDE.md `No disabled-sentinel bean` | `rule-derived + locally-verified` | 없음 | | D5 | 폴더를 `/` 구조로 이동(`googleemail`→`email/google`, `slack/*`→`slack/webhook`) — 1차 지연 후 사용자 요청으로 후속 완료 | 사용자가 `notification/email/google` 구조 명시 선호; 코어 green 확보 후 저위험 시점 | 사용자 지시 + 패키지 이동 코드(grep 잔여 0) | `user-directed + locally-verified` | 없음 (603 green) | ## 구현 가이드 ### 1. 활성화 플래그 명명 규칙 > **Trace**: D2 + cache RedisCacheAdapterConfig 미러 > **UNSUPPORTED_IMPL_DECISION**: `app.notification..enabled` 형식 선택 (Redis는 기술명 사용; notification은 providerId로 통일) — 확장 시 명확성 우선 trade-off. | Provider | 활성화 플래그 | bean 조건 | |---|---|---| | Google Email | `app.notification.google-email.enabled=true` | `@ConditionalOnProperty(name="...", havingValue="true", matchIfMissing=false)` | | Slack Webhook | `app.notification.slack-webhook.enabled=true` | 동일 | ### 2. Routes 바인딩 > **Trace**: D2 + plan §3 ```yaml app: notification: routes: email: default: google-email slack: default: slack-webhook alerts: slack-webhook,aws-ses # fan-out 예시 (aws-ses는 미구현) ``` Channel enum key는 Spring relaxed binding이 `email`→`EMAIL`로 변환. ### 3. 삭제된 파일 목록 | 삭제 파일 | 대체 | |---|---| | `notification/EmailNotifier.java` | `application.notification.NotificationPort` | | `notification/SlackNotifier.java` | 동일 | | `notification/OutboundEmailNotifier.java` | `notification/FailOpenNotificationProvider` | | `notification/OutboundSlackNotifier.java` | 동일 | | `notification/DisabledEmailNotifier.java` | `RoutingNotifier` unbound → `AdapterDisabledException` | | `notification/DisabledSlackNotifier.java` | 동일 | | `notification/EmailProvider.java` | `notification/NotificationProvider` | | `notification/SlackProvider.java` | 동일 | | `notification/EmailNotificationSettings.java` | `notification/NotificationRoutesSettings` | | `notification/SlackNotificationSettings.java` | 동일 | | `notification/Notification.java` (adapter) | `application.notification.Notification` | ## 엣지·실패·의존 - **중복 providerId**: `RoutingNotifier` 생성자에서 `IllegalStateException` → 부팅 실패 (D3). - **route가 미존재 providerId 참조**: 생성자 검증 → 부팅 실패 (D2). - **미바인딩 route 런타임 호출**: `AdapterDisabledException` (D4). - **provider send 실패**: `FailOpenNotificationProvider`가 관측(logFailure) 후 삼킴 — fan-out 나머지 계속 (D3). - **zero providers + zero routes**: 깨끗이 생성 (L262 — optional module). - **PII**: `Notification`이 `OutboundDependencyLogger`에 전달되지 않음 — 생성자 타입 시그니처로 보장. - **relaxed binding Channel key**: Spring Boot 3.4 ApplicationConversionService가 `email`→`EMAIL` 변환 — `OptionalAdapterBeanGatingTest`에서 `app.notification.routes.slack.default=slack-webhook` 로 검증됨. ## 검증해야 할 주장 | Claim | Why uncertain | How to verify | Status | |---|---|---|---| | relaxed binding이 `email`→`EMAIL` 변환 | Spring 내부 동작 | `OptionalAdapterBeanGatingTest.slack_webhook_enabled_*` / `google_email_enabled_*` green | `locally-verified` | | RoutingNotifier 빈 catch 없이 컴파일러 증명 | FailOpen.send() no-throws 가정 | compileJava green | `locally-verified` | | 모든 구 타입 참조 0 | 11파일 삭제 후 잔여 임포트 없어야 | compileTestJava green (모든 test 모듈) | `locally-verified` | | 전체 테스트 green | 광범위한 변경 | `./gradlew :application-core:test :adapter-outbound:test verifyCleanArchitectureDependencies :app-bootstrap:test --tests '*CleanArchitectureTest'` all green | `locally-verified` | ## 검증 2026-06-16 (ca-implementer 세션): - `./gradlew :application-core:test` → BUILD SUCCESSFUL - `./gradlew :adapter-outbound:test` → BUILD SUCCESSFUL - `./gradlew verifyCleanArchitectureDependencies` → BUILD SUCCESSFUL - `./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest'` → BUILD SUCCESSFUL 모든 4개 검증 명령 통과. 변경은 unstaged 작업 트리에 남겨짐 (사용자가 커밋). 2026-06-16 (follow-up — 폴더 `/` 마이그레이션 + `adapter-outbound/CLAUDE.md` 문서 드리프트 정정): - `./gradlew :adapter-outbound:test :app-bootstrap:test verifyCleanArchitectureDependencies` → **603/603 PASS** (`DisabledAdapterArchitectureTest` 2, `CleanArchitectureTest` 49, `NotificationAdapterTest` 6 포함). - 잔여 `googleemail` 문자열 0 (grep 전수). - ca-architect-sentinel working-tree 사전감사: PASS (의존방향·HARD-STOP #4·B7·D7 clean; advisory 2건 중 CLAUDE.md 드리프트는 본 패스에서 해소). - `./gradlew check` 전체(직전): 905/905 PASS. 2026-06-16 (follow-up — `RoutingNotifier` 가독성 리팩토링, 행동 보존): - `./gradlew :adapter-outbound:test` → **175/175 PASS** (`RoutingNotifierTest` 13/13 포함 — 단일/fan-out/실패격리/미바인딩/중복id/미존재provider/zero-config 전부). - 명시적 for문 유지(사용자 피드백 반영), 호이스팅 + 헬퍼 추출만. ## 마주친 문제 - `GoogleEmailClient`·`SlackClient` seam이 구 `adapter-outbound.notification.Notification`을 임포트하고 있었음 — `GoogleEmailProvider` 작성 후 IDE 진단에서 발견. 두 seam 인터페이스의 임포트를 `application.notification.Notification`으로 교체해 해소. - `RoutingNotifier` 생성자가 `Collection`를 받아 fan-out 루프에 try/catch(empty)가 필요했음 — 생성자 타입을 `Collection`로 변경해 try/catch 완전 제거. `RoutingNotifierTest`의 raw stub도 `failOpen()` 헬퍼로 래핑. ## 묶음 ### Sub-branches - 없음 ### 오류 기록 - 없음 (마주친 문제는 위 §에 기록, 재발성 오류 없음) ### 면접 준비 - 후보: "SPI + 레지스트리 + fail-open 데코레이터 패턴을 adapter layer 에 적용하는 방법과 장단점" (messaging/cache/notification 3개에 반복 적용 — 패턴 재사용 근거) - 후보: "멀티-provider 선택을 `supports()` 술어(코드) 대신 외부 routes 바인딩(설정)으로 둔 이유 — adapter 에 도메인 정책이 새면 CA HARD-STOP #4 위반; 설정 기반은 어댑터가 도메인 미열람이라 구조적으로 위반 불가" (Novu/AWS SNS/cache 수렴 근거) - 후보: "라우팅(키→1개) vs 팬아웃(1→N) 구분과, 바인딩 값을 providerId 리스트로 두어 둘을 한 메커니즘으로 통합한 설계" ### Blog topics - 후보: "CA 스켈레톤에서 notification 을 멀티-provider 라우팅으로 확장하기 — 빈 catch 없는 타입 안전 fail-open 구현" ## 진행 중 메모 - SPI·registry·router 구현과 검증 상태는 TODO와 Verification 절을 기준으로 추적한다. ## 관련 일일 노트 - 별도 일일 노트 없음. ## 완료 후 정리 - PR 링크: 미완료 - 머지 결과: locally-verified, unstaged (사용자 커밋 대기) - **wiki 추출 대상**: D1-D4, RoutingNotifier 타입화 결정, 활성화 플래그 명명 규칙