지원 관리 확장 FE 웹 설계 (FR-70~97)

Background

근거 문서

문서역할
PRD “신규 기능 편입(2026-08-08)” FR-70~97요구사항 SSOT
지원 관리 확장 TDD “Detail Design > API 계약”API 계약 SSOT — 이 문서는 계약을 소비만 합니다
공고알림앱 FE 웹 설계기존 화면 S-01~S-13, 테마 토큰, Query 규약, 컴포넌트 규약의 SSOT
확장 안전 공고수집 배치 FE 웹 설계companion 문서 선례

이 문서는 웹(web/)만 다룹니다. React Native 앱은 레포에 존재하지 않으므로 design-fe-app 문서를 작성하지 않습니다.

3단계 각각이 독립 배포 단위입니다 — 단계 경계를 넘는 화면 의존을 만들지 않습니다.

Overview

FR-70~97은 FE에 세 가지를 요구합니다.

  1. 인증 게이트 — 전 API가 세션 쿠키를 요구하게 되므로, API 클라이언트의 credentials를 바꾸고 로그인 화면·401 유도·부팅 시 세션 확인을 신설합니다. 이것이 없으면 기존 11개 화면이 전부 동작하지 않습니다.
  2. 내비게이션 축의 전환 — 지금까지 앱의 첫 화면은 회사 목록이었고 공고는 “회사 → 회사 상세 → 공고 목록” 경로로만 도달했습니다. 이제 첫 진입 화면(/)이 지원 대시보드(S-18) 가 되고, 공고 판단은 회사 경계를 넘는 관심 공고 보관함(S-15) 이 담당합니다. 회사 목록은 /companies로 이동해 등록·소스 관리 화면으로 역할이 좁아집니다 (사용자 확정, 2026-08-08).
  3. 판단을 돕는 표시 계층 — 추천도(점수·등급·축별 근거·판단 불가·상한), 매칭 근거, 연락 후보 신뢰도는 전부 “시스템이 이렇게 판단했고 근거는 이것”을 사용자가 검토·번복할 수 있게 보여 주는 화면입니다. 자동 반영을 하지 않는 것이 FR-90의 핵심이므로, 검토 UI가 곧 기능입니다.

신규 화면 15개(S-14S-28), 기존 화면 확장 3개(S-04 공고 상세, S-07 지원 상세, S-11 운영), 티켓 30건(FE-20FE-49)입니다.

Terminology

용어정의
관리 상태 (WatchStatus)공고에 대한 사용자 분류 — INTERESTED(관심)·PLANNED(지원 예정)·EXCLUDED(제외) 3종. 지원 완료는 저장하지 않고 hasApplication으로 파생 표시
중복 그룹 (dedupGroup)관리 상태가 귀속되는 단위. 개별 공고가 아님 — 대표 공고가 바뀌어도 관리 상태가 유지되게 하는 장치
보관함관심 공고 보관함(S-15). 교차 회사 공고 목록 API(GET /api/job-postings)를 소비하는 화면의 사용자 대면 이름
문서 계열 (series)같은 이력서의 v1·v2·v3를 묶는 단위. 버전 번호는 계열별로 연속 증가
프로필 초안 / 확정 프로필이력서에서 추출한 편집 가능한 초안 ↔ 사용자가 확정한 평가 기준. 확정 전 초안은 추천도 계산에 쓰이지 않습니다
판단 불가 축공고에 그 축을 판정할 정보가 없어 점수 계산에서 제외되고, 나머지 축 비중이 100으로 정규화되는 평가 축
59점 상한필수 기술 미충족 시 총점을 59점으로 제한하는 규칙(FR-96 ⑤). 공고 단위로 해제 가능(FR-97)
연락 이벤트Gmail Apps Script가 웹훅으로 전달한 이메일 1건. 상태를 자동 변경하지 않고 사용자 검토를 거칩니다
확인형 반영시스템이 제안하고 사용자가 반영·무시·다른 지원 건 선택 중 하나를 고르는 상호작용 모델

Define Problem

AS-IS

실제 web/ 코드를 읽고 기술합니다.

A. 인증이 구조적으로 불가능하다

  • web/src/api/client.ts:20-30apiRequestcredentials: 'omit'으로 고정입니다. 주석(client.ts:19)이 근거를 “NFR-7 — 로컬 Docker 전용”으로 못박고 있습니다. 쿠키가 전송되지 않으므로 세션 인증이 켜지는 즉시 전 화면이 401입니다.
  • apiRequest를 우회해 fetch를 직접 쓰는 곳이 2군데입니다 — web/src/api/company/registration.ts:79-111(registerCompany), web/src/api/application/create.ts:81-113(createApplication). 둘 다 credentials: 'omit'입니다(registration.ts:83, create.ts:85). 409 응답의 부가 필드(existingCompanyId·existingApplicationId)를 살리려고 분기한 것이라 이 구조 자체는 유지해야 합니다.
  • web/src/api/client.ts:36-43은 에러를 ApiError { status, code, message }로 정규화하지만, 401을 특별 취급하지 않습니다. 전역 401 처리 지점이 없습니다.
  • 로그인 화면·세션 개념·인증 게이트가 없습니다. web/src/App.tsxQueryClientProvider → ThemeProvider → RouterProvider 3중첩뿐입니다.
  • 기존 FE 설계 문서의 API 연동 표 하단 진술 — “전부 인증 헤더가 없습니다(NFR-7). API 클라이언트는 credentials: 'omit' + Content-Type: application/json만 설정합니다” — 는 이 문서로 폐기됩니다(아래 TO-BE).

B. 공고에 도달하는 경로가 회사뿐이다

  • web/src/routes.tsx:18-56의 11개 라우트 중 공고 목록은 companies/:companyId 하나입니다. 교차 회사 목록 화면이 없습니다.
  • web/src/api/posting/list.ts:13-21fetchJobPostingscompanyId를 경로에 박고 쿼리는 postingStatus·sort 2개뿐입니다. 페이지네이션 파라미터가 없습니다.
  • web/src/types/api.tsJobPostingListItem에는 관리 상태·우선순위·태그·추천도·플랫폼 필드가 없습니다. matched: boolean은 있으나 점수·근거는 없습니다.
  • web/src/components/layout/NavTabs.tsx:14-27은 4탭(회사·지원·매칭·운영) 고정이고, 기존 설계 문서가 “탭을 5개로 늘리지 않습니다”를 명문으로 두었습니다.

C. 지원 화면에 붙일 자리가 없다

  • web/src/pages/application/ApplicationListPage.tsx:29-159는 진행 중/종료 2섹션 목록입니다. 상태별 칸반·다가오는 면접·장기 미변경 집계가 없고, 그것을 만들 API도 소비하고 있지 않습니다.
  • web/src/pages/application/ApplicationDetailPage.tsx는 현재 상태 카드·상태 타임라인·면접 섹션으로 구성됩니다. 담당자·제출 서류 섹션이 없습니다.

D. 표시 계층의 공백

  • web/src/components/domain/index.ts가 노출하는 공용 도메인 컴포넌트는 8개(AccessRestrictedBadge·AppliedBadge·ApplicationStatusChip·CrossSourceChip·DeadlineText·JobPostingCard·SourceHealthBadge·WorkArrangementLabel)입니다. 추천도 등급·관리 상태·신뢰도·문서 유형을 표시할 컴포넌트가 없습니다.
  • web/src/components/ui/index.ts가 노출하는 프리미티브 17개에 파일 입력·태그 입력·긴 텍스트 입력(textarea)·진행률 표시가 없습니다.
  • web/src/pages/operations/OperationsPage.tsx는 수집 이력·알림 발송 이력 2탭입니다. 웹훅 수신·터널 상태·소스 상태 레지스트리 자리가 없습니다.

E. 유지해야 하는 제약 (설계가 지켜야 할 것)

