Skip to content

feat: 찜한 기사님 목록 페이지 및 Header 프로필 메뉴 연동 - #41

Merged
yooseohyeon merged 22 commits into
devfrom
feature/movers-favorite-list-page
Jul 31, 2026
Merged

feat: 찜한 기사님 목록 페이지 및 Header 프로필 메뉴 연동#41
yooseohyeon merged 22 commits into
devfrom
feature/movers-favorite-list-page

Conversation

@yooseohyeon

@yooseohyeon yooseohyeon commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

📋 작업 내용

찜한 기사님 전체 목록 페이지(/movers/favorites)를 구현했습니다. Header 프로필 메뉴에서 진입할 수 있으며, 더보기 버튼을 통한 찜한 기사님 목록 조회 및 전체선택·부분선택·삭제 기능을 제공합니다.


🔥 변경 사항

페이지 경로 변경

  • /favorites/movers/movers/favorites (APP_ROUTES.MOVERS.FAVORITES)

Header / GNB

  • 프로필 메뉴를 ProfileMenuTrigger로 분리하고, 고객·기사 role별 메뉴·디자인을 반영했습니다.
  • /movers/favorites에서는 "기사님 찾기" 메뉴가 활성으로 잡히지 않도록 수정했습니다.

찜한 기사님 목록 페이지

  • 고객 인증 게이트, 로딩 스켈레톤, 에러 / Empty 상태

  • 더보기(infinite) 버튼 도입: 한 번에 5명씩 로드

  • 전체선택 + 선택 항목 삭제

    • 일부만 체크 → 해당 항목만 바로 삭제
    • 전체선택 후 삭제 → 확인 모달 → 아직 더보기 버튼을 다 누르지 않아 미로드된 기사도 전체 찜 해제
  • 공통 Checkbox를 추가하고, MoverCard 선택 UI 확장
    - Pagination: 기본 흰 배경 제거, disabled 시 hover 제거

  • 로딩 UI(스켈레톤 UI)

  • 에러 UI(목록 로드 실패 시 에러 안내 및 다시 시도 버튼, 찜 해제 등 액션이 실패할 경우 토스트)

  • 찜 일괄 해제 도중 부분 실패할 경우도 처리할 수 있도록 수정 (리뷰 반영)

    • Promise.allSettled로 각 DELETE 결과를 모은 뒤 일부만 성공한 경우 캐시를 성공한 id만 반영하고, 토스트도 그에 맞게 띄움

더보기 방식을 선택한 이유

  • 데이터를 한 번에 모두 불러오면 특히 모바일에서 렌더링 부담이 커질 수 있어 필요한 시점에 추가로 불러오도록 구현했습니다.
  • 숫자 페이지를 이동하는 방식은 스크롤과 선택 상태가 끊기고, 화면에 아직 표시되지 않은 항목까지 포함하는 전체선택 UX를 구현하기 어렵습니다.
  • 따라서 기존 목록에 데이터를 이어 붙이는 더보기 방식을 적용했습니다.

refactor (파일 역할)

기존에 한 파일에 몰려 있던 찜 로직을 역할별로 분리했습니다.

구분 파일 하는 일
캐시 favoriteMoverCache 찜 목록 캐시 제거·카운트 보정·관련 쿼리 무효화
hook useFavoriteMover 카드의 개별 찜 추가·해제
hook useBulkRemoveFavoriteMovers 여러 명 한꺼번에 해제
hook useFavoriteMovers 기사님 찾기 페이지의 사이드바에서 찜 목록 조회
hook useFavoriteMoversInfinite 목록 페이지용 (더보기)
hook useFavoriteMoversSelection 전체선택·부분선택·삭제 확인 모달 상태

체크박스 토글 리렌더링 개선 (리뷰 반영)

  • 체크박스 토글 시 여러 MoverCard가 함께 리렌더링되는 문제를 발견해, 변경된 카드만 리렌더링되도록 개선했습니다.
  • React DevTools Profiler 기준으로 여러 카드가 함께 렌더링되던 범위를 변경된 MoverCard 중심으로 줄였습니다.

Before

  • 여러 MoverCard 리렌더링 / Render 56.8ms
스크린샷 2026-07-31 오후 4 51 55

After

  • 단일 MoverCard 리렌더링 / Render 16.6ms
스크린샷

✅ 체크리스트

  • 로컬에서 정상 동작을 확인했습니다.
  • 기존 기능에 영향을 주지 않는지 확인했습니다.
  • 불필요한 console.log를 제거했습니다.
  • lint를 통과했습니다.
  • README 또는 문서를 수정했습니다. (필요 시)
  • API 명세와 일치하는지 확인했습니다.

📷 스크린샷 (선택)

Header의 프로필 메뉴

