[BE-05] notification 도메인 모델 · 디스코드 웹훅 Gateway

작업 내용 (설계 의도)

근거 TDD: 20260722-타깃-공고-알림-및-지원-히스토리-tdd.md — “방안 4 알림 멱등”, “알림 실패 경로·멱등”

변경 사항

알림의 멱등·재발송·실패 이력이라는 고유 관심사를 한곳에 모읍니다. 알림 종류가 공고(신규)와 소스(고장) 양쪽에서 발생하므로, 각 컨텍스트에 흩어두면 멱등 규칙이 복제됩니다.

핵심 설계 의도 — 멱등 기준은 “발송 성공”입니다:

  • 멱등 키는 {targetType}:{targetId}:{notificationType}:{dispatchSequence} 4요소 조합입니다(FR-38). (공고ID + 종류)만 쓰면 P1의 7일 반복 리마인드가 2회차부터 영구 차단되고, 그때 유니크 제약을 바꾸는 파괴적 마이그레이션이 필요합니다.
  • idempotency_key 컬럼은 nullable이며 웹훅이 2xx를 반환한 시점에만 채웁니다. MySQL 유니크 인덱스가 NULL 중복을 허용하므로 실패 레코드는 여러 건 공존하고 성공 레코드는 대상당 1건만 존재합니다. 이 구조가 ① 중복 발송 0건 ② 실패 이력 보존·조회(Operations) ③ 재발송 가능(시나리오 7-3)을 동시에 만족하는 유일한 조합입니다.
  • 시도 기준으로 키를 부여하면 실패 레코드가 키를 점유해 재발송이 영구 차단됩니다 — 명시적으로 배제한 설계입니다.
  • 불변식(DBA 지적): dispatch_status='SENT'인데 idempotency_key가 NULL인 레코드를 만들지 않습니다. 멱등 판정(findSucceededBy)이 키로만 조회하므로 키 없는 성공 레코드는 곧 중복 발송입니다. markDelivered()가 상태 전이와 키 부여를 한 메서드에서 함께 수행해 도메인이 이 불변식을 강제하고, 저장 전 require 검증을 둡니다. 반대로 FAILED는 키가 항상 NULL이어야 합니다.
  • 만료 규칙: 신규 공고 알림은 firstSeenAt 기준 7일, 소스 고장 알림은 brokenSince 기준 7일이 지나면 대상에서 제외해 무한 재발송을 막습니다(NotificationDispatch.isExpired()).
  • NotificationType은 P0 3종(NEW_JOB_POSTING, SOURCE_FAILURE, DAILY_DIGEST)을 정의하되, P1 확장(DEADLINE_D1, PERMANENT_REMINDER)이 enum 값 추가만으로 가능하도록 설계합니다. 마감 알림은 만들지 않습니다(FR-36 — 의도적 제외).
  • 일일 요약(DAILY_DIGEST, FR-62): 발견 회사(DISCOVERED)의 매칭 신규 공고가 하루 수백 건이 될 수 있어 개별 알림 대신 하루 1건으로 묶습니다. 멱등 키는 DAILY_DIGEST:0:DAILY_DIGEST:{KST일자ordinal}target_id=0, dispatch_sequence에 KST 업무 일자를 정수로 넣어 하루 1건을 강제합니다. 발송 대상 산출·본문 구성은 BE-16이 담당하고, 이 티켓은 타입·멱등 키 규칙·요약 메시지 값 객체만 정의합니다.
  • DiscordWebhookGateway(domain interface) 구현체는 지수 백오프 3회(1s → 2s → 4s)를 내부에서 수행하고, 429Retry-After 헤더를 우선합니다(FR-41). RestClient는 GatewayImpl에만 주입되어 domain·application에 노출되지 않습니다.
  • 발송 결과는 WebhookDeliveryResult sealed(Delivered / Rejected(statusCode, reason, attemptCount))로 반환해 호출부가 상태 코드를 직접 다루지 않게 합니다.