제약근거
색은 시맨틱 토큰만. 원색 하드코딩 0건이 테스트로 강제됨web/src/theme/no-hardcoded-color.test.ts:22-31src/** 전 TS/TSX를 스캔
Tailwind 기본 팔레트가 제거돼 있어 원색 클래스를 쓸 수 없음web/tailwind.config.tscolorsextend가 아닌 교체로 선언
routes.tsx는 라우트 스텁 장치 — 화면 티켓이 수정하지 않음web/src/routes.tsx:5-6 주석
컴포넌트는 api/를 직접 import하지 않고 훅을 경유web/src/hooks/posting/useJobPostings.ts:11 주석 (no-direct-fetch)
페이지만 훅을 호출(컨테이너), 하위 컴포넌트는 props만. 예외는 시트기존 설계 “컴포넌트 트리” 명문
전역 Zustand 스토어는 테마·토스트 2개뿐web/src/stores/useToastStore만, 테마는 web/src/theme/useThemeStore.ts
데스크톱 2단 레이아웃을 만들지 않음 (모바일 폭 중앙 정렬, ≥md는 상단 탭)기존 설계 Open Question #3
Query 기본값 staleTime: 30_000, 4xx 재시도 안 함web/src/api/queryClient.ts

TO-BE

AS-ISTO-BE
인증없음. credentials: 'omit'세션 쿠키(same-origin) + 부팅 시 GET /api/auth/session 1회 + AuthGate가 미인증 시 /login으로 유도
401 처리없음ApiError.code === 'UNAUTHENTICATED' 판별 → 세션 쿼리 무효화 → 게이트가 로그인 화면 렌더
공고 도달 경로회사 → 회사 상세보관함(교차 회사 목록) 신설. 회사 경로는 그대로 유지
내비게이션4탭, 랜딩 = 회사 목록(/)5탭 + 랜딩 = 지원 대시보드(/). 회사 목록은 /companies로 이동. 기존 “5탭 금지” 규칙을 이 문서에서 명시적으로 개정합니다(근거는 아래 방안 2)
공고 분류없음관리 상태 3종 + 우선순위·목표일·태그·메모·제외 사유
지원 현황목록 2섹션대시보드(상태별 칸반·다가오는 면접·장기 미변경) + 지원 상세에 담당자·제출 서류 섹션
서류없음문서 계열·버전 보관함 + 업로드 + 지원 건 연결
판단 근거matched: boolean추천도(점수·등급·축별 충족·판단 불가·상한 사유) + 매칭 근거(필드별) + 연락 후보 신뢰도
색 토큰24종동일 24종. 신규 색 토큰 0건 — 신규 표시는 전부 기존 토큰 + 형태 축(채움/테두리/없음)으로 표현합니다

Architecture Benchmarking

동일 과제(개인 구직 파이프라인 관리)를 푼 제품과, 화면 문법의 기준이 되는 토스를 조사했습니다.

제품/사례해결 방식참고할 패턴미참고 사유
Huntr — 칸반형 구직 트래커 (huntr.co/blog/huntr-vs-teal)저장→지원→면접→오퍼를 칸반 보드로 표현하고, 그 아래에 연락처(CRM) 계층을 둡니다칸반 컬럼 = 지원 상태를 S-18 대시보드에 채용 ② 담당자를 지원 건에 붙이는 CRM 계층이 FR-80과 동일한 구조드래그 앤 드롭 상태 전이는 미채택 — 기존 TransitStatusSheetallowedNextStatuses로 전이를 제한하는데, 드래그는 그 제약을 UI에서 표현하기 어렵습니다. 카드 탭 → 기존 시트로 통일
Teal — 이력서 최적화 중심 트래커 (offboard.co 비교)지원 목록을 표로 두고 상단에 파이프라인 요약을 얹습니다. 이력서를 공고 JD와 키워드 대조해 적합도를 제시합니다파이프라인 요약(상태별 건수)을 목록 위에 두는 배치를 S-18 상단에 채용 ② 이력서 ↔ JD 항목 대조를 항목 단위로 나열하는 방식이 FR-96 축별 근거 표시와 동일점수만 크게 보여 주는 방식은 미채택 — FR-96은 판단 불가 축·상한 사유를 함께 노출해야 하므로, 점수 단독 표시는 오해를 만듭니다. 축별 충족 항목을 항상 펼칠 수 있게 합니다
토스 — 1 thing per 1 page (toss.tech, 앱인토스 UX 가이드)한 화면에 하나의 과업, 명확한 단일 CTA, 최소 입력, 시각적 잡음 제거전 화면의 기본 문법. 특히 관리 상태 분류(1탭)와 상세 편집(시트)을 분리한 근거토스는 하단 탭 5개를 운용합니다 — 기존 문서의 “5탭 금지”는 토스 원칙이 아니라 당시 화면 수(11개) 기준의 판단이었으므로, 화면이 26개로 늘어난 지금 개정합니다

Possible Solutions

#결정 사항방안채택사유
1세션 상태를 어디에 두는가(a) TanStack Query ['auth','session'] (b) Zustand useSessionStore(a)쿠키가 HttpOnly라 클라이언트가 만들 수 있는 진실이 없습니다. username·expiresAt은 전적으로 서버 값이므로 서버 상태입니다. Zustand에 복사하면 no-global-by-default·“서버 데이터 스토어 복사 금지” 양쪽 위반입니다. 전역 스토어는 테마·토스트 2개를 유지합니다
2첫 진입 화면과 보관함 배치(a) 보관함을 5번째 탭으로 신설하고 /는 회사 목록 유지 (b) /를 보관함으로 교체 (c) /를 지원 대시보드로 바꾸고 보관함은 별도 탭(c) — 사용자 확정(2026-08-08)초안은 (a)였고 랜딩을 Open Question으로 올렸는데, 사용자가 (c)로 확정했습니다. 근거: 앱을 여는 가장 잦은 동기가 “내 지원이 지금 어디까지 왔나”이고, 공고 판단은 알림을 받은 뒤의 행동이라 진입 동기가 다릅니다. 회사 목록은 등록·소스 관리용이라 매일 열 화면이 아니므로 /companies로 내립니다. 초기 빈 화면 문제(지원 0건일 때 대시보드가 텅 빔)는 대시보드 empty를 온보딩 진입점으로 설계해 해소합니다(아래 S-18). 경로 상수를 쓰는 기존 3파일(ApplicationListPage.tsx:14·ManualJobPostingPage.tsx:10·JobPostingDetailPage.tsx:12)은 FE-22가 일괄 갱신합니다
3서류·연락 검토·가중치 설정을 탭으로 올릴 것인가(a) 탭 추가 (b) 기존 탭 하위 진입점(b)서류는 이력서 갱신 시에만, 가중치는 초기 1회, 연락 검토는 Discord 딥링크가 주 경로입니다. 일일 사용 빈도가 낮은 화면을 탭에 올리면 탭의 의미가 희석됩니다. 서류·연락 검토는 지원 탭 하위, 가중치는 매칭 탭 하위, 소스 상태는 운영 탭 세그먼트로 붙입니다. 탭은 5개에서 더 늘리지 않습니다
4관리 상태 편집 UI(a) 필드 6개를 한 시트에 (b) 분류(1탭)와 상세 편집(시트)을 분리(b)사용자가 목록에서 하는 일의 99%는 “관심/제외” 분류 1건입니다. 여기에 우선순위·목표일·태그·메모를 같이 물으면 분류 자체가 무거워져 분류율이 떨어집니다(Success Metrics “마감 전 처리율”이 직접 타격). 분류는 카드에서 시트 1탭·버튼 1탭, 상세 편집은 공고 상세의 별도 시트로 분리합니다
5보관함 페이지네이션(a) 페이지 번호 (b) hasNext 기반 “더 보기”(useInfiniteQuery)(b)수천 건 규모에서 페이지 번호는 의미가 없습니다(3페이지가 무엇인지 사용자가 모름). PageResponse.hasNext가 이미 계약에 있으므로 그대로 쓰면 됩니다. 기존 pages/company/Pagination.tsx를 공용으로 승격하지 않습니다 — 승격하면 CompanyListPage를 수정해야 하고, 회귀 위험 대비 이득이 없습니다
6대시보드 칸반의 데스크톱 레이아웃(a) 2단 그리드 (b) 가로 스크롤 컬럼(b)기존 Open Question #3(“2단 레이아웃 만들지 않음”)을 뒤집지 않습니다. 모바일 480px 폭에서 컬럼 1.2개가 보이는 가로 스크롤(peek)로, ≥md에서도 같은 폭·같은 컴포넌트를 씁니다. 코드 경로가 하나라 다크 모드·접근성 검증 비용이 절반입니다
7이력서 프로필 편집 폼의 상태(a) Query 데이터를 직접 수정 (b) 로컬 useReducer 드래프트(b)항목 배열 3종(기술·경력·근무 선호)의 추가·삭제·수정이 있는 폼입니다. **드래프트는 서버 데이터의 복사가 아니라 “아직 저장하지 않은 사용자 입력”**이라 별개 값입니다. 저장 성공 시 드래프트를 버리고 캐시를 재조회해 SSOT를 되돌립니다
8추천도 재평가(동기, 최대 5분)의 대기 UI(a) 스피너 + 무한 대기 (b) 진행 안내 + 취소 불가 명시 (c) 폴링(b)계약이 동기 API입니다(POST /api/recommendations/re-evaluations). 폴링(c)은 상태 조회 엔드포인트가 없어 불가능합니다. “관심 공고 N건을 다시 평가하고 있어요 · 최대 5분 걸릴 수 있어요”를 명시하고 화면 이탈을 막지 않되(이탈 시 백그라운드 진행), 결과는 재진입 시 목록으로 확인하게 합니다. 프록시 타임아웃은 해소됨 — BE가 web/nginx.conf/api/proxy_read_timeout 600s를 적용합니다(C-5 확정, BE-56 단독 소유 — FE 티켓은 이 파일을 건드리지 않습니다). 서버가 재평가 대상 1,000건 상한을 두고 응답에 elapsedMillis를 실어 실측을 남깁니다
9파일 업로드 진행률(a) XHR로 진행률 표시 (b) 스피너만(b)진행률에는 XMLHttpRequest가 필요해 apiRequest(fetch) 계층을 이원화해야 합니다. 20MB 상한 + 로컬 Docker 환경이라 체감 대기가 1초 미만입니다. 지금 규모에 과한 방안으로 미채택합니다
10신규 색 토큰(a) 등급·우선순위·신뢰도용 토큰 신설 (b) 기존 토큰 + 형태 축(b)기존 문서가 “새 토큰은 추가하지 않습니다”를, 0730 문서가 “새 원색·토큰은 추가하지 않는다”를 명문화한 선례가 있습니다. 등급 4종·신뢰도 3종은 기존 Chipfill × tone 2축(채움/테두리/없음)으로 전부 표현됩니다 — WorkArrangementLabel의 확신도 위계와 같은 문법이라 학습 비용도 0입니다. 우선순위는 색 없이 기호(/)로 표현해 accent 1곳 원칙을 지킵니다
11오프라인·낙관적 업데이트 범위분류·상한 해제만관리 상태 분류(S-16)와 59점 상한 해제(S-24)는 단일 값 토글이라 롤백이 명확합니다. 상세 편집·연락 반영·담당자·서류 연결은 다중 필드거나 서버에서 파생 결과가 생겨(상태 전이·면접 생성) 낙관적 처리 시 화면이 거짓말을 합니다 — 미적용
14서류 업로드 실패 이력을 어디에 두는가 (B)(a) 별도 화면·탭 신설 (b) 서류 보관함(S-20) 하위 (c) 운영 화면(S-11) 세그먼트(c)발생 규모가 연 20행(3년 60행) 이라 별도 화면·탭은 과합니다. (b)는 보안 신호를 일상 화면에 묻습니다 — 실패 6종 중 2종(PATH_VALIDATION_FAILED·STORAGE_ROOT_ESCAPE)이 경로 이탈 시도 기록이고, 이 기능을 살린 결정 사유가 바로 그 사후 추적입니다. 업로드 실패 자체는 S-22가 즉시 400 인라인으로 알려 주므로 사후 조회의 주 동기는 회고가 아니라 보안 점검이고, 그건 운영 관심사입니다
15운영 세그먼트가 5개가 되는 문제 (B 부수)(a) 프리미티브를 가로 스크롤로 개조 (b) 라벨 축약(b)Segment(components/ui/Segment.tsx:15)는 주석부터 “2~3개 전환”용이고 inline-flex라 넘치면 잘립니다. 운영 화면 컨테이너는 max-w-md(448px) + px-4 = 가용 416px인데, 5자 라벨 5개는 약 478px로 넘칩니다(4개까지는 384px로 안전). 공용 프리미티브를 개조하면 기존 5개 화면에 파급되므로, 3단계에서 5개가 되는 시점에 라벨을 2자로 축약합니다(수집·알림·웹훅·서류·소스 → 약 268px). 운영 화면 안에서는 축약해도 의미가 유지됩니다
12도입하지 않는 것드래그 앤 드롭(방안 벤치마킹 참조) · 무한 스크롤 자동 로드(수동 “더 보기”) · 파일 미리보기 뷰어(PDF.js 등 — 다운로드로 대체) · 리치 텍스트 에디터(메모는 plain textarea) · 알림 센터(Discord가 알림 채널) · 클라이언트 사이드 검색 인덱스(서버 필터로 충분)

Detail Design

화면 목록

기존 문서의 마지막 ID가 S-13이므로 S-14부터 이어갑니다.

ID화면라우트단계주 API요구사항소유 티켓
S-14로그인/login1POST /api/auth/loginFR-70FE-23
S-15관심 공고 보관함/watchlist1GET /api/job-postingsFR-73,77,78FE-24
S-16관리 상태 분류 시트(S-15·S-04 내 시트)1PUT /api/dedup-groups/{id}/watch-state · PUT /api/job-postings/{id}/watch-stateFR-73,75,77FE-25
S-17관리 상태 상세 편집 시트(S-04 내 시트)1PUT·DELETE·GET .../historiesFR-76,77FE-25
S-18지원 대시보드 (앱 랜딩)/1GET /api/dashboardFR-79FE-26
S-19담당자 관리 (섹션 + 시트)(S-07 내)1/api/applications/{id}/contacts CRUDFR-80FE-27
S-20서류 보관함 (계열 목록)/documents2GET /api/documents/seriesFR-81FE-32
S-21서류 계열 상세 (버전 목록)/documents/series/:seriesId2GET /api/documents/series/{id}FR-81,82,85FE-34
S-22서류 업로드 시트(S-20·S-21 내 시트)2POST /api/documentsFR-82,83,84FE-33
S-23이력서 프로필 검토·확정/resume-profile2/api/resume-profiles/**FR-95FE-35
S-24지원 추천도 (섹션 + 등급 뱃지)(S-04 내 섹션 / S-15 카드)2GET /api/job-postings/{id}/recommendationFR-96,97FE-36
S-25제출 서류 연결 (섹션 + 선택 시트)(S-07 내)2/api/applications/{id}/documentsFR-85FE-37
S-26연락 검토 목록/contact-events3GET /api/contact-eventsFR-89,90FE-42
S-27연락 검토 상세·반영/contact-events/:contactEventId3GET·POST .../decisionsFR-90,91FE-43
S-28추천도 가중치 설정/settings/recommendation3GET·PUT /api/recommendations/weightsFR-97FE-45

기존 화면 확장

화면확장 내용단계소유 티켓
S-04 공고 상세관리 상태 섹션(S-16·S-17 진입)1FE-29
S-04 공고 상세추천도 섹션(S-24) · 59점 상한 해제 토글2·3FE-38 · FE-46
S-04 공고 상세매칭 근거(필드별 일치) 표시3FE-47
S-07 지원 상세담당자 섹션(S-19)1FE-27
S-07 지원 상세제출 서류 섹션(S-25)2FE-37
S-11 운영웹훅 수신 이력 · 터널 상태1FE-28
S-11 운영서류 업로드 실패 이력2FE-50
S-11 운영소스 상태 레지스트리 6종3FE-44
S-18 대시보드검토 대기 연락 배너3FE-48
S-00 앱 셸5탭 + 인증 게이트 + 로그아웃1FE-22 · FE-23

상태 표기 규칙 (신규 도메인 → 기존 토큰 매핑)

신규 색 토큰 0건. 신규 표시는 전부 아래 매핑으로 처리합니다. 매핑은 Chipfill(filled/outlined/none) × tone(5종) 2축을 씁니다 — WorkArrangementLabel의 확신도 위계와 같은 문법입니다.

표시 대상형태배경 토큰텍스트 토큰
관리 상태INTERESTED 관심filled accent--accent-subtle--accent-on-subtle
관리 상태PLANNED 지원 예정filled positive--positive-subtle--positive
관리 상태EXCLUDED 제외none--text-tertiary
관리 상태미분류outlined neutral없음(테두리 --border)--text-secondary
지원 완료 파생 (hasApplication)기존 AppliedBadge 재사용
우선순위HIGH + 굵게 (색 미사용)--text-primary
우선순위NORMAL표시 없음
우선순위LOW--text-tertiary
추천 등급STRONGLY_RECOMMENDED 강력 추천filled positive--positive-subtle--positive
추천 등급RECOMMENDED 추천outlined positive없음--positive
추천 등급REVIEW 검토filled neutral--neutral-chip-bg--neutral-chip-text
추천 등급NOT_RECOMMENDED 비추천none--text-tertiary
추천 등급평가 불가(notEvaluableReason)outlined neutral없음--text-tertiary
59점 상한 적용capApplied && !capReleasedfilled warning--warning-subtle--warning
59점 상한 해제됨capReleasedoutlined neutral + 원래 사유 병기없음--text-secondary
축 충족FULL / PARTIAL / NONE채움 / 테두리 / 없음 (positive tone)위 규칙과 동일
축 판단 불가judgeable === false행 전체 --surface-muted + 텍스트 --text-tertiary + “판단 불가” 라벨--surface-muted--text-tertiary
점수·충족률 게이지트랙 --border, 채움 --positive--border
연락 후보 신뢰도HIGH 높음filled positive--positive-subtle--positive
연락 후보 신뢰도MEDIUM 보통outlined neutral없음--text-secondary
연락 후보 신뢰도LOW 낮음filled warning--warning-subtle--warning
종료 지원(반영 불가)terminal === truenone + 자물쇠 문구--text-tertiary
문서 유형이력서/자소서/포트폴리오/기타filled neutral--neutral-chip-bg--neutral-chip-text
현재 프로필 원본isCurrentProfileSourcefilled accent--accent-subtle--accent-on-subtle
소스 레지스트리 상태ACTIVE 활성filled positive--positive-subtle--positive
소스 레지스트리 상태DISCOVERED 발견됨outlined neutral없음--text-secondary
소스 레지스트리 상태TRANSIENT_FAILURE 일시 실패filled warning--warning-subtle--warning
소스 레지스트리 상태ACCESS_RESTRICTED 접근 제한filled danger--danger-subtle--danger
소스 레지스트리 상태UNSUPPORTED 미지원filled neutral--neutral-chip-bg--neutral-chip-text
소스 레지스트리 상태DISABLED 비활성none--text-tertiary
매칭 근거 발췌의 일치 구간<mark> 배경--accent-subtle--accent-on-subtle
웹훅 검증 결과OK / 실패 3종filled positive / filled danger위 규칙과 동일
업로드 실패 사유 (보안)PATH_VALIDATION_FAILED · STORAGE_ROOT_ESCAPEfilled danger + 자물쇠 기호--danger-subtle--danger
업로드 실패 사유 (사용자 입력)EXTENSION_NOT_ALLOWED · SIZE_EXCEEDEDoutlined neutral없음--text-secondary
업로드 실패 사유 (시스템)STORAGE_IO_FAILED · OTHERfilled warning--warning-subtle--warning
터널 도달reachable true/falsefilled positive / filled danger위 규칙과 동일

accent 1곳 원칙: 각 화면에서 --accent(원색)는 주 CTA·활성 탭 1곳에만 씁니다. 관리 상태 INTERESTED 칩은 --accent-subtle 배경 + --accent-on-subtle 텍스트라 원색을 쓰지 않으므로 이 원칙과 충돌하지 않습니다.

S-14 로그인 — 유일한 무인증 화면

참고한 토스 패턴: 1 thing per 1 page + Minimum Input. 아이디·비밀번호 2개만 묻고, 화면 하단에 CTA 1개. 로고·마케팅 문구·회원가입 링크 없음(단일 사용자 도구).

┌────────────────────────────────┐
│                                │  ← 상단 여백 크게 (탭바·헤더 없음)
│  공고알림                        │  text-2xl bold
│  로그인하고 계속하세요              │  text-sm text-secondary
│                                │
│  아이디                          │
│  ┌──────────────────────────┐  │  TextField (autoComplete="username")
│  │                          │  │
│  └──────────────────────────┘  │
│  비밀번호                        │
│  ┌──────────────────────────┐  │  TextField type=password
│  │                          │  │  (autoComplete="current-password")
│  └──────────────────────────┘  │
│                                │
│  ⚠ 아이디 또는 비밀번호가 맞지     │  인라인 에러 (danger)
│    않아요                       │  — 입력 필드 연관 실패는 인라인
│                                │
│  ┌──────────────────────────┐  │
│  │        로그인             │  │  Button primary, 단일 CTA
│  └──────────────────────────┘  │
│                                │
└────────────────────────────────┘
상태UI
loading부팅 시 세션 확인 중에는 로그인 화면도 렌더하지 않습니다 — 전체 화면 빈 상태(레이아웃 점프·로그인 화면 깜빡임 방지). 로그인 제출 중에는 CTA가 “로그인 중…” + disabled
empty해당 없음 (입력 폼)
errorINVALID_CREDENTIAL → “아이디 또는 비밀번호가 맞지 않아요”를 비밀번호 필드 아래 인라인. LOGIN_LOCKED(429) → 서버 메시지를 그대로 노출(“N회 실패로 잠겼어요 · HH:MM에 다시 시도할 수 있어요”) + CTA disabled. NETWORK_ERROR → “서버에 연결하지 못했어요” + [다시 시도]
success세션 쿼리 무효화 → AuthGate가 원래 가려던 경로(location.state.from)로 복귀. from이 없으면 /

보충 규칙

  • AuthGate는 앱 셸 바깥이 아니라 routes.tsx의 최상위 element로 배치합니다. /login만 게이트 밖에 둡니다.
  • 게이트 판정은 GET /api/auth/session의 3분기입니다 (C-4 확정 — 초안의 “401이면 로그인” 2분기에서 변경).
응답의미게이트 동작
200 { authRequired: false, authenticated: false, ... }auth.required 플래그 OFF (배포 1-8 ~ 1-9 창)통과. 로그인 화면을 띄우지 않고, 헤더의 로그아웃 버튼도 숨깁니다(끊을 세션이 없음)
200 { authRequired: true, authenticated: true, username, expiresAt }플래그 ON + 유효 세션통과. 로그아웃 버튼 노출
401 UNAUTHENTICATED플래그 ON + 세션 없음·만료/login으로 이동(state.from에 원래 경로)
  • 이 계약으로 배포 1-8(FE) ~ 1-9(플래그 ON) 창의 모순이 구조적으로 사라집니다 — “로그인 화면은 떠 있는데 API는 열려 있는” 상태가 발생하지 않고, 1-9 롤백 시에도 FE 재배포가 필요 없습니다(플래그를 끄면 세션 응답이 authRequired: false로 바뀌고 게이트가 자동으로 통과 모드가 됩니다).
  • /api/auth/session은 인증 제외 경로가 아닙니다 — 플래그 ON + 무세션의 401이 곧 계약입니다. 따라서 이 요청의 401은 전역 401 핸들러를 타지 않고 useSession이 직접 소비합니다(무한 루프 방지).
  • POST /api/auth/login은 플래그와 무관하게 항상 동작하므로, 플래그 ON 전에도 로그인 화면을 실제로 검증할 수 있습니다.
  • 세션 만료 시각(expiresAt)은 화면에 노출하지 않습니다 — 만료되면 다음 요청이 401을 주고 게이트가 로그인으로 보냅니다. 만료 카운트다운은 불안을 만들 뿐 사용자가 할 수 있는 일이 없습니다.
  • 로그아웃은 앱 셸 헤더의 ThemeToggle 옆에 텍스트 버튼으로 둡니다. 실패해도 로컬 캐시를 비우고 /login으로 보냅니다(서버 세션이 이미 없는 경우가 정상이므로).
  • credentials: 'same-origin': /api는 nginx 동일 오리진 프록시이므로 include가 아니라 same-origin으로 충분합니다(계약 명시).

S-15 관심 공고 보관함 — “오늘 무엇을 결정해야 하나”

참고한 토스 패턴: 리스트 상단 고정 필터 칩 + 카드 리스트. 필터는 열지 않아도 현재 조건이 보이고(칩), 세부 조건은 시트로 접습니다. 카드 하나에 한 줄 요약 + 액션 1개.

┌────────────────────────────────┐
│ 보관함                          │  h1
│ 관심 12 · 지원 예정 3            │  text-sm text-secondary (클라이언트 집계 아님 — 아래 주)
├────────────────────────────────┤
│ [전체] [관심] [지원 예정] [제외]  │  Segment (watchStatus, URL param)
│ ┌────────────────────────────┐ │
│ │ 🔍 제목 검색                 │ │  TextField (keyword, debounce 300ms)
│ └────────────────────────────┘ │
│ [필터 2] [마감 임박순 ▾]         │  Chip(활성 필터 수) · Select(sort)
├────────────────────────────────┤
│ ┌────────────────────────────┐ │
│ │ ▲ 당근  [관심]              │ │  우선순위▲ · 회사 · 관리상태 칩
│ │ 백엔드 엔지니어              │ │  title (2줄 말줄임)
│ │ D-3 · Greenhouse, 사람인    │ │  DeadlineText · groupPlatforms
│ │ [강력 추천 92]  #보상좋음     │ │  등급 칩(2단계~) · 태그
│ │                    [분류 ▾] │ │  → S-16 시트
│ └────────────────────────────┘ │
│ ┌────────────────────────────┐ │
│ │ 우아한형제들 [지원 예정] ✅지원 │ │  hasApplication → AppliedBadge
│ │ …                           │ │
│ └────────────────────────────┘ │
│                                │
│        [ 더 보기 ]              │  hasNext일 때만
└────────────────────────────────┘

필터 시트([필터 2] 탭 시)

┌────────────────────────────────┐
│ 필터                      [닫기]│
│ 플랫폼                          │  다중 선택 Chip (repeatable query)
│ [Greenhouse][사람인][점핏][…]    │
│ 회사                            │  companyId[] repeatable OR (C-3 확정)
│ [당근][배민][우리은행][…]        │  다중 선택 Chip (관심 회사 우선 노출)
│ 태그                            │  tag[] repeatable OR (D 확정)
│ [#보상좋음][#원격][#성장]        │  등록된 태그만 노출 (watchedOnly 함의)
│ 공개 상태   [전체][진행 중][마감] │  postingStatus
│ 마감일      [____] ~ [____]     │  DateField 2개 (deadlineFrom/To)
│ [ ] 관리 상태가 있는 공고만        │  watchedOnly
│ [ ] 매칭된 공고만                │  matchedOnly
│                                │
│ [초기화]        [ 적용 ]         │  단일 주 CTA
└────────────────────────────────┘
상태UI
loading카드 스켈레톤 5개(--skeleton). 헤더·세그먼트·검색은 즉시 렌더 — 필터 조작이 스켈레톤 중에도 가능해야 합니다. “더 보기” 로딩은 목록 하단에 스켈레톤 2개 추가(기존 목록 유지)
empty (분류 전)watchedOnly=false + 필터 없음 + 0건 → “아직 보관함에 공고가 없어요 / 매일 자정 수집이 끝나면 매칭된 공고가 여기 쌓여요” + [회사 등록하기]
empty (분류 0건)watchStatus 세그먼트가 관심인데 0건 → “관심으로 분류한 공고가 없어요 / 전체 탭에서 공고를 관심으로 옮겨 보세요” + [전체 보기]
empty (필터 결과 0건)필터·검색이 1개 이상 걸린 상태에서 0건 → 위 두 문구와 다른 문구: “조건에 맞는 공고가 없어요 / 필터를 완화해 보세요” + [필터 초기화]. 활성 필터 요약을 함께 노출(“플랫폼 2개 · 마감일 범위”)
errorErrorState “공고를 불러오지 못했어요” + [다시 시도]. 필터 바는 유지 — 조건을 바꿔 재시도할 수 있어야 합니다
error (400 WATCH_STATE_FILTER_LIMIT_EXCEEDED)PRIORITY_DESC 정렬 또는 tag 필터의 대상 그룹이 상한을 넘었습니다. 응답의 actualCount·limit으로 문구를 조립합니다 — “관심 공고가 {actualCount}건이라 정렬할 수 없어요 / {limit}건 이하가 되도록 회사·플랫폼·마감일 필터로 좁혀 주세요” + [필터 열기]. 정렬은 DISCOVERED_DESC로 자동 복귀시켜 화면이 비어 있지 않게 합니다
error (400 JOB_POSTING_SORT_NOT_APPLICABLE)PRIORITY_DESC·tagwatchedOnly=false를 명시한 조합입니다. 사용자 안내 대상이 아니라 클라이언트 버그입니다 — FE가 잠금 규칙(아래 보충)을 지키면 발생하지 않습니다. 일반 오류 토스트 + console.error 로깅으로 처리하고 “필터를 좁혀 주세요” 같은 사용자 행동 안내를 띄우지 않습니다
error (400 그 외)VALIDATION_FAILED·BAD_REQUEST(enum 형식·size 범위) → “필터 값이 올바르지 않아요” + [필터 초기화]
success위 와이어프레임

보충 규칙

  • 헤더의 관심 12 · 지원 예정 3은 클라이언트 집계가 아닙니다. PageResponse.totalCount는 현재 필터 기준 총계이므로, 세그먼트별 건수는 별도 조회 없이는 알 수 없습니다. → 표시하지 않습니다. 대신 현재 조건의 totalCount만 “N건”으로 노출합니다(계약 내에서 정확한 값만 보여 준다는 원칙).
  • dedupGroupId === null이어도 분류할 수 있습니다 (C-1 확정). 초안에서는 분류 버튼을 비활성했으나, BE가 PUT /api/job-postings/{jobPostingId}/watch-state 를 신설해 쓰기 시점에 단독 그룹을 만들어 줍니다. 카드는 dedupGroupId가 있으면 그룹 기준 엔드포인트를, 없으면 공고 기준 엔드포인트를 호출하고 응답의 dedupGroupId로 이후 요청을 그룹 기준으로 전환합니다 — 이 분기는 useSaveWatchState 훅이 흡수하고 컴포넌트는 모릅니다.
  • 409 POSTING_DEDUP_KEY_ABSENT (백필 이전에 만들어져 dedup_key가 아예 없는 잔존 공고)는 분류가 불가능합니다 — 이때만 토스트 “이 공고는 아직 분류할 수 없어요 · 자정 배치 이후 다시 시도해 주세요” + 낙관적 업데이트 롤백입니다. 배포 절차 1-4 백필 완료 후에는 발생하지 않습니다.
  • matchScore·recommendation은 단계별 점진 노출입니다 — null이면 해당 표시를 렌더하지 않습니다(1단계 배포 시점에는 전부 null). 0으로 대체하지 않습니다.
  • 목록에서도 매칭 여부는 matched로만 판단합니다 — 교차 목록에는 matchScore·matched가 오지만 matchThreshold·excludedByKeyword는 오지 않으므로(상세 전용), 목록에서 점수 비교로 매칭을 판정하는 코드가 생길 여지 자체를 만들지 않습니다. 판정은 utils/matchEvidence.tsresolveMatchOutcome을 경유합니다.
  • 필터·정렬·검색은 전부 URL search params가 SSOT입니다(?watchStatus=INTERESTED&companyId=3&platform=SARAMIN&sort=DISCOVERED_DESC). 뒤로가기·링크 공유가 동작합니다. page는 URL에 넣지 않습니다(useInfiniteQuery가 소유).
  • 회사 필터(C-3 확정)companyId를 repeatable로 보냅니다(OR). 선택지는 관심 회사(companyOrigin=WATCHED)를 위에, 발견 회사를 아래에 둡니다 — 발견 회사가 수백 곳까지 늘어날 수 있어(NFR-1) 검색 가능한 목록으로 렌더합니다.
  • 정렬 기본값은 DISCOVERED_DESC 고정입니다(B-2). dba 실측에서 deadline_at71.4%(4,738/6,633)가 NULL이라 DEADLINE_ASC는 인덱스 조기 종료가 불가능합니다. 선택지 4종의 한국어 라벨: 최근 발견순(기본) / 마감 임박순 / 제목순 / 우선순위순.
  • DEADLINE_ASC는 필터와 함께 쓰도록 UI가 유도합니다 — “마감 임박순”을 고르면 마감일 필터가 비어 있는 경우 deadlineFrom = 오늘을 자동으로 채우고 필터 칩에 “마감일: 오늘 이후”를 표시합니다. 사용자가 지우면 그대로 두되(강제하지 않음), 마감일 없는 공고 4,738건이 뒤에 붙는다는 안내를 1줄 노출합니다.
  • PRIORITY_DESCtagwatchedOnly=true를 함의합니다(C-6·D). 서버가 미지정 시 true로 강제하고 명시적 false는 400 JOB_POSTING_SORT_NOT_APPLICABLE 이므로, FE는 둘 중 하나라도 활성이면 watchedOnly 체크박스를 켜고 잠급니다(해제 불가 + “분류한 공고만 대상이에요” 안내). 둘 다 해제하면 잠금이 풀립니다. 이 잠금이 지켜지면 JOB_POSTING_SORT_NOT_APPLICABLE은 발생하지 않습니다 — 발생하면 FE 버그입니다.
  • 태그 필터(D 확정)tag를 repeatable로 보냅니다(OR). 선택지는 실제로 등록된 태그만 노출합니다(자유 입력 금지 — 오타로 0건이 나오는 경험을 막습니다). 태그는 관리 상태가 있어야 존재하므로 watchedOnly를 함의하고, PRIORITY_DESC와 같은 1,000건 상한을 공유합니다(같은 그룹 id 해석 기계를 재사용).
  • 카드 탭 → 공고 상세(S-04). [분류] 버튼 탭 → S-16 시트(카드 탭과 이벤트 전파 분리).

S-16 관리 상태 분류 시트 — 1탭 분류

참고한 토스 패턴: 선택지 3개짜리 액션 시트. 화면 전체를 덮지 않고 하단에서 올라오며, 선택 즉시 닫힙니다. 저장 버튼을 따로 두지 않습니다(선택 = 확정).

┌────────────────────────────────┐
│                                │  overlay
│ ┌────────────────────────────┐ │
│ │ 백엔드 엔지니어 · 당근        │ │  대상 확인 (title 1줄 말줄임)
│ │                            │ │
│ │ ○ 관심                      │ │  선택 즉시 PUT + 시트 닫힘
│ │ ● 지원 예정                  │ │  현재 값에 ● (radiogroup)
│ │ ○ 제외                      │ │
│ │ ─────────────────────────  │ │
│ │ ✕ 분류 해제                  │ │  DELETE (관리 상태가 있을 때만)
│ │                            │ │
│ │ 자세히 편집 →                │ │  → 공고 상세로 이동 후 S-17
│ └────────────────────────────┘ │
└────────────────────────────────┘

제외 선택 시에만 사유 입력 단계가 이어집니다(1단계 → 2단계 전환, 시트 안에서).

│ │ 제외 사유 (선택)             │ │
│ │ ┌────────────────────────┐ │ │  TextField, 최대 200자
│ │ │                        │ │ │
│ │ └────────────────────────┘ │ │
│ │ [건너뛰기]      [ 제외 ]     │ │
상태UI
loading선택 직후 낙관적 업데이트로 목록 칩이 즉시 바뀌고 시트가 닫힙니다. 별도 로딩 UI 없음
empty해당 없음
error낙관적 업데이트 롤백 + 토스트 “분류를 저장하지 못했어요”. 404 DEDUP_GROUP_NOT_FOUND → 토스트 “이 공고는 중복 판정이 갱신됐어요” + 목록 무효화(재조회). 400 WATCH_STATE_INVALID_FIELD → 사유 입력 아래 인라인
error (409 POSTING_DEDUP_KEY_ABSENT)공고 기준 저장 경로에서만 발생합니다 — dedup_key가 없는 백필 이전 잔존 공고입니다. 롤백 + 토스트 “이 공고는 아직 분류할 수 없어요 · 자정 배치 이후 다시 시도해 주세요”. 재시도 액션을 주지 않습니다(사용자가 지금 할 수 있는 일이 없음)
error (409 FEATURE_DISABLED)watchlist.management 플래그 OFF입니다(C 확정). 관리 상태 UI 자체를 숨기거나 “준비 중”으로 표시합니다 — 사용자 조작 실패가 아니므로 롤백 토스트를 띄우지 않습니다. 보관함 목록 자체는 별도 엔드포인트라 정상 동작합니다
error (404 JOB_POSTING_NOT_FOUND)롤백 + 토스트 “공고를 찾을 수 없어요” + 목록 무효화
success목록 카드의 관리 상태 칩 갱신 + 토스트 없음(시각 변화 자체가 피드백 — 토스 원칙)

보충 규칙

  • 저장 경로가 2개입니다 (C-1 확정) — dedupGroupId가 있으면 PUT /api/dedup-groups/{dedupGroupId}/watch-state, 없으면 PUT /api/job-postings/{jobPostingId}/watch-state입니다. 후자는 서버가 단독(singleton) 그룹을 만든 뒤 저장하고 응답에 dedupGroupId를 실어 줍니다. 이 분기는 useSaveWatchState 훅 하나가 흡수하고 시트·카드는 어느 경로인지 모릅니다. 응답의 dedupGroupId를 캐시에 반영해 다음 요청부터 그룹 기준으로 나갑니다.
  • 이렇게 만든 단독 그룹은 다음 08:30 배치의 그룹 정체성 승계에 그대로 올라타므로 FE가 별도 이관을 하지 않습니다.
  • 낙관적 업데이트 적용 범위: status 1개 필드만. onMutate에서 useInfiniteQuery 캐시의 해당 아이템 watchStatus를 교체하고, onError에서 스냅샷 복원, onSettled에서 무효화합니다.
  • 제외를 고르면 사유를 선택 입력으로 묻습니다(계약상 exclusionReason은 optional). 건너뛰어도 제외가 저장됩니다 — 입력을 강제하면 제외율이 떨어집니다.
  • EXCLUDED가 아닌 상태로 바꿀 때는 exclusionReasonnull로 함께 보냅니다(계약: EXCLUDED가 아닌데 사유가 있으면 400).
  • 이 시트는 S-15 카드와 S-04 공고 상세 양쪽에서 열립니다 → components/watchlist/에 두고 자기 mutation을 소유합니다(시트 예외 규칙).

S-17 관리 상태 상세 편집 시트 — 부가 정보

참고한 토스 패턴: 설정 시트. 필드를 세로로 쌓고 하단에 단일 저장 CTA. 필드마다 라벨이 왼쪽, 값이 오른쪽에 오는 목록형이 아니라 폼형(입력이 주 과업이므로).

┌────────────────────────────────┐
│ 관리 정보                  [닫기]│
│                                │
│ 우선순위                        │
│ [ 높음 ][ 보통 ][ 낮음 ]         │  Segment (기본 보통)
│                                │
│ 지원 목표일                      │
│ ┌──────────────────────────┐   │  DateField (yyyy-MM-dd, nullable)
│ └──────────────────────────┘   │
│                                │
│ 태그  (10개까지)                 │
│ [#보상좋음 ✕][#원격 ✕] [+ 추가]  │  Chip 삭제 + 입력
│                                │
│ 메모  (1000자까지)               │
│ ┌──────────────────────────┐   │  TextArea (신규 프리미티브)
│ │                          │   │
│ └──────────────────────────┘   │
│                                │
│ 변경 이력                        │
│ 관심 → 지원 예정  08-05 14:20    │  최근 3건, [전체 보기]
│                                │
│ ┌──────────────────────────┐   │
│ │          저장             │   │  단일 CTA
│ └──────────────────────────┘   │
└────────────────────────────────┘
상태UI
loading관리 상태 본문은 공고 상세 응답의 watchState를 그대로 씁니다(C-1 — 추가 왕복 없음). 이력만 GET .../histories로 별도 조회하며 그 영역만 스켈레톤 2줄입니다. dedupGroupIdnull이면 이력 조회를 아예 하지 않습니다(그룹이 없으므로 이력도 없음). 그룹 기준 조회를 직접 쓰는 경우 응답은 봉투 { dedupGroupId, watchState } 입니다(B 확정 — 단독 WatchStateResponse가 아닙니다)
empty (미분류)200 { dedupGroupId, watchState: null } (C 확정 — 404가 아닙니다) → 폼을 기본값(우선순위 보통·나머지 빈 값)으로 열고 관리 상태 선택 UI를 활성한 채 “아직 분류하지 않은 공고예요 · 저장하면 관심으로 등록돼요” 안내. 이력은 없으면 빈 배열이므로 “변경 이력이 없어요”
empty (기능 준비 중)409 FEATURE_DISABLED → 관리 상태 섹션 자체를 숨기거나 “준비 중” 으로 표시합니다. 미분류(200)와 반드시 구분합니다 — 전자는 사용자가 지금 분류할 수 있고 후자는 할 수 없습니다
error (그룹 없음)404 DEDUP_GROUP_NOT_FOUND → 오류로 처리(토스트 + 목록 무효화). 이것만 404입니다
error조회 실패 → ErrorState + [다시 시도]. 저장 실패 400 WATCH_STATE_INVALID_FIELD → 위반 필드 아래 인라인(“태그는 10개까지 등록할 수 있어요” / “메모는 1000자까지예요”)
success토스트 “저장했어요” + 시트 닫힘 + 보관함·공고 상세 무효화

보충 규칙

  • 낙관적 업데이트 미적용(방안 11). 다중 필드라 부분 실패 시 어느 값이 되돌아갔는지 화면이 설명하지 못합니다.
  • DELETE는 관리 상태가 없어도 204(멱등) 이고 이력 조회는 없으면 빈 배열입니다(C 확정). 두 경로 모두 404 분기를 만들지 않습니다. 폐기된 WATCH_STATE_NOT_FOUND 코드를 참조하는 분기도 두지 않습니다.
  • 태그 입력은 Enter 또는 쉼표로 확정, 30자 초과·11번째 태그는 입력 단계에서 차단하고 이유를 인라인으로 표시합니다(서버 400을 기다리지 않음).
  • 관리 상태(status)는 이 시트에서 바꾸지 않습니다 — S-16의 책임입니다. 계약상 status는 필수이므로 현재 값을 그대로 실어 보냅니다.
  • 변경 이력은 최근 3건만 인라인, [전체 보기]는 같은 시트 내 확장(별도 화면 만들지 않음).

S-18 지원 대시보드 — “지금 어디까지 왔나” (앱 랜딩 /)

참고한 토스 패턴: 상단 요약 + 가로 스크롤 카드 섹션. 홈 화면의 “내 자산” 요약처럼 숫자를 먼저 보여 주고, 상세는 가로 스크롤로 넘깁니다. 세로 스크롤 1회로 3요소가 전부 보입니다. 빈 상태는 토스 홈의 “시작하기” 온보딩 카드 패턴 — 아무것도 없을 때 화면을 비워 두지 않고 다음 행동 하나를 제시합니다.

이 화면이 앱의 첫 진입 화면(/) 입니다(사용자 확정). 지원이 0건인 초기 사용자에게 텅 빈 화면을 보여 주지 않도록, empty 상태를 온보딩 진입점으로 설계합니다 — 지원이 쌓이면 같은 화면이 자연스럽게 3요소 대시보드로 전환됩니다.

┌────────────────────────────────┐
│ 지원                            │  h1
│ 진행 중 7건                      │  진행 중 4상태 count 합
├────────────────────────────────┤
│ 상태별                           │  h2
│ ┌───────┐┌───────┐┌───────┐    │  가로 스크롤 (컬럼 폭 고정)
│ │지원함 3││서류 2 ││면접 1 │…  │  컬럼 헤더: 라벨 + count
│ │───────││───────││───────│    │
│ │┌─────┐││┌─────┐││┌─────┐│    │  카드: 회사 · 공고 · 마감
│ ││당근 │││││배민 │││││우리││    │  최대 20건, 초과 시
│ ││…    │││││…   │││││…  ││    │  [전체 목록에서 보기]
│ │└─────┘││└─────┘││└─────┘│    │
│ └───────┘└───────┘└───────┘    │
├────────────────────────────────┤
│ 다가오는 면접 (14일)              │  h2
│ ┌────────────────────────────┐ │
│ │ D-2  배민 · 백엔드           │ │  daysUntil 강조
│ │ 2차 면접 · 08-10 14:00      │ │
│ └────────────────────────────┘ │
├────────────────────────────────┤
│ 오래 그대로예요 (14일+)           │  h2
│ ┌────────────────────────────┐ │
│ │ 당근 · 백엔드                │ │
│ │ 서류전형 · 21일째            │ │  daysSinceLastChange
│ │                  [상태 변경] │ │  → 기존 TransitStatusSheet
│ └────────────────────────────┘ │
└────────────────────────────────┘
상태UI
loading상단 요약 스켈레톤 1줄 + 칸반 컬럼 스켈레톤 3개 + 섹션별 카드 스켈레톤 2개
empty (온보딩)지원 0건 → 3섹션을 전부 숨기고 온보딩 카드 하나만 렌더합니다(빈 섹션 3개 나열은 잡음). 아래 “온보딩 empty” 와이어프레임 참조. 단일 CTA [관심 공고 보관함에서 시작하기]/watchlist. 보조 링크로 [회사 등록하기](→ /companies/new)를 텍스트 버튼으로 한 단계 낮춰 둡니다 — 회사가 0곳이면 보관함도 비어 있으므로 그 경로가 필요하지만, 주 동선은 하나여야 합니다(1 thing per 1 page)
empty (섹션별)칸반 컬럼 0건 → 컬럼은 유지하고 “없어요” 1줄(진행 중 4종은 계약상 항상 내려오므로 컬럼이 사라지지 않습니다). 면접 0건 → “예정된 면접이 없어요”. 장기 미변경 0건 → 섹션 전체를 숨깁니다 (좋은 상태이므로 굳이 빈 카드로 알릴 필요 없음)
error화면 전체 ErrorState + [다시 시도]. 부분 실패 개념 없음(단일 엔드포인트)
success위 와이어프레임

온보딩 empty 와이어프레임

┌────────────────────────────────┐
│ 지원                            │  h1 (요약 줄 없음 — 0건이므로)
│                                │
│ ┌────────────────────────────┐ │
│ │                            │ │
│ │  아직 지원한 곳이 없어요       │ │  text-lg bold
│ │                            │ │
│ │  관심 있는 공고를 보관함에     │ │  text-sm text-secondary
│ │  모아 두면, 지원한 뒤의        │ │  (다음 행동과 그 이유를 함께)
│ │  진행 상황이 여기에 쌓여요      │ │
│ │                            │ │
│ │ ┌────────────────────────┐ │ │
│ │ │ 관심 공고 보관함에서 시작 │ │ │  단일 주 CTA (accent 1곳)
│ │ └────────────────────────┘ │ │  → /watchlist
│ │                            │ │
│ │      회사 등록하기 →         │ │  텍스트 버튼 (위계 낮춤)
│ │                            │ │  → /companies/new
│ └────────────────────────────┘ │
└────────────────────────────────┘

보충 규칙

  • 랜딩 화면이므로 empty가 곧 온보딩입니다. 지원이 1건이라도 생기면 온보딩 카드가 사라지고 3요소 대시보드로 자동 전환됩니다 — 별도 “온보딩 완료” 상태나 dismiss 플래그를 두지 않습니다(전역 상태가 늘어나고, 지원을 전부 지운 사용자에게 다시 안내가 필요합니다).
  • 칸반은 가로 스크롤입니다(방안 6). 컬럼 폭은 컴포넌트 지역 상수(280px)로 두고 ≥md에서도 같은 값을 씁니다 — 2단 레이아웃을 만들지 않습니다.
  • 컬럼 순서는 계약의 statusBoard 배열 순서를 그대로 따릅니다. FE가 정렬하지 않습니다(no-logic-in-component).
  • 종료 4종 컬럼은 count > 0일 때만 내려옵니다 — FE가 없는 컬럼을 만들지 않습니다.
  • 카드의 allowedNextStatuses를 그대로 기존 TransitStatusSheet에 전달합니다. 전이 규칙을 FE가 다시 판단하지 않습니다.
  • upcomingInterviewDays·staleThresholdDays는 계약 기본값(14)을 고정으로 씁니다. 사용자 조정 UI는 만들지 않습니다(요구사항에 없음 — 오버엔지니어링 회피).
  • 진행 중 건수는 statusBoard에서 진행 중 4종의 count를 합산합니다 — 이건 계약 값의 단순 합이므로 훅(useDashboardSummary)이 아니라 순수 유틸(utils/dashboard.ts)로 추출합니다.
  • 대시보드 헤더 우측에 [전체 목록]/applications(기존 S-06 그대로)를 둡니다. ApplicationListPage의 본문은 수정하지 않고, 회사 목록 경로 상수(ApplicationListPage.tsx:14)만 FE-22가 /companies로 갱신합니다.

S-19 담당자 관리 — 지원 상세 내 섹션

참고한 토스 패턴: 목록형 섹션 + 우측 상단 추가. 항목 탭 → 편집 시트. 삭제는 시트 안 하단의 파괴적 액션(danger 텍스트 버튼).

지원 상세(S-07) 내
├────────────────────────────────┤
│ 담당자                    [+ 추가]│
│ ┌────────────────────────────┐ │
│ │ 김리크루터                   │ │
│ │ 피플팀 · recruit@example.com│ │  organization · emailAddress
│ │ 010-0000-0000               │ │
│ └────────────────────────────┘ │
└────────────────────────────────┘

담당자 시트(추가·편집 공용)

│ 담당자                     [닫기]│
│ 이름 *          [____________] │  필수
│ 소속            [____________] │
│ 이메일          [____________] │  type=email
│ 전화번호        [____________] │  type=tel
│ 메모            [____________] │  TextArea
│                                │
│ ┌──────────────────────────┐   │
│ │          저장             │   │
│ └──────────────────────────┘   │
│         담당자 삭제              │  편집 모드에서만, danger 텍스트
상태UI
loading섹션 스켈레톤 1줄
empty”등록한 담당자가 없어요 / 이메일을 등록하면 연락 이벤트를 이 지원 건과 연결할 수 있어요” + [담당자 추가]. 3단계 FR-91의 전제임을 문구로 안내해 등록 동기를 만듭니다
error조회 실패 → 섹션 내 ErrorState 축소형 + [다시 시도]. 저장 실패 400 VALIDATION_FAILED → 해당 필드 인라인. 404 APPLICATION_NOT_FOUND → 토스트 + 지원 목록으로
success목록 갱신 + 토스트 “저장했어요”

보충 규칙

  • 이메일은 서버가 소문자 정규화해 저장합니다 — FE는 입력값을 그대로 보내고, 응답 값을 표시합니다(FE에서 미리 소문자화하지 않음. 표시와 저장이 어긋나면 혼란).
  • 삭제는 확인 단계 없이 즉시 실행하되 토스트에 [실행 취소]를 두지 않습니다(계약에 복원 API 없음). 대신 시트 하단의 danger 텍스트 버튼이라 오탭 가능성이 낮습니다.
  • 이 섹션은 pages/application/ContactSection.tsx(화면 전용), 시트는 components/application/ContactSheet.tsx(자기 mutation 소유)로 나눕니다.

S-11 확장 (1단계) — 웹훅 수신 · 터널 상태

기존 운영 화면에 세그먼트 2개를 추가합니다(수집 이력 / 알림 발송 / 웹훅 수신). 터널 상태는 화면 최상단 고정 카드입니다.

│ 운영                            │
│ ┌────────────────────────────┐ │
│ │ 터널  ● 연결됨               │ │  reachable → positive/danger 칩
│ │ app.example.com · 3분 전 확인 │ │  publicHostname · checkedAt
│ └────────────────────────────┘ │
│ [수집 이력][알림 발송][웹훅 수신]  │  Segment
│ 최근 7일 · [전체 ▾]              │  days · result 필터
│ ┌────────────────────────────┐ │
│ │ ● OK  /api/webhooks/…      │ │
│ │ msg-abc123 · 08-08 09:12   │ │  providerMessageId · receivedAt
│ └────────────────────────────┘ │
│        [ 더 보기 ]              │
상태UI
loading터널 카드 스켈레톤 + 목록 스켈레톤 3개
empty”최근 7일 수신 이력이 없어요 / Gmail 연동을 아직 켜지 않았다면 정상이에요”(3단계 전에는 항상 이 상태)
error터널 조회 실패 → 카드에 “확인할 수 없어요”(중립, danger 아님 — 조회 실패와 터널 끊김은 다름). 목록 실패 → ErrorState
success위 와이어프레임. reachable=false && publicHostname=null → “터널이 설정되지 않았어요”(중립 문구, 에러 아님)

S-20 서류 보관함 (계열 목록) — 2단계

참고한 토스 패턴: 유형 필터 칩 + 목록 + 우측 하단 고정 CTA. “무엇을 올릴까”가 아니라 “무엇이 있나”를 먼저 보여 줍니다.

┌────────────────────────────────┐
│ ‹ 서류                          │  지원 탭 하위 (뒤로가기)
│ [전체][이력서][자소서][포트폴리오]│  Segment (documentType)
├────────────────────────────────┤
│ ┌────────────────────────────┐ │
│ │ [이력서] 백엔드 이력서        │ │  유형 칩 · seriesTitle
│ │ v3 · 버전 3개 · 08-08 갱신   │ │  latestVersionNumber·versionCount
│ └────────────────────────────┘ │
│ ┌────────────────────────────┐ │
│ │ [자소서] 당근 자소서          │ │
│ │ v1 · 버전 1개 · 08-01 갱신   │ │
│ └────────────────────────────┘ │
│                                │
│ ┌──────────────────────────┐   │  BottomActionBar
│ │       + 서류 올리기        │   │  → S-22 시트
│ └──────────────────────────┘   │
└────────────────────────────────┘
상태UI
loading카드 스켈레톤 3개. 세그먼트·CTA 즉시 렌더
empty (전체)“아직 올린 서류가 없어요 / 이력서를 올리면 공고별 지원 추천도를 계산할 수 있어요” + CTA를 화면 중앙에도 배치
empty (유형 필터)“이 유형의 서류가 없어요” + [전체 보기]
errorErrorState + [다시 시도]. 하단 CTA 유지(업로드는 가능)
success위 와이어프레임. 카드 탭 → S-21

S-21 서류 계열 상세 (버전 목록) — 2단계

참고한 토스 패턴: 최신 항목을 상단에 크게, 과거는 아래로 접히는 이력 목록. 각 항목의 주 액션은 하나(다운로드).

┌────────────────────────────────┐
│ ‹ 백엔드 이력서                  │
│ [이력서] · 버전 3개              │
├────────────────────────────────┤
│ ┌────────────────────────────┐ │
│ │ v3  최신  [현재 프로필 원본]  │ │  isCurrentProfileSource 칩
│ │ resume.pdf · 1.2MB          │ │  originalFileName·fileSizeBytes
│ │ 08-08 14:20 업로드           │ │
│ │ 이력서/2026-08-08_백엔드…    │ │  storedRelativePath (text-tertiary,
│ │                    [다운로드] │ │  파인더에서 찾을 수 있게)
│ └────────────────────────────┘ │
│ ┌────────────────────────────┐ │
│ │ v2                          │ │
│ │ …                    [다운로드]│ │
│ └────────────────────────────┘ │
│ ┌──────────────────────────┐   │
│ │    + 새 버전 올리기         │   │  → S-22 (seriesId 고정)
│ └──────────────────────────┘   │
└────────────────────────────────┘
상태UI
loading헤더 스켈레톤 + 버전 카드 스켈레톤 2개
empty계약상 계열에는 버전이 최소 1개 있으므로 발생하지 않습니다. 그래도 0건이면 “버전이 없어요” + [새 버전 올리기]
error404 DOCUMENT_SERIES_NOT_FOUND → “서류를 찾을 수 없어요” + [서류 목록으로]. 그 외 → ErrorState
success위 와이어프레임

보충 규칙

  • 다운로드는 <a href="/api/documents/versions/{id}/content" download>입니다. fetch로 받아 Blob을 만들지 않습니다 — 동일 오리진이라 세션 쿠키가 자동 전송되고, Content-Disposition: attachment를 브라우저가 처리합니다. no-direct-fetch 규칙과 무관합니다(fetch를 쓰지 않으므로).
  • 파일 크기는 utils/fileSize.ts(신규 순수 유틸)로 1.2MB 형태로 포맷합니다.
  • 버전 삭제·계열 제목 수정 UI는 만들지 않습니다 — 계약에 API가 없습니다(Open Question #5).

S-22 서류 업로드 시트 — 2단계

참고한 토스 패턴: 파일 선택 → 즉시 검증 → 확인 → 단일 CTA. 실패는 선택 직후 그 자리에서 알려 주고(서버 왕복 없이), 성공 경로만 CTA를 활성화합니다.

┌────────────────────────────────┐
│ 서류 올리기               [닫기]│
│                                │
│ 문서 유형                        │
│ [이력서][자소서][포트폴리오][기타]│  Segment (필수)
│                                │
│ ┌────────────────────────────┐ │  드롭존 (border-strong dashed,
│ │      파일을 선택하세요        │ │   surface-muted 배경)
│ │   pdf · docx · md · hwp     │ │  <input type="file"> + label
│ │        최대 20MB             │ │
│ └────────────────────────────┘ │
│  resume.pdf · 1.2MB      [변경] │  선택 후
│                                │
│ ⚠ 20MB를 넘는 파일이에요         │  클라이언트 선검증 (인라인 danger)
│                                │
│ 어디에 넣을까요                   │
│ ( ) 새 계열 만들기               │  seriesTitle 입력 노출
│ (•) 백엔드 이력서에 v4 추가       │  seriesId (같은 유형 계열만)
│                                │
│ ┌──────────────────────────┐   │
│ │        올리기             │   │  단일 CTA, 검증 통과 시에만 활성
│ └──────────────────────────┘   │
└────────────────────────────────┘
상태UI
loadingCTA “올리는 중…” + disabled. 시트 닫기 차단. 진행률 표시 없음(방안 9)
empty같은 유형의 기존 계열이 0개면 “새 계열 만들기”만 노출하고 라디오를 숨깁니다(선택지 1개는 선택지가 아님 — 토스 원칙)
error클라이언트 선검증: 확장자 4종 외 → “pdf·docx·md·hwp만 올릴 수 있어요”, 20MB 초과 → “20MB까지 올릴 수 있어요”. 서버 에러: DOCUMENT_EXTENSION_NOT_ALLOWED·DOCUMENT_SIZE_EXCEEDED → 같은 인라인 문구, DOCUMENT_PATH_ESCAPE → “파일 이름에 쓸 수 없는 문자가 있어요 / 이름을 바꿔 다시 올려 주세요”, 404 DOCUMENT_SERIES_NOT_FOUND → 토스트 + 계열 목록 무효화
success시트 닫힘 + 토스트 “올렸어요”. [이력서] 유형이면 토스트에 [프로필 만들기] 액션을 붙여 S-23으로 유도

보충 규칙

  • multipart 요청은 apiRequest를 통과시키되 Content-Type을 설정하지 않습니다(브라우저가 boundary를 붙여야 함). apiRequest는 항상 application/json을 넣으므로, FormData 전용 함수 apiUploadapi/client.ts에 추가합니다 — FE-21(쿠키 전환) 티켓이 소유해 2단계 티켓이 client.ts를 건드리지 않게 합니다.
  • 클라이언트 선검증(확장자·용량)은 utils/documentFile.ts(신규 순수 유틸)로 추출합니다(no-logic-in-component).
  • 파일 선택은 네이티브 <input type="file" accept=".pdf,.docx,.md,.hwp">입니다. 드래그 앤 드롭은 구현하지 않습니다(모바일 폭 기준 화면).

S-23 이력서 프로필 검토·확정 — 2단계

참고한 토스 패턴: 자동 채워진 값을 사용자가 확인·수정하고 하단 CTA로 확정하는 “검토 후 확정” 흐름(가입·본인확인의 정보 확인 단계). 자동 추출값과 사용자 수정값을 시각적으로 구분하지 않습니다 — 확정하면 똑같이 사용자의 값이므로.

┌────────────────────────────────┐
│ ‹ 이력서 프로필                  │
│ 원본: 백엔드 이력서 v3            │  sourceDocumentVersionId → 계열 링크
│ ⓘ 확정해야 추천도 계산에 쓰여요     │  미확정 안내 (accent-subtle 배너)
├────────────────────────────────┤
│ 기술 스택                 [+ 추가]│
│ ┌────────────────────────────┐ │
│ │ Kotlin                  [✕] │ │  skillName
│ │ 48개월 · 2026-06까지         │ │  usageMonths · lastUsedYearMonth
│ │ "Kotlin/Spring 기반 …"       │ │  evidence (text-tertiary, 2줄)
│ └────────────────────────────┘ │
├────────────────────────────────┤
│ 경력                     [+ 추가]│
│ ┌────────────────────────────┐ │
│ │ 백엔드 개발 · 60개월      [✕] │ │  jobCategory · totalMonths
│ └────────────────────────────┘ │
├────────────────────────────────┤
│ 근무 선호                 [+ 추가]│
│ ┌────────────────────────────┐ │
│ │ 재택 근무   [필수]        [✕] │ │  preferenceKeyword · required
│ └────────────────────────────┘ │
├────────────────────────────────┤
│ ┌──────────────────────────┐   │  BottomActionBar
│ │  임시 저장  │  프로필 확정   │   │  secondary + primary
│ └──────────────────────────┘   │
└────────────────────────────────┘

추출 실패 상태(시나리오 13)

│ ⚠ 이력서에서 텍스트를 읽을 수 없어요 │  warning-subtle 배너
│   항목을 직접 입력해 주세요          │
│   (스캔 이미지 PDF·hwp는 자동 추출이 │
│    되지 않아요)                    │
│                                │
│ 기술 스택                 [+ 추가]│
│ ┌────────────────────────────┐ │
│ │ 아직 등록한 기술이 없어요      │ │  섹션별 empty
│ │        [+ 기술 추가]         │ │
│ └────────────────────────────┘ │

확정 실행 중

│ ┌────────────────────────────┐ │
│ │ 프로필을 확정하고             │ │
│ │ 관심 공고를 다시 평가하고 있어요 │ │
│ │ 최대 5분 걸릴 수 있어요        │ │
│ │ (화면을 닫아도 계속 진행돼요)   │ │
│ └────────────────────────────┘ │
상태UI
loading초기 진입: GET /api/resume-profiles/current 조회 중 섹션 스켈레톤 3개. 확정 중: 위 진행 안내 카드 + CTA disabled
empty (프로필 없음)404(확정 프로필 없음) → “아직 프로필이 없어요 / 이력서를 올리면 기술·경력을 자동으로 읽어 초안을 만들어요” + [이력서 올리기](S-22, documentType=RESUME 고정)
empty (추출 실패)위 “추출 실패 상태” 와이어프레임. extractionFailed: true이고 항목 배열이 전부 빈 상태. 에러가 아니라 빈 상태로 취급합니다 — 등록 자체는 성공했기 때문입니다(계약: 201)
empty (섹션별)3개 섹션 각각 0건일 때 “아직 등록한 {기술/경력/근무 선호}가 없어요” + [+ 추가]
error저장 실패 409(이미 확정된 프로필 수정) → “이미 확정된 프로필이에요 / 새 이력서를 올려 다시 만들어 주세요” + [서류 보관함]. 확정 실패 → 토스트 + 폼 유지(입력 손실 없음). 조회 실패 → ErrorState
success확정 성공 → 결과 카드(“관심 공고 N건을 다시 평가했어요”) + [보관함에서 보기]. 실패 목록이 있으면 아래 “재평가 결과”(FE-39) 참조

보충 규칙

  • 폼 상태는 로컬 useReducer 드래프트입니다(방안 7). 서버 값을 초기값으로 받아 skills·experiences·workPreferences 3배열의 추가·삭제·수정을 처리합니다. 리듀서는 hooks/resume/resumeProfileDraftReducer.ts(순수 함수)로 분리해 단위 테스트합니다.
  • 임시 저장PUT /api/resume-profiles/{id}, 프로필 확정PUTPOST .../confirmations 2단계입니다 — 확정 전에 반드시 저장해 편집 내용이 확정에 반영되게 합니다.
  • 확정은 되돌릴 수 없는 동작이므로 확인 다이얼로그를 1회 띄웁니다(“확정하면 관심 공고가 다시 평가돼요 · 최대 5분 걸릴 수 있어요”). 파괴적이지는 않지만 5분 대기를 유발하므로 사전 고지가 필요합니다.
  • 이 화면은 지원 탭 하위입니다 — 서류 보관함(S-20) 헤더의 [이력서 프로필] 링크와 S-22 업로드 성공 토스트에서 진입합니다.

S-24 지원 추천도 — 공고 상세 내 섹션 + 보관함 카드 뱃지

참고한 토스 패턴: 점수 요약 카드 + 접었다 펼치는 상세 근거. “왜 이 점수인가”를 항상 1탭 안에 둡니다. 점수를 크게 쓰되 색으로 겁주지 않습니다.

공고 상세(S-04) 내
├────────────────────────────────┤
│ 지원 추천도                      │
│ ┌────────────────────────────┐ │
│ │  92  [강력 추천]             │ │  totalScore · 등급 칩
│ │  ▓▓▓▓▓▓▓▓▓░  (게이지)        │ │  트랙 --border / 채움 --positive
│ │                            │ │
│ │ ⚠ 필수 기술 미충족으로 59점    │ │  capApplied && !capReleased
│ │   상한이 적용됐어요           │ │  → warning-subtle 배너
│ │   사유: Go 경험 없음          │ │  capReason
│ │              [상한 해제]      │ │  3단계(FE-46)
│ │                            │ │
│ │ 축별 근거              [펼치기]│ │
│ └────────────────────────────┘ │

축별 근거 펼침

│ │ 필수 기술        45% → 51%   │ │  configuredWeight → normalizedWeight
│ │ ▓▓▓▓▓▓▓░░░  70%             │ │  fulfillmentRate
│ │  ● Kotlin      충족          │ │  FULL: filled positive
│ │  ◐ Go          일부 (3년 초과)│ │  PARTIAL: outlined + reason
│ │  ○ Kubernetes  없음          │ │  NONE: none
│ │ ─────────────────────────  │ │
│ │ 우대 기술        20% → 23%   │ │
│ │ …                           │ │
│ │ ─────────────────────────  │ │
│ │ 근무 조건        판단 불가     │ │  judgeable=false → 행 전체
│ │ 공고에 근무 조건 정보가 없어    │ │  surface-muted + text-tertiary
│ │ 나머지 축으로 나눠 계산했어요   │ │  (정규화 사실을 문장으로 설명)

평가 불가 상태

│ │ 아직 평가하지 않았어요         │ │  notEvaluableReason
│ │ 공고에 상세 내용이 없어         │ │  JD_UNAVAILABLE
│ │ 추천도를 계산할 수 없어요       │ │
│ │ 프로필을 확정하면 추천도를      │ │  PROFILE_NOT_CONFIRMED
│ │ 계산할 수 있어요               │ │
│ │            [프로필 만들기]     │ │  → S-23
상태UI
loading섹션 스켈레톤(점수 영역 + 축 3줄)
empty404 RECOMMENDATION_NOT_FOUND섹션을 렌더하지 않습니다(2단계 배포 전·미평가 공고). 단 확정 프로필이 있으면 “아직 평가하지 않았어요 / 관심으로 분류하면 다음 평가에 포함돼요” 1줄
error5xx → 섹션 내 축소형 ErrorState + [다시 시도]. 공고 상세의 다른 섹션은 정상 렌더되어야 합니다(부분 실패 격리)
success위 와이어프레임. totalScore === null && notEvaluableReason !== null → “평가 불가 상태” 렌더

보충 규칙

  • notEvaluableReason 2종은 원인이 다르므로 문구와 액션이 다릅니다JD_UNAVAILABLE은 사용자가 할 일이 없고(안내만), PROFILE_NOT_CONFIRMED[프로필 만들기] CTA를 붙입니다.
  • judgeable === false 축은 점수·게이지를 렌더하지 않고 “판단 불가 + 정규화 사실”을 문장으로 설명합니다. FR-96의 정규화 규칙이 사용자에게 보이지 않으면 점수가 마술처럼 느껴집니다.
  • configuredWeight → normalizedWeight를 나란히 보여 주는 이유도 같습니다 — 45%로 설정한 축이 51%로 계산된 사실을 숨기지 않습니다.
  • capReleased === true여도 capReason을 계속 노출합니다(FR-97 명시 요구). 표시는 중립 톤(“상한 해제됨 · 원래 사유: Go 경험 없음”).
  • 보관함 카드(S-15)에는 등급 칩 + 점수만 노출합니다([강력 추천 92]). 근거는 상세에서만 — 목록에 근거를 넣으면 카드가 비대해집니다.
  • 추천도 표시 컴포넌트는 components/recommendation/에 두고 props만 받는 순수 프레젠테이션으로 만듭니다(FE-36). 데이터 조회는 공고 상세 페이지가 합니다.

S-25 제출 서류 연결 — 지원 상세 내 섹션 + 선택 시트

참고한 토스 패턴: 연결 목록 + 추가 → 선택 시트(계열 → 버전 2단계). 이미 연결된 항목은 선택지에서 흐리게 처리하고 이유를 보여 줍니다.

지원 상세(S-07) 내
├────────────────────────────────┤
│ 제출 서류                 [+ 추가]│
│ ┌────────────────────────────┐ │
│ │ [이력서] 백엔드 이력서 v3     │ │  documentType·seriesTitle·versionNumber
│ │ resume.pdf · 08-05 제출      │ │  originalFileName · submittedAt
│ │                       [해제] │ │
│ └────────────────────────────┘ │
└────────────────────────────────┘

선택 시트

│ 제출한 서류 연결            [닫기]│
│ 백엔드 이력서                    │  1단계: 계열 선택
│  ├ v3  최신                     │  2단계: 버전 선택
│  ├ v2                          │
│  └ v1  이미 연결됨 (흐림)         │  중복 연결 사전 차단
│ 당근 자소서                      │
│  └ v1                          │
│                                │
│ 제출일 [2026-08-05]             │  DateField (기본 오늘)
│ ┌──────────────────────────┐   │
│ │         연결              │   │
│ └──────────────────────────┘   │
상태UI
loading섹션 스켈레톤 1줄. 시트 진입 시 계열 목록 스켈레톤
empty섹션: “연결한 서류가 없어요 / 어떤 버전으로 지원했는지 남겨 두면 나중에 확인할 수 있어요” + [+ 추가]. 시트: 서류 0건 → “올린 서류가 없어요” + [서류 올리기](S-22)
error409 DOCUMENT_ALREADY_SUBMITTED → 시트 내 인라인 “이미 연결된 버전이에요”(사전 차단을 뚫은 경우). 404 DOCUMENT_VERSION_NOT_FOUND → 토스트 + 계열 목록 무효화. 그 외 → 토스트
success섹션 목록 갱신 + 토스트 “연결했어요”

보충 규칙

  • 제출 시점의 버전이 고정된다는 계약(FR-85)을 문구로 설명합니다 — 시트 하단에 “연결한 버전은 새 버전이 생겨도 바뀌지 않아요” 1줄.
  • 이미 연결된 버전은 선택지에서 disabled + “이미 연결됨”으로 표시합니다. 숨기지 않는 이유는 “왜 안 보이지”를 만들지 않기 위해서입니다.
  • 해제(DELETE)는 확인 없이 즉시 실행 + 토스트. 연결은 기록일 뿐 파괴적이지 않습니다.

S-26 연락 검토 목록 — 3단계

참고한 토스 패턴: 미처리 항목을 상단에, 처리 완료를 아래로 접는 인박스. 각 행은 “누가·무엇을·언제” 한 줄 요약 + 신뢰도 신호 1개.

┌────────────────────────────────┐
│ ‹ 연락 검토                      │
│ [검토 대기 3][반영됨][무시됨]     │  Segment (reviewStatus)
├────────────────────────────────┤
│ ┌────────────────────────────┐ │
│ │ [높음] 배민                  │ │  신뢰도 칩 · topCandidateCompanyName
│ │ [서류] 서류 전형 결과 안내     │ │  subject
│ │ recruit@baemin.com          │ │  senderAddress
│ │ 08-08 09:12                 │ │  receivedAt
│ └────────────────────────────┘ │
│ ┌────────────────────────────┐ │
│ │ [낮음] —                    │ │  후보 없음 → topCandidate* null
│ │ 채용 관련 안내               │ │
│ │ …                           │ │
│ └────────────────────────────┘ │
│        [ 더 보기 ]              │
└────────────────────────────────┘
상태UI
loading카드 스켈레톤 3개
empty (검토 대기 0건)“검토할 연락이 없어요 / 새 연락이 오면 디스코드로 알려드려요” — 긍정 상태이므로 CTA를 두지 않습니다
empty (반영됨·무시됨 0건)“아직 없어요” 1줄
empty (최근 30일 0건 · 전체)“최근 30일 연락이 없어요 / Gmail 연동을 아직 켜지 않았다면 운영 화면에서 확인해 보세요” + [운영 열기]
errorErrorState + [다시 시도]
success위 와이어프레임

보충 규칙

  • 기본 세그먼트는 PENDING(검토 대기)입니다 — 이 화면에 오는 이유가 그것이기 때문입니다.
  • topCandidateConfidence === null(후보 0건)은 [후보 없음] 중립 칩으로 표시합니다. LOW와 구분해야 합니다(전자는 계산 불가, 후자는 계산했으나 낮음).
  • 검토 대기 건수는 PageResponse.totalCount를 세그먼트 라벨에 붙입니다.

S-27 연락 검토 상세·반영 — 3단계 (가장 복잡한 화면)

참고한 토스 패턴: 원문 확인 → 시스템 제안 확인 → 사용자 결정의 3단 세로 흐름. 마지막에 단일 주 CTA(반영)를 두고, 부차 액션(무시)은 텍스트 버튼으로 위계를 낮춥니다.

┌────────────────────────────────┐
│ ‹ 연락 검토                      │
├────────────────────────────────┤
│ 받은 연락                        │  ① 원문
│ EMAIL · recruit@baemin.com      │  channel · senderAddress
│ 김리크루터 · 08-08 09:12         │  senderName · receivedAt
│ 서류 전형 결과 안내               │  subject
│ ┌────────────────────────────┐ │
│ │ 안녕하세요, 우아한형제들…     │ │  bodyText 6줄 미리보기
│ │                    [전체 보기]│ │  → 펼침 (최대 20000자)
│ └────────────────────────────┘ │
│ 📎 채용전형안내.pdf (240KB)      │  attachments (메타데이터만)
├────────────────────────────────┤
│ 시스템이 읽은 내용                │  ② 파싱 결과
│ 회사    우아한형제들              │  extractedCompanyName
│ 공고    백엔드 엔지니어            │  extractedPostingTitle
│ 전형    1차 면접                 │  extractedStageKeyword
│ 제안    면접 · 08-12 14:00       │  suggestedStatus·suggestedInterviewAt
├────────────────────────────────┤
│ 어느 지원 건인가요                 │  ③ 후보 선택
│ ┌────────────────────────────┐ │
│ │ (•) 배민 · 백엔드 엔지니어    │ │  autoSelectedJobApplicationId
│ │     [높음 90]  서류전형       │ │  confidenceLevel·Score·현재 상태
│ │     회사 ✓ 공고 ✓ 담당자 ✓   │ │  companyMatched 등 4축 체크
│ │     최근 지원 ✓              │ │
│ └────────────────────────────┘ │
│ ┌────────────────────────────┐ │
│ │ ( ) 배민 · 프론트엔드         │ │
│ │     [낮음 40]  🔒 종료됨      │ │  terminal → 선택 불가
│ │     반영할 수 없어요           │ │
│ └────────────────────────────┘ │
├────────────────────────────────┤
│ 무엇으로 바꿀까요                  │  ④ 결정
│ [서류전형][면접][처우협의][불합격] │  allowedNextStatuses로 제한
│ ☑ 면접 일정도 등록할게요           │
│   회차 라벨 [2차 면접        ]    │  suggestedRoundLabel 기본값
│   일시     [2026-08-12 14:00]    │  suggestedInterviewAt 기본값
│ 메모       [                ]    │
├────────────────────────────────┤
│ ┌──────────────────────────┐   │  BottomActionBar
│ │         반영하기           │   │  단일 주 CTA
│ └──────────────────────────┘   │
│           이 연락 무시           │  텍스트 버튼 (위계 낮춤)
└────────────────────────────────┘
상태UI
loading섹션별 스켈레톤 4블록
empty (파싱 실패)parse === null → ② 섹션에 “내용을 읽지 못했어요 / 원문을 확인하고 직접 선택해 주세요”(warning-subtle). ③·④는 정상 노출 — 파싱 실패가 반영을 막지 않습니다
empty (후보 0건)③ 섹션 → “연결할 지원 건을 찾지 못했어요 / 이 연락과 관련된 지원 건이 아직 없다면 무시해 주세요” + [지원 목록 열기]. 반영하기 CTA disabled, 무시만 활성
empty (신뢰도 낮음 · 수동 선택)autoSelectedJobApplicationId === null → ③ 섹션 상단에 “가장 비슷한 후보를 자동으로 고르지 못했어요 / 직접 선택해 주세요”(중립 안내). 라디오 전부 미선택 상태로 시작하고, 선택 전에는 반영하기 disabled
error (전부 종료 상태)모든 후보가 terminal === true → “이 지원 건들은 이미 종료됐어요 / 상태를 바꿀 수 없어요” + 무시만 활성(시나리오 15)
error (결정 실패)409 CONTACT_EVENT_ALREADY_DECIDED → “이미 처리한 연락이에요” + 화면 무효화(결과 표시로 전환). 409 CONTACT_EVENT_TARGET_TERMINAL → 해당 후보 카드에 인라인 + 재선택 유도. 409 TRANSITION_NOT_ALLOWED → ④ 섹션 인라인 “이 상태로는 바꿀 수 없어요”. 404 APPLICATION_NOT_FOUND → 토스트 + 후보 목록 무효화
success (결정 완료)decision !== null → ③·④를 결과 요약으로 대체(“반영됨 · 배민 백엔드 → 면접 · 2차 면접 등록됨 · 08-08 10:02”) + CTA 제거
success (반영)토스트 “반영했어요” + 지원 상세로 이동할지 묻지 않고 이 화면에 머무름(결과 요약 표시). 지원 캐시·대시보드 무효화

보충 규칙

  • ④의 상태 선택지는 선택된 후보의 allowedNextStatuses로 제한합니다. 후보를 바꾸면 선택지가 다시 계산되고, 이전 선택이 새 목록에 없으면 초기화합니다. FE가 전이 규칙을 자체 판단하지 않습니다.
  • 면접 회차 번호는 표시하지 않고 라벨만 편집하게 합니다 — 계약이 nextInterviewRoundNumber를 주지만 POST .../decisionsinterview에는 roundLabel만 있습니다. 기본 라벨은 suggestedRoundLabel ?? "{nextInterviewRoundNumber}차 면접"으로 조립합니다.
  • terminal === true인 후보는 라디오 disabled + 사유 문구. 숨기지 않습니다(왜 없는지 알 수 있어야 함).
  • 원문은 기본 6줄 미리보기, [전체 보기]로 펼칩니다. 20000자를 처음부터 렌더하면 스크롤이 무너집니다.
  • 첨부는 메타데이터만 표시합니다 — recruitment_contact_event_attachments 테이블에 파일명·MIME·크기만 저장하고 바이너리를 보관하지 않으므로 다운로드 링크 자체가 없습니다(B-8 확정). 파일명 옆에 유형 칩과 utils/fileSize 포맷 크기를 나열하고, 목록 하단에 “원본은 메일에서 확인해 주세요” 1줄을 둡니다. 이벤트당 최대 20건이라 접기·페이지네이션 없이 전량 렌더합니다.
  • 결정 폼은 지역 useState입니다. 화면 이탈 시 보존하지 않습니다(계약에 초안 저장 API 없음).

S-28 추천도 가중치 설정 — 3단계

참고한 토스 패턴: 슬라이더·스텝퍼 + 합계 검증을 실시간으로. 저장 전에 규칙 위반을 화면이 먼저 알려 줍니다.

┌────────────────────────────────┐
│ ‹ 추천도 가중치                  │
│ 기준 버전 v3                     │  criteriaRevision
├────────────────────────────────┤
│ 필수 기술          [ 45 ] %      │  4축 고정 (추가·삭제 없음)
│ 우대 기술·도메인    [ 20 ] %      │
│ 경력·직무          [ 20 ] %      │
│ 근무 조건·선호      [ 15 ] %      │
│ ─────────────────────────────  │
│ 합계               100 %        │  실시간 합계 (≠100이면 danger)
│                                │
│ ⓘ 저장하면 기준 버전이 올라가고    │
│   관심 공고를 다시 평가해야 해요    │
│                                │
│ ┌──────────────────────────┐   │
│ │          저장             │   │  합계 100일 때만 활성
│ └──────────────────────────┘   │
│        기본값으로 되돌리기        │  45/20/20/15
└────────────────────────────────┘
상태UI
loading4행 스켈레톤
empty해당 없음 (4축 고정)
error400 RECOMMENDATION_WEIGHT_INVALID → 합계 행에 인라인(클라이언트 선검증을 뚫은 경우). 조회 실패 → ErrorState
success토스트 “저장했어요 · 기준 버전 v4” + [지금 다시 평가] 액션(→ 재평가 실행, FE-39)

보충 규칙

  • 합계 검증은 클라이언트에서 실시간으로 합니다(서버 400을 기다리지 않음). 합계 ≠ 100이면 CTA disabled + “합계가 100이 되어야 해요 (현재 {N})”.
  • 입력은 숫자 스텝퍼(0~100 정수)입니다. 슬라이더는 4축 연동 조정이 필요해 복잡도가 큽니다 — 미채택.
  • 매칭 설정 화면(S-10) 하단의 [추천도 가중치] 링크로 진입합니다(매칭 탭 하위).

S-11 확장 (2단계) — 서류 업로드 실패 이력

참고한 토스 패턴: 보안·이상 신호를 목록 최상단이 아니라 해당 항목의 톤으로 구분하는 방식. 위험을 화면 전체로 번지게 하지 않고 그 줄만 danger로 칠합니다. 빈 상태는 “문제가 없다”는 긍정 신호이므로 에러톤을 쓰지 않습니다.

운영 화면(S-11)에 세그먼트 1개를 추가합니다 — 2단계 시점의 세그먼트는 4개(수집 이력 / 알림 실패 / 웹훅 수신 / 서류 실패)이고, 가용 폭 416px 안에 들어갑니다(약 384px). 5개가 되는 3단계에서 라벨을 축약합니다(방안 15).

│ [수집 이력][알림 실패][웹훅 수신][서류 실패] │
│ 최근 30일 · [전체 ▾]              │  days · reasonCode 필터
│ ┌────────────────────────────┐ │
│ │ 🔒 경로 이탈 시도             │ │  보안 2종 — filled danger + 자물쇠
│ │ ../../etc/passwd.pdf        │ │  originalFileName (새니타이즈 전 원문)
│ │ 08-08 14:20 · 이력서         │ │  occurredAt · documentType
│ │ 저장 루트를 벗어나는 경로예요   │ │  detailMessage
│ └────────────────────────────┘ │
│ ┌────────────────────────────┐ │
│ │ [확장자 불가]                │ │  사용자 입력 2종 — outlined neutral
│ │ 이력서.zip                   │ │
│ │ 08-07 09:12 · 이력서 · v3    │ │  seriesTitle 있으면 병기
│ └────────────────────────────┘ │
│        [ 더 보기 ]              │  hasNext (PageResponse)
상태UI
loading카드 스켈레톤 3개. 세그먼트·필터는 즉시 렌더
empty”실패한 업로드가 없어요” — 좋은 상태이므로 중립 톤(--text-secondary), 에러톤·CTA 없음. 3년 60행 규모라 대부분의 조회가 이 상태입니다
empty (사유 필터)“이 사유로 실패한 기록이 없어요” + [전체 보기]
errorErrorState + [다시 시도]
success위 와이어프레임

보충 규칙

  • 보안 사유 2종을 시각적으로 분리합니다 — PATH_VALIDATION_FAILED·STORAGE_ROOT_ESCAPE는 사용자 조작 실수가 아니라 경로 이탈 시도 기록이므로, 확장자·용량 실패와 같은 무게로 보이면 안 됩니다. Chipfill × tone으로 filled danger + 자물쇠 기호를 씁니다. 신규 색 토큰 0건 원칙은 유지합니다(기존 --danger·--danger-subtle 재사용).
  • originalFileName은 새니타이즈 전 원문이 저장됩니다. 렌더 시 3가지를 처리합니다.
    1. XSS — React가 텍스트 노드를 자동 이스케이프하므로 {fileName}으로 렌더하면 안전합니다. dangerouslySetInnerHTML을 쓰지 않습니다(이 화면에 한해 명시적 금지).
    2. 양방향 텍스트 위장 — RTL override(U+202E) 같은 방향 제어 문자는 exe.pdffdp.exe처럼 보이게 만드는 실제 공격 벡터입니다. 제어 문자(U+0000~001F·U+007F·U+200E·U+200F·U+202A~202E·U+2066~2069)를 가시 기호로 치환해 렌더합니다.
    3. 길이 — 255자까지 올 수 있으므로 2줄 말줄임 + title 속성으로 전체를 노출합니다. 치환·판정은 utils/documentFailure.ts(신규 순수 유틸)가 담당합니다 — 컴포넌트에 문자 처리 로직을 두지 않습니다.
  • 사유 코드는 6종이 최종 확정 전입니다(BE가 dba와 정합 중). utils/documentFailure.ts의 라벨 맵에 알 수 없는 코드는 원문 그대로 표시 + 중립 톤으로 폴백해, 코드가 추가·변경돼도 화면이 깨지지 않게 합니다.
  • documentType·seriesId·seriesTitle유형 파싱 전 실패면 null 입니다 — 있을 때만 병기하고 없으면 그 줄을 생략합니다.
  • 페이지네이션은 보관함과 같은 hasNext 기반 “더 보기”(useInfiniteQuery)입니다.

S-11 확장 (3단계) — 소스 상태 레지스트리

기존 운영 화면에 세그먼트 1개 추가 — 이 시점에 세그먼트가 5개가 되어 가용 폭 416px를 넘으므로(약 478px), 이 티켓(FE-44)이 5개 라벨을 전부 2자로 축약합니다(방안 15): 수집 · 알림 · 웹훅 · 서류 · 소스 (약 268px).

│ [수집][알림][웹훅][소스 상태]     │
│ 활성 12 · 일시 실패 2 · 접근 제한 1│  상태별 집계 (클라이언트 합산 — 
│                                │  전체 목록을 한 번에 받으므로 가능)
│ ┌────────────────────────────┐ │
│ │ [활성] 사람인 · 애그리게이터   │ │  registryStatus 칩·platform·sourceType
│ │ 백엔드 카테고리               │ │  searchCategoryCode ?? sourceSlug
│ │ 마지막 정상 08-08 03:12      │ │  lastNormalAt
│ └────────────────────────────┘ │
│ ┌────────────────────────────┐ │
│ │ [일시 실패] 당근 · 회사 종속   │ │
│ │ 3일 연속 비정상               │ │  consecutiveAbnormalDays
│ │ 마지막 정상 08-05 03:10      │ │
│ └────────────────────────────┘ │
상태UI
loading집계 스켈레톤 + 카드 스켈레톤 4개
empty”등록된 소스가 없어요” + [회사 등록]
errorErrorState + [다시 시도]
success위 와이어프레임. registryStatus === null(백필 전) → [상태 미확인] 중립 칩

보충 규칙

  • 상태별 집계는 전체 목록({ sources: [] }, 30건 미만)을 받으므로 클라이언트 합산이 가능합니다. 합산은 utils/sourceRegistry.ts(순수 유틸)로 추출합니다.
  • 정렬은 문제 있는 상태 우선(접근 제한 → 일시 실패 → 미지원 → 발견됨 → 활성 → 비활성)입니다. 운영 화면의 목적이 이상 감지이기 때문입니다. 정렬 로직도 순수 유틸로 분리합니다.
  • 소급 재평가 실행(POST /api/matching/field-scope-backfills)은 운영 화면의 소스 상태 세그먼트 하단에 [매칭 범위 재평가] 버튼으로 둡니다 — dryRun 체크박스와 결과 요약(신규 매칭 N건 · 알림 억제 N건 · 매칭 해제 N건)을 함께 표시합니다. 배포 절차 3-5/3-7에서 쓰는 도구입니다.

S-04 확장 (3단계) — 매칭 근거

참고한 토스 패턴: 판정 결과 아래에 “이렇게 계산했어요” 근거 목록. 발췌 안에서 일치한 부분만 하이라이트합니다.

공고 상세(S-04) 내 — 기존 MatchResultBox 확장
│ 매칭                            │
│ ✓ 매칭됨  점수 75 / 임계 50      │  matchScore / matchThreshold
│                                │  (판정 재현성 — 둘을 항상 함께)
│ ┌────────────────────────────┐ │
│ │ 제목      +60                │ │  field · weight
│ │ "[서버] 백엔드 엔지니어"       │ │  snippet, 일치 구간 <mark>
│ │                            │ │
│ │ 태그      +35                │ │  matchField=SOURCE_TAG
│ │ "Backend, Kotlin"           │ │  jobKeywordGroupName 병기
│ │                            │ │
│ │ 본문      +20  (2회)         │ │  DESCRIPTION_BODY 만 횟수 병기
│ │ "…백엔드 시스템을 설계하고…"   │ │  snippet 최대 200자
│ └────────────────────────────┘ │

미매칭 — 점수 미달 (ⓑ 확대로 신규 표시)

│ 매칭                            │
│ ✗ 매칭 안 됨  점수 35 / 임계 50  │  matched=false, excludedByKeyword=null
│ ┌────────────────────────────┐ │
│ │ 제목       0                │ │  근거에 없는 필드는 0으로 표기해
│ │ 일치한 표현이 없어요          │ │  "어디서 모자랐는지"를 보여 줍니다
│ │                            │ │
│ │ 태그      +35                │ │  걸린 필드만 근거 배열에 옵니다
│ │ "Backend"                   │ │
│ │                            │ │
│ │ 15점이 더 필요해요            │ │  matchThreshold - matchScore
│ └────────────────────────────┘ │

미매칭 — 제외 키워드

│ 매칭                            │
│ ✗ 매칭 안 됨                    │  점수 줄을 렌더하지 않습니다
│ ┌────────────────────────────┐ │
│ │ 제외 키워드 '인턴'에 걸렸어요   │ │  excludedByKeyword.keyword
│ │ 점수와 무관하게 제외됩니다      │ │  근거 배열도 감춥니다
│ └────────────────────────────┘ │
상태UI
loading기존 매칭 박스 스켈레톤
empty (미배포)matchScore === null(3단계 배포 전·재평가 전) → 근거 섹션을 렌더하지 않고 기존 matched 표시로 폴백합니다
empty (근거 없음)matchScore === 0 → “일치한 항목이 없어요”. 미배포와 다른 문구입니다 — 전자는 아직 계산 전, 후자는 계산했는데 걸린 게 없음입니다
success (미매칭·점수 미달)matched === false && excludedByKeyword === null → 점수·임계치 + 근거 배열 + “N점이 더 필요해요”(부족분)
success (미매칭·제외 키워드)matched === false && excludedByKeyword != null → 제외 키워드명만. 점수 줄·근거 배열을 렌더하지 않습니다
error부분 실패 개념 없음(공고 상세와 같은 응답)
success위 와이어프레임

계약 확정 + 범위 확대 (C-2 → 게이트 ② 결정 ⓑ, 2026-08-09). 초안은 매칭 성립분만 근거를 표시하는 ⓐ였는데, 사용자가 ⓑ(미매칭 공고에도 근거 표시) 로 확정했습니다. dba 실측상 확대 비용이 우려했던 6.7배가 아니라 +32.6%(3년 51,600행 / 22MB) 이고, 본문 보유 공고가 13.8%뿐이라 구조적 상한이 있습니다.

  • matchFieldTITLE(60) · SOURCE_TAG(35) · DESCRIPTION_BODY(20) 3종이고, 임계치는 50입니다.
  • matchScorematchThreshold를 함께 노출해 판정을 재현 가능하게 만듭니다 — 임계치가 나중에 바뀌어도 과거 판정을 설명할 수 있어야 하므로, 판정 당시 임계치를 화면이 그대로 보여 줍니다.
  • jobKeywordGroupName이 함께 오므로 FE가 그룹 id를 라벨로 바꾸지 않습니다.
  • occurrenceCount는 유효 출현 횟수입니다 — 본문 신호는 2회 이상이어야 인정되므로 DESCRIPTION_BODY 근거에만 횟수를 병기합니다.
  • 교차 목록(S-15)에는 matchScore·matched만 옵니다(응답 비대화 방지). 근거 배열은 상세에서만 렌더합니다.

matched가 판정 SSOT입니다 — 점수 비교 금지

matchScore >= matchThreshold로 매칭 여부를 판단하면 안 됩니다. 제외 키워드가 매칭을 이기므로(FR-23) 점수 60이어도 matched: false 인 공고가 존재합니다.

필드FE의 사용법
matched: boolean매칭 여부 판정에 쓰는 유일한 값
matchScore · matchThreshold”얼마나 모자랐나”를 설명하는 수치. 판정에 쓰지 않습니다
excludedByKeyword: { keywordId, keyword } | null미매칭 사유 분기 (신규)

이 규칙을 utils/matchEvidence.ts의 순수 함수(resolveMatchOutcome)로 격리해, 컴포넌트가 점수를 비교할 수 있는 경로 자체를 없앱니다.

미매칭 사유 2분기

조건표시
excludedByKeyword != null”제외 키워드 ‘{keyword}‘에 걸렸어요” — 점수와 무관하므로 점수·임계치 줄을 렌더하지 않습니다. 근거 배열도 감춥니다(점수가 몇 점이든 결과가 바뀌지 않으므로 보여 주면 혼란)
excludedByKeyword === null”점수 {matchScore} / 임계 {matchThreshold} 미달” + 근거 배열로 어느 필드가 몇 점인지

matchScore 3-state — 빈 배열의 의미를 분리합니다

의미UI
matchScore === null미배포 (3단계 배포 전)매칭 근거 섹션 자체를 렌더하지 않고 기존 matched 표시로 폴백
matchScore === 0근거 없음 (어느 필드에도 걸리지 않음)“일치한 항목이 없어요” — 근거 배열은 비지만 미배포와 다른 문구
matchScore > 0근거 있음근거 배열 렌더 (매칭·미매칭 모두)

matchFieldEvidences === []만으로 판단하면 위 3가지가 뭉개집니다 — matchScore를 먼저 보고 분기합니다.

컴포넌트 트리

표기: * = 그 화면 전용(소유 티켓이 소유), 표시 없음 = 공용. 페이지만 훅을 호출하고(컨테이너) 하위는 props만 받습니다 — 예외는 시트(자기 mutation 소유).

App
└─ QueryClientProvider · ThemeProvider · RouterProvider           (기존, 미수정)
   └─ routes.tsx                                                  (FE-22 소유)
      ├─ /login → LoginPage*                                      (FE-23) useLogin
      └─ AuthGate                                                 (FE-22 스텁 → FE-23 구현) useSession
         └─ AppShell                                              (FE-22가 헤더 로그아웃 추가)
            ├─ NavTabs                                            (FE-22 — 5탭)
            ├─ LogoutButton*                                      (FE-23) useLogout
            │
            ├─ /watchlist → WatchlistPage*                        (FE-24) useCrossCompanyJobPostings
            │  ├─ WatchStatusSegment*                             (FE-24)
            │  ├─ WatchlistSearchField*                           (FE-24)
            │  ├─ WatchlistFilterSheet*                           (FE-24)
            │  ├─ WatchlistSortSelect*                            (FE-24)
            │  ├─ WatchlistCard*                                  (FE-24)
            │  │  ├─ WatchStatusChip            [FE-20 공용]
            │  │  ├─ WatchPriorityMark          [FE-20 공용]
            │  │  ├─ RecommendationGradeChip    [FE-36 공용, 2단계]
            │  │  ├─ DeadlineText · AppliedBadge · AccessRestrictedBadge   [기존 공용]
            │  │  └─ Chip · Card · Button       [기존 UI]
            │  └─ WatchStateSheet               [FE-25 공용 시트]  useSaveWatchState
            │
            ├─ / → DashboardPage*  (앱 랜딩)                      (FE-26) useDashboard
            │  ├─ DashboardOnboardingCard*  (지원 0건)            (FE-26)
            │  ├─ DashboardSummary*                               (FE-26)
            │  ├─ StatusBoardSection*                             (FE-26, 가로 스크롤)
            │  │  └─ StatusBoardColumn* → DashboardApplicationCard*
            │  ├─ UpcomingInterviewSection*                       (FE-26)
            │  ├─ StaleApplicationSection*                        (FE-26)
            │  ├─ PendingContactBanner*                           (FE-48, 3단계) useContactEvents
            │  └─ TransitStatusSheet            [기존 공용 시트]
            │
            ├─ /applications/:id → ApplicationDetailPage          (기존 — FE-27·FE-37이 섹션 추가)
            │  ├─ ContactSection*                                 (FE-27) props only
            │  │  └─ ContactSheet               [FE-27 공용 시트]  useSave/DeleteApplicationContact
            │  └─ SubmittedDocumentSection*                       (FE-37, 2단계) props only
            │     └─ SubmittedDocumentPickerSheet [FE-37 공용 시트] useLinkSubmittedDocument
            │
            ├─ /job-postings/:id → JobPostingDetailPage           (기존 — FE-29·38·46·47이 섹션 추가)
            │  ├─ WatchStateSection*                              (FE-29) → WatchStateSheet·WatchStateDetailSheet
            │  ├─ RecommendationSection*                          (FE-38, 2단계) useRecommendation
            │  │  ├─ RecommendationScoreCard    [FE-36 공용]
            │  │  ├─ RecommendationAxisList     [FE-36 공용]
            │  │  ├─ RecommendationCapNotice    [FE-36 공용]
            │  │  └─ CapReleaseControl          [FE-46 공용, 3단계] useReleaseRecommendationCap
            │  └─ MatchEvidenceBox*                               (FE-47, 3단계 — 계약 확정 · BE-80)
            │
            ├─ /documents → DocumentSeriesListPage*               (FE-32, 2단계) useDocumentSeries
            │  ├─ DocumentTypeSegment*                            (FE-32)
            │  ├─ DocumentSeriesCard*                             (FE-32)
            │  │  └─ DocumentTypeChip           [FE-30 공용]
            │  └─ DocumentUploadSheet           [FE-33 공용 시트]  useUploadDocument
            │
            ├─ /documents/series/:seriesId → DocumentSeriesDetailPage*  (FE-34) useDocumentSeriesDetail
            │  └─ DocumentVersionCard*                            (FE-34)
            │
            ├─ /resume-profile → ResumeProfilePage*               (FE-35) useCurrentResumeProfile 외
            │  ├─ ResumeSkillSection* · ResumeExperienceSection* · ResumePreferenceSection*
            │  ├─ ExtractionFailedNotice*                         (FE-35)
            │  └─ ReevaluationProgressCard*                       (FE-39) useReevaluateRecommendations
            │
            ├─ /contact-events → ContactEventListPage*            (FE-42, 3단계) useContactEvents
            │  └─ ContactEventCard* → ConfidenceChip [FE-40 공용]
            │
            ├─ /contact-events/:id → ContactEventDetailPage*      (FE-43) useContactEvent
            │  ├─ ContactSourceSection* · ContactParseSection*
            │  ├─ ContactCandidateSection* → ContactCandidateCard*
            │  ├─ ContactDecisionForm*                            useDecideContactEvent
            │  └─ ContactDecisionResult*
            │
            ├─ /settings/recommendation → RecommendationWeightPage*  (FE-45) useRecommendationWeights
            │  └─ WeightRow*
            │
            └─ /operations → OperationsPage                       (기존 — FE-28·FE-44가 세그먼트 추가)
               ├─ TunnelStatusCard*                               (FE-28) useTunnelStatus
               ├─ WebhookReceiptSection*                          (FE-28) useWebhookReceipts
               ├─ DocumentUploadFailureSection*                   (FE-50, 2단계) useDocumentUploadFailures
               │  └─ DocumentFailureReasonChip*                   (FE-50)
               └─ SourceRegistrySection*                          (FE-44, 3단계) useSourceRegistry
                  └─ FieldScopeBackfillPanel*                     (FE-44) useFieldScopeBackfill

신규 공용 UI 프리미티브 (components/ui/)

기존 17종에 3종을 추가합니다. 추가 근거는 전부 “두 번째 사용처가 확정됨”(토스 원칙 · 기존 문서 공용화 기준).

컴포넌트props 계약사용처(2곳 이상)소유 티켓
TextArealabel, value, onChange, maxLength, rows, error?, placeholder?S-17 메모 · S-19 담당자 메모 · S-27 결정 메모FE-20
FileFieldlabel, accept, maxSizeBytes, value: File | null, onChange, error?S-22 서류 업로드 (2단계 단독이나 검증 로직 재사용을 위해 프리미티브화)FE-30
TagInputlabel, values: string[], onChange, maxCount, maxLength, error?S-17 개인 태그 (단일 사용처) → 프리미티브화하지 않고 components/watchlist/에 둡니다

TagInput은 사용처가 1곳이므로 공용화하지 않습니다 — “두 번째 사용처가 확정된 것만 공용화” 규칙을 지킵니다.

신규 공용 도메인 컴포넌트 (components/domain/ · 도메인별 디렉토리)

컴포넌트위치사용처소유 티켓
WatchStatusChipcomponents/watchlist/S-15 카드 · S-04 관리 섹션FE-20
WatchPriorityMarkcomponents/watchlist/S-15 카드 · S-17 시트FE-20
WatchStateSheetcomponents/watchlist/S-15 · S-04FE-25
WatchStateDetailSheetcomponents/watchlist/S-04FE-25
RecommendationGradeChipcomponents/recommendation/S-15 카드 · S-04 섹션FE-36
RecommendationScoreCardcomponents/recommendation/S-04 섹션FE-36
RecommendationAxisListcomponents/recommendation/S-04 섹션FE-36
RecommendationCapNoticecomponents/recommendation/S-04 섹션FE-36
DocumentTypeChipcomponents/document/S-20 · S-21 · S-25FE-30
DocumentUploadSheetcomponents/document/S-20 · S-21FE-33
ConfidenceChipcomponents/contact/S-26 · S-27FE-40
ContactSheetcomponents/application/S-07 (추가·편집 공용)FE-27
SubmittedDocumentPickerSheetcomponents/application/S-07FE-37

신규 순수 유틸 (utils/) — no-logic-in-component 대응

파일책임소유 티켓
utils/watchStatus.ts관리 상태·우선순위 한국어 라벨, 정렬 옵션 라벨FE-20
utils/dashboard.ts진행 중 4상태 count 합산, daysUntil 표기(D-2·오늘)FE-26
utils/fileSize.ts바이트 → 1.2MB 포맷FE-30
utils/documentFailure.ts실패 사유 6종 라벨·보안 사유 판정(2종), 파일명 제어 문자 치환(RTL override 등), 알 수 없는 코드 폴백FE-30
utils/documentFile.ts확장자 4종·20MB 클라이언트 선검증FE-30
utils/recommendation.ts등급 한국어 라벨, 축 한국어 라벨, 충족 라벨, 게이지 비율 계산FE-30
utils/contactEvent.ts신뢰도 라벨, 후보 일치 축 4종 라벨, 기본 회차 라벨 조립FE-40
utils/matchEvidence.tsresolveMatchOutcomematched·matchScore·excludedByKeyword로 표시 상태(미배포/근거 없음/매칭/미매칭 점수 미달/미매칭 제외 키워드) 판정. 필드 3종 라벨, 부족 점수 계산. 점수 비교 판정을 이 파일에 가둡니다FE-40
utils/sourceRegistry.ts상태 6종 라벨, 상태별 집계, 문제 우선 정렬FE-40
hooks/resume/resumeProfileDraftReducer.ts프로필 드래프트 리듀서 (순수 함수)FE-35

상태 관리

분류대상수단
서버 상태세션·보관함·대시보드·담당자·서류·프로필·추천도·연락·가중치·소스 상태TanStack Query — 스토어 복사 금지
URL 상태보관함 필터·정렬·검색, 서류 유형 세그먼트, 연락 검토 세그먼트, 운영 세그먼트useSearchParams
지역 상태시트 열림, 선택된 후보, 결정 폼, 업로드 폼, 펼침 토글useState
지역 상태 (복합)이력서 프로필 드래프트 (3배열 편집)useReducer + 순수 리듀서 파일
전역 클라이언트 상태추가 없음 — 테마·토스트 2개 유지Zustand

전역 승격을 반려한 후보 (미채택 사유)

후보반려 사유
세션(username·expiresAt)서버 상태입니다. 쿠키가 HttpOnly라 클라이언트가 만들 수 있는 진실이 없습니다 → Query ['auth','session'], staleTime: Infinity
보관함 필터URL이 SSOT여야 뒤로가기·링크 공유가 동작합니다
업로드 진행 상태S-22 시트 1곳에서만 쓰는 지역 상태입니다. mutation의 isPending으로 충분합니다
추천도 가중치서버 상태입니다. 화면 1곳(S-28)에서만 편집합니다
검토 대기 연락 건수Query totalCount의 파생 값입니다. 배너(S-18)와 목록(S-26)이 같은 쿼리 키를 공유하면 자동 동기화됩니다

Query 규약 (기존 규약 승계 + 신규분)

queryKey 확장 — web/src/api/queryKeys.ts에 추가합니다(배열 접두사 계층 유지).

형태
세션['auth', 'session']
교차 회사 목록['job-postings', 'cross-company', filters] (useInfiniteQuery)
관리 상태['dedup-groups', dedupGroupId, 'watch-state']
관리 상태 이력['dedup-groups', dedupGroupId, 'watch-state', 'histories']
대시보드['dashboard']
담당자['applications', applicationId, 'contacts']
문서 계열 목록['documents', 'series', { documentType }]
문서 계열 상세['documents', 'series', seriesId]
제출 서류['applications', applicationId, 'documents']
현재 프로필['resume-profiles', 'current']
추천도['job-postings', jobPostingId, 'recommendation']
가중치['recommendations', 'weights']
연락 목록['contact-events', { reviewStatus, days }] (useInfiniteQuery)
연락 상세['contact-events', contactEventId]
웹훅 수신['operations', 'webhook-receipts', { days, result }] (useInfiniteQuery)
터널 상태['operations', 'tunnel-status']
서류 업로드 실패['operations', 'document-upload-failures', { days, reasonCode }] (useInfiniteQuery)
소스 상태['operations', 'source-registry']

staleTime — 기본 30초 승계. 예외는 아래 3건만.

staleTime근거
['auth','session']Infinity401 발생 시에만 무효화하면 됩니다. 주기 재조회는 무의미한 트래픽입니다
신규 ['operations', ...]0운영 이력은 항상 최신 확인. 기존 설계 문서의 규약이나 현재 코드의 기존 운영 훅은 기본값 30초를 그대로 씁니다(hooks/operations/staleTime 오버라이드 0건) — 신규 훅부터 적용하고, 기존 훅은 이번 범위에서 건드리지 않습니다
['job-postings', id, 'recommendation']5 * 60_000재평가는 사용자 명시 액션으로만 발생합니다. 자주 바뀌지 않습니다

무효화 매핑

액션무효화 대상
로그인 성공['auth','session']
로그아웃queryClient.clear() — 이전 사용자 데이터가 남으면 안 됩니다
401 수신['auth','session']
관리 상태 저장·해제['job-postings','cross-company'] 접두사 + ['dedup-groups', id] 접두사
지원 생성 (기존)기존 무효화 + ['job-postings','cross-company'] + ['dashboard']
상태 전이·면접 (기존)기존 무효화 + ['dashboard']
담당자 CRUD['applications', id, 'contacts']
서류 업로드['documents','series'] 접두사
제출 서류 연결·해제['applications', id, 'documents']
프로필 확정['resume-profiles'] + ['job-postings'] 접두사 전체(추천도가 전부 바뀜)
가중치 저장['recommendations','weights'] + ['job-postings'] 접두사 전체
상한 해제·재적용['job-postings', id, 'recommendation'] + ['job-postings','cross-company']
연락 결정['contact-events'] 접두사 + ['applications', id] + ['dashboard']
소급 재평가 백필['job-postings'] 접두사 전체

낙관적 업데이트 — 2건만 적용합니다.

대상적용롤백
S-16 관리 상태 분류onMutate에서 무한 쿼리 캐시 스냅샷 → onError에서 복원 + 토스트 “분류를 저장하지 못했어요”
S-24 59점 상한 해제 토글같은 방식 + 토스트 “상한 해제를 저장하지 못했어요”
그 외 전부서버 응답 후 무효화. 다중 필드거나 서버 파생 결과(상태 전이·면접 생성)가 있어 낙관적 처리 시 화면이 거짓말을 합니다

ApiError 확장 — 부가 필드 actualCount·limit (A 확정)

ApiErrorResponse에 nullable actualCount·limit이 추가됐습니다. 현재 api/client.ts:36-43apiRequest는 에러 바디에서 code·message만 정규화하고 나머지 필드를 버립니다 — 기존 existingCompanyId가 그래서 registration.ts·create.ts에서 직접 fetch로 우회한 것입니다.

이번에는 우회하지 않고 ApiError 클래스 자체를 확장합니다.

  • ApiErrorreadonly actualCount: number | null · readonly limit: number | null을 추가하고, apiRequest·apiUpload가 바디에서 타입 가드로 좁혀 채웁니다(no-loose-assertion).
  • *_LIMIT_EXCEEDED 계열에서만 채워지고 나머지는 null이므로, 소비 측은 code로 먼저 분기한 뒤 값을 읽습니다.
  • 이 확장은 FE-21(api/client.ts 단독 소유) 이 수행합니다. 다른 티켓은 ApiError를 읽기만 합니다.
  • 생성자 파라미터는 기본값 null을 가진 선택 인자로 추가합니다. ApiError를 상속한 CompanyNameDuplicateError(api/company/registration.ts:20-28ApplicationAlreadyExistsError(api/application/create.ts:14-22)가 super(409, code, message) 3인자로 호출하므로, 필수 인자로 추가하면 두 서브클래스가 컴파일 실패합니다. 선택 인자로 두면 두 파일의 생성자를 건드리지 않아도 됩니다(그 두 파일도 FE-21 소유지만, 불필요한 변경을 만들지 않습니다).
  • 문구 조립 순수 함수는 utils/watchStatus.ts(FE-20 소유)·utils/recommendation.ts(FE-30 소유) 에 둡니다 — 소비자인 FE-24·FE-39는 각각 후행 wave라 Single Writer 충돌이 없습니다.

400 에러 3분류 (A 확정 — 사용자 안내 vs 개발 오류)

분류코드FE 처리
범위 초과 (사용자가 필터를 좁히면 해결)WATCH_STATE_FILTER_LIMIT_EXCEEDED · RECOMMENDATION_TARGET_LIMIT_EXCEEDEDactualCount·limit으로 “{actualCount}건 → {limit}건 이하로 좁혀 주세요” 를 조립해 표시 + 복구 액션
파라미터 조합 불가 (클라이언트가 고칠 것)JOB_POSTING_SORT_NOT_APPLICABLE사용자 안내 아님. 일반 오류 토스트 + console.error 로깅. FE의 watchedOnly 잠금 규칙이 지켜지면 발생하지 않습니다
입력 값 오류 (기존)VALIDATION_FAILED · BAD_REQUEST”값이 올바르지 않아요” + 초기화

문구 조립은 컴포넌트가 아니라 utils/watchStatus.ts·utils/recommendation.ts의 순수 함수가 담당합니다(no-logic-in-component).

401 처리 규약

apiRequest / apiUpload
  └─ 401 응답 → ApiError(401, 'UNAUTHENTICATED', ...)
       └─ queryClient의 전역 콜백이 code를 판별
            └─ queryClient.setQueryData(['auth','session'], null)
                 └─ AuthGate가 null을 보고 <Navigate to="/login" state={{ from }} />
  • 판별은 ApiError.code === 'UNAUTHENTICATED' 로 합니다(status 401만 보면 INVALID_CREDENTIAL과 구분되지 않습니다).
  • 제외 경로 2개: POST /api/auth/login(로그인 실패는 게이트 대상이 아님)과 GET /api/auth/session(401이 곧 계약이며, 전역 핸들러가 세션 쿼리를 다시 무효화하면 무한 루프가 납니다). 두 요청은 자기 훅이 에러를 직접 소비합니다.
  • 게이트는 세션 쿼리 결과의 authRequired·authenticated 조합으로 판정합니다(C-4). authRequired === false면 인증 상태와 무관하게 통과입니다.
  • 구현 지점은 QueryCache·MutationCacheonError 콜백(api/queryClient.ts)입니다 — 훅마다 401을 처리하지 않습니다.

API 연동

계약은 지원 관리 확장 TDD “Detail Design > API 계약”이 SSOT입니다. FE는 필드·타입을 1:1로 소비하고 임의 변형하지 않습니다.

1단계

화면엔드포인트소비 필드오류 처리
S-14POST /api/auth/loginuseLoginusername, expiresAtINVALID_CREDENTIAL·LOGIN_LOCKED → 인라인(서버 메시지 그대로)
S-00GET /api/auth/sessionuseSessionauthRequired, authenticated, username, expiresAtauthRequired:false → 통과(로그아웃 버튼 숨김) / 401 → 게이트가 /login. 전역 401 핸들러 제외 경로
S-00POST /api/auth/logoutuseLogout실패해도 queryClient.clear() + /login
S-15GET /api/job-postingsuseCrossCompanyJobPostings (useInfiniteQuery)PageResponse<CrossCompanyJobPostingItem> 전량. 쿼리에 companyId[](C-3)·tag[](D) 포함. PRIORITY_DESC·tagwatchedOnly=true 동봉(C-6·D)WATCH_STATE_FILTER_LIMIT_EXCEEDEDactualCount·limit으로 문구 조립 + 정렬 자동 복귀 / JOB_POSTING_SORT_NOT_APPLICABLE → 일반 토스트 + 로깅(클라이언트 버그) / 그 외 400 → “필터 값이 올바르지 않아요” + 초기화 / 5xx → ErrorState
S-16·17PUT /api/dedup-groups/{dedupGroupId}/watch-stateuseSaveWatchStateWatchStateResponse404 DEDUP_GROUP_NOT_FOUND → 토스트 + 목록 무효화 / 400 WATCH_STATE_INVALID_FIELD → 인라인
S-16PUT /api/job-postings/{jobPostingId}/watch-state (C-1 신설)같은 useSaveWatchStatededupGroupId 유무로 분기WatchStateResponse (dedupGroupId 포함)409 POSTING_DEDUP_KEY_ABSENT → 롤백 + “아직 분류할 수 없어요” / 404 JOB_POSTING_NOT_FOUND → 롤백 + 토스트
S-04·17GET /api/companies/{companyId}/job-postings/{jobPostingId} (기존 확장)기존 useJobPostingdedupGroupId·watchState 추가 소비(C-1), matchFieldEvidences·matchScore·matchThreshold(C-2, 3단계)기존 처리 승계
S-17GET /api/dedup-groups/{id}/watch-stateuseWatchState (상세 응답으로 충당되지 않을 때만)봉투 { dedupGroupId, watchState }(B — 단독 DTO 아님)200+watchState:null = 미분류(정상) / 409 FEATURE_DISABLED = 준비 중 / 404 DEDUP_GROUP_NOT_FOUND = 오류
S-17GET /api/dedup-groups/{id}/watch-state/historiesuseWatchStateHistorieshistories[] (없으면 빈 배열 — 404 없음)실패 → 이력 영역만 축소 에러
S-17DELETE /api/dedup-groups/{id}/watch-stateuseRemoveWatchState없어도 204(멱등) — 404 분기 없음
S-18GET /api/dashboarduseDashboardstatusBoard·upcomingInterviews·staleApplicationsErrorState
S-19GET /api/applications/{id}/contactsuseApplicationContactscontacts[]섹션 내 축소 에러
S-19POST·PUT·DELETE .../contacts[/{contactId}]useSaveApplicationContact·useDeleteApplicationContactApplicationContactResponse400 → 필드 인라인 / 404 → 토스트
S-11GET /api/operations/webhook-receiptsuseWebhookReceipts (useInfiniteQuery)PageResponse<WebhookReceiptResponse>ErrorState
S-11GET /api/operations/tunnel-statususeTunnelStatusreachable·checkedAt·publicHostname실패 → “확인할 수 없어요”(중립)

2단계

화면엔드포인트소비 필드오류 처리
S-20GET /api/documents/seriesuseDocumentSeriesseries[]ErrorState
S-21GET /api/documents/series/{seriesId}useDocumentSeriesDetailDocumentSeriesDetailResponse404 → “서류를 찾을 수 없어요”
S-21GET /api/documents/versions/{id}/content— (<a download>)바이너리브라우저 기본
S-22POST /api/documents (multipart)useUploadDocumentDocumentVersionResponse확장자·용량·경로 3종 → 인라인 / 404 계열 → 토스트
S-23POST /api/resume-profiles/draftsuseCreateResumeProfileDraftResumeProfileResponse (extractionFailed 포함)404 → 토스트. 추출 실패는 에러가 아님(201)
S-23PUT /api/resume-profiles/{id}useSaveResumeProfileResumeProfileResponse409 → “이미 확정된 프로필이에요”
S-23POST /api/resume-profiles/{id}/confirmationsuseConfirmResumeProfilecriteriaRevision·reevaluatedCount·elapsedMillis(E)실패 → 토스트 + 폼 유지
S-23GET /api/resume-profiles/currentuseCurrentResumeProfileResumeProfileResponse404 → empty(에러 아님)
S-24GET /api/job-postings/{id}/recommendationuseRecommendationRecommendationResponse 전량404 → 섹션 미렌더
S-23·28POST /api/recommendations/re-evaluationsuseReevaluateRecommendationsevaluatedCount·skippedCount·notEvaluableCount·failures[]·elapsedMillis(E, 스키마 확정)409 RESUME_PROFILE_NOT_CONFIRMED → “프로필을 먼저 확정해 주세요” + [프로필 만들기] / 400 RECOMMENDATION_TARGET_LIMIT_EXCEEDEDactualCount·limit으로 “관심 공고 {actualCount}건이 상한 {limit}건을 넘었어요 / 제외로 정리한 뒤 다시 시도해 주세요” + [보관함 열기]
S-25GET·POST·DELETE /api/applications/{id}/documents[...]useSubmittedDocuments·useLinkSubmittedDocument·useUnlinkSubmittedDocumentSubmittedDocumentResponse409 DOCUMENT_ALREADY_SUBMITTED → 시트 인라인
S-11GET /api/operations/document-upload-failuresuseDocumentUploadFailures (useInfiniteQuery)PageResponse<DocumentUploadFailureResponse>occurredAt·documentType·seriesId·seriesTitle·originalFileName·reasonCode·detailMessageErrorState. 빈 결과는 긍정 상태로 중립 표시

3단계

화면엔드포인트소비 필드오류 처리
S-26GET /api/contact-eventsuseContactEvents (useInfiniteQuery)PageResponse<ContactEventListItem>ErrorState
S-27GET /api/contact-events/{id}useContactEventContactEventDetailResponse 전량404 → “연락을 찾을 수 없어요”
S-27POST /api/contact-events/{id}/decisionsuseDecideContactEventContactEventDetailResponse409 3종 각각 다른 처리(위 S-27 상태 표)
S-28GET·PUT /api/recommendations/weightsuseRecommendationWeights·useSaveRecommendationWeightscriteriaRevision·weights[]400 → 합계 행 인라인
S-24POST·DELETE /api/job-postings/{id}/recommendation/cap-releaseuseReleaseRecommendationCap·useRestoreRecommendationCapRecommendationResponse낙관적 롤백 + 토스트
S-11GET /api/operations/source-registryuseSourceRegistrysources[]ErrorState
S-11POST /api/matching/field-scope-backfillsuseFieldScopeBackfillprocessedCount·newlyMatchedCount·notificationSuppressedCount·unmatchedNowCount토스트 + 결과 카드
S-04공고 상세(기존 확장)기존 useJobPostingmatchFieldEvidences[](matchField·jobKeywordGroupId·jobKeywordGroupName·weightPoints·occurrenceCount·snippet) + matchScore + matchThreshold + matched(판정 SSOT) + excludedByKeyword (C-2 / 게이트 ② ⓑ / BE-80)matchScore === null → 미배포(섹션 미렌더) / === 0 → “일치한 항목이 없어요” / 미매칭 2분기는 excludedByKeyword

FE가 소비하지 않는 엔드포인트

엔드포인트사유
POST /api/webhooks/contact-eventsGmail Apps Script가 호출합니다. FE는 결과(연락 목록)만 소비합니다
POST /api/operations/backfills/posting-platform · .../source-registry배포 절차(1-4·3-4)의 1회성 운영 명령입니다. 화면을 만들지 않습니다 — Open Question #6
GET /api/health-probe서버가 자기 자신을 호출하는 경로입니다

신규 TypeScript 모델 (types/api.ts 확장)

계약과 필드·타입 1:1입니다. nullable은 계약 그대로 유지합니다(FE가 기본값으로 대체하지 않습니다).

타입비고
PageResponse<T>신규 공통 봉투. 기존 엔드포인트에는 적용하지 않습니다
AuthSessionResponseusername·expiresAt
WatchStatus · WatchPriority'INTERESTED'|'PLANNED'|'EXCLUDED' / 'HIGH'|'NORMAL'|'LOW'
WatchStateResponse · WatchStateHistoryResponse · SaveWatchStateRequest
CrossCompanyJobPostingItemmatchScore·recommendation단계별 점진 노출 → 항상 nullable
CrossCompanyJobPostingSort'DEADLINE_ASC'|'DISCOVERED_DESC'|'TITLE_ASC'|'PRIORITY_DESC'
DashboardResponse · DashboardApplicationCard
ApplicationContactResponse · SaveApplicationContactRequest
WebhookReceiptResponse · TunnelStatusResponse
DocumentType'RESUME'|'COVER_LETTER'|'PORTFOLIO'|'ETC'
DocumentSeriesResponse · DocumentSeriesDetailResponse · DocumentVersionResponse
SubmittedDocumentResponse
ResumeProfileResponse · SaveResumeProfileRequestextractionFailed: boolean
RecommendationResponse · RecommendationAxis · RecommendationGrade · RecommendationAxisKeyjudgeable·normalizedWeight·notEvaluableReason
ContactEventListItem · ContactEventDetailResponse · ContactDecisionRequestContactConfidenceLevel = 'HIGH'|'MEDIUM'|'LOW'기존 ConfidenceLevel(근무형태 4종)과 다른 타입이므로 이름을 분리합니다
SourceRegistryResponse · JobSourceRegistryStatus6종
FieldScopeBackfillResponse

타입 이름 충돌 주의: 기존 web/src/types/api.tsConfidenceLevel은 근무형태 확신도 4종(CONFIRMED\|LIKELY\|INFERRED\|UNKNOWN)입니다. 연락 후보 신뢰도 3종은 ContactConfidenceLevel로 별도 선언합니다.

라우팅 · 내비게이션

탭 구조 (5탭 — 기존 “5탭 금지” 규칙 개정 + 랜딩 전환)

첫 진입 화면(/)이 지원 대시보드가 되므로 탭 순서도 바뀝니다 — 토스는 홈을 첫 자리에 둡니다. 랜딩인 지원이 1번, 그다음이 일일 판단 화면인 보관함, 등록·관리 성격인 회사가 3번입니다.

#랜딩활성 판정하위 화면
1지원 (랜딩)//(정확 일치) · /applications* · /documents* · /resume-profile* · /contact-events*S-18·06·07·20·21·23·26·27
2보관함 (신규)/watchlist/watchlist*S-15 (S-16·17은 시트)
3회사/companies/companies* · /job-postings* · /aggregator-sources*S-01·02·03·04·05·12·13
4매칭/settings/matching/settings*S-10 · S-28
5운영/operations/operations*S-11

/dashboard 별칭을 두지 않습니다 — 한 화면에 URL 2개는 활성 탭 판정과 뒤로가기를 모호하게 만듭니다. 대시보드의 정규 URL은 /뿐입니다. 그래서 지원 탭 활성 판정만 startsWith가 아니라 정확 일치(pathname === '/') 를 씁니다.

회사 목록 경로 이동의 영향 범위/를 참조하던 프로덕션 파일 4개를 FE-22가 일괄 갱신합니다.

파일현재변경
components/layout/NavTabs.tsx:16,19to: '/'(회사 탭)탭 5종 재정의 (지원 랜딩 /)
pages/application/ApplicationListPage.tsx:14COMPANY_LIST_PATH = '/''/companies'
pages/posting/ManualJobPostingPage.tsx:10HOME_PATH = '/''/companies'
pages/posting/JobPostingDetailPage.tsx:12HOME_PATH = '/''/companies'

세 페이지 모두 상수 1줄만 바뀌고 본문 로직은 그대로입니다. JobPostingDetailPage.tsx는 FE-29(wave 3)도 수정하지만 다른 wave라 충돌하지 않습니다.

1단계 라우팅 흐름

flowchart LR
    Boot["앱 부팅"] --> Session["GET /api/auth/session"]
    Session -->|401| Login["/login S-14"]
    Session -->|authRequired false| Gate["AuthGate 통과"]
    Session -->|authenticated true| Gate
    Login -->|로그인 성공| Gate
    Gate --> Dashboard["/ S-18 대시보드 랜딩"]
    Dashboard -->|지원 0건 온보딩| Watchlist["/watchlist S-15"]
    Gate --> Companies["/companies S-01"]
    Watchlist -->|분류| WatchSheet["S-16 분류 시트"]
    Watchlist -->|카드 탭| Posting["/job-postings/:id S-04"]
    Posting -->|관리 정보| DetailSheet["S-17 상세 편집 시트"]
    Dashboard -->|카드 탭| AppDetail["/applications/:id S-07"]
    Dashboard -->|전체 목록| AppList["/applications S-06"]
    AppDetail --> Contact["S-19 담당자 시트"]
    Gate --> Ops["/operations S-11 웹훅·터널"]

2단계 라우팅 흐름

flowchart LR
    Dashboard["/ S-18 대시보드"] -->|서류| Docs["/documents S-20"]
    Docs -->|계열 탭| Series["/documents/series/:id S-21"]
    Docs -->|올리기| Upload["S-22 업로드 시트"]
    Series -->|새 버전| Upload
    Docs -->|이력서 프로필| Profile["/resume-profile S-23"]
    Upload -->|이력서 업로드 성공| Profile
    Profile -->|확정| Reeval["재평가 결과"]
    Reeval --> Watchlist["/watchlist S-15 등급 노출"]
    Watchlist -->|카드 탭| Posting["/job-postings/:id S-04"]
    Posting --> Reco["S-24 추천도 섹션"]
    AppDetail["/applications/:id S-07"] --> Submitted["S-25 제출 서류 섹션"]
    Submitted -->|연결| Picker["버전 선택 시트"]

3단계 라우팅 흐름

flowchart LR
    Discord["디스코드 검토 요청 알림"] -->|딥링크| Detail["/contact-events/:id S-27"]
    Dashboard["/ S-18 대시보드"] -->|검토 대기 배너| List["/contact-events S-26"]
    List -->|카드 탭| Detail
    Detail -->|반영| AppDetail["/applications/:id S-07"]
    Detail -->|무시| List
    Matching["/settings/matching S-10"] -->|가중치| Weight["/settings/recommendation S-28"]
    Weight -->|저장 후 재평가| Posting["/job-postings/:id S-04"]
    Posting --> Cap["S-24 상한 해제"]
    Posting --> Evidence["매칭 근거 박스"]
    Ops["/operations S-11"] --> Registry["소스 상태 세그먼트"]
    Registry --> Backfill["매칭 범위 재평가 패널"]

라우트 등록 (Single Writer per File 장치)

web/src/routes.tsx각 단계의 wave 1 골격 티켓(FE-22·FE-31·FE-41)만 수정합니다. 화면 티켓은 자기 페이지 파일만 대체합니다.

단계추가 라우트소유 티켓
1/login(게이트 밖) · /(대시보드, index) · /companies(회사 목록 이전) · /watchlistFE-22
2/documents · /documents/series/:seriesId · /resume-profileFE-31
3/contact-events · /contact-events/:contactEventId · /settings/recommendationFE-41

AuthGate도 같은 기법을 씁니다 — FE-22가 통과만 시키는 스텁으로 미리 배치하고, FE-23이 그 파일을 구현으로 대체합니다(다른 wave이므로 충돌 없음).

테마 토큰

신규 색 토큰 0건, 신규 비색 토큰 0건. 기존 24종을 그대로 씁니다 — 값의 SSOT는 web/src/theme/tokens.css, 매핑 SSOT는 기존 FE 웹 설계 “테마 토큰 정의”입니다.

신규 화면이 쓰는 토큰과 용도(라이트/다크 값은 SSOT 참조):

시맨틱 토큰라이트다크이번 범위의 용도
--background#f2f4f6#0f1115로그인·보관함·대시보드 화면 배경
--surface#ffffff#191c22카드·시트·칸반 카드
--surface-muted#f7f8fa#20242c칸반 컬럼 배경, 판단 불가 축 행, 업로드 드롭존
--surface-elevated#ffffff#22262e시트 표면
--border#e5e8eb#2c313a카드 경계, 점수·충족률 게이지 트랙
--border-strong#d1d6db#3a404b업로드 드롭존 파선 테두리
--overlayrgba(0,0,0,.48)rgba(0,0,0,.64)시트 오버레이
--skeleton#e9ecef#252a32전 화면 로딩
--text-primary#191f28#edeff2제목·점수·우선순위
--text-secondary#4e5968#a7aeb8부가 설명, 미분류 칩 텍스트
--text-tertiary#8b95a1#6b7480저장 경로, 판단 불가, EXCLUDED, 비추천, 종료 후보
--accent#3182f6#4e92f7화면당 1곳 — 주 CTA(로그인·저장·반영하기)·활성 탭
--accent-subtle / --accent-on-subtle#eaf2fe / #1b64da#17263b / #8fbbfbINTERESTED 칩, 현재 프로필 원본 칩, 매칭 근거 하이라이트
--positive / --positive-subtle#00a661 / #e5f7ef#2ecc80 / #10291ePLANNED, 강력 추천·추천, 신뢰도 높음, 소스 ACTIVE, 게이지 채움
--warning / --warning-subtle#c2660a / #fff3e5#ffa23a / #2e211359점 상한, 신뢰도 낮음, 소스 TRANSIENT_FAILURE, 파싱 실패
--danger / --danger-subtle#e02b39 / #feecee#ff6b76 / #331a1d로그인 실패, 업로드 검증 실패, 소스 ACCESS_RESTRICTED, 웹훅 검증 실패
--neutral-chip-bg / --neutral-chip-text#f2f4f6 / #4e5968#252a32 / #a7aeb8문서 유형 칩, 검토 등급, 소스 UNSUPPORTED
--focus-ring#3182f6#7fb2fa키보드 포커스
--radius-card / --radius-chip / --radius-button16px / 8px / 12px동일모서리

검증: web/src/theme/no-hardcoded-color.test.ts가 신규 파일도 자동 스캔합니다(디렉토리 재귀). 각 화면 티켓은 components/**/darkMode.test.tsx 선례를 따라 .dark 렌더 테스트를 포함합니다.