스크린샷 2026-07-31 오후 2 41 59
  • 작성 가능한 리뷰/내가 작성한 리뷰 두 메뉴가 각각 존재하던 것을 시안에 맞춰, 이사 리뷰 메뉴 하나로 통일했습니다. 이사 리뷰 클릭 시 작성 가능한 리뷰로 이동합니다.
  • 프로필 수정은 연결만 해둔 상태입니다. 아직 해당 페이지가 없어 next.js 자체 404가 뜹니다.
스크린샷 2026-07-31 오후 2 43 58
  • 프로필 수정/이사 리뷰/찜한 기사님 중 하나에 접속한 경우, 기존 구현과 똑같이 현재 들어온 페이지는 브랜드 색상으로 강조 표시합니다.

기본 (더보기 버튼)

localhost_3000_movers_favorites (1)

더보기 버튼 클릭 중

localhost_3000_movers_favorites (3)

모두 로드됨

localhost_3000_movers_favorites (2)

기사 선택

  • 선택한 기사의 수가 전체선택 옆 숫자에 바로 반영되며, "선택 항목 삭제" 버튼이 활성화됩니다. "선택 항목 삭제" 버튼을 클릭하면 바로 삭제됩니다.
localhost_3000_movers_favorites (7)

모두 삭제 (확인 모달)

localhost_3000_movers_favorites (9) localhost_3000_movers_favorites (10)

빈 상태

localhost_3000_movers_favorites

💬 To Reviewer

  • 전체 선택을 누를 경우 아직 더보기로 안 불러온 찜까지 포함합니다. 또한 일부 선택 삭제는 즉시 짐 해제가 되며, 전체 선택은 확인 모달 후 전체 해제가 됩니다. 이 구현이 사용자 입장에서 자연스러운지 검토 부탁드립니다!
  • 기존 동작이 모두 정상 동작하는 것을 확인했지만, 찜 해제 시 기사님 찾기 페이지의 사이드바나 견적 등에서 쿼리 키·캐시가 깨질 우려는 없는지 검토 부탁드립니다!

현재 구현의 한계 및 후속 작업

  • 기존 offset 방식은 조회 도중 찜이 추가·삭제되면 페이지 경계가 이동하여 다음 더보기 결과가 누락되거나 중복될 수 있었습니다.
  • 또한 일괄 해제 API가 없어 클라이언트로 순회로 전체 해제를 처리하고 있어, 서버에 부하를 일으키며 권한 검증이나 에러 처리 등 로직이 분산된다는 한계가 있습니다.
  • 이번 PR 범위에서는 기존 API로 동작 가능하게 유지했습니다. 추후 BE에서 nextCursor 기반 조회로 변경하고, 일괄 해제 API를 추가할 예정입니다!

Summary by CodeRabbit

  • 새 기능

    • 찜한 기사님 전용 목록 페이지를 추가했습니다.
    • 찜한 기사님을 선택해 여러 명을 한 번에 해제할 수 있습니다.
    • 목록의 무한 스크롤, 전체 선택, 삭제 확인 및 오류 안내를 지원합니다.
    • 프로필 메뉴에 프로필 수정, 찜한 기사님, 이사 리뷰 메뉴를 추가했습니다.
    • 재사용 가능한 체크박스와 로딩 상태 화면을 제공하고, 인증 대기 중 안내를 개선했습니다.
  • 개선

    • 페이지 이동 버튼의 비활성 상태와 호버 동작을 자연스럽게 조정했습니다.
    • 알림 및 프로필 메뉴의 화면 구성을 개선했습니다.

- 단건 찜과 일괄 해제·캐시 유틸을 분리
- 찜 목록 페이지는 Toolbar/Content/Selection 훅으로 분리
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

찜한 기사님 전용 경로와 목록 화면을 추가했습니다. 무한 조회, 선택, 전체 선택, 일괄 해제, 낙관적 캐시 갱신을 지원합니다. 프로필 메뉴를 분리하고 관련 경로, 인증 로딩 화면, 공통 UI 스타일을 변경했습니다.

Changes

찜한 기사님 관리

Layer / File(s) Summary
목록 조회와 캐시 처리
src/lib/api/favorites.ts, src/hooks/useFavoriteMovers.ts, src/lib/utils/favoriteMoverCache.ts, src/hooks/useFavoriteMover.ts, src/hooks/useBulkRemoveFavoriteMovers.ts
무한 목록 조회와 전체 ID 조회를 추가했습니다. 캐시 제거, 페이지 정보 갱신, 쿼리 무효화, 다건 삭제의 부분 실패와 롤백을 구현했습니다.
선택 및 일괄 해제 UI
src/components/common/Checkbox/Checkbox.tsx, src/components/mover/FavoriteMovers*, src/components/mover/MoverCard.tsx, src/components/mover/MoverCardSkeleton.tsx, src/hooks/useFavoriteMoversSelection.ts
카드 선택, 전체 선택, 삭제 확인 모달, 진행 상태, 오류 토스트, 더보기와 재시도 UI를 추가했습니다.
페이지 경로와 인증 렌더링
src/app/movers/favorites/page.tsx, src/components/mover/FavoriteMoversPageClient.tsx, src/components/auth/CustomerAuthGate.tsx, src/lib/constants/appRoutes.ts, src/components/mover/FavoriteMoversSidebar.tsx
/movers/favorites 경로를 추가했습니다. 인증 대기 중에는 전용 스켈레톤을 표시하고 인증 후 목록을 렌더링합니다.

