[BE-16] 09:00 알림 발송 배치 — 대상 산출 · 멱등 · 재발송

작업 내용 (설계 의도)

근거 TDD: 20260722-타깃-공고-알림-및-지원-히스토리-tdd.md — “알림 실패 경로·멱등”, “Sequence Diagram 09:00 알림 발송”

변경 사항

수집(자정)과 발송 시각을 분리해(FR-37) 09:00 KST에 알림을 발송합니다. P0 알림 종류는 신규 공고소스 고장 2종입니다(FR-35). 마감 알림은 발송하지 않습니다(FR-36 — 의도적 제외).

핵심 설계 의도:

  • 발송 대상은 pull 방식으로 산출합니다. 각 컨텍스트가 알림 큐에 push하는 대신, 발송 배치가 “조건을 만족하고 성공 발송 이력이 없는 대상”을 조회합니다. 이 구조라야 3회 재시도 실패 후 다음 배치가 자연스럽게 재발송합니다(시나리오 7-3). push 방식이면 재시도 큐를 따로 만들어야 합니다.
  • 크로스 컨텍스트 조합은 application 레이어에서 합니다 (no-crosscontext-raw-read). notification 발송 대상 산출은 posting·matching·company 여러 컨텍스트의 데이터를 필요로 하므로, DispatchPendingNotificationsUseCase(application)가 각 소유 컨텍스트의 DomainService를 호출해 조합합니다. notification 컨텍스트의 infrastructure가 posting·matching 테이블을 로우 쿼리(JdbcTemplate·네이티브 SQL)로 직접 읽는 read model(NotificationTargetRepositoryImpl)을 만들지 않습니다 — 그건 스키마 결합을 남기는 금지 패턴입니다. 흐름: ① posting DomainService가 자기 테이블에서 신규 공고 후보(대표·notification_eligible=true·first_seen_at 7일 이내)를 조회, ② matching DomainService가 그중 매칭 성공(keywordMatched) 공고 ID를 판정, ③ company DomainService가 companyOrigin으로 회사를 분류, ④ notification DomainService가 성공 발송 이력(멱등) 없는 대상만 남김, ⑤ application이 이 조각들을 조합해 notification 값 객체(NewJobPostingTarget/BrokenJobSourceTarget)로 매핑해 발송. notification DomainService 시그니처에 posting·matching·company 도메인 타입을 두지 않습니다(값 객체만 입력).
  • 관심 회사 vs 발견 회사 분기 (FR-62): 신규 공고 대상을 회사의 companyOrigin으로 가릅니다 — WATCHED 회사는 개별 NEW_JOB_POSTING, DISCOVERED 회사는 그날 매칭 신규를 묶어 DAILY_DIGEST 1건. 발견 회사 공고가 하루 수백 건이어도 디스코드 알림은 1건입니다. 이 분기(companyOrigin 판정 + digest 묶음)도 application UseCase가 company DomainService 결과로 수행합니다 — infra read model이 아니라.
  • 대표 공고만 알림 (FR-64·65): 발송 대상은 representative_id IS NULL(대표)인 공고만입니다. dedup 배치(BE-24)가 비대표를 notification_eligible=0으로 눌렀으므로 이중으로 방어됩니다. 대표가 이미 발송됐고 다른 소스의 같은 공고가 뒤늦게 발견돼도 알림이 가지 않습니다.
  • 신규 공고 알림 조건: notificationEligible=true(시딩·비대표 제외) AND 매칭 성공(FR-25 — 알림 조건은 직무 키워드 단독) AND 성공 발송 이력 없음 AND firstSeenAt 7일 이내(만료). 근무형태 확신도는 알림 조건에 관여하지 않습니다(FR-30).
  • 소스 고장 알림 조건: JobSourceHealth.brokenSince != null AND 현재 failureEpisode에 대한 성공 발송 이력 없음. 복구 후 재고장하면 episode가 증가해 새 멱등 키가 되어 재알림됩니다. 알림 본문에는 소스 가드로 마감 판정이 제외된 상태임을 함께 표기합니다(Operations).
  • 멱등은 성공 기준입니다 — 발송 전 findSucceededBy(key)로 확인하고, 성공 시에만 idempotency_key를 채웁니다. DuplicateKeyException은 “이미 발송됨”의 정상 시나리오로 흡수합니다.
  • 발송 회차: P0 두 종류 모두 회차 1(소스 고장은 episode를 회차로 사용)입니다. P1의 7일 반복 리마인드가 회차를 증가시키며 들어올 수 있도록 키 구조를 유지합니다(FR-38).
  • 피처 플래그 notification.discord-dispatch가 false면 대상 산출까지만 하고 발송하지 않습니다 — 알림 폭주 시 재기동 없이 즉시 중단하는 수단입니다.
  • 알림 본문은 공고 제목·회사명·마감일(없으면 “상시채용”)·링크를 포함합니다.