접근성

항목규칙
시맨틱 태그화면 루트 <main>, 섹션 <section> + <h2>, 목록 <ul>/<li>, 폼 <form> + onSubmit
로그인<input autoComplete="username"> / type="password" autoComplete="current-password", 에러는 aria-describedby로 필드에 연결, aria-invalid
칸반컬럼마다 <section aria-label="서류전형 2건">. 가로 스크롤 컨테이너에 tabIndex={0} + role="group"로 키보드 스크롤 허용
시트기존 BottomSheet 재사용 — 포커스 트랩·Esc 닫기·배경 스크롤 잠금·role="dialog"·aria-modal·복귀 포커스가 이미 구현돼 있습니다(components/ui/BottomSheet.tsx:17-58). 신규 시트는 title prop을 반드시 채웁니다(aria-labelledby로 연결됨)
관리 상태 선택role="radiogroup" + aria-checked. 3개 옵션이 화살표 키로 이동
세그먼트기존 Segment 재사용 — role="tablist" + label prop(aria-label) + roving tabindex 계약 승계(components/ui/Segment.tsx:42-54)
게이지<div role="meter" aria-valuenow={92} aria-valuemin={0} aria-valuemax={100} aria-label="지원 추천도"> + 숫자 텍스트 병기(색·길이만으로 정보를 전달하지 않음)
축 충족FULL/PARTIAL/NONE을 형태로 구분하되 텍스트 라벨을 항상 병기(“충족”/“일부”/“없음”)
우선순위/ 기호에 aria-label="우선순위 높음"
파일 입력<label htmlFor> + <input type="file">. 드롭존 div는 label로 감싸 키보드 접근
다운로드 링크<a> + 파일명을 링크 텍스트로(aria-label로 “다운로드” 명시)
태그 삭제각 칩의 aria-label="{태그명} 삭제"
반영 불가 후보disabled + aria-disabled + 사유를 aria-describedby로 연결
색 대비라이트 모드 warning·danger는 WCAG AA 4.5:1을 위해 원색보다 어둡게 잡힌 기존 값을 그대로 씁니다

