Files
clean-architecture-backend-…/docs/reviews/2026-08-14-notification-module-code-review.md

77 KiB

Notification 모듈 상세 코드·아키텍처 리뷰

  • 기준 일자: 2026-08-14
  • 기준 Git HEAD: 539e3eb58bed5db63e3a17f47eec213db2d2df79
  • notification 기준 source snapshot: c1ee1d9dd916719e709bbea0b7cb46118bafc590
  • 대상 Gradle leaf:
    • :application-core
    • :adapter:outbound:notification
    • :adapter:outbound:persistence-jpa
    • :adapter:inbound:web
    • :app-bootstrap
  • 대상 문서/CI: docs/notification, .github/workflows/notification-platform.yml
  • 판정: CHANGES REQUIRED — 현재 상태를 production-ready Stable로 승격하면 안 됨
  • 변경 범위: 이 리뷰 문서만 추가했으며 production/test 코드는 수정하지 않았다.

리뷰 도중 HEAD가 92744c5c1ee1d9539e3eb로 이동했다. 최종 HEAD의 c1ee1d9..539e3eb diff를 확인한 결과 notification application/adapter/bootstrap/JPA source는 변경되지 않았고, 마지막 병합은 주로 JPA persistence platform 추가였다. 새로 합쳐진 PostgreSqlWorkClaimExecutor는 NTF-004의 원자적 claim 구현에 재사용할 수 있으므로 수정안에 반영했다.

1. 최종 결론

현재 notification 코드는 단순 알림 adapter가 아니다. 요청 수락, 예약, route, template, contact point, provider runtime, retry/ambiguity, callback ledger, projection, suppression, admin, inbox까지 포함한 별도 delivery platform이다. provider-neutral port, 명시적인 evidence 모델, transaction 밖 provider call, append-only event ledger라는 큰 설계 방향은 좋다.

그러나 타입과 단위 테스트가 존재하는 것과 실제 application runtime이 완성된 것은 별개다. 현재 기본 composition root에서는 provider runtime과 route가 모두 빈 상태이고, notification Flyway stream은 운영 migration에 연결되지 않는다. 예약 row는 claim 대상이 아니며, 멀티 replica lease는 원자성·fencing을 갖추지 못했다. crash recovery, reconciliation, pending projection, unmatched callback worker도 조립되지 않는다. 이 상태에서 platform을 켜면 요청은 받아 저장할 수 있어도 안전하게 전송·복구·재생할 수 없다.

즉시 적용할 운영 원칙은 다음과 같다.

  1. ca-skeleton.notification.platform.enabled는 계속 기본 false로 유지한다.
  2. NTF-001~NTF-012가 해결되기 전 docs/notification/support-matrix.mdStable 표기를 release 근거로 사용하지 않는다.
  3. 설정만으로 platform을 활성화하지 말고, schema activation과 provider assembly가 모두 fail-closed로 검증된 뒤 worker를 시작한다.
  4. 폴더 이동이나 패턴 도입보다 예약·lease·evidence·ledger의 데이터 정합성을 먼저 고친다.
  5. notification provider를 실제 호출하는 검증 없이 “발송 가능”, PostgreSQL 검증 없이 “durable”, restart 검증 없이 “recovery 지원”이라고 표현하지 않는다.

2. 검토 범위와 증거 경계

2.1 현재 규모

영역 production Java 파일 LOC 비고
기존 + 신규 application notification 전체 372 - 기존 R1 100개 + 신규 platform 272개
신규 application.notification.platform 272 8,588 대부분 public top-level type
outbound notification platform 110 7,573 provider/runtime/template/security
JPA notification platform 36 4,266 request/delivery/attempt/event/contact 등
inbound callback 8 480 MVC + WebFlux
bootstrap notification 8 1,241 runtime config 한 파일이 506줄

이 규모에서는 “adapter 하나”로 취급해서는 안 된다. 다만 src/config/architecture/modules.json의 정확한 19개 leaf SSOT를 깨면서 31개 Gradle 모듈로 즉시 분해하는 것도 권장하지 않는다. 먼저 package DAG, public API allowlist, adapter 내부 configuration facade로 경계를 강제한 뒤 실제 독립 배포·빌드 필요가 생길 때만 leaf 분리를 검토한다.

2.2 깊게 확인한 실행 흐름

submit/schedule
  -> fingerprint + durable plan writer
  -> request/recipient JPA rows
  -> scheduler claim/lease
  -> render + contact reveal + provider runtime acquire
  -> attempt pre-commit
  -> provider call outside transaction
  -> outcome commit
  -> callback verify/normalize/ledger append
  -> projector + suppression/evidence roll-up
  -> recovery/reconciliation/admin

다음은 이번 리뷰에서 검증하지 못한 외부 증거다.

  • 실제 APNs/FCM/SES/Twilio/SMTP/WebPush provider sandbox 호출
  • notification migration을 적용한 실제 PostgreSQL CRUD 및 Hibernate schema validation
  • 두 application replica가 경쟁하는 lease/fencing 검증
  • provider call 직전·직후 process kill과 restart recovery
  • 대량 callback burst와 DNS rebinding/metadata endpoint 공격 검증

따라서 provider별 SLA, 성능 한계, 실 provider 호환성을 이 리뷰가 승인하는 것은 아니다.

3. 유지할 설계

다음 방향은 리팩터링하면서 보존한다.

  • application이 NotificationProviderAdapter, persistence, secret, attachment, callback port를 소유하고 outbound adapter가 구현하는 의존성 방향은 적절하다.
  • DeliveryStrategy, NotificationContent, ContactPointValue, RetryDecision, ReconciliationResult, DispatchGuardOutcome의 sealed hierarchy와 exhaustive switch는 Java 21을 잘 활용한다.
  • ProviderSubmissionResult가 provider acceptance와 delivery를 구분하고 AMBIGUOUS를 일급 상태로 모델링한 점은 타당하다.
  • provider call 전에 attempt를 commit하고, 외부 호출은 transaction 밖에서 수행하며, 결과를 다시 transaction으로 기록하는 큰 순서는 유지해야 한다.
  • provider event를 append-only ledger에 저장하고 ordinal status 하나가 아니라 projector로 merge하는 방향은 out-of-order callback을 다루기에 적합하다.
  • AES-GCM, AAD, lookup HMAC, redacted secret/contact representation을 분리하려는 의도는 좋다.
  • provider별 failure classifier와 mapper는 Strategy로 유지한다. 상속 기반 거대한 Template Method로 합치지 않는다.
  • HTTP redirect NEVER, callback raw-byte verification, WebFlux body buffer release, DB unique index, guaranteed/exactly-once 표현 거부는 좋은 방어선이다.

4. 우선순위 요약

ID 우선순위 심각도 주제 완료 조건 요약
NTF-001 P0 Critical provider runtime/route/callback graph가 비어 있음 full context에서 configured profile이 실제 wire call까지 수행
NTF-002 P0 Critical notification schema stream 미활성 + entity/schema 불일치 별도 history activation, migrate+validate+CRUD 통과
NTF-003 P0 Critical 예약 row가 영구 미발송 due 전 0, due 시 정확히 1회 claim
NTF-004 P0 Critical lease claim 비원자성·fencing 부재 atomic CTE+generation, 2-worker disjoint claim
NTF-005 P0 Critical recovery/reconciliation/projection worker dead code kill/restart 후 중복 없이 복구·재생
NTF-006 P0 High 모든 RuntimeException을 AMBIGUOUS로 오분류 pre-wire failure는 NOT_SUBMITTED, response-loss만 AMBIGUOUS
NTF-007 P0 High projection의 engagement/suppression fact 유실 restart 후 monotonic fact 보존
NTF-008 P0 High callback-before-outcome late binding 불가 hash bind worker가 정확히 한 번 projection
NTF-009 P0 High callback append/ack/dedupe transaction 결함 concurrent duplicate 모두 204, row 1개
NTF-010 P0 High Web Push subscription persistence가 lossy protect→DB→reveal→UA decrypt round-trip
NTF-011 P0 High callback size/encryption envelope/DB bound 모순 MVC/WebFlux/DB 동일 경계 계약
NTF-012 P0 Critical dynamic endpoint/SNS/body security 불충분 SSRF/SNS adversarial suite 통과
NTF-013 P1 High fingerprint와 variables가 비정본·가변 persisted canonical bytes와 hash 입력 동일
NTF-014 P1 High dedup/collapse/preferred order가 dead contract submit→DB→wire E2E 동작
NTF-015 P1 High webhook/attachment/VAPID/TTL/payload wire 결함 provider별 final wire contract test
NTF-016 P1 High secret fail-fast/rotation/profile binding 부재 versioned keyring + startup negative tests
NTF-017 P1 High template slot별 escaping/URI 정책 부재 HTML/TEXT/URI context security test
NTF-018 P1 High 기존 R1과 신규 platform 정본 충돌 ADR + type disposition + 단일 compatibility bridge
NTF-019 P1 High mandatory UseCase fitness gate 우회 모든 entrypoint marker/capability/permission 보유
NTF-020 P1 High admin/routing business policy가 outbound에 있음 application use case + 좁은 outbound control port
NTF-021 P1 High runtime limiter/state/registry/credential 경쟁 immutable atomic state와 concurrent tests
NTF-022 P1 Medium package DAG/public surface/god config package edge·cycle·public allowlist ArchUnit 통과
NTF-023 P1 Medium readiness/metrics/audit가 실제 상태를 반영하지 않음 schema/provider/backlog/lag readiness와 bounded tags
NTF-024 P1 High CI/release evidence가 실행 범위보다 강함 실 PostgreSQL/restart/callback/provider artifact 매핑
NTF-025 P2 Medium configuration/env surface가 template에 없음 application.yml/env registry/reference 동기화
NTF-026 P1 High execution evidence certainty가 DB에서 소실 value+certainty 전체 round-trip
NTF-027 P1 High reconciliation event fingerprint 충돌 SHA-256 canonical event identity

5. 상세 발견 사항과 구현 명세

