[FE-20] 1단계 API 계약 타입 · queryKey · MSW 목
작업 내용 (설계 의도)
근거: 지원 관리 확장 FE 웹 설계 신규 TypeScript 모델 (types/api.ts 확장) · Query 규약 (기존 규약 승계 + 신규분) · API 연동 > 1단계 · 신규 공용 UI 프리미티브 · 신규 순수 유틸, 지원 관리 확장 TDD 1단계 — 인증 (FR-70) · 1단계 — 관리 상태 (FR-73~77) · 1단계 — 교차 회사 공고 목록 (FR-78, NFR-12) · 1단계 — 공고 상세 응답 확장 · 공고 기준 관리 상태 저장 (C-1) · 1단계 — 대시보드 (FR-79) · 1단계 — 담당자 (FR-80) · 1단계 — 운영 조회 확장.
변경 사항
- 단계 1의 공통 계약 병목을 한 티켓으로 묶습니다. 후행 6개 티켓(FE-23~FE-28)이 전부 이 타입·queryKey·MSW 목을 import하므로, 쪼개면 wave 1이 직렬 사슬이 됩니다. 이 티켓이 닫히는 순간 wave 2가 6갈래로 열립니다.
types/api.ts에 1단계 신규 타입을 전량 추가합니다:PageResponse<T>(신규 공통 봉투),AuthSessionResponse,WatchStatus·WatchPriority·WatchStateResponse·WatchStateHistoryResponse·SaveWatchStateRequest,CrossCompanyJobPostingItem·CrossCompanyJobPostingSort·CrossCompanyJobPostingQuery,DashboardResponse·DashboardApplicationCard,ApplicationContactResponse·SaveApplicationContactRequest,WebhookReceiptResponse·TunnelStatusResponse.AuthSessionResponse는 4필드로 확정합니다(C-4) —{ authRequired: boolean, authenticated: boolean, username: string | null, expiresAt: string | null }. 초안의 “username·expiresAt2필드” 서술을 대체합니다.auth.required플래그 OFF 구간에서는authRequired: false+username: null·expiresAt: null이 내려오므로 뒤 2개는 반드시 nullable입니다 — non-null로 좁히면 플래그 OFF 구간(배포 1-8 ~ 1-9)에서 타입이 거짓말을 합니다.- 교차 회사 목록 쿼리 타입에
companyId: number[]를 추가합니다(C-3). repeatable로 직렬화해 OR 필터가 되며, 배열이 비면 파라미터 자체를 붙이지 않습니다(빈companyId=는 400 위험). 목도 이 파라미터를 실제로 반영해 필터링합니다. - 같은 쿼리 타입에
tag: string[]을 추가합니다(D 확정).companyId와 동일하게 repeatable OR로 직렬화하고, 빈 배열이면 파라미터를 붙이지 않습니다.tag는PRIORITY_DESC와 같은 1,000건 그룹 id 상한을 공유하고watchedOnly=true를 함의하므로, 목도tag파라미터를 실제로 반영해 필터링하고 상한 초과 시나리오를 재현할 수 있어야 합니다. - [정정 2026-08-09]
PRIORITY_DESC·tag는watchedOnly=true를 강제하므로 목이 관리 상태 없는 공고를 결과에서 제외해야 합니다. 계약 SSOT는 TDDsort=PRIORITY_DESC의 페이지네이션 규칙 1항 — “미지정이면 서버가true로 강제 적용하고, 명시적false는 400JOB_POSTING_SORT_NOT_APPLICABLE”입니다. 우선순위 정렬 키(watch_priority)가 관리 상태 테이블에 있어 관리 상태가 없는 공고는 우선순위 자체가 없고 정렬 대상이 아닙니다.- 초안의 “
HIGH → NORMAL → LOW → 관리 상태 없음순서” 서술은 오류였고 삭제합니다. 그대로 두면 목이 관리 상태 없는 공고를 4순위로 흘려보내고, FE-24가 그 전제 위에 화면을 만들어 실제 BE 통합 시점에 목록 구성이 달라집니다. - 따라서 목은 ①
watchedOnly미지정 시true로 강제 ② 명시적false면 400JOB_POSTING_SORT_NOT_APPLICABLE③ 결과에서watchStatus === null아이템 제외 — 3가지를 모두 재현해야 합니다.
- 초안의 “
ApiErrorResponse에 nullableactualCount: number | null·limit: number | null을 추가합니다(A 확정).*_LIMIT_EXCEEDED계열에서만 채워지고 나머지 코드에서는 둘 다null입니다.ApiError클래스 확장 자체는 FE-21(api/client.ts단독 소유)이 수행하고, 이 티켓은 응답 타입과 목 fixture만 소유합니다.- 신규 에러 코드 4종을 타입·목에 추가합니다 —
WATCH_STATE_FILTER_LIMIT_EXCEEDED(400) ·RECOMMENDATION_TARGET_LIMIT_EXCEEDED(400) ·JOB_POSTING_SORT_NOT_APPLICABLE(400) ·FEATURE_DISABLED(409). 앞 2종의 fixture는actualCount(예:1842)·limit(1000)을 채우고, 뒤 2종은 둘 다null입니다 — FE-21이 “부가 필드는 상한 초과 계열에서만 채워진다”를 목만으로 검증할 수 있어야 합니다. GET /api/dedup-groups/{id}/watch-state응답은 봉투입니다(B 확정) —{ dedupGroupId: number, watchState: WatchStateResponse | null }. 단독WatchStateResponse를 반환하지 않습니다. 타입 이름을 분리해(WatchStateEnvelopeResponse) 저장 응답(WatchStateResponse)과 섞이지 않게 합니다.WATCH_STATE_NOT_FOUND는 폐기됐습니다(C 확정). 타입·목·에러 시나리오 어디에도 남기지 않습니다. 조회 3분기는 ① 미분류200 { dedupGroupId, watchState: null }② 플래그 OFF409 FEATURE_DISABLED③ 그룹 없음404 DEDUP_GROUP_NOT_FOUND이며, 세 경우를 전환 가능한 fixture로 제공합니다 — FE-25가 “미분류(정상)“와 “준비 중”과 “오류”를 구분해 검증하려면 목 전환이 전제입니다.- 이력 조회는 없으면 빈 배열,
DELETE는 없어도 204(멱등) 입니다(C 확정). 두 목 모두 404 시나리오를 만들지 않습니다 — 목에 404가 남아 있으면 후행 티켓이 폐기된 분기를 다시 구현합니다. - 공고 상세 응답 확장 타입을 추가합니다(C-1) — 기존
JobPostingDetail에dedupGroupId: number | null과watchState: WatchStateResponse | null2필드를 더합니다. 추가만 하는 하위 호환 변경이라 기존 상세 화면은 그대로 동작합니다.matchFieldEvidences·matchScore·matchThreshold는 3단계 FE-40 소관이므로 이 티켓에서 만들지 않습니다 — 1단계 화면이 소비하지 않는 타입을 미리 만들면 3단계 계약 확정 전 추측이 됩니다(ContactConfidenceLevel과 같은 판단). PUT /api/job-postings/{jobPostingId}/watch-state(C-1 신설)의 요청·응답 타입과 MSW 목을 추가합니다. 요청 본문은 그룹 기준 upsert와 동일한SaveWatchStateRequest, 응답은WatchStateResponse(서버가 만든 단독 그룹의dedupGroupId포함)입니다. 에러 시나리오로409 POSTING_DEDUP_KEY_ABSENT(백필 이전dedup_key부재 잔존 공고)와404 JOB_POSTING_NOT_FOUNDfixture를 함께 제공합니다 — FE-25가 낙관적 롤백·토스트 분기를 목만으로 검증할 수 있어야 합니다.- 필드·타입은 TDD 계약과 1:1입니다. nullable을 FE가 좁히거나 기본값으로 대체하지 않습니다 — 특히
matchScore·recommendation·dedupGroupId는 단계별 점진 노출·중복 판정 전 상태를 표현하므로 항상 nullable로 선언합니다.recommendation내부의totalScore·grade·notEvaluableReason도 각각 nullable을 유지합니다. PageResponse<T>는 신규 엔드포인트 전용입니다. 기존 목록 응답(회사·지원·알림)에는 소급 적용하지 않습니다 — 기존 화면의 응답 형태를 바꾸면 이 티켓 범위 밖의 회귀가 발생합니다.- [소유권 확정 2026-08-09] 공용 테스트 헬퍼
web/src/test/hardcodedColor.ts를 이 티켓이 소유합니다 —HARDCODED_COLOR_PATTERN+expectNoHardcodedColor.- 왜 이 티켓인가: 이 티켓이 1단계 계약·목·테스트 인프라 담당이고, 소유한
WatchStatusChip테스트에서 헬퍼를 직접 사용합니다. FE-22는 다크 모드 단언에 소비만 합니다. - 헬퍼는 팔레트 이름(
neutral·gray·slate등)을 검사하지 않습니다. 검사하면 정당한 시맨틱 토큰bg-neutral-chip-bg를 하드코딩으로 오탐합니다. 검사 대상은#hex·rgb()/rgba()같은 원색 리터럴입니다. - 이 헬퍼는
src/theme/no-hardcoded-color.test.ts(파일 단위 소스 스캔)와 다른 층입니다 — 이쪽은 렌더된 컴포넌트의 className을 검사하는 런타임 가드입니다. 둘을 합치지 않습니다. - 재발 방지 원칙: 리뷰 지적으로 새로 생기는 공용 테스트 헬퍼·fixture·가드는 이 티켓(각 단계 wave 1 계약 티켓)이 선점 소유하고, 후행 wave 티켓은 소비만 합니다. 설계 시점 Single Writer 검증은 그때 알려진 파일 집합만 보므로, 리뷰로 생겨나는 공용 파일은 이 규칙으로 막습니다.
- 왜 이 티켓인가: 이 티켓이 1단계 계약·목·테스트 인프라 담당이고, 소유한
- 관리 상태 표시 칩 2종(
WatchStatusChip·WatchPriorityMark)을 이 티켓이 소유합니다. 보관함 목록(FE-24)과 관리 상태 시트(FE-25)가 같은 wave에서 둘 다 쓰기 때문에, 어느 한쪽이 소유하면 같은 wave 의존이 생깁니다. 표시 칩은utils/watchStatus의 라벨만 쓰는 순수 프레젠테이션이라 계약 티켓이 함께 갖는 것이 자연스럽습니다. 색은 신규 토큰 없이 기존Chip의fill × tone으로만 표현하고, 우선순위는 색 대신 기호(▲/▼) +aria-label로 표현합니다(accent 1곳 원칙 유지). - 타입 이름 충돌 주의: 기존
ConfidenceLevel은 근무형태 확신도 4종입니다. 3단계에서 들어올 연락 후보 신뢰도 3종(HIGH·MEDIUM·LOW)은 별도 이름(ContactConfidenceLevel)으로 FE-40이 정의합니다. 이 티켓에서는 만들지 않습니다 — 1단계 화면이 소비하지 않는 타입을 미리 만들면 3단계 계약 확정 전 추측이 됩니다. api/queryKeys.ts에 1단계 키를 추가합니다:['auth','session'],['job-postings','cross-company', filters],['dedup-groups', dedupGroupId, 'watch-state'],['dedup-groups', dedupGroupId, 'watch-state','histories'],['dashboard'],['applications', applicationId, 'contacts'],['operations','webhook-receipts', { days, result }],['operations','tunnel-status']. 배열 접두사 계층을 유지해 접두사 단위 무효화(['job-postings','cross-company']·['dedup-groups', id])가 성립하게 합니다.mocks/handlers.ts와 fixtures·errorScenarios에 1단계 엔드포인트를 전량 목 구현합니다 — 인증 3종, 관리 상태 5종(그룹 기준 PUT·GET·DELETE·histories 4종 + 공고 기준 PUT 1종), 교차 회사 목록(필터·정렬·페이지 반영), 대시보드, 담당자 CRUD 4종, 웹훅 수신 목록, 터널 상태.- 세션 목은 3분기를 전환 가능한 fixture로 제공합니다(C-4) — ①
200 { authRequired: false, authenticated: false, username: null, expiresAt: null }②200 { authRequired: true, authenticated: true, username, expiresAt }③401 UNAUTHENTICATED. FE-23의 게이트 3분기 테스트는 이 전환 없이는 작성할 수 없으므로 시나리오 스위치를 계약 티켓이 제공합니다. 후행 티켓은 이 목만으로 화면 테스트를 완결할 수 있어야 하므로 loading/empty/error/success 4상태와 계약상 에러 코드(INVALID_CREDENTIAL·LOGIN_LOCKED·UNAUTHENTICATED·DEDUP_GROUP_NOT_FOUND·FEATURE_DISABLED·WATCH_STATE_INVALID_FIELD·WATCH_STATE_FILTER_LIMIT_EXCEEDED·JOB_POSTING_SORT_NOT_APPLICABLE·APPLICATION_NOT_FOUND·VALIDATION_FAILED)를 시나리오로 제공합니다. - 교차 회사 목록 목은 필터·정렬·페이지 쿼리를 실제로 반영합니다. 후행 FE-24가 “필터 결과 0건”과 “분류 전 0건”을 구분해 검증해야 하는데, 목이 쿼리를 무시하면 그 구분이 테스트 불가능해집니다.
utils/watchStatus.ts를 신규로 추가합니다 — 관리 상태 3종·우선순위 3종 한국어 라벨, 정렬 옵션 4종 라벨(최근 발견순·마감 임박순·제목순·우선순위순). 컴포넌트 안에서 문자열을 분기하지 않게 하는no-logic-in-component대응입니다.components/ui/TextArea.tsx를 신규 프리미티브로 추가하고components/ui/index.ts에 export합니다. 사용처가 S-17 메모·S-19 담당자 메모·S-27 결정 메모로 3곳 확정이라 공용화 기준(“두 번째 사용처 확정”)을 충족합니다.TagInput은 사용처가 S-17 1곳뿐이므로 프리미티브화하지 않습니다 — FE-25가components/watchlist/에 둡니다.components/ui/index.ts는 이 티켓만 수정합니다. 2단계FileField(FE-30)가 같은 파일을 건드리지만 다른 단계·다른 wave입니다.
의존
- BE-45 (1단계 공통 계약 확정) — 타입은 BE 계약 확정 후 작성합니다. 다만 계약 문서가 SSOT이므로 BE 구현 병합 전에도 착수할 수 있고, 실제 통합 검증은 BE-46~BE-54 완료 후 수행합니다.
- 선행 FE 티켓 없음 (단계 1 wave 1).
다이어그램
처리 흐름
sequenceDiagram participant Test as 후행 티켓 테스트 participant Hook as 신규 훅 participant MSW as MSW handler participant Fixture as fixtures/errorScenarios Test->>Hook: queryKeys 로 조회 Hook->>MSW: GET/PUT 1단계 엔드포인트 MSW->>Fixture: 쿼리·시나리오로 응답 선택 Fixture-->>MSW: 계약 형태 payload MSW-->>Hook: 200 또는 계약 에러 코드 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 --> Util[utils/watchStatus.ts] TextArea[components/ui/TextArea] --> Index[components/ui/index.ts] Keys --> Next[FE-23~FE-28] Handlers --> Next Util --> Next Index --> Next
테스트 케이스
- 교차 회사 목록 목에
watchStatus=INTERESTED를 넘기면 해당 관리 상태 아이템만 담긴PageResponse가 반환된다. - 교차 회사 목록 목에
sort=PRIORITY_DESC를 넘기면 관리 상태가 있는 공고만HIGH → NORMAL → LOW순서로 반환되고, 관리 상태가 없는 공고는 결과에 포함되지 않는다. - 교차 회사 목록 목에
sort=PRIORITY_DESC를watchedOnly없이 넘기면 목이watchedOnly=true를 강제 적용한 것과 동일한 결과를 반환한다. - 교차 회사 목록 목에
tag를watchedOnly없이 넘기면 동일하게watchedOnly=true가 강제 적용된다. - 교차 회사 목록 목의 마지막 페이지 응답은
hasNext=false를 반환하고, 그 이전 페이지는hasNext=true를 반환한다. - 1단계 fixture에는
matchScore=null·recommendation=null인 아이템이 최소 1건 포함돼 있어 후행 티켓이 점진 노출 하위 호환을 검증할 수 있다. dedupGroupId=null인 아이템이 fixture에 최소 1건 포함돼 있어 후행 티켓이 공고 기준 저장 경로를 검증할 수 있다.- 교차 회사 목록 목에
companyId를 2번 실어 보내면 두 회사의 공고만 담긴PageResponse가 반환된다(OR). - 세션 목을
authRequired:false시나리오로 두면200과 함께authenticated:false·username:null·expiresAt:null이 반환된다. - 세션 목을
authRequired:true, authenticated:true시나리오로 두면200과 함께username·expiresAt이 채워져 반환된다. - 세션 목을 미인증 시나리오로 두면
401 UNAUTHENTICATED가 반환된다. - 공고 기준 관리 상태 PUT 목이
200과 함께 서버가 만든 단독 그룹의dedupGroupId를 포함한WatchStateResponse를 반환한다. - 공고 기준 관리 상태 PUT 목을
dedup_key가 없는 공고로 호출하면409 POSTING_DEDUP_KEY_ABSENT가 반환된다. - 공고 기준 관리 상태 PUT 목을 없는 공고 id로 호출하면
404 JOB_POSTING_NOT_FOUND가 반환된다. - 공고 상세 목 응답에
dedupGroupId와watchState가 포함되고, 관리 상태 미등록 공고는watchState=null로 반환된다. - 관리 상태 PUT 목에
EXCLUDED가 아닌status와 non-nullexclusionReason을 함께 보내면400 WATCH_STATE_INVALID_FIELD가 반환된다. - 관리 상태 GET 목이 봉투
{ dedupGroupId, watchState }형태로 응답하고, 관리 상태가 있는 그룹은watchState에WatchStateResponse가 채워져 반환된다. - 관리 상태 GET 목을 미분류 그룹으로 호출하면
200과 함께{ dedupGroupId, watchState: null }이 반환된다(404가 아니다). - 관리 상태 GET 목을 플래그 OFF 시나리오로 두면
409 FEATURE_DISABLED가 반환된다. - 관리 상태 GET 목을 없는 그룹 id로 호출하면
404 DEDUP_GROUP_NOT_FOUND가 반환된다. - 관리 상태 이력 목을 이력이 없는 그룹으로 호출하면
200과 빈 배열이 반환되고 404 시나리오가 존재하지 않는다. - 관리 상태 DELETE 목을 관리 상태가 없는 그룹으로 호출하면
204가 반환된다(멱등). - 교차 회사 목록 목에
tag를 2번 실어 보내면 두 태그 중 하나라도 가진 공고만 담긴PageResponse가 반환된다(OR). - 교차 회사 목록 목을 상한 초과 시나리오로 두면
400 WATCH_STATE_FILTER_LIMIT_EXCEEDED가actualCount: 1842·limit: 1000과 함께 반환된다. - 교차 회사 목록 목에
sort=PRIORITY_DESC와 명시적watchedOnly=false를 함께 보내면400 JOB_POSTING_SORT_NOT_APPLICABLE이 반환되고actualCount·limit이 둘 다null이다. - 에러 시나리오 fixture 전체에
WATCH_STATE_NOT_FOUND코드가 0건이다(폐기 코드 잔존 없음). - 대시보드 목은 진행 중 4종(
APPLIED·DOCUMENT_SCREENING·INTERVIEWING·OFFERED)을count=0이어도statusBoard에 항상 포함해 반환한다. - 인증 목은
INVALID_CREDENTIAL(401)·LOGIN_LOCKED(429)·UNAUTHENTICATED(401)를 각각 다른 코드로 반환한다. utils/watchStatus가INTERESTED·PLANNED·EXCLUDED와HIGH·NORMAL·LOW를 각각의 한국어 라벨로 변환한다.utils/watchStatus가 관리 상태null(미분류) 입력에 대해 “미분류” 라벨을 반환하고 예외를 던지지 않는다.TextArea가maxLength초과 입력을 막고errorprop이 주어지면aria-describedby로 연결된 에러 문구를 노출한다.TextArea를 다크 모드로 렌더하면 시맨틱 토큰 class만 사용하고 하드코딩 색이 0건이다.