# Notification Delivery Platform 설계서 > 상태: 구현 기준 설계(Implementation-ready) > > 기준일: 2026-08-10 > > 요구사항 원본: `Notification 전달 플랫폼 심층 리서치` > > 목표 저장 위치: `docs/superpowers/specs/2026-08-10-notification-platform-design.md` --- ## 1. 문서 목적 이 문서는 Backend Skeleton의 `notification` 기술 모듈을 구현하기 위한 최종 설계 계약이다. 구현자는 이 문서와 대응 구현 계획서만으로 다음 내용을 다시 선택하거나 질문하지 않아야 한다. - Notification Core의 책임과 비지원 범위 - 공개 API 계층 N1~N4 - 논리 알림, 수신자 전달, 물리 시도의 식별자와 수명주기 - Provider 수락, 전달, 표시, 읽음의 증거 모델 - Contact Point, Template, Suppression, Preference, Consent 계약 - Scheduling, Retry, Fallback, Callback, Reconciliation의 실행 순서 - SMTP, SES, Twilio, FCM, APNs, Web Push, In-App Adapter의 지원 수준 - 데이터베이스 스키마와 트랜잭션 경계 - 보안, 개인정보, 관측성, 관리자 기능 - 계약·장애·보안·성능 테스트와 출시 조건 Notification Platform은 단순한 `send()` Wrapper가 아니다. 다음 전체 수명주기를 소유한다. ```text 알림 의도 접수 → 논리 요청의 내구성 있는 저장 → 수신자별 전달 작업 분해 → 실제 Contact Point와 채널 결정 → 템플릿 렌더링 → 억제·만료·할당량 재검사 → Provider 물리 시도 → 수락·거절·모호한 완료 기록 → 비동기 Callback·Receipt 수집 → 상태·증거 Projection → Retry·Fallback·Reconciliation·Redrive → 감사·Metric·Trace ``` 핵심 완료 조건은 “발송 API를 호출할 수 있다”가 아니다. > 누구에게 어떤 알림을 어떤 채널과 Provider로 몇 번 시도했는지, 각 시도에서 어디까지 확실히 확인됐는지, 무엇이 아직 불명확한지를 영속적으로 설명할 수 있어야 한다. --- ## 2. 목표와 성공 기준 ### 2.1 목표 1. 애플리케이션에 Email, SMS, Mobile Push, Web Push의 Typed API를 제공한다. 2. 하나의 논리 알림을 수신자와 채널별 전달 작업으로 안전하게 분해한다. 3. Provider의 동기 응답과 비동기 Callback을 동일한 증거 원장으로 결합한다. 4. 중복 API 요청, Provider 응답 유실, Callback 중복·역순·누락을 명시적으로 처리한다. 5. Provider SDK 객체와 상태 문자열을 Core 공개 계약에 노출하지 않는다. 6. Contact Point와 메시지 내용 등 민감정보가 로그·Metric·Trace에 노출되지 않게 한다. 7. 신규 Provider Adapter가 Core 계약을 바꾸지 않고 추가되게 한다. 8. 각 Stable Adapter가 동일한 공통 Contract Suite를 통과하게 한다. ### 2.2 성공 기준 - `submit()` 성공은 최종 전달이 아니라 **논리 요청의 내구성 있는 접수**만 뜻한다. - SES `MessageId`, Twilio `accepted`, FCM handoff, APNs HTTP 성공, Web Push `201`을 `DELIVERED`로 승격하는 코드가 없다. - `AMBIGUOUS` 시도에서 기본 자동 재전송·교차 채널 Fallback이 발생하지 않는다. - 동일 `(tenantId, idempotencyKey)`의 동시 요청이 하나의 `notificationId`로 수렴한다. - 같은 idempotency key에 다른 fingerprint가 들어오면 명시적 충돌을 반환한다. - Provider Callback이 중복 또는 역순으로 도착해도 Projection이 downgrade되지 않는다. - Callback이 누락된 Provider는 capability가 있을 때 Reconciliation으로 보정한다. - 예약 알림은 프로세스 재시작 후에도 누락되지 않고, 실제 dispatch 직전에 suppression과 expiry를 다시 검사한다. - Contact Point 원문은 암호화 저장되고 equality lookup은 HMAC fingerprint로 수행한다. - 모든 Retry는 오류 종류, 제출 증거, 남은 만료 시간, 예산, Contact Point 유효성을 함께 판정한다. - Provider 인증 실패가 개별 알림 Retry 폭풍으로 증폭되지 않는다. - In-App Inbox의 DB가 source of truth이며 WebSocket은 갱신 신호일 뿐이다. --- ## 3. 입력 자료의 제약과 구현 가정 ### 3.1 요구사항 원본이 확정한 사항 다음은 조사 결과에서 직접 확정된 설계 요구사항이다. - `NotificationRequest → RecipientDelivery → DeliveryAttempt → ProviderEvent → Evidence` 구조 - 단일 선형 상태 머신 대신 append-only event ledger와 channel-specific projector - Email SMTP·SES, SMS Twilio, Mobile Push FCM·APNs, 표준 Web Push를 Stable 대상으로 설정 - In-App Inbox는 선택 Stable 별도 모듈 - Webhook은 기존 `httpclient`를 사용하는 Extension - FCM target은 FID 우선, registration token은 legacy compatibility - N1 Typed, N2 Advanced, N3 Provider Extension, N4 Admin Plane - Idempotency, Deduplication, Collapse의 분리 - Stable 기본 Routing은 Explicit Channel과 제한형 Ordered Fallback - `AMBIGUOUS_SUBMISSION`에서 기본 Retry·Fallback 금지 - Core-owned durable scheduler - Callback signature 검증, 중복 제거, 역순 병합, Reconciliation - Contact Point 암호화·HMAC lookup과 생명주기 - Provider acceptance와 delivery/read 증거 분리 - 개인정보와 secret의 로그·Metric 비노출 ### 3.2 실제 저장소가 제공되지 않아 명시적으로 고정한 구현 가정 다음은 연구 원본에 없는 저장소·프레임워크 결정이다. 실제 Backend Skeleton에 적용할 때 패키지 경로와 dependency catalog만 매핑하고 공개 계약과 의미론은 유지한다. | 항목 | 고정값 | |---|---| | Java | Java 21 | | Build | Gradle Kotlin DSL | | Root package | `io.backend.skeleton.notification` | | Module root | `modules/notification` | | Spring 공통 컴파일 기준 | Spring Framework 6.2 | | Spring 호환성 검증 | Spring Framework 7.0 별도 CI job | | Spring Boot | Host 저장소의 dependency management 사용 | | Core async type | `CompletionStage` | | Reactive facade | 별도 `notification-reactor` 모듈 | | Metadata DB | PostgreSQL 16 | | Persistence | JPA + Flyway | | Dispatch queue | PostgreSQL SKIP LOCKED 기반 durable queue | | 외부 HTTP | 기존 `httpclient` 플랫폼 재사용 | | SMTP | Spring `JavaMailSender` 기반 Adapter | | Template engine | Engine-neutral API + Thymeleaf reference implementation | | Variable validation | JSON Schema 2020-12 | | Contact Point 보호 | AES-256-GCM encryption + HMAC-SHA-256 lookup fingerprint | | Secret 공급 | `SecretMaterialProvider` Port | | 관측성 | Micrometer Observation + OpenTelemetry exporter adapter | | Test | JUnit 5, AssertJ, ArchUnit, Testcontainers, WireMock, Toxiproxy | ### 3.3 적용 원칙 - 실제 저장소가 이미 사용하는 공통 ID, clock, transaction helper가 있으면 파일 경로만 조정한다. - 이미 존재하는 `httpclient`, `fileserver`, `objectstorage`, `messaging`, `websocket` 공개 계약을 재구현하지 않는다. - 상기 가정을 변경하더라도 Notification의 상태·증거·중복·보안 계약은 변경하지 않는다. --- ## 4. 범위 ### 4.1 Core 포함 범위 ```text Typed channel API Notification orchestration Request / Recipient / Attempt lifecycle Contact Point registry Template / Localization Idempotency Opt-in deduplication Collapse mapping Durable scheduling Expiration Suppression primitive Preference / Consent record primitive Provider dispatch Retry / rate limit / backpressure Callback / Receipt ingestion Reconciliation Event ledger / projection Metrics / tracing / audit Admin redrive / reconcile / provider control ``` ### 4.2 채널별 Stable 범위 | 채널 | 기준 구현 | 등급 | Core가 기본적으로 인정하는 최대 증거 | |---|---|---:|---| | Email | SMTP + Amazon SES API | Stable | Provider 수락, 제공되는 경우 recipient mail server 전달·bounce·complaint | | SMS | Twilio Programmable Messaging | Stable | accepted/queued, sent, carrier DLR 기반 delivered/undelivered | | FCM | FID 우선 + legacy token compatibility | Stable | FCM handoff/acceptance와 명시적 실패 | | APNs | HTTP/2 Provider API | Stable | APNs acceptance | | Web Push | RFC 8030/8291/8292 | Stable | Push service acceptance, capability가 있을 때 receipt | | In-App | 자체 DB | 선택 Stable | Persisted, Seen, Read | | Webhook | 기존 httpclient 재사용 | Extension | 상대 HTTP 계약에 따름 | ### 4.3 Experimental 범위 ```text Provider failover after ambiguous attempt Parallel first-success Provider-native scheduling FCM topic/condition의 범용 N2 노출 APNs broadcast/live activity channel Application-generated push delivery receipt Web Push provider-specific receipt Kakao 알림톡, WhatsApp, RCS, MMS, Slack, Teams, Discord Adapter ``` ### 4.4 명시적 비지원 ```text 업무 대상자 선정 캠페인 segmentation 무료·유료 회원별 채널 정책 국가별 법률 판정 Guaranteed delivery Guaranteed read Exactly-once human notification Provider SDK raw type의 일반 공개 파일 내용 저장 브라우저 WebSocket 연결 자체 시스템 간 일반 Messaging Notification Core 자체 HTTP stack Provider 응답이 없는 상태의 무조건 자동 failover ``` --- ## 5. 핵심 설계 원칙 1. **내구성 우선:** Provider 호출 전에 논리 요청과 수신자 전달을 DB에 커밋한다. 2. **증거 우선:** 상태 이름보다 어떤 증거를 확보했는지를 저장한다. 3. **모호성 은폐 금지:** 결과를 알 수 없으면 `AMBIGUOUS`로 기록한다. 4. **채널 차이 보존:** 공통 골격은 통일하지만 Provider 의미를 거짓으로 평탄화하지 않는다. 5. **Callback 원장:** Provider Event는 append-only로 먼저 저장한 뒤 Projection한다. 6. **중복 정상화:** API retry, Provider retry, Callback 중복, 운영 Redrive를 정상 failure mode로 취급한다. 7. **안전한 기본값:** ambiguous retry, ambiguous fallback, provider-native schedule, parallel first-success는 기본 비활성이다. 8. **정책 주입:** 업무·법률 판단은 Port로 주입하고 Core는 결과를 실행·기록한다. 9. **민감정보 최소화:** 주소·token·본문을 식별자·로그·Metric label로 사용하지 않는다. 10. **운영자 분리:** Redrive, 강제 억제 해제, Provider disable, credential rotation은 N4 전용이다. --- ## 6. 전체 아키텍처 ```text Application │ ├─ N1 Typed API │ ├─ EmailNotifier │ ├─ SmsNotifier │ ├─ MobilePushNotifier │ └─ WebPushNotifier │ └─ N2 NotificationOrchestrator ├─ submit ├─ schedule ├─ cancel └─ getSnapshot │ ▼ ┌─────────────────────────────────────────────┐ │ Notification Core │ │ │ │ NotificationRequest │ │ └─ RecipientDelivery │ │ └─ DeliveryAttempt │ │ │ │ Template / ContactPoint / Suppression │ │ Idempotency / Scheduling / Routing │ │ Retry / RateLimit / Backpressure │ └─────────────────────┬───────────────────────┘ │ ▼ Provider Adapter SPI ┌──────────────┼──────────────┐ ▼ ▼ ▼ SMTP / SES Twilio FCM / APNs └──────────────┼──────────────┘ ▼ ProviderEvent Ledger │ ▼ Channel Projectors │ ▼ Submission / Delivery / Evidence ``` ### 6.1 Submit 흐름 ```text 1. API 입력 검증 2. tenant + idempotency key 조회 3. request fingerprint 비교 4. template version과 recipient/contact reference 고정 5. NotificationRequest 생성 6. RecipientDelivery 생성 7. scheduleAt에 따라 SCHEDULED 또는 READY_TO_DISPATCH 8. 동일 DB transaction commit 9. NotificationReceipt 반환 ``` `NotificationReceipt`는 Provider 결과를 포함하지 않는다. ### 6.2 Dispatch 흐름 ```text 1. Durable queue lease 획득 2. expiresAt 검사 3. Contact Point 상태 재조회 4. Suppression·Eligibility 재검사 5. Template version 고정 상태 확인 또는 Rendered snapshot 조회 6. Routing Plan에서 다음 Route 선택 7. Provider Profile health / rate / concurrency permit 획득 8. DeliveryAttempt를 DISPATCHING으로 생성 9. Provider Adapter 호출 10. CONFIRMED / REJECTED / AMBIGUOUS 기록 11. Retry / Fallback / Reconciliation 정책 판정 12. Recipient와 Notification Projection 갱신 13. lease 해제 ``` ### 6.3 Callback 흐름 ```text 1. Content-Type·크기 검증 2. Provider signature 검증 3. 원본 event를 bounded encrypted form으로 append 4. provider event ID 또는 fingerprint로 dedup 5. providerRequestId를 DeliveryAttempt에 매핑 6. Provider-native status를 stable event로 normalize 7. Channel projector 실행 8. Contact Point invalidation·suppression side effect 적용 9. Metric·Audit·내부 event 발행 10. 빠른 2xx 응답 ``` ### 6.4 Reconciliation 흐름 ```text 1. Provider별 reconcile 가능한 오래된 Attempt 조회 2. Provider Profile별 rate limit 적용 3. Provider status query 4. synthetic ProviderEvent를 원장에 append 5. 동일 projector 실행 6. 변경된 projection과 correction audit 기록 ``` --- ## 7. 모듈 구조 ```text modules/notification/ ├── notification-core-api ├── notification-content-api ├── notification-template-api ├── notification-template-thymeleaf ├── notification-contact-api ├── notification-policy ├── notification-provider-spi ├── notification-persistence-jpa ├── notification-dispatch-runtime ├── notification-callback-api ├── notification-callback-mvc ├── notification-callback-webflux ├── notification-email-api ├── notification-email-smtp ├── notification-email-ses ├── notification-sms-api ├── notification-sms-twilio ├── notification-push-api ├── notification-push-fcm ├── notification-push-apns ├── notification-webpush ├── notification-inbox-api ├── notification-inbox-jpa ├── notification-webhook-extension ├── notification-observability ├── notification-security ├── notification-admin-api ├── notification-admin-runtime ├── notification-reactor ├── notification-spring-boot-starter └── notification-testkit ``` ### 7.1 의존 방향 ```text *-api ↑ provider-spi / policy ↑ persistence-jpa / dispatch-runtime / adapter implementations ↑ starter / application ``` 금지 의존성: ```text core-api → Spring MVC / WebFlux / JPA / Provider SDK content-api → Provider SDK provider-spi → concrete Provider SDK email-api → SES SDK push-api → Firebase/APNs client type ``` ### 7.2 ArchUnit 규칙 - `..core..`, `..content..`, `..contact..`에서 `com.google.firebase`, `com.twilio`, `software.amazon.awssdk`, `jakarta.mail.internet.MimeMessage`를 참조하면 실패한다. - N1 API package가 `Map`를 공개 request type으로 사용하면 실패한다. - Adapter 구현이 다른 Adapter 구현에 직접 의존하면 실패한다. - N4 Admin package를 일반 starter가 기본 공개하지 않는다. --- ## 8. 공개 API 계층 ### 8.1 N1 Typed API ```java public interface EmailNotifier { NotificationReceipt send(EmailNotification notification); } public interface SmsNotifier { NotificationReceipt send(SmsNotification notification); SmsEstimate estimate(SmsNotification notification); } public interface MobilePushNotifier { NotificationReceipt send(MobilePushNotification notification); } public interface WebPushNotifier { NotificationReceipt send(WebPushNotification notification); } ``` N1 규칙: - 명시적 단일 채널만 사용한다. - durable accept까지 동기적으로 완료한다. - Provider 호출 완료를 기다리지 않는다. - Provider SDK type과 임의 provider option map을 받지 않는다. ### 8.2 N2 Advanced API ```java public interface NotificationOrchestrator { NotificationReceipt submit(NotificationPlan plan); NotificationReceipt schedule(NotificationPlan plan, Instant scheduleAt); CancelResult cancel(NotificationId notificationId, CancelCommand command); NotificationSnapshot get(NotificationId notificationId); } ``` N2에서 허용하는 기능: ```text Ordered fallback Multi-recipient Best-effort multi-channel Batch submission Scheduling Cancel Opt-in deduplication Collapse hint ``` ### 8.3 N3 Provider Extension ```java public interface ProviderExtension { ProviderId providerId(); Optional capability(Class type); } ``` N3 예: ```text SES Configuration Set Twilio Messaging Service SID FCM Topic / Condition APNs Push Type / Collapse ID Web Push Urgency / Topic ``` N3도 다음 공통 정책을 우회하지 못한다. ```text Contact Point 보호 Provider Profile 고정 Credential 관리 Expiry Payload 제한 Observation Audit ``` ### 8.4 N4 Admin Plane ```java public interface NotificationAdminService { AdminOperationResult redrive(RedriveCommand command, AdminActor actor); AdminOperationResult reconcile(ReconcileCommand command, AdminActor actor); AdminOperationResult suppress(SuppressCommand command, AdminActor actor); AdminOperationResult setProviderState(SetProviderStateCommand command, AdminActor actor); } ``` 모든 N4 operation은 다음을 요구한다. ```text 별도 authority 사유 코드 dry-run 지원 가능 여부 bounded batch 감사 로그 idempotent operation ID ``` --- ## 9. 핵심 식별자와 공통 타입 ```java public record NotificationId(UUID value) {} public record RecipientDeliveryId(UUID value) {} public record DeliveryAttemptId(UUID value) {} public record ProviderEventId(String value) {} public record ContactPointId(UUID value) {} public record TemplateId(String value) {} public record TemplateVersion(long value) {} public record ProviderProfileId(String value) {} public record CorrelationId(String value) {} public record TenantId(String value) {} public record IdempotencyKey(String value) {} ``` 규칙: - 내부 UUID는 UUIDv7을 사용한다. - Provider ID를 내부 ID로 사용하지 않는다. - Contact Point 원문을 ID로 사용하지 않는다. - `providerRequestId`가 없는 Attempt도 허용한다. - `ProviderEventId`가 없는 Callback은 deterministic fingerprint로 대체한다. - Metric label에는 위 고카디널리티 ID를 넣지 않는다. --- ## 10. Content 타입 ```java public sealed interface NotificationContent permits EmailContent, SmsContent, MobilePushContent, WebPushContent, InAppContent { } public record EmailContent( String subject, String textBody, Optional htmlBody, List attachments, EmailOptions options ) implements NotificationContent {} public record SmsContent( String text, SmsOptions options ) implements NotificationContent {} public record MobilePushContent( String title, String body, Optional deepLink, Map data, PushPresentation presentation ) implements NotificationContent {} public record WebPushContent( String title, String body, Optional deepLink, Map data, WebPushOptions options ) implements NotificationContent {} public record InAppContent( String title, String body, Optional deepLink, List actions, String category ) implements NotificationContent {} ``` 공통 금지: ```text provider SDK object arbitrary Map options raw credential unbounded header binary attachment bytes ``` --- ## 11. Notification Plan과 Routing 요청 ```java public record NotificationPlan( TenantId tenantId, IdempotencyKey idempotencyKey, String category, TemplateSelection template, List recipients, DeliveryStrategy deliveryStrategy, Optional notBefore, Optional expiresAt, Optional deduplication, Optional collapse, CorrelationId correlationId, Map boundedMetadata ) {} ``` ### 11.1 RecipientSpec ```java public record RecipientSpec( String recipientRef, Optional locale, Optional timeZone, List contactPoints, Optional channelOverride ) {} ``` `recipientRef`는 애플리케이션의 stable reference지만 Contact Point 원문이 아니다. Notification Platform은 이 reference를 Metric label로 사용하지 않는다. ### 11.2 DeliveryStrategy ```java public sealed interface DeliveryStrategy permits ExplicitChannel, OrderedFallback, ParallelMultiChannel, AllRequired, BestEffort { } public record ExplicitChannel(Channel channel) implements DeliveryStrategy {} public record OrderedFallback(List channels) implements DeliveryStrategy {} ``` Stable 기본: ```text ExplicitChannel OrderedFallback ``` Experimental: ```text ParallelMultiChannel FirstSuccess Provider failover after ambiguous submission ``` ### 11.3 Metadata 제한 - key는 등록된 allowlist에 포함되어야 한다. - 최대 key 개수 16개, key 64 bytes, value 256 bytes를 기본 hard limit으로 둔다. - Email, phone, token, 사용자 이름, credential을 metadata에 넣지 않는다. - Header·Metric·Trace로 자동 전파하지 않는다. --- ## 12. 영속 데이터 모델 ### 12.1 NotificationRequest | 필드 | 타입 | 제약 | |---|---|---| | `id` | UUIDv7 | PK | | `tenant_id` | varchar(100) | 필수 | | `idempotency_key` | varchar(200) | tenant와 unique | | `request_fingerprint` | char(64) | SHA-256 canonical fingerprint | | `category` | varchar(120) | bounded registry | | `template_id` | varchar(160) | 필수 | | `template_version` | bigint | immutable | | `strategy_type` | varchar(40) | 필수 | | `schedule_at` | timestamptz | nullable | | `not_before` | timestamptz | nullable | | `expires_at` | timestamptz | nullable | | `request_status` | varchar(40) | projection | | `correlation_id` | varchar(160) | nullable | | `metadata_json` | jsonb | bounded, PII 금지 | | `created_at` | timestamptz | 필수 | | `updated_at` | timestamptz | 필수 | | `version` | bigint | optimistic lock | Unique: ```sql UNIQUE (tenant_id, idempotency_key) ``` ### 12.2 RecipientDelivery | 필드 | 타입 | 제약 | |---|---|---| | `id` | UUIDv7 | PK | | `notification_id` | UUID | FK | | `recipient_ref` | varchar(200) | 암호화 또는 pseudonymous reference | | `locale` | varchar(35) | nullable | | `time_zone` | varchar(80) | nullable | | `routing_plan_json` | jsonb | immutable snapshot | | `route_cursor` | integer | 현재 route | | `delivery_state` | varchar(40) | projection | | `submission_outcome` | varchar(40) | projection | | `delivery_outcome` | varchar(40) | projection | | `evidence_level` | varchar(50) | projection | | `ambiguous_attempt_exists` | boolean | 필수 | | `duplicate_risk` | boolean | 필수 | | `next_dispatch_at` | timestamptz | queue index | | `lease_owner` | varchar(120) | nullable | | `lease_until` | timestamptz | nullable | | `attempt_count` | integer | 필수 | | `last_failure_category` | varchar(50) | nullable | | `created_at` | timestamptz | 필수 | | `updated_at` | timestamptz | 필수 | | `version` | bigint | optimistic lock | Indexes: ```sql CREATE INDEX ix_recipient_dispatch ON notification_recipient_delivery(next_dispatch_at, id) WHERE delivery_state IN ('PENDING', 'READY_TO_DISPATCH', 'RETRY_WAITING'); CREATE INDEX ix_recipient_notification ON notification_recipient_delivery(notification_id, id); ``` ### 12.3 DeliveryAttempt | 필드 | 타입 | 제약 | |---|---|---| | `id` | UUIDv7 | PK | | `recipient_delivery_id` | UUID | FK | | `attempt_no` | integer | recipient와 unique | | `channel` | varchar(40) | 필수 | | `provider_profile_id` | varchar(120) | 필수 | | `provider_request_id` | varchar(300) | nullable, encrypted/hash index 정책 | | `request_started` | boolean | 필수 | | `request_body_committed` | boolean | 필수 | | `provider_response_received` | boolean | 필수 | | `submission_outcome` | varchar(40) | 필수 | | `delivery_outcome` | varchar(40) | 필수 | | `confirmation` | varchar(30) | CONFIRMED/REJECTED/AMBIGUOUS | | `evidence_level` | varchar(50) | 필수 | | `failure_category` | varchar(50) | nullable | | `failure_code` | varchar(120) | nullable | | `native_status` | varchar(160) | nullable | | `provider_occurred_at` | timestamptz | nullable | | `started_at` | timestamptz | 필수 | | `completed_at` | timestamptz | nullable | | `elapsed_ms` | bigint | nullable | | `credential_generation` | bigint | 필수 | | `rendered_content_digest` | char(64) | 필수 | | `created_at` | timestamptz | 필수 | | `updated_at` | timestamptz | 필수 | | `version` | bigint | optimistic lock | ### 12.4 ProviderEvent | 필드 | 타입 | 제약 | |---|---|---| | `id` | UUIDv7 | PK | | `provider_profile_id` | varchar(120) | 필수 | | `provider_event_id` | varchar(300) | nullable | | `event_fingerprint` | char(64) | 필수 | | `provider_request_id_hash` | char(64) | nullable | | `attempt_id` | UUID | nullable, FK | | `event_type` | varchar(120) | normalized | | `provider_native_type` | varchar(160) | 필수 | | `provider_occurred_at` | timestamptz | nullable | | `received_at` | timestamptz | 필수 | | `signature_verified` | boolean | 필수 | | `raw_payload_ciphertext` | bytea | bounded nullable | | `raw_payload_digest` | char(64) | 필수 | | `normalized_payload_json` | jsonb | bounded | | `projection_status` | varchar(40) | PENDING/APPLIED/IGNORED/FAILED | | `projection_error_code` | varchar(120) | nullable | Unique: ```sql UNIQUE (provider_profile_id, provider_event_id) WHERE provider_event_id IS NOT NULL; UNIQUE (provider_profile_id, event_fingerprint); ``` ### 12.5 ContactPoint | 필드 | 타입 | 제약 | |---|---|---| | `id` | UUIDv7 | PK | | `tenant_id` | varchar(100) | 필수 | | `owner_ref` | varchar(200) | 필수 | | `type` | varchar(50) | EMAIL/PHONE/FCM_FID/... | | `provider` | varchar(80) | nullable | | `application_id` | varchar(120) | nullable | | `environment` | varchar(40) | 필수 | | `ciphertext` | bytea | AES-GCM | | `lookup_hmac` | char(64) | equality lookup | | `key_id` | varchar(120) | 필수 | | `status` | varchar(40) | 생명주기 | | `verified` | boolean | 필수 | | `last_confirmed_at` | timestamptz | nullable | | `last_successful_delivery_at` | timestamptz | nullable | | `invalidated_at` | timestamptz | nullable | | `invalidation_reason` | varchar(120) | nullable | | `locale` | varchar(35) | nullable | | `time_zone` | varchar(80) | nullable | | `created_at` | timestamptz | 필수 | | `updated_at` | timestamptz | 필수 | | `version` | bigint | optimistic lock | Unique scope: ```sql UNIQUE (tenant_id, type, provider, application_id, environment, lookup_hmac) ``` ### 12.6 Template·Suppression·Preference·Consent·Inbox 별도 테이블: ```text notification_template notification_template_version notification_suppression notification_preference notification_consent notification_inbox_item notification_inbox_user_state notification_admin_audit notification_reconciliation_job ``` 정확한 Flyway DDL은 구현 계획 Task에서 파일 단위로 고정한다. --- ## 13. 상태·증거 모델 ### 13.1 RequestStatus ```java public enum RequestStatus { CREATED, VALIDATED, SCHEDULED, PROCESSING, PARTIALLY_COMPLETED, COMPLETED, CANCELED, EXPIRED, FAILED } ``` 이 enum은 논리 요청 Projection이며 Provider 상태를 직접 표현하지 않는다. ### 13.2 RecipientDeliveryState ```java public enum RecipientDeliveryState { PENDING, READY_TO_DISPATCH, SUPPRESSED, DISPATCHING, RETRY_WAITING, RECONCILIATION_REQUIRED, COMPLETED, FAILED, EXPIRED, CANCELED } ``` ### 13.3 Attempt 결과 ```java public enum SubmissionOutcome { NOT_SUBMITTED, CONFIRMED_ACCEPTED, CONFIRMED_REJECTED, AMBIGUOUS } public enum DeliveryOutcome { UNKNOWN, SENT, DELIVERED, UNDELIVERED, BOUNCED, EXPIRED } public enum AttemptConfirmation { CONFIRMED, REJECTED, AMBIGUOUS } public enum EvidenceLevel { NONE, PLATFORM_QUEUED, PROVIDER_ACCEPTED, NETWORK_OR_CARRIER_ACCEPTED, DEVICE_DELIVERED, USER_AGENT_DISPLAYED, USER_READ } ``` ### 13.4 독립 Fact 다음은 Delivery Outcome과 독립적으로 저장한다. ```java public record EngagementFacts( boolean opened, boolean clicked, boolean read, Instant lastEngagedAt ) {} public record SuppressionFacts( boolean hardBounced, boolean complained, boolean providerSuppressed, boolean userOptedOut ) {} ``` Email이 전달된 뒤 complaint가 발생해도 `DELIVERED` 사실을 지우지 않고 complaint fact를 추가한다. ### 13.5 Evidence 승격 규칙 | Provider 이벤트 | 최대 Evidence | |---|---| | 내부 queue commit | PLATFORM_QUEUED | | SES MessageId | PROVIDER_ACCEPTED | | SES Delivery | NETWORK_OR_CARRIER_ACCEPTED | | Twilio accepted/queued | PROVIDER_ACCEPTED | | Twilio sent | NETWORK_OR_CARRIER_ACCEPTED | | Twilio delivered | DEVICE_DELIVERED | | FCM send success | PROVIDER_ACCEPTED | | APNs 2xx | PROVIDER_ACCEPTED | | Web Push 201 | PROVIDER_ACCEPTED | | Web Push receipt capability | DEVICE_DELIVERED | | In-App row commit | PROVIDER_ACCEPTED | | In-App seen endpoint | USER_AGENT_DISPLAYED | | In-App read endpoint | USER_READ | | 앱 interaction receipt | USER_READ 또는 USER_AGENT_DISPLAYED | 금지: ```text FCM success → DEVICE_DELIVERED APNs 2xx → DELIVERED SES MessageId → DELIVERED SMTP 250 → inbox delivered ``` --- ## 14. ProviderEvent 원장과 Projection ### 14.1 원장 규칙 1. Callback은 Projection 전에 원장에 append한다. 2. 원장 row가 commit되지 않으면 2xx를 반환하지 않는다. 3. 동일 Provider event ID 또는 fingerprint는 두 번째부터 no-op이다. 4. 원본 payload는 제한된 크기만 암호화 저장한다. 5. 알 수 없는 필드는 raw payload에 보존하고 typed parser는 무시한다. 6. Projection 실패는 원장 row를 지우지 않고 `FAILED`로 기록한다. 7. Projector 재실행은 idempotent해야 한다. ### 14.2 Channel projector ```java public interface ProviderEventProjector { ProviderId providerId(); ProjectionResult project( DeliveryAttemptSnapshot attempt, ProviderEventRecord event, DeliveryProjection current ); } ``` ### 14.3 Merge 규칙 단일 ordinal 비교를 금지한다. ```text sent → delivered 허용 delivered → sent 무시 delivered → complaint complaint fact 추가 complaint → delivered complaint 유지 accepted → bounced 허용 read → displayed read 유지 invalid-recipient → accepted invalidation을 자동 철회하지 않음 ``` 판정 입력: ```text normalized event type provider occurredAt receivedAt existing terminal facts evidence strength provider-specific transition table ``` ### 14.4 Attempt를 찾지 못한 이벤트 - provider request ID hash로 재검색한다. - 일정 기간 `UNMATCHED` queue에 보관한다. - Reconciliation 또는 늦은 Attempt response 등록 후 재매칭한다. - 보관 기간 만료 시 운영 alert와 audit를 남긴다. --- ## 15. 트랜잭션 경계와 불변 조건 ### 15.1 Submit transaction 동일 DB transaction: ```text NotificationRequest INSERT RecipientDelivery INSERT N개 idempotency unique 획득 initial dispatch schedule 설정 ``` Provider 호출은 포함하지 않는다. ### 15.2 Dispatch lease transaction ```sql SELECT id FROM notification_recipient_delivery WHERE next_dispatch_at <= now() AND delivery_state IN ('READY_TO_DISPATCH', 'RETRY_WAITING') AND (lease_until IS NULL OR lease_until < now()) ORDER BY next_dispatch_at, id FOR UPDATE SKIP LOCKED LIMIT :batchSize; ``` 같은 transaction에서 `lease_owner`, `lease_until`, `DISPATCHING`을 기록한다. ### 15.3 Provider 호출 경계 Provider 호출은 DB transaction 밖에서 수행한다. 호출 전 Attempt를 `DISPATCHING`으로 commit하고, 호출 후 별도 transaction에서 outcome을 기록한다. Crash 시나리오: ```text Attempt row commit 전 crash → Provider 호출 없음 Attempt row commit 후 Provider 호출 전 crash → requestStarted=false → safe lease recovery Provider accepted 후 process crash → Attempt가 DISPATCHING으로 남음 → ambiguous/reconciliation recovery Outcome commit 후 crash → projector와 next action은 idempotent replay ``` ### 15.4 핵심 invariant - NotificationReceipt가 반환된 request는 DB에 존재한다. - 하나의 RecipientDelivery에는 동시에 하나의 유효 lease만 있다. - `attempt_no`는 RecipientDelivery 안에서 단조 증가한다. - Provider 호출 전 Attempt row가 존재한다. - `AMBIGUOUS` Attempt가 존재하면 자동 cross-channel fallback을 시작하지 않는다. - Expired RecipientDelivery에는 새 Attempt를 만들지 않는다. - Suppressed Contact Point에는 새 Attempt를 만들지 않는다. - Callback event는 삭제·덮어쓰기하지 않는다. - Projection은 event ledger에서 재생 가능하다. --- ## 16. Contact Point 계약 ### 16.1 Typed hierarchy ```java public sealed interface ContactPointValue permits EmailAddress, PhoneNumber, MobilePushTarget, WebPushSubscriptionValue, InAppRecipientRef { } public sealed interface MobilePushTarget permits FcmInstallationId, LegacyFcmRegistrationToken, ApnsDeviceToken { } ``` ```java public record FcmInstallationId(String value) implements MobilePushTarget {} public record LegacyFcmRegistrationToken(String value) implements MobilePushTarget {} public record ApnsDeviceToken(String value, ApnsEnvironment environment) implements MobilePushTarget {} ``` ### 16.2 상태 ```java public enum ContactPointStatus { UNVERIFIED, ACTIVE, STALE, INVALID, SUPPRESSED, REVOKED, DELETED } ``` ### 16.3 등록·갱신 규칙 - normalized value를 HMAC fingerprint로 조회한다. - 원문은 AES-GCM으로 암호화한다. - 같은 token이 다른 owner에 등록되면 소유권 이전 정책을 명시적으로 수행한다. - APNs sandbox와 production을 별 Contact Point로 본다. - FCM project/application identity를 Provider Profile에 고정한다. - invalid target 응답은 Contact Point를 `INVALID`로 전환한다. - 사용자 재등록은 새 확인 증거로 `ACTIVE` 전환할 수 있다. - 주소 원문을 API 응답에 반환하지 않고 masked display만 제공한다. ### 16.4 암호화 계약 ```java public interface SecretMaterialProvider { SecretKeyMaterial activeKey(SecretPurpose purpose); SecretKeyMaterial keyById(String keyId); } public interface ContactPointProtector { ProtectedContactPoint protect(ContactPointValue value); ContactPointValue reveal(ProtectedContactPoint protectedValue, AccessContext context); String fingerprint(ContactPointValue value); } ``` 암호문에는 다음을 포함한다. ```text keyId nonce ciphertext authenticationTag normalizedType ``` HMAC key와 encryption key는 분리한다. --- ## 17. Template·Localization 계약 ### 17.1 Template 모델 ```java public record NotificationTemplateVersion( TemplateId templateId, TemplateVersion version, Channel channel, Locale locale, Optional fallbackLocale, VariableSchema variableSchema, TemplateContentDefinition content, TemplateStatus status, String contentDigest ) {} ``` ### 17.2 발송 시 고정값 ```text templateId templateVersion locale normalized variables renderedContentDigest ``` Retry와 Redrive에서 최신 Template을 자동 재선택하지 않는다. ### 17.3 Locale 해석 순서 ```text Recipient exact locale → language-only locale → template fallback locale → platform default locale → TEMPLATE_NOT_FOUND ``` ### 17.4 Variable 검증 - JSON Schema 2020-12를 사용한다. - Provider 호출 전에 검증한다. - 누락 필수 변수와 잘못된 타입은 non-retryable이다. - secret/PII classification이 있는 변수는 로그·preview에서 마스킹한다. - Template Version publish 전에 sample payload snapshot test를 실행한다. ### 17.5 Renderer SPI ```java public interface NotificationTemplateRenderer { Channel channel(); RenderedNotificationContent render(RenderCommand command); } ``` Thymeleaf는 reference implementation일 뿐 Core API에 engine type을 노출하지 않는다. ### 17.6 Client-side push localization FCM/APNs client resource key 기반 localization은 N3 capability로 둔다. 감사 가능한 정확한 문구가 필요한 알림은 서버 렌더링을 기본으로 한다. --- ## 18. Attachment 계약 ```java public record AttachmentRef( String contentReference, String displayName, String contentType, long expectedSize, String expectedDigest, AttachmentDisposition disposition ) {} ``` 규칙: - bytes를 Notification DB에 저장하지 않는다. - `fileserver` 또는 `objectstorage` reference만 사용한다. - Provider dispatch 직전에 권한과 READY 상태를 검증한다. - 최대 개수, 단일 크기, 총 크기를 Provider Profile로 제한한다. - Retry 시 같은 immutable content digest를 사용한다. - signed public URL을 Message body나 audit에 저장하지 않는다. - attachment 읽기 실패는 Provider 호출 전이면 명확한 reject이며 Provider 호출 후이면 시도 증거에 따라 판정한다. --- ## 19. Idempotency·Deduplication·Collapse ### 19.1 Idempotency 질문: ```text 같은 API 요청을 다시 보낸 것인가? ``` Key scope: ```text (tenantId, idempotencyKey) ``` Fingerprint 입력: ```text category templateId + version recipient specification strategy scheduleAt / notBefore / expiresAt canonical variables digest bounded metadata ``` 결과: | 상황 | 결과 | |---|---| | key 없음 | 새 request 생성 | | 같은 key + 같은 fingerprint | 기존 NotificationReceipt 반환 | | 같은 key + 다른 fingerprint | `IdempotencyConflictException` | | 동시 INSERT | unique constraint 승자 조회 후 fingerprint 비교 | Provider actual side effect 중복까지 제거한다고 광고하지 않는다. ### 19.2 Deduplication 질문: ```text 서로 다른 API 요청이지만 일정 기간 같은 사용자 알림으로 볼 것인가? ``` ```java public record DeduplicationSpec( String dedupKey, Duration window, DeduplicationAction action ) {} ``` Scope: ```text tenant + recipient + category + dedupKey + time bucket ``` 업무 의미가 개입되므로 opt-in이다. ### 19.3 Collapse·Coalescing 질문: ```text Provider에 아직 전달되지 않은 이전 알림을 최신 알림으로 대체할 것인가? ``` ```java public record CollapseSpec(String key, CollapseScope scope) {} ``` Adapter mapping: ```text FCM Android collapse key APNs apns-collapse-id Web Push Topic ``` Collapse는 이미 전달된 알림을 취소하거나 사용자가 하나만 받는다고 보장하지 않는다. --- ## 20. Scheduling·TTL·Expiration ### 20.1 Core-owned scheduler Stable 기본은 DB 기반 durable scheduler다. ```text SCHEDULED → scheduleAt 도달 → READY_TO_DISPATCH → lease claim → Provider dispatch ``` Provider-native scheduling은 N3 Experimental이다. ### 20.2 시간 필드 ```text scheduleAt - 플랫폼이 작업을 활성화할 시각 notBefore - 이보다 앞서 Provider에 제출하면 안 되는 시각 expiresAt - 이 시각 이후 새 Attempt·Retry·Fallback을 금지 ``` ### 20.3 Provider TTL 계산 ```text providerTTL = min( expiresAt - now, providerMaximumTTL, channelPolicyMaximumTTL ) ``` Web Push는 TTL header 필수다. ### 20.4 Retry 전 시간 검사 ```text now + nextBackoff + estimatedDispatchDuration < expiresAt ``` 거짓이면 `EXPIRED`로 종료한다. ### 20.5 Cancel | 시점 | 동작 | |---|---| | Provider Attempt 생성 전 | 취소 가능 | | Attempt가 NOT_SUBMITTED | 취소 가능 | | Provider accepted | 외부 부작용 취소를 보장하지 않음 | | Ambiguous | 논리 후속 시도만 중단, 기존 부작용은 불명 | | Provider native schedule | capability가 취소를 증명할 때만 Provider cancel 호출 | --- ## 21. Suppression·Preference·Consent 세 개를 분리한다. ```text Preference - 사용자의 선호 채널·빈도 Consent - 외부 정책 판단에 필요한 동의 기록 Suppression - 현재 발송을 기술적으로 차단하는 상태 ``` ### 21.1 Suppression 모델 ```java public record SuppressionEntry( SuppressionId id, TenantId tenantId, SuppressionScope scope, SuppressionReason reason, String normalizedTargetHmac, Optional notificationCategory, Instant effectiveAt, Optional expiresAt, SuppressionSource source ) {} ``` Reason: ```text USER_OPT_OUT HARD_BOUNCE COMPLAINT INVALID_TOKEN INVALID_PHONE ADMIN_BLOCK PROVIDER_BLOCK TEMPORARY_SUPPRESSION ``` ### 21.2 Eligibility Port ```java public interface NotificationEligibilityPolicy { EligibilityResult evaluate(NotificationContext context); } ``` Core가 판단하지 않는 항목: ```text 광고 여부 국가별 법률 보안 알림의 opt-out 가능 여부 야간 제한 회원 등급별 채널 우선순위 ``` ### 21.3 판정 순서 ```text Internal mandatory suppression OR Provider suppression OR injected eligibility=false → dispatch 금지 ``` Submit 시점과 dispatch 직전에 모두 실행한다. dispatch 직전 판정이 최종이다. --- ## 22. Routing·Fallback ### 22.1 Stable 전략 ```text EXPLICIT_CHANNEL ORDERED_FALLBACK ``` ### 22.2 Attempt 결과별 기본 처리 | 결과 | 동일 채널 Retry | 다음 채널 Fallback | |---|---:|---:| | Provider 호출 전 validation 실패 | X | Contact Point가 있으면 O | | INVALID_RECIPIENT | X | O | | THROTTLED | O | 기본 X | | 명백한 transient, 수락 전 | O | 정책에 따라 O | | CONFIRMED_REJECTED | 오류 종류에 따라 | O | | CONFIRMED_ACCEPTED | X | 기본 X | | AMBIGUOUS | 기본 X | 기본 X | | DELIVERED | X | X | ### 22.3 Route 상태 ```java public record RoutingDecision( Optional selected, boolean retryAllowed, boolean fallbackAllowed, boolean reconciliationRequired, boolean duplicateRisk, String reasonCode ) {} ``` ### 22.4 Parallel 전략 `PARALLEL_MULTI_CHANNEL`, `FIRST_SUCCESS`는 duplicate-tolerant 알림에서만 Experimental로 허용한다. Provider 제출 후 취소 불가능성을 API 문서와 audit에 표시한다. ### 22.5 Fallback invariant ```text ambiguousAttemptExists = true → automatic fallback prohibited ``` 운영자 강제 Fallback은 N4에서 사유와 duplicate risk 승인 후만 수행한다. --- ## 23. Provider Adapter SPI ```java public interface NotificationProviderAdapter { ProviderId providerId(); Set channels(); ProviderCapabilities capabilities(); CompletionStage submit(ProviderSubmission submission); } ``` ```java public record ProviderSubmission( DeliveryAttemptId attemptId, ProviderProfileSnapshot profile, ProtectedContactPoint contactPoint, RenderedNotificationContent content, Instant expiresAt, Optional providerIdempotencyKey, Map approvedNativeOptions, TraceContext traceContext ) {} ``` ```java public record ProviderSubmissionResult( AttemptConfirmation confirmation, SubmissionOutcome submissionOutcome, EvidenceLevel evidenceLevel, Optional providerRequestId, Optional nativeStatus, Optional failure, ProviderExecutionEvidence executionEvidence, Duration elapsed ) {} ``` ### 23.1 Execution Evidence ```java public record ProviderExecutionEvidence( boolean requestStarted, boolean requestBodyCommitted, boolean responseReceived, boolean providerAcceptanceProven ) {} ``` Adapter가 증명하지 못한 boolean은 임의로 `false` 처리하지 않고 `EvidenceCertainty`를 함께 둔다. ```java public enum EvidenceCertainty { PROVEN, INFERRED, UNKNOWN } ``` ### 23.2 Callback SPI ```java public interface ProviderCallbackAdapter { ProviderId providerId(); CallbackVerificationResult verify(CallbackRequest request); List normalize(VerifiedCallback callback); } ``` ### 23.3 Reconciliation SPI ```java public interface ReconciliationCapability { boolean supports(ProviderProfileSnapshot profile); CompletionStage reconcile(DeliveryAttemptSnapshot attempt); } ``` ### 23.4 Provider capability ```java public record ProviderCapabilities( boolean batch, boolean providerIdempotency, boolean statusCallback, boolean statusQuery, boolean deliveryReceipt, boolean nativeScheduling, boolean nativeCancel, boolean collapse, int maxBatchSize, long maxPayloadBytes, Duration maxTtl ) {} ``` --- ## 24. Provider Profile과 Runtime Generation ```yaml notification: providers: ses-primary: type: SES enabled: true environment: PRODUCTION region: ap-northeast-2 credential-profile: ses-primary sender-identity: transactional.example.com timeout: 3s max-concurrency: 32 rate-limit-per-second: 50 retry-policy: provider-transient callback-profile: ses-events twilio-primary: type: TWILIO enabled: true environment: PRODUCTION credential-profile: twilio-primary messaging-service-sid-ref: twilio-service timeout: 3s max-concurrency: 20 rate-limit-per-second: 30 retry-policy: twilio-transient fcm-main: type: FCM enabled: true project-id: example-prod application-id: mobile-main credential-profile: fcm-main max-batch-size: 500 apns-main: type: APNS enabled: true environment: PRODUCTION topic: com.example.app credential-profile: apns-main ``` ### 24.1 Runtime generation Credential·certificate rotation 시 기존 Runtime을 mutate하지 않는다. ```text Profile generation N → immutable ProviderRuntime N → 새 dispatch는 N+1 사용 → N의 in-flight 완료 → drain timeout 후 close ``` Attempt에 `credentialGeneration`을 기록한다. ### 24.2 Provider health ```java public enum ProviderRuntimeState { HEALTHY, DEGRADED, THROTTLED, AUTHENTICATION_FAILED, DISABLED, DRAINING } ``` `AUTHENTICATION_FAILED`는 개별 메시지 Retry가 아니라 Provider route open/disable과 운영 alert를 유발한다. --- ## 25. 공통 오류 모델 ```text NotificationException ├─ NotificationValidationException ├─ IdempotencyConflictException ├─ TemplateNotFoundException ├─ TemplateRenderingException ├─ InvalidContactPointException ├─ NotificationExpiredException ├─ NotificationSuppressedException ├─ ProviderAuthenticationException ├─ ProviderAuthorizationException ├─ ProviderThrottledException ├─ ProviderTransientException ├─ ProviderPermanentException ├─ ProviderRejectedException ├─ AmbiguousSubmissionException ├─ CallbackValidationException ├─ CallbackProjectionException ├─ ReconciliationException └─ NotificationCapacityException ``` ### 25.1 FailureCategory ```java public enum FailureCategory { TRANSIENT_PROVIDER, THROTTLED, AUTHENTICATION, AUTHORIZATION, INVALID_RECIPIENT, INVALID_PAYLOAD, TEMPLATE_FAILURE, PERMANENT_PROVIDER, AMBIGUOUS_SUBMISSION, CALLBACK_VALIDATION_FAILURE, CAPACITY_REJECTED, EXPIRED } ``` ### 25.2 안정 Metadata ```java public record NotificationFailureDescriptor( String code, FailureCategory category, boolean retryable, boolean ambiguous, Channel channel, ProviderId providerId, int attemptNumber, Duration elapsed ) {} ``` 금지 metadata: ```text address/token 원문 Provider credential message body Template variable 원문 Callback raw payload full Provider exception message ``` --- ## 26. Retry Policy Engine ```java public record RetryContext( FailureCategory failureCategory, AttemptConfirmation confirmation, ProviderExecutionEvidence evidence, boolean providerIdempotency, boolean contactPointActive, boolean fallbackCommitted, boolean ambiguousAttemptExists, Instant expiresAt, int attemptNumber, RetryBudgetSnapshot budget, ProviderRuntimeState providerState ) {} ``` ```java public sealed interface RetryDecision permits RetryAfter, Reconcile, Fallback, Stop { } ``` ### 26.1 기본 규칙 ```text TRANSIENT_PROVIDER + confirmed not accepted + expiry/budget remaining → RetryAfter THROTTLED → Retry-After 또는 exponential backoff INVALID_RECIPIENT → Contact Point invalidate → Fallback 가능 AUTHENTICATION / AUTHORIZATION → 개별 자동 Retry 금지 → Provider runtime unhealthy INVALID_PAYLOAD / TEMPLATE_FAILURE → 즉시 Stop AMBIGUOUS_SUBMISSION → Provider query 가능: Reconcile → Provider idempotency 증명 가능: 제한 재요청 → 둘 다 없음: Stop + manual review ``` ### 26.2 Backoff ```text exponential backoff full jitter provider Retry-After 존중 overall expiresAt 상한 provider별 retry budget ``` ### 26.3 Retry identity Retry는 새 `DeliveryAttemptId`를 만들지만 동일 `RecipientDeliveryId`, `NotificationId`, logical content digest를 유지한다. --- ## 27. Rate Limit·Backpressure·Capacity ### 27.1 제한 계층 ```text global → channel → provider account → sender identity → destination country/class → recipient/contact point ``` ### 27.2 Dispatch pipeline ```text Durable queue → expiry check → suppression re-check → provider health gate → rate limiter → concurrency limiter → provider adapter ``` ### 27.3 설정 ```yaml notification: dispatch: claim-batch-size: 100 lease-duration: 30s max-global-concurrency: 128 max-queue-age: 24h max-retry-concurrency: 32 scheduler-poll-interval: 250ms callback-worker-concurrency: 16 ``` 수치는 기본 동작 예시이며 Provider profile과 부하 테스트로 조정한다. 하드 상한과 무제한 금지는 설계 계약이다. ### 27.4 Overload 동작 - API intake가 DB capacity를 넘으면 `NotificationCapacityException`으로 fail-fast한다. - 메모리 queue로 무제한 축적하지 않는다. - Provider가 장기 장애이면 dispatch queue가 durable하게 쌓이고 retry amplification은 budget으로 제한한다. - Provider auth failure이면 해당 route를 open하고 같은 credential로 반복 호출하지 않는다. - 예약 fan-out은 한 tick에 bounded batch로 materialize한다. --- ## 28. Callback·Receipt 처리 ### 28.1 HTTP endpoint ```text POST /internal/notification/callbacks/ses/{profile} POST /internal/notification/callbacks/twilio/{profile} POST /internal/notification/callbacks/app-receipt/{applicationId} ``` WebFlux와 MVC 모듈은 동일 callback application service를 호출한다. ### 28.2 처리 순서 ```text body limit → content type → profile lookup → signature verification → replay defense → raw event append → dedup → normalize → attempt resolve → project → side effect → 2xx ``` ### 28.3 Signature - Twilio는 공식 validation algorithm/SDK adapter를 사용한다. - Reverse proxy 뒤 원본 URL 복원 규칙을 profile에 고정한다. - raw body가 필요한 서명은 decoding 전 bytes로 검증한다. - timestamp·nonce가 제공되면 허용 skew와 replay cache를 적용한다. - 서명 실패는 원장에 정상 provider event로 기록하지 않고 security audit에 기록한다. ### 28.4 Unknown fields Callback parser는 unknown field를 허용한다. 필수 식별 필드가 없을 때만 normalization reject다. ### 28.5 빠른 응답 Callback endpoint는 원장 append 이후 빠르게 2xx를 반환하고, Projection은 같은 transaction 또는 bounded async worker에서 수행한다. Provider 재전송 규칙을 고려해 응답 SLA를 profile에 기록한다. --- ## 29. Reconciliation ### 29.1 대상 ```text DISPATCHING 상태가 lease보다 오래됨 AMBIGUOUS submission Provider accepted 후 callback SLA 초과 Twilio callback 누락 unmatched provider event ``` ### 29.2 결과 ```java public sealed interface ReconciliationResult { record Confirmed(NormalizedProviderEvent event) implements ReconciliationResult {} record StillUnknown(Instant nextCheckAt) implements ReconciliationResult {} record Unsupported() implements ReconciliationResult {} record Failed(String code, boolean retryable) implements ReconciliationResult {} } ``` ### 29.3 규칙 - synthetic event도 ProviderEvent ledger에 append한다. - `source=RECONCILIATION`을 기록한다. - 기존 Callback보다 약한 증거로 downgrade하지 않는다. - 수정된 Projection은 correction audit를 남긴다. - Provider query capability가 없으면 자동으로 final status를 추정하지 않는다. --- ## 30. Email 공통 계약 ```java public record EmailNotification( TenantId tenantId, IdempotencyKey idempotencyKey, ContactPointId recipient, TemplateSelection template, Map variables, Optional expiresAt, CorrelationId correlationId ) {} ``` ### 30.1 기능 ```text Text HTML multipart/alternative attachment reference inline resource reference CC/BCC/Reply-To custom approved header List-Unsubscribe capability ``` ### 30.2 MIME 규칙 - CRLF가 포함된 header value를 거부한다. - UTF-8을 기본으로 한다. - text와 html을 multipart/alternative로 구성한다. - attachment는 immutable reference와 digest를 검증한다. - bulk recipient를 To/CC에 노출하지 않는다. - MIME 생성 실패는 Provider 호출 전 non-retryable이다. ### 30.3 SenderIdentity ```java public record SenderIdentity( String identityId, String domain, String fromAddressRef, Optional replyToAddressRef, ProviderId providerId, ReadinessStatus dkimStatus, ReadinessStatus spfStatus, ReadinessStatus dmarcStatus, boolean enabled ) {} ``` Production profile은 준비되지 않은 sender identity로 시작하지 않는다. --- ## 31. SMTP Adapter ### 31.1 구현 ```text Spring JavaMailSender MimeMessageHelper Dedicated connection/timeout profile SMTP response classifier ``` ### 31.2 결과 매핑 | SMTP 결과 | Failure/Outcome | |---|---| | final 2xx 수락 | CONFIRMED_ACCEPTED / PROVIDER_ACCEPTED | | 4yz | TRANSIENT_PROVIDER | | 5yz | PERMANENT_PROVIDER 또는 INVALID_RECIPIENT | | DATA 전 명백한 연결 실패 | CONFIRMED_REJECTED 또는 NOT_SUBMITTED | | DATA 후 final response 유실 | AMBIGUOUS_SUBMISSION | SMTP 2xx를 inbox delivery로 보지 않는다. ### 31.3 Timeout ```text connection timeout read timeout write timeout pool acquire timeout whole attempt deadline ``` 무한 timeout을 허용하지 않는다. ### 31.4 SMTP Provider Profile ```yaml notification: providers: smtp-primary: type: SMTP host: smtp.example.com port: 587 tls-mode: STARTTLS_REQUIRED credential-profile: smtp-primary connect-timeout: 1s read-timeout: 3s write-timeout: 3s max-concurrency: 10 ``` --- ## 32. Amazon SES Adapter ### 32.1 Submit - 기존 `httpclient` 또는 AWS SDK Adapter 내부 구현을 사용하되 Core에 SDK type을 노출하지 않는다. - `MessageId`를 `providerRequestId`로 저장한다. - API 성공은 `CONFIRMED_ACCEPTED`, `PROVIDER_ACCEPTED`다. - API 성공을 `SENT`·`DELIVERED`로 기록하지 않는다. ### 32.2 Event mapping | SES event | Normalized event | |---|---| | Send | PROVIDER_ACCEPTED fact 보강 | | Delivery | DELIVERY_CONFIRMED / NETWORK_OR_CARRIER_ACCEPTED | | DeliveryDelay | DELIVERY_DELAYED fact | | Bounce hard | BOUNCED + HARD_BOUNCE suppression | | Bounce transient | UNDELIVERED + retry/fallback policy input | | Complaint | COMPLAINT fact + suppression | | Reject | PROVIDER_REJECTED | | RenderingFailure | TEMPLATE_FAILURE | ### 32.3 Duplicate event SES event ID가 있으면 해당 ID를 사용하고, 없으면 message ID·event type·timestamp·payload digest로 fingerprint한다. ### 32.4 List-Unsubscribe 구독형 Email capability에서만 활성화한다. ```text List-Unsubscribe List-Unsubscribe-Post DKIM signed header readiness ``` Unsubscribe token은 암호학적으로 보호하고 로그에 남기지 않는다. --- ## 33. SMS 공통 계약 ### 33.1 PhoneNumber ```java public record PhoneNumber(String e164) implements ContactPointValue {} ``` - E.164 canonical form을 저장한다. - 형식 검증과 실제 번호 사용 가능성 검증을 분리한다. - 번호 원문은 암호화하고 HMAC fingerprint를 별도 저장한다. ### 33.2 Encoding·Segment estimator ```java public record SmsEstimate( SmsEncoding encoding, int segmentCount, int encodedLength, boolean exceedsRecommendedLimit ) {} public enum SmsEncoding { GSM_7, UCS_2 } ``` 계약: ```text GSM-7 single: 160 GSM-7 concatenated segment: 153 UCS-2 single: 70 UCS-2 concatenated segment: 67 ``` Extension table과 escape character를 실제 인코딩 길이에 반영한다. ### 33.3 Sender Profile ```text number sender ID short code messaging service country capability ``` 비즈니스 코드가 sender를 임의 문자열로 지정하지 않는다. --- ## 34. Twilio Adapter ### 34.1 Submit mapping | Twilio 상태 | Submission/Delivery | |---|---| | accepted/queued | CONFIRMED_ACCEPTED / PROVIDER_ACCEPTED | | sending | Delivery UNKNOWN, native status 보존 | | sent | SENT / NETWORK_OR_CARRIER_ACCEPTED | | delivered | DELIVERED / DEVICE_DELIVERED | | undelivered | UNDELIVERED | | failed | CONFIRMED_REJECTED 또는 permanent failure | ### 34.2 Callback merge Twilio Callback 도착 순서에 의존하지 않는다. ```text delivered callback 수신 → evidence DEVICE_DELIVERED 그 뒤 sent callback 수신 → native event는 원장에 append → projection downgrade 없음 ``` ### 34.3 Signature - `X-Twilio-Signature`를 검증한다. - Proxy가 원본 scheme/host/path를 변경할 때 canonical external URL 설정을 사용한다. - 공식 validator를 Adapter 내부에서 사용한다. ### 34.4 Reconciliation - 상태 Callback SLA를 넘은 Attempt는 Message status query 대상으로 등록한다. - polling rate limit과 최대 age를 설정한다. - query 결과도 synthetic ProviderEvent로 기록한다. ### 34.5 Opt-out Provider opt-out 상태와 내부 suppression을 동기화한다. 내부 mandatory suppression이 우선한다. --- ## 35. Mobile Push 공통 계약 ### 35.1 Target ```text FCM_FID FCM_REGISTRATION_TOKEN_LEGACY APNS_DEVICE_TOKEN ``` ### 35.2 공통 content 제한 - title/body/data payload를 구분한다. - reserved provider key 충돌을 거부한다. - deep link scheme/host allowlist를 적용한다. - payload bytes를 Provider 호출 전 계산한다. - batch 결과를 RecipientDelivery별로 분해한다. ### 35.3 Provider success 의미 ```text Provider API success → PROVIDER_ACCEPTED → DEVICE_DELIVERED 아님 ``` ### 35.4 앱 Receipt ```java public interface ApplicationReceiptService { ReceiptResult displayed(AppReceipt receipt); ReceiptResult read(AppReceipt receipt); } ``` - 앱 receipt는 cryptographically authenticated application session을 요구한다. - 동일 receipt ID를 dedup한다. - Provider delivery 결과와 별도 Evidence source로 기록한다. --- ## 36. FCM Adapter ### 36.1 Target 우선순위 - `FcmInstallationId`를 primary Stable target으로 둔다. - `LegacyFcmRegistrationToken`은 compatibility capability로 분리한다. - FID와 legacy token을 하나의 문자열 field로 평탄화하지 않는다. ### 36.2 Batch - Adapter capability의 `maxBatchSize` 기본은 500이다. - 하나의 Provider batch 요청이라도 Recipient별 `DeliveryAttempt`를 유지한다. - 부분 성공 결과를 input index로 매핑한다. - batch transport failure가 모든 item의 같은 outcome을 의미하는지 Adapter evidence로 구분한다. ### 36.3 오류 매핑 | FCM 오류 | 처리 | |---|---| | UNREGISTERED | Contact Point INVALID, 자동 retry 금지 | | INVALID_ARGUMENT payload | INVALID_PAYLOAD | | QUOTA_EXCEEDED | THROTTLED, backoff | | UNAVAILABLE | TRANSIENT_PROVIDER, Retry-After/jitter | | auth credential failure | AUTHENTICATION, Provider runtime unhealthy | | project/target mismatch | INVALID_RECIPIENT 또는 AUTHORIZATION | ### 36.4 Collapse·TTL FCM collapse는 Provider capability다. `expiresAt`을 FCM TTL로 제한 변환한다. ### 36.5 Topic·Condition N3 Experimental이다. 일반 N1 Contact Point 전송과 동일 delivery evidence를 제공한다고 가정하지 않는다. --- ## 37. APNs Adapter ### 37.1 Profile ```text applicationId topic environment SANDBOX/PRODUCTION credential generation push type allowlist ``` ### 37.2 Request mapping ```text apns-topic apns-push-type apns-expiration apns-priority apns-collapse-id apns-request-id ``` ### 37.3 결과 - HTTP 2xx는 `CONFIRMED_ACCEPTED`, `PROVIDER_ACCEPTED`다. - APNs success를 `DELIVERED`로 매핑하지 않는다. - invalid token, wrong environment, wrong topic을 Contact Point lifecycle과 profile error로 구분한다. - token auth key rotation은 runtime generation으로 수행한다. ### 37.4 Offline·Ordering Core는 APNs가 알림을 저장·교체·폐기할 수 있고 순서를 보장하지 않는다는 제약을 문서화한다. 업무 이벤트 순서 전달 수단으로 사용하지 않는다. --- ## 38. Web Push Adapter ### 38.1 표준 ```text RFC 8030 RFC 8291 RFC 8292 ``` ### 38.2 Subscription ```java public record WebPushSubscriptionValue( URI endpoint, byte[] p256dh, byte[] authSecret, String vapidKeyId ) implements ContactPointValue {} ``` 모든 필드는 암호화 저장하며 endpoint는 capability URL이므로 secret 수준으로 처리한다. ### 38.3 Send 계약 - TTL header는 필수다. - `Urgency`, `Topic`을 typed option으로 제공한다. - `aes128gcm` payload encryption을 사용한다. - VAPID JWT는 현재 target origin에 맞춰 생성한다. - Payload가 profile maximum을 넘으면 Provider 호출 전 거부한다. ### 38.4 Subscription invalidation - RFC 계약의 404 expired subscription을 처리한다. - Provider-specific 410 등은 Adapter mapping으로 추가한다. - invalid subscription은 Contact Point를 `INVALID`로 만든다. ### 38.5 VAPID rotation VAPID restricted subscription은 signing key 변경 시 새 subscription이 필요할 수 있다. 따라서 일반 credential hot rotation과 별도 migration operation으로 관리한다. ### 38.6 Receipt capability 모든 Push Service가 receipt를 제공한다고 가정하지 않는다. capability가 있을 때만 `DEVICE_DELIVERED`로 승격한다. --- ## 39. In-App Inbox ### 39.1 Source of truth DB row가 source of truth다. WebSocket 또는 Push는 새 item 존재를 알리는 신호일 뿐이다. ### 39.2 API ```java public interface NotificationInbox { InboxPage list(InboxQuery query); InboxItem get(InboxItemId id, InboxPrincipal principal); InboxMutationResult markSeen(InboxItemId id, InboxPrincipal principal); InboxMutationResult markRead(InboxItemId id, InboxPrincipal principal); InboxMutationResult archive(InboxItemId id, InboxPrincipal principal); InboxMutationResult markAllRead(MarkAllReadCommand command); long unreadCount(InboxPrincipal principal); } ``` ### 39.3 상태 ```text PERSISTED SEEN READ ARCHIVED EXPIRED DELETED ``` `READ`는 `SEEN`을 포함한다. 역순 요청은 idempotent하다. ### 39.4 Pagination Cursor: ```text (createdAt DESC, id DESC) ``` Offset pagination을 기본으로 사용하지 않는다. ### 39.5 Broadcast - 개인 알림은 fan-out-on-write. - 대규모 공지는 broadcast item + per-user state의 별도 capability. - 동일 테이블에 수백만 복제 row를 무조건 생성하지 않는다. ### 39.6 WebSocket 연계 DB commit 후 내부 event를 `messaging`으로 발행하거나 transaction outbox를 사용한다. WebSocket 실패가 Inbox write rollback을 유발하지 않는다. --- ## 40. Webhook Extension `notification-webhook-extension`은 기존 `httpclient`를 사용한다. 재사용: ```text TLS Timeout Retry Circuit Breaker SSRF Dynamic Target policy Request signing Observation ``` Notification이 추가하는 모델: ```text WebhookSubscription WebhookTemplate DeliveryAttempt Callback/response evidence Redrive ``` Webhook target이 사용자 입력이면 `DynamicTargetGateway`를 사용하고 Trusted Provider credential을 상속하지 않는다. --- ## 41. Security·Privacy ### 41.1 보호 대상 ```text Email address Phone number FCM FID / legacy token APNs device token Web Push endpoint/p256dh/auth VAPID private key Provider credential Callback signing secret Template variables Rendered message body Attachment reference Unsubscribe token ``` ### 41.2 저장 - Contact Point는 field-level encryption. - equality lookup은 HMAC fingerprint. - raw Callback payload는 bounded encrypted retention. - rendered content snapshot은 필요한 category에서만 encrypted 저장. - secret material은 secret manager Port로 공급. - encryption key ID를 row에 기록한다. ### 41.3 로그·Metric 금지 ```text Email 원문 Phone 원문 FID/token/device token Web Push endpoint/key message body Template variables Provider credential unsubscribe token attachment URL raw callback payload provider request ID 원문 ``` ### 41.4 Tenant 격리 모든 repository query는 tenant boundary를 포함한다. Admin operation도 explicit tenant 또는 global role을 요구한다. ### 41.5 Callback 보안 ```text TLS signature replay defense body limit content-type profile binding rate limit idempotent ingestion security audit ``` --- ## 42. Credential·환경 격리 Namespace: ```text tenant applicationId provider environment credentialProfile generation ``` APNs: ```text (APNS, appId, SANDBOX) (APNS, appId, PRODUCTION) ``` FCM: ```text (FCM, projectId, applicationId, environment) ``` SMTP/SES/Twilio: ```text sender identity와 credential profile을 분리 ``` Startup validation: - Production profile의 plaintext endpoint 금지. - credential reference 누락 시 startup 실패 또는 해당 provider bean 미생성. - APNs environment와 token environment 혼용 차단. - FCM project/application mismatch guard. - expired certificate/credential health fail. --- ## 43. Observability ### 43.1 관측 단위 ```text Notification RecipientDelivery ProviderAttempt ProviderCallback Reconciliation ``` ### 43.2 Metric | Metric | Low-cardinality tag | |---|---| | `notification.requested` | category, strategy | | `notification.suppressed` | channel, reason | | `notification.render` | channel, templateId, result | | `notification.dispatch` | channel, provider, result | | `notification.provider.accepted` | channel, provider | | `notification.delivery` | channel, provider, outcome | | `notification.retry` | provider, failureCategory, attemptBucket | | `notification.fallback` | fromChannel, toChannel, reason | | `notification.ambiguous` | channel, provider | | `notification.callback` | provider, eventType, result | | `notification.callback.delay` | provider, eventType | | `notification.reconciliation` | provider, correction | | `notification.queue.depth` | channel, provider | | `notification.queue.age` | channel, provider | | `notification.schedule.delay` | channel | | `notification.contact.invalid` | channel, provider | ### 43.3 금지 tag ```text recipientId Contact Point notificationId recipientDeliveryId attemptId providerRequestId full error message ``` ### 43.4 Trace ```text notification.submit notification.render notification.dispatch provider.http 또는 provider.smtp notification.callback notification.project notification.reconcile ``` Callback은 원 dispatch span의 장시간 child가 아니라 trace link/correlation으로 연결한다. ### 43.5 Audit ```text template publish/disable suppression add/remove consent update contact invalidation/reactivation manual redrive/retry/cancel reconciliation correction provider enable/disable credential rotation admin override callback signature reject ``` --- ## 44. Admin Plane ### 44.1 기능 ```text Provider runtime 조회·enable·disable Attempt 조회 Ambiguous queue 조회 Reconciliation 실행 DLQ/failed delivery redrive Suppression add/remove Contact Point invalidate/reactivate Template publish/disable Credential generation cutover Projection replay ``` ### 44.2 Redrive - 원 `NotificationId`, `RecipientDeliveryId`를 보존한다. - 새 `DeliveryAttemptId`를 생성한다. - `redriveOperationId`, actor, reason, time을 기록한다. - 원 Template version과 rendered digest를 기본 사용한다. - 새로운 내용으로 다시 보내려면 새 Notification으로 제출한다. - ambiguous Attempt의 redrive는 duplicate risk 확인을 요구한다. ### 44.3 Projection replay ProviderEvent ledger를 기준으로 projection을 재생성한다. raw provider status를 public enum으로 직접 저장하지 않기 때문에 projector version migration을 지원한다. --- ## 45. Spring Boot Starter ### 45.1 Auto-configuration ```text NotificationCoreAutoConfiguration NotificationPersistenceAutoConfiguration NotificationDispatchAutoConfiguration NotificationTemplateAutoConfiguration NotificationProviderAutoConfiguration NotificationCallbackAutoConfiguration NotificationObservabilityAutoConfiguration NotificationAdminAutoConfiguration ``` ### 45.2 조건 - Provider dependency가 classpath에 있고 profile이 enabled일 때만 Adapter bean 생성. - Admin bean은 별도 property와 authority integration이 있을 때만 생성. - MVC/WebFlux callback bean 중 활성 web stack에 맞는 것만 생성. - Reactor facade는 reactor module이 있을 때만 생성. ### 45.3 Properties validation Startup 실패 조건: ```text unbounded payload/queue setting negative timeout expiresAt 없는 provider TTL-required profile callback signature secret 누락 production trust-all APNs environment/topic 누락 WebPush VAPID key 누락 provider profile ID 중복 route가 disabled provider만 가리킴 ambiguous fallback 기본 허용 ``` ### 45.4 Actuator ```text notificationProviders notificationQueue notificationCallbacks notificationScheduler notificationReconciliation ``` 민감 주소와 Provider credential은 노출하지 않는다. --- ## 46. 테스트 전략 ### 46.1 공통 Contract Suite 모든 Provider Adapter가 다음을 통과한다. ```text confirmed acceptance confirmed rejection ambiguous response loss invalid recipient throttle transient provider failure permanent failure credential failure expiry before submit payload limit secret masking attempt evidence mapping ``` ### 46.2 Core Suite ```text idempotency concurrency idempotency conflict schedule restart lease expiry suppression at submit and dispatch ambiguous fallback block retry budget expiry during retry callback duplicate callback out-of-order projection replay unmatched event reconciliation correction tenant isolation ``` ### 46.3 Provider Suite Email: ```text SMTP 2xx/4xx/5xx DATA response loss MIME text/html/attachment SES accepted/delivery/bounce/complaint/render failure ``` SMS: ```text E.164 GSM-7/UCS-2 segment boundary Twilio callback reverse order missing callback reconciliation ``` Push: ```text FCM FID legacy token batch partial result UNREGISTERED/QUOTA/UNAVAILABLE APNs sandbox/production wrong topic token invalid acceptance not delivery ``` Web Push: ```text TTL required AES128GCM VAPID 404/410 invalidation payload size Topic replacement ``` Inbox: ```text cursor pagination seen/read idempotency unread count concurrency WebSocket outage ``` ### 46.4 장애 도구 ```text WireMock / MockWebServer GreenMail 또는 SMTP test server Toxiproxy PostgreSQL Testcontainers Provider callback fixtures process kill harness clock control ``` ### 46.5 보안 테스트 ```text PII log scanner Metric cardinality guard callback signature invalid callback replay cross-tenant query secret rotation Web Push endpoint leak header injection attachment authorization ``` ### 46.6 성능 테스트 ```text 100k recipient fan-out scheduled burst provider 30m outage 429 sustained callback burst slow provider DB slow process kill during in-flight large redrive credential failure amplification ``` 완료값은 throughput 숫자 하나가 아니라 memory, DB lock, queue age, retry amplification, thread/connection 상한을 함께 검증한다. --- ## 47. 호환성 인증 매트릭스 | 대상 | CI 빈도 | Release gate | |---|---|---| | Java 21 | 모든 PR | 필수 | | Spring 6.2 latest patch | 모든 PR | 필수 | | Spring 7.0 latest patch | release | 필수 | | PostgreSQL 16 | 모든 PR | 필수 | | SMTP test server | 모든 PR | 필수 | | SES contract fixtures | 모든 PR | 필수 | | Twilio contract fixtures | 모든 PR | 필수 | | FCM emulator/mock + fixture | 모든 PR | 필수 | | APNs HTTP/2 fixture | release | 필수 | | Web Push RFC vectors | 모든 PR | 필수 | | Toxiproxy ambiguity suite | nightly/release | 필수 | | Performance suite | release candidate | 필수 | 실제 외부 Provider sandbox smoke test는 secret이 있는 전용 CI 환경에서 수행한다. PR 필수 검증을 외부 서비스 가용성에 종속시키지 않는다. --- ## 48. 단계별 출시 ### Phase 1: Foundation Alpha ```text Core IDs Request/Recipient/Attempt JPA schema idempotent submit durable dispatch queue provider SPI error/evidence model basic audit/metrics ``` 완료 조건: - concurrent idempotency test - provider accepted response loss → AMBIGUOUS - process restart recovery - PII log scan ### Phase 2: Email Stable Beta ```text Template SMTP SES MIME/attachment SES callback bounce/complaint suppression ``` 완료 조건: - SMTP 4xx/5xx - SMTP ambiguous response - SES acceptance/delivery 분리 - callback dedup ### Phase 3: SMS Stable Beta ```text E.164 segment estimator Twilio submit/callback/reconcile opt-out suppression ``` 완료 조건: - callback reverse order - callback missing reconcile - segment boundary ### Phase 4: Mobile Push Stable Beta ```text FCM FID + legacy FCM batch APNs TTL/collapse/priority app receipt extension ``` 완료 조건: - partial batch - invalid target lifecycle - Provider acceptance not delivery ### Phase 5: Web Push RC ```text RFC 8030/8291/8292 subscription lifecycle VAPID TTL/Urgency/Topic ``` 완료 조건: - RFC vectors - secret leak test - expired subscription ### Phase 6: Advanced Delivery Release ```text ordered fallback retry budget provider health reconciliation admin redrive credential rotation ``` 완료 조건: - ambiguous fallback block - provider outage retry amplification guard - rotation drain ### Phase 7: Inbox·Preference Release ```text Inbox Preference Consent Suppression admin WebSocket signal integration ``` 완료 조건: - unread consistency - tenant isolation - WebSocket outage durability --- ## 49. 구현 결정 원장 | ID | 결정 | |---|---| | NOTIF-ADR-001 | Core submit 성공은 durable acceptance만 의미한다. | | NOTIF-ADR-002 | Request, Recipient, Attempt를 별 엔터티로 둔다. | | NOTIF-ADR-003 | ProviderEvent는 append-only ledger다. | | NOTIF-ADR-004 | 단일 선형 delivery enum을 사용하지 않는다. | | NOTIF-ADR-005 | Ambiguous submission은 Core 1급 상태다. | | NOTIF-ADR-006 | Ambiguous 상태의 자동 retry/fallback을 기본 금지한다. | | NOTIF-ADR-007 | FCM은 FID 우선, token legacy compatibility다. | | NOTIF-ADR-008 | Core-owned durable scheduler를 기본으로 한다. | | NOTIF-ADR-009 | Contact Point는 AES-GCM + HMAC lookup으로 보호한다. | | NOTIF-ADR-010 | Callback은 signature 검증 후 원장 append, 그 뒤 projection한다. | | NOTIF-ADR-011 | Template version은 submit 시 고정한다. | | NOTIF-ADR-012 | Retry, Dedup, Collapse를 별 기능으로 둔다. | | NOTIF-ADR-013 | Provider SDK type은 N1/N2에 노출하지 않는다. | | NOTIF-ADR-014 | In-App DB가 source of truth다. | | NOTIF-ADR-015 | Webhook은 기존 httpclient를 재사용한다. | --- ## 50. 비지원 범위 재확인 다음 기능은 구현 계획에 포함하지 않는다. ```text 캠페인 UI와 세그먼트 엔진 법률 규정 판정 엔진 사용자 등급별 발송 정책 실시간 WebSocket 서버 구현 Email reputation 자동 최적화 엔진 Provider 가격 비교·자동 비용 최적화 무조건적인 multi-provider failover Provider acceptance 이후 중복 없는 자동 재전송 모든 채널의 최종 delivery/read 통일 Provider raw SDK의 일반 공개 파일 bytes 저장 ``` --- ## 51. 완료 정의 ### 51.1 공개 계약 - N1 Typed API로 네 Stable 채널을 제출할 수 있다. - N2는 durable schedule, cancel, ordered fallback을 제공한다. - Provider SDK 객체가 public API에 없다. - submit 성공과 delivery success가 구분된다. ### 51.2 상태·신뢰성 - Request/Recipient/Attempt/Event가 독립 ID를 가진다. - Callback 중복·역순이 Projection을 손상시키지 않는다. - AMBIGUOUS가 명시적으로 저장된다. - Retry/Fallback이 증거와 expiry를 기반으로 판정된다. - Reconciliation 결과가 event ledger를 거쳐 반영된다. ### 51.3 채널 - SMTP/SES/Twilio/FCM/APNs/Web Push Contract Suite가 통과한다. - FCM FID와 legacy token이 분리된다. - APNs/FCM success를 DELIVERED로 매핑하지 않는다. - Web Push TTL·encryption·VAPID가 검증된다. ### 51.4 보안·운영 - Contact Point 원문 암호화와 HMAC lookup이 적용된다. - 로그·Metric·Trace에서 PII/secret 스캔이 통과한다. - Callback signature/replay defense가 통과한다. - Provider credential rotation이 runtime generation으로 검증된다. - N4 작업은 별도 권한과 audit를 요구한다. ### 51.5 장애·성능 - Provider accepted 후 response loss가 AMBIGUOUS로 재현된다. - process kill 후 durable queue가 복구된다. - Provider outage에서 retry storm이 발생하지 않는다. - callback burst, scheduled burst에서 bounded resource를 유지한다. - external Provider 없이 contract CI가 결정적으로 실행된다. --- ## 부록 A. 요구사항 원본 추적본 아래 내용은 설계 결정의 근거와 누락 방지를 위해 첨부된 심층 리서치를 그대로 보존한 추적 부록이다. 본문의 구현 계약이 우선하며, 부록의 연구 문장은 근거와 채널별 제약을 제공한다. ## 조사 결론과 지원 경계 이번 `notification` 모듈의 가장 중요한 설계 결론은 **`send()` 추상화가 아니라 “알림 의도 → 수신자별 전달 → 공급자 시도 → 비동기 전달 증거 → 실패·재처리·억제”의 전체 수명주기를 소유하는 플랫폼**으로 만들어야 한다는 것입니다. 이 결론은 채널별 실제 보장이 크게 다르기 때문에 중요합니다. Amazon SES는 API 요청을 성공적으로 받아 `MessageId`를 반환한 뒤에도 바이러스나 잘못된 템플릿 개인화 때문에 실제 발송을 하지 않을 수 있다고 명시합니다. Twilio도 `accepted/queued`, `sent`, `delivered/undelivered`를 서로 다른 단계로 관리합니다. Apple은 APNs가 알림 전달을 시도하지만 전달을 보장하지 않으며, APNs가 요청을 수락한 뒤에도 저장·폐기·후속 전달이 발생할 수 있다고 설명합니다. Web Push 역시 push service의 메시지 수락과 user agent acknowledgement를 별도의 receipt 메커니즘으로 구분합니다. citeturn14search0turn16search6turn13search1turn13search5turn12search0 따라서 Core에 `DELIVERED=true`, `exactlyOnce=true` 같은 단순 옵션을 두는 것은 잘못된 추상화입니다. 플랫폼이 반환해야 하는 핵심은 **“현재까지 어떤 증거를 확보했는가”**이며, `PROVIDER_ACCEPTED`, `DELIVERED`, `READ`는 서로 다른 증거입니다. **권장 경계는 다음과 같습니다.** ```text Domain / Application │ │ "이 사용자에게 이런 알림을 보내라" ▼ Notification Platform ├─ Request / Recipient / Attempt 수명주기 ├─ Contact Point ├─ Template / Localization ├─ Idempotency / Suppression ├─ Scheduling / Expiration ├─ Routing / Fallback ├─ Provider Adapter ├─ Callback / Receipt / Reconciliation ├─ Retry / Rate Limit / Backpressure ├─ Audit / Metrics / Trace │ ├─ Email ── SMTP / SES / ... ├─ SMS ── Twilio / ... ├─ Push ── FCM / APNs ├─ WebPush └─ Inbox ``` 반대로 **누구에게 어떤 업무 알림을 보낼지, 마케팅 세그먼트를 어떻게 만들지, 특정 국가의 법률상 동의가 필요한지, 무료·유료 회원의 우선 채널이 무엇인지는 Core 책임이 아니어야 합니다.** Core는 그런 판단을 주입할 Port와 결과를 기록할 Primitive를 제공합니다. ### 채널·공급자 지원 매트릭스 | 영역 | 기준 구현 | 권장 등급 | Core가 보장할 수 있는 최대 기본 증거 | 비고 | |---|---|---|---|---| | Email | SMTP + Amazon SES HTTP API | **Stable** | SMTP/Provider 수락, provider가 제공하면 recipient mail server 전달·bounce·complaint | SES `Delivery`는 recipient mail server까지의 전달이지 inbox/read 보장이 아님. citeturn14search0turn14search8 | | SMS | Twilio Programmable Messaging | **Stable** | accepted/queued → sent → carrier DLR 기반 delivered/undelivered | callback 순서 보장 없음. citeturn16search0turn16search3 | | Mobile Push / Android 계열 | FCM | **Stable** | FCM handoff/acceptance + 명시적 오류 | 일반 서버 API 결과를 device delivery로 승격하면 안 됨. Admin SDK는 handoff 실패와 부분 실패를 구분. citeturn15search2turn15search4 | | Mobile Push / Apple | APNs | **Stable** | APNs acceptance | APNs 수락 후 저장·폐기·전달될 수 있고 전달 자체는 보장되지 않음. citeturn13search1turn13search5 | | Web Push | RFC 8030/8291/8292 | **Stable** | push service acceptance; receipt 지원 시 user-agent acknowledgment | receipt 실제 제공 여부는 push-service capability로 취급. citeturn12search0turn20search0turn20search1 | | In-App Inbox | 자체 저장소 | **선택 Stable / 별도 모듈** | persisted/available, 명시적 seen/read | 외부 Provider가 아니라 애플리케이션 데이터 저장 문제 | | Webhook | 기존 `httpclient` 재사용 | **Extension** | 상대 시스템 HTTP 계약에 따름 | Notification Core가 HTTP stack을 다시 만들지 않음 | | Kakao 알림톡·WhatsApp·RCS·MMS | Adapter | **Experimental/확장** | 공급자별 | Core 의미론을 바꾸지 않음 | | Slack·Teams·Discord | Adapter | **Experimental/확장** | 공급자별 | 사람 대상 협업 채널이지만 Core N3 확장으로 적절 | | Provider failover | SES→다른 Email Provider 등 | **Experimental** | 첫 시도 상태에 따라 다름 | 첫 공급자 결과가 `AMBIGUOUS`이면 중복 발송 위험 | | “Exactly once notification” | 없음 | **비지원** | 보장 불가능 | Provider acceptance와 사용자 전달 사이에 플랫폼 밖 구간 존재 | 여기서 특히 **FCM의 지원 모델에는 2026년 기준 변화가 있습니다.** 2026년 8월 현재 Firebase Admin SDK 문서는 Firebase Installation ID(FID)를 권장하고 registration token 기반 multicast API를 deprecated/obsolete 경로로 표시하고 있습니다. Node Admin SDK 14 계열은 2026년 6월에 FID 기반 메시징 타입을 추가하고 기존 token 기반 타입을 deprecated 처리했습니다. 따라서 Core 타입을 지금 `FcmRegistrationToken` 하나로 고정하는 것은 피해야 합니다. citeturn15search0turn15search2turn15search6turn15search11 권장 모델은 다음입니다. ```text MobilePushTarget ├─ FCM_FID ├─ FCM_REGISTRATION_TOKEN_LEGACY └─ APNS_DEVICE_TOKEN ``` 이는 공급자 변경을 숨기기 위한 추상화가 아니라 **Contact Point의 식별 방식 자체가 변할 수 있음을 모델에 반영하기 위한 것**입니다. ### 공개 계층 최종 권고 | 계층 | 대상 | 노출 범위 | 권고 | |---|---|---|---| | N1 | 일반 애플리케이션 | Typed Email/SMS/Push/WebPush API | **기본 진입점** | | N2 | 고급 기능 | Schedule, Fallback, Batch, Multi-channel, Cancel | Stable은 기능별 capability 확인 | | N3 | 공급자 특수 기능 | FCM topic, APNs push-type, SMS sender pool, SES configuration set | 제한형 Extension | | N4 | 운영자 | Redrive, Reconcile, Suppression override, Provider disable | Admin Plane | 일반 코드에 `Firebase Message`, `MimeMessage`, Twilio SDK 객체를 그대로 노출해서는 안 됩니다. Spring의 `JavaMailSender`와 `MimeMessageHelper`는 SMTP Adapter를 구현하기에는 유용하고 attachment·inline resource도 지원하지만, 이것을 공통 Notification API로 노출할 이유는 없습니다. citeturn14search1turn14search9 ### Stable·Experimental·비지원 범위 **Stable**에는 Typed channel API, Request/Recipient/Attempt 분리, event ledger 기반 상태 추적, idempotency, provider callback ingestion, Core-owned durable scheduling, expiration, suppression primitives, Email SMTP/SES, SMS Twilio, FCM/APNs, 표준 Web Push, metric/audit를 두는 것이 적절합니다. **Experimental**에는 모호한 시도 이후의 provider failover, Parallel First-success, provider-native scheduling, APNs broadcast channel 같은 고유 기능, application-generated push delivery receipt, Web Push receipt의 공급자별 활용, 신규 Kakao/RCS/WhatsApp Adapter를 두는 편이 안전합니다. Apple의 Broadcast Push Channel은 Live Activity에 특화되어 있고 별도 환경·채널 수명주기를 가지므로 일반 Mobile Push Core에 흡수하기보다 N3 capability가 적절합니다. citeturn19search2 **Core 비지원**은 업무 대상자 선정, 캠페인 segmentation, 국가별 법률 판정, guaranteed delivery, guaranteed read, exactly-once human notification, Provider SDK raw type의 일반 노출, 파일 자체 저장, 브라우저 WebSocket 연결, 시스템 간 일반 메시징이어야 합니다. ## 공개 API와 데이터·상태 계약 ### Notification Core 공개 API 초안 단일 `NotificationService.send(Map)`보다 **채널별 Typed facade + 공통 내부 Core + Advanced Orchestrator**를 권장합니다. ```java public interface EmailNotifier { NotificationReceipt send(EmailNotification notification); } public interface SmsNotifier { NotificationReceipt send(SmsNotification notification); } public interface MobilePushNotifier { NotificationReceipt send(MobilePushNotification notification); } public interface WebPushNotifier { NotificationReceipt send(WebPushNotification notification); } public interface NotificationOrchestrator { NotificationReceipt submit(NotificationPlan plan); NotificationReceipt schedule(NotificationPlan plan, Instant scheduleAt); CancelResult cancel(NotificationId notificationId); NotificationSnapshot get(NotificationId notificationId); } ``` 여기서 `send()`의 반환값은 **최종 전송 결과가 아니라 플랫폼이 durable하게 요청을 받아들였다는 결과**로 정의하는 것이 안전합니다. ```java public record NotificationReceipt( NotificationId notificationId, RequestStatus status, Instant acceptedAt ) {} ``` 즉: ```text send() 성공 ≠ SES가 Email을 전달함 ≠ Twilio carrier가 SMS를 전달함 ≠ FCM/APNs가 기기에 표시함 ≠ 사용자가 읽음 ``` SES는 API 성공과 실제 발송을 명시적으로 구분하고, APNs도 수락 이후 저장·폐기·전달을 구분하므로 이 차이는 API 계약 자체에 반영되어야 합니다. citeturn14search0turn13search5 채널 Content도 sealed hierarchy로 분리하는 것이 적절합니다. ```java public sealed interface NotificationContent permits EmailContent, SmsContent, MobilePushContent, WebPushContent, InAppContent { } public record EmailContent( String subject, String textBody, String htmlBody, List attachments, EmailOptions options ) implements NotificationContent {} public record SmsContent( String text, SmsOptions options ) implements NotificationContent {} public record MobilePushContent( String title, String body, URI deepLink, Map data, PushPresentation presentation ) implements NotificationContent {} ``` 공급자 고유 필드를 여기 넣지 말고 N3에서 확장합니다. ```java public interface ProviderExtension { T capabilities(); } ``` ### Notification·Recipient·Attempt 데이터 모델 세 엔터티는 반드시 독립 ID와 독립 수명주기를 가져야 합니다. | 엔터티 | 의미 | 대표 필드 | |---|---|---| | `NotificationRequest` | 애플리케이션이 요청한 논리 알림 | `notificationId`, `tenantId`, `idempotencyKey`, `category`, `templateId`, `templateVersion`, `scheduleAt`, `expiresAt`, `correlationId` | | `RecipientDelivery` | 한 수신자에게 전달할 논리 작업 | `recipientDeliveryId`, `notificationId`, `recipientRef`, `routingPlan`, `deliveryState`, `evidence` | | `DeliveryAttempt` | 특정 채널·Provider로 한 번 수행한 물리 시도 | `attemptId`, `recipientDeliveryId`, `channel`, `provider`, `providerRequestId`, `attemptNo`, `submissionOutcome`, `failureCategory` | | `ProviderEvent` | 비동기 callback/receipt의 원본 이벤트 | `providerEventId`, `providerRequestId`, `providerOccurredAt`, `receivedAt`, `rawDigest`, `verified` | | `ContactPoint` | 실제 주소/토큰/구독 | `contactPointId`, `ownerId`, `type`, `provider`, `environment`, `status`, `lastConfirmedAt` | | `RenderedContent` | 특정 Template 버전을 사용해 생성한 결과 | `templateId`, `templateVersion`, `locale`, `contentDigest`, 필요 시 암호화 snapshot | `providerRequestId`와 `attemptId`도 분리해야 합니다. 요청을 보냈지만 응답을 잃은 경우 Provider ID를 얻지 못할 수도 있기 때문입니다. 같은 이유로 `notificationId = providerRequestId` 방식도 피해야 합니다. ### 단일 상태 머신 대신 “상태 + 증거 + 이벤트 원장” 사용자가 제안한 다음 상태들은 API projection으로는 유용합니다. ```text PENDING SUPPRESSED QUEUED DISPATCHING PROVIDER_ACCEPTED PROVIDER_REJECTED SENT DELIVERED UNDELIVERED BOUNCED COMPLAINT READ EXPIRED CANCELED UNKNOWN ``` 다만 이것을 **하나의 선형 enum 상태 머신으로 저장하는 것은 권장하지 않습니다.** 예를 들어 Email은 `DELIVERY` 후 `COMPLAINT`가 발생할 수 있습니다. Twilio callback은 네트워크 지연 때문에 순서대로 도착한다는 보장이 없습니다. APNs의 경우 수락한 알림이 나중에 저장되거나 폐기될 수 있습니다. 따라서 단순히 `ordinal(new) > ordinal(old)` 규칙으로 상태를 갱신하면 실제 정보를 잃게 됩니다. citeturn14search8turn16search0turn13search5 권장 구조는 다음입니다. ```text ProviderEvent append-only ledger │ ▼ Channel-specific projector │ ├─ SubmissionOutcome ├─ DeliveryOutcome ├─ EvidenceLevel ├─ EngagementFacts └─ SuppressionFacts ``` `DeliveryAttempt`에는 별도로 다음 완료 결과가 필요합니다. ```text CONFIRMED - Provider의 명시적 결과를 받음 REJECTED - Provider가 확실하게 수락하지 않음 AMBIGUOUS - 요청 전송 여부 또는 Provider 수락 여부를 확정할 수 없음 ``` 그리고 Submission과 Delivery를 분리합니다. ```java enum SubmissionOutcome { NOT_SUBMITTED, CONFIRMED_ACCEPTED, CONFIRMED_REJECTED, AMBIGUOUS } enum DeliveryOutcome { UNKNOWN, SENT, DELIVERED, UNDELIVERED, BOUNCED, EXPIRED } ``` ### 전달 증거 매핑표 | 채널 | PLATFORM_QUEUED | PROVIDER_ACCEPTED | NETWORK_OR_CARRIER_ACCEPTED | DEVICE_DELIVERED | USER_AGENT_DISPLAYED | USER_READ | |---|---|---|---|---|---|---| | Email/SES | 내부 queue | SES `MessageId` | SES `Delivery`: recipient mail server 수락 | 직접 증거 없음 | 직접 증거 없음 | Open은 별도 engagement telemetry로만 취급 | | SMS/Twilio | 내부 queue | `accepted/queued` | `sent` | `delivered` DLR | 보통 직접 증거 없음 | 일반 SMS는 없음 | | FCM | 내부 queue | Admin SDK가 FCM에 handoff | 공급자 내부 | 일반 send API로 확정 불가 | 기본 서버 API로 확정 불가 | 앱 callback을 별도로 구현해야 함 | | APNs | 내부 queue | APNs HTTP 성공 | APNs 내부 | 일반 provider API로 보장 불가 | 앱 측 관측 필요 | 앱 interaction event 필요 | | Web Push | 내부 queue | Push service `201` | Push service 저장/전달 | RFC receipt 지원 시 UA acknowledgement | 브라우저 앱 instrumentation | 앱 event 필요 | | In-App | 저장 대기 | 자체 저장 완료 | 해당 없음 | 해당 없음 | `seen` API | `read` API | SES의 `Delivery`는 recipient의 mail server까지 전달했다는 의미이며 최종 inbox 표시를 의미하지 않습니다. Twilio는 SMS `sent`, `delivered`, `undelivered`를 별도로 정의하고 delivery callback에는 carrier DLR 정보가 포함될 수 있습니다. citeturn14search8turn16search0turn16search3 FCM Admin SDK는 실패를 “FCM으로 전달하기 위한 handoff 과정의 오류”로 표현하고, multicast에서 개별 성공·실패를 별도로 반환합니다. 따라서 FCM send 성공을 `DEVICE_DELIVERED`로 해석해서는 안 됩니다. citeturn15search2turn15search4 Apple도 APNs가 알림을 적시에 전달하기 위해 노력하지만 전달 자체는 보장하지 않는다고 설명하며, 최신 Metrics 문서는 APNs acceptance 이후 알림이 delivered, discarded, persistent storage 중 하나로 갈 수 있음을 보여줍니다. citeturn13search1turn13search5 Web Push RFC 8030은 application server의 push 요청 수락과 별개로 delivery receipt를 정의하고, user agent가 메시지를 acknowledge하면 receipt stream에 `204`가 전달될 수 있습니다. 그러나 이 기능을 모든 실제 브라우저 push service가 동일하게 노출한다고 Core가 가정해서는 안 되므로 `DeliveryReceiptCapability`로 모델링하는 것이 안전합니다. citeturn12search0 ### Contact Point 생명주기 권장 타입은 다음과 같습니다. ```text EmailAddress PhoneNumber MobilePushTarget WebPushSubscription InAppRecipient ``` 공통 상태는 단순 `active=true/false`보다 다음이 적합합니다. ```text UNVERIFIED ACTIVE STALE INVALID SUPPRESSED REVOKED DELETED ``` FCM의 경우 2026년 현재 FID를 중심으로 보는 것이 중요합니다. Firebase는 FID가 uninstall/reinstall, cache 삭제, 장기 미사용 등으로 rotate/delete될 수 있고 현재 문서에서는 장기 inactivity 기준으로 270일을 설명합니다. 따라서 저장소에는 “등록 당시 값”만 두지 말고 `lastConfirmedAt`, `lastSuccessfulDeliveryAt`, `invalidatedAt`, `targetKind`, `providerEnvironment`를 함께 관리해야 합니다. citeturn15search7turn15search0 APNs device token도 주기적으로 바뀔 수 있으므로 Apple은 로컬에 영구 캐시하지 말고 등록 결과를 서버에 전달할 것을 권고합니다. 개발·운영 APNs 환경도 논리적으로 분리해야 합니다. citeturn19search9turn19search10 Web Push subscription은 push service가 언제든 만료시킬 수 있고, RFC 8030은 expired subscription에 application server가 보내면 `404 Not Found`를 반환하도록 규정합니다. 따라서 흔히 구현체에서 보이는 `410`만 Core 규칙으로 고정해서는 안 됩니다. 표준 Core는 `404 = expired subscription`을 지원하고, `410` 등은 Provider Adapter가 해당 서비스의 문서에 따라 `INVALID`로 추가 매핑하도록 하는 편이 맞습니다. citeturn12search2 권장 Contact Point 식별 방식은 다음과 같습니다. ```text 실제 address/token → 암호화 저장 normalized address hash → equality / dedup / suppression lookup contactPointId → 애플리케이션·API 노출 ``` 즉, 이메일·전화번호·device token 자체를 primary key나 외부 API identifier로 사용하지 않습니다. ## 템플릿·라우팅·중복·스케줄링 정책 ### Template·Localization 계약 공통 Template의 최소 계약은 다음 정도가 적합합니다. ```java record NotificationTemplate( String templateId, long version, Channel channel, Locale locale, String fallbackLocale, VariableSchema variables, ContentDefinition content, TemplateStatus status ) {} ``` 발송 시점에는 반드시 다음 조합을 고정해야 합니다. ```text templateId + templateVersion + locale + normalized variables + renderedContentDigest ``` 템플릿이 이후 수정되더라도 이미 생성된 Notification의 의미가 바뀌면 안 되기 때문입니다. 이 정책은 retry와 redrive에서도 중요합니다. 기본 redrive는 **현재 최신 Template을 다시 렌더링하는 작업이 아니라 원래 논리 알림을 재실행하는 것**으로 보는 것이 안전합니다. Provider Template은 N3로 제한하는 것이 좋습니다. Provider 측 Template을 직접 사용하면 최종 렌더링 내용을 플랫폼이 재현하기 어려울 수 있으므로, provider template ID와 version/revision을 반드시 Attempt에 기록해야 합니다. Localization은 다음 순서가 적절합니다. ```text Recipient locale → 정확한 locale template → language-only locale → template fallback locale → platform default → 실패 ``` FCM 자체도 `*_loc_key`, `*_loc_args`로 클라이언트 리소스 기반 localization을 지원하지만, 이는 동일 Template 버전의 실제 최종 문자열이 앱 버전에 따라 달라질 수 있습니다. 따라서 **서버가 감사 가능한 정확한 문구를 통제해야 하는 알림은 서버 렌더링을 기본값으로 하고, FCM client-side localization은 N3 capability로 두는 편이 낫습니다.** citeturn15search8 채널별 renderer는 별도로 두어야 합니다. | 채널 | 발송 전 검증 | |---|---| | Email | subject, text/html MIME, character encoding, attachment reference, header injection | | SMS | GSM-7/UCS-2 판정, segment 수, 길이·비용 estimate | | Mobile Push | title/body/data size, platform override, deep link, collapse | | Web Push | encrypted payload 크기, TTL, urgency, topic | | In-App | UI-independent semantic content, deep link/action schema | Spring의 `MimeMessageHelper`는 multipart attachment와 inline resource를 제공하므로 SMTP adapter 구현 재료로는 적합합니다. 단 attachment 자체의 저장 수명주기는 Notification이 아니라 `fileserver`/`objectstorage`가 담당해야 합니다. citeturn14search1turn14search9 ### Routing·Fallback 정책 지원 primitive는 다음 여섯 가지로 충분합니다. ```text EXPLICIT_CHANNEL ORDERED_FALLBACK PARALLEL_MULTI_CHANNEL FIRST_SUCCESS ALL_REQUIRED BEST_EFFORT ``` 그러나 **Stable 기본은 `EXPLICIT_CHANNEL`과 제한형 `ORDERED_FALLBACK`**을 권장합니다. Fallback 판정은 아래와 같이 해야 합니다. | 첫 Attempt 결과 | 같은 채널 Retry | 다른 채널 Fallback | 기본 정책 | |---|---:|---:|---| | 요청 전 명백한 validation 실패 | X | 조건부 | 다른 Contact Point가 있으면 가능 | | `INVALID_RECIPIENT` | X | O | 해당 Contact Point invalidation 후 fallback | | `THROTTLED` | O | 보통 X | TTL 내 backoff | | 명확한 Provider 5xx/일시 장애, 수락 전 | O | 정책에 따라 O | 같은 Provider retry 우선 | | `CONFIRMED_REJECTED` | 실패 유형에 따라 | O | 안전 | | `PROVIDER_ACCEPTED` | X | 보통 X | 중복 가능 | | `AMBIGUOUS_SUBMISSION` | **기본 X** | **기본 X** | reconcile 또는 운영 판단 | | `DELIVERED` | X | X | 완료 | 핵심은: ```text Push Timeout → 곧바로 SMS Fallback ``` 이 기본 동작이 위험하다는 것입니다. Push가 이미 Provider에 접수됐는데 응답만 유실됐다면 SMS와 Push가 모두 사용자에게 도착할 수 있습니다. Email 공급자 A의 응답이 유실됐다고 공급자 B로 즉시 재전송하는 것도 같은 문제입니다. 특히 SES는 “API accepted”와 실제 send가 이미 분리되어 있으므로 Provider 경계를 넘는 fallback은 더욱 보수적으로 다뤄야 합니다. citeturn14search0 따라서 `RecipientDelivery`에 다음 필드가 실제로 필요합니다. ```text fallbackAllowed fallbackReason previousAttemptOutcome ambiguousAttemptExists duplicateRisk ``` `FIRST_SUCCESS`도 이름과 달리 진정한 first-success가 아닐 수 있습니다. 두 Provider에 병렬로 제출한 순간 둘 다 취소 불가능한 상태가 될 수 있기 때문에, 이를 Stable 기본 primitive로 제공하기보다 **duplicate-tolerant notification 전용 Experimental 기능**으로 두는 것이 맞습니다. ### Idempotency·Deduplication·Collapse 세 개는 완전히 다른 기능으로 정의해야 합니다. | 기능 | 질문 | 권장 구현 | |---|---|---| | Idempotency | “같은 API 요청인가?” | `(tenantId, idempotencyKey)` unique + request fingerprint | | Deduplication | “서로 다른 요청이지만 같은 사용자 알림인가?” | `dedupKey + recipient + window` | | Collapse/Coalescing | “아직 전달되지 않은 옛 알림을 새 알림으로 대체할 것인가?” | Provider capability | Idempotency에서는 같은 key와 같은 fingerprint면 기존 `notificationId`를 반환하고, **같은 key인데 payload/template/recipient가 다르면 충돌 오류**를 반환하는 것이 안전합니다. ```text idempotencyKey = abc request A = "결제 완료 10,000원" 같은 key request B = "결제 완료 100,000원" → 기존 성공 반환 X → IDENTITY_CONFLICT ``` Deduplication은 업무 의미가 개입되므로 Opt-in이어야 합니다. ```text dedupKey = "order:123:shipping-delayed" window = 30m recipient = user-42 ``` Collapse는 Provider transport 최적화입니다. FCM은 미전달 collapsible message를 새 메시지로 교체할 수 있고, 전송 순서 자체는 보장하지 않습니다. FCM 문서에 따르면 Android의 collapse key, APNs의 `apns-collapse-id`, Web Push의 `Topic`을 각각 활용할 수 있습니다. citeturn15search3 Web Push RFC 8030도 같은 `Topic`의 outstanding message를 새로운 message resource로 대체하도록 정의합니다. citeturn12search0 중요한 규칙은: > **Collapse는 Deduplication이 아니다.** 이전 메시지가 이미 기기에 도달했다면 collapse는 아무 효과가 없습니다. 따라서 `collapseKey`가 있다고 “사용자가 하나만 받는다”고 보장해서는 안 됩니다. ### Scheduling·TTL·Expiration 권장 구조는 **플랫폼 자체 Durable Scheduler를 Stable 기본값**으로 두는 것입니다. ```text Notification DB SCHEDULED │ │ scheduleAt 도래 ▼ READY_TO_DISPATCH │ ▼ Provider ``` Provider Native Scheduling은 N3입니다. 예를 들어 Twilio는 `scheduled` 상태를 제공하고 send time 이전 cancel을 지원하지만, FCM의 console scheduling도 실제 fan-out이 시작된 뒤에는 취소할 수 없습니다. Provider마다 “예약했다”와 “취소 가능하다”의 의미가 달라 공통 Core를 Provider schedule에 종속시키면 일관된 수명주기를 만들기 어렵습니다. citeturn16search6turn15search13 Core의 세 시각은 분리해야 합니다. ```text scheduleAt → 플랫폼이 발송을 시작할 수 있는 시각 notBefore → 이보다 먼저 Provider에 제출하면 안 됨 expiresAt → 이 시각 이후에는 새 Retry/Fallback/Dispatch 금지 ``` 그리고 Adapter가 실제 Provider TTL로 변환합니다. ```text providerTTL = min( expiresAt - now, providerMaximumTTL, channelPolicyMaximumTTL ) ``` Web Push에서는 TTL header가 선택이 아니라 프로토콜 필수이며, 누락 시 push service가 `400`을 반환해야 합니다. Push service는 요청보다 더 짧은 실제 TTL을 선택할 수 있고 TTL이 끝난 메시지를 더 이상 전달하려 해서는 안 됩니다. citeturn12search0 FCM은 message lifespan과 collapse를 제공하고, APNs도 expiry를 이용해 저장된 알림 수명을 제어합니다. Apple의 현재 Metrics 문서는 persistent storage에서 명시된 expiry가 없을 경우 최대 30일 TTL을 언급하지만, Core에서 이를 공통 기본값으로 하드코딩해서는 안 됩니다. citeturn15search5turn19search8 모든 Retry는 다음 검사를 먼저 해야 합니다. ```text now + nextBackoff + estimatedDispatchDuration < expiresAt ``` 거짓이면 `EXPIRED`로 종료합니다. ## 채널별 기능과 운영 매트릭스 ### Email 기능·운영 매트릭스 | 기능 | SMTP | SES API 기준 | Core 정책 | |---|---|---|---| | Text | O | O | Stable | | HTML | O | O | Stable | | multipart/alternative | O | O | Stable | | Attachment | O | O | Ref 기반 Stable | | Inline Resource | O | O | Ref 기반 Stable | | CC/BCC/Reply-To | O | O | Typed field | | Provider Template | 해당 없음 | O | N3 | | Bulk personalization | 직접 구현 | Provider 지원 | N2/N3 | | Provider accepted 확인 | SMTP final response | `MessageId` | `PROVIDER_ACCEPTED` | | Delivery | DSN/bounce 인프라 필요 | Delivery event | 별도 evidence | | Bounce | DSN/Return Path | event | suppression 연동 | | Complaint | SMTP 자체로 부족 | event | 즉시 suppression 후보 | | Open/Click | 외부 기능 | event publishing 가능 | reliability 상태와 분리 | | Retry | SMTP code 기반 | API 오류 기반 | 공통 RetryPolicy | | List unsubscribe | MIME header | 가능 | Bulk/subscription mail capability | SMTP의 기본 오류 분류는 프로토콜 자체에서 4yz와 5yz로 나뉩니다. RFC 5321은 4yz가 같은 요청을 다시 시도하면 성공할 수 있는 임시 실패이고 client가 retry해야 하는 범주이며, 5yz는 동일한 요청을 그대로 반복해서는 안 되는 영구 실패로 설명합니다. citeturn18search2 단, SMTP에서도 네트워크가 **최종 수락 응답 경계에서 끊어지면 실제 Provider가 메시지를 받은 것인지 모호한 상태**가 생길 수 있으므로 `SMTP_SEND_FAILED = safeRetry`로 단순화해서는 안 됩니다. 이는 `AMBIGUOUS_SUBMISSION` 모델이 HTTP Provider에만 필요한 것이 아니라는 뜻입니다. SES의 `200 + MessageId`는 accepted 증거지만 최종 전달 증거가 아니며, SES 자체가 accept 이후 virus 또는 invalid template personalization으로 send하지 않을 수 있다고 명시합니다. citeturn14search0 SES event publishing은 reject, delivery, bounce, complaint, delivery delay 등을 분리합니다. `Delivery`는 recipient mail server까지 전달됐다는 뜻이고, `Complaint`는 그 후 사용자가 spam으로 신고한 상황이므로 이 역시 앞서 설명한 “단일 선형 상태 enum”이 부적절한 이유입니다. citeturn14search8 대량·구독형 Email의 one-click unsubscribe를 지원한다면 RFC 8058 규칙을 별도 capability로 구현해야 합니다. `List-Unsubscribe`의 HTTPS URI와 `List-Unsubscribe-Post`를 사용하고, 해당 헤더들이 유효한 DKIM signature에 포함되어야 합니다. citeturn14search2turn14search4 DKIM 자체는 도메인이 메시지에 대한 책임을 cryptographic signature로 선언하고 DNS에서 공개 키를 검색해 검증하는 표준입니다. Notification Core가 DKIM을 업무 API에 노출하기보다 `SenderIdentity`의 운영 준비 상태로 관리하는 것이 적절합니다. citeturn18search0 ```text SenderIdentity ├─ domain ├─ fromAddress ├─ replyTo ├─ provider ├─ dkimStatus ├─ spfStatus ├─ dmarcStatus └─ enabled ``` SPF/DKIM/DMARC 정책 자체는 Email delivery infrastructure 영역이고, Core는 “이 Sender가 Production 발송에 적합한가”를 Admin/Health Gate로 확인하는 역할이 적절합니다. ### SMS 기능·운영 매트릭스 | 항목 | Stable 정책 | |---|---| | 주소 | E.164 normalized phone number | | 실제 번호 유효성 | 형식 검증과 별도; Provider lookup/verification capability | | Sender | number/sender ID/short code 등 adapter profile | | 본문 | Unicode String | | Encoding 분석 | GSM-7/UCS-2 사전 계산 | | Segment 예상 | 필수 | | 예상 비용 정보 | Provider pricing과 분리하되 segment count 제공 | | Delivery callback | Stable | | Callback ordering | 순서 의존 금지 | | Recipient invalidation | provider error 기반 ContactPoint invalid | | Opt-out | 내부 suppression + Provider suppression 동기화 | | MMS/RCS | 별도 Adapter/Extension | Twilio는 수신 번호를 E.164 형태로 받으며, 상태를 `accepted`, `queued`, `sending`, `sent`, `failed`, `delivered`, `undelivered` 등으로 구분합니다. citeturn16search3 SMS는 문자열 `.length()`로 비용이나 전송 크기를 계산하면 안 됩니다. Twilio의 설명처럼 GSM-7이면 일반 단일 segment가 160문자이며 concatenated segment는 153자, UCS-2는 각각 70/67자가 기준이 됩니다. 하나의 비-GSM 문자가 전체 메시지를 UCS-2로 바꿔 segment 수를 크게 늘릴 수 있습니다. citeturn16search1turn16search2 따라서 N1 API에 `estimate()`를 제공할 가치가 있습니다. ```java SmsEstimate estimate(SmsNotification notification); ``` 예: ```java record SmsEstimate( SmsEncoding encoding, int segmentCount, int encodedLength, boolean exceedsRecommendedLimit ) {} ``` Twilio callback은 HTTP 네트워크 지연 때문에 전송된 순서대로 도착한다는 보장이 없습니다. 그러므로 `queued → delivered → sent` 순으로 callback이 실제 endpoint에 도착해도 마지막 `sent`가 상태를 downgrade해서는 안 됩니다. citeturn16search0 ### FCM·APNs 기능 비교 | 항목 | FCM | APNs | |---|---|---| | 기준 Target | **2026 현재 FID 권장**, token legacy 경로 존재 | device token | | 단일 Device | O | O | | Batch/Multicast | Admin SDK 최대 500 destination, 부분 실패 결과 | Core가 개별 request orchestration | | Topic/Broadcast | Topic 지원 | 일반 push와 별도 Broadcast/Live Activity capability | | Notification/Data | 둘 다 지원 | `aps` + custom payload | | Platform Override | Android/APNs/Web push config | APNs native headers/payload | | TTL/Expiration | O | `apns-expiration` | | Collapse | Android collapse key, APNs/Webpush override 가능 | `apns-collapse-id`, 저장 시 교체 의미 | | Priority | O | `apns-priority` | | Push Type | platform config | `apns-push-type` | | Provider Request ID | FCM message result | `apns-request-id` | | Delivery order | 보장하지 않음 | 보장하지 않음 | | Server-side final delivery receipt | 일반 send API로 없음 | 일반 provider API로 없음 | | Invalid target 처리 | `UNREGISTERED` 등 | token-related errors | | 앱 측 권한 | 플랫폼별 | UserNotifications permission | | 환경 | Firebase project/app | sandbox/production 분리 | FCM은 notification message와 data message를 구분하고 payload는 최대 4096 bytes를 지원한다고 현재 문서에서 설명합니다. citeturn15search1 현재 FCM Admin SDK에서 multicast는 최대 500개의 FID/target을 다루고, 응답은 각 input과 대응되는 부분 성공·실패를 제공하므로 `Batch Attempt 1건`이 아니라 **Recipient별 Attempt 결과를 반드시 생성해야 합니다.** citeturn15search0turn15search2 또한 Firebase 문서는 FCM이 delivery order를 보장하지 않으며 collapsible message는 아직 전달되지 않은 이전 메시지를 대체할 수 있다고 명시합니다. 따라서 Push를 순차적인 업무 이벤트 전달 수단처럼 사용하면 안 됩니다. citeturn15search3 APNs는 일반 remote notification의 timely delivery를 보장하지 않습니다. Apple의 현재 운영 문서는 APNs가 수락한 알림을 전달하거나, 저장하거나, 조건에 따라 폐기할 수 있고 persistent storage에서는 같은 앱·기기의 다른 알림에 의해 기존 알림이 덮어써질 수 있으며 ordering guarantee도 없다고 설명합니다. citeturn13search1turn19search8 따라서 다음은 금지해야 합니다. ```text APNs HTTP 200 ↓ RecipientDelivery.status = DELIVERED // 금지 ``` 대신: ```text APNs HTTP 200 ↓ SubmissionOutcome = CONFIRMED_ACCEPTED Evidence = PROVIDER_ACCEPTED ``` 사용자가 실제로 notification을 열었다는 증거가 필요하면 앱에서 `UNNotificationResponse` 등 사용자 interaction을 application backend로 다시 보고하는 별도 telemetry가 필요합니다. Apple은 사용자가 알림을 열거나 action을 선택했을 때 앱이 이를 처리할 수 있는 callback API를 제공합니다. citeturn19search6turn19search4 ### Web Push 표준 지원표 | 표준 | 역할 | 지원 등급 | |---|---|---| | RFC 8030 | Push subscription, delivery, TTL, urgency, topic, optional receipt | Stable | | RFC 8291 | Payload encryption | Stable, 필수 | | RFC 8292 | VAPID application server identification/restricted subscription | Stable | | RFC 8030 Receipt | User agent acknowledgement | Capability/Experimental 활용 | | Provider-specific 404/410 처리 | subscription invalidation | Adapter capability | RFC 8030은 Push Service와 Application Server 간 통신에 TLS를 요구하고, application server가 push 요청에 `TTL` header를 반드시 넣도록 규정합니다. Push service는 요청 TTL보다 짧은 TTL을 선택할 수도 있습니다. citeturn12search0 RFC 8291은 Web Push payload에 P-256 ECDH와 authentication secret을 사용하는 `aes128gcm` 암호화를 정의하며, subscription의 public key와 auth secret 자체도 authenticated confidential channel을 통해 application server로 전달해야 합니다. RFC는 push service가 4096 bytes를 초과한 body를 지원할 필요가 없다고 명시합니다. citeturn20search0 RFC 8292 VAPID는 ES256-signed JWT를 이용해 application server의 identity를 표현하고, subscription을 특정 application server key에 제한할 수 있게 합니다. 제한된 subscription의 signing key를 교체하면 새로운 subscription이 필요할 수 있으므로 VAPID key rotation은 단순 credential rotation과 다릅니다. citeturn20search1 Web Push endpoint는 일반 URL처럼 취급해서는 안 됩니다. RFC 8030은 push URI를 **knowledge 자체가 권한이 되는 capability URL, 사실상의 bearer token**으로 정의합니다. 따라서 endpoint를 로그·metric·일반 설정에 노출해서는 안 됩니다. citeturn12search0 권장 `WebPushSubscription`은 다음과 같습니다. ```text subscriptionId ownerId endpointEncrypted endpointHash p256dhEncrypted authSecretEncrypted vapidKeyId createdAt lastConfirmedAt expiredAt status ``` ### In-App Inbox 설계 범위 In-App은 Provider Adapter가 아니라 별도 `notification-inbox`로 두는 것이 좋습니다. ```text notification │ └── RecipientDelivery │ ▼ notification-inbox ├─ persisted ├─ seen ├─ read ├─ archived └─ expired ``` 지원 범위는 cursor pagination, unread/read, seen, archive, expire, bulk mark-read, unread count, category, deep link/action 정도입니다. `WebSocket`은 새 Inbox item의 실시간 신호일 뿐 source of truth가 되어서는 안 됩니다. ```text DB commit → Inbox item 존재 WebSocket 실패 → 화면 실시간 갱신만 실패 → Inbox 데이터는 보존 ``` 대규모 broadcast에서는 fan-out-on-write와 fan-out-on-read를 별도 전략으로 둡니다. 일반 개인 알림은 fan-out-on-write가 단순하고, 수백만 사용자에게 동일한 공지를 복제하는 경우는 broadcast item + per-user state 같은 fan-out-on-read/materialization 방식을 별도 capability로 두는 것이 적절합니다. ## 실패·재시도·Callback·Reconciliation ### 공통 오류 모델 Provider SDK exception을 그대로 밖으로 노출하지 않고 최소 다음 taxonomy가 필요합니다. ```text NotificationException ├─ NotificationValidationException ├─ TemplateRenderingException ├─ InvalidContactPointException ├─ NotificationExpiredException ├─ ProviderAuthenticationException ├─ ProviderAuthorizationException ├─ ProviderThrottledException ├─ ProviderTransientException ├─ ProviderPermanentException ├─ ProviderRejectedException ├─ AmbiguousSubmissionException ├─ CallbackValidationException └─ NotificationSuppressedException ``` 실제 retry 판정에 사용하는 category는 다음과 같이 단순화할 수 있습니다. | Failure Category | 자동 Retry | Contact Point 변경 | Fallback | 운영 대응 | |---|---:|---:|---:|---| | `TRANSIENT_PROVIDER` | O | X | 조건부 | backoff | | `THROTTLED` | O | X | 보통 X | Retry-After/Provider rate | | `AUTHENTICATION` | 일반 메시지별 Retry X | X | X | Provider route 중단·credential 점검 | | `AUTHORIZATION` | X | X | X | ACL/config 점검 | | `INVALID_RECIPIENT` | X | **O** | O | invalidate/suppress | | `INVALID_PAYLOAD` | X | X | X | 개발 오류 | | `TEMPLATE_FAILURE` | X | X | X | template 수정 필요 | | `PERMANENT_PROVIDER` | X | 상황별 | 정책에 따라 | admin | | `AMBIGUOUS_SUBMISSION` | **기본 X** | X | **기본 X** | reconcile | | `CALLBACK_VALIDATION_FAILURE` | callback 처리 X | X | X | security alert | FCM은 `QUOTA_EXCEEDED`, `UNAVAILABLE`, `UNREGISTERED`, 인증 관련 오류 등을 구분합니다. 현재 공식 문서는 `429 QUOTA_EXCEEDED`에는 exponential backoff를 적용하고, `503 UNAVAILABLE`에서는 `Retry-After`를 존중하고 jitter를 사용하도록 안내합니다. `UNREGISTERED`는 해당 target이 더 이상 유효하지 않음을 의미합니다. citeturn21search1turn21search4 따라서 Retry는 단순: ```text 429 || 5xx → retry ``` 가 아니라: ```text retryableFailure AND submissionIsSafeToRepeat AND recipientStillValid AND !fallbackAlreadyCommitted AND deadlineRemaining AND retryBudgetRemaining ``` 이어야 합니다. ### 모호한 완료 모델 가장 중요한 장애 케이스는 다음입니다. ```text Platform │ ├──── send ────▶ Provider │ │ │ └─ accepted │ X connection reset ``` 이때 플랫폼에는 Provider ID가 없을 수 있지만 실제 알림은 발송될 수 있습니다. 따라서 Attempt는 다음 metadata를 보존해야 합니다. ```text requestStarted requestBodyCommitted providerResponseReceived providerRequestId submissionOutcome ambiguousReason attemptNumber elapsed ``` 그리고 다음 규칙을 권장합니다. ```text CONFIRMED_REJECTED → 재시도 가능 CONFIRMED_ACCEPTED → 같은 알림 재제출 금지 AMBIGUOUS → Provider 조회 가능: reconciliation → Provider idempotency 지원: 동일 idempotency contract로 재요청 → 둘 다 없음: 자동 재발송 기본 금지 ``` Provider가 처음부터 per-request idempotency를 지원하지 않는다면 Core의 `idempotencyKey`만으로 **Provider 실제 발송 중복까지 제거할 수 없습니다.** Core idempotency는 “NotificationRequest 중복 생성”을 막을 뿐 이미 Provider에 제출된 Attempt의 부작용을 지울 수 없습니다. ### Rate Limit·Backpressure Global QPS 하나보다 다음 계층을 모두 지원해야 합니다. ```text global ↓ channel ↓ provider account ↓ sender identity ↓ country / destination class ↓ recipient/contact point ``` Dispatcher에는 다음 제한을 별도로 둡니다. ```text maxConcurrentDispatch providerQps providerBurst maxBatchSize maxQueueDepth maxQueueAge maxRetryConcurrency maxScheduledFanoutPerTick callbackWorkerConcurrency ``` 외부 Provider의 실제 quota는 자주 변할 수 있기 때문에 코드 상수보다 Provider Profile 설정으로 두어야 합니다. FCM에서도 quota 초과의 원인이 project message rate, individual device rate, topic rate로 다를 수 있습니다. citeturn21search4 권장 흐름은: ```text Durable Dispatch Queue │ ▼ Expiration check │ ▼ Suppression re-check │ ▼ Rate Limiter │ ▼ Concurrency Limiter │ ▼ Provider Adapter ``` 특히 예약 후 실제 발송까지 시간이 길다면 **submit 시점 한 번만 suppression을 검사해서는 안 됩니다.** 예약 후 사용자가 opt-out하거나 Contact Point가 invalidated될 수 있으므로 실제 dispatch 직전에 다시 검사합니다. ### Callback·Receipt 처리 계약 Callback pipeline은 아래 순서로 고정하는 것을 권장합니다. ```text HTTP Callback │ ▼ Payload size/content-type limit │ ▼ Provider signature verification │ ▼ Raw event durable append │ ▼ Event deduplication │ ▼ providerRequestId → attemptId resolve │ ▼ Provider-specific normalization │ ▼ Projection merge │ ▼ Metric / audit / internal event ``` Twilio는 webhook 요청을 `X-Twilio-Signature`로 서명하고 공식 SDK의 validator 사용을 권장합니다. 또한 webhook 필드는 채널·이벤트에 따라 달라지고 새 필드가 추가될 수 있다고 명시하므로, DTO parser가 unknown field 때문에 실패해서는 안 됩니다. citeturn17search0turn16search0 따라서 callback DTO의 원칙은: ```text known fields → typed normalize unknown fields → preserve/ignore safely raw payload → bounded encrypted/archive storage signature → 원본 bytes/원본 parameter set 기준 검증 ``` 입니다. Callback 중복 제거는 가능한 경우: ```text UNIQUE(provider, providerEventId) ``` 를 사용하고 Provider event ID가 없다면: ```text provider + providerRequestId + eventType + providerOccurredAt + normalizedPayloadDigest ``` 같은 fingerprint 방식이 필요합니다. Twilio는 callback이 순서대로 도착한다는 보장이 없다고 명시하므로 `receivedAt`만 보고 현재 상태를 갱신해서는 안 됩니다. citeturn16search0 또 callback은 **누락될 수도 있습니다.** Twilio는 delivery status가 12시간 내 갱신되지 않으면 API polling을 수행하고, 놓친 이벤트 확인을 위해 최소 하루 한 번 reconciliation을 권장합니다. citeturn17search1 따라서 Adapter SPI에 다음 capability를 둘 가치가 있습니다. ```java interface ReconciliationCapability { ReconciliationResult reconcile(DeliveryAttempt attempt); } ``` 단 모든 Provider가 이를 지원한다고 가정해서는 안 됩니다. | Provider | Callback | Query/Reconcile | 최종 상태 복구 전략 | |---|---|---|---| | SES | event publishing | Provider 기능에 따라 | event + internal audit | | Twilio | 상태 callback | Message status polling 가능 | callback + scheduled reconciliation | | FCM | 일반 delivery callback 없음 | send 오류·target lifecycle 중심 | 앱 receipt 선택 | | APNs | 일반 transactional delivery callback 없음 | Console/Metrics 운영 정보 | 앱 receipt 선택 | | Web Push | RFC receipt optional | Push-service dependent | capability | | In-App | 내부 event | DB 자체 | transactional projection | 상태 merge에는 단일 numeric priority가 아니라 **상태 종류별 transition rule**을 사용합니다. ```text sent → delivered : 허용 delivered → sent : 무시 delivered → complaint : complaint fact 추가 complaint → delivered : complaint 제거 금지 accepted → bounced : 허용 expired callback가 늦게 도착 → providerOccurredAt와 기존 terminal event 비교 ``` 그리고 callback에서 새로운 상태를 발견했다고 raw provider 상태 문자열을 public enum에 바로 추가하지 않습니다. ```text Provider status → Adapter normalization → stable common status + providerNativeStatus ``` 이렇게 해야 Provider가 상태를 추가해도 Core API를 깨뜨리지 않습니다. ## 보안·억제·동의·관측성 ### Suppression·Preference·Consent Primitive Core가 제공할 데이터 구조는 다음처럼 분리하는 것이 좋습니다. ```text SuppressionEntry PreferenceRecord ConsentRecord ContactPointStatus ``` `SuppressionEntry`의 예시는: ```java record SuppressionEntry( SuppressionId id, TenantId tenantId, SuppressionScope scope, SuppressionReason reason, String normalizedTargetHash, String notificationCategory, Instant effectiveAt, Instant expiresAt, SuppressionSource source ) {} ``` 억제 이유는 사용자가 제안한 형태가 적절합니다. ```text USER_OPT_OUT HARD_BOUNCE COMPLAINT INVALID_TOKEN INVALID_PHONE ADMIN_BLOCK PROVIDER_BLOCK TEMPORARY_SUPPRESSION ``` 핵심은 `Preference`와 `Consent`를 합치지 않는 것입니다. ```text Preference → "나는 Push보다 Email을 선호한다" Consent → "어떤 정책 판단에 필요한 동의 기록" Suppression → "현재 이 발송을 기술적으로 차단한다" ``` Core는: ```java interface NotificationEligibilityPolicy { EligibilityResult evaluate(NotificationContext context); } ``` 같은 Hook을 실행할 수 있지만, “한국의 특정 메시지가 광고인지”, “보안 알림을 opt-out할 수 있는지” 같은 법률·업무 판정을 자체 hard-code해서는 안 됩니다. Email hard bounce와 complaint, Push invalid target, Web Push expired subscription, SMS opt-out 등은 Adapter에서 suppression primitive로 연결할 수 있습니다. SES는 bounce·complaint event를 별도로 제공하고, Twilio도 opt-out 관련 Provider 기능을 제공하므로 내부 suppression과 Provider suppression을 동기화하되 **어느 쪽이 source of truth인지 명시**해야 합니다. citeturn14search8turn16search3 권장 우선순위는: ```text Internal mandatory suppression OR Provider suppression OR Injected business eligibility=false → SEND BLOCK ``` 입니다. ### Security·Privacy 정책 보호 대상에는 최소 다음이 포함됩니다. ```text Email address Phone number FCM FID / legacy token APNs device token Web Push endpoint p256dh / auth secret VAPID private key Provider credentials Callback signing secret Template variables Rendered message body Attachment reference ``` Web Push endpoint는 RFC상 capability URL이므로 secret에 준해 취급해야 합니다. citeturn12search0 로그 정책은 특히 엄격해야 합니다. OWASP는 access token, authentication password, encryption key, sensitive PII 등을 원문으로 기록하지 말고 필요하면 제거·마스킹·해시·암호화하도록 권고하며, 전화번호와 이메일 주소도 특별 취급 대상이라고 명시합니다. citeturn21search0 따라서 다음은 **로그 및 Metric Label 금지**로 지정하는 것이 맞습니다. ```text Email address phone number FCM/APNs target Web Push endpoint p256dh/auth 전체 message body template variables provider credential unsubscribe token attachment URL provider callback raw payload ``` 구조화 로그는 다음 정도만 남깁니다. ```text channel=email provider=ses operation=dispatch attempt=2 result=throttled failureCategory=THROTTLED templateId=password-reset ``` 민감 Contact Point의 lookup이 필요하면: ```text HMAC(key, normalizedAddress) ``` 같은 keyed fingerprint를 별도로 저장하고 원문은 암호화 저장하는 방식을 권장합니다. 일반 SHA-256만 쓰면 전화번호나 이메일처럼 추측 가능한 값은 dictionary attack이 쉬우므로 lookup hash에도 secret key를 사용하는 편이 안전합니다. Provider credential은 code/config repository나 평문 로그가 아니라 secret manager를 통해 전달하고, 최소 권한·rotation·revocation이 가능한 구조로 관리해야 합니다. OWASP도 secrets가 필요한 주체에만 보이고 회전·폐기 가능해야 하며 평문으로 로그에 남아서는 안 된다고 권고합니다. citeturn21search2 Callback endpoint에는 다음 방어가 필요합니다. ```text TLS provider signature validation timestamp / nonce 검증이 제공되면 replay 방어 body size limit content-type validation unknown field tolerant parsing idempotent ingestion rate limit tenant/provider binding raw body bounded retention ``` Twilio의 경우 서명 검증이 URL과 payload를 사용하므로 proxy/rewrite 뒤에서 원래 URL을 잘못 복원하면 검증이 실패할 수 있고, JSON callback은 raw body를 기준으로 확인해야 합니다. 공식 SDK validator를 사용하는 것이 권장됩니다. citeturn17search0 ### 환경과 credential 격리 아래 값들은 Contact Point 또는 Provider Profile에 environment를 포함해야 합니다. ```text tenant applicationId provider environment credentialProfile ``` 특히 APNs 개발/운영 token을 혼용하지 않도록: ```text (APNS, appId, SANDBOX) (APNS, appId, PRODUCTION) ``` 을 별 namespace로 보아야 합니다. Apple도 device token과 환경이 맞지 않는 경우를 APNs 오류로 다루며, 최신 troubleshooting 문서 역시 서버가 최신 device token을 유지할 것을 요구합니다. citeturn19search10 FCM 역시 project/app identity와 target association을 Adapter profile 안에 고정합니다. ### Metric·Trace·Audit 계약 관측 단위는 반드시 세 레벨입니다. ```text Notification RecipientDelivery ProviderAttempt ``` **권장 Metric** | Metric | 주요 Tag | |---|---| | notification.requested | channel plan, category | | notification.suppressed | channel, reason | | notification.render | channel, templateId, result | | notification.dispatch | channel, provider, result | | notification.provider.accepted | channel, provider | | notification.delivery | channel, provider, outcome | | notification.retry | provider, failureCategory, attempt bucket | | notification.fallback | fromChannel, toChannel, reason | | notification.ambiguous | channel, provider | | notification.callback | provider, eventType, result | | notification.callback.delay | provider, eventType | | notification.reconciliation | provider, correction | | notification.queue.depth | channel/provider | | notification.queue.age | channel/provider | | notification.schedule.delay | channel | | notification.contact.invalid | channel, provider | 허용 tag는 bounded vocabulary로 제한합니다. ```text channel provider templateId notificationCategory status failureCategory attemptBucket sizeBucket ``` `templateId`도 무제한 사용자 입력이 아니라 등록된 template registry 값일 때만 Metric tag로 허용해야 합니다. 금지: ```text recipientId email phone device/FID/token notificationId recipientDeliveryId attemptId providerRequestId full exception message full URL ``` Trace는 다음처럼 나누는 것이 좋습니다. ```text notification.submit │ ├─ render ├─ dispatch attempt │ └─ provider HTTP/SMTP span │ └─ enqueue ``` 비동기 callback은 원래 dispatch span이 이미 끝난 뒤 수시간 후 도착할 수 있으므로 **장시간 child span**으로 유지하는 것보다 원래 trace/attempt와 correlation/link를 두는 방식이 적절합니다. Audit는 Metric보다 강하게 보존해야 합니다. ```text template publish template disable suppression add/remove consent update contact invalidation/reactivation manual redrive manual retry manual cancel reconciliation correction provider enable/disable credential profile rotation admin override callback signature reject ``` Audit에도 PII 원문은 꼭 필요한 경우가 아니면 저장하지 않고 `contactPointId`나 보호된 identifier를 사용합니다. ## 테스트와 구현 로드맵 ### 공통 계약·보안·장애·성능 테스트 구현 전부터 모든 Adapter가 통과해야 할 **공통 Contract Test Kit**을 만드는 것이 중요합니다. | 테스트 영역 | 필수 시나리오 | 합격 조건 | |---|---|---| | Request | 단일·다중 recipient | RecipientDelivery 개수가 계약과 일치 | | Idempotency | 같은 key 재요청 | 같은 logical Notification | | Idempotency conflict | 같은 key, 다른 payload | 명확한 conflict | | Template | 변수 누락·타입 오류 | Provider 호출 전 실패 | | Locale | exact/fallback/default | 고정된 template version 사용 | | Scheduling | restart 포함 | schedule 누락·중복 없음 | | Expiration | queue/retry 중 만료 | 만료 후 Provider 제출 없음 | | Suppression | schedule 후 opt-out | dispatch 직전 다시 차단 | | Provider accepted | 명시적 성공 | PROVIDER_ACCEPTED만 기록 | | Provider rejected | 4xx/permanent | 자동 위험 retry 없음 | | Timeout before send | 명백한 미전송 | safe retry | | Response loss | Provider accepted 후 응답 단절 | AMBIGUOUS | | Retry | transient/429 | backoff·budget·expiry 준수 | | Fallback | confirmed failure | 규칙대로 차순위 채널 | | Ambiguous fallback | 응답 유실 | 기본 자동 fallback 금지 | | Callback | 정상 | Attempt와 정확히 매핑 | | Callback duplicate | 동일 이벤트 N회 | projection 1회 효과 | | Callback out-of-order | delivered 후 sent 도착 | downgrade 없음 | | Callback unknown fields | 새 field 추가 | 파싱 성공 | | Callback security | invalid signature | 상태 변경 없음 | | Reconciliation | callback 누락 | Provider 상태로 correction | | Batch | 부분 성공 | recipient별 독립 상태 | | Shutdown | in-flight dispatch | 유실 또는 무조건 중복 없음; ambiguous 기록 | | DB crash | commit 전/후 | durable state 계약 유지 | | Queue overload | burst | heap/thread 폭증 없이 backpressure | | Credential rotation | old/new 중간 상태 | 안전한 전환 | | Tenant isolation | 교차 ID 접근 | 차단 | | Logging | PII/secret 입력 | 원문 미노출 | Twilio Adapter에서는 `delivered` callback보다 `sent` callback을 늦게 보내는 테스트를 필수로 넣어야 합니다. 실제 Twilio가 callback 도착 순서를 보장하지 않기 때문입니다. citeturn16search0 Twilio reconciliation 테스트에서는 callback을 의도적으로 누락시키고 polling으로 수정되는지도 확인합니다. 공급자 자체도 callback 누락 가능성 때문에 polling과 주기적 reconciliation을 권장합니다. citeturn17search1 ### Email 테스트 ```text SMTP 2xx SMTP 4xx SMTP 5xx DATA 후 connection reset MIME text/html multipart/alternative attachment inline resource Unicode subject/body malformed recipient SES accepted SES reject SES delivery delivery delay hard bounce complaint rendering failure duplicate provider event List-Unsubscribe headers DKIM-signed unsubscribe headers ``` SMTP 4xx/5xx 분류는 RFC 5321 계약과 일치해야 합니다. citeturn18search2 ### SMS 테스트 ```text E.164 normalization invalid phone GSM-7 160 GSM-7 161 UCS-2 70 UCS-2 71 emoji 1개로 encoding 변경 segment estimator accepted→queued→sent→delivered accepted→failed sent→undelivered delivered callback before sent callback duplicate callback provider 429 provider timeout opt-out provider suppression synchronization ``` SMS segment 경계는 실제 GSM-7/UCS-2 규칙으로 검증해야 합니다. citeturn16search1turn16search2 ### FCM·APNs 테스트 ```text FCM FID 정상 legacy token compatibility UNREGISTERED QUOTA_EXCEEDED UNAVAILABLE 500개 multicast partial batch failure collapse TTL expiry notification/data combinations foreground/background semantics APNs sandbox APNs production namespace separation invalid device token wrong topic/environment apns-expiration apns-priority apns-collapse-id apns-push-type provider accepted ≠ delivered offline storage / expiry assumptions app-generated read/open receipt ``` FCM 2026 FID migration은 반드시 contract test에 포함해야 합니다. registration-token-only 구현은 현재 Admin SDK의 권장 방향과 어긋납니다. citeturn15search0turn15search6 또 Android에서는 background notification message와 notification+data 조합의 앱 callback 동작이 foreground와 다르므로, “Provider가 보냈으니 앱 callback이 반드시 실행된다”는 테스트 가정을 두어서는 안 됩니다. Firebase는 background notification을 system tray가 처리하는 경우를 별도로 설명합니다. citeturn15search10 ### Web Push 테스트 ```text RFC 8030 TTL 누락 → reject TTL 0 TTL expiry Urgency Topic replacement expired subscription VAPID success/failure wrong VAPID key VAPID key rotation p256dh/auth corruption aes128gcm encryption oversized payload receipt capability on/off endpoint secret logging test ``` RFC 8030상 TTL 누락은 `400` 대상이며 expired subscription에는 `404`가 정의되어 있으므로 이 두 계약은 공통 Web Push contract test에 넣을 수 있습니다. citeturn12search0turn12search2 RFC 8291 encryption test에는 P-256, auth secret, `aes128gcm` 처리와 잘못된 key/payload 검증을 포함해야 합니다. citeturn20search0 ### In-App 테스트 ```text cursor pagination read/unread seen idempotent mark-read concurrent mark-read archive expire unread count consistency bulk mark-read notification deletion policy large broadcast fan-out restart WebSocket outage tenant isolation ``` 실시간 WebSocket이 끊겨도 DB 조회에서는 Inbox notification이 나타나는 것이 핵심 계약입니다. ### 성능·장애 시나리오 최소 다음 부하 시나리오를 별도 성능 suite로 둡니다. | 시나리오 | 검증값 | |---|---| | 대량 recipient fan-out | DB write rate, queue depth, memory | | 특정 예약시각 집중 | schedule lag, dispatch burst | | Provider 30분 장애 | backlog 증가율, retry amplification | | Provider 429 지속 | rate adaptation, retry storm 여부 | | Callback burst | callback queue lag | | Slow Provider | worker/concurrency 고갈 여부 | | DB slow | queue claim/lock behavior | | Process kill | in-flight ambiguous attempt 수 | | Provider accepted 후 response loss | duplicate-risk 처리 | | 대규모 DLQ/redrive | Provider flood 방지 | | credential 만료 | 실패가 개별 recipient retry 폭풍으로 증폭되지 않는지 | 특히 인증 실패는 메시지별 exponential retry를 해서는 안 됩니다. Credential이 만료된 상태에서 수십만 notification이 각각 retry하면 Provider와 내부 queue를 동시에 압박하므로, `AUTHENTICATION`은 Provider Profile을 unhealthy/open 상태로 만들고 운영 alert를 발생시키는 쪽이 낫습니다. ### 구현 순서와 단계별 완료 조건 **기반 단계 — Notification Core** 구현 범위: ```text NotificationRequest RecipientDelivery DeliveryAttempt ContactPoint ProviderEvent ledger Typed API Provider Adapter SPI Idempotency Durable dispatch queue Error taxonomy Basic metrics/audit ``` 완료 조건은 동일 idempotency key의 동시 요청에서 logical notification이 하나만 생성되고, `Provider accepted 후 response loss`를 `AMBIGUOUS`로 표현할 수 있으며, process kill 후 미처리 Notification을 복구하고, logs/metrics에서 Contact Point 원문이 검출되지 않는 것입니다. **Email 단계** 구현 범위: ```text EmailContent Template renderer SMTP Adapter SES Adapter MIME attachment reference bounce/complaint/delivery Email suppression ``` 완료 조건은 SMTP 4xx/5xx 계약, SES acceptance와 delivery 분리, bounce·complaint suppression, MIME·Unicode·attachment 테스트, callback/event idempotency가 모두 통과하는 것입니다. SES acceptance를 `DELIVERED`로 매핑하는 코드가 없어야 합니다. citeturn14search0turn14search8 **SMS 단계** 구현 범위: ```text PhoneNumber Twilio Adapter SMS encoding/segment estimator Status callback Reconciliation Opt-out/suppression integration ``` 완료 조건은 GSM-7/UCS-2 segment 계산, 역순 callback, callback 누락 후 reconciliation, invalid number, throttling 테스트가 통과하는 것입니다. citeturn16search0turn17search1 **Mobile Push 단계** 구현 범위: ```text MobilePushTarget FCM FID legacy registration token compatibility APNs token FCM Adapter APNs Adapter TTL priority collapse platform override ``` 완료 조건은 FCM 부분 batch 실패가 Recipient별로 분해되고, FCM `UNREGISTERED`와 APNs invalid token이 Contact Point lifecycle에 반영되며, APNs/FCM Provider success가 `DELIVERED`로 오인되지 않는 것입니다. FCM FID를 현재 기준의 primary target으로 테스트해야 합니다. citeturn15search0turn21search1turn19search9 **Web Push 단계** 구현 범위: ```text WebPushSubscription RFC 8030 RFC 8291 encryption RFC 8292 VAPID TTL/Urgency/Topic subscription invalidation ``` 완료 조건은 표준 test vector/interop test, expired subscription cleanup, VAPID 오류, endpoint 비밀값 보호가 통과하는 것입니다. citeturn12search0turn20search0turn20search1 **Advanced Delivery 단계** 구현 범위: ```text Durable scheduling Ordered fallback Dedup Collapse mapping Retry budget provider rate limiter backpressure reconciliation framework ``` 완료 조건은 예약 중 재시작, 예약 후 suppression, expiry 중 retry, ambiguous submission 상태에서 자동 cross-channel fallback 금지, provider outage 중 retry storm 방지가 검증되는 것입니다. **Inbox·Preference 단계** 구현 범위: ```text notification-inbox Preference ConsentRecord SuppressionEntry ChannelPreferenceResolver Eligibility Policy Port ``` 완료 조건은 Inbox pagination/read/unread concurrency, tenant isolation, suppression audit, provider suppression synchronization이 통과하는 것입니다. **운영·확장 단계** 구현 범위: ```text N3 provider extensions N4 admin redrive manual reconciliation provider profile enable/disable credential rotation Kakao/WhatsApp/RCS 등 adapter ``` 완료 조건은 Admin 기능에 별도 권한이 적용되고, manual redrive가 원 Notification과 새 Attempt의 관계를 보존하며, Provider credential rotation 중 유실·중복이 발생하지 않고, 신규 Adapter가 기존 Core contract test를 수정하지 않고 통과하는 것입니다. ### 최종 권장 아키텍처 전체 연구를 종합하면 목표 구조는 다음과 같습니다. ```text Application │ ▼ ┌──────────────────────────────────────────────┐ │ Notification API │ │ Email / SMS / Push / WebPush / Orchestrator │ └──────────────────────┬───────────────────────┘ │ ▼ ┌──────────────────────────────────────────────┐ │ Notification Core │ │ │ │ Request ── RecipientDelivery ── Attempt │ │ │ │ │ │ │ │ │ ├─ outcome │ │ │ │ └─ evidence│ │ │ │ │ │ Template ContactPoint │ │ Scheduling Suppression │ │ Idempotency Routing/Fallback │ │ Retry RateLimit/Backpressure │ │ Audit Metrics/Trace │ └──────────────────────┬───────────────────────┘ │ ┌─────────────┼─────────────┐ ▼ ▼ ▼ Email SMS Mobile Push SMTP / SES Twilio FCM / APNs │ │ │ └─────────────┼─────────────┘ │ ProviderEvent │ ▼ Callback / Receipt / Reconciliation │ ▼ Append-only Event Ledger │ ▼ Delivery State Projection ``` 가장 중요한 설계 규칙은 하나로 압축할 수 있습니다. > **Notification Core는 “보냈다”를 기록하는 시스템이 아니라, “누구에게 어떤 알림을 어떤 채널과 공급자로 몇 번 시도했고, 각 시도에서 어디까지 확실히 확인됐으며, 아직 무엇을 모르는가”를 기록하는 시스템이어야 합니다.** SES의 API 수락과 실제 전송이 다르고, Twilio의 callback은 역순으로 올 수 있으며, APNs는 수락 뒤에도 저장·폐기·후속 전달될 수 있고, FCM은 현재 FID 중심으로 target 모델 자체가 전환되고 있으며, Web Push는 수락·TTL·receipt를 프로토콜 수준에서 별도로 정의합니다. 이 차이를 없애려고 공통 enum 하나로 평탄화하기보다 **`Notification → RecipientDelivery → DeliveryAttempt → ProviderEvent → Evidence`를 공통 골격으로 만들고, 채널별 의미를 Adapter capability로 보존하는 설계가 장기적으로 가장 안전합니다.** citeturn14search0turn16search0turn13search5turn15search0turn12search0