NTF-001 — 설정된 provider가 production dispatch graph에 조립되지 않는다

근거

  • NotificationPlatformRuntimeConfig.java:274-299는 빈 ProviderRuntimeRegistry, CapabilityReconciliationGateway(Map.of(), ...), ConfiguredRoutePlanner(Map.of())를 만든다.
  • NotificationPlatformSettings.java:17-24,97-145는 provider profile map을 받지만 adapter/runtime 생성에 연결하지 않으며 unknown provider type도 실질적으로 조립 단계에서 거부되지 않는다.
  • production source에서 ProviderRuntimeRegistry.register(...)와 provider별 runtime assembly 호출은 없다.
  • SMTP의 SmtpDispatch, FCM의 FcmGateway, SES SNS의 SnsCertificateProvider는 production 구현이 없는 seam이다.
  • NotificationPlatformRegistriesConfig.java:35-68은 이미 존재하는 callback/projector bean 목록만 map으로 바꾼다. 목록에 넣을 production bean factory가 없다.
  • MVC는 CallbackRequestFactoryExternalRequestUrlResolver, WebFlux는 CallbackRequestFactory가 필요하지만 bootstrap에 해당 bean 조립이 없다.
  • N1 EmailNotifier, SmsNotifier 등의 facade도 production 구현/bean이 없다.

실패 모드

platform을 enable하고 provider profile을 설정해도 request는 durable acceptance 뒤 eligible route를 찾지 못한다. 일부 runtime을 수동 구성해도 SMTP/FCM은 실제 transport가 없다. callback enabled context는 필수 bean 부재로 실패할 수 있다. 현재 NotificationAutoConfigurationTest는 codec/validator/JDK HTTP gateway 조각만 검사하므로 전체 graph 공백을 드러내지 않는다.

구현 결정: Abstract Factory + explicit contribution

범용 plugin framework나 reflection은 필요 없다. 지원 provider 집합을 닫힌 enum으로 두고 provider별 assembler가 하나의 완결된 contribution을 반환하게 한다.

enum ProviderType { APNS, FCM, SES, SMTP, TWILIO, WEB_PUSH, WEBHOOK }

interface ProviderRuntimeAssembler<P extends ProviderProfileSettings> {
  ProviderType type();
  AssembledProvider assemble(P profile, ProviderAssemblyDependencies dependencies);
}

record AssembledProvider(
    ProviderRuntime runtime,
    Channel channel,
    Optional<ProviderCallbackAdapter> callback,
    Optional<ProviderEventProjector> projector,
    Optional<ReconciliationCapability> reconciliation) {}
  1. string typeProviderType과 provider별 typed settings로 바꾼다.
  2. profile 하나를 adapter, mapper, transport, credential generation, limiter, capability, callback, projector, reconciliation까지 한 번에 조립한다.
  3. 조립 결과로 runtime/profile map과 channel route를 불변 map으로 만든다.
  4. 중복 profile, 한 channel의 모호한 primary route, 필수 secret/transport 누락, unknown type은 worker 시작 전에 boot failure로 만든다.
  5. provider가 0개인 모드를 허용하려면 INGEST_ONLY 같은 별도 mode로 명시하고 readiness를 DOWN 또는 non-serving으로 표시한다.
  6. callback MVC/WebFlux 공통 request factory와 trusted external URL resolver를 composition root에서 제공한다.

필수 인수 테스트

  • 실제 CaSkeletonApplication context + fake provider profile로 submit → claim → wire request → outcome.
  • 각 Stable provider profile이 정확히 1 runtime과 1 adapter를 만든다.
  • unknown type, duplicate profile, missing credential/transport, callback adapter 없는 callback-enabled profile은 context startup이 실패한다.
  • servlet/reactive callback enabled context가 각각 하나의 route만 등록한다.

NTF-002 — notification Flyway stream이 활성화되지 않고 entity와 schema도 맞지 않는다

근거

  • V1__notification_platform_core.sql:3-5는 notification migration이 opt-in stream이라고 명시한다.
  • PostgreSqlPersistenceConfig.java:55-58은 기본 Flyway location을 classpath:db/migration/postgresql로 고정한다.
  • fileserver에는 FileserverSchemaActivation.java:49-65, 전용 history table과 readiness card가 있지만 notification에는 동등한 activation/registry/readiness 항목이 없다.
  • NotificationRequestEntity.java:46-47template_locale은 V1 request table V1__notification_platform_core.sql:11-29에 존재하지 않는다.
  • migration의 metadata_json, routing_plan_json, normalized_payload_json, content_jsonjsonb지만 entity는 JSON JDBC mapping annotation 없이 String으로 선언한 곳이 있다. 실제 Hibernate validate와 bind 계약을 입증하는 테스트가 없다.
  • persistence-jpa notification 테스트는 isolated crypto 위주이며 migration/store PostgreSQL 통합 테스트가 없다.

실패 모드

빈 PostgreSQL에서 platform을 enable하면 table이 없거나, 운영자가 stream을 수동 적용해도 template_locale과 JSON type mismatch로 Hibernate validation/CRUD가 실패할 수 있다. scheduler는 즉시 시작되고 tick 예외를 log 후 반복하므로 readiness가 거짓 정상일 수 있다.

구현 결정: optional schema capability + fail-closed activation

  1. db/migration/jpa/notification-platform에 전용 history table flyway_jpa_notification_history를 부여한다. primary Flyway history에 같은 V1 번호를 섞지 않는다.
  2. jpa-notification-platform-v1 capability registry/readiness card와 operator apply/promote 절차를 추가한다.
  3. NotificationSchemaActivation.requireActive()를 worker/provider bean보다 먼저 실행한다.
  4. template_locale을 migration에 추가하거나 entity/record에서 제거해 단일 SSOT를 선택한다.
  5. JSONB 필드는 @JdbcTypeCode(SqlTypes.JSON) 등 현재 Hibernate 7/8 호환 정책에 맞춘 명시적 mapping을 사용하거나 DB 타입을 text로 바꾼다. 문자열로 JSONB에 기대어 쓰지 않는다.
  6. schema가 ACTIVE가 아니면 platform enabled context가 readiness 이전에 실패한다.

필수 인수 테스트

  • Testcontainers PostgreSQL: empty DB → core migration → notification migration → capability promotion → Hibernate validate.
  • request/recipient/attempt/event/contact/template/inbox CRUD와 재기동.
  • notification stream 미적용/미승격/잘못된 revision이면 boot failure.
  • downgrade/repair가 아니라 forward-only upgrade와 checksum validation.

NTF-003 — 예약 notification은 영구적으로 dispatch되지 않는다

근거

  • CanonicalNotificationPlanWriter.java:71-76scheduleAt이 있으면 recipient를 PENDING으로 만든다.
  • RecipientDeliveryJpaRepository.java:24-34의 claim query는 READY_TO_DISPATCH, RETRY_WAITING만 조회한다.
  • production search에서 due PENDINGREADY_TO_DISPATCH activation path는 없다.
  • migration index는 PENDING을 포함하지만 query는 포함하지 않는다.
  • 테스트는 예약 요청의 초기 PENDING만 확인하고 시간 경과 후 claim을 확인하지 않는다.

실패 모드

모든 schedule(...) 요청은 next_dispatch_at이 지나도 claim되지 않아 무기한 정체된다.

구현 결정: due-time queue를 단일 상태로 단순화

권장안은 scheduled row도 READY_TO_DISPATCH로 저장하고 next_dispatch_at = max(scheduleAt, notBefore)만 미래로 두는 것이다. PENDING이 별도 UI 의미로 반드시 필요하면 NTF-004의 atomic claim CTE가 due PENDING을 직접 DISPATCHING으로 전이하도록 한다. 별도 activation daemon을 추가하는 것은 현재 규모에서는 불필요하다.

claim 조건에는 다음을 한 문장으로 고정한다.

next_dispatch_at <= now
and delivery_state in ('PENDING', 'READY_TO_DISPATCH', 'RETRY_WAITING')
and (expires_at is null or expires_at > now)
and (lease_until is null or lease_until < now)

필수 인수 테스트

  • due 1ns 전 claim 0, due 순간 claim 1, 반복 poll/provider call 1회.
  • 과거 schedule은 즉시 claim.
  • notBefore 전 0회, expiresAt 이후 0회와 EXPIRED 전이.
  • restart 및 두 worker 경쟁에서도 정확히 한 claim.

NTF-004 — lease claim이 원자적이지 않고 fencing이 없다

근거

  • JpaRecipientLeaseStore.java:29-46selectClaimable 뒤 별도 markLeased를 호출하며 enclosing transaction이 없다.
  • RecipientDeliveryJpaRepository.java:24-49FOR UPDATE SKIP LOCKED SELECT와 UPDATE가 별도 repository call이다. lock이 두 호출 사이에 유지된다는 보장이 없다.
  • markLeased는 id만 조건으로 사용하고 이전 state/owner/version/token을 확인하지 않는다.
  • renew는 JpaRecipientLeaseStore.java:49-57에서 동일한 broad update를 재사용하므로 stale worker가 새 lease를 덮을 수 있다.
  • native update는 RecipientDeliveryEntity.java:84-86의 JPA @Version을 증가시키지 않아 stale managed entity write가 lease/state를 덮을 수 있다.
  • NotificationDispatchService.load(lease)는 owner/token/expiry를 재검증하지 않는다.
  • 모든 replica의 worker ID는 NotificationPlatformRuntimeConfig.java:500-503notification-worker-1이다.
  • scheduler는 batch 전체를 먼저 claim한 뒤 semaphore를 기다리므로 concurrency보다 큰 batch는 실행 전에 lease가 만료될 수 있다.

실패 모드

두 replica가 같은 row를 소유했다고 믿거나, 만료된 worker가 새 owner의 claim 이후 provider를 호출하고 결과를 기록할 수 있다. 이는 duplicate notification과 잘못된 outcome overwrite로 이어진다.

구현 결정: atomic work claim + fencing token