BE 역제안 4건 — 전부 수용, 계약 확정 (2026-08-08)

초안에서 올린 부족분 4건이 senior-be에서 전부 수용됐습니다. 아래는 확정된 결과이며, 이 문서의 화면·티켓 설계는 확정 계약을 기준으로 갱신됐습니다.

#역제안확정 결과이 문서 반영 위치
C-1공고 상세에 dedupGroupId수용 + 방식 대안 — 상세 응답에 dedupGroupId·watchState 추가(왕복 1회 절약). 단 GET 시 그룹 생성은 미채택(조회가 쓰기를 하면 멱등·캐시 가정이 깨짐) → PUT /api/job-postings/{jobPostingId}/watch-state 신설로 쓰기 시점에 단독 그룹 생성. 409 POSTING_DEDUP_KEY_ABSENT 신설S-15 보충 · S-16 상태 표/보충 · S-17 loading/empty · API 연동 1단계
C-2매칭 근거 노출수용MatchFieldEvidenceResponse 확정, 상세에 배열 + matchScore + matchThreshold, 목록에는 matchScore만. BE-80 신설로 FE-47 착수 차단 해제S-04 확장(3단계) · API 연동 3단계 · FE-47
C-3교차 목록 회사 필터수용companyId: number[] repeatable OR 필터S-15 필터 시트/보충 · API 연동 1단계
C-4auth.required OFF 구간 세션 계약수용 — 응답에 authRequired·authenticated 추가, OFF는 200/ON+무세션은 401. /api/auth/session은 인증 제외 경로가 아님S-14 보충(게이트 3분기) · 401 처리 규약 · Release Scenario

