Files
clean-architecture-backend-…/docs/superpowers/specs/2026-08-10-notification-platform-design.md

146 KiB

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가 아니다. 다음 전체 수명주기를 소유한다.

알림 의도 접수
→ 논리 요청의 내구성 있는 저장
→ 수신자별 전달 작업 분해
→ 실제 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 201DELIVERED로 승격하는 코드가 없다.
  • 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 포함 범위

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 범위

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 명시적 비지원

업무 대상자 선정
캠페인 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. 전체 아키텍처

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 흐름

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 흐름

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 흐름

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 흐름

1. Provider별 reconcile 가능한 오래된 Attempt 조회
2. Provider Profile별 rate limit 적용
3. Provider status query
4. synthetic ProviderEvent를 원장에 append
5. 동일 projector 실행
6. 변경된 projection과 correction audit 기록

7. 모듈 구조

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 의존 방향

*-api
  ↑
provider-spi / policy
  ↑
persistence-jpa / dispatch-runtime / adapter implementations
  ↑
starter / application

금지 의존성:

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<String,Object>를 공개 request type으로 사용하면 실패한다.
  • Adapter 구현이 다른 Adapter 구현에 직접 의존하면 실패한다.
  • N4 Admin package를 일반 starter가 기본 공개하지 않는다.

8. 공개 API 계층

8.1 N1 Typed API

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

public interface NotificationOrchestrator {
    NotificationReceipt submit(NotificationPlan plan);
    NotificationReceipt schedule(NotificationPlan plan, Instant scheduleAt);
    CancelResult cancel(NotificationId notificationId, CancelCommand command);
    NotificationSnapshot get(NotificationId notificationId);
}

N2에서 허용하는 기능:

Ordered fallback
Multi-recipient
Best-effort multi-channel
Batch submission
Scheduling
Cancel
Opt-in deduplication
Collapse hint

8.3 N3 Provider Extension

public interface ProviderExtension<C extends ProviderCapability> {
    ProviderId providerId();
    Optional<C> capability(Class<C> type);
}

N3 예:

SES Configuration Set
Twilio Messaging Service SID
FCM Topic / Condition
APNs Push Type / Collapse ID
Web Push Urgency / Topic

N3도 다음 공통 정책을 우회하지 못한다.

Contact Point 보호
Provider Profile 고정
Credential 관리
Expiry
Payload 제한
Observation
Audit

8.4 N4 Admin Plane

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은 다음을 요구한다.

별도 authority
사유 코드
dry-run 지원 가능 여부
bounded batch
감사 로그
idempotent operation ID

9. 핵심 식별자와 공통 타입

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 타입

public sealed interface NotificationContent
        permits EmailContent, SmsContent, MobilePushContent,
                WebPushContent, InAppContent {
}

public record EmailContent(
        String subject,
        String textBody,
        Optional<String> htmlBody,
        List<AttachmentRef> attachments,
        EmailOptions options
) implements NotificationContent {}

public record SmsContent(
        String text,
        SmsOptions options
) implements NotificationContent {}

public record MobilePushContent(
        String title,
        String body,
        Optional<URI> deepLink,
        Map<String, String> data,
        PushPresentation presentation
) implements NotificationContent {}

public record WebPushContent(
        String title,
        String body,
        Optional<URI> deepLink,
        Map<String, String> data,
        WebPushOptions options
) implements NotificationContent {}

public record InAppContent(
        String title,
        String body,
        Optional<URI> deepLink,
        List<InAppAction> actions,
        String category
) implements NotificationContent {}

공통 금지:

provider SDK object
arbitrary Map<String,Object> options
raw credential
unbounded header
binary attachment bytes

11. Notification Plan과 Routing 요청

public record NotificationPlan(
        TenantId tenantId,
        IdempotencyKey idempotencyKey,
        String category,
        TemplateSelection template,
        List<RecipientSpec> recipients,
        DeliveryStrategy deliveryStrategy,
        Optional<Instant> notBefore,
        Optional<Instant> expiresAt,
        Optional<DeduplicationSpec> deduplication,
        Optional<CollapseSpec> collapse,
        CorrelationId correlationId,
        Map<String, String> boundedMetadata
) {}

11.1 RecipientSpec

public record RecipientSpec(
        String recipientRef,
        Optional<Locale> locale,
        Optional<ZoneId> timeZone,
        List<ContactPointSelector> contactPoints,
        Optional<ChannelPreferenceOverride> channelOverride
) {}

