지원 관리 확장 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에 세 가지를 요구합니다.
- 인증 게이트 — 전 API가 세션 쿠키를 요구하게 되므로, API 클라이언트의
credentials를 바꾸고 로그인 화면·401 유도·부팅 시 세션 확인을 신설합니다. 이것이 없으면 기존 11개 화면이 전부 동작하지 않습니다. - 내비게이션 축의 전환 — 지금까지 앱의 첫 화면은 회사 목록이었고 공고는 “회사 → 회사 상세 → 공고 목록” 경로로만 도달했습니다. 이제 첫 진입 화면(
/)이 지원 대시보드(S-18) 가 되고, 공고 판단은 회사 경계를 넘는 관심 공고 보관함(S-15) 이 담당합니다. 회사 목록은/companies로 이동해 등록·소스 관리 화면으로 역할이 좁아집니다 (사용자 확정, 2026-08-08). - 판단을 돕는 표시 계층 — 추천도(점수·등급·축별 근거·판단 불가·상한), 매칭 근거, 연락 후보 신뢰도는 전부 “시스템이 이렇게 판단했고 근거는 이것”을 사용자가 검토·번복할 수 있게 보여 주는 화면입니다. 자동 반영을 하지 않는 것이 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-30의apiRequest는credentials: '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.tsx는QueryClientProvider → ThemeProvider → RouterProvider3중첩뿐입니다. - 기존 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-21의fetchJobPostings는companyId를 경로에 박고 쿼리는postingStatus·sort2개뿐입니다. 페이지네이션 파라미터가 없습니다.web/src/types/api.ts의JobPostingListItem에는 관리 상태·우선순위·태그·추천도·플랫폼 필드가 없습니다.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-31이 src/** 전 TS/TSX를 스캔 |
| Tailwind 기본 팔레트가 제거돼 있어 원색 클래스를 쓸 수 없음 | web/tailwind.config.ts — colors를 extend가 아닌 교체로 선언 |
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-IS | TO-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과 동일한 구조 | 드래그 앤 드롭 상태 전이는 미채택 — 기존 TransitStatusSheet가 allowedNextStatuses로 전이를 제한하는데, 드래그는 그 제약을 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종은 기존 Chip의 fill × 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 | 로그인 | /login | 1 | POST /api/auth/login | FR-70 | FE-23 |
| S-15 | 관심 공고 보관함 | /watchlist | 1 | GET /api/job-postings | FR-73,77,78 | FE-24 |
| S-16 | 관리 상태 분류 시트 | (S-15·S-04 내 시트) | 1 | PUT /api/dedup-groups/{id}/watch-state · PUT /api/job-postings/{id}/watch-state | FR-73,75,77 | FE-25 |
| S-17 | 관리 상태 상세 편집 시트 | (S-04 내 시트) | 1 | PUT·DELETE·GET .../histories | FR-76,77 | FE-25 |
| S-18 | 지원 대시보드 (앱 랜딩) | / | 1 | GET /api/dashboard | FR-79 | FE-26 |
| S-19 | 담당자 관리 (섹션 + 시트) | (S-07 내) | 1 | /api/applications/{id}/contacts CRUD | FR-80 | FE-27 |
| S-20 | 서류 보관함 (계열 목록) | /documents | 2 | GET /api/documents/series | FR-81 | FE-32 |
| S-21 | 서류 계열 상세 (버전 목록) | /documents/series/:seriesId | 2 | GET /api/documents/series/{id} | FR-81,82,85 | FE-34 |
| S-22 | 서류 업로드 시트 | (S-20·S-21 내 시트) | 2 | POST /api/documents | FR-82,83,84 | FE-33 |
| S-23 | 이력서 프로필 검토·확정 | /resume-profile | 2 | /api/resume-profiles/** | FR-95 | FE-35 |
| S-24 | 지원 추천도 (섹션 + 등급 뱃지) | (S-04 내 섹션 / S-15 카드) | 2 | GET /api/job-postings/{id}/recommendation | FR-96,97 | FE-36 |
| S-25 | 제출 서류 연결 (섹션 + 선택 시트) | (S-07 내) | 2 | /api/applications/{id}/documents | FR-85 | FE-37 |
| S-26 | 연락 검토 목록 | /contact-events | 3 | GET /api/contact-events | FR-89,90 | FE-42 |
| S-27 | 연락 검토 상세·반영 | /contact-events/:contactEventId | 3 | GET·POST .../decisions | FR-90,91 | FE-43 |
| S-28 | 추천도 가중치 설정 | /settings/recommendation | 3 | GET·PUT /api/recommendations/weights | FR-97 | FE-45 |
기존 화면 확장
| 화면 | 확장 내용 | 단계 | 소유 티켓 |
|---|---|---|---|
| S-04 공고 상세 | 관리 상태 섹션(S-16·S-17 진입) | 1 | FE-29 |
| S-04 공고 상세 | 추천도 섹션(S-24) · 59점 상한 해제 토글 | 2·3 | FE-38 · FE-46 |
| S-04 공고 상세 | 매칭 근거(필드별 일치) 표시 | 3 | FE-47 |
| S-07 지원 상세 | 담당자 섹션(S-19) | 1 | FE-27 |
| S-07 지원 상세 | 제출 서류 섹션(S-25) | 2 | FE-37 |
| S-11 운영 | 웹훅 수신 이력 · 터널 상태 | 1 | FE-28 |
| S-11 운영 | 서류 업로드 실패 이력 | 2 | FE-50 |
| S-11 운영 | 소스 상태 레지스트리 6종 | 3 | FE-44 |
| S-18 대시보드 | 검토 대기 연락 배너 | 3 | FE-48 |
| S-00 앱 셸 | 5탭 + 인증 게이트 + 로그아웃 | 1 | FE-22 · FE-23 |
상태 표기 규칙 (신규 도메인 → 기존 토큰 매핑)
신규 색 토큰 0건. 신규 표시는 전부 아래 매핑으로 처리합니다. 매핑은 Chip의 fill(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 && !capReleased | filled warning | --warning-subtle | --warning |
| 59점 상한 해제됨 | capReleased | outlined 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 === true | none + 자물쇠 문구 | — | --text-tertiary |
| 문서 유형 | 이력서/자소서/포트폴리오/기타 | filled neutral | --neutral-chip-bg | --neutral-chip-text |
| 현재 프로필 원본 | isCurrentProfileSource | filled 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_ESCAPE | filled danger + 자물쇠 기호 | --danger-subtle | --danger |
| 업로드 실패 사유 (사용자 입력) | EXTENSION_NOT_ALLOWED · SIZE_EXCEEDED | outlined neutral | 없음 | --text-secondary |
| 업로드 실패 사유 (시스템) | STORAGE_IO_FAILED · OTHER | filled warning | --warning-subtle | --warning |
| 터널 도달 | reachable true/false | filled 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 | 해당 없음 (입력 폼) |
| error | INVALID_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개 · 마감일 범위”) |
| error | ErrorState “공고를 불러오지 못했어요” + [다시 시도]. 필터 바는 유지 — 조건을 바꿔 재시도할 수 있어야 합니다 |
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·tag에 watchedOnly=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.ts의resolveMatchOutcome을 경유합니다. - 필터·정렬·검색은 전부 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_at의 71.4%(4,738/6,633)가 NULL이라DEADLINE_ASC는 인덱스 조기 종료가 불가능합니다. 선택지 4종의 한국어 라벨: 최근 발견순(기본) / 마감 임박순 / 제목순 / 우선순위순. DEADLINE_ASC는 필터와 함께 쓰도록 UI가 유도합니다 — “마감 임박순”을 고르면 마감일 필터가 비어 있는 경우deadlineFrom = 오늘을 자동으로 채우고 필터 칩에 “마감일: 오늘 이후”를 표시합니다. 사용자가 지우면 그대로 두되(강제하지 않음), 마감일 없는 공고 4,738건이 뒤에 붙는다는 안내를 1줄 노출합니다.PRIORITY_DESC와tag는watchedOnly=true를 함의합니다(C-6·D). 서버가 미지정 시true로 강제하고 명시적false는 400JOB_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가 별도 이관을 하지 않습니다.
- 낙관적 업데이트 적용 범위:
status1개 필드만.onMutate에서useInfiniteQuery캐시의 해당 아이템watchStatus를 교체하고,onError에서 스냅샷 복원,onSettled에서 무효화합니다. 제외를 고르면 사유를 선택 입력으로 묻습니다(계약상exclusionReason은 optional). 건너뛰어도 제외가 저장됩니다 — 입력을 강제하면 제외율이 떨어집니다.EXCLUDED가 아닌 상태로 바꿀 때는exclusionReason을null로 함께 보냅니다(계약: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줄입니다. dedupGroupId가 null이면 이력 조회를 아예 하지 않습니다(그룹이 없으므로 이력도 없음). 그룹 기준 조회를 직접 쓰는 경우 응답은 봉투 { 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 (유형 필터) | “이 유형의 서류가 없어요” + [전체 보기] |
| error | ErrorState + [다시 시도]. 하단 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건이면 “버전이 없어요” + [새 버전 올리기] |
| error | 404 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 |
|---|---|
| loading | CTA “올리는 중…” + 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 전용 함수apiUpload를api/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·workPreferences3배열의 추가·삭제·수정을 처리합니다. 리듀서는hooks/resume/resumeProfileDraftReducer.ts(순수 함수)로 분리해 단위 테스트합니다. 임시 저장은PUT /api/resume-profiles/{id},프로필 확정은PUT후POST .../confirmations2단계입니다 — 확정 전에 반드시 저장해 편집 내용이 확정에 반영되게 합니다.- 확정은 되돌릴 수 없는 동작이므로 확인 다이얼로그를 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줄) |
| empty | 404 RECOMMENDATION_NOT_FOUND → 섹션을 렌더하지 않습니다(2단계 배포 전·미평가 공고). 단 확정 프로필이 있으면 “아직 평가하지 않았어요 / 관심으로 분류하면 다음 평가에 포함돼요” 1줄 |
| error | 5xx → 섹션 내 축소형 ErrorState + [다시 시도]. 공고 상세의 다른 섹션은 정상 렌더되어야 합니다(부분 실패 격리) |
| success | 위 와이어프레임. totalScore === null && notEvaluableReason !== null → “평가 불가 상태” 렌더 |
보충 규칙
notEvaluableReason2종은 원인이 다르므로 문구와 액션이 다릅니다 —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) |
| error | 409 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 연동을 아직 켜지 않았다면 운영 화면에서 확인해 보세요” + [운영 열기] |
| error | ErrorState + [다시 시도] |
| 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 .../decisions의interview에는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 |
|---|---|
| loading | 4행 스켈레톤 |
| empty | 해당 없음 (4축 고정) |
| error | 400 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 (사유 필터) | “이 사유로 실패한 기록이 없어요” + [전체 보기] |
| error | ErrorState + [다시 시도] |
| success | 위 와이어프레임 |
보충 규칙
- 보안 사유 2종을 시각적으로 분리합니다 —
PATH_VALIDATION_FAILED·STORAGE_ROOT_ESCAPE는 사용자 조작 실수가 아니라 경로 이탈 시도 기록이므로, 확장자·용량 실패와 같은 무게로 보이면 안 됩니다.Chip의fill × tone으로 filled danger + 자물쇠 기호를 씁니다. 신규 색 토큰 0건 원칙은 유지합니다(기존--danger·--danger-subtle재사용). originalFileName은 새니타이즈 전 원문이 저장됩니다. 렌더 시 3가지를 처리합니다.- XSS — React가 텍스트 노드를 자동 이스케이프하므로
{fileName}으로 렌더하면 안전합니다.dangerouslySetInnerHTML을 쓰지 않습니다(이 화면에 한해 명시적 금지). - 양방향 텍스트 위장 — RTL override(
U+202E) 같은 방향 제어 문자는exe.pdf를fdp.exe처럼 보이게 만드는 실제 공격 벡터입니다. 제어 문자(U+0000~001F·U+007F·U+200E·U+200F·U+202A~202E·U+2066~2069)를 가시 기호로 치환해 렌더합니다. - 길이 — 255자까지 올 수 있으므로 2줄 말줄임 +
title속성으로 전체를 노출합니다. 치환·판정은utils/documentFailure.ts(신규 순수 유틸)가 담당합니다 — 컴포넌트에 문자 처리 로직을 두지 않습니다.
- XSS — React가 텍스트 노드를 자동 이스케이프하므로
- 사유 코드는 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 | ”등록된 소스가 없어요” + [회사 등록] |
| error | ErrorState + [다시 시도] |
| 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%뿐이라 구조적 상한이 있습니다.
matchField는TITLE(60) ·SOURCE_TAG(35) ·DESCRIPTION_BODY(20) 3종이고, 임계치는 50입니다.matchScore와matchThreshold를 함께 노출해 판정을 재현 가능하게 만듭니다 — 임계치가 나중에 바뀌어도 과거 판정을 설명할 수 있어야 하므로, 판정 당시 임계치를 화면이 그대로 보여 줍니다.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곳 이상) | 소유 티켓 |
|---|---|---|---|
TextArea | label, value, onChange, maxLength, rows, error?, placeholder? | S-17 메모 · S-19 담당자 메모 · S-27 결정 메모 | FE-20 |
FileField | label, accept, maxSizeBytes, value: File | null, onChange, error? | S-22 서류 업로드 (2단계 단독이나 검증 로직 재사용을 위해 프리미티브화) | FE-30 |
TagInput | label, values: string[], onChange, maxCount, maxLength, error? | S-17 개인 태그 (단일 사용처) → 프리미티브화하지 않고 components/watchlist/에 둡니다 | — |
TagInput은 사용처가 1곳이므로 공용화하지 않습니다 — “두 번째 사용처가 확정된 것만 공용화” 규칙을 지킵니다.
신규 공용 도메인 컴포넌트 (components/domain/ · 도메인별 디렉토리)
| 컴포넌트 | 위치 | 사용처 | 소유 티켓 |
|---|---|---|---|
WatchStatusChip | components/watchlist/ | S-15 카드 · S-04 관리 섹션 | FE-20 |
WatchPriorityMark | components/watchlist/ | S-15 카드 · S-17 시트 | FE-20 |
WatchStateSheet | components/watchlist/ | S-15 · S-04 | FE-25 |
WatchStateDetailSheet | components/watchlist/ | S-04 | FE-25 |
RecommendationGradeChip | components/recommendation/ | S-15 카드 · S-04 섹션 | FE-36 |
RecommendationScoreCard | components/recommendation/ | S-04 섹션 | FE-36 |
RecommendationAxisList | components/recommendation/ | S-04 섹션 | FE-36 |
RecommendationCapNotice | components/recommendation/ | S-04 섹션 | FE-36 |
DocumentTypeChip | components/document/ | S-20 · S-21 · S-25 | FE-30 |
DocumentUploadSheet | components/document/ | S-20 · S-21 | FE-33 |
ConfidenceChip | components/contact/ | S-26 · S-27 | FE-40 |
ContactSheet | components/application/ | S-07 (추가·편집 공용) | FE-27 |
SubmittedDocumentPickerSheet | components/application/ | S-07 | FE-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.ts | resolveMatchOutcome — matched·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'] | Infinity | 401 발생 시에만 무효화하면 됩니다. 주기 재조회는 무의미한 트래픽입니다 |
신규 ['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-43의 apiRequest는 에러 바디에서 code·message만 정규화하고 나머지 필드를 버립니다 — 기존 existingCompanyId가 그래서 registration.ts·create.ts에서 직접 fetch로 우회한 것입니다.
이번에는 우회하지 않고 ApiError 클래스 자체를 확장합니다.
ApiError에readonly 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-28)·ApplicationAlreadyExistsError(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_EXCEEDED | actualCount·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·MutationCache의onError콜백(api/queryClient.ts)입니다 — 훅마다 401을 처리하지 않습니다.
API 연동
계약은 지원 관리 확장 TDD “Detail Design > API 계약”이 SSOT입니다. FE는 필드·타입을 1:1로 소비하고 임의 변형하지 않습니다.
1단계
| 화면 | 엔드포인트 | 훅 | 소비 필드 | 오류 처리 |
|---|---|---|---|---|
| S-14 | POST /api/auth/login | useLogin | username, expiresAt | INVALID_CREDENTIAL·LOGIN_LOCKED → 인라인(서버 메시지 그대로) |
| S-00 | GET /api/auth/session | useSession | authRequired, authenticated, username, expiresAt | authRequired:false → 통과(로그아웃 버튼 숨김) / 401 → 게이트가 /login. 전역 401 핸들러 제외 경로 |
| S-00 | POST /api/auth/logout | useLogout | — | 실패해도 queryClient.clear() + /login |
| S-15 | GET /api/job-postings | useCrossCompanyJobPostings (useInfiniteQuery) | PageResponse<CrossCompanyJobPostingItem> 전량. 쿼리에 companyId[](C-3)·tag[](D) 포함. PRIORITY_DESC·tag는 watchedOnly=true 동봉(C-6·D) | WATCH_STATE_FILTER_LIMIT_EXCEEDED → actualCount·limit으로 문구 조립 + 정렬 자동 복귀 / JOB_POSTING_SORT_NOT_APPLICABLE → 일반 토스트 + 로깅(클라이언트 버그) / 그 외 400 → “필터 값이 올바르지 않아요” + 초기화 / 5xx → ErrorState |
| S-16·17 | PUT /api/dedup-groups/{dedupGroupId}/watch-state | useSaveWatchState | WatchStateResponse | 404 DEDUP_GROUP_NOT_FOUND → 토스트 + 목록 무효화 / 400 WATCH_STATE_INVALID_FIELD → 인라인 |
| S-16 | PUT /api/job-postings/{jobPostingId}/watch-state (C-1 신설) | 같은 useSaveWatchState가 dedupGroupId 유무로 분기 | WatchStateResponse (dedupGroupId 포함) | 409 POSTING_DEDUP_KEY_ABSENT → 롤백 + “아직 분류할 수 없어요” / 404 JOB_POSTING_NOT_FOUND → 롤백 + 토스트 |
| S-04·17 | GET /api/companies/{companyId}/job-postings/{jobPostingId} (기존 확장) | 기존 useJobPosting | dedupGroupId·watchState 추가 소비(C-1), matchFieldEvidences·matchScore·matchThreshold(C-2, 3단계) | 기존 처리 승계 |
| S-17 | GET /api/dedup-groups/{id}/watch-state | useWatchState (상세 응답으로 충당되지 않을 때만) | 봉투 { dedupGroupId, watchState }(B — 단독 DTO 아님) | 200+watchState:null = 미분류(정상) / 409 FEATURE_DISABLED = 준비 중 / 404 DEDUP_GROUP_NOT_FOUND = 오류 |
| S-17 | GET /api/dedup-groups/{id}/watch-state/histories | useWatchStateHistories | histories[] (없으면 빈 배열 — 404 없음) | 실패 → 이력 영역만 축소 에러 |
| S-17 | DELETE /api/dedup-groups/{id}/watch-state | useRemoveWatchState | — | 없어도 204(멱등) — 404 분기 없음 |
| S-18 | GET /api/dashboard | useDashboard | statusBoard·upcomingInterviews·staleApplications | ErrorState |
| S-19 | GET /api/applications/{id}/contacts | useApplicationContacts | contacts[] | 섹션 내 축소 에러 |
| S-19 | POST·PUT·DELETE .../contacts[/{contactId}] | useSaveApplicationContact·useDeleteApplicationContact | ApplicationContactResponse | 400 → 필드 인라인 / 404 → 토스트 |
| S-11 | GET /api/operations/webhook-receipts | useWebhookReceipts (useInfiniteQuery) | PageResponse<WebhookReceiptResponse> | ErrorState |
| S-11 | GET /api/operations/tunnel-status | useTunnelStatus | reachable·checkedAt·publicHostname | 실패 → “확인할 수 없어요”(중립) |
2단계
| 화면 | 엔드포인트 | 훅 | 소비 필드 | 오류 처리 |
|---|---|---|---|---|
| S-20 | GET /api/documents/series | useDocumentSeries | series[] | ErrorState |
| S-21 | GET /api/documents/series/{seriesId} | useDocumentSeriesDetail | DocumentSeriesDetailResponse | 404 → “서류를 찾을 수 없어요” |
| S-21 | GET /api/documents/versions/{id}/content | — (<a download>) | 바이너리 | 브라우저 기본 |
| S-22 | POST /api/documents (multipart) | useUploadDocument | DocumentVersionResponse | 확장자·용량·경로 3종 → 인라인 / 404 계열 → 토스트 |
| S-23 | POST /api/resume-profiles/drafts | useCreateResumeProfileDraft | ResumeProfileResponse (extractionFailed 포함) | 404 → 토스트. 추출 실패는 에러가 아님(201) |
| S-23 | PUT /api/resume-profiles/{id} | useSaveResumeProfile | ResumeProfileResponse | 409 → “이미 확정된 프로필이에요” |
| S-23 | POST /api/resume-profiles/{id}/confirmations | useConfirmResumeProfile | criteriaRevision·reevaluatedCount·elapsedMillis(E) | 실패 → 토스트 + 폼 유지 |
| S-23 | GET /api/resume-profiles/current | useCurrentResumeProfile | ResumeProfileResponse | 404 → empty(에러 아님) |
| S-24 | GET /api/job-postings/{id}/recommendation | useRecommendation | RecommendationResponse 전량 | 404 → 섹션 미렌더 |
| S-23·28 | POST /api/recommendations/re-evaluations | useReevaluateRecommendations | evaluatedCount·skippedCount·notEvaluableCount·failures[]·elapsedMillis(E, 스키마 확정) | 409 RESUME_PROFILE_NOT_CONFIRMED → “프로필을 먼저 확정해 주세요” + [프로필 만들기] / 400 RECOMMENDATION_TARGET_LIMIT_EXCEEDED → actualCount·limit으로 “관심 공고 {actualCount}건이 상한 {limit}건을 넘었어요 / 제외로 정리한 뒤 다시 시도해 주세요” + [보관함 열기] |
| S-25 | GET·POST·DELETE /api/applications/{id}/documents[...] | useSubmittedDocuments·useLinkSubmittedDocument·useUnlinkSubmittedDocument | SubmittedDocumentResponse | 409 DOCUMENT_ALREADY_SUBMITTED → 시트 인라인 |
| S-11 | GET /api/operations/document-upload-failures | useDocumentUploadFailures (useInfiniteQuery) | PageResponse<DocumentUploadFailureResponse> — occurredAt·documentType·seriesId·seriesTitle·originalFileName·reasonCode·detailMessage | ErrorState. 빈 결과는 긍정 상태로 중립 표시 |
3단계
| 화면 | 엔드포인트 | 훅 | 소비 필드 | 오류 처리 |
|---|---|---|---|---|
| S-26 | GET /api/contact-events | useContactEvents (useInfiniteQuery) | PageResponse<ContactEventListItem> | ErrorState |
| S-27 | GET /api/contact-events/{id} | useContactEvent | ContactEventDetailResponse 전량 | 404 → “연락을 찾을 수 없어요” |
| S-27 | POST /api/contact-events/{id}/decisions | useDecideContactEvent | ContactEventDetailResponse | 409 3종 각각 다른 처리(위 S-27 상태 표) |
| S-28 | GET·PUT /api/recommendations/weights | useRecommendationWeights·useSaveRecommendationWeights | criteriaRevision·weights[] | 400 → 합계 행 인라인 |
| S-24 | POST·DELETE /api/job-postings/{id}/recommendation/cap-release | useReleaseRecommendationCap·useRestoreRecommendationCap | RecommendationResponse | 낙관적 롤백 + 토스트 |
| S-11 | GET /api/operations/source-registry | useSourceRegistry | sources[] | ErrorState |
| S-11 | POST /api/matching/field-scope-backfills | useFieldScopeBackfill | processedCount·newlyMatchedCount·notificationSuppressedCount·unmatchedNowCount | 토스트 + 결과 카드 |
| S-04 | 공고 상세(기존 확장) | 기존 useJobPosting | matchFieldEvidences[](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-events | Gmail 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> | 신규 공통 봉투. 기존 엔드포인트에는 적용하지 않습니다 |
AuthSessionResponse | username·expiresAt |
WatchStatus · WatchPriority | 'INTERESTED'|'PLANNED'|'EXCLUDED' / 'HIGH'|'NORMAL'|'LOW' |
WatchStateResponse · WatchStateHistoryResponse · SaveWatchStateRequest | — |
CrossCompanyJobPostingItem | matchScore·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 · SaveResumeProfileRequest | extractionFailed: boolean |
RecommendationResponse · RecommendationAxis · RecommendationGrade · RecommendationAxisKey | judgeable·normalizedWeight·notEvaluableReason |
ContactEventListItem · ContactEventDetailResponse · ContactDecisionRequest | ContactConfidenceLevel = 'HIGH'|'MEDIUM'|'LOW' — 기존 ConfidenceLevel(근무형태 4종)과 다른 타입이므로 이름을 분리합니다 |
SourceRegistryResponse · JobSourceRegistryStatus | 6종 |
FieldScopeBackfillResponse | — |
⚠ 타입 이름 충돌 주의: 기존
web/src/types/api.ts의ConfidenceLevel은 근무형태 확신도 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,19 | to: '/'(회사 탭) | 탭 5종 재정의 (지원 랜딩 /) |
pages/application/ApplicationListPage.tsx:14 | COMPANY_LIST_PATH = '/' | '/companies' |
pages/posting/ManualJobPostingPage.tsx:10 | HOME_PATH = '/' | '/companies' |
pages/posting/JobPostingDetailPage.tsx:12 | HOME_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(회사 목록 이전) · /watchlist | FE-22 |
| 2 | /documents · /documents/series/:seriesId · /resume-profile | FE-31 |
| 3 | /contact-events · /contact-events/:contactEventId · /settings/recommendation | FE-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 | 업로드 드롭존 파선 테두리 |
--overlay | rgba(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 / #8fbbfb | INTERESTED 칩, 현재 프로필 원본 칩, 매칭 근거 하이라이트 |
--positive / --positive-subtle | #00a661 / #e5f7ef | #2ecc80 / #10291e | PLANNED, 강력 추천·추천, 신뢰도 높음, 소스 ACTIVE, 게이지 채움 |
--warning / --warning-subtle | #c2660a / #fff3e5 | #ffa23a / #2e2113 | 59점 상한, 신뢰도 낮음, 소스 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-button | 16px / 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-4 | auth.required OFF 구간 세션 계약 | 수용 — 응답에 authRequired·authenticated 추가, OFF는 200/ON+무세션은 401. /api/auth/session은 인증 제외 경로가 아님 | S-14 보충(게이트 3분기) · 401 처리 규약 · Release Scenario |
추가로 dba·BE가 확정한 제약 4건을 반영했습니다.
| 항목 | 확정 | 이 문서 반영 위치 |
|---|---|---|
C-6 sort=PRIORITY_DESC | watchedOnly=true 강제(명시적 false는 400), 그룹 id 1,000건 상한 초과 시 400 | S-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-7 | 400이 전부 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 연동 |
| B | GET .../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 연동 |
| E | elapsedMillis | 재평가 + 프로필 확정 양쪽 응답 스키마에 확정(밀리초). 표시는 선택 사항 | 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, resumeProfileDraftReducer | Vitest | 해피 · 경계값(0·상한·null) · 잘못된 입력 |
| API 클라이언트 | apiRequest 쿠키 전송, apiUpload multipart, 401 → UNAUTHENTICATED 정규화, 직접 fetch 2곳 | Vitest + MSW | 200 · 401 · 4xx 부가 필드 보존 · 네트워크 실패 |
| 훅 | 전 신규 훅 | renderHook + MSW | 성공 · 실패 · 무효화 발생 · 낙관적 롤백(2건) |
| 컴포넌트 | 신규 프리미티브·도메인 컴포넌트 | Testing Library | 렌더 · 인터랙션 · disabled 조건 · 접근성 속성 |
| 화면 (통합) | S-14~S-28 각각 | Testing Library + MSW + MemoryRouter | loading/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 |
| 6 | credentials가 same-origin인지 | apiRequest·registerCompany·createApplication 3곳 전부 쿠키를 실어 보낸다 | FE-21 |
| 7 | 보관함 필터 0건 | ”조건에 맞는 공고가 없어요”가 보이고, “아직 보관함에 공고가 없어요”는 보이지 않는다 | FE-24 |
| 8 | 보관함 분류 전 0건 | ”아직 보관함에 공고가 없어요”가 보인다 | FE-24 |
| 9 | dedupGroupId === null 공고 | 분류가 가능하고, 저장 요청이 PUT /api/job-postings/{id}/watch-state 경로로 나간다 | FE-24 · FE-25 |
| 9-a | dedupGroupId가 있는 공고 | 저장 요청이 PUT /api/dedup-groups/{id}/watch-state 경로로 나간다 | FE-25 |
| 9-b | 공고 기준 저장 응답의 dedupGroupId | 캐시에 반영되어 다음 저장은 그룹 기준 경로로 나간다 | FE-25 |
| 9-c | 409 POSTING_DEDUP_KEY_ABSENT | 낙관적 업데이트가 롤백되고 “아직 분류할 수 없어요” 토스트가 뜬다 | FE-25 |
| 10 | matchScore·recommendation이 null | 점수·등급 표시가 렌더되지 않는다(0으로 대체하지 않는다) | FE-24 |
| 11 | 관리 상태 저장 실패 | 카드 칩이 이전 값으로 롤백되고 토스트가 뜬다 | FE-25 |
| 12 | EXCLUDED가 아닌 상태로 전환 | 요청에 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 | 관리 상태가 없는 그룹에 DELETE | 204를 받아 정상 처리된다(멱등) | 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 |
| 20 | 20MB 초과 파일 선택 | 서버 요청 없이 인라인 에러가 뜨고 CTA가 비활성된다 | FE-33 |
| 21 | .hwp 업로드 | 업로드는 성공하고, 프로필 초안 생성 시 extractionFailed 안내가 보인다 | FE-33 · FE-35 |
| 22 | DOCUMENT_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 |
| 28 | capReleased: true | ”상한 해제됨”과 원래 상한 사유가 함께 보인다 | FE-36 |
| 29 | notEvaluableReason: 'JD_UNAVAILABLE' vs 'PROFILE_NOT_CONFIRMED' | 문구가 다르고, 후자에만 [프로필 만들기] CTA가 있다 | FE-36 |
| 30 | 추천도 조회 404 | 공고 상세의 다른 섹션은 정상 렌더된다 | FE-38 |
| 31 | 이미 연결된 버전 | 선택지가 비활성 + “이미 연결됨”으로 보인다 | FE-37 |
| 32 | DOCUMENT_ALREADY_SUBMITTED(409) | 시트 인라인 에러가 보이고 시트가 닫히지 않는다 | FE-37 |
| 33 | 재평가 응답에 failures[]가 있음 | 실패 건수·사유가 목록으로 보인다 | FE-39 |
| 34 | RESUME_PROFILE_NOT_CONFIRMED(409) | “프로필을 먼저 확정해 주세요” + CTA가 보인다 | FE-39 |
| 35 | 후보 0건 | 반영하기가 비활성이고 무시만 활성이다 | FE-43 |
| 36 | autoSelectedJobApplicationId === null | 라디오가 전부 미선택으로 시작하고 수동 선택 안내가 보인다 | FE-43 |
| 37 | 모든 후보가 terminal: true | 반영 불가 안내가 보이고 무시만 활성이다 | FE-43 |
| 38 | 후보를 바꿈 | 상태 선택지가 새 후보의 allowedNextStatuses로 갱신되고 이전 선택이 초기화된다 | FE-43 |
| 39 | parse === null | 파싱 실패 안내가 보이되 후보 선택·반영은 여전히 가능하다 | FE-43 |
| 40 | CONTACT_EVENT_ALREADY_DECIDED(409) | 결과 요약으로 전환되고 CTA가 사라진다 | FE-43 |
| 41 | TRANSITION_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-c | WATCH_STATE_FILTER_LIMIT_EXCEEDED 수신 | 정렬이 DISCOVERED_DESC로 자동 복귀하고 actualCount·limit 값이 들어간 문구(“1,842건 → 1,000건 이하로”)가 보인다 | FE-24 |
| 43-c1 | JOB_POSTING_SORT_NOT_APPLICABLE 수신 | 일반 오류 토스트만 뜨고 “필터를 좁혀 주세요” 안내가 보이지 않는다 | FE-24 |
| 43-c2 | 태그 필터 2개 선택 | 요청에 tag가 repeatable로 2번 실리고 watchedOnly=true가 동봉된다 | FE-24 |
| 43-c3 | 태그·우선순위순 둘 다 해제 | watchedOnly 잠금이 풀린다 | FE-24 |
| 43-c4 | ApiError의 actualCount·limit | *_LIMIT_EXCEEDED가 아닌 코드에서는 둘 다 null이다 | FE-21 |
| 43-d | 회사 필터 2개 선택 | 요청에 companyId가 repeatable로 2번 실린다 | FE-24 |
| 43-e | RECOMMENDATION_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 |
| 44 | registryStatus === null(백필 전) | “상태 미확인” 중립 칩이 보인다 | FE-44 |
| 45 | 소스 정렬 | 접근 제한 → 일시 실패 → … → 비활성 순으로 정렬된다 | FE-44 |
| 46 | dryRun=true 백필 | 결과 요약만 보이고 “적용됐어요” 문구가 보이지 않는다 | FE-44 |
| 47 | 검토 대기 0건 | 대시보드 배너가 렌더되지 않는다 | FE-48 |
| 48 | 전 신규 화면 다크 모드 | .dark에서 렌더되고 색 하드코딩이 0건이다 | 전 화면 티켓 |
| 49 | 매칭 근거 렌더 | matchScore와 matchThreshold가 함께 보이고, DESCRIPTION_BODY 근거에만 출현 횟수가 병기된다 | FE-47 |
| 50 | matchScore === null(미배포) | 근거 섹션이 렌더되지 않고 기존 matched 표시로 폴백한다 | FE-47 |
| 50-a | matchScore === 0(근거 없음) | “일치한 항목이 없어요”가 보이고 미배포 폴백과 다른 표시가 된다 | FE-47 |
| 50-b | matched: false + excludedByKeyword != null | ”제외 키워드 ‘{keyword}‘에 걸렸어요”가 보이고 점수 줄·근거 배열이 렌더되지 않는다 | FE-47 |
| 50-c | matched: false + excludedByKeyword === null | 점수·임계치 + 근거 배열 + 부족 점수(“15점이 더 필요해요”)가 보인다 | FE-47 |
| 50-d | matchScore: 60 + matched: false (제외 키워드가 점수를 이긴 경우) | “매칭 안 됨”으로 표시된다 — 점수가 임계치를 넘어도 matched를 따른다 | FE-47 |
| 50-e | resolveMatchOutcome 유틸 | 5가지 상태(미배포·근거 없음·매칭·미매칭 점수 미달·미매칭 제외 키워드)를 정확히 판정한다 | FE-40 |
| 51 | 업로드 실패 0건 | ”실패한 업로드가 없어요”가 중립 톤으로 보이고 에러톤·CTA가 없다 | FE-50 |
| 51-a | 보안 사유 2종 | PATH_VALIDATION_FAILED·STORAGE_ROOT_ESCAPE가 danger + 자물쇠로, 확장자·용량 실패와 다른 톤으로 보인다 | FE-50 |
| 51-b | 파일명에 RTL override(U+202E) 포함 | 방향 제어 문자가 가시 기호로 치환되어 파일명 위장이 발생하지 않는다 | FE-30 · FE-50 |
| 51-c | 알 수 없는 reasonCode | 원문 그대로 + 중립 톤으로 폴백하고 화면이 깨지지 않는다 | FE-50 |
| 51-d | documentType·seriesTitle이 null | 해당 줄을 생략하고 나머지는 정상 렌더된다 | 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 OFF | dedupGroupId가 전부 null이지만 분류는 동작합니다 — PUT /api/job-postings/{id}/watch-state가 쓰기 시점에 단독 그룹을 만듭니다(C-1). 백필 이전 잔존 공고만 409 POSTING_DEDUP_KEY_ABSENT로 거부되며, 이는 1-4 백필 완료 후 사라집니다 |
recommendation.evaluation OFF | recommendation: null / 추천도 404 → 등급 칩·섹션이 렌더되지 않습니다 |
matching.field-weighted-scope OFF · 재평가 전 | matchScore: null + matchFieldEvidences: [] → 기존 matched: boolean 표시로 폴백, 근거 박스 미렌더 |
contact.inbound-webhook OFF | 연락 목록이 0건 → “검토할 연락이 없어요”(긍정 empty) |
source.registry-status OFF | registryStatus: 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 의존 | 너비 |
|---|---|---|---|---|
| 1 | FE-20 1단계 API 계약 타입·queryKey·MSW 목 | M | BE-45 (계약 확정) | 3 |
| 1 | FE-21 쿠키 인증 전환 · 401 정규화 · apiUpload · ApiError 부가 필드 확장 | M | BE-45, BE-46 | |
| 1 | FE-22 라우트 골격 · 5탭 내비 · 랜딩 전환(/=대시보드, /companies=회사 목록) · AuthGate 스텁 | M | — | |
| 2 | FE-23 로그인 화면 · 세션 훅 · 인증 게이트 · 로그아웃 | M | BE-46 | 6 |
| 2 | FE-24 관심 공고 보관함 목록 · 필터(회사 포함) · 정렬(제약 UI 포함) | L | BE-52 | |
| 2 | FE-25 관리 상태 분류 시트 · 상세 편집 시트 (저장 경로 2종 분기) | M | BE-50, BE-52 | |
| 2 | FE-26 지원 대시보드 3요소 | L | BE-51 | |
| 2 | FE-27 담당자 관리 섹션 · 시트 | M | BE-49 | |
| 2 | FE-28 운영 확장 — 웹훅 수신 · 터널 상태 | M | BE-54 | |
| 3 | FE-29 1단계 배선 · 통합 (공고 상세 관리 섹션 — C-1 watchState 직접 소비) | M | BE-48, BE-52, BE-55 | 1 |
단계 2 — 서류·추천도 (FR-8185, 9596), 11건
| wave | 티켓 | 사이즈 | BE 의존 | 너비 |
|---|---|---|---|---|
| 1 | FE-30 2단계 계약 타입·queryKey·MSW 목·유틸 3종 | M | BE-56 | 2 |
| 1 | FE-31 2단계 라우트 골격 · 지원 탭 하위 진입점 | S | — | |
| 2 | FE-32 서류 보관함 (계열 목록) | M | BE-61 | 6 |
| 2 | FE-33 서류 업로드 시트 | M | BE-61 | |
| 2 | FE-34 서류 계열 상세 (버전 목록·다운로드) | M | BE-61 | |
| 2 | FE-35 이력서 프로필 검토·확정 | L | BE-62 | |
| 2 | FE-36 추천도 표시 컴포넌트 (점수·축·상한) | M | BE-63, BE-65 | |
| 2 | FE-37 제출 서류 연결 섹션 · 선택 시트 | M | BE-64 | |
| 2 | FE-50 운영 확장 — 서류 업로드 실패 이력 (신규) | S | BE-81 | |
| 3 | FE-38 2단계 배선 (보관함 등급 칩 · 공고 상세 추천도 섹션 · 업로드 CTA) | M | BE-66 | 2 |
| 3 | FE-39 일괄 재평가 진입점 · 결과 요약 · 실패 목록 | S | BE-65, BE-67 |
단계 3 — 연동 (FR-86~94, 97), 10건
| wave | 티켓 | 사이즈 | BE 의존 | 너비 |
|---|---|---|---|---|
| 1 | FE-40 3단계 계약 타입·queryKey·MSW 목·유틸 2종 | M | BE-68 | 2 |
| 1 | FE-41 3단계 라우트 골격 · 매칭/운영 하위 진입점 | S | — | |
| 2 | FE-42 연락 검토 목록 | M | BE-77 | 6 |
| 2 | FE-43 연락 검토 상세 · 반영 결정 | L | BE-77 | |
| 2 | FE-44 소스 상태 레지스트리 · 매칭 범위 재평가 패널 | M | BE-78, BE-74 | |
| 2 | FE-45 추천도 가중치 설정 | S | BE-72 | |
| 2 | FE-46 59점 상한 해제 컨트롤 | S | BE-72 | |
| 2 | FE-47 매칭 근거 표시 (미매칭 포함 — 게이트 ② ⓑ) | L | BE-80, BE-69 | |
| 3 | FE-48 대시보드 검토 대기 배너 · 딥링크 배선 | S | BE-76 | 2 |
| 3 | FE-49 3단계 회귀 · 다크 모드 · 접근성 전수 점검 | M | BE-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-20 | types/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-21 | api/client.ts(ApiError에 actualCount·limit 추가 포함), api/company/registration.ts, api/application/create.ts, api/queryClient.ts |
| FE-22 | routes.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-23 | api/auth/*.ts(신규), hooks/auth/*.ts(신규), components/auth/AuthGate.tsx(스텁 대체), pages/auth/LoginPage.tsx(스텁 대체), components/layout/LogoutButton.tsx(신규) |
| FE-24 | api/posting/crossCompanyList.ts(신규), hooks/watchlist/useCrossCompanyJobPostings.ts(신규), pages/watchlist/**(스텁 대체 + 신규 5파일) |
| FE-25 | api/watchlist/watchState.ts(신규), hooks/watchlist/useWatchState*.ts(신규), components/watchlist/{WatchStateSheet,WatchStateDetailSheet,TagInput}.tsx(신규 3파일 — 표시 칩 2종은 FE-20 소유이므로 제외) |
| FE-26 | api/dashboard.ts(신규), hooks/dashboard/useDashboard.ts(신규), pages/dashboard/**(스텁 대체 + 신규 5파일), utils/dashboard.ts(신규) |
| FE-27 | api/application/contacts.ts(신규), hooks/application/use*Contact*.ts(신규), components/application/ContactSheet.tsx(신규), pages/application/ContactSection.tsx(신규), pages/application/ApplicationDetailPage.tsx(수정) |
| FE-28 | api/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/vscomponents/watchlist/). FE-24가 쓰는WatchStatusChip·WatchPriorityMark는 wave 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-32 | api/document/seriesList.ts, hooks/document/useDocumentSeries.ts, pages/document/DocumentSeriesListPage.tsx(스텁 대체), pages/document/DocumentSeriesCard.tsx |
| FE-33 | api/document/upload.ts, hooks/document/useUploadDocument.ts, components/document/DocumentUploadSheet.tsx |
| FE-34 | api/document/seriesDetail.ts, hooks/document/useDocumentSeriesDetail.ts, pages/document/DocumentSeriesDetailPage.tsx(스텁 대체), pages/document/DocumentVersionCard.tsx |
| FE-35 | api/resume/*.ts, hooks/resume/*.ts, pages/resume/**(스텁 대체 + 신규 4파일) |
| FE-36 | api/recommendation/detail.ts, hooks/recommendation/useRecommendation.ts, components/recommendation/**(신규 4파일) |
| FE-37 | api/application/submittedDocuments.ts, hooks/application/useSubmittedDocument*.ts, components/application/SubmittedDocumentPickerSheet.tsx, pages/application/SubmittedDocumentSection.tsx, pages/application/ApplicationDetailPage.tsx(수정) |
| FE-50 | api/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-38 | pages/watchlist/WatchlistCard.tsx, pages/posting/JobPostingDetailPage.tsx, pages/posting/RecommendationSection.tsx(신규), pages/document/DocumentSeriesListPage.tsx(CTA 배선), pages/document/DocumentSeriesDetailPage.tsx(CTA 배선) |
| FE-39 | hooks/recommendation/useReevaluateRecommendations.ts, pages/resume/ReevaluationProgressCard.tsx, pages/resume/ResumeProfilePage.tsx(결과 영역) |
교집합 ∅ — FE-38은 보관함·공고 상세·서류, FE-39는 이력서 프로필 화면만 건드립니다.
단계 3 wave 2 (6티켓)
| 티켓 | 수정 예상 파일 |
|---|---|
| FE-42 | api/contact/list.ts, hooks/contact/useContactEvents.ts, pages/contact/ContactEventListPage.tsx(스텁 대체), pages/contact/ContactEventCard.tsx |
| FE-43 | api/contact/{detail,decision}.ts, hooks/contact/use{ContactEvent,DecideContactEvent}.ts, pages/contact/ContactEventDetailPage.tsx(스텁 대체) + 신규 5파일 |
| FE-44 | api/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-45 | api/recommendation/weights.ts, hooks/recommendation/useRecommendationWeights.ts, pages/recommendation/**(스텁 대체), pages/matching/MatchingSettingsPage.tsx(링크 추가) |
| FE-46 | api/recommendation/capRelease.ts, hooks/recommendation/useRecommendationCap.ts, components/recommendation/CapReleaseControl.tsx(신규) |
| FE-47 | api/posting/detail.ts(수정), pages/posting/MatchEvidenceBox.tsx(신규) |
- FE-42와 FE-43이 둘 다
pages/contact/이지만 파일이 다릅니다(목록 vs 상세). 둘 다 쓰는ConfidenceChip은 wave 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-48 | pages/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.ts | FE-20(TextArea) · FE-30(FileField) — 다른 단계 |
components/layout/NavTabs.tsx · AppShell.tsx | 단계 1 wave 1 단독 (FE-22). 2·3단계는 탭을 늘리지 않으므로 수정 없음 |
pages/application/ApplicationDetailPage.tsx | FE-27(단계 1) · FE-37(단계 2) — 다른 단계 |
pages/operations/OperationsPage.tsx | FE-28(단계 1 w2 — 웹훅 세그먼트) · FE-50(단계 2 w2 — 서류 실패 세그먼트) · FE-44(단계 3 w2 — 소스 상태 세그먼트 + 라벨 5종 축약) — 전부 다른 단계라 wave 교집합 없음. 각 단계에서 이 파일을 만지는 티켓은 정확히 1개입니다 |
pages/posting/JobPostingDetailPage.tsx | FE-22(단계 1 wave 1 — 경로 상수 1줄) · FE-29(단계 1 wave 3) · FE-38(단계 2) · FE-48(단계 3) — 전부 다른 wave |
pages/application/ApplicationListPage.tsx · pages/posting/ManualJobPostingPage.tsx | FE-22 단독 (경로 상수 1줄). 다른 티켓은 건드리지 않습니다 |
web/nginx.conf | BE-56 단독 소유 (C-5). FE 티켓은 이 파일을 수정하지 않습니다 — 현재 FE 티켓 30건 중 이 파일을 만지는 티켓은 0건입니다 |
pages/watchlist/WatchlistCard.tsx | FE-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-17 | FE-24, FE-25 |
② PLANNED 전환 | S-16 | FE-25 | |
| ③ 지원 레코드 생성 → 파생 표시 | S-04 기존 지원 시트 → S-15 AppliedBadge | FE-24, FE-29 | |
| ④ 변경 이력 | S-17 이력 영역 | FE-25 | |
| 12 — 이력서 → 프로필 확정 → 추천도 | ① PDF 업로드 | S-22 | FE-33 |
| ② 초안 생성 | S-23 | FE-35 | |
| ③ 검토·수정·확정 | S-23 | FE-35 | |
| ④ 재평가 + 추천도 표시 | S-23 결과 → S-24 · S-15 등급 칩 | FE-36, FE-38, FE-39 | |
| 13 — 텍스트 추출 실패 | ① 스캔 PDF 업로드 | S-22 | FE-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 === null | FE-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 웹훅 HMAC | S-11 웹훅 수신 이력 | FE-28 |
| FR-73 관리 상태 3종 | S-16 | FE-25 |
| FR-74 그룹 귀속 | S-15 dedupGroupId 처리 | FE-24, FE-29 |
| FR-75 전이 규칙 | S-16 · S-15 hasApplication | FE-25, FE-29 |
| FR-76 변경 이력 | S-17 이력 영역 | FE-25 |
| FR-77 우선순위·목표일·태그·메모·제외 사유 | S-16 · S-17 | FE-25 |
| FR-78 교차 목록·필터·정렬 | S-15 | FE-24 |
| FR-79 대시보드 3요소 | S-18 | FE-26 |
| FR-80 담당자 | S-19 | FE-27 |
| FR-81 문서 유형·계열·버전 | S-20 · S-21 | FE-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-25 | FE-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-27 | FE-42, FE-43 |
| FR-90 검토 화면·확인형 반영 | S-27 | FE-43 |
| FR-91 후보 점수 4축 | S-27 후보 카드 | FE-43 |
| FR-92 Gmail 계약 | (FE 미소비) | — |
| FR-93 변경 공고 알림 | (BE 전용 — 화면 요구 없음) | — |
| FR-94 검토 요청 알림 | S-18 배너 · 딥링크 | FE-48 |
| FR-95 프로필 초안·확정 | S-23 | FE-35 |
| FR-96 추천도 4축·정규화·상한 | S-24 | FE-36, FE-38 |
| FR-97 상한 해제·가중치 수정 | S-24 · S-28 | FE-45, FE-46 |
누락 0건. FR-92·93은 FE 화면 요구가 없는 항목이며, 시나리오 17·18은 PRD가 P2 선택 기능으로 명시한 범위 밖입니다.
Open Questions
해소된 항목 (2026-08-08 패치)
| # | 항목 | 해소 결과 |
|---|---|---|
| 첫 진입 화면 | 사용자 확정 — / = 지원 대시보드(S-18), 회사 목록은 /companies. 초기 빈 화면 문제는 대시보드 empty를 온보딩 진입점으로 설계해 해소 | |
| 보관함 회사 필터 | C-3 수용 — companyId: number[] repeatable 추가. 태그 필터는 여전히 없습니다 → 아래 신규 9로 이월 | |
| 재평가 프록시 타임아웃 | C-5 확정 — nginx proxy_read_timeout 600s 상향(BE-56 소유). 비동기 잡 미채택. 대상 1,000건 상한 + elapsedMillis 실측 | |
watchlist.management OFF 구간 문구 | C 확정 — 플래그 OFF는 409 FEATURE_DISABLED(섹션 숨김), 미분류는 200 + watchState:null(선택 UI 활성). WATCH_STATE_NOT_FOUND 폐기로 404 분기 제거 | |
| 400 에러 코드 부재 | C-7 수용 — 코드 3종 신설 + actualCount·limit 부가 필드. FE-24·FE-39 블로커 해제 | |
elapsedMillis 스키마 미반영 | E 확정 — 재평가·프로필 확정 양쪽 응답 스키마에 elapsedMillis: number(밀리초) 반영 | |
| 태그 필터 | D 수용 — tag repeatable OR. watchedOnly 함의, 1,000건 상한 공유 | |
| 미매칭 공고의 매칭 근거 노출 범위 | 게이트 ② ⓑ 채택 — 미매칭도 근거 표시. 확대 비용은 +32.6%(3년 51,600행/22MB)로 실측됨. matched가 판정 SSOT, excludedByKeyword로 미매칭 사유 2분기 | |
| 서류 업로드 실패 조회 수단 | 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-70 |
| 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-08 | 2차 계약 확정 패치 — A 400 에러 3분류 수용(WATCH_STATE_FILTER_LIMIT_EXCEEDED·RECOMMENDATION_TARGET_LIMIT_EXCEEDED·JOB_POSTING_SORT_NOT_APPLICABLE) + ApiError에 actualCount·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_DESC의 watchedOnly 강제·1,000건 상한 UI · C-5 nginx 600s(BE-56 소유, FE 미수정) · B-2 기본 정렬 DISCOVERED_DESC 고정·DEADLINE_ASC 필터 유도 · B-8 첨부 메타 20건·다운로드 없음) / 신규 역제안 C-7(400 에러 코드 3종 부재) |