헤더 및 공통 UI

Layer / File(s) Summary
프로필 메뉴 분리와 헤더 연결
src/components/common/Header/ProfileMenuTrigger.tsx, src/components/common/Header/Header.tsx
프로필 메뉴를 분리했습니다. 링크 활성화, 키보드 탐색, 외부 클릭 닫기, 포커스 복귀, 로그아웃 후 역할별 로그인 이동을 구현했습니다.
알림·페이지네이션·스타일 토큰
src/components/common/Header/NotificationTrigger.tsx, src/components/common/Pagination/Pagination.tsx, src/styles/tokens.theme.css
알림 버튼과 활성 페이지네이션 버튼의 스타일을 변경했습니다. 프로필 메뉴 그림자 토큰을 추가했습니다.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Customer
  participant FavoriteMoversPageClient
  participant CustomerAuthGate
  participant FavoriteMoversContent
  participant FavoriteMoversAPI
  Customer->>FavoriteMoversPageClient: 찜한 기사님 페이지 요청
  FavoriteMoversPageClient->>CustomerAuthGate: 인증 상태 확인
  CustomerAuthGate->>FavoriteMoversContent: 인증 완료 후 콘텐츠 렌더링
  FavoriteMoversContent->>FavoriteMoversAPI: 페이지별 찜 목록 조회
  FavoriteMoversAPI-->>FavoriteMoversContent: 기사님 목록과 페이지 정보 반환
  FavoriteMoversContent-->>Customer: 목록 및 선택 UI 표시
Loading

Possibly related PRs

Suggested reviewers: wndnjs2037, juengseulki, 9g-g9

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.04% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 제목은 찜한 기사님 목록 페이지 구현과 Header 프로필 메뉴 연동이라는 PR의 주요 변경 사항을 간결하고 구체적으로 설명합니다.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/movers-favorite-list-page

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (4)
src/hooks/useBulkRemoveFavoriteMovers.ts (1)

19-54: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

로그인 필요 처리 로직을 공용 훅으로 분리하세요.

두 훅이 LOGIN_REQUIRED_MESSAGE, isUnauthorizedError, requireLogin을 동일하게 구현합니다. 공용 useRequireLogin 훅으로 분리해 재사용하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/useBulkRemoveFavoriteMovers.ts` around lines 19 - 54, Extract the
duplicated LOGIN_REQUIRED_MESSAGE, isUnauthorizedError, and requireLogin logic
from useBulkRemoveFavoriteMovers into a shared useRequireLogin hook, then update
this hook to consume it while preserving the existing modal-or-redirect behavior
and unauthorized handling.

Source: Coding guidelines

src/components/common/Checkbox/Checkbox.tsx (1)

52-62: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

체크 아이콘을 src/icons의 공통 아이콘으로 관리해 주세요.

src/icons/index.ts에는 동일한 체크 아이콘이 없습니다. ConfirmedCheckIcon은 확인 배지 전체 모양이므로 대체용으로 사용할 수 없습니다. public/icons/checkbox-check.svgsrc/icons에 등록하고 @/icons에서 import해 사용해 주세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/common/Checkbox/Checkbox.tsx` around lines 52 - 62, Update the
Checkbox component’s checked-state icon to use a shared icon registered in
src/icons/index.ts: add public/icons/checkbox-check.svg to the icon exports,
then import and render that icon from `@/icons` instead of referencing the public
path directly. Do not substitute ConfirmedCheckIcon.

Source: Path instructions

src/components/mover/FavoriteMoversPageClient.tsx (1)

10-25: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

정적 PageHeader까지 클라이언트 경계 안에 있습니다.

이 파일은 "use client"이며 PageHeader, CustomerAuthGate, FavoriteMoversContent, FavoriteMoversLoadingSkeleton을 모두 감쌉니다. PageHeader가 제목만 표시하고 상태나 이벤트 핸들러가 필요 없다면, page.tsx(Server Component)에서 PageHeader를 직접 렌더링하고 이 클라이언트 컴포넌트에는 실제로 클라이언트 동작이 필요한 인증 게이트와 목록만 남기세요. 이렇게 하면 정적 헤더는 서버에서 렌더링되고 클라이언트 번들 크기도 줄어듭니다.