recipientRef는 애플리케이션의 stable reference지만 Contact Point 원문이 아니다. Notification Platform은 이 reference를 Metric label로 사용하지 않는다.

11.2 DeliveryStrategy

public sealed interface DeliveryStrategy
        permits ExplicitChannel, OrderedFallback,
                ParallelMultiChannel, AllRequired, BestEffort {
}

public record ExplicitChannel(Channel channel) implements DeliveryStrategy {}

public record OrderedFallback(List<Channel> channels)
        implements DeliveryStrategy {}

Stable 기본:

ExplicitChannel
OrderedFallback

Experimental:

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:

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:

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:

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:

UNIQUE (tenant_id, type, provider, application_id, environment, lookup_hmac)

12.6 Template·Suppression·Preference·Consent·Inbox

별도 테이블:

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

public enum RequestStatus {
    CREATED,
    VALIDATED,
    SCHEDULED,
    PROCESSING,
    PARTIALLY_COMPLETED,
    COMPLETED,
    CANCELED,
    EXPIRED,
    FAILED
}

이 enum은 논리 요청 Projection이며 Provider 상태를 직접 표현하지 않는다.

13.2 RecipientDeliveryState

public enum RecipientDeliveryState {
    PENDING,
    READY_TO_DISPATCH,
    SUPPRESSED,
    DISPATCHING,
    RETRY_WAITING,
    RECONCILIATION_REQUIRED,
    COMPLETED,
    FAILED,
    EXPIRED,
    CANCELED
}

13.3 Attempt 결과

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과 독립적으로 저장한다.

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

금지:

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

public interface ProviderEventProjector {
    ProviderId providerId();
    ProjectionResult project(
            DeliveryAttemptSnapshot attempt,
            ProviderEventRecord event,
            DeliveryProjection current
    );
}

14.3 Merge 규칙

단일 ordinal 비교를 금지한다.

sent → delivered               허용
delivered → sent               무시
delivered → complaint          complaint fact 추가
complaint → delivered          complaint 유지
accepted → bounced             허용
read → displayed               read 유지
invalid-recipient → accepted   invalidation을 자동 철회하지 않음

판정 입력:

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:

NotificationRequest INSERT
RecipientDelivery INSERT N개
idempotency unique 획득
initial dispatch schedule 설정

Provider 호출은 포함하지 않는다.

15.2 Dispatch lease transaction

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 시나리오:

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

public sealed interface ContactPointValue
        permits EmailAddress, PhoneNumber, MobilePushTarget,
                WebPushSubscriptionValue, InAppRecipientRef {
}

public sealed interface MobilePushTarget
        permits FcmInstallationId, LegacyFcmRegistrationToken,
                ApnsDeviceToken {
}
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 상태

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 암호화 계약

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);
}

암호문에는 다음을 포함한다.

keyId
nonce
ciphertext
authenticationTag
normalizedType

HMAC key와 encryption key는 분리한다.


17. Template·Localization 계약

17.1 Template 모델

public record NotificationTemplateVersion(
        TemplateId templateId,
        TemplateVersion version,
        Channel channel,
        Locale locale,
        Optional<Locale> fallbackLocale,
        VariableSchema variableSchema,
        TemplateContentDefinition content,
        TemplateStatus status,
        String contentDigest
) {}

17.2 발송 시 고정값

templateId
templateVersion
locale
normalized variables
renderedContentDigest

Retry와 Redrive에서 최신 Template을 자동 재선택하지 않는다.

17.3 Locale 해석 순서

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

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 계약

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

질문:

같은 API 요청을 다시 보낸 것인가?

Key scope:

(tenantId, idempotencyKey)

Fingerprint 입력:

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

질문:

서로 다른 API 요청이지만 일정 기간 같은 사용자 알림으로 볼 것인가?
public record DeduplicationSpec(
        String dedupKey,
        Duration window,
        DeduplicationAction action
) {}

Scope:

tenant + recipient + category + dedupKey + time bucket

업무 의미가 개입되므로 opt-in이다.

19.3 Collapse·Coalescing

질문:

Provider에 아직 전달되지 않은 이전 알림을 최신 알림으로 대체할 것인가?
public record CollapseSpec(String key, CollapseScope scope) {}

Adapter mapping:

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다.

