[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)를 내부에서 수행하고,429는Retry-After헤더를 우선합니다(FR-41).RestClient는 GatewayImpl에만 주입되어 domain·application에 노출되지 않습니다.- 발송 결과는
WebhookDeliveryResultsealed(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멱등 키가 달라져 다음 날 요약이 발송 가능하다