공고알림앱 FE(React 웹) 기술 설계 문서
Background
근거 PRD: /Users/biuea/Desktop/dpdpdndn/프로젝트/공고알림앱/20260722-타깃-공고-알림-및-지원-히스토리-prd.md
근거 BE TDD (API 계약 출처): /Users/biuea/Desktop/dpdpdndn/프로젝트/공고알림앱/20260722-타깃-공고-알림-및-지원-히스토리-tdd.md
근거 BE 티켓: /Users/biuea/Desktop/dpdpdndn/프로젝트/공고알림앱/tickets/ (API 생산 티켓 BE-12·13·14·15·17·18·19)
이 앱의 알림 채널은 디스코드 웹훅입니다. 웹 UI는 알림을 대체하는 것이 아니라 알림으로 알 수 없는 것을 담당합니다 — ① 회사·소스 등록(알림보다 먼저 일어나는 입력) ② 공고 이력 조회(알림은 신규 1회뿐) ③ 지원 상태 추적(알림 대상이 아님) ④ 운영 이력 조회(알림 자체가 실패했을 때의 확인 수단, 시나리오 7). 이 네 가지가 화면 설계의 축입니다.
이 문서의 범위는 PRD Milestone 1단계 = P0 전부입니다. P1(자소서 초안→제출, D-1·리마인드 알림, 웹 검색 보조 탐색)·P2(근무형태 ③ 추론, 자소서 재사용 검색, 지원 통계)는 확장 지점만 표시하고 상세 설계하지 않습니다.
Overview
| 항목 | 결정 |
|---|---|
| 무엇을 | 회사 반자동 등록 → 애그리게이터 소스 등록 → 관심/발견 회사별 공고 조회 → 수동 공고 등록 → 지원 상태·면접 회차 추적 → 매칭 기준 설정 → 운영 이력 조회 (화면 14개) |
| 왜 | 알림(디스코드)이 커버하지 못하는 입력·이력·추적·운영 확인을 담당합니다 |
| 어떻게 | React 19 + TypeScript strict + Vite, TanStack Query(서버 상태), Zustand 2개 스토어(테마·토스트), Tailwind + CSS 변수 시맨틱 토큰, React Router, Testing Library + Vitest, MSW |
| 위치 | BE와 같은 레포의 web/ 디렉토리 (근거는 아래 “레포 배치 결정”) |
| 디자인 기준 | 토스(Toss) 패턴 — 1 thing per 1 page, Clear CTA, Minimum Input, 절제된 accent |
| 테마 | 라이트·다크 두 모드 의무. 색은 시맨틱 토큰(CSS 변수)만 사용, 하드코딩 0건 |
| 인증 | 없음. 1인용·로컬 Docker 전용(NFR-7). 로그인·회원가입 화면을 만들지 않습니다 |
| 핵심 위험 | ① 확신도 낮은 근무형태가 확정 정보처럼 보이는 것 ② 허용되지 않는 상태 전이를 UI가 제시하는 것 ③ BE API 미완성이 FE 진행을 막는 것 ④ 발견 회사 수백 곳이 관심 회사(이 앱의 본질)를 UI에서 잠식하는 것 — 넷 다 설계 단계에서 방어 장치를 확정합니다 |
2차 갱신 (2026-07-22): BE 설계가 ① 계약 변경 응답(수용 7·부분 수용 1·미채택 1) ② 애그리게이터 P0 편입(FR-60~69)으로 갱신됐습니다. 아래 두 절이 그 반영입니다. 나머지 절은 이 두 절과 상충하는 부분만 갱신했습니다.
2차 갱신 반영 — 계약 변경 응답 (A)
| # | 반영 |
|---|---|
| 1 | GET /api/matching/criteria 신설 → S-10 매칭 설정이 실 API로 완주 가능 (더 이상 blocking 아님) |
| 2 | 제외어·근무형태 키워드 DELETE 신설(소프트 삭제) → S-10 삭제 흐름 실 API 완주 |
| 3 | 공고 목록에 applicationId: number | null(← applied: boolean 아님) → null=미지원, 값 있으면 지원 상세로 바로 이동. FE-04 목·types/api.ts·FE-10을 applicationId != null로 수정 |
| 4 | 지원 목록에 companyName·jobPostingTitle 포함 확정 → S-06 카드 식별 가능 |
| 5 | GET /api/companies에 brokenSourceCount(불리언 아닌 개수) → S-01 소스 고장 배지는 brokenSourceCount > 0 조건 |
| 6 | POST /api/matching/re-evaluations {force?}(기본 false) + 응답 skippedCount → S-10 “다시 확인하기”는 force 미전송, skip 건수로 “왜 0건인지” 설명 |
| 7 | collection-runs에서 guardApplied 제거, abnormal 단일 필드 → FE-06에서 guardApplied 참조 제거 |
| 8 | 상세·전이 응답에 allowedNextStatuses[] 포함 → FE-14의 전이 맵 상수는 폴백으로 격하(서버 값 우선), 동기화 주석·검증 테스트 삭제 가능. 409 방어선은 유지 |
| 9 | 전 회사 통합 공고 조회는 P1 보류 → 회사 우선 내비게이션 유지(“전체 공고” 탭 없음) |
2차 갱신 반영 — 애그리게이터 P0 편입 (B)
| 항목 | FE 반영 |
|---|---|
회사 축 companyOrigin | WATCHED(관심 — 사용자 직접 등록) / DISCOVERED(발견 — 애그리게이터 자동 등록). registrationType(AUTO/MANUAL_ONLY)과 직교하는 별도 축 |
| 관심/발견 위계 (핵심) | 이 앱의 본질은 “타깃 구직(관심 회사)“입니다. 홈 기본 뷰는 관심 회사, 발견 회사(수백 곳)는 세그먼트 탭 + 페이지네이션으로 분리해 잠식을 막습니다 (설계 “방안 8”) |
| 신규 화면 S-12·S-13 | S-12 애그리게이터 소스 등록(POST /api/aggregator-sources, 카테고리는 GET .../categories로 채움) + S-13 소스 관리(GET /api/aggregator-sources·DELETE /api/aggregator-sources/{id} 소프트 삭제). 회사 등록과 별개 진입점 |
| 신규 액션 | 발견 회사 → 관심 회사 승격 (POST /api/companies/{id}/promotion) — 홈 발견 탭 카드에서 인라인 |
| 공고 목록 아이템 | sourceType(COMPANY_BOUND/AGGREGATOR)·alternateSourceCount 추가. 대표 공고만 노출(크로스 소스 중복은 서버가 1건으로 묶음, FR-64) |
| 공고 상세 | alternateSources[] — 같은 공고의 다른 출처(“당근 직접 채용 + 사람인에도 게재”) |
| 알림 | 발견 회사는 디스코드 일일 요약(DAILY_DIGEST) — FE 무영향, 인지만 |
Terminology
FE 고유 용어만 정의합니다. 도메인 용어(소스·시딩·확신도·발송 회차 등)는 BE TDD “Terminology”를 그대로 따릅니다.
| 용어 | 정의 |
|---|---|
| 시맨틱 토큰 | 역할로 이름 붙인 색 변수(--text-primary, --accent). 라이트/다크 값 매핑을 가지며 컴포넌트는 원색을 모릅니다 |
| 4상태 | 데이터 화면이 반드시 처리해야 하는 loading / empty / error / success |
| 위저드 | 여러 스텝을 한 라우트 안에서 진행하는 입력 흐름. 회사 등록에만 사용 |
| 시트(BottomSheet) | 화면 하단에서 올라오는 모달. 쓰기 액션(지원 기록·상태 전이·면접 회차)의 표준 진입 형태 |
| 확신도 위계 | CONFIRMED > LIKELY > UNKNOWN 을 시각 무게로 구분하는 규칙 (칩 채움 > 테두리 > 텍스트 없음) |
| 스텁 페이지 | FE-01이 만드는 “준비 중” 자리표시 페이지. 각 화면 티켓이 이를 대체하므로 라우터 파일 수정이 0건이 됩니다 |
| 계약 요청 | BE TDD 계약이 화면 요구를 못 채우는 지점. FE가 임의로 만들지 않고 목록으로 역제안합니다 |
Define Problem
AS-IS
대상 레포에 FE 코드가 없습니다. 실제 확인 결과:
/Users/biuea/recruitment-application
├── .claude/private-project (마커)
├── .git (커밋 1건: 4d2c9c6 "레포 초기화 및 개인 프로젝트 마커 추가")
└── README.md (190 bytes)
BE도 아직 코드가 없고 BE-01(스캐폴딩)이 Gradle 단일 모듈·build.gradle.kts·docker-compose.yml·src/main/kotlin을 레포 루트에 만들 예정입니다. 따라서 FE의 AS-IS 문제는 “기존 구조와의 정합”이 아니라 BE 스캐폴딩과 파일 충돌 없이 공존하는 배치를 정하는 것입니다.
기존 컴포넌트·라우팅·상태관리 패턴이 존재하지 않으므로 이 문서가 그 기준을 새로 세웁니다.
TO-BE
web/디렉토리에 Vite 기반 React SPA. BE의 Gradle 빌드와 파일 경로가 겹치지 않습니다.- 모든 색은 CSS 변수 시맨틱 토큰.
.dark클래스 전환으로 두 모드를 동시에 만족합니다. - 서버 상태는 전부 TanStack Query 캐시가 SSOT. 서버 데이터를 스토어·
useState에 복사 보관하지 않습니다. - 목록 필터·정렬은 URL search params가 SSOT. 전역 스토어에 두지 않습니다.
- MSW 목 핸들러가 BE 계약을 그대로 구현해, BE-12~19 완료를 기다리지 않고 FE 화면 티켓을 병렬 진행합니다.
레포 배치 결정 — 같은 레포 web/ 디렉토리
| 방안 | 설명 | 왜 채택 / 미채택 |
|---|---|---|
A. 같은 레포 web/ 하위 디렉토리 | BE(Gradle, 루트) + FE(Vite, web/)가 한 레포에 공존. 루트 docker-compose.yml이 둘을 함께 기동 | 채택. ① API 계약이 한 레포에서 커밋 단위로 동기화됩니다 — 계약 변경 시 BE·FE 수정이 한 PR에 들어갈 수 있습니다 ② 1인 프로젝트에서 레포 2개는 이슈·브랜치·배포 파이프라인을 2벌 운영하는 비용만 만듭니다 ③ BE-01이 소유하는 파일(build.gradle.kts·src/main/**·docker-compose.yml)과 FE가 소유하는 web/**이 완전히 분리되어 Single Writer per File이 자연히 성립합니다 |
| B. 별도 레포 | recruitment-application-web 신설 | 미채택. 독립 배포·독립 팀·독립 릴리즈 주기가 하나도 성립하지 않습니다. 계약 변경 시 두 레포를 오가는 비용만 늘어납니다 |
C. 같은 레포, Gradle 하위 모듈로 FE 빌드 통합(node-gradle 플러그인) | ./gradlew build가 프론트까지 빌드 | 미채택. FE 개발 루프(vite dev HMR)가 Gradle을 경유하면 느려지고, 빌드 도구 결합이 생겨 어느 한쪽 업그레이드가 다른 쪽을 깨뜨립니다. 지금 규모에 이득 0 |
D. BE 정적 리소스로 번들(src/main/resources/static) | Spring이 SPA를 서빙 | 미채택. FE 빌드 산출물이 BE 소스 트리에 들어가 Single Writer가 깨지고, 개발 중 프록시 설정이 오히려 복잡해집니다. 배포 시 정적 서빙이 필요하면 nginx 컨테이너 1개를 추가하는 편이 단순합니다 |
개발 시 API 연결: Vite dev server의 server.proxy로 /api → http://localhost:8080. CORS 설정을 BE에 추가할 필요가 없습니다(BE 무수정).
배포: web/Dockerfile(빌드 → nginx 서빙) + 루트 docker-compose.yml에 web 서비스 1개 추가. compose 파일 수정은 **마지막 wave 단독 통합 티켓(FE-17)**이 수행해 BE-01과 시간축으로 분리합니다.
Architecture Benchmarking
동일 과제(개인 구직 트래커 UI, 신뢰도가 다른 정보의 표시)를 푼 사례를 조사했습니다.
| 제품/사례 | 해결 방식 | 참고할 패턴 | 미참고 사유 |
|---|---|---|---|
| 토스 디자인 시스템(TDS) (toss.tech, rethinking-design-system, tds-component-making) | 제품 원칙을 디자인 시스템에 박아 넣습니다 — “1 thing per 1 page”(한 화면에 유저가 할 단 하나의 액션), Clear CTA(다음 단계 CTA가 잘 보이고 바로 누를 수 있는가), Minimum Input(최소한의 정보만 요구), Minimum Features(꼭 필요한 기능인가). 컴포넌트는 “두 번째 사용처가 생겼을 때” 만듭니다 | ① 1 thing per 1 page → 회사 등록을 3스텝 위저드로 분해(한 스텝 한 질문), 지원 상세의 CTA를 “상태 변경” 하나로 고정 ② Clear CTA → 화면당 accent 사용을 1곳으로 제한, 주 CTA는 하단 고정 ③ Minimum Input → 수동 공고 등록 필수 입력을 공고명·링크 2개로 축소(마감일 선택) ④ 컴포넌트 후행 추출 → 도메인 표시 컴포넌트(FE-05)를 두 번째 사용처가 확정된 것만 공용화 | TDS 자체(패키지·Figma 라이브러리)는 비공개 사내 자산이라 직접 사용할 수 없습니다. 패턴만 참고하고 토큰·컴포넌트는 이 문서에서 새로 정의합니다 |
| Huntr — Job Tracker (huntr.co) | 지원 건을 Kanban 보드(Saved→Applied→Interview→Offer)로 두고, 카드 상세에 활동 타임라인·면접 일정·연락처를 붙입니다. 단계 이동은 카드 드래그 | ① 단계 전이 이력을 타임라인으로 표시 → 지원 상세의 핵심 뷰로 채택(PRD FR-47이 “이력이 히스토리 조회의 기준 데이터”라고 못박음) ② 지원 건이 공고와 별개 카드로 존재 → “미지원 = Application 미존재”(FR-42)의 UI 표현으로 채택 | Kanban 드래그 미채택. 이 앱의 전이 규칙은 단방향이고 종료 상태가 4종이라(FR-43·44·45) 드래그는 허용되지 않는 전이를 물리적으로 제시하게 됩니다. “허용되지 않는 전이는 UI에서 아예 제시하지 않는다”는 요구와 정면 충돌합니다. 대신 허용 전이만 나열하는 시트를 채택합니다 |
| Gmail / Google 검색의 신뢰도 낮은 정보 표시 (일반 패턴) | 확정 정보는 본문 위계로, 추정 정보는 “약 ~”, 회색 보조 텍스트, 출처 링크로 강등해 표시 | 확신도 위계를 시각 무게로 표현 → CONFIRMED=채운 칩 / LIKELY=테두리 칩 + “본문 언급” 보조 텍스트 / UNKNOWN=칩 없이 회색 텍스트 “근무형태 정보 없음”. 같은 칩 모양에 색만 바꾸면 사용자가 위계를 읽지 못합니다 | 신뢰도 점수 수치 노출(예: 87%)은 미채택. 확신도가 4단계 이산값이라 숫자가 의미를 더하지 않고 정밀해 보이는 착시만 만듭니다 |
Possible Solutions
방안 1 — 내비게이션 축: 무엇을 최상위로 둘 것인가
| 방안 | 설명 | 왜 채택 / 미채택 |
|---|---|---|
| a. 전체 공고 타임라인이 홈 | 모든 회사의 공고를 최신순 단일 목록으로 | 미채택 (계약 제약 + 요구 불일치). ① BE 계약에 전 회사 통합 공고 조회 엔드포인트가 없습니다 — GET /api/companies/{companyId}/job-postings뿐이라 회사 10~20곳에 대해 N번 호출해야 합니다 ② PRD FR-50이 “공고와 지원 이력은 회사 단위로 분류해 조회”를 P0으로 요구합니다 |
| b. 회사 우선 내비게이션 | 홈=회사 목록 → 회사 선택 → 그 회사 공고 목록 → 공고 상세 | 채택. FR-50과 정확히 일치하고, 현재 BE 계약만으로 완주 가능합니다(계약을 임의로 만들지 않음). 회사가 10~20곳이라 한 화면에 다 들어와 탐색 깊이가 실질적으로 늘지 않습니다 |
| c. 지원 현황이 홈 | Huntr식 보드가 첫 화면 | 미채택. 지원은 공고의 부분집합이고, 이 앱의 1차 가치는 “놓치지 않는 것”(공고 발견)입니다. 지원 목록은 2번째 탭으로 충분합니다 |
확장 지점: 계약 요청 #3(전 회사 통합 공고 조회)이 수용되면 “전체 공고” 탭을 회사 탭 옆에 추가합니다. 라우트·훅만 추가하면 되고 기존 화면은 무변경입니다.
방안 2 — 매칭/비매칭 공고를 한 목록에서 어떻게 다룰 것인가
FR-25가 “매칭 실패 공고도 저장”을 요구하고 FR-33이 “근무형태를 필터로 쓰지 말 것”을 요구합니다. 즉 아무것도 목록에서 제외하면 안 되는데, 그렇다고 다 똑같이 보여주면 밀도가 무너집니다.
| 방안 | 설명 | 왜 채택 / 미채택 |
|---|---|---|
| a. 세그먼트 필터 “매칭 / 전체” | 탭으로 전환 | 미채택. 상태(진행중/마감) 축과 겹쳐 화면에 필터 축이 2개가 되고, “전체” 탭을 안 눌러본 사용자에게 비매칭 공고가 영구히 숨습니다 |
| b. 기본은 매칭 공고, 하단에 “매칭되지 않은 공고 N건” 접힘 섹션 | 한 목록 안에서 위계로 분리. 접힘 헤더에 건수를 항상 노출해 존재를 숨기지 않음 | 채택. ① 제외가 아니라 강등이라 FR-25·33을 지킵니다 ② 필터 축이 늘지 않아 “한 화면 한 과업”이 유지됩니다 ③ 건수가 항상 보이므로 사용자가 조건을 바꿔야 할 때(재매칭) 인지할 수 있습니다 |
| c. 매칭 여부를 배지로만 구분, 한 줄로 섞기 | 정렬만 다르게 | 미채택. 회사당 공고가 수십~수백 건일 때 매칭 5건이 비매칭 200건에 묻힙니다 |
상태(진행중/마감) 축은 별도입니다 — 서버 파라미터 postingStatus가 계약에 존재하므로 상단 세그먼트 탭(진행 중 / 마감)으로 처리합니다. CLOSED 공고는 삭제되지 않고 마감 탭에서 마감 사유와 함께 조회됩니다(FR-18, NFR-5).
방안 3 — 상태 전이를 UI로 어떻게 드러낼 것인가
요구: 허용되지 않는 전이는 아예 제시하지 않는다. OFFERED에서는 ACCEPTED/OFFER_DECLINED만, WITHDRAWN은 앞 3단계에서만, 종료 상태는 전이 불가.
| 방안 | 설명 | 왜 채택 / 미채택 |
|---|---|---|
| a. Kanban 드래그 | Huntr식 | 미채택. 드래그는 모든 열을 물리적으로 제시하므로 요구와 충돌합니다 |
| b. 전 상태를 라디오로 나열하고 불가한 것을 disabled | 전이 규칙을 학습시킴 | 미채택. disabled 항목은 “왜 안 되는지” 설명이 필요해지고, 종료 상태에서는 전체가 disabled인 무의미한 화면이 됩니다 |
| c. 허용 전이만 나열하는 시트 + 종료 상태에서는 CTA 자체를 숨김 | 지원 상세 하단 단일 CTA “상태 변경” → 시트에 현재 상태에서 갈 수 있는 상태만 나열. 종료 상태면 CTA를 렌더하지 않고 “종료된 지원입니다” 안내로 대체 | 채택. 요구 그대로입니다. 사용자가 잘못된 선택지를 볼 일이 없고, CTA가 화면당 1개라는 토스 원칙과도 맞습니다 |
전이 규칙의 SSOT 문제: 클라이언트가 허용 전이만 제시하려면 규칙을 알아야 하는데, 규칙의 SSOT는 BE의 ApplicationStatus.canTransitTo()입니다.
- 1차 선택(권장): 계약 요청 #7 —
GET /api/applications/{id}응답에allowedNextStatuses: string[]를 포함시켜 서버가 선택지를 내려줍니다. 규칙 중복이 0이 됩니다. - 폴백(계약 미수용 시):
src/constants/applicationTransition.ts에 허용 전이 맵을 상수로 두고, BE의 409 응답을 최종 방어선으로 둡니다(시트에서 409를 토스트로 표시하고 상세를 refetch). 상수 파일 상단에 “BE TDD 상태 전이 표가 SSOT — 변경 시 동기화 필수” 주석과 근거 경로를 명시하고, 전이 맵 전수를 검증하는 단위 테스트를 둡니다. - 어느 쪽이든 서버 409를 신뢰합니다. 클라이언트 규칙은 UX 최적화이지 권한 판정이 아닙니다.
REJECTED 입력 흐름: BE 계약의 요청 바디는 {nextStatus, memo?}뿐이고, rejectedAtStage는 서버가 이전 상태로부터 자동 기록합니다(TDD 상태 전이 표: “* × REJECTED → 허용 + rejectedAtStage=이전 상태 기록”). 따라서 FE는 단계를 입력받지 않습니다. 대신 확인 스텝에서 “서류전형 단계에서 불합격으로 기록됩니다”를 명시해 사용자가 기록될 내용을 알고 확정하게 합니다 — Minimum Input 원칙(입력을 늘리지 않고 확인만 제공).
방안 4 — BE API 미완성과의 병행 개발
| 방안 | 설명 | 왜 채택 / 미채택 |
|---|---|---|
| a. BE-12~19 완료를 기다린 뒤 FE 시작 | 순차 진행 | 미채택. FE 전체가 BE 마지막 wave에 직렬로 매달려 전체 리드타임이 두 배가 됩니다 |
| b. MSW(Mock Service Worker) 핸들러로 계약을 선구현 | BE TDD의 계약 표를 그대로 구현한 목 핸들러 + 픽스처를 선행 티켓(FE-04)으로 만들고, 화면 티켓은 목을 상대로 개발·테스트 | 채택. ① FE 화면 티켓 12건이 BE 완료와 무관하게 병렬 진행됩니다 ② 목 핸들러가 곧 계약의 실행 가능한 명세라, 계약 불일치가 통합 시점이 아니라 작성 시점에 드러납니다 ③ 테스트가 네트워크·BE 기동에 의존하지 않아 안정적입니다 |
| c. 계약 타입만 정의하고 훅은 임시 하드코딩 | 가벼운 우회 | 미채택. 하드코딩은 지워지지 않고 남으며, 4상태(loading/error) 테스트를 작성할 수 없습니다 |
통합 지점: 실 API 연동 검증은 마지막 wave의 FE-17이 담당합니다(BE-12~19 머지 완료가 그 시점의 전제).
방안 5 — 단순함 우선: 도입하지 않는 것
| 후보 | 판정 |
|---|---|
| 마이크로 프론트엔드 / 모듈 페더레이션 | 미채택. 화면 11개·1인 프로젝트. 검토할 이유가 없습니다 |
| SSR / Next.js | 미채택. 로컬 전용 1인 도구라 SEO·초기 로딩 최적화 가치가 0이고, 서버 런타임이 하나 더 늘어납니다. Vite SPA로 충분 |
| 상태관리 라이브러리 추가(Redux 등) | 미채택. 서버 상태는 Query가 전담하고 진짜 전역은 테마·토스트 2개뿐입니다 |
| 폼 라이브러리(react-hook-form) | 미채택(1단계). 입력 폼이 3개(회사 등록·수동 공고·면접 회차)이고 필드가 각 2~4개입니다. useState + 제출 시 검증으로 충분하며, 검증 규칙은 순수 함수(utils/validation.ts)로 분리해 테스트합니다. 폼이 5개를 넘거나 필드 단위 실시간 검증이 필요해지면 그때 도입 |
| 컴포넌트 라이브러리(MUI·shadcn 등) | 미채택. 토스 패턴을 기준으로 하는데 기성 라이브러리의 시각 언어를 덮어쓰는 비용이 직접 만드는 비용보다 큽니다. 필요한 프리미티브가 10개뿐입니다 |
| 애니메이션 라이브러리(framer-motion) | 미채택(1단계). 시트 슬라이드·페이드는 CSS transition으로 충분합니다 |
| i18n | 미채택. 사용자 1명, 한국어 단일 |
| E2E 프레임워크(Playwright) | 미채택(1단계). Testing Library + MSW로 화면 단위 통합 테스트를 작성합니다. BE E2E는 BE-20이 담당합니다 |
방안 8 — 관심 회사 vs 발견 회사(수백 곳)의 위계
애그리게이터 편입으로 DISCOVERED 회사가 수백 곳까지 늘어납니다. 이 앱의 본질은 “타깃 구직(관심 회사)“이므로 관심 회사가 항상 첫 화면·기본 뷰의 주인공이어야 합니다(PRD Goals).
| 방안 | 설명 | 왜 채택 / 미채택 |
|---|---|---|
| a. 관심·발견을 한 목록에 섞고 배지로만 구분 | 단일 목록 | 미채택. 발견 회사 수백 곳이 관심 회사 10~20곳을 시각적으로 압도합니다. 스크롤해도 관심 회사를 못 찾습니다 |
b. 발견 회사를 별도 라우트/탭(/discovered)으로 완전 분리 | 4탭에 하나 추가 | 미채택. 내비게이션 탭이 5개로 늘고, 발견 회사가 관심 회사와 “회사”라는 같은 개념인데 물리적으로 다른 곳에 있으면 승격 흐름이 어색해집니다 |
c. 홈 안의 세그먼트 탭(관심 N / 발견 N) + 발견 탭에만 페이지네이션·검색 | 홈에서 ?companyOrigin=으로 전환. 기본은 관심 탭. 발견 탭은 수백 건이므로 페이지네이션(서버 page·size) | 채택. ① 관심 회사가 기본 뷰라 본질이 지켜집니다 ② 세그먼트는 S-03에서 이미 쓰는 검증된 패턴이라 학습 비용 0 ③ 발견 탭에서 바로 승격할 수 있어 흐름이 자연스럽습니다 ④ URL 파라미터가 SSOT라 링크·뒤로가기가 동작합니다 |
승격 액션 위치: 발견 탭 카드에 인라인 [관심 등록] 버튼을 둡니다. 상세 화면으로 들어가 승격하는 방식(미채택)은 수백 곳을 하나씩 열어봐야 해 마찰이 큽니다. 인라인 승격 성공 시 그 회사는 관심 탭으로 이동합니다(두 목록 쿼리 무효화).
애그리게이터 소스 진입점: 발견 회사는 애그리게이터 소스가 만들어냅니다. 따라서 소스 관리(S-13) 진입점을 **발견 탭 헤더의 [발견 소스 관리]**에 둡니다 — 발견 회사의 출처를 관리하는 곳이 발견 탭이라는 맥락이 자연스럽습니다. 관리 화면(S-13)이 등록 화면(S-12)의 부모가 되어 “무엇이 등록돼 있나 → 새로 추가” 흐름을 만듭니다. 홈 주 CTA “회사 추가하기”(→ S-02)는 관심 회사 등록 전용으로 유지해 두 진입점을 혼동하지 않게 합니다.
Detail Design
화면 목록
| ID | 화면 | 라우트 | 주 API | 주 요구사항 | 소유 티켓 |
|---|---|---|---|---|---|
| S-01 | 회사 목록 (홈) — 관심/발견 세그먼트 · 승격 | / /?companyOrigin=DISCOVERED&page= | GET /api/companies, POST /api/companies/{id}/promotion | FR-50,61 · 시나리오 9.5 | FE-09 |
| S-02 | 회사 등록 (위저드 3스텝) | /companies/new | POST /api/companies/source-discoveries, POST /api/companies | FR-1,2,4,5 · 시나리오 1·5 | FE-08 |
| S-03 | 회사 상세 = 공고 목록 | /companies/:companyId | GET /api/companies/{id}/job-postings | FR-50,30,33,34,25,18,64 | FE-10 |
| S-04 | 공고 상세 | /job-postings/:jobPostingId | GET /api/job-postings/{id}, POST /api/applications | FR-29,34,42,21,64 | FE-11 |
| S-05 | 수동 공고 등록 | /job-postings/new?companyId= | POST /api/job-postings/manual-registrations | FR-20,48 · 시나리오 5·6 | FE-12 |
| S-06 | 지원 목록 | /applications | GET /api/applications?companyId= | FR-50,42 | FE-13 |
| S-07 | 지원 상세 (타임라인) | /applications/:applicationId | GET /api/applications/{id} | FR-47,46,43 | FE-16 |
| S-08 | 상태 변경 시트 | (S-07 내 시트) | POST /api/applications/{id}/status-transitions | FR-43,44,45,47 | FE-14 |
| S-09 | 면접 회차 시트 (추가·수정) | (S-07 내 시트) | POST/PATCH .../interviews | FR-46 | FE-15 |
| S-10 | 매칭 설정 | /settings/matching | POST/DELETE /api/matching/**, POST /api/matching/re-evaluations | FR-22,23,26,25 | FE-07 |
| S-11 | 운영 | /operations | GET /api/operations/collection-runs, .../notification-dispatches | Operations · 시나리오 4·7 | FE-06 |
| S-12 | 애그리게이터 소스 등록 | /aggregator-sources/new | POST /api/aggregator-sources, GET .../categories?platform= | FR-60 · 시나리오 9 | FE-18 |
| S-13 | 애그리게이터 소스 관리 (목록·소프트 삭제) | /aggregator-sources | GET /api/aggregator-sources, DELETE /api/aggregator-sources/{id} | FR-60 | FE-18 |
부속: S-00 앱 셸(내비게이션·테마 토글), S-99 404 — FE-17.
P1·P2 확장 지점 (이번 범위 아님, 자리만 표시)
- 자소서(FR-49, P1) → S-07 지원 상세에 “자소서” 섹션이 들어갈 자리. 1단계에서는 렌더하지 않습니다.
- 지원 통계(FR-52, P2) → 새 라우트
/statistics. 1단계에서는 내비게이션에 노출하지 않습니다. - 근무형태
INFERRED근거 링크(FR-31, P2) → S-04 근무형태 근거 섹션의 아이템 타입 확장으로 흡수됩니다.
S-01 회사 목록 (홈) — 관심/발견 세그먼트 · 승격
참고한 토스 패턴: 1 thing per 1 page — 이 화면의 과업은 “회사를 고르거나 새로 등록하는 것”. 관심 회사가 기본 뷰의 주인공이고, 발견 회사(수백 곳)는 세그먼트로 분리해 잠식을 막습니다(설계 “방안 8”). 카드에 정보를 더 얹지 않고 이름·소스 수·상태 배지만 둡니다.
관심 탭 (기본)
┌──────────────────────────────────────────────┐
│ 공고알림 [☀/🌙] │ ← 앱 셸 헤더 (테마 토글)
├──────────────────────────────────────────────┤
│ 회사 │ ← 24px Bold, text-primary
│ │
│ ┌ 관심 4 ┬ 발견 213 ┐ │ ← 세그먼트 (?companyOrigin=)
│ └────────┴──────────┘ │ 기본 = 관심 (WATCHED)
│ │
│ ┌────────────────────────────────────────┐ │
│ │ 당근 › │ │ ← surface 카드
│ │ 소스 1개 · Greenhouse │ │
│ └────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────┐ │
│ │ 우리은행 › │ │
│ │ 소스 2개 · 인크루트 외 1 [소스 1개 고장]│ │ ← brokenSourceCount>0 → danger
│ └────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────┐ │
│ │ OO스타트업 › │ │
│ │ [수동 등록 전용] │ │ ← neutral 칩 (MANUAL_ONLY)
│ └────────────────────────────────────────┘ │
├──────────────────────────────────────────────┤
│ [ 회사 추가하기 ] │ ← accent 단일 CTA (관심 등록)
├──────────────────────────────────────────────┤
│ 회사 지원 매칭 운영 │ ← 하단 탭 (모바일) / 상단 탭 (≥md)
└──────────────────────────────────────────────┘
발견 탭 (페이지네이션 + 인라인 승격)
│ ┌ 관심 4 ┬ 발견 213 ┐ │
│ └────────┴──────────┘ │
│ │
│ 애그리게이터가 발견한 회사예요. │ ← 발견 탭 성격 1줄 안내
│ 일일 요약으로 알림을 받아요. │ (관심 회사와 구분)
│ [ 발견 소스 관리 ] │ ← S-13 진입 (헤더 우측)
│ │
│ ┌────────────────────────────────────────┐ │
│ │ 토스 [관심 등록] │ │ ← 인라인 승격 버튼 (secondary)
│ │ 사람인 · 공고 3건 › │ │
│ └────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────┐ │
│ │ 라인 [관심 등록] │ │
│ │ 점핏 · 공고 1건 › │ │
│ └────────────────────────────────────────┘ │
│ … │
│ ‹ 1 / 11 › │ ← 페이지네이션 (발견 탭만)
| 상태 | UI |
|---|---|
| loading | 카드 스켈레톤 3개(--skeleton). 헤더·세그먼트·CTA는 즉시 렌더 — 레이아웃 점프 방지 |
| empty | 관심 탭 0건: “아직 등록한 회사가 없어요 / 관심 있는 회사를 등록하면 매일 자정에 공고를 확인해요” + CTA를 화면 중앙에도 배치. 발견 탭 0건: “아직 발견된 회사가 없어요 / 애그리게이터 소스를 추가하면 매칭되는 회사를 자동으로 찾아요” + [발견 소스 관리] |
| error | ”회사 목록을 불러오지 못했어요” + 원인 요약 + [다시 시도]. 하단 CTA는 유지(등록은 가능) |
| success | 위 와이어프레임. MANUAL_ONLY=중립 칩, brokenSourceCount>0=danger 배지(“소스 N개 고장”), 발견 카드=인라인 [관심 등록] |
세그먼트·페이지 상태는 URL이 SSOT(?companyOrigin=WATCHED|DISCOVERED&page=). 관심 탭은 10~20곳이라 페이지네이션 없이 전건, 발견 탭만 서버 페이지네이션을 씁니다.
승격 흐름: [관심 등록] 클릭 → POST /api/companies/{id}/promotion → 성공 시 토스트(“관심 회사로 등록했어요”) + 관심·발견 두 목록 쿼리 무효화(그 회사가 관심 탭으로 이동). 승격에는 낙관적 업데이트를 적용하지 않습니다 — 목록 간 이동이라 낙관적으로 옮겼다가 실패하면 되돌리는 애니메이션이 오히려 혼란스럽습니다.
S-02 회사 등록 (위저드 3스텝) — 이 앱의 첫인상
참고한 토스 패턴: 1 thing per 1 page + Minimum Input — 한 스텝에 질문 하나만. 탐색은 시간이 걸리므로 로딩을 화면 전체로 전면화해 “기다리는 중”임을 숨기지 않고, 대기 중 무엇을 하는지 설명합니다. 결과 확인 후 Clear CTA 하나로 확정합니다.
Step 1 — 입력
┌──────────────────────────────────────────────┐
│ ‹ │
│ │
│ 어떤 회사의 공고를 │ ← 24px Bold, 2줄 질문형
│ 받아볼까요? │
│ │
│ 회사명 │
│ ┌────────────────────────────────────────┐ │
│ │ 당근 │ │ ← 자동 포커스
│ └────────────────────────────────────────┘ │
│ │
│ 채용 페이지 주소를 알고 있다면 (선택) │ ← text-secondary 13px
│ ┌────────────────────────────────────────┐ │
│ │ https://… │ │
│ └────────────────────────────────────────┘ │
│ 주소를 넣으면 더 정확하게 찾아요 │
│ │
├──────────────────────────────────────────────┤
│ [ 공고 찾아보기 ] │ ← 회사명 비면 disabled
└──────────────────────────────────────────────┘
Step 2 — 탐색 로딩 (전면)
┌──────────────────────────────────────────────┐
│ │
│ ◐ │ ← accent 스피너
│ │
│ 당근의 채용 페이지를 │ ← 18px Medium
│ 찾고 있어요 │
│ │
│ 채용 플랫폼 3곳을 확인하는 중이에요 │ ← text-secondary
│ 최대 30초 정도 걸릴 수 있어요 │ ← 소요 시간 사전 고지
│ │
└──────────────────────────────────────────────┘
Step 3a — 후보 있음
┌──────────────────────────────────────────────┐
│ ‹ │
│ 2곳을 찾았어요 │
│ 맞는 곳을 골라주세요 (여러 개 선택 가능) │ ← FR-4 복수 소스 1:N
│ │
│ ┌────────────────────────────────────────┐ │
│ │ ☑ Greenhouse · daangn │ │ ← 선택 시 accent 테두리
│ │ boards-api.greenhouse.io/… │ │
│ │ ┌──────────────────────────────────┐ │ │
│ │ │ 미리보기 │ │ │ ← surface-muted 내부 박스
│ │ │ · 서버 개발자 (Core) │ │ │
│ │ │ · 프론트엔드 개발자 │ │ │
│ │ │ · 데이터 엔지니어 │ │ │ ← 샘플 3건 (FR-2)
│ │ └──────────────────────────────────┘ │ │
│ └────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────┐ │
│ │ ☐ 인크루트 · daangn │ │
│ │ 미리보기 · 경력 채용 … │ │
│ └────────────────────────────────────────┘ │
│ │
│ 찾는 회사가 아닌가요? │
│ [ 수동 등록 전용으로 저장 ] │ ← 텍스트 버튼 (탈출구)
├──────────────────────────────────────────────┤
│ [ 당근 등록하기 (1곳 선택됨) ] │
└──────────────────────────────────────────────┘
Step 3b — 후보 0건 (시나리오 5)
┌──────────────────────────────────────────────┐
│ ‹ │
│ │
│ 자동으로 찾지 못했어요 │ ← 실패를 사용자 탓으로 두지 않음
│ │
│ OO스타트업의 채용 페이지를 자동으로 │
│ 수집할 수 있는 형태로 찾지 못했어요. │
│ 회사는 등록하고, 공고는 직접 추가할 수 │ ← 다음 행동을 명확히
│ 있어요. │
│ │
│ ┌────────────────────────────────────────┐ │
│ │ 수동 등록 전용으로 저장하면 │ │ ← surface-muted 안내 박스
│ │ · 자동 수집·알림은 동작하지 않아요 │ │
│ │ · 공고를 직접 추가하면 지원 관리는 │ │
│ │ 똑같이 사용할 수 있어요 │ │ ← FR-21 보장을 명시
│ └────────────────────────────────────────┘ │
│ │
│ [ 채용 페이지 주소로 다시 찾기 ] │ ← Step 1 URL 입력으로 복귀
├──────────────────────────────────────────────┤
│ [ 수동 등록 전용으로 저장 ] │ ← 주 CTA (막다른 길 없음)
└──────────────────────────────────────────────┘
| 상태 | UI |
|---|---|
| loading | Step 2 전면 로딩. 취소 불가로 두지 않습니다 — 헤더 ‹로 Step 1 복귀 가능(진행 중 요청은 AbortController로 취소) |
| empty | Step 3b (후보 0건). 빈 결과가 막다른 길이 아니라 분기가 되도록 두 개의 탈출구(URL 재시도 / 수동 등록 전용 저장)를 제공 |
| error | 탐색 실패: “채용 페이지를 확인하는 중 문제가 생겼어요” + [다시 시도] + [수동 등록 전용으로 저장]. 등록 실패(409 중복 회사명): 필드 하단 인라인 에러 “이미 등록된 회사예요” + 해당 회사로 이동하는 링크 |
| success | 등록 완료 → /companies/{id}로 replace 이동 + 토스트 “당근을 등록했어요. 오늘 자정부터 공고를 확인해요” (첫 수집이 시딩이라 알림이 안 온다는 사실을 미리 알림 — FR-10) |
상태 관리: 위저드 스텝·선택된 후보는 라우트 지역 상태(useReducer)입니다. 전역 승격하지 않는 근거 — 이 라우트를 벗어나면 폐기되는 것이 옳고(중단된 등록이 되살아나면 혼란), 다른 화면이 참조하지 않습니다. 탐색 결과 자체는 Query 캐시(mutation 결과를 useState가 아니라 useMutation의 data로 보유)에 둡니다.
S-03 회사 상세 = 공고 목록 — 정보 밀도와 절제의 균형
참고한 토스 패턴: Minimum Features — 필터 축을 늘리지 않습니다. 상태(진행중/마감)는 세그먼트 하나, 매칭 여부는 필터가 아니라 접힘 섹션(강등), 근무형태는 라벨·정렬 전용. accent는 이 화면에서 “지원함” 배지 한 곳에만 씁니다.
┌──────────────────────────────────────────────┐
│ ‹ 당근 │
│ │
│ ┌ 진행 중 12 ┬ 마감 8 ┐ [최신순 ▾] │ ← 세그먼트(서버 postingStatus)
│ └────────────┴────────┘ 정렬 셀렉트 │ 정렬: 최신순/마감임박/근무형태
│ │
│ ┌────────────────────────────────────────┐ │
│ │ 서버 개발자 (Core) │ │ ← 16px Medium, 2줄까지
│ │ [재택 가능] ~7.30 마감 │ │ ← CONFIRMED = 채운 칩
│ └────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────┐ │
│ │ 백엔드 엔지니어 [지원함] │ │ ← accent-subtle 배지
│ │ ⌞재택 가능 · 본문 언급⌝ D-1 │ │ ← LIKELY = 테두리 칩
│ │ ↑danger │ │ 마감 임박 = danger 텍스트
│ └────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────┐ │
│ │ 플랫폼 엔지니어 🔗 3곳 게재 │ │ ← alternateSourceCount>0 (neutral)
│ │ 근무형태 정보 없음 상시채용 │ │ ← UNKNOWN = 칩 없이 회색 텍스트
│ └────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────┐ │
│ │ 정보보안 담당 [접근 제한] │ │ ← warning 배지 (시나리오 6)
│ │ 근무형태 정보 없음 │ │
│ └────────────────────────────────────────┘ │
│ │
│ ───────────────────────────────────────── │
│ ▸ 매칭되지 않은 공고 47건 │ ← 접힘 (기본 닫힘, 건수 상시 노출)
│ ───────────────────────────────────────── │
│ │
├──────────────────────────────────────────────┤
│ [ 공고 직접 추가 ] │ ← 보조 CTA (outline)
└──────────────────────────────────────────────┘
표기 규칙 (핵심 — 확신도 위계)
| 확신도 | 표기 | 시각 무게 | 근거 |
|---|---|---|---|
CONFIRMED | [재택 가능] 채운 칩 (accent-subtle bg / accent-on-subtle text) | 최상 | 구조화 필드 근거 — 확정 정보 |
LIKELY | ⌞재택 가능 · 본문 언급⌟ 테두리 칩 (투명 bg / border / text-secondary) + 보조 문구 | 중간 | JD 본문 근거 — “언급”임을 문구로 명시 |
INFERRED (P2) | 테두리 칩 + “다른 공고 참고” (1단계 미렌더) | 하 | P2 확장 지점 |
UNKNOWN | 칩 없이 근무형태 정보 없음 회색 텍스트(text-tertiary) | 최하 | 칩 모양을 주지 않습니다 — 정보가 없는데 정보처럼 보이면 안 됨 (FR-34) |
왜 색이 아니라 형태로 구분하는가: 같은 칩에 색만 다르면 사용자가 색-신뢰도 매핑을 학습해야 합니다. 채움 → 테두리 → 텍스트 없음의 형태 위계는 학습 없이 읽힙니다. 다크 모드에서 채도 대비가 달라져도 위계가 무너지지 않는다는 실용적 이점도 있습니다.
마감일 표기
| 조건 | 표기 | 토큰 |
|---|---|---|
deadlineAt == null | 상시채용 | --neutral-chip-text |
| D-3 이내 | D-1 / D-3 | --danger (Bold) |
| 그 외 | ~7.30 마감 | --text-secondary |
postingStatus == CLOSED | 마감됨 · 2회 연속 미발견 또는 마감됨 · 마감일 경과 | --text-tertiary |
CLOSED 공고 취급: 삭제하지 않고 마감 탭에서 조회합니다(FR-18, NFR-5). 진행 중 탭에는 섞지 않습니다 — 섞으면 “지금 지원 가능한 것”을 판단하는 화면의 목적이 흐려집니다. 지원 기록이 있는 CLOSED 공고는 마감 탭에서도 [지원함] 배지를 유지하며, 지원 상세는 계속 접근 가능합니다.
크로스 소스 중복 표기 (애그리게이터): 목록은 대표 공고만 나옵니다(서버가 representative_id IS NULL로 거름, FR-64). alternateSourceCount > 0인 공고에 🔗 N곳 게재 neutral 칩을 붙여 “이 공고가 여러 출처에 있다”를 알립니다. 구체적 출처 목록은 상세(S-04)의 alternateSources[]에만 노출합니다 — 목록에서 출처를 다 펼치면 밀도가 무너집니다.
지원 여부: applicationId != null이면 [지원함] 배지를 렌더합니다(applied: boolean이 아니라 ID 유무로 판정 — 계약 응답 #3). 카드 클릭이 아닌 배지 클릭 시 지원 상세로 바로 이동할 수 있습니다.
| 상태 | UI |
|---|---|
| loading | 세그먼트·정렬은 즉시 렌더, 목록만 카드 스켈레톤 4개 |
| empty | 진행 중 0건: “진행 중인 공고가 없어요 / 마감된 공고 8건 보기”(마감 탭으로 유도). 마감 0건도 동일 구조. 회사 전체가 0건(시딩 전): “아직 수집된 공고가 없어요 / 오늘 자정에 첫 수집이 진행돼요” + [공고 직접 추가] |
| error | 목록 영역만 에러 대체([다시 시도]). 세그먼트·CTA는 유지 |
| success | 위 와이어프레임 |
의존 계약: 목록 응답 필드(matched·applicationId·workArrangement·closedReason·accessRestricted·sourceType·alternateSourceCount)는 계약 응답 #3·#4로 확정됨 — 더 이상 blocking 아님. FE-04 목·types/api.ts가 이 확정된 shape를 씁니다.
S-04 공고 상세
참고한 토스 패턴: Clear CTA — 이 화면의 결론은 “지원 기록을 남길 것인가”입니다. 하단 고정 CTA 하나가 그 결론을 담고, 지원 기록이 이미 있으면 CTA가 “지원 현황 보기”로 바뀝니다(버튼을 2개로 늘리지 않음).
┌──────────────────────────────────────────────┐
│ ‹ 공고 │
│ │
│ 당근 · Greenhouse │ ← text-secondary 13px
│ 서버 개발자 (Core) │ ← 22px Bold
│ │
│ [재택 가능] ~7.30 마감 진행 중 │
│ │
│ ┌────────────────────────────────────────┐ │
│ │ 근무형태 근거 │ │ ← surface-muted 박스
│ │ 확신도: 확실 (공고 태그) │ │
│ │ "Remote-friendly" │ │ ← 근거 스니펫 (FR-29)
│ └────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────┐ │
│ │ 매칭 │ │
│ │ 매칭됨 · 백엔드 그룹 │ │ ← 또는 "매칭 안 됨 · 제외어(인턴)"
│ └────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────┐ │
│ │ 이 공고는 3곳에 게재됐어요 │ │ ← alternateSources[] (FR-64)
│ │ • 당근 · Greenhouse (대표) ↗ │ │ ← 대표 = 지금 보는 출처
│ │ • 사람인 ↗ │ │ ← 다른 출처 링크
│ │ • 점핏 ↗ │ │
│ └────────────────────────────────────────┘ │
│ │
│ 공고 바로가기 ↗ │ ← 대표 출처 외부 링크 (새 탭)
│ │
│ 최초 발견 7.16 · 최근 확인 7.22 │ ← text-tertiary 13px
│ │
├──────────────────────────────────────────────┤
│ [ 지원 기록 남기기 ] │ ← accent 단일 CTA
└──────────────────────────────────────────────┘
크로스 소스 출처 표기 (FR-64, shape 확정): alternateSources[] = { jobSourceId, platform, postingUrl, isRepresentative }[]이며 대표 자신을 포함한 그룹 전체가 반환됩니다(비면 단독 공고 → 박스 미렌더). isRepresentative === true인 항목을 “(대표)“로 강조하고 나머지는 외부 링크(새 탭, rel="noopener noreferrer")로 렌더합니다. sourceLabel 필드는 없으므로 platform 값을 라벨로 매핑합니다(예: SARAMIN → “사람인”). “게재됐어요”라는 표현으로 여러 채용 플랫폼에 같은 공고가 올라왔다는 사실을 자연스럽게 전달합니다 — “중복”·“dedup” 같은 내부 용어를 쓰지 않습니다(토스 Casual Concept). 지원·마감·매칭은 대표 공고 기준이므로 사용자는 대표 하나만 신경 쓰면 됩니다.
지원 기록 생성 시트 (CTA → 하단 시트)
┌──────────────────────────────────────────────┐
│ ▬▬▬ │
│ 지원 기록 남기기 │
│ │
│ 지원한 날 │
│ ┌────────────────────────────────────────┐ │
│ │ 2026. 07. 22. │ │ ← 기본값 = 오늘 (Minimum Input)
│ └────────────────────────────────────────┘ │
│ │
│ 메모 (선택) │
│ ┌────────────────────────────────────────┐ │
│ │ │ │
│ └────────────────────────────────────────┘ │
│ │
│ 기록하면 '지원 완료' 상태로 시작해요 │ ← 결과 사전 고지
│ │
│ [ 기록하기 ] │
└──────────────────────────────────────────────┘
| 상태 | UI |
|---|---|
| loading | 제목 영역 스켈레톤 2줄 + 박스 스켈레톤 2개. CTA는 disabled 상태로 렌더 |
| empty | 해당 없음 — 단건 조회이며 미존재는 404(error로 처리) |
| error | 404: “공고를 찾을 수 없어요” + [회사 목록으로]. 그 외: 전체 에러 대체 + [다시 시도] |
| success | 위 와이어프레임. application != null이면 CTA가 [지원 현황 보기]로 바뀌고 상단에 [지원함 · 서류전형] 상태 칩 표시 |
| 시트 제출 실패 | 409(이미 지원 기록 존재) → 시트 유지 + 인라인 에러 + [지원 현황 보기] 링크. 그 외 → 토스트 + 시트 유지(입력값 보존) |
accessRestricted=true인 공고는 상단에 warning 배너: “로그인이 필요한 공고예요. 내용은 직접 확인해야 해요” + 공고 바로가기를 강조합니다(시나리오 6 — P0에서는 알림 대신 배지·배너로 표현).
S-05 수동 공고 등록
참고한 토스 패턴: Minimum Input — 필수는 공고명·링크 2개뿐입니다. 회사는 진입 경로에서 이미 결정되므로 다시 묻지 않고(쿼리 파라미터), 마감일은 선택입니다.
┌──────────────────────────────────────────────┐
│ ‹ │
│ 공고를 직접 추가할게요 │
│ 당근 │ ← 회사는 고정 표시 (재입력 없음)
│ │
│ 공고명 │
│ ┌────────────────────────────────────────┐ │
│ │ 백엔드 엔지니어 (경력) │ │
│ └────────────────────────────────────────┘ │
│ │
│ 공고 링크 │
│ ┌────────────────────────────────────────┐ │
│ │ https://… │ │
│ └────────────────────────────────────────┘ │
│ │
│ 마감일 (선택) │
│ ┌────────────────────────────────────────┐ │
│ │ 선택 안 함 │ │
│ └────────────────────────────────────────┘ │
│ 비워두면 상시채용으로 저장해요 │ ← null = 상시채용 (FR-13)
│ │
│ ┌────────────────────────────────────────┐ │
│ │ 직접 추가한 공고는 자동 수집·마감 판정 │ │ ← 자동 공고와의 차이를 명시
│ │ 대상이 아니에요. 지원 관리는 똑같이 │ │
│ │ 사용할 수 있어요. │ │ ← FR-21 보장
│ └────────────────────────────────────────┘ │
├──────────────────────────────────────────────┤
│ [ 추가하기 ] │
└──────────────────────────────────────────────┘
| 상태 | UI |
|---|---|
| loading | 제출 중 CTA 스피너 + 폼 disabled. (조회가 없는 화면이라 초기 로딩 없음. companyId가 없으면 회사 선택 셀렉트를 렌더하며 그때만 회사 목록 loading이 존재) |
| empty | 해당 없음 (입력 화면) |
| error | 필드 검증 실패 → 인라인 에러(공고명 필수 / 링크 형식). 404(회사 없음) → 전면 에러 + 홈 이동. 5xx → 토스트 + 입력값 보존 |
| success | /job-postings/{id}로 이동 + 토스트 “공고를 추가했어요” |
검증 규칙은 utils/validation.ts 순수 함수로 분리합니다 — isBlank, isHttpUrl, isFutureOrNull.
S-06 지원 목록
참고한 토스 패턴: 1 thing per 1 page — “지금 어디까지 진행됐나”만 답합니다. 카드에 회사·공고명·현재 상태·마지막 전이일만 두고 그 이상 넣지 않습니다.
┌──────────────────────────────────────────────┐
│ 지원 │
│ 진행 중 3 · 종료 5 │
│ │
│ [ 전체 ▾ ] │ ← 회사 필터 (서버 companyId)
│ │
│ 진행 중 │ ← 섹션 헤더 13px
│ ┌────────────────────────────────────────┐ │
│ │ 당근 · 서버 개발자 (Core) › │ │
│ │ [면접] 7.20 전이 │ │ ← accent-subtle 상태 칩
│ └────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────┐ │
│ │ 배민 · 백엔드 엔지니어 › │ │
│ │ [처우협의] 7.21 전이 │ │
│ └────────────────────────────────────────┘ │
│ │
│ 종료 │
│ ┌────────────────────────────────────────┐ │
│ │ 우리은행 · IT 신입 › │ │
│ │ [불합격 · 서류전형] 7.10 전이 │ │ ← danger-subtle, 탈락 단계 병기
│ └────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────┐ │
│ │ OO사 · 플랫폼 엔지니어 › │ │
│ │ [지원 사퇴] 7.05 전이 │ │ ← neutral 칩
│ └────────────────────────────────────────┘ │
└──────────────────────────────────────────────┘
진행 중/종료를 섹션으로 분리합니다 — 종료된 지원이 진행 중인 것과 섞이면 “지금 신경 쓸 것”을 못 고릅니다. 종료 섹션은 기본 노출하되 진행 중 아래에 둡니다(NFR-5: 삭제하지 않으므로 계속 쌓임 → 5건을 넘으면 “더 보기”로 접습니다).
| 상태 | UI |
|---|---|
| loading | 섹션 헤더 없이 카드 스켈레톤 3개 |
| empty | ”아직 지원 기록이 없어요 / 공고 상세에서 지원 기록을 남길 수 있어요” + [회사 목록 보기] |
| error | 전체 에러 대체 + [다시 시도] |
| success | 위 와이어프레임. 회사 필터 적용 시 결과 0건이면 “이 회사에 지원한 기록이 없어요 / 필터 해제” |
S-07 지원 상세 (타임라인) — 이 앱의 핵심 가치
참고한 토스 패턴: Clear CTA + 1 thing per 1 page — 하단 고정 CTA는 “상태 변경” 하나입니다. 면접 회차 추가는 해당 섹션 안의 보조 버튼으로 두어 주 CTA와 경쟁시키지 않습니다. 종료된 지원에서는 주 CTA를 렌더하지 않습니다.
┌──────────────────────────────────────────────┐
│ ‹ 지원 │
│ │
│ 당근 │
│ 서버 개발자 (Core) ↗ │ ← 공고 상세로 이동
│ │
│ ┌────────────────────────────────────────┐ │
│ │ 현재 상태 │ │
│ │ [ 면접 ] │ │ ← 20px, accent-subtle 칩
│ │ 7월 20일부터 │ │
│ └────────────────────────────────────────┘ │
│ │
│ 면접 회차 [+ 추가] │ ← 보조 버튼 (텍스트)
│ ┌────────────────────────────────────────┐ │
│ │ 1차 · 기술면접 [합격] │ │
│ │ 7.18 (금) 14:00 │ │
│ └────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────┐ │
│ │ 2차 · 임원면접 [예정] │ │
│ │ 7.25 (금) 10:00 │ │
│ └────────────────────────────────────────┘ │
│ │
│ 진행 이력 │
│ ● 면접 7.20 │ ← 타임라인 (최신 → 과거)
│ │ 서류 합격 통보 받음 │ ← 메모
│ │ │
│ ● 서류전형 7.17 │
│ │ │
│ ○ 지원 완료 7.16 │ ← 시작점은 빈 원
│ │
├──────────────────────────────────────────────┤
│ [ 상태 변경 ] │ ← 종료 상태면 렌더 안 함
└──────────────────────────────────────────────┘
종료 상태일 때 하단 영역
├──────────────────────────────────────────────┤
│ 이 지원은 종료됐어요 (불합격 · 서류전형) │ ← CTA 자리에 안내 텍스트
└──────────────────────────────────────────────┘
타임라인은 최신이 위입니다 — 현재 상태를 확인하는 것이 주 목적이고, 상단 현재 상태 카드와 타임라인 첫 항목이 시각적으로 이어집니다.
| 상태 | UI |
|---|---|
| loading | 현재 상태 카드 스켈레톤 + 타임라인 3줄 스켈레톤 |
| empty | 타임라인은 최소 1건(생성 시 APPLIED 이력)이 항상 존재하므로 empty 없음. 면접 회차만 empty: “아직 면접 회차가 없어요” + [+ 추가] |
| error | 404: “지원 기록을 찾을 수 없어요” + [지원 목록으로]. 그 외 전체 에러 대체 |
| success | 위 와이어프레임 |
S-08 상태 변경 시트
참고한 토스 패턴: Minimum Policy — 규칙을 설명하지 않고 선택지 자체를 규칙에 맞게 줍니다. 갈 수 없는 상태는 목록에 없습니다.
현재 INTERVIEWING 일 때 현재 OFFERED 일 때
┌────────────────────────────┐ ┌────────────────────────────┐
│ ▬▬▬ │ │ ▬▬▬ │
│ 상태 변경 │ │ 상태 변경 │
│ 지금은 '면접' 단계예요 │ │ 지금은 '처우협의' 단계예요 │
│ │ │ │
│ ○ 처우협의 │ │ ○ 최종 합격 │
│ ○ 불합격 │ │ ○ 오퍼 거절 │
│ ○ 지원 사퇴 │ │ │
│ │ │ (지원 사퇴·불합격 없음) │ ← FR-44
│ 메모 (선택) │ │ 메모 (선택) │
│ ┌──────────────────────┐ │ │ ┌──────────────────────┐ │
│ └──────────────────────┘ │ │ └──────────────────────┘ │
│ [ 변경하기 ] │ │ [ 변경하기 ] │
└────────────────────────────┘ └────────────────────────────┘
REJECTED 선택 시 — 확인 문구가 자동으로 바뀝니다
│ ● 불합격 │
│ ┌──────────────────────────────────────┐ │
│ │ '면접' 단계에서 불합격으로 기록돼요 │ │ ← 서버가 자동 기록할 값을 사전 고지
│ └──────────────────────────────────────┘ │
탈락 단계를 입력받지 않습니다 — 서버가 이전 상태로 자동 기록하므로(BE TDD 상태 전이 표) 입력을 늘리지 않고 확인만 제공합니다.
종료 상태 선택 시 공통 확인: ACCEPTED/REJECTED/WITHDRAWN/OFFER_DECLINED 선택 시 CTA 위에 “변경하면 되돌릴 수 없어요” 경고 문구(warning 텍스트)를 표시합니다. 별도 확인 다이얼로그를 겹치지 않습니다(시트 위 다이얼로그는 토스 패턴에 반하는 중첩).
| 상태 | UI |
|---|---|
| loading | 제출 중 CTA 스피너 + 라디오 disabled |
| empty | 허용 전이 0건(종료 상태)이면 시트를 열 수 없습니다 — S-07이 CTA 자체를 렌더하지 않음 |
| error | 409(전이 불가·낙관적 잠금 충돌) → 시트 유지 + “상태가 이미 바뀌었어요. 최신 정보를 불러왔어요” + 상세 자동 refetch → 선택지 갱신. 그 외 → 토스트 |
| success | 시트 닫힘 + 상세 갱신 + 토스트 “‘처우협의’로 변경했어요” |
S-09 면접 회차 시트
┌──────────────────────────────────────────────┐
│ ▬▬▬ │
│ 면접 회차 추가 │
│ │
│ 회차 │
│ ┌────────────────────────────────────────┐ │
│ │ 2 │ │ ← 기존 최대 회차 + 1 자동 채움
│ └────────────────────────────────────────┘ │
│ │
│ 이름 │
│ ┌────────────────────────────────────────┐ │
│ │ 임원면접 │ │ ← 자유 입력 (회사마다 다름)
│ └────────────────────────────────────────┘ │
│ │
│ 일정 (선택) │
│ ┌────────────────────────────────────────┐ │
│ │ 아직 안 정해짐 │ │ ← 일정 미정 상태로 먼저 등록 가능
│ └────────────────────────────────────────┘ │
│ │
│ [ 추가하기 ] │
└──────────────────────────────────────────────┘
수정 모드(PATCH)에서는 회차·이름이 읽기 전용이 되고 일정·결과(예정/합격/불합격/취소)·메모만 편집합니다. 결과를 기록해도 지원 상태는 자동 전이되지 않습니다(BE-15 설계 의도) — 시트 하단에 “결과를 기록해도 지원 상태는 바뀌지 않아요. 상태는 직접 변경해 주세요” 보조 문구로 명시합니다.
| 상태 | UI |
|---|---|
| loading | 제출 중 CTA 스피너 |
| empty | 해당 없음 (입력 시트) |
| error | 409(회차 중복) → 회차 필드 인라인 에러 “이미 있는 회차예요”. 409(종료된 지원) → 시트 닫고 토스트 + 상세 refetch. 400(회차 ≤ 0) → 인라인 에러 |
| success | 시트 닫힘 + 상세 갱신 + 토스트 |
S-10 매칭 설정
참고한 토스 패턴: Casual Concept — “동의어 그룹”·“criteriaRevision” 같은 내부 용어를 그대로 노출하지 않고 “같은 뜻으로 볼 단어”, “조건이 바뀌었어요”로 풀어씁니다.
┌──────────────────────────────────────────────┐
│ 매칭 설정 │
│ │
│ ┌────────────────────────────────────────┐ │
│ │ 조건이 바뀌었어요 │ │ ← revision 변경 시에만 노출
│ │ 저장된 공고를 다시 확인해 보세요 │ │ (accent-subtle 배너)
│ │ [ 다시 확인하기 ] │ │ ← POST re-evaluations
│ └────────────────────────────────────────┘ │
│ │
│ 관심 직무 │
│ 같은 뜻으로 볼 단어를 묶어서 등록해요 │ ← FR-22를 평이하게
│ ┌────────────────────────────────────────┐ │
│ │ 백엔드 ⨯ │ │
│ │ Backend · 서버 · Server │ │ ← 동의어 나열
│ └────────────────────────────────────────┘ │
│ [ + 직무 추가 ] │
│ │
│ 제외할 단어 │
│ 이 단어가 들어간 공고는 알리지 않아요 │ ← FR-23
│ [인턴 ⨯] [계약직 ⨯] [ + 추가 ] │ ← 인라인 칩
│ │
│ 근무형태 단어 │
│ 공고에서 찾아볼 근무형태 표현이에요. │ ← FR-26·33을 명시
│ 목록에서 라벨과 정렬에만 쓰고, │
│ 공고를 걸러내지는 않아요. │ ← "필터 아님"을 사용자에게 고지
│ [재택 ⨯] [원격 ⨯] [ + 추가 ] │
│ │
└──────────────────────────────────────────────┘
| 상태 | UI |
|---|---|
| loading | 섹션별 칩 스켈레톤 |
| empty | 각 섹션별 개별 empty: “등록한 직무가 없어요 / 직무를 등록하면 매칭된 공고만 알림을 받아요” + 추가 버튼. 세 섹션 모두 비었을 때도 화면 자체는 렌더(설정 화면이므로) |
| error | 조회 실패 → 전체 에러 대체. 추가·삭제 실패 → 낙관적 업데이트 롤백 + 토스트 (아래 참조) |
| success | 위 와이어프레임 |
낙관적 업데이트 적용 지점 (근거 있는 곳만): 키워드 칩 추가·삭제에만 적용합니다. 근거 — 조작이 매우 가볍고 즉시 피드백이 자연스러우며 결과가 단순(칩 하나 추가/제거)합니다. 롤백 흐름: onMutate에서 이전 캐시를 스냅샷 → 실패 시 onError에서 스냅샷 복원 + 토스트(“추가하지 못했어요”) → onSettled에서 invalidateQueries. 재시도는 자동으로 하지 않습니다(사용자가 다시 누르는 편이 명확).
낙관적 업데이트 미적용: 재평가(수 초 소요·결과가 목록 전체에 영향), 상태 전이(409 규칙 판정이 서버에 있음), 회사 등록(외부 탐색 결과 의존). 근거를 각각 명시합니다.
재평가 결과 표기 (계약 응답 #6): “다시 확인하기”는 POST /api/matching/re-evaluations를 **force 미전송(기본 false)**으로 호출합니다 — revision이 바뀐 공고만 재평가합니다. 응답의 {evaluatedCount, skippedCount}를 토스트로 보여줍니다: “42건 재평가 · 1,200건은 이미 최신이라 건너뜀”. skippedCount를 노출하는 이유 — 재평가 후 화면 변화가 적을 때 사용자가 “왜 아무 일도 안 일어났지”라고 오해하지 않게, “이미 최신이라 건너뛴 것”임을 설명합니다.
의존 계약 (해소됨): 매칭 기준 조회 API가 계약 응답 1로 신설됐고(GET /api/matching/criteria), 제외어·근무형태 키워드 DELETE도 응답 2로 신설(소프트 삭제)됐습니다 → 이 화면은 더 이상 blocking이 아니며 실 API로 완주 가능합니다.
S-11 운영
참고한 토스 패턴: Minimum Features — “과하게 만들지 않는다”. 차트·대시보드를 만들지 않고 목록 2개 + 요약 수치 1개로 끝냅니다. 알림이 죽었을 때 확인할 수 있으면 목적을 달성합니다.
┌──────────────────────────────────────────────┐
│ 운영 │
│ │
│ ┌ 수집 이력 ┬ 알림 실패 ┐ │ ← 탭 2개
│ └───────────┴──────────┘ │
│ │
│ 최근 30일 성공률 96.2% │ ← 요약 1개만
│ │
│ ┌────────────────────────────────────────┐ │
│ │ 배민 채용 7.22 00:00 │ │
│ │ [실패] 스키마 파싱 오류 │ │ ← danger 칩 + 오류 요약
│ │ 3일 연속 비정상 · 마감 판정 제외 중 │ │ ← 소스 가드 상태 (Operations)
│ └────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────┐ │
│ │ 당근 Greenhouse 7.22 00:00 │ │
│ │ [성공] 24건 │ │ ← positive 칩
│ └────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────┐ │
│ │ 인크루트 우리은행 7.22 00:00 │ │
│ │ [0건] 비정상으로 분류됨 │ │ ← warning 칩 (0건 ≠ 성공)
│ └────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────┘
[알림 실패 탭]
│ ┌────────────────────────────────────────┐ │
│ │ 신규 공고 · 공고 #1024 7.22 09:00 │ │
│ │ [실패] 3회 시도 · 429 rate limit │ │
│ │ 다음 발송 시각에 다시 시도해요 │ │ ← 재발송 정책을 명시 (시나리오 7)
│ └────────────────────────────────────────┘ │
0건 회차를 성공과 구분하는 것이 이 화면의 핵심입니다 — BE 설계상 “예외 없는 0건”이 silent failure이므로 [성공]으로 표기하면 관측 목적이 무너집니다. [0건] 비정상으로 분류됨 warning 칩으로 별도 표기합니다.
| 상태 | UI |
|---|---|
| loading | 요약 수치 스켈레톤 + 목록 스켈레톤 4개 |
| empty | 수집 이력 0건: “아직 수집 실행 이력이 없어요 / 첫 수집은 오늘 자정에 진행돼요”. 알림 실패 0건: “실패한 알림이 없어요” (긍정적 empty — 에러처럼 보이지 않게 positive 톤) |
| error | 탭별 독립 에러 대체 + [다시 시도] |
| success | 위 와이어프레임 |
S-12 애그리게이터 소스 등록
참고한 토스 패턴: Minimum Input + 1 thing per 1 page — 회사 등록(회사명 → 탐색 → 확정)과 흐름이 다릅니다. 여기서는 플랫폼을 고르고 검색 조건을 지정하는 것이 유일한 과업입니다. 탐색 단계가 없어 위저드가 아니라 단일 폼입니다.
┌──────────────────────────────────────────────┐
│ ‹ │
│ 발견 소스를 추가할게요 │
│ 선택한 조건에 맞는 공고를 매일 찾아 │ ← 이 소스가 무엇을 하는지 1줄
│ 새 회사를 발견해요 │
│ │
│ 플랫폼 │
│ ┌────────────────────────────────────────┐ │
│ │ 사람인 ▾ │ │ ← Select (6종: 사람인·점핏 +
│ └────────────────────────────────────────┘ │ 원티드·리멤버·잡코리아·서핏)
│ ┌────────────────────────────────────────┐ │
│ │ ⚠ 이 플랫폼은 서비스 약관상 자동 수집에 │ │ ← 회색지대 선택 시만 노출
│ │ 제약이 있어요. 개인 열람 용도로만 │ │ (warning-subtle 배너)
│ │ 사용됩니다. │ │ 사람인·점핏은 미노출
│ └────────────────────────────────────────┘ │
│ │
│ 직무 카테고리 │
│ ┌────────────────────────────────────────┐ │
│ │ 백엔드 개발 ▾ │ │ ← Select (플랫폼별 카테고리 API로 채움)
│ └────────────────────────────────────────┘ │ 플랫폼 바뀌면 재조회
│ │
│ 키워드 (선택) │
│ ┌────────────────────────────────────────┐ │
│ │ 서버 │ │
│ └────────────────────────────────────────┘ │
│ 비워두면 카테고리 전체를 수집해요 │
│ │
│ ┌────────────────────────────────────────┐ │
│ │ 발견된 회사는 관심 회사와 구분돼요. │ │ ← 기대 관리
│ │ 개별 알림 대신 하루 1건 요약으로 │ │
│ │ 받고, 마음에 들면 관심 회사로 올릴 수 │ │ ← 승격 흐름 예고
│ │ 있어요. │ │
│ └────────────────────────────────────────┘ │
├──────────────────────────────────────────────┤
│ [ 소스 추가하기 ] │
└──────────────────────────────────────────────┘
핵심 설계 의도:
- 직무 카테고리는 API로 채웁니다 (상수화 금지) —
GET /api/aggregator-sources/categories?platform=을 호출해{categories:[{code, label}]}로 Select 옵션을 구성합니다. FE 상수화는 어댑터 지원 코드와 드리프트가 나므로 금지입니다(계약 확정 반영). 사용자에게 코드를 노출하지 않고label(한글)을 보여주며, 플랫폼 세그먼트가 바뀌면 카테고리를 재조회합니다(의존 쿼리 —enabled: !!platform, queryKey에 platform 포함). - 키워드는 선택입니다 — 비우면 카테고리 전체를 수집합니다(Minimum Input).
- 발견 회사의 성격을 등록 시점에 안내합니다 — “관심 회사와 구분 / 일일 요약 / 승격 가능”. 이 안내가 없으면 사용자는 애그리게이터 소스를 등록하고 왜 개별 알림이 안 오는지 의아해합니다(FR-62).
- 플랫폼은 6종 Select입니다(사람인·점핏 청정 + 원티드·리멤버·잡코리아·서핏 회색지대). 6개라 세그먼트가 아니라 Select로 제시합니다(세그먼트는 2~3개 한정).
- 회색지대 소스는 안내 배지를 노출합니다 — 원티드·리멤버·잡코리아·서핏은 약관·robots 리스크가 있고 사용자가 인지·승인한 소스입니다. 선택 시 warning-subtle 배너로 “이 플랫폼은 서비스 약관상 자동 수집에 제약이 있어요. 개인 열람 용도로만 사용됩니다”를 표시합니다. 사람인·점핏(청정)은 배너를 띄우지 않아 시각적으로 구분합니다. 회색지대 여부 판정은 FE-03의
isGrayZonePlatform(platform)에 위임합니다. 배너는 등록을 막지 않습니다(안내 전용). - 등록 성공 시 소스 관리 화면(S-13)으로 이동합니다 — 방금 추가한 소스가 목록에 나타나 등록이 반영됐음을 즉시 확인할 수 있습니다. 토스트로 “오늘 자정부터 이 조건으로 공고를 찾아요”(첫 수집이 다음 자정임)를 안내합니다.
| 상태 | UI |
|---|---|
| loading | 카테고리 로딩: 플랫폼 선택 후 카테고리 Select가 로딩 상태(placeholder “불러오는 중”)로 표시되고 그 동안 CTA는 비활성. 제출 중: CTA 스피너 + 폼 disabled |
| empty | 카테고리 0건(플랫폼이 카테고리를 안 내려줌): “이 플랫폼의 카테고리를 불러오지 못했어요” + 플랫폼 재선택 유도. 폼 자체는 입력 화면이라 별도 empty 없음 |
| error | 카테고리 조회 실패: Select 자리에 에러 + [다시 시도]. 검증 실패(카테고리 미선택) → 인라인 에러. 등록 5xx → 토스트 + 입력값 보존 |
| success | 소스 관리 화면(S-13)으로 이동 + 토스트 |
상태 관리: 플랫폼·선택 카테고리·키워드는 라우트 지역 상태(useState)입니다. 카테고리 옵션 목록은 서버 상태(Query, ['aggregator-categories', platform])라 useState에 복사하지 않습니다. 전역 승격 반려 — 한 화면 수명이고 다른 화면이 참조하지 않습니다.
의존 계약 (확정): POST /api/aggregator-sources(등록), GET /api/aggregator-sources/categories?platform=(카테고리), GET /api/aggregator-sources·DELETE /api/aggregator-sources/{id}(관리) — 전부 확정됐습니다. 더 이상 추가 계약 요청이 없습니다.
S-13 애그리게이터 소스 관리 (등록 소스 목록 · 소프트 삭제)
참고한 토스 패턴: Minimum Features — 회사 종속 소스와 대칭인 관리 UI지만 과하게 만들지 않습니다. 목록 + 추가 CTA + 삭제만 둡니다. 등록(S-12)과 관리(S-13)를 한 라우트 계층(
/aggregator-sources→/aggregator-sources/new)으로 묶어, 관리 목록이 등록의 부모가 되게 합니다 — “무엇이 등록돼 있나 → 새로 추가” 흐름이 자연스럽습니다.
┌──────────────────────────────────────────────┐
│ ‹ 발견 소스 │
│ │
│ 애그리게이터에서 공고를 찾는 검색 조건이에요. │ ← 이 화면 성격 1줄
│ │
│ ┌────────────────────────────────────────┐ │
│ │ 사람인 · 백엔드 개발 │ │ ← 활성 소스
│ │ 키워드: 서버 [삭제] │ │
│ └────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────┐ │
│ │ 점핏 · 프론트엔드 │ │
│ │ 키워드 없음 [삭제] │ │
│ └────────────────────────────────────────┘ │
│ ┌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐ │
│ ┊ 사람인 · 데이터 엔지니어 [비활성] ┊ │ ← disabled=true → 흐리게
│ ┊ 중지된 소스예요 ┊ │ (text-tertiary)
│ └╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘ │
├──────────────────────────────────────────────┤
│ [ 발견 소스 추가 ] │ ← accent CTA → S-12
└──────────────────────────────────────────────┘
핵심 설계 의도:
- 비활성 소스를
disabled=true로 흐리게 표시합니다(text-tertiary + 점선 테두리 + “중지된 소스예요”). 삭제(소프트 삭제)된 소스는 완전히 사라지지 않고 비활성으로 남아, 과거에 무엇을 수집했는지 이력이 보존됩니다(NFR-5 정신). - 삭제는 소프트 삭제입니다 — 확인 다이얼로그 없이
[삭제]→ 낙관적으로 비활성 처리하되, 실패 시 롤백합니다. 소프트 삭제라 되돌릴 여지가 있어 확인 단계를 생략합니다(Minimum Policy). - 진입점: 홈 발견 탭 헤더의
[발견 소스 관리]→ 이 화면(S-13). 등록(S-12)은 이 화면의[발견 소스 추가]CTA로 진입합니다.
| 상태 | UI |
|---|---|
| loading | 카드 스켈레톤 3개. 헤더·CTA 즉시 렌더 |
| empty | ”아직 등록한 발견 소스가 없어요 / 소스를 추가하면 조건에 맞는 회사를 자동으로 찾아요” + 중앙 CTA |
| error | 목록 영역 에러 대체 + [다시 시도]. CTA는 유지(추가는 가능) |
| success | 위 와이어프레임. 활성/비활성을 시각 위계로 구분 |
삭제 낙관적 업데이트 롤백: onMutate에서 해당 소스를 낙관적으로 disabled=true 처리 → 실패 시 onError에서 원복 + 토스트 → onSettled에서 ['aggregator-sources'] 무효화.
테마 토큰 정의 (의무) — 시맨틱 토큰 → 라이트/다크 매핑
색 하드코딩은 금지(no-hardcoded-color)이며 아래 토큰 이름으로만 사용합니다. 정의 위치는 web/src/theme/tokens.css(CSS 변수)이고 tailwind.config.ts가 이 변수를 참조합니다. 이 표가 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,0.48) | rgba(0,0,0,0.64) |
--shadow-sheet | 시트 그림자 | 0 -2px 16px rgba(0,0,0,0.08) | 0 -2px 16px rgba(0,0,0,0.40) |
--skeleton | 로딩 플레이스홀더 | #E9ECEF | #252A32 |
텍스트
| 토큰 | 용도 | 라이트 | 다크 |
|---|---|---|---|
--text-primary | 제목·본문 | #191F28 | #EDEFF2 |
--text-secondary | 보조 설명·메타 | #4E5968 | #A7AEB8 |
--text-tertiary | 비활성·정보 없음 표기 | #8B95A1 | #6B7480 |
--text-inverse | accent 버튼 위 텍스트 | #FFFFFF | #0F1115 |
강조(accent) — 화면당 1곳 원칙
| 토큰 | 용도 | 라이트 | 다크 |
|---|---|---|---|
--accent | 주 CTA 배경·활성 탭 | #3182F6 | #4E92F7 |
--accent-pressed | 눌림 상태 | #1B64DA | #3B7FE0 |
--accent-subtle | 상태 칩·배너 배경 | #EAF2FE | #17263B |
--accent-on-subtle | subtle 위 텍스트 | #1B64DA | #8FBBFB |
의미 색
| 토큰 | 용도 | 라이트 | 다크 |
|---|---|---|---|
--positive | 수집 성공·면접 합격 | #00A661 | #2ECC80 |
--positive-subtle | 위의 배경 | #E5F7EF | #10291E |
--warning | 0건 회차·접근 제한 | #C2660A | #FFA23A |
--warning-subtle | 위의 배경 | #FFF3E5 | #2E2113 |
--danger | 실패·마감 임박·불합격 | #E02B39 | #FF6B76 |
--danger-subtle | 위의 배경 | #FEECEE | #331A1D |
--neutral-chip-bg | 중립 칩(상시채용·사퇴) | #F2F4F6 | #252A32 |
--neutral-chip-text | 중립 칩 텍스트 | #4E5968 | #A7AEB8 |
--focus-ring | 키보드 포커스 링 | #3182F6 | #7FB2FA |
라이트 모드의
--warning·--danger는 원색보다 어둡게 잡았습니다(#FF8A00→#C2660A,#F04452→#E02B39) — 흰 배경 위 텍스트로 쓸 때 WCAG AA(4.5:1)를 만족시키기 위함입니다. 다크 모드는 반대로 밝게 잡습니다.
도메인 표시 규칙 → 토큰 매핑
| 표시 대상 | 형태 | 배경 | 텍스트 |
|---|---|---|---|
근무형태 CONFIRMED | 채운 칩 | --accent-subtle | --accent-on-subtle |
근무형태 LIKELY | 테두리 칩 | 투명 + --border | --text-secondary |
근무형태 UNKNOWN | 칩 없음(텍스트) | — | --text-tertiary |
지원 진행 중(APPLIED~OFFERED) | 채운 칩 | --accent-subtle | --accent-on-subtle |
지원 ACCEPTED | 채운 칩 | --positive-subtle | --positive |
지원 REJECTED·OFFER_DECLINED | 채운 칩 | --danger-subtle | --danger |
지원 WITHDRAWN | 채운 칩 | --neutral-chip-bg | --neutral-chip-text |
| 마감 임박(D-3 이내) | 텍스트 Bold | — | --danger |
| 상시채용 | 텍스트 | — | --neutral-chip-text |
공고 CLOSED | 텍스트 | — | --text-tertiary |
| 접근 제한 배지 | 채운 칩 | --warning-subtle | --warning |
| 소스 고장 배지 | 채운 칩 | --danger-subtle | --danger |
수집 [성공] | 채운 칩 | --positive-subtle | --positive |
수집 [0건] | 채운 칩 | --warning-subtle | --warning |
수집 [실패] | 채운 칩 | --danger-subtle | --danger |
크로스 소스 🔗 N곳 게재 | 테두리 칩 | 투명 + --border | --text-secondary |
| 발견 회사 표식(발견 탭) | 텍스트 | — | --text-secondary |
| 수동 등록 전용 칩 | 채운 칩 | --neutral-chip-bg | --neutral-chip-text |
| 회색지대 플랫폼 안내 배너(S-12) | 배너 | --warning-subtle | --warning |
새 토큰은 추가하지 않습니다 — 애그리게이터 관련 표시는 전부 기존 토큰(neutral·border·text-secondary)으로 충분합니다. accent는 여전히 화면당 1곳(주 CTA·활성 탭) 원칙을 지키며, 크로스 소스·발견 표식에 accent를 쓰지 않습니다(정보성 표식이 CTA와 시각 무게로 경쟁하면 안 됨).
모드 전환 방식
<html>에class="dark"토글.tokens.css가:root {}와.dark {}두 블록에 같은 변수 이름으로 값을 선언합니다.- 초기값은
prefers-color-scheme, 사용자 오버라이드는localStorage(Zustandpersist). 플래시 방지를 위해index.html의 인라인 스크립트가 첫 페인트 전에 클래스를 적용합니다. - 비색 토큰(radius·spacing·타이포)도 같은 파일에 두되 모드별 값은 없습니다:
--radius-card: 16px,--radius-chip: 8px,--radius-button: 12px.
컴포넌트 트리
컨테이너/프레젠테이션 분리 원칙: 페이지(pages/**)만 훅을 호출하고(컨테이너), 그 아래 컴포넌트는 props만 받는 순수 프레젠테이션입니다. 예외는 시트 — 자기 mutation을 소유합니다(트리거 위치와 무관하게 재사용되어야 하므로).
App (FE-01)
└─ QueryProvider · ThemeProvider · ToastProvider · RouterProvider
└─ AppShell (FE-17) 내비게이션 4탭 · 테마 토글 · ErrorBoundary
├─ CompanyListPage (FE-09) useCompanies({companyOrigin, page}) · useCompanyPromotion()
│ ├─ CompanyOriginSegment* 관심/발견 세그먼트 (?companyOrigin=)
│ ├─ CompanyCard* 이름 · 소스 요약 · 배지 (관심)
│ ├─ DiscoveredCompanyCard* 인라인 [관심 등록] 승격 (발견)
│ └─ Pagination* 발견 탭만
├─ RegisterCompanyPage (FE-08) useReducer(위저드) · useSourceDiscovery() · useRegisterCompany()
│ ├─ CompanyNameStep*
│ ├─ DiscoveringStep*
│ ├─ CandidateSelectStep*
│ │ └─ SourceCandidateCard* 샘플 3건 미리보기
│ └─ NoCandidateStep*
├─ AggregatorSourceListPage (FE-18) useAggregatorSources() · useDeleteAggregatorSource()
│ └─ AggregatorSourceItem* 활성/비활성(disabled) 위계 · 소프트 삭제
├─ AggregatorSourcePage (FE-18) useAggregatorCategories(platform) · useRegisterAggregatorSource()
│ ├─ PlatformSelect* 6종 · 회색지대 안내 배지
│ └─ CategorySelect* 플랫폼별 카테고리 (API로 채움, 의존 쿼리)
├─ JobPostingListPage (FE-10) useJobPostings(companyId, status, sort)
│ ├─ PostingStatusSegment*
│ ├─ PostingSortSelect*
│ ├─ JobPostingCard [FE-05 공용]
│ │ ├─ WorkArrangementLabel [FE-05 공용] 확신도 위계
│ │ ├─ DeadlineText [FE-05 공용] 상시채용·D-day·마감됨
│ │ ├─ AppliedBadge [FE-05 공용] applicationId != null
│ │ └─ CrossSourceChip [FE-05 공용] 🔗 N곳 게재
│ └─ UnmatchedSection* 접힘 · 건수 상시 노출
├─ JobPostingDetailPage (FE-11) useJobPosting(id)
│ ├─ WorkArrangementEvidenceBox*
│ ├─ MatchResultBox*
│ ├─ AlternateSourcesBox* 크로스 소스 출처 (FR-64)
│ └─ CreateApplicationSheet* useCreateApplication()
├─ ManualJobPostingPage (FE-12) useRegisterManualJobPosting()
├─ ApplicationListPage (FE-13) useApplications(companyId)
│ └─ ApplicationCard*
│ └─ ApplicationStatusChip [FE-05 공용]
├─ ApplicationDetailPage (FE-16) useApplication(id)
│ ├─ CurrentStatusCard*
│ ├─ InterviewSection*
│ ├─ StatusTimeline*
│ ├─ TransitStatusSheet [FE-14] useTransitApplicationStatus()
│ └─ InterviewSheet [FE-15] useAddInterview() · useUpdateInterview()
├─ MatchingSettingsPage (FE-07) useMatchCriteria() · 키워드 mutation 4종
│ ├─ ReEvaluationBanner*
│ ├─ KeywordGroupSection*
│ └─ KeywordChipSection* 제외어 · 근무형태 공용
├─ OperationsPage (FE-06) useCollectionRuns() · useNotificationDispatches()
│ ├─ CollectionRunItem*
│ └─ NotificationDispatchItem*
└─ NotFoundPage (FE-17)
공용 UI 프리미티브 (FE-02) — components/ui/**
Button · TextField · Select · DatePicker · Chip · Badge · Card
BottomSheet · Segment · Tabs · Skeleton · EmptyState · ErrorState · Toast
* = 해당 화면 전용(그 티켓이 소유). 표시 없음 = 공용 컴포넌트.
공용화 기준(토스 “두 번째 사용처” 원칙): WorkArrangementLabel·DeadlineText·ApplicationStatusChip·JobPostingCard·AppliedBadge·CrossSourceChip만 FE-05로 공용화합니다 — 각각 2개 이상 화면에서 쓰이는 것이 확정된 것들입니다(CrossSourceChip은 S-03 목록·S-04 상세 요약에서 사용). CompanyCard·DiscoveredCompanyCard·SourceCandidateCard·AlternateSourcesBox는 한 화면에서만 쓰이므로 그 티켓이 소유하고 공용화하지 않습니다.
상태관리 설계
| 분류 | 대상 | 수단 | 채택 근거 |
|---|---|---|---|
| 서버 상태 | 회사·공고·지원·매칭 기준·운영 이력 전부 | TanStack Query | 캐싱·재요청·무효화가 필요한 원격 데이터. Query 캐시가 SSOT이고 어떤 서버 데이터도 스토어·useState에 복사하지 않습니다 |
| URL 상태 | 홈의 companyOrigin·page, 공고 목록의 postingStatus·sort, 지원 목록의 companyId, 수동 등록의 companyId | useSearchParams | 뒤로가기·새로고침·링크 공유가 자연히 동작합니다. 전역 스토어에 두면 이 넷이 전부 깨집니다. 발견 회사 페이지네이션도 URL이 SSOT라 특정 페이지 링크가 공유됩니다 |
| 지역 상태 | 위저드 스텝·선택 후보, 시트 열림 여부, 폼 입력값, 접힘 섹션 펼침 | useState / useReducer | 한 화면·한 컴포넌트 수명. 라우트를 벗어나면 폐기되는 것이 옳습니다 |
| 전역 클라이언트 상태 | ① 테마 모드 ② 토스트 큐 | Zustand (스토어 2개) | ①은 모든 화면에 영향 + localStorage 영속이 필요 ②는 어느 화면에서든 발생하고 앱 셸이 렌더 — 둘 다 “정말 전역”의 정의를 만족 |
전역 승격을 반려한 후보 (미채택 사유 명시)
| 후보 | 반려 사유 |
|---|---|
| 회사 목록을 전역 스토어에 캐시 | 서버 데이터입니다. Query 캐시를 두고 스토어에 복사하면 두 SSOT가 생겨 갱신 누락이 발생합니다 (no-global-by-default) |
| 공고 목록 필터를 전역 스토어에 | URL이 더 나은 SSOT입니다. 전역 스토어는 새로고침 시 초기화되고 공유 불가합니다 |
| 위저드 진행 상태를 전역 스토어에 | 라우트 이탈 후 되살아나는 것이 오히려 버그입니다. 중단된 등록의 복원은 요구사항이 아닙니다 |
| ”현재 선택된 회사”를 전역 스토어에 | URL 파라미터(/companies/:companyId)가 이미 그 역할입니다. 중복 상태 |
| 시트 열림 상태를 전역 모달 매니저로 | 시트가 3개뿐이고 각자 한 화면에만 붙습니다. 매니저는 지금 규모에 과한 간접층입니다 |
Query 규약 (FE-01이 확립)
| 항목 | 규칙 |
|---|---|
| queryKey | ['companies', { companyOrigin, page }], ['companies', companyId, 'job-postings', { postingStatus, sort }], ['job-postings', id], ['applications', { companyId }], ['applications', id], ['match-criteria'], ['operations', 'collection-runs', { days }], ['aggregator-sources'], ['aggregator-categories', platform] — 배열 접두사 계층 구조로 부분 무효화 가능 |
| staleTime | 기본 30초. 운영 이력은 0(항상 최신 확인) |
| retry | 기본 1회. 4xx는 재시도하지 않음(shouldRetry가 status로 판별) |
| 무효화 | 지원 생성 → ['applications'] + ['job-postings', id] + 해당 회사 공고 목록 / 상태 전이·면접 → ['applications', id] / 키워드 변경 → ['match-criteria'] / 재평가 → ['companies'] 하위 공고 목록 전체 / 승격 → ['companies'] 접두사 전체(관심·발견 두 탭이 함께 갱신) / 애그리게이터 소스 등록·삭제 → ['aggregator-sources'](다음 수집 전이라 발견 회사 즉시 변화는 없으나 소스 목록은 즉시 갱신) |
| 에러 정규화 | API 클라이언트가 ApiError { status, code, message }로 정규화. 컴포넌트는 HTTP 세부를 모릅니다 |
API 연동 표 (화면 × 엔드포인트 × 의존 티켓)
| 화면 | 메서드 · 경로 | 훅 | BE 티켓 | 실패 처리 |
|---|---|---|---|---|
| S-01 | GET /api/companies?companyOrigin=&page=&size= | useCompanies | BE-17 | 목록 영역 에러 대체 + 재시도 |
| S-01 | POST /api/companies/{id}/promotion | useCompanyPromotion | BE-17 | 토스트 + 목록 유지(승격 실패 시 재시도) |
| S-02 | POST /api/companies/source-discoveries | useSourceDiscovery (mutation) | BE-17 | 전면 에러 + 재시도 + 수동 등록 전용 분기 |
| S-02 | POST /api/companies | useRegisterCompany | BE-17 | 409 → 인라인 “이미 등록된 회사” + 해당 회사 링크 |
| S-03 | GET /api/companies/{companyId}/job-postings?postingStatus=&sort= | useJobPostings | BE-18 | 목록 영역 에러 대체 |
| S-04 | GET /api/job-postings/{id} | useJobPosting | BE-18 | 404 전용 화면 / 그 외 에러 대체 |
| S-04 | POST /api/applications | useCreateApplication | BE-14 | 409 → 인라인 + 지원 현황 링크 |
| S-05 | POST /api/job-postings/manual-registrations | useRegisterManualJobPosting | BE-18 | 400 인라인 / 404 전면 / 5xx 토스트 + 입력 보존 |
| S-06 | GET /api/applications?companyId= | useApplications | BE-14 | 목록 영역 에러 대체 |
| S-07 | GET /api/applications/{id} | useApplication | BE-14 · BE-15 | 404 전용 화면 |
| S-08 | POST /api/applications/{id}/status-transitions | useTransitApplicationStatus | BE-14 | 409 → 안내 + 자동 refetch → 선택지 갱신 |
| S-09 | POST /api/applications/{id}/interviews | useAddInterview | BE-15 | 409(회차 중복) 인라인 / 409(종료 지원) 시트 닫고 refetch |
| S-09 | PATCH /api/applications/{id}/interviews/{interviewId} | useUpdateInterview | BE-15 | 토스트 + 시트 유지 |
| S-10 | GET /api/matching/criteria (계약 응답 #1 신설됨) | useMatchCriteria | BE-12 | 전체 에러 대체 |
| S-10 | POST /api/matching/keyword-groups | useRegisterKeywordGroup | BE-12 | 낙관적 롤백 + 토스트 |
| S-10 | DELETE /api/matching/keyword-groups/{id} | useDeleteKeywordGroup | BE-12 | 낙관적 롤백 + 토스트 |
| S-10 | POST /api/matching/exclusion-keywords | useRegisterExclusionKeyword | BE-12 | 낙관적 롤백 + 토스트 |
| S-10 | POST /api/matching/work-arrangement-keywords | useRegisterWorkArrangementKeyword | BE-12 | 낙관적 롤백 + 토스트 |
| S-10 | DELETE 제외어·근무형태 키워드 (계약 응답 #2 신설됨, 소프트 삭제) | useDeleteKeyword | BE-12 | 동일 |
| S-10 | POST /api/matching/re-evaluations ({force?} 미전송) | useReEvaluate | BE-13 | 배너 유지 + skippedCount 포함 토스트. 진행 중 CTA 스피너 |
| S-12 | GET /api/aggregator-sources/categories?platform= | useAggregatorCategories | BE-17(또는 신규) | Select 자리 에러 + 재시도. 플랫폼 변경 시 재조회(의존 쿼리) |
| S-12 | POST /api/aggregator-sources | useRegisterAggregatorSource | BE-17(또는 신규) | 400 인라인 / 5xx 토스트 + 입력 보존 |
| S-13 | GET /api/aggregator-sources | useAggregatorSources | BE-17(또는 신규) | 목록 영역 에러 대체 + 재시도 |
| S-13 | DELETE /api/aggregator-sources/{id} | useDeleteAggregatorSource | BE-17(또는 신규) | 낙관적 비활성 롤백 + 토스트 |
| S-11 | GET /api/operations/collection-runs?jobSourceId=&days=30 | useCollectionRuns | BE-19 | 탭별 에러 대체 |
| S-11 | GET /api/operations/notification-dispatches?dispatchStatus=FAILED&days=30 | useNotificationDispatches | BE-19 | 탭별 에러 대체 |
전부 인증 헤더가 없습니다(NFR-7). API 클라이언트는 credentials: 'omit' + Content-Type: application/json만 설정합니다.
라우팅·내비게이션 흐름
flowchart LR Home["/ 회사 목록 관심/발견"] Register["/companies/new 회사 등록"] AggList["/aggregator-sources 발견 소스 관리"] AggNew["/aggregator-sources/new 발견 소스 등록"] Company["/companies/:id 공고 목록"] Posting["/job-postings/:id 공고 상세"] Manual["/job-postings/new 수동 등록"] AppList["/applications 지원 목록"] AppDetail["/applications/:id 지원 상세"] Matching["/settings/matching 매칭 설정"] Ops["/operations 운영"] Home --> Register Home --> AggList AggList --> AggNew AggNew --> AggList Register --> Company Home --> Company Company --> Posting Company --> Manual Manual --> Posting Posting --> AppDetail AppList --> AppDetail AppDetail --> Posting
내비게이션 4탭(회사 지원 매칭 운영)은 /, /applications, /settings/matching, /operations를 가리킵니다. 애그리게이터 소스 관리/등록은 홈 발견 탭 헤더([발견 소스 관리] → /aggregator-sources → /aggregator-sources/new)에서 진입하므로 탭을 5개로 늘리지 않습니다(내비게이션 단순 유지). 나머지는 계층 이동(뒤로가기 ‹)입니다.
라우트 등록 방식 (Single Writer per File의 핵심 장치): web/src/routes.tsx는 FE-01이 전 라우트를 lazy import로 미리 선언하고, 각 페이지 파일을 “준비 중” 스텁으로 생성합니다. 이후 각 화면 티켓은 자기 스텁 파일만 대체하므로 라우터 파일 수정이 0건이 되어 wave 내 충돌이 원천 차단됩니다. 동시에 이것이 점진 공개 장치입니다 — 미완성 화면은 “준비 중”을 표시하므로 부분 머지가 안전합니다.
Testing Plan
프레임워크는 Vitest + @testing-library/react + MSW입니다. RED → GREEN → REFACTOR를 강제하며, 테스트는 사용자 관점 동작(보이는 텍스트·role·인터랙션 결과)만 검증합니다. 내부 state·함수 호출 검증과 동작 대용 snapshot은 금지입니다.
| 레벨 | 대상 | 도구 | 핵심 시나리오 |
|---|---|---|---|
| 유틸(순수) | 날짜·D-day·상시채용 포맷, 확신도 표기 규칙, 전이 맵, 검증 함수 | Vitest | 경계값(D-0/D-1/D-3/D-4), null 마감일, 종료 상태 전이 0건 |
| 훅 | query·mutation 훅 | renderHook + MSW | 성공/실패/낙관적 롤백 |
| 컴포넌트 | UI 프리미티브, 도메인 표시 컴포넌트 | Testing Library | 렌더·인터랙션·접근성 role |
| 화면(통합) | 각 페이지 | Testing Library + MSW + MemoryRouter | 4상태 전부(loading/empty/error/success) + 주요 인터랙션 |
| 테마 | 전 화면 | Testing Library + 토큰 린트 | .dark 적용 시 렌더 성공, 색 하드코딩 0건 |
화면별 필수 테스트 케이스 (티켓의 TDD 입력)
각 티켓 문서에 개별 기재합니다. 공통 규칙은 아래와 같습니다.
| 유형 | 모든 데이터 화면이 반드시 포함 |
|---|---|
| 해피 | 데이터가 있을 때 핵심 정보가 보인다 |
| empty | 0건일 때 안내 문구와 다음 행동 CTA가 보인다 |
| error | 실패 시 에러 안내와 재시도 수단이 보인다 |
| 인터랙션 | 주 CTA·필터·시트가 의도대로 동작한다 |
반드시 커버할 실패·엣지 경로 (전체 관점)
| # | 시나리오 | 기대 | 담당 |
|---|---|---|---|
| 1 | 소스 탐색 결과 0건 | ”수동 등록 전용으로 저장” 경로가 보이고 등록이 완료된다 | FE-08 |
| 2 | 소스 탐색 실패(5xx) | 재시도 버튼과 수동 등록 전용 분기가 둘 다 보인다 | FE-08 |
| 3 | 회사명 중복(409) | 인라인 에러와 기존 회사 링크가 보인다 | FE-08 |
| 4 | 확신도 UNKNOWN 공고 | ”근무형태 정보 없음”이 칩이 아닌 텍스트로 렌더된다 | FE-05 |
| 5 | 확신도 CONFIRMED/LIKELY | 두 칩의 형태(채움/테두리)가 다르게 렌더된다 | FE-05 |
| 6 | 매칭 안 된 공고 | 목록에서 제외되지 않고 접힘 섹션에 건수와 함께 존재한다 | FE-10 |
| 7 | 마감일 null | ”상시채용”으로 표기되고 D-day가 계산되지 않는다 | FE-05 |
| 8 | CLOSED 공고 | 진행 중 탭에 없고 마감 탭에서 마감 사유와 함께 조회된다 | FE-10 |
| 9 | 지원 상태 OFFERED | 시트에 ACCEPTED·OFFER_DECLINED만 있고 WITHDRAWN이 없다 | FE-14 |
| 10 | 지원 상태 종료(ACCEPTED) | “상태 변경” CTA가 렌더되지 않는다 | FE-16 |
| 11 | REJECTED 선택 | “‘면접’ 단계에서 불합격으로 기록돼요” 문구가 현재 상태에 따라 바뀐다 | FE-14 |
| 12 | 전이 409 | 안내 후 상세가 refetch되고 선택지가 갱신된다 | FE-14 |
| 13 | 면접 회차 중복(409) | 회차 필드 인라인 에러가 보인다 | FE-15 |
| 14 | 면접 결과 기록 | 지원 상태 칩이 변하지 않는다 | FE-15 |
| 15 | 키워드 추가 실패 | 낙관적으로 추가된 칩이 사라지고 토스트가 뜬다 | FE-07 |
| 16 | 수집 0건 회차 | [성공]이 아니라 [0건] 비정상으로 표기된다 | FE-06 |
| 17 | 알림 실패 이력 0건 | 에러가 아닌 긍정 톤 empty가 보인다 | FE-06 |
| 18 | 다크 모드 토글 | <html>에 dark 클래스가 붙고 전 화면이 렌더된다 | FE-17 |
| 19 | 알 수 없는 경로 | 404 화면과 홈 이동 수단이 보인다 | FE-17 |
| 20 | 색 하드코딩 | #·rgb(·Tailwind 원색 클래스 사용이 0건 (lint 규칙) | FE-01 |
| 21 | 홈 기본 진입 | 관심 탭이 기본으로 선택되고 관심 회사만 렌더된다 | FE-09 |
| 22 | 발견 탭 전환 | URL companyOrigin=DISCOVERED로 갱신되고 페이지네이션이 렌더된다 | FE-09 |
| 23 | 발견 회사 승격 | [관심 등록] 클릭 후 그 회사가 관심 탭으로 이동한다(두 목록 무효화) | FE-09 |
| 24 | brokenSourceCount > 0 | ”소스 N개 고장” 배지가 렌더되고, 0이면 배지가 없다 | FE-05/09 |
| 25 | 공고 목록 지원 여부 | applicationId != null이면 지원함 배지, null이면 배지 없음 | FE-10 |
| 26 | alternateSourceCount > 0 | 목록 카드에 ”🔗 N곳 게재” 칩이 렌더된다 | FE-05/10 |
| 27 | 공고 상세 크로스 소스 | alternateSources[]가 있으면 대표+대체 출처가 렌더되고, 비면 박스가 없다 | FE-11 |
| 28 | 애그리게이터 소스 등록 | 플랫폼 전환 시 카테고리를 API로 재조회하고, 카테고리 미선택 시 CTA 비활성 | FE-18 |
| 28b | 카테고리 조회 실패 | Select 자리에 에러 + 재시도가 보이고 CTA가 비활성이다 | FE-18 |
| 28c | 발견 소스 관리 | 목록에 활성 소스와 비활성(disabled) 소스가 흐리게 함께 렌더된다 | FE-18 |
| 28d | 소스 소프트 삭제 | [삭제] 클릭 시 낙관적으로 비활성 처리되고, 실패 시 원복된다 | FE-18 |
| 28e | 크로스 소스 대표 표시 | alternateSources[](대표 포함 그룹)에서 isRepresentative=true가 “(대표)“로 강조된다 | FE-11 |
| 29 | 재평가 skip | 응답 skippedCount가 “이미 최신이라 건너뜀”으로 토스트에 표기된다 | FE-07 |
| 30 | 수집 이력 필드 | guardApplied 없이 abnormal 단일 필드로 0건/성공을 구분한다 | FE-06 |
Release Scenario — 점진 공개
FE는 무중단 요건이 BE보다 약하지만(로컬 SPA), 부분 머지가 항상 안전해야 wave 병렬이 성립합니다.
| 단계 | 내용 | 전환 조건 | 롤백 |
|---|---|---|---|
| 0 | FE-01 머지 — 스캐폴딩 + 전 라우트 스텁 | npm run build + tsc --noEmit + lint exit 0 | 브랜치 미머지 |
| 1 | wave 2~5 화면 티켓 머지 — 스텁이 하나씩 실제 화면으로 대체 | 티켓별 테스트·타입체크 통과 + 리뷰 APPROVED/COMMENT | 해당 티켓 revert(스텁으로 복귀 — 다른 화면 무영향) |
| 2 | FE-17 머지 — 내비게이션·404·에러 바운더리 + 실 API 통합 확인 | BE-12~19 머지 완료 + 두 모드 시각 확인 | revert |
| 3 | 루트 docker-compose.yml에 web 서비스 추가 (FE-17 범위) | docker compose up -d로 웹·API 동시 기동 확인 | compose에서 web 서비스 제거 |
피처 플래그 미도입 근거: 스텁 페이지 방식이 이미 점진 공개 장치 역할을 하고, 사용자가 1명이라 A/B·부분 롤아웃 대상이 없습니다. BE의 feature_flags는 배치·알림 제어용이며 FE가 조회하지 않습니다.
데이터 마이그레이션: 해당 없음 — FE에 영속 데이터가 localStorage의 테마 설정 1건뿐이고, 값이 없으면 시스템 설정으로 폴백합니다.
티켓 분해 · Wave DAG
티켓 18건(2차 갱신으로 FE-18 신규 1건 추가). 저장 위치는 /Users/biuea/Desktop/dpdpdndn/프로젝트/공고알림앱/tickets/FE-{NN}-*.md입니다.
티켓 목록
| ID | 제목 | wave | FE 의존 | BE 의존 | 2차 갱신 |
|---|---|---|---|---|---|
| FE-01 | 웹 스캐폴딩 · 테마 토큰 · API 클라이언트 · 라우트 스텁 | 1 | — | — | 갱신 — DTO 타입(companyOrigin·brokenSourceCount·sourceType·alternateSource·applicationId·allowedNextStatuses·skippedCount·aggregator), /aggregator-sources/new 스텁, guardApplied 제거 |
| FE-02 | 공용 UI 프리미티브 | 2 | FE-01 | — | 무변경 (Pagination 프리미티브 추가) |
| FE-03 | 도메인 순수 로직 — 상태 라벨·전이 맵·확신도 규칙·포매터·검증 | 2 | FE-01 | — | 갱신 — 전이 맵은 서버 allowedNextStatuses 폴백으로 격하, 애그리게이터 카테고리 상수 추가 |
| FE-04 | MSW 목 핸들러 · 픽스처 (BE 계약 선구현) | 2 | FE-01 | — | 갱신 — 신규 엔드포인트(criteria GET·키워드 DELETE·promotion·aggregator-sources)·companyOrigin·페이지네이션·applicationId·alternateSources·sourceType 픽스처 |
| FE-05 | 도메인 표시 컴포넌트 — 확신도 라벨·상태 칩·공고 카드·크로스 소스 칩 | 3 | FE-02, FE-03 | — | 갱신 — CrossSourceChip 추가, AppliedBadge는 applicationId 기반 |
| FE-06 | 운영 화면 — 수집 이력 · 알림 발송 실패 | 3 | FE-02·03·04 | BE-19 | 갱신 — guardApplied 참조 제거, abnormal 단일 필드 |
| FE-07 | 매칭 설정 화면 — 키워드·제외어·근무형태·재평가 | 3 | FE-02·03·04 | BE-12, BE-13 | 갱신 — 실 API 완주(blocking 해소), 재평가 skippedCount 표기 |
| FE-08 | 회사 등록 위저드 — 탐색·후보 확정·후보 0건 분기 | 3 | FE-02·03·04 | BE-17 | 무변경 |
| FE-09 | 회사 목록 화면 (홈) — 관심/발견 세그먼트·페이지네이션·승격 | 3 | FE-02·03·04 | BE-17 | 갱신 — companyOrigin 세그먼트·발견 페이지네이션·인라인 승격·brokenSourceCount 배지 |
| FE-18 | 애그리게이터 소스 등록(S-12) · 관리(S-13) 화면 | 3 | FE-02·03·04 | BE-17(또는 신규) | 신규(3차: S-13 관리 화면 + 카테고리 API 흡수 — wave·티켓 수 무변경) |
| FE-10 | 공고 목록 화면 — 상태 세그먼트·정렬·매칭 강등·크로스 소스 | 4 | FE-05 | BE-18 | 갱신 — applied→applicationId != null, sourceType·alternateSourceCount 표시 |
| FE-11 | 공고 상세 화면 · 지원 기록 생성 시트 · 크로스 소스 출처 | 4 | FE-05 | BE-18, BE-14 | 갱신 — AlternateSourcesBox 추가 |
| FE-12 | 수동 공고 등록 화면 | 4 | FE-05 | BE-18 | 무변경 |
| FE-13 | 지원 목록 화면 | 4 | FE-05 | BE-14 | 무변경 (companyName·jobPostingTitle 확정) |
| FE-14 | 지원 상태 변경 시트 — 허용 전이만 제시 | 4 | FE-05, FE-03 | BE-14 | 갱신 — 서버 allowedNextStatuses 우선, 상수 동기화 테스트 삭제 |
| FE-15 | 면접 회차 시트 — 추가·수정 | 4 | FE-05, FE-03 | BE-15 | 무변경 |
| FE-16 | 지원 상세 화면 — 타임라인·면접 회차·시트 조립 | 5 | FE-14, FE-15, FE-05 | BE-14, BE-15 | 무변경 |
| FE-17 | 앱 셸 통합 — 내비게이션·404·에러 바운더리·실 API 통합·컨테이너 | 6 | FE-06~FE-16, FE-18 | BE-12~BE-19 | 갱신 — 화면 13종 두 모드 회귀, 애그리게이터 API 통합, 발견 탭 진입점 확인 |
Wave 너비 분포 (위상정렬 결과)
| wave | 티켓 | 너비 |
|---|---|---|
| 1 | FE-01 | 1 |
| 2 | FE-02, FE-03, FE-04 | 3 |
| 3 | FE-05, FE-06, FE-07, FE-08, FE-09, FE-18 | 6 |
| 4 | FE-10, FE-11, FE-12, FE-13, FE-14, FE-15 | 6 |
| 5 | FE-16 | 1 |
| 6 | FE-17 | 1 |
- 총 18건 / 6 wave / 평균 너비 3.0 / 최대 너비 6 (3차 갱신은 FE-18에 S-13·카테고리 API를 흡수 — 티켓 수·wave 구조 불변)
- 직선형 DAG(모든 wave 너비 1~2) 아님 — 분해 게이트 통과. FE-18을 wave 3에 추가해 fan-out을 5→6으로 넓혔습니다(애그리게이터 소스 등록은 FE-02·03·04에만 의존하는 독립 화면이라 기존 wave 구조를 깨지 않고 편입).
- wave 5·6이 너비 1인 이유: FE-16은 두 시트를 조립하는 컨테이너라 시트 완성이 선행이어야 하고, FE-17은 공통 파일(앱 셸·compose)을 수정하는 유일한 티켓이라 단독 배치가 규칙입니다(Single Writer per File).
BE와의 병렬성: MSW 목 핸들러(FE-04) 덕분에 FE-05~FE-18(화면 12건)은 BE-12~19 완료를 기다리지 않고 진행됩니다. 애그리게이터 API(POST /api/aggregator-sources·promotion)도 목으로 선구현하므로 FE-18·FE-09도 BE 무대기로 진행됩니다. BE 완료가 실제로 필요한 시점은 FE-17(실 API 통합 확인) 한 곳뿐입니다.
병목을 하나로 묶은 근거
- FE-01(스캐폴딩): 빌드 설정·Tailwind 설정·테마 토큰·API 클라이언트·DTO 타입·Query 규약·라우트 트리는 서로 결합돼 있습니다. 쪼개면 wave가 1→1→1 직렬 사슬이 되거나 같은 설정 파일에 머지 충돌이 납니다. “공통 계약 확립”을 한 책임으로 묶어 한 wave에 끝냅니다.
- FE-02·03·04를 쪼갠 근거: 서로 의존하지 않고(UI 프리미티브는 도메인을 모르고, 순수 로직은 UI를 모르고, 목 핸들러는 둘 다 모름) 다른 파일을 건드리므로 독립 병목입니다. 쪼개서 fan-out을 넓혔습니다.
- API DTO 타입을 FE-01에 둔 근거: FE-03(도메인 로직)과 FE-04(목 핸들러)가 둘 다 계약 타입을 필요로 합니다. FE-03에 두면 FE-04가 FE-03에 의존해 wave 2가 직렬화됩니다. BE TDD 계약이 이미 확정돼 있으므로 스캐폴딩 단계에서 한 번에 선언하는 것이 가능하고 옳습니다.
Single Writer per File 검증
라우터 파일(src/routes.tsx)을 FE-01이 전 라우트 lazy 선언 + 스텁 페이지 생성으로 완결하므로, 이후 화면 티켓은 자기 스텁 파일만 대체합니다 → 라우터 파일 수정 0건. 이것이 wave 3·4의 넓은 fan-out을 가능하게 하는 핵심 장치입니다.
| wave | 티켓 | 소유 파일 집합 | 교집합 |
|---|---|---|---|
| 2 | FE-02 | src/components/ui/** | ∅ |
| 2 | FE-03 | src/constants/**, src/utils/**, src/types/domain.ts | ∅ |
| 2 | FE-04 | src/mocks/** | ∅ |
| 3 | FE-05 | src/components/domain/** | ∅ |
| 3 | FE-06 | src/pages/operations/**, src/api/operations.ts, src/hooks/operations/** | ∅ |
| 3 | FE-07 | src/pages/matching/**, src/api/matching.ts, src/hooks/matching/** | ∅ |
| 3 | FE-08 | src/pages/company/RegisterCompanyPage*, src/api/company/discovery.ts, src/api/company/registration.ts, src/hooks/company/useSourceDiscovery.ts, useRegisterCompany.ts | ∅ |
| 3 | FE-09 | src/pages/company/CompanyListPage*, src/api/company/query.ts, src/api/company/promotion.ts, src/hooks/company/useCompanies.ts, useCompanyPromotion.ts | ∅ |
| 3 | FE-18 | src/pages/aggregator/**, src/api/aggregator.ts, src/hooks/aggregator/** | ∅ |
| 4 | FE-10 | src/pages/posting/JobPostingListPage*, src/api/posting/list.ts, src/hooks/posting/useJobPostings.ts | ∅ |
| 4 | FE-11 | src/pages/posting/JobPostingDetailPage*, src/api/posting/detail.ts, src/api/application/create.ts, src/hooks/posting/useJobPosting.ts, src/hooks/application/useCreateApplication.ts | ∅ |
| 4 | FE-12 | src/pages/posting/ManualJobPostingPage*, src/api/posting/manualRegistration.ts, src/hooks/posting/useRegisterManualJobPosting.ts | ∅ |
| 4 | FE-13 | src/pages/application/ApplicationListPage*, src/api/application/list.ts, src/hooks/application/useApplications.ts | ∅ |
| 4 | FE-14 | src/components/application/TransitStatusSheet.tsx, src/api/application/transition.ts, src/hooks/application/useTransitApplicationStatus.ts | ∅ |
| 4 | FE-15 | src/components/application/InterviewSheet.tsx, src/api/application/interview.ts, src/hooks/application/useAddInterview.ts, useUpdateInterview.ts | ∅ |
| 5 | FE-16 | src/pages/application/ApplicationDetailPage*, src/api/application/detail.ts, src/hooks/application/useApplication.ts | ∅ (단독) |
| 6 | FE-17 | src/components/layout/**, src/pages/NotFoundPage.tsx, web/Dockerfile, 루트 docker-compose.yml·docker-compose.prod.yml | ∅ (단독) |
주의 지점 4건과 해소 방법
| 위험 | 해소 |
|---|---|
| FE-08·FE-09가 둘 다 회사 API를 다룸 (같은 wave 3) | src/api/company/를 용도별 파일로 분리 — 탐색·등록은 FE-08(discovery.ts·registration.ts), 조회·승격은 FE-09(query.ts·promotion.ts). 파일이 다르므로 디렉토리 공유는 충돌이 아닙니다. 훅도 동일하게 분리 |
| FE-09(승격) · FE-18(애그리게이터)이 둘 다 발견 회사 개념을 다룸 (같은 wave 3) | 파일이 완전히 분리됩니다 — 승격은 company/promotion.ts, 애그리게이터는 aggregator.ts. companyOrigin DTO 타입은 FE-01이 이미 소유하므로 두 티켓이 타입을 각자 만들지 않습니다 |
FE-11·FE-14·FE-15가 모두 src/api/application/ 아래를 씀 (같은 wave 4) | 디렉토리는 공유하되 파일이 다릅니다(create.ts / transition.ts / interview.ts). 디렉토리 공유는 충돌이 아닙니다 |
FE-17이 BE-01 소유의 루트 docker-compose.yml을 수정 | BE wave와 FE 마지막 wave는 시간축이 완전히 분리됩니다. FE-17이 FE 측 유일한 compose 작성자이며 티켓 본문에 명시했습니다 |
공용 확장의 파일 소유 재확인: 2차 갱신으로 여러 티켓이 갱신되지만 파일 소유 경계는 그대로입니다 — 공유되는 것은 types/api.ts(FE-01)·components/domain/**(FE-05)·components/ui/**(FE-02)이고, 이들은 각 소유 티켓이 선행 wave에서 완결한 뒤 후행이 소비만 하므로 같은 wave 내 동시 수정이 발생하지 않습니다.
시나리오 커버리지 (자가 점검)
PRD 유저 시나리오 → 화면 → 티켓
| PRD 시나리오 | FE가 담당하는 부분 | 화면 | 티켓 | 누락 |
|---|---|---|---|---|
| 1 — 회사 등록 (해피) | 회사명 입력 → 후보 탐색 → 샘플 3건 미리보기 → 확정 | S-02 | FE-08 | 없음 |
| 2 — 일일 수집과 신규 공고 알림 | 배치·알림은 BE. FE는 ① 수집 결과 조회 ② 수집 실행 이력 확인 ③ 시딩이라 첫 알림이 없다는 사실 고지 | S-03, S-11, S-02(토스트) | FE-10, FE-06, FE-08 | 없음 |
| 3 — 지원 및 상태 추적 (P0 최소 경로) | 지원 기록 생성 → 상태 전이 + 이력 → 면접 회차 → REJECTED 탈락 단계 기록 | S-04, S-07, S-08, S-09, S-06 | FE-11, FE-16, FE-14, FE-15, FE-13 | 없음 |
| 4 — 예외: 소스 고장 | 홈의 고장 배지 + 운영 화면의 실패 이력·가드 상태 표기 | S-01, S-11 | FE-09, FE-06 | 없음 |
| 5 — 예외: 매핑 후보 0건 | ”수동 등록 전용으로 저장” 분기 → 수동 공고 등록 | S-02(3b), S-05 | FE-08, FE-12 | 없음 |
| 6 — 예외: 로그인 필요 공고 발견 | 접근 제한 배지·배너(P0에서는 알림 대신 배지) → 수동 등록 | S-03, S-04, S-05 | FE-10, FE-11, FE-12 | 없음 |
| 7 — 예외: 알림 발송 실패 | 알림에 의존하지 않는 확인 수단 — 발송 실패 이력 조회 + 재발송 정책 안내 | S-11 | FE-06 | 없음 |
| 9 — 애그리게이터에서 미등록 회사 발견 | 애그리게이터 소스 등록 → 발견 탭에서 발견 회사 확인 → 관심/발견 구분 조회 | S-12, S-01(발견 탭) | FE-18, FE-09 | 없음 |
| 9.5 — 발견 회사 승격 | 발견 탭 카드의 인라인 [관심 등록] → 관심 탭으로 이동 | S-01(발견 탭) | FE-09 | 없음 |
| 10 — 크로스 소스 중복 | 목록은 대표 공고만 노출 + 🔗 N곳 게재 칩, 상세에서 alternateSources[] 출처 목록 | S-03, S-04 | FE-10, FE-11 | 없음 |
누락 0건. 시나리오 2·9의 배치·수집·자동 등록·일일 요약 발송, 시나리오 4의 고장 알림, 시나리오 10의 대표 선정 판정은 전부 BE 담당이며, FE는 그 결과를 조회·표시합니다. 발견 회사 일일 요약 알림(FR-62)은 디스코드 채널로 나가므로 FE 화면이 없습니다(인지만).
FE가 담당하는 P0 요구사항 → 티켓
| FR | 내용 | 티켓 |
|---|---|---|
| FR-1, 2, 4, 5 | 회사명 입력·후보 탐색(프로빙+URL)·샘플 3건·복수 소스 확정·후보 0건 → 수동 전용 | FE-08 |
| FR-18 | CLOSED 소프트 삭제 → 마감 탭에서 사유와 함께 조회 | FE-10 |
| FR-20, 48 | 수동 공고 등록 (로그인 필요·자소서 필수·후보 0건 회사) | FE-12 |
| FR-21 | 수동 공고도 동일한 지원 관리 | FE-11, FE-16 |
| FR-22, 23, 26 | 직무 동의어 그룹·제외어·근무형태 키워드 등록 | FE-07 |
| FR-25 | 매칭 실패 공고도 조회 대상 + 기준 변경 시 재평가 | FE-10, FE-07 |
| FR-29 | 근무형태 근거·확신도 함께 표시 | FE-11, FE-05 |
| FR-30 | CONFIRMED·LIKELY만 정렬 기준 | FE-10, FE-03 |
| FR-33 | 근무형태를 필터로 쓰지 않음 | FE-10 |
| FR-34 | UNKNOWN → “근무형태 정보 없음” 표기 | FE-05 |
| FR-42 | ”미지원 = 기록 미존재” (별도 상태값 없음) | FE-11, FE-13 |
| FR-43, 44, 45 | 상태 전이 규칙 — 허용 전이만 제시, 종료 상태 전이 불가 | FE-14 |
| FR-46 | 면접 회차 = 별도 기록 (회차·레이블·일정·결과) | FE-15 |
| FR-47 | 전이 이력 타임라인 | FE-16 |
| FR-50 | 회사 단위 분류 조회 | FE-09, FE-10, FE-13 |
| FR-60 | 애그리게이터 소스 등록·관리 (플랫폼 + 검색 조건, 목록·소프트 삭제) | FE-18 |
| FR-61 | 관심/발견 회사 구분 표시 + 발견 회사 승격 | FE-09 |
| FR-64 | 크로스 소스 대표 공고 노출 + 상세의 다른 출처 표기 | FE-10, FE-11, FE-05 |
| Operations | 수집 이력·알림 실패 이력 조회 | FE-06 |
| NFR-5 | 삭제 UI 미제공 (무기한 보존) | FE-09·10·13·16 (전 화면 공통 규칙) |
| NFR-7 | 인증 화면 없음 | 전 티켓 (로그인·회원가입 미구현) |
FE 화면이 없는 P0 요구사항 (전부 BE 담당이며 의도적 제외): FR-617·19(수집·변경·마감 감지 배치), FR-24(키워드 정규화), FR-27·28·32(근무형태 추출 로직), FR-3541(알림 발송·멱등·재시도), FR-62(발견 회사 일일 요약 발송 — 디스코드 채널), FR-63·65(크로스 소스 판정·중복 알림 억제 — 서버 dedup 배치), FR-66·67·68·69(애그리게이터 저장·마감일 정규화·변경 감지·규약 준수 — 어댑터·수집 로직). FR-31(P2)·FR-49·FR-51·FR-52(P1·P2)는 이번 범위 밖으로 확장 지점만 표시했습니다.
BE에 요청할 계약 변경 목록
1차 요청 9건 — BE 응답 반영 완료
| # | 요청 | BE 응답 | FE 반영 |
|---|---|---|---|
| 1 | GET /api/matching/criteria 신설 | 수용 | S-10 blocking 해소, 실 API 완주 (useMatchCriteria) |
| 2 | 제외어·근무형태 키워드 DELETE 신설 | 수용 (소프트 삭제) | S-10 삭제 흐름 실 API 완주 |
| 3 | 전 회사 통합 공고 조회 | 미채택 (P1 보류) | 회사 우선 내비게이션 유지, “전체 공고” 탭 없음 |
| 4 | 공고 목록 응답 필드 명세 | 수용 — applicationId | null로 확정(≠applied) | FE-04 목·types/api.ts·FE-10을 applicationId != null로 |
| 5 | GET /api/companies에 고장 필드 | 수용 — brokenSourceCount(개수) | S-01 배지 조건 brokenSourceCount > 0 |
| 6 | re-evaluations {force?} | 수용 — 기본 false + 응답 skippedCount | S-10 “다시 확인하기” force 미전송, skip 건수 표기 |
| 7 | allowedNextStatuses[] | 수용 — 상세·전이 응답 포함 | FE-14 전이 맵 상수 → 폴백 격하, 동기화 테스트 삭제. 409 방어선 유지 |
| 8 | 지원 목록 필드(companyName·jobPostingTitle) | 수용 | S-06 blocking 해소 |
| 9 | collection-runs 필드 | 부분 수용 — abnormal 채택, guardApplied 제거 | FE-06에서 guardApplied 참조 제거, abnormal 단일 필드 |
2차 요청 3건 — 애그리게이터 편입 (BE 최종 확정으로 전부 수용)
| # | 요청 | BE 응답 | FE 반영 |
|---|---|---|---|
| 10 | 애그리게이터 소스 목록 조회·삭제 | 수용 — GET /api/aggregator-sources(활성·비활성 함께), DELETE /api/aggregator-sources/{id}(소프트 삭제) | S-13 소스 관리 화면 신설(FE-18) — 비활성 소스는 disabled=true로 흐리게. 진입은 홈 발견 탭 헤더 [발견 소스 관리] |
| 11 | alternateSources[] shape | 수용 — {jobSourceId, platform, postingUrl, isRepresentative}, 대표 자신 포함한 그룹 전체 반환 | S-04에서 isRepresentative=true를 “(대표)“로 강조, 나머지는 링크. sourceLabel 없어 platform을 라벨로 매핑(FE-11·FE-03 platformLabel) |
| 12 | 카테고리 코드 목록 | 수용 (상수화 금지로 확정) — GET /api/aggregator-sources/categories?platform= → {categories:[{code, label}]} | FE 상수화 폐기(드리프트 방지). S-12 카테고리 Select를 API로 채우고, 플랫폼 변경 시 재조회(의존 쿼리). FE-03의 aggregatorCategories 상수 제거 |
모든 계약 요청(1차 9건 + 2차 3건)이 확정됐습니다. 추가 계약 요청 없음. FE-04 목 핸들러·
types/api.ts가 확정된 shape를 반영하고, FE-17이 실 API와의 최종 정합화를 담당합니다.
Open Questions
| # | 항목 | 현재 처리 | 확정 필요 시점 |
|---|---|---|---|
| 1 | 회사 수정·삭제 | BE 계약에 없어 P0 범위 밖으로 두고 UI를 만들지 않았습니다. 잘못 등록한 회사는 그대로 남습니다 | 사용자가 오등록을 실제로 겪으면 P1에서 PATCH/DELETE /api/companies/{id} 요청 |
| 2 | 소스 추가·비활성화 | 동일하게 계약이 없어 미설계. 회사 등록 시점의 소스 확정이 유일한 입력입니다 | 소스 교체 필요가 관찰되면 P1 |
| 3 | 데스크톱 레이아웃 밀도 | 모바일 폭(최대 480px 중앙 정렬) 기준으로 설계하고 ≥md에서는 상단 탭 + 동일 폭 유지로 처리했습니다. 넓은 화면의 2단 레이아웃은 만들지 않습니다 | 사용자가 데스크톱에서 정보 밀도 부족을 느끼면 재검토 |
| 4 | 근무형태 정렬의 사용자 인지 | sort=WORK_ARRANGEMENT가 “확신도 높은 것 먼저”임을 정렬 셀렉트 라벨(“근무형태 확실한 순”)로만 표현했습니다 | 사용 후 혼란이 있으면 셀렉트 하단 설명 추가 |
| 5 | 토스트 vs 인라인 에러 기준 | 입력 필드와 직접 연관된 실패는 인라인, 그 외는 토스트로 통일했습니다 | 예외 사례 발견 시 |
| 6 | 발견 회사 대량 시 지원 관리 | 발견 회사(수백 곳)의 공고도 승격 없이 지원 기록을 남길 수 있는지 — 현재는 가능(공고 상세 진입 → 지원 기록). 다만 발견 회사 공고는 회사 상세로만 접근하고 홈 관심 탭엔 안 보입니다 | 발견 회사 공고에 지원하는 흐름이 잦아지면 접근 경로 재검토 |
| 7 | 크로스 소스 오판정 정정 | 대표 선정이 틀렸을 때 사용자가 수동 정정하는 기능은 P0 범위 밖(PRD FR-65 명시 — 2단계 검토). 1단계는 자동 판정 결과만 표시 | P1 |
| 8 | 애그리게이터 카테고리 UX | 플랫폼별 카테고리를 한글 라벨 Select로 제시하되, 카테고리가 수십 개면 검색 가능한 Select가 필요할 수 있습니다. 1단계는 네이티브 Select | 카테고리 수가 많아 선택이 불편하면 재검토 |
Document History
| 날짜 | 변경 내용 |
|---|---|
| 2026-07-22 | 4차 갱신 (회색지대 애그리게이터 P0 편입) — S-12 플랫폼 선택지 2→6종(사람인·점핏 + 원티드·리멤버·잡코리아·서핏), 6개라 세그먼트→Select. platformLabel 4종 라벨 추가. 회색지대 4종 선택 시 warning 안내 배너(청정 2종과 시각 구분, isGrayZonePlatform 판정). MSW 플랫폼 목록·카테고리 픽스처에 4종 추가. 카테고리는 API 동적 구성이라 카테고리 코드 변경 없음 — 플랫폼 목록만 확장. 화면 수·티켓 수·wave 구조 불변(FE-03·04·18에 흡수) |
| 2026-07-22 | 3차 갱신 (BE 최종 확정) — ① 카테고리 Select를 GET /api/aggregator-sources/categories?platform= API로 채움(FE 상수화 폐기, 드리프트 방지) ② S-13 애그리게이터 소스 관리 화면 신설(GET /api/aggregator-sources·DELETE(소프트 삭제), 비활성 소스 흐리게), 진입점을 발견 탭 헤더 [발견 소스 관리]로 변경(S-13이 S-12의 부모) ③ alternateSources[] shape 확정({jobSourceId, platform, postingUrl, isRepresentative}, 대표 포함 그룹 전체, platform→라벨 매핑). 2차·3차 계약 요청 12건 전부 확정 — 추가 요청 없음. 화면 13→14, 티켓 수·wave 구조 불변(FE-18에 흡수) |
| 2026-07-22 | 2차 갱신 — ① BE 계약 응답 반영(수용 7·부분 수용 1·미채택 1): 매칭 기준 조회·키워드 DELETE 신설로 S-10 blocking 해소, applied→applicationId, brokenSourceCount, skippedCount, guardApplied 제거, allowedNextStatuses 서버 우선 ② 애그리게이터 P0 편입: 방안 8(관심/발견 위계) 신설, S-01 홈에 관심/발견 세그먼트·페이지네이션·인라인 승격, S-12 애그리게이터 소스 등록 화면 신설, S-03 크로스 소스 칩·S-04 alternateSources 박스, 신규 티켓 FE-18 추가(wave 3, 너비 5→6), 시나리오 9·9.5·10 커버리지 추가, 2차 계약 요청 3건(#10~#12). 화면 11→13, 티켓 17→18 |
| 2026-07-22 | 최초 작성 — PRD 1단계(P0) 대상 FE 웹 설계. 화면 11개, 테마 토큰 라이트/다크 매핑, 상태관리 4분류, 라우트 스텁 기반 Single Writer 장치, BE 계약 변경 요청 9건 확정 |