[FE-47] 매칭 근거 표시

계약 확정 + 범위 확대 확정 — 착수 가능합니다 (C-2 수용 + 게이트 ② 결정 ⓑ, 2026-08-09). 공고 상세 응답에 matchFieldEvidences[] · matchScore · matchThreshold · matched · excludedByKeyword가 추가됐고 BE-80이 신설됐습니다. 미매칭 공고에도 근거를 표시하는 ⓑ가 확정되어 초안의 게이트 대기가 해제됩니다 — 확대 비용은 우려했던 6.7배가 아니라 dba 실측 +32.6%(3년 51,600행 / 22MB) 이고, 본문 보유 공고가 13.8%(914/6,633) 뿐이라 구조적 상한이 있습니다. 범위 확대로 티켓 사이즈는 M → L입니다. 타입·유틸은 FE-40이 소유하므로 이 티켓은 import해 화면만 만듭니다.

작업 내용 (설계 의도)

근거: 지원 관리 확장 FE 웹 설계 S-04 확장 (3단계) — 매칭 근거(미매칭 와이어프레임 2종 · matched SSOT 표 · 미매칭 2분기 표 · matchScore 3-state 표) · API 연동 > 3단계 · 상태 표기 규칙(매칭 근거 발췌의 일치 구간) · 컴포넌트 트리 · 신규 순수 유틸(utils/matchEvidence.ts) · Release Scenario(matching.field-weighted-scope OFF 대응) · Testing Plan 49·50·50-a~50-d, 지원 관리 확장 TDD 매칭/미매칭의 구분 — matched가 SSOT입니다 · 3단계 — 매칭 근거 노출 (C-2) · 3단계 — 필드 가중 매칭(테이블 job_posting_match_field_evidences).

변경 사항

  • 최상단 규칙 — matched가 판정 SSOT입니다. 매칭 여부는 오직 matched: boolean으로 판단하고, matchScore >= matchThreshold 비교를 코드·테스트 어디에도 쓰지 않습니다. 제외 키워드가 매칭을 이기므로(FR-23) 점수 60인데 matched: false 인 공고가 실제로 존재합니다 — 점수로 판정하면 그 공고가 “매칭됨”으로 표시돼 사용자에게 거짓말을 합니다. matchScore·matchThreshold는 “얼마나 모자랐나”를 설명하는 수치일 뿐입니다.
  • 판정은 utils/matchEvidence.ts#resolveMatchOutcome(FE-40 소유)을 import만 해서 씁니다. 이 컴포넌트는 5가지 상태(NOT_DEPLOYED·NO_EVIDENCE·MATCHED·UNMATCHED_BELOW_THRESHOLD·UNMATCHED_EXCLUDED)를 받아 렌더만 하고, 판정 로직을 스스로 만들지 않습니다(no-logic-in-component). 유틸 파일은 수정하지 않습니다.
  • 공고 상세(S-04)의 기존 매칭 박스를 확장해 “이렇게 계산했어요” 근거 목록을 보여 줍니다. 판정 결과(매칭됨/아님)만 있으면 사용자가 왜 이 공고가 걸렸는지 알 수 없어 키워드 설정을 고칠 근거가 생기지 않습니다.
  • 미매칭 공고도 근거를 표시합니다(ⓑ 확정). “왜 안 걸렸는지”를 설명할 수 있어야 사용자가 키워드를 고칠지 판단합니다. 미매칭은 사유에 따라 2분기로 렌더가 갈립니다.
조건렌더
excludedByKeyword != null”제외 키워드 ‘{keyword}‘에 걸렸어요” + “점수와 무관하게 제외됩니다”. 점수 줄·근거 배열을 렌더하지 않습니다
excludedByKeyword === null”점수 {matchScore} / 임계 {matchThreshold}” + 근거 배열 + “N점이 더 필요해요”(matchThreshold - matchScore)
  • 제외 키워드 분기에서 점수와 근거를 감추는 이유는 점수가 몇 점이든 결과가 바뀌지 않기 때문입니다. “점수 60 / 임계 50”과 “매칭 안 됨”을 나란히 보여 주면 사용자가 화면을 오류로 읽습니다.
  • matchScore 3-state로 분기합니다matchFieldEvidences === []만 보면 세 상태가 뭉개집니다.