Based on coding guidelines: "컴포넌트의 책임을 명확히 분리하고, 불필요한 상태·useEffect·Client Component를 추가하지 않는다."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/mover/FavoriteMoversPageClient.tsx` around lines 10 - 25, Move
the static PageHeader rendering from FavoriteMoversPageClient into the
surrounding page.tsx Server Component, and remove its import and usage from
FavoriteMoversPageClient. Keep CustomerAuthGate, FavoriteMoversContent, and
FavoriteMoversLoadingSkeleton in the client component with the existing layout
and loading behavior unchanged.

Source: Coding guidelines

src/components/mover/FavoriteMoversContent.tsx (1)

100-113: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

체크박스 하나를 토글해도 로드된 카드 전체가 리렌더링됩니다.

selection={{ checked: ..., onCheckedChange: (checked) => ... }}는 매 렌더마다 새 객체와 새 함수를 생성해 각 MoverCard에 전달합니다. useFavoriteMoversSelection의 상태는 FavoriteMoversContent 안에서 관리되므로, 체크박스 하나만 토글해도 이 컴포넌트가 리렌더되고 로드된 모든 MoverCard가 새로운 props를 받아 다시 렌더링됩니다. 목록에 항목이 많이 로드될수록(더보기를 여러 번 누른 뒤) 체감 지연이 커질 수 있습니다.

mover.idselection.handleToggleMover, selection.isMoverSelected처럼 안정적인 참조를 직접 전달하고, MoverCard 쪽에서 mover.id를 바인딩하도록 구조를 바꾸면 매 렌더 새 클로저 생성을 줄일 수 있습니다. MoverCardReact.memo로 감싸는 작업과 함께 적용해야 실제 효과가 있습니다.

Based on path instructions: "불필요한 리렌더링을 유발하는 구조인지 확인해 주세요. (렌더링마다 새로 만들어지는 객체·배열·함수를 자식 props로 전달 등)"

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/mover/FavoriteMoversContent.tsx` around lines 100 - 113,
Update the FavoriteMoversContent mapping and MoverCard props so mover.id,
selection.handleToggleMover, and selection.isMoverSelected are passed as stable
references instead of creating a new selection object and callback per item on
each render. Bind mover.id inside MoverCard, wrap MoverCard with React.memo, and
preserve the existing checked-state and toggle behavior so unchanged cards can
skip re-rendering.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/common/Header/ProfileMenuTrigger.tsx`:
- Around line 62-83: Update the menu focus handling around onKeyDown and the
menu container to close the open menu when focus leaves it via Tab. Add blur
handling that checks the next focused element, calls closeQuiet when it is
outside the menu container, and preserves the menu when focus moves between its
own items.
- Around line 38-40: Update the menu state in ProfileMenuTrigger to use a
boolean rather than storing openMenuPath, and reset it to closed whenever
pathname changes. Ensure browser back/forward navigation cannot make isOpen
become true automatically or move focus to the first menu item without a new
user action.

In `@src/components/mover/FavoriteMoversLoadingSkeleton.tsx`:
- Line 6: Update LIST_SKELETON_COUNT in FavoriteMoversLoadingSkeleton to match
the actual five-item page size used by the load-more flow, so the loading
skeleton count remains consistent with the loaded cards.

In `@src/hooks/useBulkRemoveFavoriteMovers.ts`:
- Around line 56-92: Update the bulk removal flow in mutationFn and onError to
distinguish partial failures instead of treating Promise.all rejection as a
complete rollback: use Promise.allSettled to track successful and failed mover
IDs, keep successful removals reflected in the cache, and restore or retain only
the failed IDs as appropriate. Ensure the error callback communicates partial
failure accurately while preserving unauthorized handling and final query
invalidation.

In `@src/hooks/useFavoriteMoversSelection.ts`:
- Around line 79-97: Update handleBulkDelete so removeFavorites failures in its
catch are not passed to setToastMessage, allowing useBulkRemoveFavoriteMovers
and its onError/authentication handling to remain the sole toast path. Keep
fetchAllFavoriteMoverIds errors on a separate path with their own toast
handling, and preserve rejection handling for removeFavorites without
duplicating user-facing errors.

In `@src/lib/api/favorites.ts`:
- Around line 44-69: Update fetchAllFavoriteMoverIds and handleConfirmDeleteAll
so the deletion target comes from a consistent favorites-list snapshot rather
than concurrently fetched offset pages based on totalPages; use the available
backend bulk-delete or cursor/snapshot-based API, preserving the full captured
ID set for the subsequent deletion.

---

Nitpick comments:
In `@src/components/common/Checkbox/Checkbox.tsx`:
- Around line 52-62: Update the Checkbox component’s checked-state icon to use a
shared icon registered in src/icons/index.ts: add
public/icons/checkbox-check.svg to the icon exports, then import and render that
icon from `@/icons` instead of referencing the public path directly. Do not
substitute ConfirmedCheckIcon.

In `@src/components/mover/FavoriteMoversContent.tsx`:
- Around line 100-113: Update the FavoriteMoversContent mapping and MoverCard
props so mover.id, selection.handleToggleMover, and selection.isMoverSelected
are passed as stable references instead of creating a new selection object and
callback per item on each render. Bind mover.id inside MoverCard, wrap MoverCard
with React.memo, and preserve the existing checked-state and toggle behavior so
unchanged cards can skip re-rendering.

In `@src/components/mover/FavoriteMoversPageClient.tsx`:
- Around line 10-25: Move the static PageHeader rendering from
FavoriteMoversPageClient into the surrounding page.tsx Server Component, and
remove its import and usage from FavoriteMoversPageClient. Keep
CustomerAuthGate, FavoriteMoversContent, and FavoriteMoversLoadingSkeleton in
the client component with the existing layout and loading behavior unchanged.

In `@src/hooks/useBulkRemoveFavoriteMovers.ts`:
- Around line 19-54: Extract the duplicated LOGIN_REQUIRED_MESSAGE,
isUnauthorizedError, and requireLogin logic from useBulkRemoveFavoriteMovers
into a shared useRequireLogin hook, then update this hook to consume it while
preserving the existing modal-or-redirect behavior and unauthorized handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4f98cbe3-a1bb-4ba6-ad2d-83c213dac171

📥 Commits

Reviewing files that changed from the base of the PR and between d5e2fe8 and 050e5c2.

📒 Files selected for processing (24)
  • src/app/favorites/movers/page.tsx
  • src/app/movers/favorites/page.tsx
  • src/components/auth/CustomerAuthGate.tsx
  • src/components/common/Checkbox/Checkbox.tsx
  • src/components/common/Header/Header.tsx
  • src/components/common/Header/NotificationTrigger.tsx
  • src/components/common/Header/ProfileMenuTrigger.tsx
  • src/components/common/Pagination/Pagination.tsx
  • src/components/mover/FavoriteMoversContent.tsx
  • src/components/mover/FavoriteMoversDeleteConfirmModal.tsx
  • src/components/mover/FavoriteMoversLoadingSkeleton.tsx
  • src/components/mover/FavoriteMoversPageClient.tsx
  • src/components/mover/FavoriteMoversSidebar.tsx
  • src/components/mover/FavoriteMoversToolbar.tsx
  • src/components/mover/MoverCard.tsx
  • src/components/mover/MoverCardSkeleton.tsx
  • src/hooks/useBulkRemoveFavoriteMovers.ts
  • src/hooks/useFavoriteMover.ts
  • src/hooks/useFavoriteMovers.ts
  • src/hooks/useFavoriteMoversSelection.ts
  • src/lib/api/favorites.ts
  • src/lib/constants/appRoutes.ts
  • src/lib/utils/favoriteMoverCache.ts
  • src/styles/tokens.theme.css
💤 Files with no reviewable changes (1)
  • src/app/favorites/movers/page.tsx

Comment thread src/components/common/Header/ProfileMenuTrigger.tsx Outdated
Comment thread src/components/common/Header/ProfileMenuTrigger.tsx
Comment thread src/components/mover/FavoriteMoversLoadingSkeleton.tsx Outdated
Comment thread src/hooks/useBulkRemoveFavoriteMovers.ts
Comment thread src/hooks/useFavoriteMoversSelection.ts Outdated
Comment thread src/lib/api/favorites.ts

@juengseulki juengseulki left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📋 PR 리뷰

👍 좋았던 점

  • 찜한 기사님 전체 목록과 기사 찾기 사이드바용 쿼리를 각각 infinite query와 일반 query로 분리해 사용 목적을 명확하게 나눈 점이 좋았습니다.
  • 찜 목록 관련 캐시 갱신 로직을 favoriteMoverCache로 분리해 단건 삭제와 일괄 삭제에서 공통으로 활용할 수 있도록 구성했습니다.
  • 찜 해제 시 받은 견적, 대기 견적, 견적 상세, 기사 목록·상세와 찜 목록을 함께 무효화해 화면마다 찜 상태가 달라지는 문제를 방지했습니다.
  • 유한 목록과 infinite 목록 모두 FAVORITES.MOVERS prefix 아래에 구성되어 사이드바와 전체 목록 캐시를 함께 관리할 수 있습니다.
  • 전체선택 시 아직 불러오지 않은 데이터까지 포함하고, 실제 삭제 직전에 전체 ID를 다시 조회해 누락 없이 처리하려는 흐름이 좋았습니다.
  • 부분 삭제는 즉시 실행하고 전체 삭제에는 확인 모달을 적용해 작업의 영향도에 따라 UX를 구분했습니다.
  • 로딩 스켈레톤, 최초 조회 실패, 추가 조회 실패, 빈 상태, 액션 실패 Toast를 각각 분리해 상태별 대응을 꼼꼼하게 구성했습니다.
  • 프로필 메뉴에 키보드 방향키, Home·End, Escape, 포커스 복귀 처리를 적용해 접근성까지 고려한 점이 좋았습니다.
  • 공통 Checkbox를 추가하고 MoverCard에는 선택 기능을 선택적 prop으로 확장해 기존 사용처에 영향을 최소화했습니다.

🚨 수정이 필요한 부분

인라인 코멘트로 아래 내용을 남겼습니다.

  • 전체 찜 해제 시 전체 ID 개수만큼 DELETE 요청을 Promise.all()로 동시에 실행하고 있습니다.
  • 찜 개수가 많으면 서버에 요청이 한꺼번에 몰릴 수 있고, 일부 요청이 성공한 뒤 하나가 실패하면 UI에서는 전체 실패로 보이지만 실제 서버에는 일부 삭제가 반영될 수 있습니다.
  • 가능하면 백엔드 일괄 삭제 API를 사용하는 것이 좋고, 현재 API만 사용해야 한다면 요청 수 제한 또는 부분 실패 처리 보완이 필요해 보입니다.

🔍 확인 및 제안

  • 전체선택 후 현재 보이는 기사 한 명을 해제하면 전체선택이 풀리면서 미로드 항목도 모두 선택에서 제외됩니다. 예를 들어 30명 전체선택 상태가 현재 로드된 4명 선택 상태로 바뀔 수 있는데, 의도한 UX인지 확인 부탁드립니다.
  • 전체선택이 미로드 항목까지 포함한다는 사실을 툴바나 안내 문구로 조금 더 명확하게 전달하면 사용자가 선택 범위를 이해하기 쉬울 것 같습니다.
  • 현재 쿼리 키 구조와 관련 쿼리 무효화 범위를 확인했을 때, 기사 찾기 사이드바·견적 목록·상세의 찜 캐시는 정상적으로 함께 갱신될 것으로 보입니다.
  • 프로필 수정 메뉴가 아직 존재하지 않는 경로로 연결되어 있으므로, 페이지 구현 전까지 비활성화하거나 준비 중 안내를 제공하는 방식도 고려하면 좋겠습니다.

전체적으로 데이터 로딩 방식부터 선택 상태, 캐시 동기화, 오류 UI까지 기능 범위를 세밀하게 나누어 구현한 PR이었습니다. 특히 기존 찜 로직을 역할별 훅과 캐시 유틸로 분리해 이후 유지보수하기 좋은 구조로 정리한 점이 좋았습니다. 다만 전체 삭제 시 다수의 DELETE 요청을 무제한으로 병렬 실행하는 부분은 실제 데이터가 늘어날수록 실패 가능성과 부분 성공 문제가 커질 수 있어 병합 전에 보완하는 것이 안전해 보입니다. 수고하셨습니다! 😊

Comment thread src/hooks/useBulkRemoveFavoriteMovers.ts Outdated
Comment thread src/hooks/useFavoriteMoversSelection.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/hooks/useFavoriteMoversSelection.ts (1)

79-94: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

전체선택 상태에서 부분 실패가 발생하면 선택 개수가 실제보다 적게 표시될 수 있습니다.

removeFavorites는 부분 실패 시 isSelectAllfalse로 바꾸고 selectedIdsresult.failedIds로 설정합니다. handleConfirmDeleteAll에서 isSelectAlltrue일 때 삭제 대상은 fetchAllFavoriteMoverIds()로 가져온 전체 id 목록입니다. 따라서 result.failedIds에는 화면에 아직 로드되지 않은 기사님 id도 포함될 수 있습니다.

isSelectAllfalse가 되면 selectedCountselectedOnLoadedCount, 즉 loadedIds.filter((id) => selectedIds.includes(id)).length만 사용합니다. 로드되지 않은 실패 id는 이 계산에서 빠집니다. 예를 들어 총 30명을 전체선택했고 10명만 로드된 상태에서 5명이 실패하면, 그중 로드되지 않은 실패 id는 "전체선택(N/M)" 표시 개수에서 누락됩니다. 화면 개수와 selectedIds에 실제로 담긴 대상이 어긋납니다.

로드되지 않은 실패 id를 별도로 추적하거나, 실패 후에도 남은 대상을 totalCount 기반으로 표시하는 방식을 검토해 주세요.
원하시면 로드되지 않은 실패 id를 다루는 수정안을 제안해 드릴까요?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/useFavoriteMoversSelection.ts` around lines 79 - 94, The
removeFavorites function discards unloaded failed ids when handling partial
failures. When result.failedIds contains ids that haven't been loaded on screen
yet, switching isSelectAll to false and setting selectedIds to result.failedIds
causes those unloaded ids to drop out of the selectedCount calculation (which
only counts ids in loadedIds). Instead of switching to false isSelectAll state,
preserve the select-all state and update excludedIds to exclude the successfully
removed items, keeping all failed ids (loaded and unloaded) counted in the
selection total. This maintains consistency between the displayed selection
count and the actual removal targets.
🧹 Nitpick comments (1)
src/hooks/useBulkRemoveFavoriteMovers.ts (1)

