[BE-48] 중복 그룹 영속화·정체성 승계 · MANUAL dedup_key · tie-break 5단 · platform 컬럼 (FR-74)
작업 내용 (설계 의도)
근거 TDD: 20260808-지원관리-확장-tdd.md — “방안 3 관리 상태의 귀속 단위”, “방안 4 MANUAL 공고의 dedup 후보 편입”, “방안 7 플랫폼 OR 매칭”
변경 사항
단계 1에서 사고 위험이 가장 높은 티켓입니다. 관리 상태(FR-73~78)가 붙을 대상 자체를 만들고, 기존 dedup 배치의 동작을 바꿉니다.
- 그룹 정체성 문제 — 현재
deduplicate()는 매 실행findAllWithDedupKey()전량을 재그룹핑하고 그룹은 메모리에만 존재합니다(JobPostingDeduplicationDomainService.kt:37-44). 마감일 클러스터는 연쇄 클러스터링이라(:90-107) 멤버가 늘면 경계가 이동해 자연 키를 만들 수 없습니다. →job_posting_dedup_groups를 영속화하고, 새 클러스터마다 같은dedup_key의 기존 그룹 중 멤버 교집합이 가장 큰 그룹을 재사용합니다. 교집합 0이면 새 그룹입니다. 교집합 동률이면 그룹 id 오름차순(결정성).- 마감일 30일 이상 벌어진 재공고 → 교집합 0 → 새 그룹 → 관리 상태 미승계(FR-74 A-1)
- 같은 그룹에 다른 플랫폼 공고 합류 → 교집합 > 0 → 그룹 유지 → 관리 상태 보존(FR-74 B-2)
- MANUAL dedup 편입 (검수 미결 #2 해소) —
JobPosting.createManual()이dedupKey=null이고(JobPosting.kt:279), 후보 조회가isDeltaTarget()으로 MANUAL을 또 거릅니다(:37,JobPosting.kt:153). → 후보 선정 필터와 델타 판정 필터를 분리합니다.isDeltaTarget()은 이름·의미 그대로 수집 델타 경로에만 남기고, dedup 후보는findAllWithDedupKey()전량(접근 제한 포함 — B-25 tie-break ①이 의미를 가지려면 그룹에 포함돼야 합니다)으로 바꿉니다.createManual()은companyName을 받아dedupKey를 계산합니다.RegisterManualJobPostingUseCase가 이미companyDomainService.getBy()를 호출하므로(RegisterManualJobPostingUseCase.kt:22) 추가 조회가 없습니다. - tie-break 5단 — ① 접근 가능 > 접근 제한 ②
COMPANY_BOUND·MANUAL동급 >AGGREGATOR③ OPEN > CLOSED ④ 이른firstSeenAt⑤ PK.sourcePriority()의requireNotNull(jobSourceId)(:79)를 제거하고origin을 먼저 분기해야 MANUAL이 들어와도 터지지 않습니다. job_postings.platform컬럼 — FR-78의 플랫폼 OR 매칭은job_sources(company 컨텍스트) 조인이 필요한데no-crosscontext-raw-read위반입니다.platform은 “이 공고가 어디서 왔는가”라는 posting 자기 사실이므로 자기 컬럼으로 보유합니다(JobPlatform은domain.common소속이라 교차 참조가 아닙니다). MANUAL은 NULL입니다.- 승계되지 않은 그룹은 삭제하지 않고
member_count=0으로 남깁니다 — 관리 상태·이력을 보존하고, 같은 공고가 다시 나타나면 교집합으로 복귀합니다. - 피처 플래그
posting.dedup-group-identity가 OFF면 그룹을 영속화하지 않고 기존 동작(대표 선정만)을 유지합니다. - 500그룹 단위 커밋 (B-3 — dba 실측). 플래그를 켠 뒤의 첫 배치는 백필급 규모입니다 — 실측
job_posting_dedup_groups6,311 INSERT +job_postings6,633 UPDATE. 현재 구현은 전체를 계산한 뒤saveAll(changed)를 한 번 호출하므로(JobPostingDeduplicationDomainService.kt:67) 단일 트랜잭션이면job_postings거의 전 행에 락이 걸려 사용자 조회·수동 등록이 대기합니다.- 그룹 단위로 끊습니다 — 공고 단위로 끊으면 한 그룹의 대표·비대표가 다른 트랜잭션에 걸쳐 “대표가 둘”인 중간 상태가 외부에 보입니다.
- 청크 경계에서 중단돼도 전량 재그룹핑이 멱등이라 재실행이 안전합니다.
- 두 번째 실행부터는 변경분만 쓰이므로 규모가 급감하지만, 청크 로직은 조건 분기 없이 그대로 둡니다.
롤백: 플래그 OFF로 기존 dedup 동작 복귀. 그룹 테이블은 남지만 아무도 읽지 않습니다. platform·dedup_group_id는 nullable이라 역방향 DDL로 컬럼 제거 가능합니다.
의존
- BE-45 (예외 클래스·플래그 시드)
- DB-02 (
job_posting_dedup_groups+job_postings컬럼 2개)
다이어그램
처리 흐름
sequenceDiagram participant S as DeduplicationScheduler participant U as DeduplicateJobPostingsUseCase participant D as DeduplicationDomainService participant PR as JobPostingRepository participant GR as DedupGroupRepository S->>U: execute() U->>D: deduplicate(sourceTypesOf) D->>PR: findAllWithDedupKey() (MANUAL·접근제한 포함) D->>D: dedupKey 그룹핑 → splitByDeadlineGap D->>GR: findAllBy(dedupKeys) loop 새 클러스터마다 D->>D: 기존 그룹과 교집합 계산 alt 교집합 최대 > 0 D->>D: 그룹 정체성 승계 else 교집합 0 D->>D: 새 그룹 생성 end D->>D: 대표 선정 5단 tie-break end D->>GR: saveAll(groups) D->>PR: saveAll(dedupGroupId 배정)
클래스 의존
flowchart LR subgraph Presentation["presentation/posting"] Sched[JobPostingDeduplicationScheduler] end subgraph Application["application/posting"] UC[DeduplicateJobPostingsUseCase] Manual[RegisterManualJobPostingUseCase] end subgraph Domain["domain/posting"] DS[JobPostingDeduplicationDomainService] Group[JobPostingDedupGroup] Posting[JobPosting] ManualDS[ManualJobPostingDomainService] PRepo[JobPostingRepository] GRepo[JobPostingDedupGroupRepository] Flag[FeatureFlagGateway] end Sched --> UC UC --> DS Manual --> ManualDS DS --> Group DS --> Posting DS --> PRepo DS --> GRepo DS --> Flag ManualDS --> Posting
테스트 케이스
- 같은 dedup_key 공고 3건이 한 그룹으로 영속화되고 모든 멤버에
dedup_group_id가 배정된다 - 다음 배치에서 같은 멤버가 유지되면 그룹 id가 바뀌지 않는다 (정체성 승계)
- 마감일이 40일 벌어진 재공고가 들어오면 새 그룹이 생기고 이전 그룹은 그대로 남는다
- 같은 그룹에 애그리게이터 공고 1건이 뒤늦게 합류하면 그룹 id가 유지되고 멤버가 늘어난다
- 기존 그룹 멤버가 전원 사라지면 그룹이 삭제되지 않고
member_count=0으로 남는다 - 교집합이 동률인 기존 그룹이 둘이면 그룹 id가 작은 쪽이 승계한다 (결정성)
- 한 기존 그룹이 두 새 클러스터에 매칭되면 교집합이 큰 쪽이 정체성을 가져가고 나머지는 새 그룹이 된다
- MANUAL 공고를 등록하면 정규화 회사명+제목으로
dedup_key가 부여된다 - MANUAL 공고가 dedup 후보에 포함되어 그룹에 묶인다
- MANUAL 1건 + AGGREGATOR 1건이 같은 그룹이면 MANUAL이 대표가 된다 (tie-break ②)
- MANUAL 1건 + COMPANY_BOUND 1건이면 동급이므로 ③④⑤로 결정된다
- 접근 제한 공고 1건 + 접근 가능 공고 1건이 같은 그룹이면 접근 가능 공고가 대표다 (tie-break ①)
- 접근 제한 공고도 그룹에 포함되어
alternateSources에 노출된다 jobSourceId가 null인 MANUAL 공고가 후보에 있어도 예외가 발생하지 않는다- 수집 공고 저장 시
platform이 어댑터 플랫폼으로 채워진다 - MANUAL 공고의
platform은 null이다 - 배치를 두 번 실행해도 그룹 id·대표·멤버가 동일하다 (멱등)
- 피처 플래그 OFF면 그룹을 영속화하지 않고 기존 대표 선정만 수행한다
- 6,000건 규모 첫 실행이 500그룹 단위로 커밋되어 단일 트랜잭션으로 전체를 잠그지 않는다
- 청크 경계에서 중단한 뒤 재실행하면 최종 그룹·대표가 중단 없이 실행한 결과와 동일하다 (멱등)
- 한 그룹의 대표·비대표가 항상 같은 청크(트랜잭션)에 포함된다 (대표 둘 중간 상태 없음)
isDeltaTarget()의 동작이 변경되지 않는다 (수집 델타 회귀)