SCHEDULED
→ scheduleAt 도달
→ READY_TO_DISPATCH
→ lease claim
→ Provider dispatch

Provider-native scheduling은 N3 Experimental이다.

20.2 시간 필드

scheduleAt
- 플랫폼이 작업을 활성화할 시각

notBefore
- 이보다 앞서 Provider에 제출하면 안 되는 시각

expiresAt
- 이 시각 이후 새 Attempt·Retry·Fallback을 금지

20.3 Provider TTL 계산

providerTTL = min(
    expiresAt - now,
    providerMaximumTTL,
    channelPolicyMaximumTTL
)

Web Push는 TTL header 필수다.

20.4 Retry 전 시간 검사

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

세 개를 분리한다.

Preference
- 사용자의 선호 채널·빈도

Consent
- 외부 정책 판단에 필요한 동의 기록

Suppression
- 현재 발송을 기술적으로 차단하는 상태

21.1 Suppression 모델

public record SuppressionEntry(
        SuppressionId id,
        TenantId tenantId,
        SuppressionScope scope,
        SuppressionReason reason,
        String normalizedTargetHmac,
        Optional<String> notificationCategory,
        Instant effectiveAt,
        Optional<Instant> expiresAt,
        SuppressionSource source
) {}

Reason:

USER_OPT_OUT
HARD_BOUNCE
COMPLAINT
INVALID_TOKEN
INVALID_PHONE
ADMIN_BLOCK
PROVIDER_BLOCK
TEMPORARY_SUPPRESSION

21.2 Eligibility Port

public interface NotificationEligibilityPolicy {
    EligibilityResult evaluate(NotificationContext context);
}

Core가 판단하지 않는 항목:

광고 여부
국가별 법률
보안 알림의 opt-out 가능 여부
야간 제한
회원 등급별 채널 우선순위

21.3 판정 순서

Internal mandatory suppression
OR Provider suppression
OR injected eligibility=false
→ dispatch 금지

Submit 시점과 dispatch 직전에 모두 실행한다. dispatch 직전 판정이 최종이다.


22. Routing·Fallback

22.1 Stable 전략

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 상태

public record RoutingDecision(
        Optional<RouteCandidate> 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

ambiguousAttemptExists = true
→ automatic fallback prohibited

운영자 강제 Fallback은 N4에서 사유와 duplicate risk 승인 후만 수행한다.


23. Provider Adapter SPI

public interface NotificationProviderAdapter {
    ProviderId providerId();
    Set<Channel> channels();
    ProviderCapabilities capabilities();
    CompletionStage<ProviderSubmissionResult> submit(ProviderSubmission submission);
}
public record ProviderSubmission(
        DeliveryAttemptId attemptId,
        ProviderProfileSnapshot profile,
        ProtectedContactPoint contactPoint,
        RenderedNotificationContent content,
        Instant expiresAt,
        Optional<String> providerIdempotencyKey,
        Map<String, String> approvedNativeOptions,
        TraceContext traceContext
) {}
public record ProviderSubmissionResult(
        AttemptConfirmation confirmation,
        SubmissionOutcome submissionOutcome,
        EvidenceLevel evidenceLevel,
        Optional<String> providerRequestId,
        Optional<String> nativeStatus,
        Optional<ProviderFailure> failure,
        ProviderExecutionEvidence executionEvidence,
        Duration elapsed
) {}

23.1 Execution Evidence

public record ProviderExecutionEvidence(
        boolean requestStarted,
        boolean requestBodyCommitted,
        boolean responseReceived,
        boolean providerAcceptanceProven
) {}

Adapter가 증명하지 못한 boolean은 임의로 false 처리하지 않고 EvidenceCertainty를 함께 둔다.

public enum EvidenceCertainty {
    PROVEN,
    INFERRED,
    UNKNOWN
}

23.2 Callback SPI

public interface ProviderCallbackAdapter {
    ProviderId providerId();
    CallbackVerificationResult verify(CallbackRequest request);
    List<NormalizedProviderEvent> normalize(VerifiedCallback callback);
}

23.3 Reconciliation SPI

public interface ReconciliationCapability {
    boolean supports(ProviderProfileSnapshot profile);
    CompletionStage<ReconciliationResult> reconcile(DeliveryAttemptSnapshot attempt);
}

23.4 Provider capability

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

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하지 않는다.

Profile generation N
→ immutable ProviderRuntime N
→ 새 dispatch는 N+1 사용
→ N의 in-flight 완료
→ drain timeout 후 close

Attempt에 credentialGeneration을 기록한다.

24.2 Provider health

public enum ProviderRuntimeState {
    HEALTHY,
    DEGRADED,
    THROTTLED,
    AUTHENTICATION_FAILED,
    DISABLED,
    DRAINING
}

AUTHENTICATION_FAILED는 개별 메시지 Retry가 아니라 Provider route open/disable과 운영 alert를 유발한다.


25. 공통 오류 모델

NotificationException
 ├─ NotificationValidationException
 ├─ IdempotencyConflictException
 ├─ TemplateNotFoundException
 ├─ TemplateRenderingException
 ├─ InvalidContactPointException
 ├─ NotificationExpiredException
 ├─ NotificationSuppressedException
 ├─ ProviderAuthenticationException
 ├─ ProviderAuthorizationException
 ├─ ProviderThrottledException
 ├─ ProviderTransientException
 ├─ ProviderPermanentException
 ├─ ProviderRejectedException
 ├─ AmbiguousSubmissionException
 ├─ CallbackValidationException
 ├─ CallbackProjectionException
 ├─ ReconciliationException
 └─ NotificationCapacityException

25.1 FailureCategory

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

public record NotificationFailureDescriptor(
        String code,
        FailureCategory category,
        boolean retryable,
        boolean ambiguous,
        Channel channel,
        ProviderId providerId,
        int attemptNumber,
        Duration elapsed
) {}

금지 metadata:

address/token 원문
Provider credential
message body
Template variable 원문
Callback raw payload
full Provider exception message

26. Retry Policy Engine

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
) {}
public sealed interface RetryDecision
        permits RetryAfter, Reconcile, Fallback, Stop {
}