157-159: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

전체 실패 시에도 관련 쿼리를 모두 무효화합니다.

onSettled는 결과와 무관하게 항상 invalidateFavoriteRelatedQueries를 호출합니다. 전체 실패 시에는 서버 상태가 변하지 않았고, onError가 이미 캐시를 이전 상태로 복원했습니다. 이 경우 무효화와 그에 따른 리페치는 실질적인 이득이 없습니다.

FAVORITES.MOVERS는 무한 조회 쿼리입니다. TanStack Query 문서에 따르면 "queryClient.invalidateQueries also works for infinite queries. Per default, it will refetch all pages". 사용자가 로드한 페이지 수가 많을수록 전체 실패 시의 불필요한 무효화 비용이 커집니다. onSettled는 "onSettled: (data: TData | undefined, error: TError | null, variables: TVariables, onMutateResult: TOnMutateResult | undefined, context: MutationFunctionContext) => void" 형태로 error 인자를 받을 수 있으므로, 전체 실패일 때는 무효화를 건너뛰는 편이 좋습니다.

♻️ 전체 실패 시 무효화를 건너뛰는 수정안
-    onSettled: async () => {
-      await invalidateFavoriteRelatedQueries(queryClient);
-    },
+    onSettled: async (_data, error) => {
+      if (error) {
+        return;
+      }
+      await invalidateFavoriteRelatedQueries(queryClient);
+    },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/useBulkRemoveFavoriteMovers.ts` around lines 157 - 159, The
onSettled callback unconditionally calls invalidateFavoriteRelatedQueries, but
on error the cache has already been restored by onError and no server state
change occurred, making invalidation and refetch of the infinite
FAVORITES.MOVERS query unnecessary. Update the onSettled callback signature to
accept the error parameter and only invoke invalidateFavoriteRelatedQueries when
the error is null, skipping the invalidation entirely when a mutation failure
has already been handled.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/hooks/useBulkRemoveFavoriteMovers.ts`:
- Around line 67-107: Move the partial-success authentication side effect out of
mutationFn: remove its direct requireLogin() call and include an unauthorized
indicator in the returned BulkRemoveFavoriteResult. Update onSuccess to invoke
requireLogin() based on that indicator while preserving the existing
partial-failure message handling, so authentication expiry does not trigger
overlapping login and error-toast effects.

