[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 배치의 동작을 바꿉니다.

  1. 그룹 정체성 문제 — 현재 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)
  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) 추가 조회가 없습니다.
  3. tie-break 5단 — ① 접근 가능 > 접근 제한 ② COMPANY_BOUND·MANUAL 동급 > AGGREGATOR ③ OPEN > CLOSED ④ 이른 firstSeenAt ⑤ PK. sourcePriority()requireNotNull(jobSourceId)(:79)를 제거하고 origin을 먼저 분기해야 MANUAL이 들어와도 터지지 않습니다.
  4. job_postings.platform 컬럼 — FR-78의 플랫폼 OR 매칭은 job_sources(company 컨텍스트) 조인이 필요한데 no-crosscontext-raw-read 위반입니다. platform은 “이 공고가 어디서 왔는가”라는 posting 자기 사실이므로 자기 컬럼으로 보유합니다(JobPlatformdomain.common 소속이라 교차 참조가 아닙니다). MANUAL은 NULL입니다.
  5. 승계되지 않은 그룹은 삭제하지 않고 member_count=0으로 남깁니다 — 관리 상태·이력을 보존하고, 같은 공고가 다시 나타나면 교집합으로 복귀합니다.
  6. 피처 플래그 posting.dedup-group-identity가 OFF면 그룹을 영속화하지 않고 기존 동작(대표 선정만)을 유지합니다.
  7. 500그룹 단위 커밋 (B-3 — dba 실측). 플래그를 켠 뒤의 첫 배치는 백필급 규모입니다 — 실측 job_posting_dedup_groups 6,311 INSERT + job_postings 6,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()의 동작이 변경되지 않는다 (수집 델타 회귀)