[FE-24] 관심 공고 보관함 목록 · 필터 · 정렬
작업 내용 (설계 의도)
근거: 지원 관리 확장 FE 웹 설계 S-15 관심 공고 보관함 · 컴포넌트 트리의 /watchlist 서브트리 · 상태 관리(URL 상태) · API 연동 > 1단계의 S-15 행 · Single Writer per File 검증 > 단계 1 wave 2, 지원 관리 확장 TDD 1단계 — 교차 회사 공고 목록 (FR-78, NFR-12).
변경 사항
- 회사 경계를 가로지르는 공고 목록 화면을 만듭니다. 지금까지 공고에 도달하는 유일한 경로가 “회사 → 공고”뿐이라, 오늘 무엇을 결정해야 하는지 한 화면에서 볼 수 없었습니다.
api/posting/crossCompanyList.ts와hooks/watchlist/useCrossCompanyJobPostings.ts(useInfiniteQuery)를 신규로 둡니다. 페이지 전환은 무한 스크롤이 아니라hasNext일 때만 보이는[더 보기]버튼입니다 — 마감일 판단이 필요한 목록에서 자동 로드는 사용자가 위치를 잃게 합니다.- 필터·정렬·검색은 URL search params가 SSOT입니다. 뒤로가기와 링크 공유가 동작해야 하기 때문입니다. 단
page는 URL에 넣지 않습니다 —useInfiniteQuery가 소유합니다. - 회사 필터를 추가합니다(C-3).
companyId를 repeatable로 보내 OR 필터가 되게 하고, 선택지는 관심 회사(companyOrigin=WATCHED)를 위에, 발견 회사를 아래에 둡니다. 발견 회사는 수백 곳까지 늘어날 수 있어(NFR-1) 검색 가능한 목록으로 렌더합니다 — 칩을 수백 개 나열하면 시트가 목록 화면이 됩니다. - 정렬 기본값은
DISCOVERED_DESC고정입니다(B-2). dba 실측에서deadline_at의 71.4%(4,738/6,633)가 NULL이라DEADLINE_ASC는 인덱스 조기 종료가 불가능합니다 — 기본값으로 두면 첫 화면부터 전 구간 정렬이 걸립니다. 선택지 라벨 4종(최근 발견순 / 마감 임박순 / 제목순 / 우선순위순)은utils/watchStatus에서 가져옵니다. - “마감 임박순”(
DEADLINE_ASC)을 고르면 마감일 필터가 비어 있는 경우deadlineFrom = 오늘을 자동으로 채우고 필터 칩에 “마감일: 오늘 이후”를 표시합니다. 필터로 후보를 좁힌 뒤 정렬하는 경로로 유도하기 위해서입니다. 사용자가 지우면 그대로 두되(강제하지 않음), 마감일 없는 공고 4,738건이 뒤에 붙는다는 안내를 1줄 노출합니다. - 태그 필터를 추가합니다(D 확정).
tag를 repeatable로 보내 OR 필터가 되게 하고, 선택지는 실제로 등록된 태그만 노출합니다 — 자유 입력은 금지합니다. 오타 한 글자로 0건이 나오면 사용자는 “태그가 없는 것”과 “잘못 친 것”을 구별하지 못합니다. 태그는 관리 상태가 있어야 존재하므로watchedOnly=true를 함의하고,PRIORITY_DESC와 같은 1,000건 그룹 id 상한을 공유합니다(같은 해석 기계 재사용이라 새 상한·새 구조가 없습니다). watchedOnly잠금 규칙은PRIORITY_DESC와tag둘 다에 걸립니다(C-6·D). 서버가 미지정 시true로 강제하고 명시적false는 400JOB_POSTING_SORT_NOT_APPLICABLE이므로, 둘 중 하나라도 활성이면watchedOnly체크박스를 켜고 잠급니다(해제 불가 + “분류한 공고만 대상이에요” 안내). 둘 다 해제해야 잠금이 풀립니다 — 정렬만 되돌리고 태그가 남아 있는데 잠금을 풀면 사용자가 체크를 해제할 수 있고 그 즉시 400입니다. 이 잠금이 지켜지면JOB_POSTING_SORT_NOT_APPLICABLE은 발생하지 않습니다 — 발생하면 FE 버그입니다.- 빈 상태 3종을 서로 다른 문구로 구분합니다. 셋을 뭉뚱그리면 사용자가 “데이터가 없는 것”과 “필터를 잘못 건 것”을 구별하지 못합니다.
- 분류 전 0건(필터 없음) — “아직 보관함에 공고가 없어요” +
[회사 등록하기] - 세그먼트 0건(예:
관심탭) — “관심으로 분류한 공고가 없어요” +[전체 보기] - 필터 결과 0건 — “조건에 맞는 공고가 없어요” +
[필터 초기화]+ 활성 필터 요약
- 분류 전 0건(필터 없음) — “아직 보관함에 공고가 없어요” +
- 헤더에 세그먼트별 건수(
관심 12 · 지원 예정 3)를 표시하지 않습니다.PageResponse.totalCount는 현재 필터 기준 총계라 세그먼트별 건수를 알 수 없고, 클라이언트 집계는 페이지 로드분만 세어 거짓말이 됩니다. 현재 조건의totalCount만 “N건”으로 노출합니다. dedupGroupId === null이어도 분류할 수 있습니다(C-1 확정). 초안의 “[분류]버튼disabled”는 철회합니다 — BE가PUT /api/job-postings/{jobPostingId}/watch-state를 신설해 쓰기 시점에 단독 그룹을 만들어 주기 때문입니다. 저장 경로 분기(dedupGroupId유무)는useSaveWatchState훅이 흡수하므로 카드는 어느 경로인지 모릅니다 — 카드는[분류]버튼을 항상 활성으로 둡니다.matchScore·recommendation이null이면 해당 표시를 렌더하지 않습니다. 0점·“미평가” 같은 값으로 대체하지 않습니다 — 1단계 배포 시점에는 계약상 전부null이고, 0으로 채우면 “평가했는데 0점”으로 읽힙니다.- 목록에서도 매칭 여부는
matched로만 판단합니다. 교차 목록 항목에는matchScore·matched가 오지만matchThreshold·excludedByKeyword는 오지 않습니다(상세 전용) — 목록에서 점수 비교로 판정하는 코드가 생길 여지를 만들지 않기 위해, 판정은 항상utils/matchEvidence.ts#resolveMatchOutcome(FE-40 소유)을 경유합니다. 제외 키워드가 매칭을 이기므로 점수가 임계치 이상이어도matched: false인 공고가 존재합니다. - 카드 탭은 공고 상세(S-04)로,
[분류]버튼 탭은 관리 상태 시트로 갑니다. 두 영역의 이벤트 전파를 분리합니다. - 시트 연결은 이 티켓이 하지 않습니다.
WatchStatusChip·WatchPriorityMark(FE-25 소유)는 import만 하고 수정하지 않으며,WatchStateSheet실제 연결은 wave 3의 FE-29가 수행합니다 — 같은 wave에서components/watchlist/를 두 티켓이 건드리지 않기 위해서입니다. - 로딩 중에도 헤더·세그먼트·검색은 즉시 렌더합니다. 스켈레톤이 뜬 동안 필터를 조작할 수 있어야 합니다.
[더 보기]로딩은 기존 목록을 유지한 채 하단에 스켈레톤을 덧붙입니다. - 오류 시에도 필터 바를 유지합니다 — 조건을 바꿔 재시도할 수 있어야 합니다. 400은 확정 코드로 3분류해 안내합니다(A 확정 — C-7 수용으로 코드가 신설돼 블로커가 해제됐습니다).
WATCH_STATE_FILTER_LIMIT_EXCEEDED(PRIORITY_DESC또는tag의 대상 그룹이 1,000건 초과) → 응답의actualCount·limit으로 문구를 조립합니다: “관심 공고가 {actualCount}건이라 정렬할 수 없어요 · {limit}건 이하가 되도록 회사·플랫폼·마감일 필터로 좁혀 주세요” +[필터 열기]. 동시에 정렬을DISCOVERED_DESC로 자동 복귀시켜 화면이 비어 있지 않게 합니다 — 에러만 띄우고 목록을 비우면 사용자가 다음 행동을 할 근거를 잃습니다. 수치를 문구에 넣는 이유는 “얼마나 줄여야 하는지”가 안내에 있어야 사용자가 필터를 몇 개 걸지 판단하기 때문입니다.JOB_POSTING_SORT_NOT_APPLICABLE(PRIORITY_DESC·tag에 명시적watchedOnly=false) → 사용자 안내 대상이 아니라 클라이언트 버그입니다. 일반 오류 토스트 +console.error로깅으로 처리하고, “필터를 좁혀 주세요” 같은 사용자 행동 안내를 띄우지 않습니다 — 사용자가 할 수 있는 일이 없는데 행동을 요구하면 잘못된 귀인을 만듭니다. 위 잠금 규칙이 지켜지면 발생하지 않습니다.- 그 외 400(
VALIDATION_FAILED·BAD_REQUEST— enum 형식·size범위) → “필터 값이 올바르지 않아요” +[필터 초기화].
- 문구 조립은 컴포넌트가 아니라
utils/watchStatus.ts의 순수 함수가 담당합니다(no-logic-in-component). 화면은code와ApiError를 넘기고 문구를 받기만 합니다.ApiError의actualCount·limit은 FE-21이 소유하므로 이 티켓은 읽기만 하고 클래스를 수정하지 않습니다. 분기 기준은 항상code이며 메시지 문자열 매칭으로 분기하지 않습니다(취약).
의존
- FE-20 —
CrossCompanyJobPostingItem·CrossCompanyJobPostingSort·CrossCompanyJobPostingQuery(companyId: number[]·tag: string[]포함)·PageResponse<T>타입,['job-postings','cross-company', filters]queryKey, 목록 MSW 목(쿼리 반영 +WATCH_STATE_FILTER_LIMIT_EXCEEDED·JOB_POSTING_SORT_NOT_APPLICABLE시나리오),utils/watchStatus. - FE-21 —
ApiError의actualCount·limit. 이 티켓은 읽기만 하고api/client.ts를 수정하지 않습니다. - FE-22 —
/watchlist라우트와WatchlistPage스텁, 보관함 탭. - FE-20 —
WatchStatusChip·WatchPriorityMark를 import만 합니다(정의는 wave 1 소유, 이 티켓은 수정 금지). - BE-52 — 교차 회사 목록 API. 통합 검증은 BE-52 완료 후 수행합니다.
다이어그램
처리 흐름
sequenceDiagram participant User as 사용자 participant Page as WatchlistPage participant Params as useSearchParams participant Query as useCrossCompanyJobPostings participant Api as GET /api/job-postings User->>Page: 마감 임박순 선택 Page->>Params: sort=DEADLINE_ASC 반영 Params->>Query: 필터 객체 변경 Query->>Api: page=0 재조회 Api-->>Query: PageResponse + hasNext Query-->>User: 카드 목록 + 더 보기
컴포넌트 의존
flowchart LR Page[WatchlistPage] --> Hook[useCrossCompanyJobPostings] Hook --> Api[crossCompanyList] Page --> Segment[WatchStatusSegment] Page --> Search[WatchlistSearchField] Page --> Filter[WatchlistFilterSheet] Filter --> Company[회사 필터 companyId 다중] Filter --> Tag[태그 필터 tag 다중 · 등록 태그만] Page --> Sort[WatchlistSortSelect] Tag --> Lock[watchedOnly 잠금] Sort --> Lock Page --> Card[WatchlistCard] Card --> Chip[WatchStatusChip FE-25] Card --> Mark[WatchPriorityMark FE-25] Card --> Existing[DeadlineText · AppliedBadge] Page --> Util[utils/watchStatus]
테스트 케이스
- 목록 응답이 있으면 회사명·공고 제목·마감·플랫폼이 카드로 렌더되고
hasNext=true일 때만[더 보기]가 보인다. [더 보기]를 누르면 다음 페이지가 기존 목록 아래에 이어 붙고 기존 카드가 사라지지 않는다.- 필터·정렬·검색을 바꾸면 URL search params가 갱신되고, 그 URL로 직접 진입하면 같은 조건이 복원된다.
page는 URL search params에 포함되지 않는다.- 필터가 걸린 상태에서 0건이면 “조건에 맞는 공고가 없어요”가 보이고 “아직 보관함에 공고가 없어요”는 보이지 않는다.
- 필터 없이 0건이면 “아직 보관함에 공고가 없어요”가 보인다.
관심세그먼트에서 0건이면 세그먼트 전용 빈 문구와[전체 보기]가 보이고, 필터 결과 0건 문구는 보이지 않는다.dedupGroupId === null인 공고도[분류]버튼이 활성이고 비활성 사유 문구가 보이지 않는다.- 회사 필터에서 회사 2개를 선택하면 요청에
companyId가 repeatable로 2번 실린다. - 회사 필터 목록에서 관심 회사가 발견 회사보다 위에 노출되고, 검색어로 목록을 좁힐 수 있다.
- 정렬 기본값이
DISCOVERED_DESC이고, 화면 진입 시 요청에 그 값이 실린다. - 정렬을 “우선순위순”으로 바꾸면
watchedOnly체크박스가 자동으로 켜지고 잠겨 해제할 수 없으며, 요청에watchedOnly=true가 실린다. - 태그 필터에서 태그 2개를 선택하면 요청에
tag가 repeatable로 2번 실리고watchedOnly=true가 함께 실린다. - 태그 선택지에 등록된 태그만 노출되고 자유 입력 필드가 렌더되지 않는다.
- 태그를 1개라도 선택하면 정렬이 “우선순위순”이 아니어도
watchedOnly체크박스가 켜지고 잠긴다. - 정렬을 “우선순위순”에서 다른 정렬로 바꿔도 태그가 선택돼 있으면
watchedOnly잠금이 유지된다. - 태그와 “우선순위순”을 둘 다 해제하면
watchedOnly잠금이 풀린다. - 정렬을 “마감 임박순”으로 바꾸면 마감일 필터가 비어 있을 때
deadlineFrom이 오늘로 자동 설정되고 필터 칩에 표시된다. - 자동 설정된
deadlineFrom을 사용자가 지우면 그대로 유지되고, 마감일 없는 공고가 뒤에 붙는다는 안내가 1줄 보인다. 400 WATCH_STATE_FILTER_LIMIT_EXCEEDED(actualCount: 1842·limit: 1000)를 받으면 정렬이DISCOVERED_DESC로 자동 복귀하고 두 수치가 그대로 들어간 문구(“1,842건 → 1,000건 이하로”)와[필터 열기]가 보인다.- 태그 필터에서
WATCH_STATE_FILTER_LIMIT_EXCEEDED를 받아도 정렬 경로와 동일한 수치 문구와[필터 열기]가 보인다. 400 JOB_POSTING_SORT_NOT_APPLICABLE을 받으면 일반 오류 토스트만 뜨고 “필터를 좁혀 주세요” 안내가 보이지 않으며console.error로깅이 발생한다.- 문구 조립이
utils/watchStatus의 순수 함수에서 이뤄지고 화면 컴포넌트가actualCount·limit으로 문자열을 직접 만들지 않는다. matchScore·recommendation이null이면 점수·등급 표시가 렌더되지 않고 0으로 대체되지 않는다.matchScore가 임계치 이상이어도matched: false인 항목에는 매칭 표시가 렌더되지 않는다.hasApplication=true인 공고에 기존AppliedBadge가 보인다.- 카드 본문을 탭하면 공고 상세로 이동하고,
[분류]버튼 탭은 상세로 이동시키지 않는다. - 로딩 중에도 세그먼트·검색 입력이 조작 가능하다.
400 BAD_REQUEST(필터 값) 응답 시 “필터 값이 올바르지 않아요”와[필터 초기화]가 보이고, 5xx는ErrorState와[다시 시도]가 보인다.- 400 분기 로직이
utils/watchStatus에 격리돼 있어 화면 컴포넌트가 에러 메시지 문자열로 분기하지 않는다. - 조회 실패 상태에서도 필터 바가 유지돼 조건을 바꿔 재시도할 수 있다.
- 보관함 화면을 다크 모드로 렌더하면 시맨틱 토큰 class만 사용하고 하드코딩 색이 0건이다.