[BE-80] 매칭 근거 API 노출 — 공고 상세 근거 배열 · 목록 matchScore (FR-86 / C-2)

작업 내용 (설계 의도)

근거 TDD: 20260808-지원관리-확장-tdd.md — “3단계 — 매칭 근거 노출 (C-2)“

변경 사항

BE-69가 job_posting_match_field_evidences에 필드별 근거를 저장하지만 API 응답 필드가 없어 FR-86의 “어느 필드에서 일치했는지 근거를 저장·표시한다” 중 표시가 성립하지 않습니다. senior-fe가 FE-47을 이 계약 확정 전까지 착수 금지로 묶어 둔 상태입니다.

  1. 공고 상세 응답 확장matchFieldEvidences[] + matchScore + matchThreshold를 추가합니다.
    • matchThreshold(판정 당시 임계치)를 함께 내리는 이유는 판정 재현성입니다. 임계치 상수가 나중에 바뀌어도 과거 판정을 “그때 기준으로는 통과였다”고 설명할 수 있습니다.
    • jobKeywordGroupName을 함께 내려 FE가 그룹 id를 라벨로 바꾸는 조회를 하지 않게 합니다.
  2. 교차 목록은 matchScore·matched 채웁니다 — 근거 배열은 상세에서만 노출합니다. 목록 20건 × 그룹 × 필드만큼 응답이 커지는 것을 막습니다. 2-1. 미매칭 공고에서도 근거 배열이 채워집니다 (A-2) — BE-69가 match_score > 0이면 전부 저장하므로, 상세 화면이 “제목 0 + 태그 35 = 50 미달”을 설명할 수 있습니다. 배열이 비는 경우는 어느 필드에서도 걸리지 않은 공고(matchScore: 0) 뿐입니다. 2-2. matched·excludedByKeyword를 함께 노출합니다matchScore >= matchThreshold 비교로는 매칭 여부를 판정할 수 없습니다(제외 키워드가 매칭을 이깁니다, FR-23). matched가 SSOT이고, excludedByKeyword: { keywordId, keyword } | null로 미매칭 사유가 “제외 키워드”인지 “점수 미달”인지 구분합니다.
  3. 단계별 점진 노출 계약 준수 — BE-52(1단계)가 matchScore: null로, 이 티켓이 실제 값으로 채웁니다. 3단계 미배포 상태에서 matchFieldEvidences는 빈 배열입니다.
  4. N+1 금지 — 상세는 단건이라 조회 1회, 목록은 jobPostingId 집합으로 배치 조회 1회입니다.
  5. 크로스 컨텍스트 조합은 application 레이어 — matching이 소유한 근거를 posting 응답에 얹는 것이므로 application/posting 매퍼가 MatchResultQueryDomainService에서 받아 조립합니다. posting 도메인이 matching 타입을 참조하지 않습니다.

파일 소유: application/posting/{JobPostingDetailResponse, JobPostingResponseMapper, CrossCompanyPostingResponseMapper}.kt를 수정합니다. 같은 wave(BE-74·75·76)는 각각 application/matching·application/contact·application/notification만 건드리므로 파일이 겹치지 않습니다.

롤백: matching.field-weighted-scope 플래그 OFF면 matchScore가 갱신되지 않고 근거도 쌓이지 않아 빈 배열이 내려갑니다(FE 하위 호환).

의존

  • BE-69 (필드별 가중치 매칭 · 근거 저장)

다이어그램

처리 흐름

sequenceDiagram
    participant FE as web(SPA)
    participant C as JobPostingApiController
    participant U as GetJobPostingUseCase
    participant P as JobPostingQueryService
    participant M as MatchResultQueryDomainService
    participant Map as JobPostingResponseMapper
    FE->>C: GET /api/companies/{cid}/job-postings/{id}
    C->>U: execute(command)
    U->>P: getBy(jobPostingId)
    U->>M: findEvidencesBy(jobPostingId)
    M-->>U: 필드별 근거 + matchScore + matchThreshold
    U->>Map: 조립 (근거 배열 + 점수)
    Map-->>C: JobPostingDetailResponse
    C-->>FE: 200

클래스 의존

flowchart LR
    subgraph Application["application/posting"]
        UC[GetJobPostingUseCase]
        Detail[JobPostingDetailResponse]
        Mapper[JobPostingResponseMapper]
        ListMapper[CrossCompanyPostingResponseMapper]
    end
    subgraph Domain["domain"]
        PQ[JobPostingQueryService]
        MQ[MatchResultQueryDomainService]
        Evidence[MatchFieldEvidence]
    end
    UC --> Mapper
    UC --> PQ
    UC --> MQ
    Mapper --> Detail
    ListMapper --> MQ
    MQ --> Evidence

테스트 케이스

  • 제목에서 매칭된 공고 상세에 matchField=TITLE, weightPoints=60 근거가 노출된다
  • 태그 + 본문으로 매칭된 공고에 근거 2건(SOURCE_TAG 35 / DESCRIPTION_BODY 20)이 노출된다
  • 본문 근거의 occurrenceCount가 유효 출현 횟수(2 이상)로 노출된다
  • 근거의 snippet이 200자를 넘지 않는다 (경계값)
  • jobKeywordGroupName이 그룹 표시명으로 채워진다
  • matchScorematchThreshold가 판정 당시 값으로 함께 노출된다
  • 미매칭 공고(점수 35 < 임계 50)의 상세에 근거 배열이 채워진다 (A-2)
  • matchScore: 0인 공고는 matchFieldEvidences: []다 (근거 없음)
  • 평가되지 않은 공고는 matchScore: null, matchFieldEvidences: []다 (미배포 — 0과 구분됨)
  • 제외 키워드로 미매칭된 공고는 matched: false이고 excludedByKeyword가 채워진다
  • 점수 미달로 미매칭된 공고는 matched: false이고 excludedByKeyword: null이다
  • 매칭 성립 공고는 matched: true이고 excludedByKeyword: null이다
  • 교차 목록 응답에 matchScore가 채워지고 근거 배열은 포함되지 않는다
  • 교차 목록 20건에 대해 매칭 근거 조회가 1회만 발생한다 (N+1 방지)
  • matching.field-weighted-scope 플래그 OFF면 근거가 빈 배열이다 (하위 호환)
  • 기존 공고 상세 응답 필드가 변경되지 않는다 (회귀 — 추가만)