의미렌더
matchScore === null미배포 (3단계 배포 전·재평가 전)근거 섹션 자체를 렌더하지 않고 기존 matched 표시로 폴백
matchScore === 0근거 없음 (어느 필드에도 안 걸림)“일치한 항목이 없어요” — 미배포 폴백과 다른 문구
matchScore > 0근거 있음근거 배열 렌더 (매칭·미매칭 공통)
  • 미매칭 공고에서는 근거 배열에 없는 필드를 0으로 표기합니다(“제목 0 · 일치한 표현이 없어요”). 걸린 필드만 배열로 오므로, 빠진 필드를 그냥 감추면 “어디서 모자랐는지”가 화면에 남지 않습니다(설계 미매칭 와이어프레임 참조).
  • 확정 계약 필드명을 그대로 씁니다matchField(TITLE·SOURCE_TAG·DESCRIPTION_BODY) · jobKeywordGroupId · jobKeywordGroupName · weightPoints · occurrenceCount · snippet(최대 200자). 초안에서 역제안했던 이름(field·keywordGroupName·weight)은 채택되지 않았으므로 코드·테스트 어디에도 남기지 않습니다.
  • matchScorematchThreshold를 항상 함께 노출해 “왜 매칭됐는가”가 재현 가능하게 만듭니다(75 / 임계 50). 임계치가 나중에 바뀌어도 과거 판정을 설명할 수 있어야 하므로, 판정 당시 임계치를 화면이 그대로 보여 줍니다. 점수만 보여 주면 그 값이 통과인지 판단할 기준이 없고, 임계치를 상수로 박으면 변경 후 과거 판정이 거짓말이 됩니다.
  • 가중치는 제목 60 / 태그 35 / 본문 20이고 임계는 50입니다. 이 값은 서버가 weightPoints·matchThreshold로 내려 주므로 FE가 상수로 갖지 않습니다 — 표기 검증용 기준일 뿐입니다.
  • jobKeywordGroupName이 함께 오므로 FE가 그룹 id를 라벨로 매핑하지 않습니다. 프런트에 id→이름 사전을 두면 그룹 이름이 바뀔 때마다 화면이 낡습니다.
  • occurrenceCount유효 출현 횟수이며 본문 신호는 2회 이상이어야 인정됩니다. 그래서 DESCRIPTION_BODY 근거에만 횟수를 병기합니다 — 제목·태그에 “1회”를 붙이면 의미 없는 숫자가 화면을 채웁니다.
  • 근거는 필드 단위로 나열합니다 — 필드 라벨 · +weightPoints · (본문일 때) 횟수 · jobKeywordGroupName · 발췌. 발췌 안의 일치 구간은 <mark>로 감싸고 --accent-subtle 배경 + --accent-on-subtle 텍스트를 씁니다(기존 토큰 매핑, 신규 색 토큰 0건).
  • matchScore === null(미배포)일 때만 기존 matched: boolean 표시로 폴백하고 근거 섹션을 렌더하지 않습니다. 3단계 배포 전(matching.field-weighted-scope OFF)과 재평가 이전 공고가 이 상태입니다. 0점으로 대체하면 “매칭 점수 0”이라는 거짓 정보가 됩니다.
  • 교차 목록(S-15)에는 matchScore·matched만 내려오므로 근거 목록은 공고 상세에서만 렌더합니다.
  • 이 컴포넌트는 props만 받는 순수 프레젠테이션입니다. 데이터 조회는 공고 상세 페이지가 하고, 공고 상세 배선은 wave 3의 FE-48이 수행합니다(같은 wave 파일 충돌 회피).
  • 타입·판정 유틸은 FE-40이 소유합니다(MatchFieldEvidenceResponse + 상세 응답 확장 + utils/matchEvidence.ts). 이 티켓은 import만 하고 types/api.ts·utils/matchEvidence.ts를 수정하지 않습니다.
  • 롤백: BE-80 배포가 늦어지면 matchScore: null이 내려와 근거 박스가 렌더되지 않고 기존 matched 표시가 그대로 동작하므로, 공고 상세는 정상입니다.

의존

  • BE-80 — 매칭 근거 API 노출(matchFieldEvidences[]·matchScore·matchThreshold·matched·excludedByKeyword, 미매칭 공고 포함). C-2 수용 + 게이트 ② ⓑ로 확정된 티켓이며 통합 검증의 선행 조건입니다.
  • BE-69 — 필드 가중치 매칭 근거 저장 구현(job_posting_match_field_evidences). BE-80이 노출할 데이터의 원천입니다.
  • FE-40 — MatchFieldEvidenceResponse·matched·excludedByKeyword 타입, utils/matchEvidence.ts#resolveMatchOutcome, 5종 상태 MSW 목(매칭 / 미매칭-점수 미달 / 미매칭-제외 키워드 / matchScore: 0 / matchScore: null + matchScore: 60·matched: false)을 소유합니다. 이 티켓은 import만 하고 수정하지 않습니다.
  • 화면 배선은 FE-48이 이어받습니다.

