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

18 KiB

title, source_type, status, branch, parent_branch, related_projects, tags, created, target_merge, status_label, id, kind, project, work_item, inherits, refines, overrides, depends_on, contract_packet, contract_packet_sha256
title source_type status branch parent_branch related_projects tags created target_merge status_label id kind project work_item inherits refines overrides depends_on contract_packet contract_packet_sha256
branch / feature-notification-provider-spi branch-note raw feature-notification-provider-spi
ca-skeleton
branch
ca-skeleton
adapter-outbound
notification
spi
extensibility
refactoring
multi-provider
routing
2026-06-16 in-progress BR-CA-SKELETON-OPERATIONAL-CONTRACT-054 project-work-item ca-skeleton-operational-contract WI-CA-SKELETON-OPERATIONAL-CONTRACT-054
DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MODULE-LAYOUT-001@1
DEC-CA-SKELETON-OPERATIONAL-CONTRACT-RESILIENCE-001@1
WI-CA-SKELETON-OPERATIONAL-CONTRACT-053
WI-CA-SKELETON-OPERATIONAL-CONTRACT-049
1 b96b0985426cc2f8b11495fac621f016a2f588595e898aa65f890e2abe3ac986

branch: feature-notification-provider-spi (multi-provider registry iteration)

Layer: raw/branch-notes/ — 단일 브랜치의 TODO·결정·진행 기록. git 작업은 develop 브랜치에서 수행.

부모 (필수)

선택 (관련 형제 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.<kind>.provider=<id>) 에서 제네릭 NotificationPort + (channel, route) 키 레지스트리 + 외부 routes 바인딩 으로 전환. cache(CacheStoreRouter)·messaging 패턴의 notification 이식.

  • 채널별 분리 포트(EmailNotifier/SlackNotifier)·단일 selector 폐기.
  • 멀티-provider fan-out, 채널/route 기반 라우팅(설정만으로), 부팅 시 일관성 검증.
  • Notification 값 타입을 adapter-outboundapplication-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.

제외 범위

  • 폴더 이름 변경(googleemailemail/google, slackslack/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

  • application-core: Channel, Notification, NotificationPort, NotificationPortContractTest — 등급: actually-implemented, locally-verified
  • adapter-outbound: NotificationProvider, FailOpenNotificationProvider, RoutingNotifier, RoutingNotifierTest — 등급: actually-implemented, locally-verified
  • NotificationRoutesSettings (@ConfigurationProperties("app.notification")) — 등급: actually-implemented
  • NotificationConfig 재작성 (ObjectProvider + FailOpen 중앙 래핑 + RoutingNotifier 빈) — 등급: actually-implemented
  • GoogleEmailProvider, GoogleEmailNotificationAdapterConfig 마이그레이션 — 등급: actually-implemented
  • SlackWebhookProvider, SlackNotificationAdapterConfig 마이그레이션 — 등급: actually-implemented
  • GoogleEmailClient, SlackClient seam: 임포트 app-core Notification으로 교체 — 등급: actually-implemented
  • 구 파일 11개 삭제 — 등급: actually-implemented
  • 테스트 4개 갱신 (NotificationAdapterTest, OptionalAdapterBeanGatingTest, DisabledAdapterSentinelTest, RoutingNotifierTest) — 등급: actually-implemented, locally-verified
  • 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> 구조로 이동 (googleemailemail/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/CacheBindingSettingsRoutingNotifier/NotificationConfig/NotificationRoutesSettings) 동일 선택 메커니즘(외부 설정 키 → provider) 필요 기존 cache 구현 코드(code-evidence) code-evidence relaxed binding이 Channel enum key를 emailEMAIL로 정확히 변환하는지 Spring Boot 3.4 동작 확인 필요
D3 RoutingNotifierFailOpenNotificationProvider 타입으로 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> 구조로 이동(googleemailemail/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

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이 emailEMAIL로 변환.

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: NotificationOutboundDependencyLogger에 전달되지 않음 — 생성자 타입 시그니처로 보장.
  • relaxed binding Channel key: Spring Boot 3.4 ApplicationConversionService가 emailEMAIL 변환 — OptionalAdapterBeanGatingTest에서 app.notification.routes.slack.default=slack-webhook 로 검증됨.

검증해야 할 주장

Claim Why uncertain How to verify Status
relaxed binding이 emailEMAIL 변환 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 verifyCleanArchitectureDependencies603/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:test175/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 타입화 결정, 활성화 플래그 명명 규칙