[BE-24] 크로스 소스 중복 판정 · 대표 공고 선정 배치

작업 내용 (설계 의도)

근거 TDD: 20260722-타깃-공고-알림-및-지원-히스토리-tdd.md — “방안 9”, “크로스 소스 대표 선정 상태 전이”

변경 사항

애그리게이터 편입에서 사고 위험이 가장 높은 티켓입니다. 같은 공고가 회사 직접 소스(Greenhouse)와 애그리게이터(사람인·점핏)에 동시 존재하면, 식별 키 (job_source_id, source_job_id)로는 별개 저장돼 알림이 2회 갑니다(FR-63·64·65).

핵심 설계 의도 — dedup을 수집에서 분리한 별도 배치로 둡니다:

  1. 수집 순서 무관(방안 9) — 회사 직접(00:00)과 애그리게이터(00:10)의 수집 시각·순서가 달라, 인라인 판정하면 먼저 저장된 쪽을 대표로 잘못 뽑습니다. 08:30에 하루치 전체를 보고 대표를 선정하면 순서에 무관합니다.
  2. 그룹핑dedup_key(BE-02 엔티티가 저장 시 계산한 정규화 회사명+제목)가 같은 공고들을 그룹으로 묶습니다. 마감일이 30일 이상 차이나면 다른 공고로 분리합니다(FR-63 보조 근거 — 회사·제목이 같아도 채용 회차가 다른 경우).
  3. 대표 선정(FR-64) — 3단 tie-break: ① 회사 직접 > 애그리게이터 → ② OPEN > CLOSED → ③ 가장 이른 firstSeenAt. 대표는 representativeId=NULL, 비대표는 linkTo(대표id) + notificationEligible=0.
    • CLOSED 공고도 dedup 그룹에 포함합니다(DBA Open Q 판단) — 그룹에서 빼면 상세 alternateSources[] 히스토리가 깨지고 재오픈(FR-16) 시 그룹을 못 찾습니다. 대신 tie-break ②로 OPEN을 대표로 올려 사용자가 유효한 지원 경로를 보게 하고, 그룹 전체가 CLOSED면 그중 1건이 대표로 남아 지원 이력 참조(NFR-5)를 보존합니다.
  4. 대표 재선정 — 애그리게이터본이 먼저 대표였다가 회사 직접본이 나중에 등장하면 대표를 재선정하고 기존 대표를 강등합니다(자기참조 재지정 1회).
  5. 알림 억제(FR-65) — 비대표를 notificationEligible=0으로 눌러 알림은 대표만 1회. 대표가 이미 발송된 뒤 대표가 바뀌어도, 발송 배치(BE-16)가 대표 공고 기준 발송 이력으로 skip해 추가 알림이 없습니다.
  6. 피처 플래그 posting.cross-source-dedup이 false면 그룹핑을 하지 않고 전부 대표로 둡니다 — 오판정(다른 공고를 묶어 알림 누락) 시 재기동 없이 즉시 중단하는 수단입니다.
  7. 멱등 재실행 — 매번 dedup_key 전체를 재그룹핑·대표 재선정하므로 여러 번 실행해도 안전합니다.

이 티켓은 새 파일만 추가합니다domain/posting/JobPostingDeduplicationDomainService, application/posting/DeduplicateJobPostingsUseCase, presentation/posting/scheduler/JobPostingDeduplicationScheduler(cron 0 30 8 * * *). BE-02가 제공한 JobPosting의 dedup 원시 기능(computeDedupKey·linkTo·markRepresentative)과 JobPostingRepositorydedup_key 조회를 사용합니다. BE-10(회사 종속형 수집)·BE-25(애그리게이터 수집)와 파일이 겹치지 않습니다.

롤백: 피처 플래그 OFF. 오판정 시 representative_id를 NULL로 되돌리면 복구(소프트).

의존

  • BE-02 (JobPosting dedup 원시 기능·Repository)

다이어그램

처리 흐름

sequenceDiagram
    participant S as DeduplicationScheduler
    participant U as DeduplicateJobPostingsUseCase
    participant D as DeduplicationDomainService
    participant F as FeatureFlagGateway
    participant R as JobPostingRepository
    S->>U: execute()
    U->>D: deduplicate()
    D->>F: isEnabled("posting.cross-source-dedup")
    alt 플래그 OFF
        F-->>D: false → 전부 대표 취급 후 종료
    else 플래그 ON
        D->>R: dedup_key 별 그룹 조회
        loop 그룹별 (마감일 30일 초과 차이는 분리)
            D->>D: 대표 선정 (직접 > 애그리게이터, 동률 firstSeenAt)
            D->>R: 대표 representativeId=NULL / 비대표 linkTo + notificationEligible=0
        end
    end
    D-->>U: 대표·중복 건수

클래스 의존

flowchart LR
    subgraph Presentation["presentation/posting"]
        Sched[JobPostingDeduplicationScheduler]
    end
    subgraph Application["application/posting"]
        UC[DeduplicateJobPostingsUseCase]
    end
    subgraph Domain["domain/posting"]
        DS[JobPostingDeduplicationDomainService]
        Posting[JobPosting]
        Repo[JobPostingRepository]
        Flag[FeatureFlagGateway]
    end
    Sched --> UC
    UC --> DS
    DS --> Posting
    DS --> Repo
    DS --> Flag

테스트 케이스

  • 같은 dedup_key 그룹에 회사 직접 1 + 애그리게이터 2가 있으면 직접이 대표, 나머지 2건이 비대표로 링크된다
  • 비대표는 notificationEligible=0으로 눌려 알림 대상에서 제외된다
  • 애그리게이터만 3건인 그룹은 가장 이른 firstSeenAt 1건이 대표가 된다
  • 같은 sourceType에서 OPEN 1 + CLOSED N이면 OPEN이 대표가 된다 (tie-break ②)
  • 그룹 전체가 CLOSED면 그룹에서 빠지지 않고 1건이 대표로 남는다(히스토리 보존)
  • CLOSED 공고도 dedup 그룹에 포함되어 alternateSources[]에 노출된다
  • 애그리게이터가 대표였는데 회사 직접본이 등장하면 대표가 재선정되고 기존 대표가 강등된다
  • 같은 dedup_key라도 마감일이 30일 이상 차이나면 별개 그룹으로 분리된다
  • dedup_key가 유일한(단독) 공고는 자기 자신이 대표(representativeId=NULL)로 유지된다
  • 피처 플래그 OFF면 그룹핑하지 않고 전부 대표로 취급한다
  • 배치를 두 번 실행해도 결과가 동일하다(멱등)
  • 대표 재선정 후에도 이미 발송된 알림에 대해 추가 발송 대상이 생기지 않는다
  • 수동 등록·접근 제한 공고는 dedup 그룹핑 대상에서 제외된다