추가로 dba·BE가 확정한 제약 4건을 반영했습니다.

항목확정이 문서 반영 위치
C-6 sort=PRIORITY_DESCwatchedOnly=true 강제(명시적 false는 400), 그룹 id 1,000건 상한 초과 시 400S-15 상태 표(400 세분화)·보충(체크박스 잠금)
C-5 재평가 타임아웃nginx proxy_read_timeout 600s 상향으로 해소. web/nginx.conf는 BE-56 단독 소유 — FE 티켓은 건드리지 않습니다. 대상 1,000건 상한 + elapsedMillis 응답방안 8 · API 연동 2단계 · FE-39
B-2 정렬 기본값deadline_at 실측 71.4% NULL → 기본 정렬 DISCOVERED_DESC 고정, DEADLINE_ASC는 필터 병행 유도S-15 보충
B-8 연락 첨부recruitment_contact_event_attachments 신설. 메타데이터만 · 바이너리 없음 · 이벤트당 20건S-27 보충

2차 검수 결과 — C-7 수용 + 부속 확정 4건 (2026-08-08)

#역제안·지적확정 결과이 문서 반영 위치
C-7400이 전부 BAD_REQUEST로 수렴수용 — 코드 3종 신설(WATCH_STATE_FILTER_LIMIT_EXCEEDED · RECOMMENDATION_TARGET_LIMIT_EXCEEDED · JOB_POSTING_SORT_NOT_APPLICABLE) + ApiErrorResponse에 nullable actualCount·limit 추가. FE-24·FE-39 블로커 해제ApiError 확장 · 400 3분류 표 · S-15 상태 표 · S-24 · API 연동
BGET .../watch-state 응답봉투로 변경{ dedupGroupId, watchState: WatchStateResponse | null }S-17 loading · API 연동 1단계
C미분류 vs 준비 중 vs 그룹 없음3분기 확정 — 미분류 200 + watchState:null(선택 UI 활성) / 플래그 OFF 409 FEATURE_DISABLED(숨김) / 그룹 없음 404(오류). WATCH_STATE_NOT_FOUND 폐기, DELETE는 204 멱등, 이력은 빈 배열 → 404 분기 전면 제거S-16 상태 표 · S-17 상태 표·보충 · API 연동
D태그 필터수용tag repeatable OR. C-6 기계 재사용이라 새 구조·새 상한 없음. watchedOnly 함의, 1,000건 상한 공유S-15 필터 시트·보충 · API 연동
EelapsedMillis재평가 + 프로필 확정 양쪽 응답 스키마에 확정(밀리초). 표시는 선택 사항API 연동 2단계