---

Outside diff comments:
In `@src/hooks/useFavoriteMoversSelection.ts`:
- Around line 79-94: The removeFavorites function discards unloaded failed ids
when handling partial failures. When result.failedIds contains ids that haven't
been loaded on screen yet, switching isSelectAll to false and setting
selectedIds to result.failedIds causes those unloaded ids to drop out of the
selectedCount calculation (which only counts ids in loadedIds). Instead of
switching to false isSelectAll state, preserve the select-all state and update
excludedIds to exclude the successfully removed items, keeping all failed ids
(loaded and unloaded) counted in the selection total. This maintains consistency
between the displayed selection count and the actual removal targets.

---

Nitpick comments:
In `@src/hooks/useBulkRemoveFavoriteMovers.ts`:
- Around line 157-159: The onSettled callback unconditionally calls
invalidateFavoriteRelatedQueries, but on error the cache has already been
restored by onError and no server state change occurred, making invalidation and
refetch of the infinite FAVORITES.MOVERS query unnecessary. Update the onSettled
callback signature to accept the error parameter and only invoke
invalidateFavoriteRelatedQueries when the error is null, skipping the
invalidation entirely when a mutation failure has already been handled.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0cf0feff-3777-4245-b3b1-ba4ef63317df

📥 Commits

Reviewing files that changed from the base of the PR and between 050e5c2 and e934860.

