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

4770 lines
146 KiB
Markdown

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