새 JPA 병합에 들어온 PostgreSqlWorkClaimExecutor의 fixed-statement registry와 단일 native query 구조를 재사용한다. 다만 현재 WorkClaim에는 generation이 없으므로 notification queue에는 fencing을 확장해야 한다.

with candidate as (
  select id
    from notification_recipient_delivery
   where next_dispatch_at <= :now
     and delivery_state in ('PENDING','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
)
update notification_recipient_delivery d
   set lease_owner = :owner,
       lease_generation = d.lease_generation + 1,
       lease_until = :leaseUntil,
       delivery_state = 'DISPATCHING',
       version = d.version + 1,
       updated_at = :now
  from candidate c
 where d.id = c.id
returning d.id, d.lease_generation, d.lease_until;
record RecipientLease(
    RecipientDeliveryId id,
    String owner,
    long generation,
    Instant until) {}
  1. worker ID는 instance UUID/pod identity + boot UUID로 만든다.
  2. renew/release/begin-attempt/outcome update는 모두 id + owner + generation + lease_until > now를 조건으로 한다.
  3. stale token으로 영향받은 row가 0이면 provider call을 시작하지 않거나 outcome write를 거부한다.
  4. claim은 현재 available permit 수만큼만 수행한다.
  5. native write와 JPA version을 일치시킨다.
  6. transaction은 새 공통 JpaTransactionExecutor 또는 명시적 adapter transaction boundary에서 한 statement 전체를 감싼다.

필수 인수 테스트

  • 실제 PostgreSQL의 두 connection barrier에서 두 worker claim 집합이 disjoint.
  • stale owner/generation renew, release, begin-attempt, outcome update가 모두 0 rows.
  • lease expiry 직전/직후 race와 process restart.
  • batch=10, concurrency=1, 짧은 lease에서도 provider call 최대 1회.
  • native claim 뒤 stale JPA entity flush가 state/version을 덮지 못함.

NTF-005 — recovery, reconciliation, projection replay가 runtime에 연결되지 않는다

근거

  • LeaseRecoveryService는 class만 있고 bean/scheduler/caller가 없다.
  • 구현도 incomplete attempt만 순회하므로 provider attempt row를 쓰기 전에 crash한 DISPATCHING row는 복구하지 못한다.
  • ProviderEventLedger.pendingProjection(...), unmatched(...)는 persistence 구현만 있고 production consumer가 없다.
  • V3 migration은 notification_reconciliation_job table을 만들지만 대응 entity/store/worker가 없다.
  • RECONCILIATION_REQUIRED recipient를 claim하는 worker가 없다.
  • NotificationSchedulerWorker는 raw virtual thread handle을 보관·join하지 않고 close()가 polling thread를 확실히 중지하지 않는다.
  • scheduler catch는 lease를 남겨 recovery를 기대하지만 실제 recovery lifecycle이 없다.

실패 모드

crash 시 DISPATCHING row, pending/failed projection, unmatched callback, ambiguous attempt가 영구 정체된다. shutdown 중 executor submission race도 lease를 남긴다.

구현 결정: explicit recovery state machine + managed lifecycle

다음 세 worker를 SmartLifecycle로 관리한다.

  1. DispatchRecoveryWorker: 만료 lease를 case별로 복구한다.
    • attempt 없음: provider call 전 crash가 증명되므로 safe requeue.
    • attempt 존재 + requestStarted=PROVEN false: safe retry.
    • body committed/unknown: reconciliation queue.
    • response가 proven rejected: terminal/fallback policy.
  2. ReconciliationWorker: due reconciliation job을 claim/fence하고 capability 결과를 적용한다.
  3. ProviderEventWorker: pending/failed projection replay와 unmatched binding을 수행한다.

worker는 start/stop phase, interrupt, join timeout, jitter, batch/permit, lag metric을 공통 lifecycle support로 관리하되 하나의 범용 workflow engine으로 만들 필요는 없다.

필수 인수 테스트

  • attempt insert 전, insert 후/request 전, body commit 후/response 전, outcome commit 전 process kill.
  • restart 후 safe case만 retry하고 ambiguous case는 provider resend 없이 reconciliation.
  • unsupported reconciliation은 operator-visible 상태로 남고 busy loop하지 않음.
  • pending/failed projection replay, callback-before-outcome binding, graceful shutdown 시 lease 반환.

NTF-006 — 모든 RuntimeException을 body-committed AMBIGUOUS로 기록한다

근거

  • NotificationDispatchService.java:178-190gateway.submit()의 모든 RuntimeExceptionProviderExecutionEvidence.responseLost()로 변환한다.
  • ProviderRuntime.acquireAttempt()의 disabled/auth/rate/concurrency failure는 wire call 전 발생한다.
  • contact reveal, payload mapping, expiry, size, configuration error도 network call 전에 발생할 수 있다.
  • 이미 ProviderSubmissionResult.notSubmitted(...)ProviderExecutionEvidence.notStarted()가 있지만 이 경로에서는 사용하지 않는다.
  • JDK HTTP gateway는 exception message substring으로 commitment phase를 추정한다.

실패 모드

provider byte를 하나도 쓰지 않은 limiter/profile/payload 오류가 “이미 전송됐을 수 있음”으로 기록되어 automatic retry/fallback이 영구 차단되고 불가능한 reconciliation에 들어간다.

구현 결정: typed transport milestone result

adapter의 실제 transport seam만 commitment evidence를 결정하게 한다.

sealed interface ProviderCallOutcome {
  record Completed(ProviderSubmissionResult result) implements ProviderCallOutcome {}
  record FailedBeforeWrite(ProviderFailure failure) implements ProviderCallOutcome {}
  record FailedAfterCommit(ProviderFailure failure) implements ProviderCallOutcome {}
}
  1. runtime unavailable, limiter, mapping, validation은 FailedBeforeWrite/NOT_SUBMITTED.
  2. body write 완료를 transport가 관측한 뒤 response를 잃은 경우만 AMBIGUOUS.
  3. SDK hidden retry는 끄고 각 SDK의 milestone/attempt count를 명시적으로 매핑한다.
  4. 예상 밖 programming error는 internal failure로 관측하고 body commitment를 추측하지 않는다.
  5. generic Either library는 추가하지 않고 기존 Result/evidence 타입을 확장한다.

필수 인수 테스트

  • disabled/auth failed/rate exhausted/concurrency exhausted/payload invalid: wire bytes 0, NOT_SUBMITTED, ambiguous=false.
  • connect failure와 request-body write 전 reset: NOT_SUBMITTED.
  • body commit 후 socket reset: AMBIGUOUS.
  • provider response 완료: accepted/rejected evidence가 정확히 기록됨.

NTF-007 — projection snapshot이 engagement와 suppression 사실을 잃는다

근거

  • JpaDeliveryAttemptStore.java:130-144는 DB row를 DeliveryProjection으로 복원할 때 persisted submission/delivery/evidence는 읽지만 EngagementFacts.NONE, SuppressionFacts.NONE을 항상 넣는다.
  • save(...)도 delivery outcome/evidence만 attempt/recipient에 roll-up한다.
  • migration/entity에는 opened/clicked/complaint/hard-bounce/invalid-target fact를 보존할 컬럼이나 versioned projection payload가 없다.
  • StandardDeliveryProjector.java:100-111은 hard bounce가 있으면 later delivered를 무시하지만, reload 후 hard-bounce fact가 사라져 이 불변식이 깨진다.
  • complaint/opened 사실도 다음 event/restart에서 사라진다.

실패 모드

hard bounce 뒤 late delivered가 적용되거나 complaint/read/open facts가 사라진다. suppression side effect가 재실행되거나 monotonic projection이 후퇴할 수 있다.

구현 결정: durable projection snapshot + replay verifier

권장안은 명시적 typed columns 또는 versioned canonical projection JSON을 optimistic version과 함께 저장하는 것이다. event 수가 작더라도 매번 전체 ledger fold만 수행하면 조회 비용과 side-effect exactly once 문제가 커지므로 snapshot을 기본으로 하고, ledger replay verifier를 운영/테스트용으로 둔다.

저장할 최소 내용:

  • submission outcome, delivery outcome, evidence level
  • opened/clicked/displayed/read와 최초/최종 시각
  • hard bounce, complaint, invalid target
  • projection version/last applied event ID
  • suppression side-effect applied marker 또는 독립 idempotency key

필수 인수 테스트

  • hard-bounce → transaction 종료 → restart → delivered: ignored.
  • delivered → complaint → restart → late delivered: delivery+complaint 모두 보존.
  • read → displayed: read가 후퇴하지 않음.
  • 동일 event replay와 projector retry에서 side effect 1회.
  • snapshot을 full ledger fold 결과와 비교하는 property test.

NTF-008 — callback-before-outcome event를 나중에 attempt에 연결할 수 없다

근거

  • JpaProviderEventLedger.java:152-176은 append 시 raw provider request ID로 즉시 attempt를 찾고, 없으면 attempt_id=null로 저장한다.
  • raw provider request ID는 저장하지 않고 hash만 저장한다.
  • toRecord(...):190-214는 provider request ID를 항상 empty로 복원한다.
  • ProviderEventEntity.java:40-42attempt_idupdatable=false다.
  • ledger에는 bind API가 없고 unmatched(...) consumer도 없다.
  • callback은 provider outcome commit보다 먼저 도착할 수 있으므로 이 race는 정상 운영 시나리오다.

실패 모드

callback-before-outcome event가 영구 unmatched로 남고 delivery/suppression projection에 반영되지 않는다.

구현 결정: hash-based late binding

  1. ledger port에 bindUnmatched(profileId, providerRequestIdHash, attemptId) 또는 batch matcher CAS를 추가한다.
  2. attempt_id는 payload identity가 아니라 후발 association이므로 update를 허용한다.
  3. provider outcome이 request ID hash를 저장한 직후 matcher를 trigger하고, background worker가 race를 보완한다.
  4. bind는 attempt_id is null 조건의 atomic update이며 성공한 event만 projection queue에 넣는다.
  5. 원문 ID가 reconciliation에 필요하면 lookup hash와 별도로 목적별 AEAD ciphertext를 저장한다.

필수 인수 테스트

  • callback append → attempt outcome/hash 저장 → matcher → projection 정확히 1회.
  • matcher와 callback/outcome의 모든 순서 permutation.
  • 동일 hash의 tenant/profile scope 충돌 방지.
  • stale/잘못된 profile은 bind 0건, operator metric 증가.

NTF-009 — callback append, acknowledgement, dedupe가 원자적이지 않다

근거

  • ProviderCallbackIngestionService.java:84-106은 normalize/protect/append 뒤 새 event를 동기적으로 project한다. append 전체를 감싸는 application transaction이 없다.
  • JpaProviderEventLedger.java:66-79는 event마다 find-before-insert 후 saveAndFlush한다. batch 중간 실패 시 앞 event만 commit될 수 있다.
  • concurrent duplicate는 둘 다 pre-check를 통과한 뒤 unique violation loser가 500이 될 수 있다.
  • MVC는 validation exception만 400으로 매핑하고 persistence unique race를 duplicate 204로 바꾸지 않는다.
  • ProviderEventProjectionService.java:56-59의 no-projector markFailed는 transaction 밖이고, JpaProviderEventLedger.markFailed는 명시적 save/update query도 없다.
  • “durable append 후 빠른 2xx”라는 주석과 달리 synchronous projector failure가 callback response를 실패시킬 수 있다.

구현 결정: transactional inbox append + asynchronous projector

  1. signature 검증·normalization 이후 batch를 하나의 write transaction에서 append한다.
  2. PostgreSQL INSERT ... ON CONFLICT DO NOTHING RETURNING 또는 정확한 constraint 분류+reread로 concurrent duplicate를 정상 결과로 만든다.
  3. durable append가 commit되면 HTTP 204를 반환한다. projection은 별도 worker가 수행한다.
  4. projection status는 PENDING -> APPLYING -> APPLIED|IGNORED|FAILED CAS/update query로 전이한다.
  5. callback URL의 {provider}와 profile에 등록된 provider ID, enabled 상태를 서명 검증 전에 결합한다.

필수 인수 테스트

  • 동일 callback 동시 N개: 모두 204, ledger row 1개, created 1/duplicate N-1.
  • batch 3개 중 DB failure: 전부 rollback 또는 명시적 per-event 결과; 부분 성공을 숨기지 않음.
  • projector throw: callback은 durable append 후 204, event는 FAILED/PENDING으로 replay 가능.
  • wrong provider/profile/disabled profile은 ledger append와 cert fetch 전에 거부.

NTF-010 — Web Push subscription이 persistence round-trip에서 손실된다

근거

  • WebPushSubscriptionValue는 endpoint, p256dh, authSecret, vapidKeyId 네 필드를 갖는다.
  • WebPushSubscriptionValue.java:76-80normalized()는 endpoint+p256dh만 포함한다.
  • AesGcmContactPointProtector.java:70-80은 normalized 문자열만 암호화한다.
  • reveal의 parseWebPush(...):191-203은 authSecret을 zero 16 bytes, vapidKeyId를 restored로 만든다.
  • 주석은 별도 encrypted columns가 있다고 하지만 notification_contact_point schema와 entity에는 해당 컬럼이 없다.
  • WebPushRequestMapper.java:103-108은 subscription의 vapidKeyId도 사용하지 않고 active key를 선택한다.

실패 모드

저장 후 복원된 subscription으로 RFC 8291 payload를 만들면 browser가 decrypt할 수 없고, VAPID rotation 후 구 subscription 서명 key도 선택하지 못한다.

구현 결정: identity와 secret serialization 분리

  1. lookup fingerprint용 identity codec은 endpoint+p256dh만 사용할 수 있다.
  2. encrypted payload codec은 versioned envelope로 네 필드를 모두 직렬화한다.
  3. AEAD AAD에 contact type, codec version, tenant/contact ID를 포함한다.
  4. VapidKeyRegistry가 subscription.vapidKeyId로 historical key pair를 선택한다.
  5. 모든 ContactPointValue subtype이 같은 codec registry/Strategy를 사용한다.

필수 인수 테스트

  • 모든 contact subtype의 protect → JPA save → load → reveal equality.
  • WebPush는 원 UA private key/auth secret으로 provider payload decrypt 성공.
  • active VAPID key 변경 후 old subscription은 old key로 서명.
  • unknown/retired key ID는 발송 전에 typed failure.

NTF-011 — callback body bound, AES-GCM envelope, DB bound가 서로 모순된다

근거

  • settings는 callback body를 최대 1,048,576 bytes까지 허용한다.
  • MVC는 @RequestBody byte[]로 이미 전부 할당한 뒤 hard-coded 65,536을 검사한다.
  • WebFlux는 configured max를 join 단계에서 적용한다.
  • AesGcmCallbackPayloadProtection.java:55-72는 plaintext를 max로 자른 뒤 12-byte nonce와 16-byte GCM tag를 더한다.
  • V1 DB check는 ciphertext 전체를 65,536 bytes 이하로 제한한다.
  • 정확히 65,536-byte plaintext는 65,564-byte ciphertext가 되어 DB check를 위반한다.
  • callback ciphertext에는 key ID/version이 없어 rotation 뒤 historical payload decrypt 계약도 없다.

구현 결정: 하나의 end-to-end payload envelope contract

예를 들어 stored ciphertext cap을 65,536으로 유지한다면 retained plaintext는 최대 65,508이어야 한다. request acceptance max와 retained diagnostic max를 분리해도 된다.

record ProtectedCallbackPayload(
    int version,
    String keyId,
    byte[] nonce,
    byte[] ciphertext) {}
  1. shared contract에 maxRequestBytes, maxRetainedPlaintextBytes, envelope overhead, DB cap을 정의한다.
  2. servlet은 endpoint-specific filter/container limit로 deserialization 전에 차단한다.
  3. WebFlux와 MVC가 같은 property/boundary semantics를 사용한다.
  4. DB에 version/keyId/nonce/ciphertext를 분리하거나 self-describing envelope를 저장한다.
  5. raw payload 복호화가 실제 필요 없다면 ciphertext 자체를 제거하고 bounded digest만 보존하는 선택도 검토한다.

필수 인수 테스트

  • MVC/WebFlux/PostgreSQL 모두 max-1/max/max+1.
  • Content-Length 없음, chunked body, cancellation, oversized stream.
  • max request의 encryption/insert 성공.
  • active key rotation 뒤 historical envelope decrypt 또는 의도한 삭제 정책.

NTF-012 — dynamic endpoint, HTTP response, SNS callback security가 fail-closed가 아니다

근거

  • NotificationEndpoints.java:24-35requireSecureOrLoopback은 HTTPS라는 이유만으로 private, link-local, metadata 주소를 허용한다. dynamic webhook/WebPush endpoint에는 SSRF 방어가 되지 않는다.
  • JdkNotificationHttpGateway.java:59-63BodyHandlers.ofByteArray()로 response body를 무제한 읽는다.
  • 기존 HTTP client platform에는 dynamic target validation, DNS/response size policy가 있지만 notification bootstrap은 이를 bridge하지 않는다.
  • SnsSignatureVerifier.java:49-75는 host suffix만 검사한다. evilamazonaws.com류 label confusion, non-default port/path/userinfo/query, private DNS/redirect를 충분히 제한하지 않는다.
  • SignatureVersion 2 이외는 SHA-1로 내려가며 unknown/missing version을 fail-closed로 거부하지 않는다.
  • TopicArn/account/region/profile, timestamp/replaySkew가 signature policy에 결합되지 않는다.
  • production SnsCertificateProvider 구현이 없다. 지금은 unwired지만 연결 순간 보안 결함이 활성화된다.

구현 결정: 기존 dynamic HTTP platform 재사용 + provider-specific verifier

  1. bootstrap에서 notification NotificationHttpGateway를 기존 dynamic target gateway에 bridge한다.
  2. 외부 endpoint는 DNS resolve/re-resolve, private/loopback/link-local/multicast/metadata deny, redirect deny, scheme/port allowlist를 적용한다. loopback 허용은 명시적 test/local profile만 가능하게 한다.
  3. response는 streaming cap을 적용하고 status+headers+bounded body/digest만 보존한다.
  4. SNS cert URL은 AWS partition별 exact hostname grammar, expected path, default HTTPS port, no userinfo/query를 검사한다.
  5. signature version은 정확히 지원 목록만, TopicArn/account/region과 timestamp skew는 profile에 bind한다.
  6. X509 fetch cache는 time/size bounded이며 cert validity와 hostname/profile을 검증한다.

필수 인수 테스트

  • 127.0.0.1, RFC1918, link-local, IPv6 local, cloud metadata, DNS rebinding, redirect, mixed DNS answer 거부.
  • attacker-owned suffix host, port/path/userinfo/query, SignatureVersion 3/missing, wrong TopicArn/profile, stale timestamp는 cert fetch/ledger append 전에 거부.
  • huge/chunked-infinite response가 byte cap에서 중단되고 OOM이 발생하지 않음.
  • 정상 SNS v1/v2 fixture만 통과.

NTF-013 — idempotency fingerprint가 저장된 요청의 정본 표현이 아니다

근거

  • NotificationPlan.java:23,51Map<String,Object>를 받고 shallow Map.copyOf만 수행한다.
  • RequestFingerprint.java:47-50은 strategy class simple name과 primary channel만 기록해 ordered fallback tail/order를 누락한다.
  • recipient의 ChannelPreferenceOverride preferred/blocked 전체가 fingerprint에 포함되지 않는다.
  • dedup key/window는 포함하지만 DeduplicationAction은 누락된다.
  • delimiter를 escape하지 않은 key=value,와 arbitrary value toString()을 사용한다.
  • 중첩 mutable object가 fingerprint 계산 뒤 persistence encode 전에 바뀔 수 있다.
  • persistence는 별도 JSON codec으로 variables를 저장하므로 hash 입력과 저장 byte가 다르다.

실패 모드

서로 다른 fallback/override/dedup 요청이 같은 idempotency fingerprint로 합쳐지거나, 동일 logical JSON이 map 순서/구현체에 따라 conflict가 난다. delimiter collision과 TOCTOU도 가능하다.

구현 결정: typed immutable values + canonical plan encoder

기존 R1의 닫힌 NotificationTemplateValue algebra를 승격하거나 새 sealed JSON-neutral value type을 만든다. application-core에 Jackson JsonNode를 넣지는 않는다.

sealed interface NotificationVariable
    permits TextValue, NumberValue, BooleanValue, NullValue, ListValue, ObjectValue {}

record EncodedNotificationPlan(int version, byte[] bytes, String variablesPayload) {}
  1. public DTO graph에서 arbitrary Object를 제거하고 depth/key/value/total byte bound를 둔다.
  2. CanonicalNotificationPlanEncoderPort가 versioned length-framed bytes를 한 번 만든다.
  3. fingerprint는 그 bytes에 SHA-256을 적용하고 persistence도 같은 encoded payload를 사용한다.
  4. full fallback order, override, dedup action, collapse, schedule/expiry, metadata 의미 필드를 모두 포함한다.
  5. secret/PII를 toString()에 노출하지 않는다.

필수 인수 테스트

  • [EMAIL,SMS] vs [EMAIL,PUSH], 순서 변경, override/dedup action/collapse 변경은 다른 hash.
  • delimiter/type/nested collection collision property/fuzz test.
  • 동일 object map insertion order는 동일 hash.
  • input nested object를 계산 중 변경해도 저장 payload와 hash가 어긋나지 않음.
  • public DTO graph reflection test가 Object/Map<?,Object>를 재귀적으로 금지.

NTF-014 — deduplication, collapse, recipient preferred order가 공개 계약만 있고 실행되지 않는다

근거

  • NotificationPlan은 deduplication과 collapse를 받는다.
  • DeduplicationService와 JPA store bean은 있지만 submission path에서 호출되지 않는다.
  • NotificationRequestRecord/entity/schema는 dedup/collapse frozen semantics를 저장하지 않는다.
  • NotificationDispatchService.java:166-177ProviderSubmission.collapse를 항상 empty로 만든다.
  • APNs/FCM mapper와 capability는 collapse를 지원하지만 값이 도달하지 않는다.
  • ConfiguredRoutePlanner.java:41-68은 blocked channel만 보고 preferredOrder를 무시한다.

실패 모드

caller가 dedup/drop/return-existing/collapse/preference를 요청해도 duplicate message가 전송되고 provider collapse header/route 순서가 반영되지 않는다.

구현 결정: acceptance transaction에 정책 고정

  1. multi-recipient dedup 의미를 확정한다. 정해지기 전에는 dedup plan을 single recipient로 제한하거나 명시적 validation failure로 거부한다.
  2. candidate notification ID를 먼저 발급하고 request/recipient insert와 dedup claim을 같은 transaction에 넣는다.
  3. DROPRETURN_EXISTING을 receipt/result에 명시적으로 표현한다.
  4. collapse를 durable request/recipient plan에 저장해 retry/redrive에도 동일 값을 사용한다.
  5. provider capability가 false면 조용히 무시하지 말고 pre-dispatch typed failure로 거부한다.
  6. route는 preferred-order 교집합을 먼저 두고 나머지는 original strategy order를 유지한다.

필수 인수 테스트

  • 동일 tenant/recipient/category/key/window 동시 N건에서 durable notification/provider call 1회.
  • DROP과 RETURN_EXISTING 결과 차이, window 경계.
  • APNs/FCM final wire collapse key.
  • preferred/blocked/fallback 조합의 deterministic route.

NTF-015 — provider별 wire contract에 내용 누락과 expiry/limit 오류가 있다

근거

  • WebhookNotificationProviderAdapter.java:101-107 body는 attemptId와 contentDigest뿐이며 실제 rendered content가 없다.
  • WebhookSubscription.signingKeyRef는 무시되고 global callback-signing key가 사용된다.
  • EmailContent는 attachment를 계약으로 받지만 SMTP adapter는 MIME factory에 List.of()를 넘기고 SES mapper도 attachment를 처리하지 않는다. AttachmentResolver는 production에 연결되지 않는다.
  • WebPushRequestMapper는 이미 있는 VapidKeyRegistry를 사용하지 않고 active key를 사용한다.
  • WebPush receipt capability가 선언되지만 production receipt flow에 연결되지 않는다.
  • FCM remaining TTL이 이미 음수면 expired가 아니라 provider max TTL로 되살아날 수 있다.
  • APNs/FCM capability가 4096-byte limit을 선언하지만 final UTF-8 wire payload size를 provider call 전에 강제하지 않는다.
  • ProviderSubmission은 channel/profile/content compatibility 불변식을 강제하지 않아 webhook test가 다른 channel content를 넣어도 성립할 수 있다.

구현 결정: provider별 versioned wire DTO + preflight validator

  1. webhook에 schema version, attempt/idempotency/expiry, channel, 허용된 rendered content/metadata를 담는 명시적 envelope를 만들고 정확한 canonical bytes를 key-ref별로 서명한다.
  2. attachment는 resolver → digest/size/content-type 검증 → try-with-resources → MIME/SES raw message 순서로 처리한다. 지원하지 않으면 provider call 전 typed rejection.
  3. WebPush는 subscription-specific VAPID key를 사용하고 receipt end-to-end 연결 전 capability를 false로 둔다.
  4. FCM expiry는 no-expiry/max, remaining<=0/EXPIRED, future/min의 세 분기로 나눈다.
  5. APNs/FCM/WebPush/Webhook은 final serialized bytes에 provider limit을 적용한다.
  6. ProviderSubmission compact constructor에서 channel/content/profile compatibility를 검사한다.

필수 인수 테스트

  • webhook body에 실제 title/body/data와 schema version이 있고 key ref별 signature가 다름.
  • attachment byte/digest/name/content-type, 성공/실패 모두 resource close.
  • resolver 미설정/oversize/digest mismatch는 provider call 0회.
  • expired FCM은 call 0회, future TTL은 정확한 min.
  • APNs/FCM 4096 경계 ±1 byte.
  • mismatched channel/content/profile은 construction 또는 preflight에서 실패.

NTF-016 — secret startup validation, rotation, profile binding이 완성되지 않았다

근거

  • NotificationPlatformSecretsConfig.java:17-23은 strict startup validation을 설명하지만 :41-70은 blank 값을 건너뛰고 Base64 decode만 한다.
  • AES 길이와 purpose별 material distinctness는 첫 protect 시 일부만 검사된다.
  • key ID는 contact-enc, payload-enc 등 상수라 env material 교체 시 동일 ID 아래 ciphertext가 decrypt 불가능해진다.
  • historical key map은 항상 empty다.
  • callback payload에는 key ID가 없다.
  • SES/Twilio/Webhook/WebPush는 profile의 credential/key ref 대신 global active key를 사용하는 경로가 있다.
  • contact lookup HMAC을 callback/provider ID hashing에도 재사용해 purpose separation이 약해진다.

구현 결정: versioned keyring + capability-aware startup validator

ca-skeleton:
  notification:
    platform:
      secrets:
        contact-encryption:
          active-key-id: contact-2026-08
          keys:
            contact-2026-08: ${...}
            contact-2026-01: ${...}
  1. purpose별 active key ID와 historical key map을 바인딩한다.
  2. enabled provider/callback/contact 기능별 required-purpose matrix를 startup에서 검사한다.
  3. Base64, AES/HMAC 길이, distinct material, known ref, active/historical 중복을 검증한다.
  4. adapter에는 global provider가 아니라 profile-bound credential handle/generation을 전달한다.
  5. old generation은 in-flight drain 및 historical decrypt 기간 동안 유지한다.
  6. callback fingerprint/provider request lookup에는 별도 SecretPurpose를 둔다.

필수 인수 테스트

  • missing/weak/duplicate/unknown ref는 full context boot failure.
  • key rotation 전 row를 rotation 후 historical key로 decrypt.
  • 두 provider profile의 wire auth가 서로 다름.
  • rotation 중 old in-flight는 old generation, 새 attempt는 new generation을 사용하고 DB 기록과 일치.

NTF-017 — template engine이 slot context를 구분하지 않는다

근거

  • default PlaceholderTemplateEngine.java:24-45는 값을 raw string으로 치환한다.
  • NotificationPlatformRuntimeConfig.java:242-271은 subject, plain text, HTML, SMS, push, URI slot에 같은 engine을 사용한다.
  • HTML_BODY에서 caller value가 active markup이 될 수 있다.
  • Thymeleaf를 HTML mode로 모든 slot에 쓰면 plain text/SMS의 & 등이 entity로 변할 수 있다.
  • rendered deep link는 URI parse만 하고 scheme/host allowlist를 강제하지 않는다.

구현 결정: context-aware rendering Strategy

enum TemplateSlotMode { SUBJECT, TEXT, HTML_TEXT, URI }

interface NotificationTemplateEngine {
  String render(TemplateSlotMode mode, String source, NotificationVariables variables);
}
  1. SUBJECT/TEXT/SMS는 literal text mode와 CR/LF/length 정책을 적용한다.
  2. HTML body는 HTML text/attribute/URL context를 구분하거나 unescaped construct를 금지한다.
  3. URI slot은 허용한 https/명시적 app scheme만 통과시킨다.
  4. template publish 시 slot별 compile/validation을 수행하고 runtime cache는 bounded digest key로 둔다.
  5. 범용 expression language는 허용하지 않는다.

필수 인수 테스트

  • HTML element/attribute/URL injection, quote breakout, script/event handler.
  • plain text a & b&amp;로 변하지 않음.
  • subject CR/LF 거부.
  • javascript:, data:, file: 거부, 허용 HTTPS/app scheme 통과.

NTF-018 — 기존 R1과 신규 platform의 canonical ownership이 충돌한다

근거

  • application-core/CLAUDE.md:75-96은 기존 dev.caskeleton.application.notification을 R1 canonical로 설명한다.
  • docs/notification/migration-guide.md:3-18은 R0 router → 신규 platform만 설명하고 기존 R1 100개 production type의 처분을 다루지 않는다.
  • outbound notification README는 현재 구현 전체를 R0처럼 기술한다.
  • 기존/new Channel, plan, dispatch, receipt/evidence 모델이 중복되며 production bridge/import가 없다.

실패 모드

새 consumer가 어느 API를 사용해야 하는지 알 수 없고 두 모델이 별도 진화한다. R0 삭제 후에도 R1 graph가 고아로 남거나, platform이 R1 정책을 우회하는 이중 canonical이 된다.

구현 결정: ADR + temporary Anti-Corruption Layer

  1. R0 → R1 → platform 버전/역할과 최종 canonical namespace를 ADR로 확정한다.
  2. 기존 R1 100개 type을 replace / bridge / retain / delete로 전수 분류한다.
  3. platform이 canonical이면 R1 public entrypoint에 deprecation/forRemoval과 신규 production consumer 금지 ArchUnit을 추가한다.
  4. 필요한 변환은 compatibility/r1의 단 하나 ACL에만 둔다.
  5. README, CLAUDE, migration guide, deletion inventory, public path snapshot을 같은 disposition 표와 동기화한다.

필수 인수 테스트

  • 허용 ACL 외 두 namespace 간 production dependency 0건.
  • 모든 R1 public type disposition 목록.
  • R0/R1/platform consumer와 삭제 조건 contract test.
  • 중복 simple-name API 제거 또는 명시적 compatibility test.

NTF-019 — 신규 orchestration이 mandatory UseCase fitness gate를 우회한다

근거

  • NotificationOrchestrator는 submit/schedule/cancel/get을 한 interface에 섞는다.
  • NotificationSubmissionService, ProviderCallbackIngestionService, ReconciliationService, dispatch/admin orchestration이 CommandUseCase/QueryUseCase@UseCaseCapability를 사용하지 않는다.
  • MVC controller는 inbound use case port가 아니라 concrete ProviderCallbackIngestionService를 주입한다.
  • 기존 ArchUnit은 이미 marker를 구현한 type만 검사하므로 marker를 쓰지 않은 신규 entrypoint를 놓친다.

실패 모드

transaction mode, repository access, external outbound, idempotency, permission metadata가 자동 fitness gate를 우회한다. read/write를 한 class에 섞어 class-level capability도 정확히 선언할 수 없다.

구현 결정: CQRS-style explicit inbound use cases

submit, schedule, cancel, get, callback ingest, dispatch, reconcile, admin command/query를 각각 분리한다. controller/worker는 해당 port.in interface만 의존한다. NotificationOrchestrator가 호환성 때문에 필요하면 deprecated facade로 두고 분리 use case에 위임한다.

필수 인수 테스트

  • 모든 public application orchestration entrypoint가 Command/Query marker와 capability를 보유.
  • mutating/admin use case가 필요한 permission을 보유.
  • controller/worker가 concrete application implementation을 참조하지 않음.
  • marker 없는 위반 fixture가 ArchUnit에서 실제 실패.

NTF-020 — admin과 route business policy가 outbound adapter에 있다

근거

  • outbound AdminAuthorizationGuard, DuplicateRiskGuard가 actor/tenant/ambiguous redrive business rule을 구현한다.
  • NotificationAdminServiceImpl은 authorization, idempotency, suppression, redrive, reconcile, transaction orchestration과 provider state mutation을 한 class에서 수행한다.
  • admin operation은 find → side effect → save라 concurrent 동일 operation ID에서 side effect가 중복될 수 있다.
  • ConfiguredRoutePlanner가 delivery strategy/blocked channel eligibility를 해석한다.
  • settings가 ambiguous fallback invariant를 소유한다.

구현 결정: application command handler + atomic admin claim

  1. admin command별 application use case로 authorization/tenant/idempotency/duplicate-risk policy를 옮긴다.
  2. outbound에는 좁은 ProviderRuntimeControlPort, catalog/status adapter만 남긴다.
  3. admin store port를 claim(operationId, commandFingerprint)Claimed/Replay/InProgress/Conflict 결과로 바꾼다.
  4. 외부/DB side effect 전에 durable claim하고 exact result/phase를 저장한다.
  5. route business eligibility/preference/fallback은 application에 두고 adapter는 configured profile catalog만 제공한다.
  6. 범용 command bus/workflow engine은 필요 없다. 작은 phase state machine이면 충분하다.

필수 인수 테스트

  • 동시 N개 동일 operation ID에서 side effect 1회.
  • 동일 ID/다른 command payload는 conflict.
  • 중간 failure 재시도는 완료 item을 반복하지 않음.
  • outbound notification package에서 actor authorization/TransactionPort orchestration이 사라짐.

NTF-021 — runtime limiter/state/registry/credential compound operation이 원자적이지 않다

근거

  • ProviderAttemptLimiter는 window CAS와 count reset을 별도 atomic으로 수행해 rollover race에서 증가를 잃을 수 있다.
  • provider permit close는 idempotent guard가 없어 double-close가 semaphore 한도를 늘릴 수 있다.
  • ProviderRuntime은 state와 reason을 별도 AtomicReference로 두어 모순 snapshot이 가능하다.
  • markHealthy는 일부 state만 전이하지만 admin은 실제 적용되지 않아도 성공을 기록할 수 있다.
  • ProviderRuntimeRegistry.register는 duplicate current generation을 조용히 교체한다.
  • draining cleanup과 replace가 경쟁하면 generation을 잃을 수 있다.
  • credential manager의 get/validate/put 경쟁은 낮은 generation이 높은 generation 뒤에 활성화될 수 있다.

구현 결정: immutable atomic state + transition table

record RuntimeHealth(ProviderRuntimeState state, Optional<String> reason) {}
record RateWindow(long epochSecond, int used) {}
  1. health는 하나의 AtomicReference<RuntimeHealth>와 명시적 operator/provider transition table로 관리한다.
  2. limiter는 (window,count) 단일 CAS 또는 작은 synchronized token bucket을 사용하고 monotonic ticker를 주입한다.
  3. permit은 AtomicBoolean released로 idempotent close.
  4. 최초 registry register는 putIfAbsent로 duplicate fail; replace/drain/cleanup은 profile holder의 compute/lock으로 원자화한다.
  5. credential generation은 strictly increasing CAS로 검증한다.

필수 인수 테스트

  • 100+ thread window boundary에서 configured rate 초과 없음.
  • double close가 permit 수를 늘리지 않음.
  • 모든 legal/illegal health transition과 reason 일관성.
  • concurrent replace/cleanup/rotation에서 in-flight generation 보존, 최종 generation=max.

NTF-022 — logical module/package DAG와 public API 경계가 강제되지 않는다

근거

  • docs는 31 logical modules를 5개 leaf의 package로 매핑하지만 같은 Gradle project 내부 package edge와 cycle은 registry가 검사하지 않는다.
  • application platform 272개, outbound 110개, persistence 36개 등 거의 모든 top-level type이 public이다.
  • PublicApiBoundaryTest는 facade 직접 parameter만 보고 record 내부 Map<String,Object>와 visibility를 놓친다.
  • NotificationPlatformRuntimeConfig 506줄, persistence config 220줄이 codec/security/template/policy/ worker/entity/repository/mapper 조립을 한곳에 모은다.
  • bootstrap이 persistence entity/repository/mapper internals를 직접 import하므로 해당 type이 public이어야 한다.
  • root controller/mapper ArchUnit 일부는 package 문자열 기준이라 현재 callback controller와 NotificationRecordMapper를 선택하지 못한다.

구현 결정: package DAG + internal-by-default

  1. 아래 6절의 package 구조로 이동한다.
  2. apiport allowlist 외 type은 package-private 또는 .internal로 둔다.
  3. ArchUnit에 exact allowed package edge, cycle 금지, public API allowlist를 추가한다.
  4. controller는 @RestController, mapper는 suffix/annotation, entity는 JPA annotation처럼 semantic predicate로 선택한다.
  5. persistence leaf 내부 configuration facade 하나만 application port bean을 노출하고 bootstrap은 entity/repository/mapper를 직접 import하지 않는다.

필수 인수 테스트

  • package graph cycle 0, 허용되지 않은 edge fixture 실패.
  • internal type 외부 접근 0.
  • public path snapshot이 의도한 API/SPI만 승인.
  • package 위치와 무관하게 bad controller/mapper/entity fixture 실패.

NTF-023 — readiness, metrics, audit가 실제 serving 상태를 반영하지 않는다

근거

  • health reporter가 queue를 빈 map으로 반환하고 actual oldest-due age/depth를 조회하지 않는다.
  • scheduler QUEUE_DEPTH는 전체 backlog가 아니라 이번 claim 수에 가깝다.
  • schema activation, provider route 0개, projection/reconciliation lag가 application readiness에 연결되지 않는다.
  • tag guard는 key allowlist 위주이고 raw category/callback path/provider 같은 값 cardinality를 닫지 않는다.
  • audit actorRef/attributes에 contact/credential/OTP가 들어가는 것을 타입/mandatory redactor가 막지 않는다.

구현 결정: serving readiness + low-cardinality vocabulary

enabled platform의 readiness는 최소 다음을 함께 확인한다.

  • notification schema capability ACTIVE
  • enabled profile마다 runtime+route+required callback/projector 존재
  • dispatch oldest due age/depth와 stuck lease 수
  • pending/failed/unmatched projection lag
  • reconciliation due/oldest age
  • secret/key generation load 상태

metric tag value는 enum/profile alias/bucket만 허용하고 tenant/recipient/category/path의 raw 값을 tag로 쓰지 않는다. audit builder는 sensitivity classifier/redactor를 반드시 통과하게 한다.

필수 인수 테스트

  • schema 누락, route 0, provider unavailable, queue/projection lag SLO 초과 시 readiness DOWN.
  • 임의 category/path 10,000개 입력 후 meter series 수가 상수 bound.
  • email/phone/token/OTP/credential이 모든 logger/audit sink에 없음.

NTF-024 — CI와 release gate가 실행한 것보다 강한 증거를 만든다

근거

  • support matrix는 SMTP/SES/Twilio/FCM/APNs/WebPush를 Stable로 표시한다.
  • PR workflow는 unit/architecture subset은 실행하지만 notification PostgreSQL migration, full configured context, multi-replica lease, restart recovery가 없다.
  • nightly job 이름은 ambiguity/restart recovery/callback burst지만 실제로는 일부 unit suite와 전체 test를 실행한다.
  • provider sandbox job은 continue-on-error이며 실제 provider 호출 없이 echo 두 줄만 실행한다.
  • release gate 일부는 docs file/문구 존재를 확인할 뿐 runtime artifact와 직접 연결하지 않는다.
  • workflow path filter가 bootstrap notification/configuration surface 변경을 완전하게 포괄하지 않는다.

구현 결정: evidence manifest

support grade마다 필요한 executable artifact를 선언한다.

주장 필요한 최소 증거
Durable PostgreSQL migration+CRUD+restart
Multi-worker safe real DB 2-worker claim/fencing race
Recoverable process kill phase matrix
Callback supported signature+burst+duplicate+late-bind E2E
Provider Stable secret-protected real sandbox wire+correlation artifact
  1. 위 artifact가 없으면 grade를 Contract implemented / runtime unqualified 또는 Experimental로 낮춘다.
  2. provider sandbox는 실제 test를 실행하고 immutable evidence artifact를 업로드한다.
  3. failOnNoDiscoveredTests, skip reason, artifact digest를 gate에 포함한다.
  4. workflow path에 application/bootstrap/settings/migration/docs 전체 notification surface를 포함한다.

NTF-025 — template의 configuration/env surface에 신규 platform이 없다

근거

  • application.yml과 local/sample 설정은 기존 ca-skeleton.notification/app.notification provider만 보여 주고 신규 ca-skeleton.notification.platform tree를 제공하지 않는다.
  • configuration reference에 property 이름은 있으나 실제 env placeholder와 env-key registry가 동기화되지 않았다.
  • Boot relaxed binding으로 직접 환경변수를 넣을 수는 있지만 템플릿 사용자가 필요한 profile/secret/ callback/schema activation 조합을 안전하게 발견할 수 없다.

개선

NTF-001/002/016 설계가 고정된 뒤 disabled-safe 기본 tree, 명시적 env placeholders, env validation registry, configuration reference를 한 source에서 동기화한다. 지금 미완성 property를 먼저 문서화해 enable을 유도하지 않는다.

NTF-026 — execution evidence certainty가 persistence에서 소실된다

근거

  • ProviderExecutionEvidence는 각 milestone을 EvidenceFact(value, certainty)로 표현한다.
  • docs도 requestStarted/bodyCommitted/responseReceived 각각의 certainty를 저장한다고 설명한다.
  • DispatchOutcomeRecorder.java:55-58.value() boolean만 DeliveryAttemptRecord에 넣고 certainty를 버린다.
  • V1 attempt table도 세 boolean만 저장하고 certainty/providerAcceptance fact를 저장하지 않는다.

실패 모드

restart 뒤 PROVEN falseUNKNOWN false를 구분할 수 없어 safe retry와 reconciliation 판단이 불가능해진다.

구현 결정

각 milestone을 value+certainty 컬럼으로 저장하거나 versioned evidence JSON을 저장한다. NTF-005 recovery state machine은 persisted certainty만 사용하고 추측하지 않는다.

필수 인수 테스트

  • UNKNOWN false와 PROVEN false가 서로 다른 DB representation으로 round-trip.
  • every ProviderExecutionEvidence combination의 entity/record round-trip.
  • restart 뒤 recovery decision이 원래 decision과 동일.

NTF-027 — reconciliation synthetic event fingerprint가 hash가 아니다

근거

  • ReconciliationService.java:126-135는 seed 문자열 bytes를 BigInteger hex로 바꾸고 앞 64자를 substring한다.
  • attempt UUID prefix가 seed 앞부분을 차지하므로 같은 attempt의 서로 다른 event type/native type이 같은 64-char prefix를 만들 수 있다.
  • ledger unique fingerprint에 걸려 later accepted/delivered 같은 별도 reconciliation event가 duplicate로 사라질 수 있다.

구현 결정

기존 MessageDigestPort를 사용해 provider profile, attempt ID, normalized event type, native type, providerOccurredAt, stable native identity를 versioned length-framed encoding한 뒤 SHA-256한다.

필수 인수 테스트

  • 같은 attempt의 accepted와 delivered는 다른 fingerprint.
  • 동일 event replay는 같은 fingerprint.
  • delimiter/Unicode/timezone representation collision 없음.

6. 권장 폴더 구조

아래 구조는 현재 19개 Gradle leaf를 유지한다. package 경계와 visibility를 먼저 강제하고, 실제 build-time 독립성이 필요해질 때만 registry/Gradle leaf를 늘린다.

6.1 application-core

dev.caskeleton.application.notification/
  api/                         # 의도한 public DTO/value만
  port/in/
    submission/
    query/
    cancellation/
    callback/
    reconciliation/
    admin/
  port/out/
    persistence/
    provider/
    security/
    observation/
  usecase/                     # package-private implementation
    submission/
    dispatch/
    callback/
    reconciliation/
    admin/
  model/
    request/
    delivery/
    event/
    routing/
    policy/
    template/
    contact/
  compatibility/r1/           # 임시 ACL만, 신규 기능 금지

원칙:

  • apiport만 public allowlist에 넣는다.
  • JPA/HTTP/provider SDK/Spring DTO는 들어오지 않는다.
  • use case 구현과 policy implementation은 package-private를 기본으로 한다.
  • submit/read/cancel/callback/admin을 한 orchestrator interface에 합치지 않는다.

6.2 adapter:outbound:notification

dev.caskeleton.adapter.outbound.notification.platform/
  configuration/              # ProviderRuntimeAssembler contribution만 노출
  runtime/
    dispatch/
    lease/
    lifecycle/
  provider/
    shared/http/
    apns/internal/
    fcm/internal/
    ses/internal/
    smtp/internal/
    twilio/internal/
    webpush/internal/
    webhook/internal/
  callback/
    ses/
    twilio/
  template/
  security/
  observation/

원칙:

  • provider package 외부에는 assembler/configuration facade와 application port 구현만 보인다.
  • business eligibility/authorization/idempotency는 application으로 이동한다.
  • 공통 HTTP/security code가 provider-specific rule을 삼키지 않는다.
  • provider capability는 8개 positional boolean보다 EnumSet<ProviderCapability> + typed limits를 선호한다.

6.3 adapter:outbound:persistence-jpa

dev.caskeleton.adapter.outbound.persistence.notification.platform/
  configuration/NotificationPersistenceAdapters
  request/
  delivery/
  attempt/
  event/
  contact/
  template/
  policy/
  admin/
  inbox/

원칙:

  • entity/repository/mapper는 package-private/internal.
  • bootstrap에는 NotificationPersistenceAdapters 한 facade만 노출한다.
  • claim은 새 JPA platform의 registered fixed statement executor를 재사용하되 notification fencing을 추가한다.
  • JSON/crypto schema mapping과 migration owner를 slice별 integration test로 고정한다.

6.4 adapter:inbound:web

notification/platform/callback/
  controller/                 # MVC
  servlet/
  reactive/
  shared/                     # request factory/canonical URL contract

MVC/WebFlux는 같은 application inbound port, provider/profile validation, canonical external URL contract, body size semantics를 사용한다. framework별 buffer/security configuration만 분리한다.

6.5 app-bootstrap

notification/
  NotificationPlatformConfiguration
  NotificationProviderGraphConfiguration
  NotificationDispatchRuntimeConfiguration
  NotificationCallbackRuntimeConfiguration
  NotificationObservationConfiguration
  NotificationSchemaActivationConfiguration

각 configuration은 100~200줄 이하를 목표로 하되 줄 수 자체를 gate로 만들지는 않는다. 핵심은 bootstrap이 entity/repository/mapper를 알지 않고, provider graph가 worker 시작 전에 완성·검증된다는 점이다.

7. 디자인 패턴 적용 판단

패턴은 이름을 붙이기 위해 도입하지 않고 현재 실패 경계를 닫는 데만 사용한다.

문제 적용할 패턴 이유 피할 것
provider별 조립 Abstract Factory / Contribution profile 하나의 adapter·runtime·callback graph를 원자적으로 구성 reflection/범용 plugin framework
dispatch 단계 Pipeline + typed Result pre-wire/committed/response milestone을 명시 모든 예외를 catch해 AMBIGUOUS 추측
lease/recovery State Machine + Fencing Token stale worker write를 데이터로 차단 owner 문자열만 사용
DB orchestration Unit of Work claim/attempt/ledger append 원자성 repository별 암묵 transaction 의존
callback Transactional Inbox + Projector 빠른 honest 2xx와 재생 가능성 HTTP thread에서 synchronous projection
provider error/route/template Strategy provider/slot별 정책 차이를 닫힌 계약으로 표현 거대한 if/switch god service
R1 migration Anti-Corruption Layer 두 모델의 임시 변환을 한곳에 격리 양방향 자유 import
runtime health Immutable State + transition table state/reason 일관성 enum마다 class를 만드는 과도한 State hierarchy
cross-cutting provider call Decorator metric/rate/circuit/auth를 순서대로 합성 adapter 내부 곳곳의 중복 try/catch

8. 구현 순서

Wave 0 — 사실성 및 release 차단

  1. platform default disabled 유지.
  2. support matrix를 Contract implemented / runtime unqualified로 정정.
  3. fake provider echo sandbox와 과장된 nightly 이름/문구 수정.
  4. R0/R1/platform canonical ADR 작성.

완료 기준: 문서/CI가 현재 executable evidence보다 강한 주장을 하지 않는다.

Wave 1 — boot graph와 schema

  1. typed provider settings + assembler/contribution.
  2. 실제 runtime/route/callback/projector/reconciliation registry 조립.
  3. notification schema stream/history/readiness activation.
  4. entity/migration/JSONB mapping 정합화.
  5. full application context fake-provider smoke.

완료 기준: enabled context가 실제 provider 한 건을 보내거나, 구성 불완전 시 boot fail한다.

Wave 2 — durable queue

  1. scheduled due-time state 수정.
  2. atomic CTE claim + lease generation/fencing.
  3. unique worker identity, capacity-aware claim.
  4. SmartLifecycle dispatch/recovery worker.
  5. PostgreSQL two-worker/restart/expiry tests.

완료 기준: duplicate provider call을 만드는 stale worker race가 DB 조건으로 차단된다.

Wave 3 — evidence와 ledger

  1. evidence certainty persistence.
  2. complete projection snapshot/version.
  3. transactional callback append + conflict-safe duplicate.
  4. late binding + pending/failed projection worker.
  5. durable reconciliation queue/worker + SHA-256 fingerprint.

완료 기준: out-of-order/restart/replay 후에도 ledger fold와 snapshot이 같고 side effect가 1회다.

Wave 4 — security와 provider correctness

  1. WebPush versioned contact codec/VAPID keyring.
  2. capability-aware secret keyring/startup validation.
  3. callback payload envelope/body bound.
  4. dynamic HTTP/SNS/response bound.
  5. template slot modes/deep-link allowlist.
  6. webhook/attachment/FCM/APNs wire contract.

완료 기준: security negative suite와 provider final-wire tests가 모두 통과한다.

Wave 5 — API와 architecture

  1. typed variables + canonical encoder/fingerprint.
  2. dedup/collapse/preference E2E.
  3. CQRS-style inbound use cases/capability marker.
  4. admin/routing policy application 이동.
  5. package 구조/internal visibility/ArchUnit/public snapshot.

완료 기준: dead public contract가 없고 새 consumer가 canonical API 하나만 사용한다.

Wave 6 — qualification

  1. real provider sandbox test와 correlation evidence.
  2. PostgreSQL performance/lease/callback burst/chaos/restart lane.
  3. readiness/metrics/audit cardinality/PII test.
  4. evidence manifest 충족 provider만 Stable 승격.

9. 권장 테스트 구조와 명령

9.1 새 테스트 source set/fixture

application-core:test
  - request canonicalization property tests
  - use-case/policy/state-machine unit tests

adapter:outbound:notification:test
  - provider final-wire contract
  - transport milestone/error classification
  - template/security/concurrency tests

adapter:outbound:persistence-jpa:notificationPostgresqlIntegrationTest
  - migration + Hibernate validate
  - CRUD/projection/ledger
  - claim/fencing/concurrent duplicate

app-bootstrap:test
  - full configured context
  - provider contribution/schema activation/readiness

notificationChaosTest
  - process kill/restart
  - response loss and callback burst

9.2 구현 중 focused 검증

cd src
./gradlew :application-core:test --console=plain
./gradlew :adapter:outbound:notification:test --console=plain
./gradlew :adapter:outbound:persistence-jpa:test --console=plain
./gradlew :adapter:inbound:web:test --console=plain
./gradlew :app-bootstrap:test --tests '*Notification*' --console=plain
./gradlew verifyCleanArchitectureDependencies verifyPublicPathSnapshot verifyEnvKeys --console=plain

9.3 release 전 필수 검증

cd src
./gradlew notificationPostgresqlIntegrationTest --console=plain
./gradlew notificationChaosTest --console=plain
./gradlew test --console=plain
./gradlew check --console=plain

task는 실제 source set을 만든 뒤 정확한 Gradle 이름으로 확정한다. 존재하지 않는 이름을 문서만 먼저 추가하지 않는다. 모든 lane은 failOnNoDiscoveredTests와 skip reason을 강제한다.

10. 완료 정의

다음 질문에 모두 코드·DB·실행 artifact로 “예”라고 답할 수 있을 때만 notification platform을 Stable로 판정한다.

  • enabled profile이 full application context에서 실제 runtime/route/transport로 조립되는가?
  • notification schema가 별도 history로 적용·승격되고 Hibernate validate를 통과하는가?
  • 예약 요청이 due 시 정확히 한 번 claim되는가?
  • 두 replica와 stale worker가 같은 notification을 provider에 중복 제출하지 못하는가?
  • crash 지점별 recovery가 safe retry와 ambiguity를 evidence로 구분하는가?
  • callback-before-outcome, duplicate, out-of-order, projector failure가 재생 가능한가?
  • engagement/suppression/evidence certainty가 restart 뒤 보존되는가?
  • WebPush/contact/callback ciphertext가 key rotation 뒤 복원되는가?
  • SSRF, oversized request/response, malicious SNS URL/topic/replay가 provider/ledger 전에 거부되는가?
  • fingerprint가 저장한 plan의 정확한 semantic bytes를 대표하는가?
  • dedup/collapse/preference/attachment/webhook 공개 계약이 final wire까지 적용되는가?
  • 모든 orchestration entrypoint가 repository의 UseCase/permission/architecture fitness gate를 통과하는가?
  • support matrix의 각 Stable 주장이 실제 실행 evidence artifact에 연결되는가?

하나라도 아니면 해당 기능은 Stable이 아니라 Experimental, Unwired, 또는 Contract-only로 표시한다.

11. 이번 리뷰에서 실행한 검증

모든 최종 성공 결과는 HEAD 539e3eb58bed5db63e3a17f47eec213db2d2df79에서 build 산출물을 재생성한 뒤 얻었다.

명령 결과 관측 범위
./gradlew :application-core:clean :application-core:test :application-core:jar :adapter:outbound:notification:clean :adapter:outbound:notification:test --rerun-tasks --console=plain BUILD SUCCESSFUL, 3m 16s application 98 suites/656 tests, notification 31 suites/176 tests, failure/skip 0
./gradlew :adapter:outbound:persistence-jpa:clean :adapter:outbound:persistence-jpa:test --rerun-tasks --console=plain BUILD SUCCESSFUL, 46s 78 suites/384 tests, failure/skip 0
./gradlew :adapter:inbound:web:clean :adapter:inbound:web:test --rerun-tasks --console=plain BUILD SUCCESSFUL, 44s 57 suites/333 tests, failure/skip 0
./gradlew :app-bootstrap:clean :app-bootstrap:test --tests '*NotificationAutoConfigurationTest' --tests '*NotificationArchitectureTest' --tests '*CleanArchitectureTest' verifyCleanArchitectureDependencies --rerun-tasks --console=plain BUILD SUCCESSFUL, 3m 25s 선택된 7 suites/83 tests와 module dependency gate, failure/skip 0
./gradlew verifyPublicPathSnapshot verifyEnvKeys --rerun-tasks --console=plain BUILD SUCCESSFUL, 1m 2s public path unchanged, env registry gate OK
git diff --no-index --check /dev/null docs/reviews/2026-08-14-notification-module-code-review.md 2>&1 | wc -c 0 untracked 신규 문서의 whitespace 오류 출력 없음

첫 통합 실행 ./gradlew :application-core:test :adapter:outbound:notification:test :adapter:outbound:persistence-jpa:test :adapter:inbound:web:test --rerun-tasks --console=plainapplication-core-0.0.1+539e3eb58bed.jar: zip END header not found 때문에 :adapter:outbound:persistence-jpa:compileTestJava에서 101개 연쇄 symbol error로 실패했다. 리뷰 중 병렬 빌드가 같은 공유 build output을 사용한 뒤 남은 손상으로 판단했고, 위 표처럼 대상 build directory를 clean한 뒤 모듈별 순차 실행하여 모두 fresh 통과했다. 이 최초 실패를 notification assertion 실패로 분류하지는 않지만, 공유 workspace에서 병렬 Gradle build output을 격리해야 한다는 도구 운영상 주의점은 남는다.

다음 검증은 실행하지 못한 것이 아니라 현재 repository에 해당 executable test lane이 존재하지 않아 검증할 수 없었다.

  • notification 전용 PostgreSQL migration/Hibernate validate/CRUD integration task
  • 두 process/connection을 사용한 notification lease fencing task
  • process kill/restart recovery 및 callback burst chaos task
  • secret-protected real provider sandbox test

일반 unit/contract/ArchUnit 통과는 위 네 운영 계약을 대신하지 않는다. NTF-024의 핵심은 이 공백을 release evidence에 정직하게 반영하고 실제 lane으로 채우는 것이다.

11.1 최종 작업공간 상태 주의

위 검증을 마친 뒤, 이 리뷰 작업이 만들지 않은 messaging 모듈 병합 변경이 같은 worktree에 추가됐고 src/config/spotbugs/exclude.xml은 현재 unmerged(UU) 상태가 됐다. 최종 대조 시 Git HEAD는 여전히 539e3eb58bed5db63e3a17f47eec213db2d2df79였으며, notification 검토 대상 경로에는 HEAD 대비 staged 또는 unstaged 변경이 없었다. 따라서 notification source finding은 유지되지만, 다음 두 범위는 구분해야 한다.

  • 위 표의 성공 결과는 messaging 병합 충돌이 나타나기 전, HEAD 539e3eb의 notification 관련 graph에서 얻은 fresh evidence다.
  • 현재 충돌이 남은 worktree 전체가 build/check를 통과한다는 뜻은 아니다. 충돌을 사용자 작업에서 해소한 뒤 verifyCleanArchitectureDependencies, notification focused test, 전체 check를 다시 실행해야 한다.

또한 이 문서의 “19개 leaf” 판단은 committed HEAD와 이 저장소의 현재 AGENTS.md 정책을 기준으로 한다. 진행 중인 messaging 병합이 modules.json에 다수 leaf를 추가하므로, 그 변경이 최종 정책이라면 AGENTS.md의 정확히 19개 leaf 계약과 registry/settings/build 검증을 함께 개정·승인해야 한다. 이번 notification read-only 리뷰에서는 그 별도 병합이나 충돌을 수정하지 않았다.