📒 Files selected for processing (2)
  • src/hooks/useBulkRemoveFavoriteMovers.ts
  • src/hooks/useFavoriteMoversSelection.ts

Comment thread src/hooks/useBulkRemoveFavoriteMovers.ts
@yooseohyeon
yooseohyeon removed the request for review from 9g-g9 July 31, 2026 08:14

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/hooks/useFavoriteMoversSelection.ts (1)

136-156: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

selectedCount === totalCount 케이스에서 새로 조회한 allIds를 그대로 삭제하지 마세요.

handleBulkDelete(Line 113)는 isSelectAll이 아니어도 selectedCount === totalCount이면 확인 모달을 연다. 이 경우는 "사용자가 로드된 항목을 모두 수동으로 선택했다"는 뜻이지, "전체선택 토글을 눌렀다"는 뜻이 아니다.

그런데 handleConfirmDeleteAll은 이 두 경우를 구분하지 않는다. isSelectAll이 아니면 idsToRemove = allIds를 그대로 사용한다. allIds는 확인 시점에 서버에서 새로 조회한 값이다.

확인 모달이 열려 있는 동안 다른 세션에서 새 기사님을 찜하면, allIds에 그 새 항목이 포함된다. 사용자가 "모두 해제"를 누르면 한 번도 선택하지 않은 항목까지 삭제된다.

