[BE-69] 필드별 가중치 매칭 · 근거 저장 (FR-86)

작업 내용 (설계 의도)

근거 TDD: 20260808-지원관리-확장-tdd.md — “방안 8 필드별 가중치 매칭 (가장 중요)“

변경 사항

단계 3에서 오탐 위험이 가장 높은 티켓입니다. 기존 TDD가 본문 매칭을 기각한 사유(“‘백엔드와 협업’ 같은 문장에서 대량 오탐”, 20260722-...-tdd.md:1149)를 구조로 막는 것이 이 티켓의 본체입니다. FR-86의 성공 기준이 “제목 단독 대비 오탐률 증가 없음”입니다.

  1. 1차 방어선 — 가중치 설계로 본문 단독 매칭을 구조적으로 불가능하게 만듭니다.
    • 제목 60 / 구조화 태그 35 / JD 본문 20, 임계치 50
    • PRD FR-86의 “직무 분류”는 구조화 태그(35)에 포함됩니다 — 직무 분류 전용 컬럼이 스키마에 없고 어댑터가 job_posting_source_tags로 평탄화해 저장하기 때문입니다(V202607220000__baseline_schema_and_feature_flags.sql:118-126 “어댑터가 평탄화한 태그 문자열”). 직무 분류 전용 필드를 새로 만들지 마세요 — PRD 4필드와 이 3필드는 저장 구조상 같은 범위입니다.
    • 제목만 = 60 → 매칭 ✅ (기존 동작 100% 보존)
    • 본문만 = 20 → 미매칭 ✅ (기존 기각 사유 해소 — “백엔드와 협업”은 어떤 경우에도 단독 매칭 불가)
    • 태그만 = 35 → 미매칭 / 태그+본문 = 55 → 매칭 (신규 획득)
  2. 2차 방어선 — 본문 신호의 문맥 규칙
    • (a) 부정·협업 문맥 배제: 키워드 출현 앞 12자 / 뒤 20자 윈도우에 배제 표현(협업·유관·함께 일할·함께 일하는·소통·대상이 아·제외)이 있으면 그 출현을 무효화합니다. WorkArrangementDetector.findValidSnippet()이 이미 같은 구조로 부정어를 처리하므로(WorkArrangementDetector.kt:78-96, 윈도우 상수 :99-100) 검증된 패턴을 재사용합니다.
    • (b) 출현 빈도 하한: 본문 신호는 서로 다른 문장에서 2회 이상 유효 출현해야 인정합니다. 1회 스침은 무시합니다.
    • (c) 섹션 한정은 미채택 — 섹션 헤더 포맷이 소스 9종마다 달라 인식 실패 시 정상 매칭을 통째로 잃습니다. (a)+(b)로 충분합니다.
  3. 제외 키워드는 제목 + 구조화 태그에만 적용합니다. 본문에 “인턴”이 한 번 스쳤다고 정규직 공고가 탈락하는 것은 매칭 누락(더 나쁜 실패)입니다.
  4. 가중치는 사용자 설정으로 열지 않습니다 — FR-86이 요구하지 않았고(FR-97의 가중치 수정은 추천도 축입니다), 열면 스키마·API·FE가 따라옵니다. MatchField enum 상수로 둡니다.
  5. 근거 저장 — match_score > 0 전부 (A-2, 게이트 ② 결정 ⓑ). job_posting_match_field_evidences(매칭 결과 × 키워드 그룹 × 필드)에 가중치·발췌·출현 횟수를 남깁니다. job_posting_match_resultsmatch_score·match_threshold를 추가해 판정 재현성을 확보합니다.
    • 저장 조건 (구현 계약 — 추측 금지): weightPoints > 0인 (키워드 그룹 × 필드) 조합을 전부 저장한다. 즉 공고의 match_score > 0이면 매칭 성립 여부와 무관하게 근거를 남긴다.
      • 저장 대상: 그 필드에서 그 그룹이 유효 신호로 인정된 경우(본문은 위 (b) “서로 다른 문장 2회 이상” 통과분만).
      • 미저장 대상: match_score = 0(어느 필드에서도 걸리지 않음) — 남길 근거가 없습니다.
      • 매칭 성립분만 저장하지 않습니다 — 그러면 미매칭 공고가 matchScore: 35, matchThreshold: 50, evidences: []가 되어 “왜 못 미쳤는지”를 설명할 수 없습니다.
    • 비용 (dba 실측, 2026-08-09 정정): 3년 38,900 → 51,600행(+32.6%), 17MB → 22MB. 최초 추정 “6.7배/30만 행”은 본문 보유 공고가 13.8%(914건, 전량 GREENHOUSE) 라는 사실을 놓친 과대 추정이었습니다 — 애그리게이터 5종(WANTED 3,224·SARAMIN 2,265 등)은 본문이 0건이라 DESCRIPTION_BODY 근거가 생기지 않습니다.
    • 재평가 시 orphanRemoval로 교체되므로 누적되지 않습니다.
    • 성능 영향: 일일 증분 +41행/일 → NFR-2(09:00 이전 완료) 영향 없음. 전량 재평가(BE-74)도 공고당 평균 +0.09행이라 소요 증가가 무시할 수준이고 락 범위는 불변입니다.
  6. 매칭/미매칭 구분은 matched가 SSOT입니다matchScore >= matchThreshold 비교로는 판정할 수 없습니다. 제외 키워드가 매칭을 이기므로(FR-23) 점수가 60이어도 matched=false일 수 있습니다. 미매칭 사유를 구분할 수 있게 excludedByKeywordId를 결과에 유지하고 응답으로 노출합니다(BE-80).
  7. 기존 MatchCriteria.matches(title)은 유지하고 evaluateFields()에 위임합니다 — 호출부 호환과 플래그 OFF 경로를 모두 살립니다.
  8. 피처 플래그 matching.field-weighted-scope가 OFF면 제목 단독 경로로 복귀합니다.