남은 계약 부족분: 없음.

C-7 역제안 원문 (해소됨)
#부족화면 영향역제안단계
C-7필터·정렬 제약 위반의 400이 전부 BAD_REQUEST 한 코드로 수렴합니다신규 400 발생 조건이 4가지인데(enum 형식 오류 / size 범위 초과 / PRIORITY_DESC + 명시적 watchedOnly=false / 정렬 그룹 id 1,000건 초과) 신규 에러 코드 표에 어느 것도 없습니다. FE는 code로 분기할 수 없어 “필터 값이 올바르지 않아요”와 “범위를 좁혀 주세요”를 구분해 안내할 수 없습니다 — 후자는 사용자가 할 행동이 명확한데 전자와 같은 문구가 나갑니다. 재평가 대상 1,000건 초과 400도 동일합니다코드 3종 신설 — WATCHLIST_SORT_SCOPE_REQUIRED(400, PRIORITY_DESC인데 watchedOnly=false) · WATCHLIST_SORT_SCOPE_EXCEEDED(400, 그룹 id 1,000건 초과) · RECOMMENDATION_TARGET_EXCEEDED(400, 재평가 대상 1,000건 초과). 코드가 없으면 FE는 메시지 문자열 매칭이라는 취약한 분기를 하거나 안내를 뭉뚱그려야 합니다1·2