26.1 기본 규칙

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

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 제한 계층

global
→ channel
→ provider account
→ sender identity
→ destination country/class
→ recipient/contact point

27.2 Dispatch pipeline

Durable queue
→ expiry check
→ suppression re-check
→ provider health gate
→ rate limiter
→ concurrency limiter
→ provider adapter

27.3 설정

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

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 처리 순서

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 대상

DISPATCHING 상태가 lease보다 오래됨
AMBIGUOUS submission
Provider accepted 후 callback SLA 초과
Twilio callback 누락
unmatched provider event

29.2 결과

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 공통 계약

public record EmailNotification(
        TenantId tenantId,
        IdempotencyKey idempotencyKey,
        ContactPointId recipient,
        TemplateSelection template,
        Map<String, Object> variables,
        Optional<Instant> expiresAt,
        CorrelationId correlationId
) {}

30.1 기능

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

public record SenderIdentity(
        String identityId,
        String domain,
        String fromAddressRef,
        Optional<String> replyToAddressRef,
        ProviderId providerId,
        ReadinessStatus dkimStatus,
        ReadinessStatus spfStatus,
        ReadinessStatus dmarcStatus,
        boolean enabled
) {}

Production profile은 준비되지 않은 sender identity로 시작하지 않는다.


31. SMTP Adapter

31.1 구현

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

connection timeout
read timeout
write timeout
pool acquire timeout
whole attempt deadline

무한 timeout을 허용하지 않는다.

31.4 SMTP Provider Profile

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을 노출하지 않는다.
  • MessageIdproviderRequestId로 저장한다.
  • 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에서만 활성화한다.

List-Unsubscribe
List-Unsubscribe-Post
DKIM signed header readiness

Unsubscribe token은 암호학적으로 보호하고 로그에 남기지 않는다.


33. SMS 공통 계약

33.1 PhoneNumber

public record PhoneNumber(String e164) implements ContactPointValue {}
  • E.164 canonical form을 저장한다.
  • 형식 검증과 실제 번호 사용 가능성 검증을 분리한다.
  • 번호 원문은 암호화하고 HMAC fingerprint를 별도 저장한다.

33.2 Encoding·Segment estimator

public record SmsEstimate(
        SmsEncoding encoding,
        int segmentCount,
        int encodedLength,
        boolean exceedsRecommendedLimit
) {}

public enum SmsEncoding {
    GSM_7,
    UCS_2
}

계약:

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

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 도착 순서에 의존하지 않는다.

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

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 의미

Provider API success
→ PROVIDER_ACCEPTED
→ DEVICE_DELIVERED 아님

35.4 앱 Receipt

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

