공고알림앱 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)

#반영
1GET /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 카드 식별 가능
5GET /api/companiesbrokenSourceCount(불리언 아닌 개수) → S-01 소스 고장 배지는 brokenSourceCount > 0 조건
6POST /api/matching/re-evaluations {force?}(기본 false) + 응답 skippedCount → S-10 “다시 확인하기”는 force 미전송, skip 건수로 “왜 0건인지” 설명
7collection-runs에서 guardApplied 제거, abnormal 단일 필드 → FE-06에서 guardApplied 참조 제거
8상세·전이 응답에 allowedNextStatuses[] 포함 → FE-14의 전이 맵 상수는 폴백으로 격하(서버 값 우선), 동기화 주석·검증 테스트 삭제 가능. 409 방어선은 유지
9전 회사 통합 공고 조회는 P1 보류 → 회사 우선 내비게이션 유지(“전체 공고” 탭 없음)

2차 갱신 반영 — 애그리게이터 P0 편입 (B)

항목FE 반영
회사 축 companyOriginWATCHED(관심 — 사용자 직접 등록) / DISCOVERED(발견 — 애그리게이터 자동 등록). registrationType(AUTO/MANUAL_ONLY)과 직교하는 별도 축
관심/발견 위계 (핵심)이 앱의 본질은 “타깃 구직(관심 회사)“입니다. 홈 기본 뷰는 관심 회사, 발견 회사(수백 곳)는 세그먼트 탭 + 페이지네이션으로 분리해 잠식을 막습니다 (설계 “방안 8”)
신규 화면 S-12·S-13S-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/apihttp://localhost:8080. CORS 설정을 BE에 추가할 필요가 없습니다(BE 무수정). 배포: web/Dockerfile(빌드 → nginx 서빙) + 루트 docker-compose.ymlweb 서비스 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}/promotionFR-50,61 · 시나리오 9.5FE-09
S-02회사 등록 (위저드 3스텝)/companies/newPOST /api/companies/source-discoveries, POST /api/companiesFR-1,2,4,5 · 시나리오 1·5FE-08
S-03회사 상세 = 공고 목록/companies/:companyIdGET /api/companies/{id}/job-postingsFR-50,30,33,34,25,18,64FE-10
S-04공고 상세/job-postings/:jobPostingIdGET /api/job-postings/{id}, POST /api/applicationsFR-29,34,42,21,64FE-11
S-05수동 공고 등록/job-postings/new?companyId=POST /api/job-postings/manual-registrationsFR-20,48 · 시나리오 5·6FE-12
S-06지원 목록/applicationsGET /api/applications?companyId=FR-50,42FE-13
S-07지원 상세 (타임라인)/applications/:applicationIdGET /api/applications/{id}FR-47,46,43FE-16
S-08상태 변경 시트(S-07 내 시트)POST /api/applications/{id}/status-transitionsFR-43,44,45,47FE-14
S-09면접 회차 시트 (추가·수정)(S-07 내 시트)POST/PATCH .../interviewsFR-46FE-15
S-10매칭 설정/settings/matchingPOST/DELETE /api/matching/**, POST /api/matching/re-evaluationsFR-22,23,26,25FE-07
S-11운영/operationsGET /api/operations/collection-runs, .../notification-dispatchesOperations · 시나리오 4·7FE-06
S-12애그리게이터 소스 등록/aggregator-sources/newPOST /api/aggregator-sources, GET .../categories?platform=FR-60 · 시나리오 9FE-18
S-13애그리게이터 소스 관리 (목록·소프트 삭제)/aggregator-sourcesGET /api/aggregator-sources, DELETE /api/aggregator-sources/{id}FR-60FE-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
loadingStep 2 전면 로딩. 취소 불가로 두지 않습니다 — 헤더 로 Step 1 복귀 가능(진행 중 요청은 AbortController로 취소)
emptyStep 3b (후보 0건). 빈 결과가 막다른 길이 아니라 분기가 되도록 두 개의 탈출구(URL 재시도 / 수동 등록 전용 저장)를 제공
error탐색 실패: “채용 페이지를 확인하는 중 문제가 생겼어요” + [다시 시도] + [수동 등록 전용으로 저장]. 등록 실패(409 중복 회사명): 필드 하단 인라인 에러 “이미 등록된 회사예요” + 해당 회사로 이동하는 링크
success등록 완료 → /companies/{id}로 replace 이동 + 토스트 “당근을 등록했어요. 오늘 자정부터 공고를 확인해요” (첫 수집이 시딩이라 알림이 안 온다는 사실을 미리 알림 — FR-10)

상태 관리: 위저드 스텝·선택된 후보는 라우트 지역 상태(useReducer)입니다. 전역 승격하지 않는 근거 — 이 라우트를 벗어나면 폐기되는 것이 옳고(중단된 등록이 되살아나면 혼란), 다른 화면이 참조하지 않습니다. 탐색 결과 자체는 Query 캐시(mutation 결과를 useState가 아니라 useMutationdata로 보유)에 둡니다.


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로 처리)
error404: “공고를 찾을 수 없어요” + [회사 목록으로]. 그 외: 전체 에러 대체 + [다시 시도]
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: “아직 면접 회차가 없어요” + [+ 추가]
error404: “지원 기록을 찾을 수 없어요” + [지원 목록으로]. 그 외 전체 에러 대체
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 자체를 렌더하지 않음
error409(전이 불가·낙관적 잠금 충돌) → 시트 유지 + “상태가 이미 바뀌었어요. 최신 정보를 불러왔어요” + 상세 자동 refetch → 선택지 갱신. 그 외 → 토스트
success시트 닫힘 + 상세 갱신 + 토스트 “‘처우협의’로 변경했어요”

S-09 면접 회차 시트

┌──────────────────────────────────────────────┐
│              ▬▬▬                             │
│  면접 회차 추가                               │
│                                              │
│  회차                                        │
│  ┌────────────────────────────────────────┐  │
│  │ 2                                      │  │  ← 기존 최대 회차 + 1 자동 채움
│  └────────────────────────────────────────┘  │
│                                              │
│  이름                                        │
│  ┌────────────────────────────────────────┐  │
│  │ 임원면접                               │  │  ← 자유 입력 (회사마다 다름)
│  └────────────────────────────────────────┘  │
│                                              │
│  일정 (선택)                                 │
│  ┌────────────────────────────────────────┐  │
│  │ 아직 안 정해짐                          │  │  ← 일정 미정 상태로 먼저 등록 가능
│  └────────────────────────────────────────┘  │
│                                              │
│           [  추가하기  ]                     │
└──────────────────────────────────────────────┘

수정 모드(PATCH)에서는 회차·이름이 읽기 전용이 되고 일정·결과(예정/합격/불합격/취소)·메모만 편집합니다. 결과를 기록해도 지원 상태는 자동 전이되지 않습니다(BE-15 설계 의도) — 시트 하단에 “결과를 기록해도 지원 상태는 바뀌지 않아요. 상태는 직접 변경해 주세요” 보조 문구로 명시합니다.

상태UI
loading제출 중 CTA 스피너
empty해당 없음 (입력 시트)
error409(회차 중복) → 회차 필드 인라인 에러 “이미 있는 회차예요”. 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-inverseaccent 버튼 위 텍스트#FFFFFF#0F1115

강조(accent) — 화면당 1곳 원칙

토큰용도라이트다크
--accent주 CTA 배경·활성 탭#3182F6#4E92F7
--accent-pressed눌림 상태#1B64DA#3B7FE0
--accent-subtle상태 칩·배너 배경#EAF2FE#17263B
--accent-on-subtlesubtle 위 텍스트#1B64DA#8FBBFB

의미 색

토큰용도라이트다크
--positive수집 성공·면접 합격#00A661#2ECC80
--positive-subtle위의 배경#E5F7EF#10291E
--warning0건 회차·접근 제한#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(Zustand persist). 플래시 방지를 위해 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, 수동 등록의 companyIduseSearchParams뒤로가기·새로고침·링크 공유가 자연히 동작합니다. 전역 스토어에 두면 이 넷이 전부 깨집니다. 발견 회사 페이지네이션도 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-01GET /api/companies?companyOrigin=&page=&size=useCompaniesBE-17목록 영역 에러 대체 + 재시도
S-01POST /api/companies/{id}/promotionuseCompanyPromotionBE-17토스트 + 목록 유지(승격 실패 시 재시도)
S-02POST /api/companies/source-discoveriesuseSourceDiscovery (mutation)BE-17전면 에러 + 재시도 + 수동 등록 전용 분기
S-02POST /api/companiesuseRegisterCompanyBE-17409 → 인라인 “이미 등록된 회사” + 해당 회사 링크
S-03GET /api/companies/{companyId}/job-postings?postingStatus=&sort=useJobPostingsBE-18목록 영역 에러 대체
S-04GET /api/job-postings/{id}useJobPostingBE-18404 전용 화면 / 그 외 에러 대체
S-04POST /api/applicationsuseCreateApplicationBE-14409 → 인라인 + 지원 현황 링크
S-05POST /api/job-postings/manual-registrationsuseRegisterManualJobPostingBE-18400 인라인 / 404 전면 / 5xx 토스트 + 입력 보존
S-06GET /api/applications?companyId=useApplicationsBE-14목록 영역 에러 대체
S-07GET /api/applications/{id}useApplicationBE-14 · BE-15404 전용 화면
S-08POST /api/applications/{id}/status-transitionsuseTransitApplicationStatusBE-14409 → 안내 + 자동 refetch → 선택지 갱신
S-09POST /api/applications/{id}/interviewsuseAddInterviewBE-15409(회차 중복) 인라인 / 409(종료 지원) 시트 닫고 refetch
S-09PATCH /api/applications/{id}/interviews/{interviewId}useUpdateInterviewBE-15토스트 + 시트 유지
S-10GET /api/matching/criteria (계약 응답 #1 신설됨)useMatchCriteriaBE-12전체 에러 대체
S-10POST /api/matching/keyword-groupsuseRegisterKeywordGroupBE-12낙관적 롤백 + 토스트
S-10DELETE /api/matching/keyword-groups/{id}useDeleteKeywordGroupBE-12낙관적 롤백 + 토스트
S-10POST /api/matching/exclusion-keywordsuseRegisterExclusionKeywordBE-12낙관적 롤백 + 토스트
S-10POST /api/matching/work-arrangement-keywordsuseRegisterWorkArrangementKeywordBE-12낙관적 롤백 + 토스트
S-10DELETE 제외어·근무형태 키워드 (계약 응답 #2 신설됨, 소프트 삭제)useDeleteKeywordBE-12동일
S-10POST /api/matching/re-evaluations ({force?} 미전송)useReEvaluateBE-13배너 유지 + skippedCount 포함 토스트. 진행 중 CTA 스피너
S-12GET /api/aggregator-sources/categories?platform=useAggregatorCategoriesBE-17(또는 신규)Select 자리 에러 + 재시도. 플랫폼 변경 시 재조회(의존 쿼리)
S-12POST /api/aggregator-sourcesuseRegisterAggregatorSourceBE-17(또는 신규)400 인라인 / 5xx 토스트 + 입력 보존
S-13GET /api/aggregator-sourcesuseAggregatorSourcesBE-17(또는 신규)목록 영역 에러 대체 + 재시도
S-13DELETE /api/aggregator-sources/{id}useDeleteAggregatorSourceBE-17(또는 신규)낙관적 비활성 롤백 + 토스트
S-11GET /api/operations/collection-runs?jobSourceId=&days=30useCollectionRunsBE-19탭별 에러 대체
S-11GET /api/operations/notification-dispatches?dispatchStatus=FAILED&days=30useNotificationDispatchesBE-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.tsxFE-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 + MemoryRouter4상태 전부(loading/empty/error/success) + 주요 인터랙션
테마전 화면Testing Library + 토큰 린트.dark 적용 시 렌더 성공, 색 하드코딩 0건

화면별 필수 테스트 케이스 (티켓의 TDD 입력)

각 티켓 문서에 개별 기재합니다. 공통 규칙은 아래와 같습니다.

유형모든 데이터 화면이 반드시 포함
해피데이터가 있을 때 핵심 정보가 보인다
empty0건일 때 안내 문구와 다음 행동 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
8CLOSED 공고진행 중 탭에 없고 마감 탭에서 마감 사유와 함께 조회된다FE-10
9지원 상태 OFFERED시트에 ACCEPTED·OFFER_DECLINED만 있고 WITHDRAWN없다FE-14
10지원 상태 종료(ACCEPTED)“상태 변경” CTA가 렌더되지 않는다FE-16
11REJECTED 선택“‘면접’ 단계에서 불합격으로 기록돼요” 문구가 현재 상태에 따라 바뀐다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
24brokenSourceCount > 0”소스 N개 고장” 배지가 렌더되고, 0이면 배지가 없다FE-05/09
25공고 목록 지원 여부applicationId != null이면 지원함 배지, null이면 배지 없음FE-10
26alternateSourceCount > 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 병렬이 성립합니다.

단계내용전환 조건롤백
0FE-01 머지 — 스캐폴딩 + 전 라우트 스텁npm run build + tsc --noEmit + lint exit 0브랜치 미머지
1wave 2~5 화면 티켓 머지 — 스텁이 하나씩 실제 화면으로 대체티켓별 테스트·타입체크 통과 + 리뷰 APPROVED/COMMENT해당 티켓 revert(스텁으로 복귀 — 다른 화면 무영향)
2FE-17 머지 — 내비게이션·404·에러 바운더리 + 실 API 통합 확인BE-12~19 머지 완료 + 두 모드 시각 확인revert
3루트 docker-compose.ymlweb 서비스 추가 (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제목waveFE 의존BE 의존2차 갱신
FE-01웹 스캐폴딩 · 테마 토큰 · API 클라이언트 · 라우트 스텁1갱신 — DTO 타입(companyOrigin·brokenSourceCount·sourceType·alternateSource·applicationId·allowedNextStatuses·skippedCount·aggregator), /aggregator-sources/new 스텁, guardApplied 제거
FE-02공용 UI 프리미티브2FE-01무변경 (Pagination 프리미티브 추가)
FE-03도메인 순수 로직 — 상태 라벨·전이 맵·확신도 규칙·포매터·검증2FE-01갱신 — 전이 맵은 서버 allowedNextStatuses 폴백으로 격하, 애그리게이터 카테고리 상수 추가
FE-04MSW 목 핸들러 · 픽스처 (BE 계약 선구현)2FE-01갱신 — 신규 엔드포인트(criteria GET·키워드 DELETE·promotion·aggregator-sources)·companyOrigin·페이지네이션·applicationId·alternateSources·sourceType 픽스처
FE-05도메인 표시 컴포넌트 — 확신도 라벨·상태 칩·공고 카드·크로스 소스 칩3FE-02, FE-03갱신 — CrossSourceChip 추가, AppliedBadge는 applicationId 기반
FE-06운영 화면 — 수집 이력 · 알림 발송 실패3FE-02·03·04BE-19갱신 — guardApplied 참조 제거, abnormal 단일 필드
FE-07매칭 설정 화면 — 키워드·제외어·근무형태·재평가3FE-02·03·04BE-12, BE-13갱신 — 실 API 완주(blocking 해소), 재평가 skippedCount 표기
FE-08회사 등록 위저드 — 탐색·후보 확정·후보 0건 분기3FE-02·03·04BE-17무변경
FE-09회사 목록 화면 (홈) — 관심/발견 세그먼트·페이지네이션·승격3FE-02·03·04BE-17갱신 — companyOrigin 세그먼트·발견 페이지네이션·인라인 승격·brokenSourceCount 배지
FE-18애그리게이터 소스 등록(S-12) · 관리(S-13) 화면3FE-02·03·04BE-17(또는 신규)신규(3차: S-13 관리 화면 + 카테고리 API 흡수 — wave·티켓 수 무변경)
FE-10공고 목록 화면 — 상태 세그먼트·정렬·매칭 강등·크로스 소스4FE-05BE-18갱신 — appliedapplicationId != null, sourceType·alternateSourceCount 표시
FE-11공고 상세 화면 · 지원 기록 생성 시트 · 크로스 소스 출처4FE-05BE-18, BE-14갱신 — AlternateSourcesBox 추가
FE-12수동 공고 등록 화면4FE-05BE-18무변경
FE-13지원 목록 화면4FE-05BE-14무변경 (companyName·jobPostingTitle 확정)
FE-14지원 상태 변경 시트 — 허용 전이만 제시4FE-05, FE-03BE-14갱신 — 서버 allowedNextStatuses 우선, 상수 동기화 테스트 삭제
FE-15면접 회차 시트 — 추가·수정4FE-05, FE-03BE-15무변경
FE-16지원 상세 화면 — 타임라인·면접 회차·시트 조립5FE-14, FE-15, FE-05BE-14, BE-15무변경
FE-17앱 셸 통합 — 내비게이션·404·에러 바운더리·실 API 통합·컨테이너6FE-06~FE-16, FE-18BE-12~BE-19갱신 — 화면 13종 두 모드 회귀, 애그리게이터 API 통합, 발견 탭 진입점 확인

Wave 너비 분포 (위상정렬 결과)

wave티켓너비
1FE-011
2FE-02, FE-03, FE-043
3FE-05, FE-06, FE-07, FE-08, FE-09, FE-186
4FE-10, FE-11, FE-12, FE-13, FE-14, FE-156
5FE-161
6FE-171
  • 총 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티켓소유 파일 집합교집합
2FE-02src/components/ui/**
2FE-03src/constants/**, src/utils/**, src/types/domain.ts
2FE-04src/mocks/**
3FE-05src/components/domain/**
3FE-06src/pages/operations/**, src/api/operations.ts, src/hooks/operations/**
3FE-07src/pages/matching/**, src/api/matching.ts, src/hooks/matching/**
3FE-08src/pages/company/RegisterCompanyPage*, src/api/company/discovery.ts, src/api/company/registration.ts, src/hooks/company/useSourceDiscovery.ts, useRegisterCompany.ts
3FE-09src/pages/company/CompanyListPage*, src/api/company/query.ts, src/api/company/promotion.ts, src/hooks/company/useCompanies.ts, useCompanyPromotion.ts
3FE-18src/pages/aggregator/**, src/api/aggregator.ts, src/hooks/aggregator/**
4FE-10src/pages/posting/JobPostingListPage*, src/api/posting/list.ts, src/hooks/posting/useJobPostings.ts
4FE-11src/pages/posting/JobPostingDetailPage*, src/api/posting/detail.ts, src/api/application/create.ts, src/hooks/posting/useJobPosting.ts, src/hooks/application/useCreateApplication.ts
4FE-12src/pages/posting/ManualJobPostingPage*, src/api/posting/manualRegistration.ts, src/hooks/posting/useRegisterManualJobPosting.ts
4FE-13src/pages/application/ApplicationListPage*, src/api/application/list.ts, src/hooks/application/useApplications.ts
4FE-14src/components/application/TransitStatusSheet.tsx, src/api/application/transition.ts, src/hooks/application/useTransitApplicationStatus.ts
4FE-15src/components/application/InterviewSheet.tsx, src/api/application/interview.ts, src/hooks/application/useAddInterview.ts, useUpdateInterview.ts
5FE-16src/pages/application/ApplicationDetailPage*, src/api/application/detail.ts, src/hooks/application/useApplication.ts∅ (단독)
6FE-17src/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-02FE-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-06FE-11, FE-16, FE-14, FE-15, FE-13없음
4 — 예외: 소스 고장홈의 고장 배지 + 운영 화면의 실패 이력·가드 상태 표기S-01, S-11FE-09, FE-06없음
5 — 예외: 매핑 후보 0건”수동 등록 전용으로 저장” 분기 → 수동 공고 등록S-02(3b), S-05FE-08, FE-12없음
6 — 예외: 로그인 필요 공고 발견접근 제한 배지·배너(P0에서는 알림 대신 배지) → 수동 등록S-03, S-04, S-05FE-10, FE-11, FE-12없음
7 — 예외: 알림 발송 실패알림에 의존하지 않는 확인 수단 — 발송 실패 이력 조회 + 재발송 정책 안내S-11FE-06없음
9 — 애그리게이터에서 미등록 회사 발견애그리게이터 소스 등록 → 발견 탭에서 발견 회사 확인 → 관심/발견 구분 조회S-12, S-01(발견 탭)FE-18, FE-09없음
9.5 — 발견 회사 승격발견 탭 카드의 인라인 [관심 등록] → 관심 탭으로 이동S-01(발견 탭)FE-09없음
10 — 크로스 소스 중복목록은 대표 공고만 노출 + 🔗 N곳 게재 칩, 상세에서 alternateSources[] 출처 목록S-03, S-04FE-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-18CLOSED 소프트 삭제 → 마감 탭에서 사유와 함께 조회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-30CONFIRMED·LIKELY만 정렬 기준FE-10, FE-03
FR-33근무형태를 필터로 쓰지 않음FE-10
FR-34UNKNOWN → “근무형태 정보 없음” 표기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 반영
1GET /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
5GET /api/companies에 고장 필드수용brokenSourceCount(개수)S-01 배지 조건 brokenSourceCount > 0
6re-evaluations {force?}수용 — 기본 false + 응답 skippedCountS-10 “다시 확인하기” force 미전송, skip 건수 표기
7allowedNextStatuses[]수용 — 상세·전이 응답 포함FE-14 전이 맵 상수 → 폴백 격하, 동기화 테스트 삭제. 409 방어선 유지
8지원 목록 필드(companyName·jobPostingTitle)수용S-06 blocking 해소
9collection-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로 흐리게. 진입은 홈 발견 탭 헤더 [발견 소스 관리]
11alternateSources[] 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-224차 갱신 (회색지대 애그리게이터 P0 편입) — S-12 플랫폼 선택지 2→6종(사람인·점핏 + 원티드·리멤버·잡코리아·서핏), 6개라 세그먼트→Select. platformLabel 4종 라벨 추가. 회색지대 4종 선택 시 warning 안내 배너(청정 2종과 시각 구분, isGrayZonePlatform 판정). MSW 플랫폼 목록·카테고리 픽스처에 4종 추가. 카테고리는 API 동적 구성이라 카테고리 코드 변경 없음 — 플랫폼 목록만 확장. 화면 수·티켓 수·wave 구조 불변(FE-03·04·18에 흡수)
2026-07-223차 갱신 (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-222차 갱신 — ① BE 계약 응답 반영(수용 7·부분 수용 1·미채택 1): 매칭 기준 조회·키워드 DELETE 신설로 S-10 blocking 해소, appliedapplicationId, 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건 확정