Files
llm-wiki/raw/branch-notes/feature-notification-provider-spi.md

262 lines
18 KiB
Markdown

---
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` 브랜치에서 수행.
<!-- section-id: branch-parent -->
## 부모 (필수)
- **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 결함의 발견 맥락으로만 보존한다.
<!-- GENERATED: branch-contract:start -->
<!-- section-id: branch-contract-packet -->
## 브랜치 계약 패킷
- **생성 시 프로젝트 개정**: `1`
- **패킷 스키마**: `contract_packet: 1`
- **완료 조건**: notification provider SPI·routing·failure contract와 test가 명시된다
<!-- section-id: inherited-project-decisions -->
### 상속한 프로젝트 결정
| 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]] |
<!-- section-id: branch-local-decisions -->
### 브랜치 지역 결정
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
| Decision ID | Decision | Relation | Supporting Claims | Status |
|---|---|---|---|---|
<!-- section-id: declared-overrides -->
### 선언한 예외
| Override ID | Overrides | Reason | Approval | Status |
|---|---|---|---|---|
<!-- GENERATED: branch-contract:end -->
<!-- section-id: branch-goal -->
## 목표
notification 을 단일-provider 바이너리 플래그(`app.notification.<kind>.provider=<id>`) 에서
**제네릭 `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`.
<!-- section-id: branch-scope -->
## 범위
### 포함 범위
- `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<Channel, Map<String, FailOpenNotificationProvider>>`로 타입화해 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): 폴더를 `<channel>/<tech>` 구조로 이동 (`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 | 폴더를 `<channel>/<tech>` 구조로 이동(`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.<providerId>.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 — 폴더 `<channel>/<tech>` 마이그레이션 + `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<? extends NotificationProvider>`를 받아 fan-out 루프에 try/catch(empty)가 필요했음 — 생성자 타입을 `Collection<? extends FailOpenNotificationProvider>`로 변경해 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 타입화 결정, 활성화 플래그 명명 규칙