[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_at7일 이내)를 조회, ② 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_DIGEST1건. 발견 회사 공고가 하루 수백 건이어도 디스코드 알림은 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 성공 발송 이력 없음 ANDfirstSeenAt7일 이내(만료). 근무형태 확신도는 알림 조건에 관여하지 않습니다(FR-30). - 소스 고장 알림 조건:
JobSourceHealth.brokenSince != nullAND 현재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_DIGEST1건으로 묶여 발송된다 - 같은 날 두 번째 발송 실행 시
DAILY_DIGEST는 일자 멱등 키로 skip된다 - 비대표(representative_id 있음) 공고는 발송 대상에서 제외된다
- 발견 회사 매칭 공고가 0건이면
DAILY_DIGEST를 발송하지 않는다