[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.tshooks/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_at71.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_DESCtag 둘 다에 걸립니다(C-6·D). 서버가 미지정 시 true로 강제하고 명시적 false는 400 JOB_POSTING_SORT_NOT_APPLICABLE 이므로, 둘 중 하나라도 활성이면 watchedOnly 체크박스를 켜고 잠급니다(해제 불가 + “분류한 공고만 대상이에요” 안내). 둘 다 해제해야 잠금이 풀립니다 — 정렬만 되돌리고 태그가 남아 있는데 잠금을 풀면 사용자가 체크를 해제할 수 있고 그 즉시 400입니다. 이 잠금이 지켜지면 JOB_POSTING_SORT_NOT_APPLICABLE은 발생하지 않습니다 — 발생하면 FE 버그입니다.
  • 빈 상태 3종을 서로 다른 문구로 구분합니다. 셋을 뭉뚱그리면 사용자가 “데이터가 없는 것”과 “필터를 잘못 건 것”을 구별하지 못합니다.
    • 분류 전 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·recommendationnull이면 해당 표시를 렌더하지 않습니다. 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). 화면은 codeApiError를 넘기고 문구를 받기만 합니다. ApiErroractualCount·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 — ApiErroractualCount·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·recommendationnull이면 점수·등급 표시가 렌더되지 않고 0으로 대체되지 않는다.
  • matchScore가 임계치 이상이어도 matched: false인 항목에는 매칭 표시가 렌더되지 않는다.
  • hasApplication=true인 공고에 기존 AppliedBadge가 보인다.
  • 카드 본문을 탭하면 공고 상세로 이동하고, [분류] 버튼 탭은 상세로 이동시키지 않는다.
  • 로딩 중에도 세그먼트·검색 입력이 조작 가능하다.
  • 400 BAD_REQUEST(필터 값) 응답 시 “필터 값이 올바르지 않아요”와 [필터 초기화]가 보이고, 5xx는 ErrorState[다시 시도]가 보인다.
  • 400 분기 로직이 utils/watchStatus에 격리돼 있어 화면 컴포넌트가 에러 메시지 문자열로 분기하지 않는다.
  • 조회 실패 상태에서도 필터 바가 유지돼 조건을 바꿔 재시도할 수 있다.
  • 보관함 화면을 다크 모드로 렌더하면 시맨틱 토큰 class만 사용하고 하드코딩 색이 0건이다.