applicationId
topic
environment SANDBOX/PRODUCTION
credential generation
push type allowlist

37.2 Request mapping

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 표준

RFC 8030
RFC 8291
RFC 8292

38.2 Subscription

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

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 상태

PERSISTED
SEEN
READ
ARCHIVED
EXPIRED
DELETED

READSEEN을 포함한다. 역순 요청은 idempotent하다.

39.4 Pagination

Cursor:

(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를 사용한다.

재사용:

TLS
Timeout
Retry
Circuit Breaker
SSRF
Dynamic Target policy
Request signing
Observation

Notification이 추가하는 모델:

WebhookSubscription
WebhookTemplate
DeliveryAttempt
Callback/response evidence
Redrive

Webhook target이 사용자 입력이면 DynamicTargetGateway를 사용하고 Trusted Provider credential을 상속하지 않는다.


41. Security·Privacy

41.1 보호 대상

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 금지

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 보안

TLS
signature
replay defense
body limit
content-type
profile binding
rate limit
idempotent ingestion
security audit

42. Credential·환경 격리

Namespace:

tenant
applicationId
provider
environment
credentialProfile
generation

APNs:

(APNS, appId, SANDBOX)
(APNS, appId, PRODUCTION)

FCM:

(FCM, projectId, applicationId, environment)

SMTP/SES/Twilio:

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 관측 단위

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

recipientId
Contact Point
notificationId
recipientDeliveryId
attemptId
providerRequestId
full error message

43.4 Trace

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

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 기능

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

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 실패 조건:

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

notificationProviders
notificationQueue
notificationCallbacks
notificationScheduler
notificationReconciliation

민감 주소와 Provider credential은 노출하지 않는다.


46. 테스트 전략

46.1 공통 Contract Suite

모든 Provider Adapter가 다음을 통과한다.

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

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:

SMTP 2xx/4xx/5xx
DATA response loss
MIME text/html/attachment
SES accepted/delivery/bounce/complaint/render failure

SMS:

E.164
GSM-7/UCS-2 segment boundary
Twilio callback reverse order
missing callback reconciliation

Push:

FCM FID
legacy token
batch partial result
UNREGISTERED/QUOTA/UNAVAILABLE
APNs sandbox/production
wrong topic
token invalid
acceptance not delivery

Web Push:

TTL required
AES128GCM
VAPID
404/410 invalidation
payload size
Topic replacement

Inbox:

cursor pagination
seen/read idempotency
unread count concurrency
WebSocket outage

46.4 장애 도구

WireMock / MockWebServer
GreenMail 또는 SMTP test server
Toxiproxy
PostgreSQL Testcontainers
Provider callback fixtures
process kill harness
clock control

46.5 보안 테스트

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 성능 테스트

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

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

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

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

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

RFC 8030/8291/8292
subscription lifecycle
VAPID
TTL/Urgency/Topic

완료 조건:

  • RFC vectors
  • secret leak test
  • expired subscription

Phase 6: Advanced Delivery Release

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

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. 비지원 범위 재확인

다음 기능은 구현 계획에 포함하지 않는다.

캠페인 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는 서로 다른 증거입니다.

권장 경계는 다음과 같습니다.

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

권장 모델은 다음입니다.

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의 JavaMailSenderMimeMessageHelper는 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<String,Object>)보다 채널별 Typed facade + 공통 내부 Core + Advanced Orchestrator를 권장합니다.

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하게 요청을 받아들였다는 결과로 정의하는 것이 안전합니다.

public record NotificationReceipt(
    NotificationId notificationId,
    RequestStatus status,
    Instant acceptedAt
) {}

즉:

send() 성공
≠ SES가 Email을 전달함
≠ Twilio carrier가 SMS를 전달함
≠ FCM/APNs가 기기에 표시함
≠ 사용자가 읽음

SES는 API 성공과 실제 발송을 명시적으로 구분하고, APNs도 수락 이후 저장·폐기·전달을 구분하므로 이 차이는 API 계약 자체에 반영되어야 합니다. citeturn14search0turn13search5

채널 Content도 sealed hierarchy로 분리하는 것이 적절합니다.

public sealed interface NotificationContent
    permits EmailContent, SmsContent, MobilePushContent,
            WebPushContent, InAppContent {
}

public record EmailContent(
    String subject,
    String textBody,
    String htmlBody,
    List<AttachmentRef> attachments,
    EmailOptions options
) implements NotificationContent {}

