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

작업 내용 (설계 의도)

근거: 지원 관리 확장 FE 웹 설계 신규 TypeScript 모델·Query 규약·신규 공용 UI 프리미티브·신규 순수 유틸·티켓 분해 · Wave DAG > 단계 2, 지원 관리 확장 TDD 2단계 — 지원 서류·2단계 — 지원 건 ↔ 서류 연결·2단계 — 이력서 프로필·2단계 — 지원 추천도 API 계약.

변경 사항

  • 단계 2 wave 1의 계약 병목 티켓입니다. 후행 6티켓(FE-32~FE-37)이 참조할 타입·queryKey·MSW 목·순수 유틸을 한 wave에 확립해, 화면 티켓이 계약을 각자 해석하지 않게 합니다. 화면·훅·API 함수는 이 티켓에서 만들지 않습니다.

  • types/api.ts에 2단계 타입을 계약과 필드·타입 1:1로 추가합니다 — DocumentType(RESUME·COVER_LETTER·PORTFOLIO·ETC), DocumentSeriesResponse, DocumentSeriesDetailResponse, DocumentVersionResponse, SubmittedDocumentResponse, ResumeProfileResponse·SaveResumeProfileRequest, RecommendationResponse·RecommendationAxis·RecommendationGrade·RecommendationAxisKey.

  • nullable을 계약 그대로 유지합니다. sourceDocumentVersionId·confirmedAt·totalScore·grade·capReason·notEvaluableReason·fulfillmentRate·usageMonths·lastUsedYearMonth·evidence·description은 전부 | null입니다. FE가 0·''로 대체하는 기본값 처리를 타입 단계에서 봉쇄합니다 — 사용자에게 거짓 값을 보여 주는 것보다 렌더하지 않는 편이 낫습니다.

  • extractionFailed: boolean·judgeable: boolean·capApplied/capReleased: boolean·isCurrentProfileSource: boolean논리 판정이 아니라 서버 소유 값입니다. FE가 항목 배열 길이나 점수로 이 값을 추론하지 않도록 타입 주석으로 의도를 남깁니다.

  • DocumentUploadFailureResponse 타입을 추가합니다(A-3, 운영 확장 FE-50이 소비). 필드는 failureId: number · occurredAt: string · documentType: DocumentType | null · seriesId: number | null · seriesTitle: string | null · originalFileName: string · reasonCode: string · detailMessage: string | null입니다. 응답 봉투는 PageResponse<DocumentUploadFailureResponse>입니다.

  • reasonCode는 유니온이 아니라 string으로 둡니다. 사유 6종이 BE·dba 정합 중이라 아직 확정 전이고, 유니온으로 좁히면 코드가 1개만 추가돼도 목·화면이 타입 에러로 깨집니다. 알 수 없는 코드 폴백이 전제인 계약이므로 타입도 그 전제를 따릅니다.

  • api/queryKeys.ts에 2단계 키를 배열 접두사 계층으로 추가합니다 — 문서 계열 목록(documentType 포함), 문서 계열 상세, 제출 서류, 현재 프로필, 추천도, ['operations', 'document-upload-failures', { days, reasonCode }](무한 쿼리). 추천도 키는 후행 티켓이 staleTime: 5 * 60_000을 걸 수 있도록 단일 지점에서 만듭니다.

  • MSW에 2단계 엔드포인트를 전량 목킹합니다 — 계열 목록·계열 상세·multipart 업로드·프로필 초안/저장/확정/현재 조회·추천도 조회·일괄 재평가·제출 서류 CRUD. 업로드 목은 FormData 파트(file·documentType·seriesId·seriesTitle)를 실제로 읽어 검증 에러를 재현할 수 있어야 합니다.

  • 에러 시나리오 픽스처를 코드별로 준비합니다 — DOCUMENT_EXTENSION_NOT_ALLOWED·DOCUMENT_SIZE_EXCEEDED·DOCUMENT_PATH_ESCAPE·DOCUMENT_SERIES_NOT_FOUND·DOCUMENT_VERSION_NOT_FOUND·DOCUMENT_ALREADY_SUBMITTED·RECOMMENDATION_NOT_FOUND·RESUME_PROFILE_NOT_CONFIRMED·프로필 수정 409. 후행 티켓이 각자 목을 만들지 않게 하는 것이 목적입니다.

  • elapsedMillis: number(밀리초)를 필수 필드로 선언합니다(E 확정). BE 응답 스키마에 확정 반영됐으므로 optional로 둘 이유가 사라졌습니다 — optional로 남기면 후행 티켓이 “없을 때” 분기를 계속 들고 다닙니다. 재평가 응답(POST /api/recommendations/re-evaluations)과 프로필 확정 응답(POST /api/resume-profiles/{id}/confirmations) 양쪽에 선언하고, 두 목의 성공 픽스처 전부가 이 필드를 채웁니다. 표시 여부는 화면 티켓의 선택 사항입니다(FE-35·FE-39).

  • 재평가 400 에러 시나리오 목을 RECOMMENDATION_TARGET_LIMIT_EXCEEDED로 확정합니다(A 확정). 서버가 재평가 대상에 1,000건 상한을 두고 초과 시 이 코드로 거부합니다(C-5 서버 측 방어) — 상한 없이 열어 두면 타임아웃을 아무리 늘려도 언젠가 넘기 때문입니다. fixture는 actualCount(예: 1842limit(1000)을 채워 반환해, FE-39가 수치가 들어간 안내 문구와 [보관함 열기] 경로를 목만으로 검증할 수 있게 합니다. BAD_REQUEST로 뭉뚱그린 목은 만들지 않습니다.

  • 성공 픽스처는 분기 케이스를 전부 담습니다extractionFailed: true인 빈 프로필, notEvaluableReason 2종, judgeable: false 축, capApplied && !capReleasedcapReleased: true(사유 유지), isCurrentProfileSource 버전, 같은 유형 계열 0개 상태.

  • 순수 유틸 3종을 신설합니다 — utils/fileSize.ts(바이트 → 1.2MB 포맷), utils/documentFile.ts(확장자 4종·20MB 클라이언트 선검증), utils/recommendation.ts(등급·축 한국어 라벨, 충족 라벨, 게이지 비율 계산). 컴포넌트에 계산 로직이 들어가는 것을 막기 위한 no-logic-in-component 대응이며, 셋 다 단위 테스트 대상입니다.

  • utils/documentFailure.ts를 이 티켓이 소유합니다(FE-50이 import만). 책임 4가지입니다.

    1. 사유 6종 라벨EXTENSION_NOT_ALLOWED·SIZE_EXCEEDED·PATH_VALIDATION_FAILED·STORAGE_ROOT_ESCAPE·STORAGE_IO_FAILED·OTHER.
    2. 보안 사유 2종 판정PATH_VALIDATION_FAILED·STORAGE_ROOT_ESCAPE. 이 둘은 사용자 실수가 아니라 경로 이탈 시도 기록이라 화면이 다른 톤으로 그려야 하므로, 판정을 유틸에 고정합니다.
    3. 파일명 제어 문자 치환U+0000~001F·U+007F·U+200E·U+200F·U+202A~202E·U+2066~2069를 가시 기호로 바꿉니다. RTL override(U+202E)는 exe.pdffdp.exe처럼 보이게 만드는 실제 공격 벡터이므로 표시 전에 반드시 무력화합니다.
    4. 알 수 없는 코드 폴백 — 사유 6종이 확정 전이므로, 목록에 없는 코드는 원문 그대로 표시 + 중립 톤으로 떨어뜨려 코드가 추가·변경돼도 화면이 깨지지 않게 합니다.
  • MSW에 GET /api/operations/document-upload-failures 목을 추가합니다 — days·reasonCode·page 쿼리를 실제로 반영하고 PageResponse.hasNext로 “더 보기”를 검증할 수 있어야 합니다. fixture는 보안 사유 2종·사용자 입력 2종(EXTENSION_NOT_ALLOWED·SIZE_EXCEEDED)·시스템 2종(STORAGE_IO_FAILED·OTHER) 6건 + 알 수 없는 코드 1건 + documentType: null(유형 파싱 전 실패) 1건 + 0건 응답 시나리오를 포함합니다. FE-50이 톤 3분류·폴백·부분 필드 생략·긍정 빈 상태를 이 목만으로 검증합니다.

  • 공용 프리미티브 components/ui/FileField.tsx를 추가하고 components/ui/index.ts에 export합니다 — 검증 결과를 error prop으로 표시하는 계약(label·accept·maxSizeBytes·value: File | null·onChange·error?)만 가지며, 검증 자체는 utils/documentFile.ts가 수행합니다. 색 토큰은 기존 24종만 씁니다(신규 색 토큰 0건).

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

    • 1단계에서 같은 헬퍼를 두 티켓이 각자 만들어 머지 충돌이 난 사고가 있었습니다(web/src/test/hardcodedColor.ts — FE-20·FE-22). 원인은 리뷰 지적이 양쪽에 독립적으로 갔는데 티켓 어디에도 소유자가 없었던 것입니다. 설계 시점 Single Writer 검증은 그때 알려진 파일 집합만 보므로, 리뷰로 생겨나는 공용 파일은 이 규칙으로 막습니다.
    • web/src/test/hardcodedColor.ts는 이미 1단계 FE-20이 소유합니다 — 2단계 티켓은 다시 만들지 말고 main에서 import합니다.
    • 2단계 화면 티켓이 다크 모드 단언에 색 가드가 필요하면 그 헬퍼를 그대로 씁니다. 새 공용 헬퍼가 필요해지면 이 티켓으로 되돌려 보고하고, 화면 티켓이 직접 만들지 않습니다.
  • 이 티켓은 api/client.ts·api/queryClient.ts·routes.tsx건드리지 않습니다. apiUpload는 단계 1의 FE-21이 소유하고, 라우트는 같은 wave의 FE-31이 소유합니다.

  • web/nginx.conf는 BE-56 단독 소유입니다. 재평가 프록시 타임아웃 상향(proxy_read_timeout 600s 등, C-5)은 BE가 처리하며, 이 티켓을 포함해 어떤 FE 티켓도 이 파일을 수정하지 않습니다(Single Writer per File).

  • 문서 유형 칩(DocumentTypeChip)을 이 티켓이 소유합니다. 서류 보관함(FE-32)·계열 상세(FE-34)·제출 서류 연결(FE-37)이 같은 wave에서 셋 다 쓰므로, 어느 한쪽이 소유하면 같은 wave 의존이 생깁니다. 유형 4종 라벨만 쓰는 순수 프레젠테이션이라 계약 티켓이 함께 갖습니다. 색은 기존 Chip의 filled neutral 하나로 통일합니다(신규 토큰 0건).

의존

  • FE-20 — types/api.ts·api/queryKeys.ts·mocks/ 기반 구조와 components/ui/index.ts export 관례. 단계 1 완료 후 착수합니다.
  • FE-21 — apiUpload(multipart 전용 클라이언트 함수)를 소유합니다. FE-33이 소비하므로 목 계약이 이 시그니처와 맞아야 합니다.
  • BE-56 — 2단계 API 계약 확정. 계약 확정 전에는 MSW 목만으로 선행할 수 있으나, 필드·코드 문자열은 BE-56 확정본과 대조합니다.
  • BE-81 — 서류 업로드 실패 이력 테이블·조회 API. DocumentUploadFailureResponse 계약의 근거입니다. 사유 6종은 확정 전이므로 reasonCodestring으로 두고 폴백을 전제로 작성하며, 통합 검증은 BE-81 완료 후 수행합니다.

다이어그램

처리 흐름

sequenceDiagram
    participant Hook as 후행 티켓 훅
    participant Key as queryKeys
    participant Msw as MSW handler
    participant Fix as 2단계 fixture
    Hook->>Key: 2단계 queryKey 조회
    Hook->>Msw: GET/POST 요청
    Msw->>Fix: 분기 케이스 픽스처 선택
    Fix-->>Msw: 계약 1:1 payload
    Msw-->>Hook: 200/201 또는 코드 에러

컴포넌트 의존

flowchart LR
    Types[types/api.ts 2단계 타입] --> Keys[api/queryKeys.ts]
    Types --> Handlers[mocks/handlers.ts]
    Handlers --> Fixtures[mocks/fixtures 2단계]
    Handlers --> Errors[mocks/errorScenarios]
    Types --> Reco[utils/recommendation.ts]
    Types --> Failure[utils/documentFailure.ts]
    DocFile[utils/documentFile.ts] --> FileField[components/ui/FileField.tsx]
    Size[utils/fileSize.ts] --> FileField
    FileField --> UiIndex[components/ui/index.ts]

테스트 케이스

  • utils/fileSize.ts1258291바이트를 1.2MB로, 0바이트를 0B로 포맷한다.
  • utils/documentFile.tsresume.pdf·resume.docx·resume.md·resume.hwp 4종을 통과시키고 resume.txt를 확장자 위반으로 판정한다.
  • utils/documentFile.ts가 정확히 20MB인 파일은 통과시키고 20MB를 1바이트 넘긴 파일은 용량 위반으로 판정한다(경계값).
  • utils/documentFile.ts가 확장자와 용량을 동시에 위반한 파일에 대해 결정적인 단일 사유를 반환한다.
  • utils/recommendation.ts가 등급 4종과 축 4종의 한국어 라벨을 반환하고, null 등급에는 라벨을 만들지 않는다.
  • utils/recommendation.ts의 게이지 비율 계산이 fulfillmentRate: null에 대해 비율을 만들지 않고 null을 반환한다.
  • MSW 업로드 핸들러가 FormDatadocumentType·seriesTitle 파트를 읽어 201 DocumentVersionResponse를 반환한다.
  • MSW 업로드 핸들러가 시나리오 지정 시 DOCUMENT_EXTENSION_NOT_ALLOWED·DOCUMENT_SIZE_EXCEEDED·DOCUMENT_PATH_ESCAPE를 각각 400 코드로 반환한다.
  • MSW 프로필 초안 핸들러가 추출 실패 시나리오에서 201extractionFailed: true + 빈 3배열을 반환한다(에러 상태가 아니다).
  • MSW GET /api/resume-profiles/current 핸들러가 프로필 없음 시나리오에서 404를 반환한다.
  • MSW 추천도 핸들러가 judgeable: false 축과 fulfillmentRate: null을 포함한 픽스처를 반환한다.
  • MSW 추천도 핸들러가 capReleased: true인 픽스처에서도 capReasonnull이 아닌 값으로 유지한다.
  • MSW 재평가 핸들러가 failures[]가 채워진 200과 RESUME_PROFILE_NOT_CONFIRMED 409를 각각 반환한다.
  • MSW 재평가 핸들러가 대상 1,000건 초과 시나리오에서 400 RECOMMENDATION_TARGET_LIMIT_EXCEEDEDactualCount: 1842·limit: 1000과 함께 반환한다.
  • 재평가 응답 타입이 elapsedMillis: number를 필수로 선언해, 이 필드가 없는 픽스처는 타입 검사에서 실패한다.
  • MSW 재평가 핸들러의 성공 픽스처가 elapsedMillis를 밀리초 정수로 채워 반환한다.
  • MSW 프로필 확정 핸들러의 응답에도 criteriaRevision·reevaluatedCount와 함께 elapsedMillis가 채워져 반환된다.
  • 2단계 에러 시나리오 fixture에 codeBAD_REQUEST로 수렴하는 재평가 400이 0건이다.
  • utils/documentFailure.ts가 파일명의 RTL override(U+202E)·제어 문자(U+0000~001F·U+007F·U+200E·U+200F·U+202A~202E·U+2066~2069)를 가시 기호로 치환하고 원문 길이 정보를 잃지 않는다.
  • utils/documentFailure.tsPATH_VALIDATION_FAILED·STORAGE_ROOT_ESCAPE만 보안 사유로 판정하고 EXTENSION_NOT_ALLOWED·SIZE_EXCEEDED·STORAGE_IO_FAILED·OTHER는 보안 사유가 아니라고 판정한다.
  • utils/documentFailure.ts가 목록에 없는 reasonCode에 대해 원문 문자열을 그대로 반환하고 예외를 던지지 않으며 중립 톤으로 분류한다.
  • MSW 업로드 실패 핸들러가 days·reasonCode 쿼리를 반영해 필터된 PageResponse를 반환하고, 마지막 페이지에서 hasNext=false를 반환한다.
  • 업로드 실패 fixture에 보안 2종·사용자 입력 2종·시스템 2종·알 수 없는 코드·documentType=null 케이스가 각각 최소 1건씩 있고, 0건 응답 시나리오가 존재한다.
  • DocumentUploadFailureResponse.reasonCodestring이라 계약에 없는 신규 코드를 담은 픽스처도 타입 검사를 통과한다.
  • FileFielderror prop이 있을 때 사유 텍스트를 렌더하고 aria-describedby로 입력에 연결한다.
  • FileField.dark 클래스 환경에서 렌더되고 하드코딩 색(#·rgb()을 0건 사용한다.