다이어그램

처리 흐름

sequenceDiagram
    participant User as 사용자
    participant Page as JobPostingDetailPage
    participant Api as 공고 상세 API
    participant Box as MatchEvidenceBox
    participant Util as resolveMatchOutcome
    User->>Page: 공고 상세 열기
    Page->>Api: GET 공고 상세
    Api-->>Page: matched · matchScore · matchThreshold · excludedByKeyword · 근거 배열
    Page->>Box: props 전달
    Box->>Util: 5상태 판정 요청
    Util-->>Box: NOT_DEPLOYED 또는 NO_EVIDENCE 또는 MATCHED 또는 미매칭 2종
    Box-->>User: NOT_DEPLOYED 면 기존 matched 폴백
    Box-->>User: UNMATCHED_EXCLUDED 면 제외 키워드 문구만
    Box-->>User: 그 외에는 점수·임계 + 필드별 근거 목록

컴포넌트 의존

flowchart LR
    Detail[JobPostingDetailPage · FE-48 배선] --> Box[MatchEvidenceBox]
    Box --> Types[MatchFieldEvidenceResponse · FE-40 소유]
    Box --> Outcome[resolveMatchOutcome · FE-40 소유]
    Outcome --> Fallback[NOT_DEPLOYED · matched 폴백]
    Outcome --> NoEvidence[NO_EVIDENCE · 일치한 항목이 없어요]
    Outcome --> Excluded[UNMATCHED_EXCLUDED · 제외 키워드 문구만]
    Outcome --> Scored[MATCHED 또는 점수 미달]
    Scored --> Score[matchScore 슬래시 matchThreshold 동시 표기]
    Scored --> Short[부족 점수 · N점이 더 필요해요]
    Scored --> Row[근거 행 · matchField 라벨 · weightPoints]
    Row --> Count[occurrenceCount · DESCRIPTION_BODY 만 병기]
    Row --> Group[jobKeywordGroupName 그대로 표기]
    Row --> Zero[근거 없는 필드 0 표기]
    Box --> Mark[snippet 일치 구간 mark · accent-subtle]

테스트 케이스

  • matchScore=75·matchThreshold=50이면 “점수 75 / 임계 50”이 함께 보이고 매칭됨 표시가 같이 렌더된다.
  • 근거 3건(TITLE 60·SOURCE_TAG 35·DESCRIPTION_BODY 20)이 있으면 필드 라벨·가중치·발췌가 각각 보인다.
  • DESCRIPTION_BODY 근거에만 occurrenceCount가 병기되고, TITLE·SOURCE_TAG 근거에는 횟수가 보이지 않는다.
  • 각 근거에 jobKeywordGroupName이 그대로 표기되고, 화면이 jobKeywordGroupId를 라벨로 변환하지 않는다.
  • 발췌 안의 일치 구간이 <mark>로 감싸져 강조되고 하드코딩 색 없이 accent 계열 토큰만 사용한다.
  • matchScore === null(3단계 배포 전)이면 근거 목록과 점수 줄이 렌더되지 않고 기존 matched: boolean 표시로 폴백한다(설계 50).
  • matchScore === 0이면 “일치한 항목이 없어요”가 보이고, 미배포 폴백과 다른 표시가 된다(설계 50-a).
  • matched: false + excludedByKeyword != null이면 “제외 키워드 ‘인턴’에 걸렸어요”가 보이고 점수 줄·근거 배열이 렌더되지 않는다(설계 50-b).
  • matched: false + excludedByKeyword === null이면 점수·임계치와 근거 배열, 부족 점수 “15점이 더 필요해요”가 함께 보인다(설계 50-c).
  • matchScore: 60 + matchThreshold: 50 + matched: false 이면 “매칭 안 됨”으로 표시된다 — 점수가 임계치를 넘어도 matched를 따른다(설계 50-d).
  • 미매칭 공고에서 근거 배열에 없는 필드가 0으로 표기되고 “일치한 표현이 없어요”가 함께 보인다.
  • 컴포넌트가 matchScorematchThreshold를 직접 비교하지 않고 resolveMatchOutcome 결과로만 분기한다.
  • 200자 발췌가 잘리지 않고 렌더되며 레이아웃이 무너지지 않는다.
  • 근거 박스를 다크 모드로 렌더하면 <mark> 하이라이트를 포함해 시맨틱 토큰 class만 사용하고 하드코딩 색이 0건이다.