채택된 이름은 역제안과 다릅니다. 초안이 제안한 WATCHLIST_SORT_SCOPE_REQUIRED·WATCHLIST_SORT_SCOPE_EXCEEDED·RECOMMENDATION_TARGET_EXCEEDED가 아니라 JOB_POSTING_SORT_NOT_APPLICABLE·WATCH_STATE_FILTER_LIMIT_EXCEEDED·RECOMMENDATION_TARGET_LIMIT_EXCEEDED 가 확정 이름입니다. 코드·테스트에 역제안 이름을 남기지 않습니다.

Testing Plan

기존 규약 승계 — Vitest + @testing-library/react + MSW. RED → GREEN → REFACTOR. 사용자 관점 동작만 검증하고, 내부 state 검증·동작 대용 snapshot은 금지합니다.

레벨대상도구필수 케이스
유틸 (순수)utils/watchStatus·dashboard·fileSize·documentFile·recommendation·contactEvent·sourceRegistry, resumeProfileDraftReducerVitest해피 · 경계값(0·상한·null) · 잘못된 입력
API 클라이언트apiRequest 쿠키 전송, apiUpload multipart, 401 → UNAUTHENTICATED 정규화, 직접 fetch 2곳Vitest + MSW200 · 401 · 4xx 부가 필드 보존 · 네트워크 실패
전 신규 훅renderHook + MSW성공 · 실패 · 무효화 발생 · 낙관적 롤백(2건)
컴포넌트신규 프리미티브·도메인 컴포넌트Testing Library렌더 · 인터랙션 · disabled 조건 · 접근성 속성
화면 (통합)S-14~S-28 각각Testing Library + MSW + MemoryRouterloading/empty/error/success 4상태 전부 + 주 인터랙션
테마신규 화면·컴포넌트.dark 렌더 + no-hardcoded-color 스캔두 모드 렌더 · 색 하드코딩 0건

반드시 커버할 실패·엣지 경로 (티켓의 TDD 입력)

#시나리오기대담당
1세션 조회가 401을 반환로그인 화면이 렌더되고, 보호 화면 콘텐츠가 렌더되지 않는다FE-23
1-a세션 조회가 200 authRequired:false게이트를 통과하고 로그인 화면이 렌더되지 않으며 로그아웃 버튼이 숨겨진다FE-23
1-b세션 조회가 200 authRequired:true, authenticated:true게이트 통과 + 로그아웃 버튼이 노출된다FE-23
1-c세션 조회 401이 전역 401 핸들러를 타지 않는다세션 쿼리 무효화가 재귀 호출되지 않는다(무한 루프 없음)FE-21 · FE-23
2세션 조회 응답 전로그인 화면도 보호 화면도 렌더되지 않는다(빈 상태)FE-23
3로그인 5회 실패로 429서버 메시지가 그대로 보이고 CTA가 비활성된다FE-23
4보호 화면에서 API가 401을 반환로그인 화면으로 이동하고, 로그인 성공 시 원래 경로로 복귀한다FE-23
5로그아웃 요청이 실패그래도 캐시가 비워지고 로그인 화면으로 이동한다FE-23
6credentialssame-origin인지apiRequest·registerCompany·createApplication 3곳 전부 쿠키를 실어 보낸다FE-21
7보관함 필터 0건”조건에 맞는 공고가 없어요”가 보이고, “아직 보관함에 공고가 없어요”는 보이지 않는다FE-24
8보관함 분류 전 0건”아직 보관함에 공고가 없어요”가 보인다FE-24
9dedupGroupId === null 공고분류가 가능하고, 저장 요청이 PUT /api/job-postings/{id}/watch-state 경로로 나간다FE-24 · FE-25
9-adedupGroupId가 있는 공고저장 요청이 PUT /api/dedup-groups/{id}/watch-state 경로로 나간다FE-25
9-b공고 기준 저장 응답의 dedupGroupId캐시에 반영되어 다음 저장은 그룹 기준 경로로 나간다FE-25
9-c409 POSTING_DEDUP_KEY_ABSENT낙관적 업데이트가 롤백되고 “아직 분류할 수 없어요” 토스트가 뜬다FE-25
10matchScore·recommendationnull점수·등급 표시가 렌더되지 않는다(0으로 대체하지 않는다)FE-24
11관리 상태 저장 실패카드 칩이 이전 값으로 롤백되고 토스트가 뜬다FE-25
12EXCLUDED가 아닌 상태로 전환요청에 exclusionReason: null이 실린다FE-25
12-a관리 상태 조회가 200 { watchState: null }미분류로 렌더되고 선택 UI가 활성이며 오류·404 처리가 발생하지 않는다FE-25
12-b관리 상태 조회가 409 FEATURE_DISABLED섹션이 숨겨지거나 “준비 중”으로 보이고 롤백 토스트가 뜨지 않는다FE-25
12-c관리 상태 조회가 404 DEDUP_GROUP_NOT_FOUND오류로 처리된다(미분류와 구분)FE-25
12-d이력이 없는 그룹빈 배열을 받아 “변경 이력이 없어요”가 보인다(404 분기 없음)FE-25
12-e관리 상태가 없는 그룹에 DELETE204를 받아 정상 처리된다(멱등)FE-25
13태그 11개째 입력입력 단계에서 차단되고 사유가 인라인으로 보인다(서버 요청이 발생하지 않는다)FE-25
14대시보드 진행 중 4종이 0건컬럼 4개가 유지되고 각각 “없어요”가 보인다FE-26
15장기 미변경 0건해당 섹션이 렌더되지 않는다FE-26
16대시보드 카드의 allowedNextStatuses시트의 선택지가 그 배열과 정확히 일치한다FE-26
17담당자 0건3단계 FR-91 안내 문구가 함께 보인다FE-27
18터널 조회 실패 vs reachable=false전자는 중립 “확인할 수 없어요”, 후자는 danger “끊김”으로 다르게 보인다FE-28
19터널 미설정(publicHostname=null)중립 문구가 보이고 에러로 표시되지 않는다FE-28
2020MB 초과 파일 선택서버 요청 없이 인라인 에러가 뜨고 CTA가 비활성된다FE-33
21.hwp 업로드업로드는 성공하고, 프로필 초안 생성 시 extractionFailed 안내가 보인다FE-33 · FE-35
22DOCUMENT_PATH_ESCAPE 수신”파일 이름에 쓸 수 없는 문자가 있어요”가 인라인으로 보인다FE-33
23같은 유형 계열이 0개”새 계열 만들기”만 보이고 라디오가 렌더되지 않는다FE-33
24텍스트 추출 실패(extractionFailed: true)에러가 아니라 빈 상태로 렌더되고 수동 입력 안내가 보인다FE-35
25확정 프로필 없음(404)빈 상태 + [이력서 올리기]가 보이고 ErrorState는 보이지 않는다FE-35
26확정된 프로필 수정 시도(409)“이미 확정된 프로필이에요” + 서류 보관함 링크가 보인다FE-35
27판단 불가 축(judgeable: false)게이지가 렌더되지 않고 정규화 사실이 문장으로 보인다FE-36
28capReleased: true”상한 해제됨”과 원래 상한 사유가 함께 보인다FE-36
29notEvaluableReason: 'JD_UNAVAILABLE' vs 'PROFILE_NOT_CONFIRMED'문구가 다르고, 후자에만 [프로필 만들기] CTA가 있다FE-36
30추천도 조회 404공고 상세의 다른 섹션은 정상 렌더된다FE-38
31이미 연결된 버전선택지가 비활성 + “이미 연결됨”으로 보인다FE-37
32DOCUMENT_ALREADY_SUBMITTED(409)시트 인라인 에러가 보이고 시트가 닫히지 않는다FE-37
33재평가 응답에 failures[]가 있음실패 건수·사유가 목록으로 보인다FE-39
34RESUME_PROFILE_NOT_CONFIRMED(409)“프로필을 먼저 확정해 주세요” + CTA가 보인다FE-39
35후보 0건반영하기가 비활성이고 무시만 활성이다FE-43
36autoSelectedJobApplicationId === null라디오가 전부 미선택으로 시작하고 수동 선택 안내가 보인다FE-43
37모든 후보가 terminal: true반영 불가 안내가 보이고 무시만 활성이다FE-43
38후보를 바꿈상태 선택지가 새 후보의 allowedNextStatuses로 갱신되고 이전 선택이 초기화된다FE-43
39parse === null파싱 실패 안내가 보이되 후보 선택·반영은 여전히 가능하다FE-43
40CONTACT_EVENT_ALREADY_DECIDED(409)결과 요약으로 전환되고 CTA가 사라진다FE-43
41TRANSITION_NOT_ALLOWED(409)상태 선택 영역에 인라인 에러가 보인다FE-43
42본문 20000자기본 6줄 미리보기 + [전체 보기]로 펼쳐진다FE-43
43가중치 합계 ≠ 100서버 요청 없이 CTA가 비활성되고 현재 합계가 보인다FE-45
43-a정렬을 “우선순위순”으로 변경watchedOnly 체크박스가 자동으로 켜지고 잠긴다, 요청에 watchedOnly=true가 실린다FE-24
43-b정렬을 “마감 임박순”으로 변경 (마감일 필터 없음)deadlineFrom이 오늘로 자동 설정되고 필터 칩에 표시된다FE-24
43-cWATCH_STATE_FILTER_LIMIT_EXCEEDED 수신정렬이 DISCOVERED_DESC로 자동 복귀하고 actualCount·limit 값이 들어간 문구(“1,842건 → 1,000건 이하로”)가 보인다FE-24
43-c1JOB_POSTING_SORT_NOT_APPLICABLE 수신일반 오류 토스트만 뜨고 “필터를 좁혀 주세요” 안내가 보이지 않는다FE-24
43-c2태그 필터 2개 선택요청에 tag가 repeatable로 2번 실리고 watchedOnly=true가 동봉된다FE-24
43-c3태그·우선순위순 둘 다 해제watchedOnly 잠금이 풀린다FE-24
43-c4ApiErroractualCount·limit*_LIMIT_EXCEEDED가 아닌 코드에서는 둘 다 null이다FE-21
43-d회사 필터 2개 선택요청에 companyIdrepeatable로 2번 실린다FE-24
43-eRECOMMENDATION_TARGET_LIMIT_EXCEEDED 수신actualCount·limit이 들어간 안내 + [보관함 열기]가 보인다FE-39
43-e1재평가·프로필 확정 응답의 elapsedMillis결과 카드에 소요 시간이 표시된다FE-35 · FE-39
43-f대시보드 지원 0건온보딩 카드 1개만 보이고 칸반·면접·미변경 3섹션이 전부 렌더되지 않는다FE-26
43-g대시보드 지원 1건 이상온보딩 카드가 사라지고 3요소 대시보드가 보인다FE-26
43-h온보딩 CTA 탭/watchlist로 이동한다FE-26
43-i회사 목록 경로 이동지원 목록·수동 등록·공고 상세의 “회사 목록” 이동이 /companies로 간다FE-22
43-j첨부 20건접기 없이 전량 렌더되고 다운로드 링크가 없다FE-43
44registryStatus === null(백필 전)“상태 미확인” 중립 칩이 보인다FE-44
45소스 정렬접근 제한 → 일시 실패 → … → 비활성 순으로 정렬된다FE-44
46dryRun=true 백필결과 요약만 보이고 “적용됐어요” 문구가 보이지 않는다FE-44
47검토 대기 0건대시보드 배너가 렌더되지 않는다FE-48
48전 신규 화면 다크 모드.dark에서 렌더되고 색 하드코딩이 0건이다전 화면 티켓
49매칭 근거 렌더matchScorematchThreshold함께 보이고, DESCRIPTION_BODY 근거에만 출현 횟수가 병기된다FE-47
50matchScore === null(미배포)근거 섹션이 렌더되지 않고 기존 matched 표시로 폴백한다FE-47
50-amatchScore === 0(근거 없음)“일치한 항목이 없어요”가 보이고 미배포 폴백과 다른 표시가 된다FE-47
50-bmatched: false + excludedByKeyword != null”제외 키워드 ‘{keyword}‘에 걸렸어요”가 보이고 점수 줄·근거 배열이 렌더되지 않는다FE-47
50-cmatched: false + excludedByKeyword === null점수·임계치 + 근거 배열 + 부족 점수(“15점이 더 필요해요”)가 보인다FE-47
50-dmatchScore: 60 + matched: false (제외 키워드가 점수를 이긴 경우)“매칭 안 됨”으로 표시된다 — 점수가 임계치를 넘어도 matched를 따른다FE-47
50-eresolveMatchOutcome 유틸5가지 상태(미배포·근거 없음·매칭·미매칭 점수 미달·미매칭 제외 키워드)를 정확히 판정한다FE-40
51업로드 실패 0건”실패한 업로드가 없어요”가 중립 톤으로 보이고 에러톤·CTA가 없다FE-50
51-a보안 사유 2종PATH_VALIDATION_FAILED·STORAGE_ROOT_ESCAPEdanger + 자물쇠로, 확장자·용량 실패와 다른 톤으로 보인다FE-50
51-b파일명에 RTL override(U+202E) 포함방향 제어 문자가 가시 기호로 치환되어 파일명 위장이 발생하지 않는다FE-30 · FE-50
51-c알 수 없는 reasonCode원문 그대로 + 중립 톤으로 폴백하고 화면이 깨지지 않는다FE-50
51-ddocumentType·seriesTitlenull해당 줄을 생략하고 나머지는 정상 렌더된다FE-50
51-e운영 세그먼트 개수2단계 시점 4개가 가용 폭 안에 렌더된다FE-50

Release Scenario — 점진 공개

BE의 피처 플래그와 배포 순서에 FE가 대응하는 방식입니다. FE는 플래그를 조회하지 않습니다 — 응답 형태(nullable 필드·404)로 하위 호환합니다.

BE 플래그 / 순서FE 대응
1-8 FE 배포 (플래그 전부 OFF)credentials: 'same-origin' + 로그인 화면 + 게이트가 배포됩니다. 세션 조회가 200 authRequired:false를 주므로 게이트가 통과 모드로 동작하고 로그인 화면이 뜨지 않습니다(C-4 확정 — 초안의 모순 구간이 해소됨). 로그아웃 버튼도 숨겨집니다
1-9 auth.required ON세션 조회가 authRequired:true(또는 401)로 바뀌고 게이트가 자동 전환됩니다. FE 재배포 불필요. 1-9를 롤백해도 마찬가지로 FE 재배포가 필요 없습니다
1-10 터널 활성화POST /api/auth/login은 플래그와 무관하게 동작하므로, 1-9 이전에도 로그인 화면을 실제 자격 증명으로 검증해 둘 수 있습니다
watchlist.management OFF관리 상태 API가 409 FEATURE_DISABLED 를 줍니다(C 확정 — 404가 아님) → S-16·17이 섹션을 숨기거나 “준비 중”으로 표시합니다. 미분류(200 + watchState:null)와 명확히 구분됩니다. 보관함 목록 자체는 별도 엔드포인트라 정상 동작합니다
posting.dedup-group-identity OFFdedupGroupId가 전부 null이지만 분류는 동작합니다PUT /api/job-postings/{id}/watch-state가 쓰기 시점에 단독 그룹을 만듭니다(C-1). 백필 이전 잔존 공고만 409 POSTING_DEDUP_KEY_ABSENT로 거부되며, 이는 1-4 백필 완료 후 사라집니다
recommendation.evaluation OFFrecommendation: null / 추천도 404 → 등급 칩·섹션이 렌더되지 않습니다
matching.field-weighted-scope OFF · 재평가 전matchScore: null + matchFieldEvidences: [] → 기존 matched: boolean 표시로 폴백, 근거 박스 미렌더
contact.inbound-webhook OFF연락 목록이 0건 → “검토할 연락이 없어요”(긍정 empty)
source.registry-status OFFregistryStatus: null → “상태 미확인” 중립 칩

핵심 원칙: 신규 필드는 전부 nullable로 소비하고, null이면 해당 표시를 렌더하지 않습니다. 0·빈 문자열로 대체하지 않습니다 — 사용자에게 거짓 정보를 보여 주는 것보다 아무것도 보여 주지 않는 편이 낫습니다.

롤백: FE를 이전 이미지 태그로 되돌리면 신규 라우트가 사라지고 기존 11개 화면이 그대로 동작합니다. 단 auth.required가 ON인 상태에서 구 FE로 되돌리면 credentials: 'omit'이라 전 화면이 401입니다 — FE 롤백 시에는 auth.required를 함께 OFF해야 합니다. 이 순서 제약을 /private-release 체크리스트에 넣습니다.

티켓 분해 · Wave DAG

티켓 30건(FE-20 ~ FE-49). 3단계가 독립 배포 단위이므로 wave도 단계별로 닫습니다.

단계 1 — 기반 (FR-70~80), 10건

wave티켓사이즈BE 의존너비
1FE-20 1단계 API 계약 타입·queryKey·MSW 목MBE-45 (계약 확정)3
1FE-21 쿠키 인증 전환 · 401 정규화 · apiUpload · ApiError 부가 필드 확장MBE-45, BE-46
1FE-22 라우트 골격 · 5탭 내비 · 랜딩 전환(/=대시보드, /companies=회사 목록) · AuthGate 스텁M
2FE-23 로그인 화면 · 세션 훅 · 인증 게이트 · 로그아웃MBE-466
2FE-24 관심 공고 보관함 목록 · 필터(회사 포함) · 정렬(제약 UI 포함)LBE-52
2FE-25 관리 상태 분류 시트 · 상세 편집 시트 (저장 경로 2종 분기)MBE-50, BE-52
2FE-26 지원 대시보드 3요소LBE-51
2FE-27 담당자 관리 섹션 · 시트MBE-49
2FE-28 운영 확장 — 웹훅 수신 · 터널 상태MBE-54
3FE-29 1단계 배선 · 통합 (공고 상세 관리 섹션 — C-1 watchState 직접 소비)MBE-48, BE-52, BE-551

단계 2 — 서류·추천도 (FR-8185, 9596), 11건

wave티켓사이즈BE 의존너비
1FE-30 2단계 계약 타입·queryKey·MSW 목·유틸 3종MBE-562
1FE-31 2단계 라우트 골격 · 지원 탭 하위 진입점S
2FE-32 서류 보관함 (계열 목록)MBE-616
2FE-33 서류 업로드 시트MBE-61
2FE-34 서류 계열 상세 (버전 목록·다운로드)MBE-61
2FE-35 이력서 프로필 검토·확정LBE-62
2FE-36 추천도 표시 컴포넌트 (점수·축·상한)MBE-63, BE-65
2FE-37 제출 서류 연결 섹션 · 선택 시트MBE-64
2FE-50 운영 확장 — 서류 업로드 실패 이력 (신규)SBE-81
3FE-38 2단계 배선 (보관함 등급 칩 · 공고 상세 추천도 섹션 · 업로드 CTA)MBE-662
3FE-39 일괄 재평가 진입점 · 결과 요약 · 실패 목록SBE-65, BE-67

단계 3 — 연동 (FR-86~94, 97), 10건