범위: DispatchPendingNotificationsUseCase(application — 크로스 컨텍스트 조합), NotificationDispatchDomainService, 발송 대상 조회는 각 소유 컨텍스트(posting·matching·company)의 DomainService에 자기 테이블 조회 메서드를 추가(없으면), NotificationDispatchScheduler(cron 0 0 9 * * *). notification infra는 발송 이력(멱등)·발송 자체만 담당하고 타 컨텍스트 read model을 두지 않습니다.

롤백: 피처 플래그 OFF로 발송 즉시 중단.

의존

  • BE-05 (발송 도메인·웹훅 Gateway)

다이어그램

처리 흐름

sequenceDiagram
    participant S as NotificationScheduler
    participant U as DispatchPendingNotificationsUseCase
    participant D as NotificationDispatchDomainService
    participant T as NotificationTargetRepository
    participant N as NotificationDispatchRepository
    participant W as DiscordWebhookGateway
    S->>U: execute()
    U->>D: dispatchPending()
    D->>T: findNewJobPostingTargets / findBrokenJobSourceTargets
    T-->>D: 대상 목록 (성공 이력 없는 건만)
    loop 대상별
        D->>N: findSucceededBy(idempotencyKey)
        alt 이미 성공
            D->>D: skip
        else
            D->>W: send(message)
            alt Delivered
                D->>N: save(멱등 키 부여, SENT)
            else Rejected (3회 실패)
                D->>N: save(키 NULL, FAILED, last_error)
            end
        end
    end

클래스 의존

flowchart LR
    subgraph Presentation["presentation/notification"]
        Sched[NotificationDispatchScheduler]
    end
    subgraph Application["application/notification"]
        UC[DispatchPendingNotificationsUseCase]
    end
    subgraph Domain["domain/notification"]
        DS[NotificationDispatchDomainService]
        Target[NotificationTargetRepository]
        Repo[NotificationDispatchRepository]
        GW[DiscordWebhookGateway]
        Flag[FeatureFlagGateway]
    end
    subgraph Infra["infrastructure/notification"]
        TImpl[NotificationTargetRepositoryImpl]
    end
    Sched --> UC
    UC --> DS
    DS --> Target
    DS --> Repo
    DS --> GW
    DS --> Flag
    TImpl -.->|implements| Target

테스트 케이스

  • 매칭 성공한 신규 공고가 발송 대상으로 산출되어 디스코드로 발송된다
  • 시딩 회차 공고(notificationEligible=false)는 대상에서 제외된다
  • 매칭 실패 공고는 저장돼 있어도 발송 대상이 아니다
  • 재오픈된 공고는 신규 공고 알림 대상이 아니다
  • 근무형태 확신도가 UNKNOWN이어도 매칭됐다면 발송 대상이다 (알림 조건에 관여하지 않음)
  • 같은 공고에 대해 두 번째 실행은 성공 이력이 있어 skip된다 (중복 발송 0건)
  • 웹훅 3회 실패 후 다음 배치 실행에서 같은 대상이 재발송된다
  • 재발송이 성공하면 그때 멱등 키가 채워진다
  • 발견 후 7일이 지난 신규 공고는 만료되어 대상에서 제외된다
  • 3일 연속 비정상으로 고장 진입한 소스에 대해 고장 알림이 1건 발송된다
  • 같은 고장 에피소드에서 두 번째 실행은 skip된다
  • 복구 후 재고장하면 에피소드가 증가해 알림이 다시 발송된다
  • 소스 고장 알림 본문에 마감 판정이 제외된 상태임이 표기된다
  • 마감된 공고에 대한 마감 알림은 어떤 경우에도 발송되지 않는다 (FR-36)
  • 피처 플래그 OFF면 발송이 0건이고 시도 레코드도 생성되지 않는다
  • 발송 성공 레코드는 dispatch_status='SENT'와 멱등 키를 항상 함께 가진다(키 없는 SENT가 생기지 않는다)
  • 실패 레코드는 dispatch_status='FAILED'이고 멱등 키가 NULL이다
  • 상시채용 공고(마감일 null)의 알림 본문에 “상시채용”이 표기된다
  • 관심 회사(WATCHED)의 매칭 신규 공고는 개별 NEW_JOB_POSTING으로 발송된다
  • 발견 회사(DISCOVERED)의 매칭 신규 3건은 개별이 아니라 DAILY_DIGEST 1건으로 묶여 발송된다
  • 같은 날 두 번째 발송 실행 시 DAILY_DIGEST는 일자 멱등 키로 skip된다
  • 비대표(representative_id 있음) 공고는 발송 대상에서 제외된다
  • 발견 회사 매칭 공고가 0건이면 DAILY_DIGEST를 발송하지 않는다