[FE-40] 3단계 API 계약 타입 · queryKey · MSW 목 · 유틸

작업 내용 (설계 의도)

근거: 지원 관리 확장 FE 웹 설계 신규 TypeScript 모델 (types/api.ts 확장) · Query 규약 (기존 규약 승계 + 신규분) · API 연동 > 3단계 · 신규 순수 유틸 (utils/) · 상태 표기 규칙 · Single Writer per File 검증 > 단계 3 wave 2, 지원 관리 확장 TDD 3단계 — 연락 검토 화면 (FR-90) · 3단계 — 가중치 수정·상한 해제 (FR-97) · 3단계 — 소스 상태 레지스트리 (FR-88) · 3단계 — 소급 재평가 백필 (FR-87).

변경 사항

  • 단계 3의 공통 계약 병목을 한 티켓으로 묶습니다. 후행 6개 티켓(FE-42~FE-47)이 전부 이 타입·queryKey·MSW 목을 import하므로, 쪼개면 wave 1이 직렬 사슬이 되고 types/api.ts·mocks/handlers.ts에서 머지 충돌이 납니다. 이 티켓이 닫히는 순간 wave 2가 6갈래로 열립니다.

  • [분해 원칙 2026-08-09] 3단계에서 새로 필요해지는 공용 테스트 헬퍼·fixture·가드는 이 티켓이 선점 소유하고, 후행 wave(FE-42~FE-49)는 소비만 합니다.

    • 1단계에서 같은 헬퍼를 두 티켓이 각자 만들어 머지 충돌이 난 사고가 있었습니다(web/src/test/hardcodedColor.ts — FE-20·FE-22). 리뷰 지적이 양쪽에 독립적으로 갔는데 소유자가 티켓에 없었던 것이 원인입니다.
    • web/src/test/hardcodedColor.ts는 1단계 FE-20 소유입니다 — 3단계 티켓은 다시 만들지 말고 main에서 import합니다. 특히 FE-49(회귀·다크 모드 전수)가 이 헬퍼를 쓰지만 만들지 않습니다.
    • 새 공용 헬퍼가 필요해지면 이 티켓으로 되돌려 보고하고 화면 티켓이 직접 만들지 않습니다.
  • types/api.ts에 3단계 신규 타입을 전량 추가합니다 — ContactEventListItem, ContactEventDetailResponse(중첩된 attachments·parse·candidates·decision 포함), ContactDecisionRequest, ContactConfidenceLevel, SourceRegistryResponse, JobSourceRegistryStatus(6종), FieldScopeBackfillResponse, 그리고 가중치 조회·저장 타입(criteriaRevision + { axis, weight }[]). 필드·타입은 TDD 계약과 1:1이며 FE가 임의로 좁히거나 합치지 않습니다.

  • 타입 이름을 분리합니다. 기존 ConfidenceLevel은 근무형태 확신도 4종(CONFIRMED·LIKELY·INFERRED·UNKNOWN)이고, 연락 후보 신뢰도는 3종(HIGH·MEDIUM·LOW)으로 의미가 다릅니다. 후자는 **ContactConfidenceLevel**로 선언합니다(설계 “타입 이름 충돌 주의”가 지정한 이름). 기존 ConfidenceLevel을 확장하거나 유니온으로 합치지 않습니다 — 합치면 근무형태 칩과 연락 신뢰도 칩이 같은 타입을 받아 잘못된 조합이 컴파일을 통과합니다.

  • nullable을 계약 그대로 유지합니다. topCandidateConfidence(후보 0건)·topCandidateCompanyName·parse(파싱 실패)·autoSelectedJobApplicationId(자동 선택 불가)·decision(미결정)·registryStatus(레지스트리 백필 전)·registryStatusChangedAt·lastNormalAt·lastCollectedAt이 전부 null을 가질 수 있습니다. FE가 0·빈 문자열·기본 enum으로 대체하면 사용자에게 거짓 정보를 보여 주게 되므로 금지합니다.

  • api/queryKeys.ts에 3단계 키를 추가합니다 — ['contact-events', { reviewStatus, days }](무한 쿼리), ['contact-events', contactEventId], ['recommendations','weights'], ['operations','source-registry']. 배열 접두사 계층을 유지해 연락 결정 후 ['contact-events'] 접두사 단위 무효화가 목록·상세·대시보드 배너에 동시에 걸리게 합니다.

  • mocks/에 3단계 엔드포인트를 전량 목 구현합니다 — 연락 목록·상세·결정, 가중치 조회·저장, 상한 해제·재적용, 소스 상태 레지스트리, 매칭 범위 재평가 백필. 후행 티켓이 이 목만으로 화면 테스트를 완결해야 하므로 loading/empty/error/success 4상태와 계약상 에러 코드(CONTACT_EVENT_NOT_FOUND·CONTACT_EVENT_ALREADY_DECIDED·CONTACT_EVENT_TARGET_TERMINAL·TRANSITION_NOT_ALLOWED·APPLICATION_NOT_FOUND·RECOMMENDATION_WEIGHT_INVALID)를 시나리오로 제공합니다.

  • 연락 목록 목은 reviewStatus·days·page 쿼리를 실제로 반영하고 PageResponse.totalCount를 세그먼트별로 다르게 반환합니다. 목이 쿼리를 무시하면 FE-42의 세그먼트 건수 표기와 세그먼트별 빈 상태 3종 구분, FE-48의 검토 대기 0건 배너 미렌더를 테스트할 수 없습니다.

  • 연락 상세 fixture는 후행 티켓이 요구하는 변형을 전부 포함합니다 — 후보 0건, 후보 전원 terminal: true, autoSelectedJobApplicationId: null, parse: null, 본문 20000자, 후보별로 서로 다른 allowedNextStatuses, decision !== null(결정 완료). 백필 목은 dryRun 값을 반영해 dryRun에서는 criteriaRevision을 올리지 않은 결과를 돌려줍니다.

  • 소스 레지스트리 fixture는 6종 상태를 전부 포함하고, registryStatus: null(백필 전) 행을 최소 1건 넣습니다. FE-44가 정렬과 “상태 미확인” 중립 칩을 검증할 근거입니다.

  • utils/contactEvent.ts를 신규로 추가합니다 — 신뢰도 3종 + 후보 없음 라벨, 후보 일치 축 4종(회사·공고·담당자·최근 지원) 라벨, 기본 회차 라벨 조립(suggestedRoundLabel ?? "{nextInterviewRoundNumber}차 면접"). 컴포넌트 안에서 문자열을 분기하지 않게 하는 no-logic-in-component 대응입니다.

  • utils/sourceRegistry.ts를 신규로 추가합니다 — 상태 6종 한국어 라벨, 상태별 집계(전체 목록 30건 미만 전량 수신이라 클라이언트 합산 가능), 문제 우선 정렬(접근 제한 → 일시 실패 → 미지원 → 발견됨 → 활성 → 비활성). 운영 화면의 목적이 이상 감지이므로 정렬 기준을 유틸에 고정합니다.

  • 매칭 근거 타입 MatchFieldEvidenceResponse를 확정 계약대로 추가합니다 (C-2 수용, BE-80 신설). 필드는 matchField: 'TITLE' | 'SOURCE_TAG' | 'DESCRIPTION_BODY' · jobKeywordGroupId: number · jobKeywordGroupName: string · weightPoints: number · occurrenceCount: number · snippet: string입니다. 초안에서 역제안했던 필드명(field·keywordGroupName·weight)이 아니라 확정 계약 필드명을 그대로 씁니다 — 역제안 이름을 남겨 두면 FE-47이 컴파일되는 잘못된 계약 위에 화면을 만듭니다.

  • 공고 상세 타입에 matchFieldEvidences: MatchFieldEvidenceResponse[] · matchScore: number | null · matchThreshold: number | null · matched: boolean · excludedByKeyword: { keywordId: number, keyword: string } | null 을 둡니다. 교차 목록 항목에는 matchScore·matched 둡니다 — 항목 수 × 근거 수만큼 응답이 커지므로 근거 배열은 상세에서만 내려오고, matchThreshold·excludedByKeyword도 상세 전용입니다.

  • 3단계 배포 전에는 matchFieldEvidences: [] · matchScore: null · matchThreshold: null · excludedByKeyword: null입니다. matchScore의 3-state를 타입 주석에 명시합니다 — null은 미배포(계산 전), 0은 근거 없음(계산했는데 어느 필드에도 안 걸림), > 0은 근거 있음입니다. matchFieldEvidences === []만으로 판단하면 앞의 두 상태가 뭉개지므로, 후행 티켓이 배열 길이로 분기하지 않도록 주석으로 못을 박습니다.

  • utils/matchEvidence.ts를 이 티켓이 소유합니다. 핵심은 순수 함수 resolveMatchOutcomematched·matchScore·matchThreshold·excludedByKeyword를 받아 5가지 상태 중 하나를 반환합니다: NOT_DEPLOYED(matchScore === null) · NO_EVIDENCE(matchScore === 0) · MATCHED · UNMATCHED_BELOW_THRESHOLD · UNMATCHED_EXCLUDED. 필드 3종 라벨(TITLE 제목 / SOURCE_TAG 태그 / DESCRIPTION_BODY 본문)과 부족 점수(matchThreshold - matchScore) 계산도 이 파일이 담당합니다.

  • 목적은 점수 비교 판정을 이 파일 하나에 가두는 것입니다. 컴포넌트가 matchScore >= matchThreshold를 직접 쓸 수 있는 경로를 없앱니다 — 제외 키워드가 매칭을 이기므로(FR-23) 점수 60인데 matched: false 인 공고가 실제로 존재하고, 점수 비교로 판정하면 그 공고가 “매칭됨”으로 표시됩니다. 판정 SSOT는 matched 하나뿐이고 점수·임계치는 “얼마나 모자랐나”를 설명하는 수치일 뿐입니다.

  • 연락 첨부 타입을 { fileName: string, mimeType: string, sizeBytes: number }[]로 확정합니다 (B-8). recruitment_contact_event_attachments 테이블은 메타데이터만 저장하고 바이너리를 보관하지 않으므로 다운로드 URL 필드가 없습니다. 이벤트당 최대 20건이며, fixture도 20건 케이스를 포함해 FE-43이 전량 렌더를 검증할 수 있게 합니다.

  • MSW 공고 상세 목을 5종 상태 fixture 전부로 확장합니다 — ① 매칭(근거 3종 TITLE 60 / SOURCE_TAG 35 / DESCRIPTION_BODY 20, matchScore: 75·matchThreshold: 50·matched: true) ② 미매칭-점수 미달(matchScore: 35·matched: false·excludedByKeyword: null) ③ 미매칭-제외 키워드(matched: false·excludedByKeyword: { keywordId, keyword: '인턴' }) ④ matchScore: 0(근거 없음·빈 배열) ⑤ matchScore: null(미배포). FE-47이 5분기를 이 목만으로 전부 검증합니다.

  • matchScore: 60 + matched: false fixture를 반드시 포함합니다 — 제외 키워드가 점수를 이긴 경우입니다. 이 fixture가 없으면 “점수로 판정해도 테스트가 통과하는” 상태가 되어 금지 규칙이 검증되지 않습니다.

  • types/api.ts·api/queryKeys.ts·mocks/**이 티켓만 수정합니다(각 단계 wave 1 단독 규약). 후행 티켓은 읽기만 합니다.

  • 연락 후보 신뢰도 칩(ConfidenceChip)을 이 티켓이 소유합니다. 연락 목록(FE-42)과 검토 상세(FE-43)가 같은 wave에서 둘 다 쓰므로, 어느 한쪽이 소유하면 같은 wave 의존이 생깁니다. HIGH는 filled positive, MEDIUM은 outlined neutral, LOW는 filled warning으로 표현하고, 후보 0건(null)은 [후보 없음] 중립 칩으로 LOW와 구분합니다(신규 토큰 0건).

의존

  • BE-68 (3단계 공통 계약 확정) — 계약 문서가 SSOT이므로 BE 구현 병합 전에도 착수할 수 있고, 실제 통합 검증은 BE-72·BE-74·BE-77·BE-78 완료 후 수행합니다.
  • BE-80 (매칭 근거 API 노출) — MatchFieldEvidenceResponse 계약의 근거입니다. 타입·목은 확정 계약으로 선행 작성하고, 통합 검증은 BE-80 완료 후 수행합니다.
  • 선행 FE 티켓 없음 (단계 3 wave 1).

다이어그램

처리 흐름

sequenceDiagram
    participant Test as 후행 티켓 테스트
    participant Hook as 3단계 훅
    participant MSW as MSW handler
    participant Fixture as fixtures/errorScenarios
    Test->>Hook: queryKeys 로 조회·변경
    Hook->>MSW: 3단계 엔드포인트 요청
    MSW->>Fixture: reviewStatus·dryRun·시나리오로 선택
    Fixture-->>MSW: 계약 형태 payload
    MSW-->>Hook: 200 또는 409/400 에러 코드
    Hook-->>Test: types/api.ts 타입으로 소비

컴포넌트 의존

flowchart LR
    Types[types/api.ts] --> Keys[api/queryKeys.ts]
    Types --> Handlers[mocks/handlers.ts]
    Handlers --> Fixtures[mocks/fixtures]
    Handlers --> Errors[mocks/errorScenarios]
    Types --> ContactUtil[utils/contactEvent.ts]
    Types --> SourceUtil[utils/sourceRegistry.ts]
    Types --> MatchUtil[utils/matchEvidence.ts · resolveMatchOutcome]
    Keys --> Next[FE-42~FE-47]
    Handlers --> Next
    ContactUtil --> Next
    SourceUtil --> Next
    MatchUtil --> Next

테스트 케이스

  • 연락 목록 목에 reviewStatus=PENDING을 넘기면 검토 대기 건만 담긴 PageResponse와 그 세그먼트의 totalCount가 반환된다.
  • 연락 목록 목의 마지막 페이지는 hasNext=false를, 그 이전 페이지는 hasNext=true를 반환해 무한 스크롤을 검증할 수 있다.
  • 연락 목록 fixture에 topCandidateConfidence=null인 항목이 최소 1건 있어 후보 없음과 LOW를 구분해 검증할 수 있다.
  • 연락 상세 fixture에 parse=null·autoSelectedJobApplicationId=null·후보 전원 terminal=true·본문 20000자 케이스가 각각 최소 1건씩 존재한다.
  • 이미 결정된 연락에 결정을 다시 보내면 409 CONTACT_EVENT_ALREADY_DECIDED가 반환된다.
  • 종료된 후보를 지정해 결정을 보내면 409 CONTACT_EVENT_TARGET_TERMINAL이, 후보의 allowedNextStatuses 밖 상태를 보내면 409 TRANSITION_NOT_ALLOWED가 각각 다른 코드로 반환된다.
  • 가중치 PUT 목에 합계 100이 아닌 4축을 보내면 400 RECOMMENDATION_WEIGHT_INVALID가 반환된다.
  • 백필 목을 dryRun=true로 호출하면 통계 4수치는 채워지고 criteriaRevision은 이전 값 그대로 반환된다.
  • 소스 레지스트리 fixture가 6종 상태를 전부 포함하고 registryStatus=null 행을 최소 1건 포함한다.
  • utils/contactEventHIGH·MEDIUM·LOWnull(후보 없음)을 각각 다른 한국어 라벨로 변환하고 예외를 던지지 않는다.
  • utils/contactEventsuggestedRoundLabel이 없으면 nextInterviewRoundNumber로 “{N}차 면접” 라벨을 조립한다.
  • utils/sourceRegistry가 소스 배열을 접근 제한 → 일시 실패 → 미지원 → 발견됨 → 활성 → 비활성 순으로 정렬한다.
  • utils/sourceRegistryregistryStatus=null 항목을 집계에서 별도 “상태 미확인”으로 세고 다른 상태 카운트에 섞지 않는다.
  • ContactConfidenceLevel과 기존 ConfidenceLevel이 서로 다른 타입이어서 근무형태 확신도 값을 연락 신뢰도 자리에 넣으면 타입 검사에 실패한다.
  • 공고 상세 목이 TITLE 60 · SOURCE_TAG 35 · DESCRIPTION_BODY 20 근거 3건과 matchScore=75·matchThreshold=50·matched=true를 반환한다.
  • 공고 상세 목이 matchFieldEvidences=[]·matchScore=null·matchThreshold=null·excludedByKeyword=null인 3단계 배포 전 픽스처를 반환한다.
  • 공고 상세 목이 matchScore=0·빈 근거 배열 픽스처를 matchScore=null 픽스처와 별개로 반환한다.
  • 공고 상세 목이 matched=false·excludedByKeyword={ keywordId, keyword:'인턴' } 픽스처와 matched=false·excludedByKeyword=null·matchScore=35 픽스처를 각각 반환한다.
  • 공고 상세 목에 matchScore=60·matched=false 픽스처(제외 키워드가 점수를 이긴 경우)가 존재한다.
  • MatchFieldEvidenceResponsejobKeywordGroupName을 포함해, 화면이 jobKeywordGroupId를 라벨로 매핑하지 않아도 렌더할 수 있다.
  • 교차 목록 항목 타입에는 matchScore·matched만 있고 matchFieldEvidences·matchThreshold·excludedByKeyword가 없어, 목록에서 이들을 참조하면 타입 검사에 실패한다.
  • resolveMatchOutcomematchScore=nullNOT_DEPLOYED로, matchScore=0NO_EVIDENCE로 판정해 둘을 다른 값으로 구분한다.
  • resolveMatchOutcomematched=trueMATCHED로 판정한다.
  • resolveMatchOutcomematched=false·excludedByKeyword=nullUNMATCHED_BELOW_THRESHOLD로, excludedByKeyword != nullUNMATCHED_EXCLUDED로 판정한다.
  • resolveMatchOutcomematchScore=60·matchThreshold=50·matched=false 입력에 대해 MATCHED가 아닌 미매칭 상태를 반환한다(점수가 임계치를 넘어도 matched를 따른다).
  • utils/matchEvidencematchThreshold - matchScore로 부족 점수를 계산하고, matchScorenull이면 부족 점수를 만들지 않는다.
  • utils/matchEvidenceTITLE·SOURCE_TAG·DESCRIPTION_BODY 3종을 각각 다른 한국어 라벨로 변환하고 알 수 없는 값에 예외를 던지지 않는다.
  • 연락 상세 fixture에 첨부 20건 케이스가 있고, 각 첨부가 fileName·mimeType·sizeBytes만 가지며 다운로드 URL 필드를 포함하지 않는다.