롤백: 발송을 즉시 멈춰야 하면 피처 플래그 notification.discord-dispatch를 false로 UPDATE합니다(재기동 불필요).

범위: 도메인 POJO + 멱등 키 값 객체 + Repository interface + DiscordWebhookGateway interface/구현 + JPA 엔티티/RepositoryImpl(Testcontainers) + MockWebServer 기반 백오프 테스트. 발송 대상 산출(read model)과 스케줄러는 BE-16에서 처리합니다.

의존

  • BE-01

다이어그램

처리 흐름

sequenceDiagram
    participant C as 호출부
    participant N as NotificationDispatch
    participant R as NotificationDispatchRepository
    participant G as DiscordWebhookGateway
    participant D as Discord
    C->>R: findSucceededBy(idempotencyKey)
    alt 성공 이력 있음
        R-->>C: 존재 → skip
    else 없음
        C->>N: attempt(target)
        C->>G: send(message)
        G->>D: POST webhook (1회차)
        D-->>G: 429 Retry-After
        G->>D: POST webhook (백오프 후 재시도)
        D-->>G: 204
        G-->>C: Delivered
        C->>N: markDelivered() — 멱등 키 부여
        C->>R: save(dispatch)
    end

클래스 의존

flowchart LR
    subgraph Domain["domain/notification"]
        Dispatch[NotificationDispatch]
        Type[NotificationType]
        Key[IdempotencyKey]
        Msg[NotificationMessage]
        DResult[WebhookDeliveryResult]
        GW[DiscordWebhookGateway]
        Repo[NotificationDispatchRepository]
    end
    subgraph Infra["infrastructure/notification"]
        GWImpl[DiscordWebhookGatewayImpl]
        Client[DiscordWebhookClient]
        RepoImpl[NotificationDispatchRepositoryImpl]
    end
    Dispatch --> Type
    Dispatch --> Key
    GW --> Msg
    GW --> DResult
    GWImpl -.->|implements| GW
    GWImpl --> Client
    RepoImpl -.->|implements| Repo

테스트 케이스

  • 멱등 키가 JOB_POSTING:1024:NEW_JOB_POSTING:1 형식으로 생성된다
  • 발송 성공 시에만 idempotency_key가 채워지고, 실패 시에는 NULL로 유지된다
  • SENT 상태인데 멱등 키가 없는 객체를 만들려 하면 도메인이 거부한다
  • FAILED 상태에 멱등 키를 부여하려 하면 도메인이 거부한다
  • 같은 멱등 키로 두 번째 성공 저장을 시도하면 유니크 제약 위반이 발생한다
  • 같은 대상에 대해 실패 레코드는 여러 건 저장된다(NULL 중복 허용)
  • findSucceededBy(key)가 실패 레코드는 반환하지 않고 성공 레코드만 반환한다
  • 웹훅이 500을 3회 반환하면 Rejected(attemptCount=3)를 반환하고 예외를 던지지 않는다
  • 웹훅이 429 + Retry-After: 2를 반환하면 해당 시간만큼 대기 후 재시도한다
  • 웹훅이 첫 시도에 204를 반환하면 재시도 없이 Delivered를 반환한다
  • 4xx(429 제외) 응답은 재시도하지 않고 즉시 Rejected를 반환한다
  • 신규 공고 알림 대상이 발견 후 7일이 지나면 isExpired()가 true를 반환한다
  • 같은 소스가 복구 후 재고장하면 발송 회차가 증가해 새로운 멱등 키가 생성된다
  • DAILY_DIGEST 멱등 키가 KST 일자 기반으로 생성되어 같은 날 두 번째 발송이 유니크 제약으로 거부된다
  • 날짜가 바뀌면 DAILY_DIGEST 멱등 키가 달라져 다음 날 요약이 발송 가능하다