wave티켓사이즈BE 의존너비
1FE-40 3단계 계약 타입·queryKey·MSW 목·유틸 2종MBE-682
1FE-41 3단계 라우트 골격 · 매칭/운영 하위 진입점S
2FE-42 연락 검토 목록MBE-776
2FE-43 연락 검토 상세 · 반영 결정LBE-77
2FE-44 소스 상태 레지스트리 · 매칭 범위 재평가 패널MBE-78, BE-74
2FE-45 추천도 가중치 설정SBE-72
2FE-46 59점 상한 해제 컨트롤SBE-72
2FE-47 매칭 근거 표시 (미매칭 포함 — 게이트 ② ⓑ)LBE-80, BE-69
3FE-48 대시보드 검토 대기 배너 · 딥링크 배선SBE-762
3FE-49 3단계 회귀 · 다크 모드 · 접근성 전수 점검MBE-79

Wave 너비 분포

단계 1: 3, 6, 1
단계 2: 2, 7, 2
단계 3: 2, 6, 2
  • 전체 분포: 3, 6, 1 | 2, 7, 2 | 2, 6, 2 (티켓 31건 — FE-50 신설)
  • 평균 3.44, 최대 7, 최소 1
  • 모든 wave 너비가 1~2인 직선형 DAG가 아니므로 분해 게이트를 통과합니다.
  • 각 단계의 wave 1이 병목이고 wave 2가 6갈래로 벌어집니다. 병목을 2~3개로 쪼갠 근거: 계약·목(FE-20/30/40)과 라우트 골격(FE-22/31/41)은 서로 다른 파일을 건드리고 서로를 참조하지 않으므로 합칠 이유가 없습니다. 1단계에만 쿠키 전환(FE-21)이 추가로 독립합니다.

Single Writer per File 검증

같은 wave의 두 티켓이 같은 파일을 수정하는지 확인했습니다. 교집합 0건입니다.

단계 1 wave 1 (3티켓)

티켓수정 예상 파일
FE-20types/api.ts, api/queryKeys.ts, mocks/handlers.ts, mocks/fixtures/{watchlist,dashboard,contacts,operations}.ts, mocks/errorScenarios.ts, mocks/store.ts, utils/watchStatus.ts(신규), components/ui/TextArea.tsx(신규), components/ui/index.ts, components/watchlist/{WatchStatusChip,WatchPriorityMark}.tsx(신규 — 표시 칩만)
FE-21api/client.ts(ApiErroractualCount·limit 추가 포함), api/company/registration.ts, api/application/create.ts, api/queryClient.ts
FE-22routes.tsx, components/layout/NavTabs.tsx, components/layout/AppShell.tsx, components/auth/AuthGate.tsx(스텁), pages/auth/LoginPage.tsx(스텁), pages/watchlist/WatchlistPage.tsx(스텁), pages/dashboard/DashboardPage.tsx(스텁), 경로 상수 1줄 갱신 3파일pages/application/ApplicationListPage.tsx:14·pages/posting/ManualJobPostingPage.tsx:10·pages/posting/JobPostingDetailPage.tsx:12

components/ui/index.ts는 FE-20만 수정합니다. FE-30이 FileField를 추가할 때도 같은 파일을 건드리지만 다른 단계·다른 wave입니다.

단계 1 wave 2 (6티켓)

티켓수정 예상 파일
FE-23api/auth/*.ts(신규), hooks/auth/*.ts(신규), components/auth/AuthGate.tsx(스텁 대체), pages/auth/LoginPage.tsx(스텁 대체), components/layout/LogoutButton.tsx(신규)
FE-24api/posting/crossCompanyList.ts(신규), hooks/watchlist/useCrossCompanyJobPostings.ts(신규), pages/watchlist/**(스텁 대체 + 신규 5파일)
FE-25api/watchlist/watchState.ts(신규), hooks/watchlist/useWatchState*.ts(신규), components/watchlist/{WatchStateSheet,WatchStateDetailSheet,TagInput}.tsx(신규 3파일 — 표시 칩 2종은 FE-20 소유이므로 제외)
FE-26api/dashboard.ts(신규), hooks/dashboard/useDashboard.ts(신규), pages/dashboard/**(스텁 대체 + 신규 5파일), utils/dashboard.ts(신규)
FE-27api/application/contacts.ts(신규), hooks/application/use*Contact*.ts(신규), components/application/ContactSheet.tsx(신규), pages/application/ContactSection.tsx(신규), pages/application/ApplicationDetailPage.tsx(수정)
FE-28api/operations.ts(수정), hooks/operations/use{WebhookReceipts,TunnelStatus}.ts(신규), pages/operations/{TunnelStatusCard,WebhookReceiptSection}.tsx(신규), pages/operations/OperationsPage.tsx(수정)
  • FE-23과 FE-22가 AuthGate.tsx·LoginPage.tsx를 공유하지만 다른 wave입니다(FE-22가 스텁 생성 → FE-23이 대체).
  • FE-24와 FE-25가 둘 다 watchlist 도메인이지만 디렉토리가 다릅니다(pages/watchlist/ vs components/watchlist/). FE-24가 쓰는 WatchStatusChip·WatchPriorityMarkwave 1의 FE-20이 소유합니다 — 같은 wave 티켓의 산출물에 의존하지 않게 표시 칩을 선행 wave로 올렸습니다(정의는 선행, 사용은 후행).
  • FE-27과 FE-26이 둘 다 지원 도메인이지만 파일이 다릅니다 — FE-26은 ApplicationDetailPage.tsx를 건드리지 않습니다(대시보드는 링크만).
  • hooks/application/ 디렉토리를 FE-27이 쓰지만 기존 파일은 수정하지 않고 신규 파일만 추가합니다.

단계 1 wave 3 (1티켓)

FE-29 단독이므로 검증 불필요. 수정 대상: pages/posting/JobPostingDetailPage.tsx, pages/posting/WatchStateSection.tsx(신규), pages/watchlist/WatchlistCard.tsx(시트 연결).

단계 2 wave 2 (6티켓)

티켓수정 예상 파일
FE-32api/document/seriesList.ts, hooks/document/useDocumentSeries.ts, pages/document/DocumentSeriesListPage.tsx(스텁 대체), pages/document/DocumentSeriesCard.tsx
FE-33api/document/upload.ts, hooks/document/useUploadDocument.ts, components/document/DocumentUploadSheet.tsx
FE-34api/document/seriesDetail.ts, hooks/document/useDocumentSeriesDetail.ts, pages/document/DocumentSeriesDetailPage.tsx(스텁 대체), pages/document/DocumentVersionCard.tsx
FE-35api/resume/*.ts, hooks/resume/*.ts, pages/resume/**(스텁 대체 + 신규 4파일)
FE-36api/recommendation/detail.ts, hooks/recommendation/useRecommendation.ts, components/recommendation/**(신규 4파일)
FE-37api/application/submittedDocuments.ts, hooks/application/useSubmittedDocument*.ts, components/application/SubmittedDocumentPickerSheet.tsx, pages/application/SubmittedDocumentSection.tsx, pages/application/ApplicationDetailPage.tsx(수정)
FE-50api/operations/documentUploadFailures.ts(신규), hooks/operations/useDocumentUploadFailures.ts(신규), pages/operations/{DocumentUploadFailureSection,DocumentFailureReasonChip}.tsx(신규), pages/operations/OperationsPage.tsx(수정 — 세그먼트 1개 추가)
  • api/document/ 안에서 목록·상세·업로드를 3파일로 분리해 FE-32/33/34 교집합을 없앴습니다.
  • FE-37만 ApplicationDetailPage.tsx를 수정합니다(FE-27은 다른 단계).
  • FE-33의 시트를 FE-32·34가 열어야 하지만, 배선은 wave 3의 FE-38이 수행합니다(같은 wave 충돌 회피).

단계 2 wave 3 (2티켓)

티켓수정 예상 파일
FE-38pages/watchlist/WatchlistCard.tsx, pages/posting/JobPostingDetailPage.tsx, pages/posting/RecommendationSection.tsx(신규), pages/document/DocumentSeriesListPage.tsx(CTA 배선), pages/document/DocumentSeriesDetailPage.tsx(CTA 배선)
FE-39hooks/recommendation/useReevaluateRecommendations.ts, pages/resume/ReevaluationProgressCard.tsx, pages/resume/ResumeProfilePage.tsx(결과 영역)

교집합 ∅ — FE-38은 보관함·공고 상세·서류, FE-39는 이력서 프로필 화면만 건드립니다.

단계 3 wave 2 (6티켓)

티켓수정 예상 파일
FE-42api/contact/list.ts, hooks/contact/useContactEvents.ts, pages/contact/ContactEventListPage.tsx(스텁 대체), pages/contact/ContactEventCard.tsx
FE-43api/contact/{detail,decision}.ts, hooks/contact/use{ContactEvent,DecideContactEvent}.ts, pages/contact/ContactEventDetailPage.tsx(스텁 대체) + 신규 5파일
FE-44api/operations/sourceRegistry.ts, api/matching/fieldScopeBackfill.ts, hooks/operations/useSourceRegistry.ts, hooks/matching/useFieldScopeBackfill.ts, pages/operations/{SourceRegistrySection,FieldScopeBackfillPanel}.tsx, pages/operations/OperationsPage.tsx(수정)
FE-45api/recommendation/weights.ts, hooks/recommendation/useRecommendationWeights.ts, pages/recommendation/**(스텁 대체), pages/matching/MatchingSettingsPage.tsx(링크 추가)
FE-46api/recommendation/capRelease.ts, hooks/recommendation/useRecommendationCap.ts, components/recommendation/CapReleaseControl.tsx(신규)
FE-47api/posting/detail.ts(수정), pages/posting/MatchEvidenceBox.tsx(신규)
  • FE-42와 FE-43이 둘 다 pages/contact/이지만 파일이 다릅니다(목록 vs 상세). 둘 다 쓰는 ConfidenceChipwave 1의 FE-40이 소유합니다 — 같은 wave 의존을 만들지 않기 위해 표시 칩을 선행 wave로 올렸습니다.
  • FE-46은 components/recommendation/에 신규 파일만 추가합니다(FE-36의 기존 4파일 미수정). 공고 상세 배선은 wave 3의 FE-48이 합니다.
  • FE-47이 pages/posting/MatchEvidenceBox.tsx를 만들고, 공고 상세 배선은 FE-48이 합니다.
  • FE-44만 OperationsPage.tsx를 수정합니다(FE-28은 다른 단계).

단계 3 wave 3 (2티켓)

티켓수정 예상 파일
FE-48pages/dashboard/PendingContactBanner.tsx(신규), pages/dashboard/DashboardPage.tsx(수정), pages/posting/JobPostingDetailPage.tsx(FE-46·47 배선)
FE-49테스트 파일만 — e2e성 통합 테스트·다크 모드 전수·접근성 점검. 프로덕션 파일 수정 시 해당 티켓에 되돌려 보고

공통 파일 와이어업 배치

파일배치
routes.tsx각 단계 wave 1 단독 (FE-22 · FE-31 · FE-41)
api/client.ts · api/queryClient.ts단계 1 wave 1 단독 (FE-21). 이후 단계는 수정하지 않습니다
types/api.ts · api/queryKeys.ts각 단계 wave 1 단독 (FE-20 · FE-30 · FE-40)
mocks/handlers.ts · mocks/fixtures/**각 단계 wave 1 단독 (FE-20 · FE-30 · FE-40)
components/ui/index.tsFE-20(TextArea) · FE-30(FileField) — 다른 단계
components/layout/NavTabs.tsx · AppShell.tsx단계 1 wave 1 단독 (FE-22). 2·3단계는 탭을 늘리지 않으므로 수정 없음
pages/application/ApplicationDetailPage.tsxFE-27(단계 1) · FE-37(단계 2) — 다른 단계
pages/operations/OperationsPage.tsxFE-28(단계 1 w2 — 웹훅 세그먼트) · FE-50(단계 2 w2 — 서류 실패 세그먼트) · FE-44(단계 3 w2 — 소스 상태 세그먼트 + 라벨 5종 축약) — 전부 다른 단계라 wave 교집합 없음. 각 단계에서 이 파일을 만지는 티켓은 정확히 1개입니다
pages/posting/JobPostingDetailPage.tsxFE-22(단계 1 wave 1 — 경로 상수 1줄) · FE-29(단계 1 wave 3) · FE-38(단계 2) · FE-48(단계 3) — 전부 다른 wave
pages/application/ApplicationListPage.tsx · pages/posting/ManualJobPostingPage.tsxFE-22 단독 (경로 상수 1줄). 다른 티켓은 건드리지 않습니다
web/nginx.confBE-56 단독 소유 (C-5). FE 티켓은 이 파일을 수정하지 않습니다 — 현재 FE 티켓 30건 중 이 파일을 만지는 티켓은 0건입니다
pages/watchlist/WatchlistCard.tsxFE-24(생성) · FE-29(시트 배선) · FE-38(등급 칩) — 전부 다른 wave
공용 표시 칩 (WatchStatusChip·WatchPriorityMark·DocumentTypeChip·ConfidenceChip)각 단계 wave 1 계약 티켓이 소유 (FE-20 · FE-30 · FE-40). wave 2의 두 티켓이 같은 칩을 쓰는 상황을 만들지 않기 위해 정의를 선행 wave로 올렸습니다
theme/tokens.css · tailwind.config.ts수정 0건 (신규 토큰 없음)

시나리오 커버리지

PRD 유저 시나리오 → 화면 → 티켓 매핑입니다. 누락 0건입니다.

PRD 시나리오요구 흐름화면티켓
11 — 관심 → 지원 예정 → 지원① 관심 저장 + 우선순위·목표일·태그S-15 → S-16 → S-17FE-24, FE-25
PLANNED 전환S-16FE-25
③ 지원 레코드 생성 → 파생 표시S-04 기존 지원 시트 → S-15 AppliedBadgeFE-24, FE-29
④ 변경 이력S-17 이력 영역FE-25
12 — 이력서 → 프로필 확정 → 추천도① PDF 업로드S-22FE-33
② 초안 생성S-23FE-35
③ 검토·수정·확정S-23FE-35
④ 재평가 + 추천도 표시S-23 결과 → S-24 · S-15 등급 칩FE-36, FE-38, FE-39
13 — 텍스트 추출 실패① 스캔 PDF 업로드S-22FE-33
② 실패 안내 + 수동 입력 유도S-23 extractionFailed 빈 상태FE-35
③ 등록 자체는 저장됨S-21 버전 목록에 존재FE-34
14 — 연락 수신 → 검토 → 반영① 웹훅 수신(FE 미소비) · 운영 확인FE-28
② 후보·신뢰도 계산S-27 후보 섹션FE-43
③ 디스코드 알림(BE) · 배너FE-48
④ 검토 화면 진입S-26 · S-27 (딥링크)FE-42, FE-43, FE-48
⑤ 반영 시에만 전이S-27 결정 폼FE-43
15 — 종료된 지원에 연락 도착반영 불가 표시 + 무시·재선택만S-27 terminal 후보FE-43
16 — 신뢰도 낮아 수동 선택자동 선택 없음 + 수동 선택 요구S-27 autoSelectedJobApplicationId === nullFE-43
17·18 — AI 보조 추출 실패·한도 초과범위 밖 (P2 선택 기능) — PRD가 2단계 이후 후속 범위로 명시

FR → 화면 → 티켓 매핑

FR화면티켓
FR-70 최소 인증S-14 · S-00 게이트FE-21, FE-22, FE-23
FR-71 터널 노출S-11 터널 상태 카드FE-28
FR-72 웹훅 HMACS-11 웹훅 수신 이력FE-28
FR-73 관리 상태 3종S-16FE-25
FR-74 그룹 귀속S-15 dedupGroupId 처리FE-24, FE-29
FR-75 전이 규칙S-16 · S-15 hasApplicationFE-25, FE-29
FR-76 변경 이력S-17 이력 영역FE-25
FR-77 우선순위·목표일·태그·메모·제외 사유S-16 · S-17FE-25
FR-78 교차 목록·필터·정렬S-15FE-24
FR-79 대시보드 3요소S-18FE-26
FR-80 담당자S-19FE-27
FR-81 문서 유형·계열·버전S-20 · S-21FE-32, FE-34
FR-82 저장 경로S-21 storedRelativePath 표시FE-34
FR-83 확장자·용량S-22 선검증FE-33
FR-84 경로 새니타이즈S-22 DOCUMENT_PATH_ESCAPE 처리FE-33
FR-85 버전 고정 연결S-25FE-37
FR-86 필드별 가중치 매칭S-04 매칭 근거 박스FE-47 (계약 확정 · BE-80)
FR-87 소급 재평가S-11 재평가 패널FE-44
FR-88 소스 상태 6종S-11 소스 상태 세그먼트FE-44
FR-89 연락 웹훅·후보S-26 · S-27FE-42, FE-43
FR-90 검토 화면·확인형 반영S-27FE-43
FR-91 후보 점수 4축S-27 후보 카드FE-43
FR-92 Gmail 계약(FE 미소비)
FR-93 변경 공고 알림(BE 전용 — 화면 요구 없음)
FR-94 검토 요청 알림S-18 배너 · 딥링크FE-48
FR-95 프로필 초안·확정S-23FE-35
FR-96 추천도 4축·정규화·상한S-24FE-36, FE-38
FR-97 상한 해제·가중치 수정S-24 · S-28FE-45, FE-46

누락 0건. FR-92·93은 FE 화면 요구가 없는 항목이며, 시나리오 17·18은 PRD가 P2 선택 기능으로 명시한 범위 밖입니다.

Open Questions

해소된 항목 (2026-08-08 패치)

#항목해소 결과
1첫 진입 화면사용자 확정/ = 지원 대시보드(S-18), 회사 목록은 /companies. 초기 빈 화면 문제는 대시보드 empty를 온보딩 진입점으로 설계해 해소
2보관함 회사 필터C-3 수용companyId: number[] repeatable 추가. 태그 필터는 여전히 없습니다 → 아래 신규 9로 이월
4재평가 프록시 타임아웃C-5 확정 — nginx proxy_read_timeout 600s 상향(BE-56 소유). 비동기 잡 미채택. 대상 1,000건 상한 + elapsedMillis 실측
3watchlist.management OFF 구간 문구C 확정 — 플래그 OFF는 409 FEATURE_DISABLED(섹션 숨김), 미분류는 200 + watchState:null(선택 UI 활성). WATCH_STATE_NOT_FOUND 폐기로 404 분기 제거
9400 에러 코드 부재C-7 수용 — 코드 3종 신설 + actualCount·limit 부가 필드. FE-24·FE-39 블로커 해제
10elapsedMillis 스키마 미반영E 확정 — 재평가·프로필 확정 양쪽 응답 스키마에 elapsedMillis: number(밀리초) 반영
11태그 필터D 수용tag repeatable OR. watchedOnly 함의, 1,000건 상한 공유
12미매칭 공고의 매칭 근거 노출 범위게이트 ② ⓑ 채택 — 미매칭도 근거 표시. 확대 비용은 +32.6%(3년 51,600행/22MB)로 실측됨. matched가 판정 SSOT, excludedByKeyword로 미매칭 사유 2분기
4(구 BE)서류 업로드 실패 조회 수단B 확정 — 테이블 + GET /api/operations/document-upload-failures 신설(BE-81). 운영 화면 세그먼트로 배치(방안 14)

남은 항목

#항목현재 처리확정 필요 시점
13업로드 실패 사유 코드 6종 최종 확정 (신규)BE가 dba와 정합 중입니다. FE는 utils/documentFailure.ts의 라벨 맵에 알 수 없는 코드는 원문 + 중립 톤으로 폴백하도록 설계해, 코드가 바뀌어도 화면이 깨지지 않습니다2단계 FE-50 통합 검증 전. 목록이 바뀌면 라벨 맵 1곳만 갱신하면 됩니다
5문서 계열 제목 수정·버전 삭제계약에 API가 없어 UI를 만들지 않았습니다. 오타를 낸 계열 제목은 영구히 남습니다실사용 후 불편하면 BE 계약 추가
6백필 실행 운영 화면POST /api/operations/backfills/* 2종은 배포 절차의 1회성 명령이라 화면을 만들지 않았습니다(터미널 curl)반복 실행이 필요해지면 운영 화면에 추가
7세션 만료 임박 안내expiresAt을 받지만 화면에 노출하지 않습니다 — 만료되면 다음 요청이 401을 주고 게이트가 처리합니다작성 중 문서가 날아가는 불편이 실제로 발생하면 만료 5분 전 배너 추가 검토
8다크 모드 스크린샷 검증 수단단위 테스트는 .dark 클래스 렌더만 확인하고 실제 대비를 보지 않습니다기존 scripts/capture-usecases.mjs(Playwright) 하네스에 신규 화면을 등록할지 3단계 FE-49에서 판단

Document History

날짜변경 내용
2026-08-08최초 작성 — FR-7097 FE 웹 설계. 화면 15개(S-14S-28)·기존 화면 확장 3개·티켓 30건(FE-20~FE-49) 분해. 신규 색 토큰 0건. BE 계약 역제안 4건
2026-08-09게이트 ② 승인 반영 패치 (최종) — A 미매칭 근거 표시 ⓑ 확대 채택(비용 +32.6% 실측): matched판정 SSOT로 못박고 점수 비교 판정을 utils/matchEvidence.ts#resolveMatchOutcome에 격리, excludedByKeyword로 미매칭 사유 2분기(제외 키워드 vs 점수 미달), matchScore 3-state(null=미배포 / 0=근거 없음 / >0=근거 있음)로 빈 배열 의미 분리, 미매칭 와이어프레임 2종 신설 / B 서류 업로드 실패 이력 화면 신설 — 방안 14로 운영 화면(S-11) 세그먼트 배치 확정(연 20행 규모라 별도 화면 과함 + 보안 신호를 일상 화면에 묻지 않음), 보안 사유 2종 danger+자물쇠 구분(신규 토큰 0건), originalFileName XSS·RTL override 위장·길이 3중 처리, 방안 15로 세그먼트 5개 시점(3단계) 라벨 축약 확정. FE-50 신설 → 티켓 31건, wave 3,6,1 | 2,7,2 | 2,6,2(평균 3.44)
2026-08-082차 계약 확정 패치 — A 400 에러 3분류 수용(WATCH_STATE_FILTER_LIMIT_EXCEEDED·RECOMMENDATION_TARGET_LIMIT_EXCEEDED·JOB_POSTING_SORT_NOT_APPLICABLE) + ApiErroractualCount·limit 확장(FE-21 소유) / B GET .../watch-state 봉투 응답 / C 미분류(200+watchState:null) vs 준비 중(409 FEATURE_DISABLED) vs 그룹 없음(404) 3분기 확정, WATCH_STATE_NOT_FOUND 폐기로 404 분기 전면 제거, DELETE 204 멱등·이력 빈 배열 / D 태그 필터 tag 수용(watchedOnly 함의·1,000건 상한 공유) / E elapsedMillis 재평가+프로필 확정 양쪽 스키마 확정 / F 미매칭 근거 범위는 게이트 ② 대기(설계는 ⓐ 유지, FE-47 주석만). 남은 계약 부족분 0건
2026-08-08계약 확정 반영 패치 — 사용자 확정(랜딩 /=지원 대시보드 + 대시보드 empty 온보딩, 탭 순서 지원·보관함·회사·매칭·운영) / 역제안 4건 전부 수용(C-1 공고 상세 dedupGroupId·watchState + PUT /api/job-postings/{id}/watch-state 신설·409 POSTING_DEDUP_KEY_ABSENT · C-2 매칭 근거 노출·BE-80 신설로 FE-47 차단 해제 · C-3 companyId 필터 · C-4 authRequired 세션 계약으로 게이트 3분기) / dba·BE 확정 반영(C-6 PRIORITY_DESCwatchedOnly 강제·1,000건 상한 UI · C-5 nginx 600s(BE-56 소유, FE 미수정) · B-2 기본 정렬 DISCOVERED_DESC 고정·DEADLINE_ASC 필터 유도 · B-8 첨부 메타 20건·다운로드 없음) / 신규 역제안 C-7(400 에러 코드 3종 부재)