public record SmsContent(
    String text,
    SmsOptions options
) implements NotificationContent {}

public record MobilePushContent(
    String title,
    String body,
    URI deepLink,
    Map<String, String> data,
    PushPresentation presentation
) implements NotificationContent {}

공급자 고유 필드를 여기 넣지 말고 N3에서 확장합니다.

public interface ProviderExtension<T> {
    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

providerRequestIdattemptId도 분리해야 합니다. 요청을 보냈지만 응답을 잃은 경우 Provider ID를 얻지 못할 수도 있기 때문입니다. 같은 이유로 notificationId = providerRequestId 방식도 피해야 합니다.

단일 상태 머신 대신 “상태 + 증거 + 이벤트 원장”

사용자가 제안한 다음 상태들은 API projection으로는 유용합니다.

PENDING
SUPPRESSED
QUEUED
DISPATCHING
PROVIDER_ACCEPTED
PROVIDER_REJECTED
SENT
DELIVERED
UNDELIVERED
BOUNCED
COMPLAINT
READ
EXPIRED
CANCELED
UNKNOWN

다만 이것을 하나의 선형 enum 상태 머신으로 저장하는 것은 권장하지 않습니다.

예를 들어 Email은 DELIVERYCOMPLAINT가 발생할 수 있습니다. Twilio callback은 네트워크 지연 때문에 순서대로 도착한다는 보장이 없습니다. APNs의 경우 수락한 알림이 나중에 저장되거나 폐기될 수 있습니다. 따라서 단순히 ordinal(new) > ordinal(old) 규칙으로 상태를 갱신하면 실제 정보를 잃게 됩니다. citeturn14search8turn16search0turn13search5

권장 구조는 다음입니다.

ProviderEvent append-only ledger
        │
        ▼
Channel-specific projector
        │
        ├─ SubmissionOutcome
        ├─ DeliveryOutcome
        ├─ EvidenceLevel
        ├─ EngagementFacts
        └─ SuppressionFacts

DeliveryAttempt에는 별도로 다음 완료 결과가 필요합니다.

CONFIRMED
- Provider의 명시적 결과를 받음

REJECTED
- Provider가 확실하게 수락하지 않음

AMBIGUOUS
- 요청 전송 여부 또는 Provider 수락 여부를 확정할 수 없음

그리고 Submission과 Delivery를 분리합니다.

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 생명주기

권장 타입은 다음과 같습니다.

EmailAddress
PhoneNumber
MobilePushTarget
WebPushSubscription
InAppRecipient

공통 상태는 단순 active=true/false보다 다음이 적합합니다.

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 식별 방식은 다음과 같습니다.

실제 address/token
→ 암호화 저장

normalized address hash
→ equality / dedup / suppression lookup

contactPointId
→ 애플리케이션·API 노출

즉, 이메일·전화번호·device token 자체를 primary key나 외부 API identifier로 사용하지 않습니다.

템플릿·라우팅·중복·스케줄링 정책

Template·Localization 계약

공통 Template의 최소 계약은 다음 정도가 적합합니다.

record NotificationTemplate(
    String templateId,
    long version,
    Channel channel,
    Locale locale,
    String fallbackLocale,
    VariableSchema variables,
    ContentDefinition content,
    TemplateStatus status
) {}

발송 시점에는 반드시 다음 조합을 고정해야 합니다.

templateId
+ templateVersion
+ locale
+ normalized variables
+ renderedContentDigest

템플릿이 이후 수정되더라도 이미 생성된 Notification의 의미가 바뀌면 안 되기 때문입니다. 이 정책은 retry와 redrive에서도 중요합니다. 기본 redrive는 현재 최신 Template을 다시 렌더링하는 작업이 아니라 원래 논리 알림을 재실행하는 것으로 보는 것이 안전합니다.

Provider Template은 N3로 제한하는 것이 좋습니다. Provider 측 Template을 직접 사용하면 최종 렌더링 내용을 플랫폼이 재현하기 어려울 수 있으므로, provider template ID와 version/revision을 반드시 Attempt에 기록해야 합니다.

Localization은 다음 순서가 적절합니다.

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는 다음 여섯 가지로 충분합니다.

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 완료

핵심은:

Push Timeout
→ 곧바로 SMS Fallback

이 기본 동작이 위험하다는 것입니다. Push가 이미 Provider에 접수됐는데 응답만 유실됐다면 SMS와 Push가 모두 사용자에게 도착할 수 있습니다.

Email 공급자 A의 응답이 유실됐다고 공급자 B로 즉시 재전송하는 것도 같은 문제입니다. 특히 SES는 “API accepted”와 실제 send가 이미 분리되어 있으므로 Provider 경계를 넘는 fallback은 더욱 보수적으로 다뤄야 합니다. citeturn14search0

따라서 RecipientDelivery에 다음 필드가 실제로 필요합니다.

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가 다르면 충돌 오류를 반환하는 것이 안전합니다.

idempotencyKey = abc
request A = "결제 완료 10,000원"

같은 key
request B = "결제 완료 100,000원"

→ 기존 성공 반환 X
→ IDENTITY_CONFLICT

Deduplication은 업무 의미가 개입되므로 Opt-in이어야 합니다.

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 기본값으로 두는 것입니다.

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의 세 시각은 분리해야 합니다.

scheduleAt
→ 플랫폼이 발송을 시작할 수 있는 시각

notBefore
→ 이보다 먼저 Provider에 제출하면 안 됨

expiresAt
→ 이 시각 이후에는 새 Retry/Fallback/Dispatch 금지

그리고 Adapter가 실제 Provider TTL로 변환합니다.

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는 다음 검사를 먼저 해야 합니다.

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

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()를 제공할 가치가 있습니다.

SmsEstimate estimate(SmsNotification notification);

예:

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

따라서 다음은 금지해야 합니다.

APNs HTTP 200
    ↓
RecipientDelivery.status = DELIVERED   // 금지

대신:

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은 다음과 같습니다.

subscriptionId
ownerId
endpointEncrypted
endpointHash
p256dhEncrypted
authSecretEncrypted
vapidKeyId
createdAt
lastConfirmedAt
expiredAt
status

In-App Inbox 설계 범위

In-App은 Provider Adapter가 아니라 별도 notification-inbox로 두는 것이 좋습니다.

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가 되어서는 안 됩니다.

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가 필요합니다.

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는 단순:

429 || 5xx → retry

가 아니라:

retryableFailure
AND submissionIsSafeToRepeat
AND recipientStillValid
AND !fallbackAlreadyCommitted
AND deadlineRemaining
AND retryBudgetRemaining

이어야 합니다.

모호한 완료 모델

가장 중요한 장애 케이스는 다음입니다.

Platform
  │
  ├──── send ────▶ Provider
  │                  │
  │                  └─ accepted
  │
  X connection reset

이때 플랫폼에는 Provider ID가 없을 수 있지만 실제 알림은 발송될 수 있습니다.

따라서 Attempt는 다음 metadata를 보존해야 합니다.

requestStarted
requestBodyCommitted
providerResponseReceived
providerRequestId
submissionOutcome
ambiguousReason
attemptNumber
elapsed

그리고 다음 규칙을 권장합니다.

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 하나보다 다음 계층을 모두 지원해야 합니다.

global
  ↓
channel
  ↓
provider account
  ↓
sender identity
  ↓
country / destination class
  ↓
recipient/contact point

Dispatcher에는 다음 제한을 별도로 둡니다.

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

권장 흐름은:

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은 아래 순서로 고정하는 것을 권장합니다.

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의 원칙은:

known fields → typed normalize
unknown fields → preserve/ignore safely
raw payload → bounded encrypted/archive storage
signature → 원본 bytes/원본 parameter set 기준 검증

입니다.

Callback 중복 제거는 가능한 경우:

UNIQUE(provider, providerEventId)

를 사용하고 Provider event ID가 없다면:

provider
+ providerRequestId
+ eventType
+ providerOccurredAt
+ normalizedPayloadDigest

같은 fingerprint 방식이 필요합니다.

Twilio는 callback이 순서대로 도착한다는 보장이 없다고 명시하므로 receivedAt만 보고 현재 상태를 갱신해서는 안 됩니다. citeturn16search0

또 callback은 누락될 수도 있습니다. Twilio는 delivery status가 12시간 내 갱신되지 않으면 API polling을 수행하고, 놓친 이벤트 확인을 위해 최소 하루 한 번 reconciliation을 권장합니다. citeturn17search1

따라서 Adapter SPI에 다음 capability를 둘 가치가 있습니다.

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을 사용합니다.

sent → delivered        : 허용
delivered → sent        : 무시

delivered → complaint   : complaint fact 추가
complaint → delivered   : complaint 제거 금지

accepted → bounced      : 허용

expired callback가 늦게 도착
→ providerOccurredAt와 기존 terminal event 비교

그리고 callback에서 새로운 상태를 발견했다고 raw provider 상태 문자열을 public enum에 바로 추가하지 않습니다.

Provider status
→ Adapter normalization
→ stable common status
+ providerNativeStatus

이렇게 해야 Provider가 상태를 추가해도 Core API를 깨뜨리지 않습니다.

보안·억제·동의·관측성

Suppression·Preference·Consent Primitive

Core가 제공할 데이터 구조는 다음처럼 분리하는 것이 좋습니다.

SuppressionEntry
PreferenceRecord
ConsentRecord
ContactPointStatus

SuppressionEntry의 예시는:

record SuppressionEntry(
    SuppressionId id,
    TenantId tenantId,
    SuppressionScope scope,
    SuppressionReason reason,
    String normalizedTargetHash,
    String notificationCategory,
    Instant effectiveAt,
    Instant expiresAt,
    SuppressionSource source
) {}

억제 이유는 사용자가 제안한 형태가 적절합니다.

USER_OPT_OUT
HARD_BOUNCE
COMPLAINT
INVALID_TOKEN
INVALID_PHONE
ADMIN_BLOCK
PROVIDER_BLOCK
TEMPORARY_SUPPRESSION

핵심은 PreferenceConsent를 합치지 않는 것입니다.

Preference
→ "나는 Push보다 Email을 선호한다"

Consent
→ "어떤 정책 판단에 필요한 동의 기록"

Suppression
→ "현재 이 발송을 기술적으로 차단한다"

Core는:

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

권장 우선순위는:

Internal mandatory suppression
      OR
Provider suppression
      OR
Injected business eligibility=false
→ SEND BLOCK

입니다.

Security·Privacy 정책

보호 대상에는 최소 다음이 포함됩니다.

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 금지로 지정하는 것이 맞습니다.

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

구조화 로그는 다음 정도만 남깁니다.

channel=email
provider=ses
operation=dispatch
attempt=2
result=throttled
failureCategory=THROTTLED
templateId=password-reset

민감 Contact Point의 lookup이 필요하면:

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에는 다음 방어가 필요합니다.

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를 포함해야 합니다.

tenant
applicationId
provider
environment
credentialProfile

특히 APNs 개발/운영 token을 혼용하지 않도록:

(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 계약

관측 단위는 반드시 세 레벨입니다.

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로 제한합니다.

channel
provider
templateId
notificationCategory
status
failureCategory
attemptBucket
sizeBucket

templateId도 무제한 사용자 입력이 아니라 등록된 template registry 값일 때만 Metric tag로 허용해야 합니다.

금지:

recipientId
email
phone
device/FID/token
notificationId
recipientDeliveryId
attemptId
providerRequestId
full exception message
full URL

Trace는 다음처럼 나누는 것이 좋습니다.

notification.submit
     │
     ├─ render
     ├─ dispatch attempt
     │     └─ provider HTTP/SMTP span
     │
     └─ enqueue

비동기 callback은 원래 dispatch span이 이미 끝난 뒤 수시간 후 도착할 수 있으므로 장시간 child span으로 유지하는 것보다 원래 trace/attempt와 correlation/link를 두는 방식이 적절합니다.

Audit는 Metric보다 강하게 보존해야 합니다.

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 테스트

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 테스트

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 테스트

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 테스트

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 테스트

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

구현 범위:

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 lossAMBIGUOUS로 표현할 수 있으며, process kill 후 미처리 Notification을 복구하고, logs/metrics에서 Contact Point 원문이 검출되지 않는 것입니다.

Email 단계

구현 범위:

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 단계

구현 범위:

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 단계

구현 범위:

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 단계

구현 범위:

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 단계

구현 범위:

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 단계

구현 범위:

notification-inbox
Preference
ConsentRecord
SuppressionEntry
ChannelPreferenceResolver
Eligibility Policy Port

완료 조건은 Inbox pagination/read/unread concurrency, tenant isolation, suppression audit, provider suppression synchronization이 통과하는 것입니다.

운영·확장 단계

구현 범위:

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를 수정하지 않고 통과하는 것입니다.

최종 권장 아키텍처

전체 연구를 종합하면 목표 구조는 다음과 같습니다.

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