롤백: 플래그 OFF → 즉시 제목 단독 복귀. match_score는 nullable이라 스키마 롤백도 안전합니다.

의존

  • BE-68 (예외·플래그 시드)
  • DB-04 (job_posting_match_field_evidences, match_results 컬럼 2개)

다이어그램

처리 흐름

sequenceDiagram
    participant E as JobPostingEvaluationDomainService
    participant M as JobKeywordMatcher
    participant C as MatchCriteria
    participant F as FieldWeightedMatchOutcome
    participant R as JobPostingMatchResultRepository
    E->>M: evaluate(target, criteria)
    M->>C: evaluateFields(title, tags, body)
    C->>C: 제목 일치 → 60점
    C->>C: 태그 일치 → 35점
    C->>C: 본문 — 부정·협업 문맥 배제 후 서로 다른 문장 2회 이상이면 20점
    C->>C: 제목·태그에만 제외 키워드 적용
    C->>F: 합산 ≥ 50이면 matched
    F-->>E: score / threshold / 필드별 근거
    E->>R: save(결과 + 근거 cascade)

클래스 의존

flowchart LR
    subgraph Domain["domain/matching"]
        DS[JobPostingEvaluationDomainService]
        Matcher[JobKeywordMatcher]
        Criteria[MatchCriteria]
        Field[MatchField]
        Evidence[MatchFieldEvidence]
        Outcome[FieldWeightedMatchOutcome]
        Result[JobPostingMatchResult]
        Flag[FeatureFlagGateway]
    end
    DS --> Matcher
    Matcher --> Criteria
    Criteria --> Field
    Criteria --> Evidence
    Criteria --> Outcome
    DS --> Result
    DS --> Flag

테스트 케이스

  • 제목에만 키워드가 있으면 60점으로 매칭된다 (기존 동작 보존)
  • 본문에만 키워드가 유효 출현 2회 있으면 20점으로 미매칭이다 (기존 기각 사유 해소)
  • 구조화 태그에만 있으면 35점으로 미매칭이다
  • 태그 + 본문이면 55점으로 매칭된다
  • 제목 + 태그 + 본문이면 115점으로 매칭된다
  • 합산이 정확히 50이면 매칭된다 (경계값)
  • 합산이 49면 미매칭이다 (경계값)
  • 본문에 “백엔드와 협업”이 1회만 있으면 본문 신호가 무효다 (협업 문맥 + 1회 출현)
  • 본문에 유효 출현이 1회뿐이면 본문 신호가 무효다 (빈도 하한, 경계값)
  • 본문 서로 다른 문장에 2회 유효 출현하면 본문 신호가 유효하다 (경계값)
  • 같은 문장에 2회 출현하면 1회로 센다
  • 본문 키워드 앞뒤 윈도우에 “유관부서”가 있으면 무효화된다
  • 제목에 제외 키워드가 있으면 미매칭이다
  • 구조화 태그에 제외 키워드가 있으면 미매칭이다
  • 본문에만 제외 키워드가 있으면 매칭이 유지된다 (본문에 제외어 미적용)
  • 매칭 결과에 match_score와 판정 당시 match_threshold가 저장된다
  • 필드별 근거가 (그룹 × 필드) 단위로 저장되고 발췌·출현 횟수가 담긴다
  • 미매칭 공고(점수 35 < 임계 50)도 근거가 저장된다 (A-2 — 매칭 성립분만 저장하지 않는다)
  • 어느 필드에서도 걸리지 않은 공고(matchScore: 0)는 근거가 저장되지 않는다 (경계값)
  • 제외 키워드로 미매칭된 공고는 matchScore >= matchThreshold인데도 matched=false이고 excludedByKeywordId가 채워진다
  • 재평가 시 기존 근거가 orphanRemoval로 교체된다
  • 피처 플래그 OFF면 제목 단독 판정으로 복귀하고 match_score가 갱신되지 않는다
  • MatchCriteria.matches(title) 기존 시그니처가 유지되고 동일 결과를 낸다 (회귀)