[BE-24] 크로스 소스 중복 판정 · 대표 공고 선정 배치
작업 내용 (설계 의도)
근거 TDD: 20260722-타깃-공고-알림-및-지원-히스토리-tdd.md — “방안 9”, “크로스 소스 대표 선정 상태 전이”
변경 사항
애그리게이터 편입에서 사고 위험이 가장 높은 티켓입니다. 같은 공고가 회사 직접 소스(Greenhouse)와 애그리게이터(사람인·점핏)에 동시 존재하면, 식별 키 (job_source_id, source_job_id)로는 별개 저장돼 알림이 2회 갑니다(FR-63·64·65).
핵심 설계 의도 — dedup을 수집에서 분리한 별도 배치로 둡니다:
- 수집 순서 무관(방안 9) — 회사 직접(00:00)과 애그리게이터(00:10)의 수집 시각·순서가 달라, 인라인 판정하면 먼저 저장된 쪽을 대표로 잘못 뽑습니다. 08:30에 하루치 전체를 보고 대표를 선정하면 순서에 무관합니다.
- 그룹핑 —
dedup_key(BE-02 엔티티가 저장 시 계산한 정규화 회사명+제목)가 같은 공고들을 그룹으로 묶습니다. 마감일이 30일 이상 차이나면 다른 공고로 분리합니다(FR-63 보조 근거 — 회사·제목이 같아도 채용 회차가 다른 경우). - 대표 선정(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)를 보존합니다.
- CLOSED 공고도 dedup 그룹에 포함합니다(DBA Open Q 판단) — 그룹에서 빼면 상세
- 대표 재선정 — 애그리게이터본이 먼저 대표였다가 회사 직접본이 나중에 등장하면 대표를 재선정하고 기존 대표를 강등합니다(자기참조 재지정 1회).
- 알림 억제(FR-65) — 비대표를
notificationEligible=0으로 눌러 알림은 대표만 1회. 대표가 이미 발송된 뒤 대표가 바뀌어도, 발송 배치(BE-16)가 대표 공고 기준 발송 이력으로 skip해 추가 알림이 없습니다. - 피처 플래그
posting.cross-source-dedup이 false면 그룹핑을 하지 않고 전부 대표로 둡니다 — 오판정(다른 공고를 묶어 알림 누락) 시 재기동 없이 즉시 중단하는 수단입니다. - 멱등 재실행 — 매번
dedup_key전체를 재그룹핑·대표 재선정하므로 여러 번 실행해도 안전합니다.
이 티켓은 새 파일만 추가합니다 — domain/posting/JobPostingDeduplicationDomainService, application/posting/DeduplicateJobPostingsUseCase, presentation/posting/scheduler/JobPostingDeduplicationScheduler(cron 0 30 8 * * *). BE-02가 제공한 JobPosting의 dedup 원시 기능(computeDedupKey·linkTo·markRepresentative)과 JobPostingRepository의 dedup_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건인 그룹은 가장 이른
firstSeenAt1건이 대표가 된다 - 같은 sourceType에서 OPEN 1 + CLOSED N이면 OPEN이 대표가 된다 (tie-break ②)
- 그룹 전체가 CLOSED면 그룹에서 빠지지 않고 1건이 대표로 남는다(히스토리 보존)
- CLOSED 공고도 dedup 그룹에 포함되어
alternateSources[]에 노출된다 - 애그리게이터가 대표였는데 회사 직접본이 등장하면 대표가 재선정되고 기존 대표가 강등된다
- 같은 dedup_key라도 마감일이 30일 이상 차이나면 별개 그룹으로 분리된다
- dedup_key가 유일한(단독) 공고는 자기 자신이 대표(
representativeId=NULL)로 유지된다 - 피처 플래그 OFF면 그룹핑하지 않고 전부 대표로 취급한다
- 배치를 두 번 실행해도 결과가 동일하다(멱등)
- 대표 재선정 후에도 이미 발송된 알림에 대해 추가 발송 대상이 생기지 않는다
- 수동 등록·접근 제한 공고는 dedup 그룹핑 대상에서 제외된다