isSelectAll이 아닌 경우에는 클릭 시점에 이미 확정된 selectedIds를 삭제 대상으로 사용하세요. 이 경로에서는 fetchAllFavoriteMoverIds() 호출도 불필요합니다.

🛠️ 제안하는 수정
   const handleConfirmDeleteAll = useCallback(() => {
     void (async () => {
       setIsResolvingAllIds(true);
       try {
-        const allIds = await fetchAllFavoriteMoverIds();
-        const excludedSet = new Set(excludedIds);
-        const idsToRemove = isSelectAll ? allIds.filter((id) => !excludedSet.has(id)) : allIds;
+        let idsToRemove: string[];
+        if (isSelectAll) {
+          const allIds = await fetchAllFavoriteMoverIds();
+          const excludedSet = new Set(excludedIds);
+          idsToRemove = allIds.filter((id) => !excludedSet.has(id));
+        } else {
+          // 로드된 전체를 수동으로 선택한 경우: 확인 시점에 새로 조회하지 않고
+          // 클릭 시점에 확정된 selectedIds만 삭제 대상으로 사용한다.
+          idsToRemove = selectedIds;
+        }
 
         try {
           await removeFavorites(idsToRemove);
           setIsDeleteConfirmOpen(false);
         } catch {
           // 전부 실패 시 mutation onError에서 토스트 처리. 모달은 재시도 가능하도록 유지
         }
       } catch (error) {
         setToastMessage(getApiErrorMessage(error, DELETE_ERROR_MESSAGE));
       } finally {
         setIsResolvingAllIds(false);
       }
     })();
-  }, [excludedIds, isSelectAll, removeFavorites]);
+  }, [excludedIds, isSelectAll, removeFavorites, selectedIds]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/useFavoriteMoversSelection.ts` around lines 136 - 156, Update
handleConfirmDeleteAll so the non-isSelectAll path deletes the click-time
selectedIds rather than newly fetched allIds, preserving the distinction between
manually selecting every loaded item and using the select-all toggle. Avoid
calling fetchAllFavoriteMoverIds in that path; retain the existing
fetch-and-exclude behavior only for isSelectAll.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/hooks/useFavoriteMoversSelection.ts`:
- Around line 136-156: Update handleConfirmDeleteAll so the non-isSelectAll path
deletes the click-time selectedIds rather than newly fetched allIds, preserving
the distinction between manually selecting every loaded item and using the
select-all toggle. Avoid calling fetchAllFavoriteMoverIds in that path; retain
the existing fetch-and-exclude behavior only for isSelectAll.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 36b9a4e2-6eaa-454a-b683-61a67cc2404f

📥 Commits

Reviewing files that changed from the base of the PR and between 5a16326 and f6f62bc.

📒 Files selected for processing (6)
  • src/components/common/Header/Header.tsx
  • src/components/common/Header/ProfileMenuTrigger.tsx
  • src/components/mover/FavoriteMoversContent.tsx
  • src/components/mover/MoverCard.tsx
  • src/hooks/useBulkRemoveFavoriteMovers.ts
  • src/hooks/useFavoriteMoversSelection.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/components/mover/FavoriteMoversContent.tsx
  • src/components/common/Header/ProfileMenuTrigger.tsx
  • src/hooks/useBulkRemoveFavoriteMovers.ts
  • src/components/common/Header/Header.tsx

if (result.failedIds.length > 0) {
setIsSelectAll(false);
setExcludedIds([]);
setSelectedIds(result.failedIds);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

부분 실패 시 isSelectAll을 false로 바꾸고 있어서, 화면에 아직 뜨지 않은 실패 항목이 개수 계산에서 빠질 수 있을 것 같습니다.
isSelectAll을 유지한 채 성공한 id만 excludedIds로 넘기는 방식을 사용하면 개수를 totalCount 기준으로 계산해서 로드되지 않은 실패 항목까지 선택 개수에 남기 때문에, 실제 selectedIds와 화면에 보이는 개수가 어긋나지 않을 것 같아요!

@wndnjs2037

Copy link
Copy Markdown
Collaborator

MoverCard를 memo로 감싸 불필요한 리렌더를 막고, 벌크 삭제를 allSettled로 처리해 성공한 항목만 반영하는 방식으로 잘 구현된 것 같습니다.

To Reviewer에 적어주신 내용 관련해서는
전체 삭제는 화면에 로드되지 않은 항목까지 대상으로 잡아서 사용자가 무엇이 지워지는지 볼 수 없으니,
전체 삭제에만 확인 모달을 두고 부분 삭제는 즉시 처리하는 구분이 자연스러울 것 같습니다.

캐시는 invalidateFavoriteRelatedQueries를 공통으로 빼서 단건 해제와 일괄 삭제 양쪽이 같은 함수를 onSettled에서 호출하고 있어서 어느 경로로 해제해도 무효화 범위가 같으니 사이드바나 견적 쪽이 어긋나지는 않을 것 같아요!

구현하느라 고생 많으셨습니다!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants