Skip to content

fix: 찜 연속 클릭 시 상태 불일치 수정 및 찜 버튼 공통화 - #66

Merged
yooseohyeon merged 10 commits into
devfrom
fix/favorite-toggle-double-click
Aug 5, 2026
Merged

yooseohyeon merged 10 commits into
devfrom
fix/favorite-toggle-double-click

Conversation

@yooseohyeon

@yooseohyeon yooseohyeon commented Aug 5, 2026

Copy link
Copy Markdown
Member

📋 작업 내용

기사님 찾기, 찜한 기사님 페이지의 카드와 기사 상세 패이지에서 찜 취소 후 바로 다시 등록하려고 클릭하면 반응하지 않고 한 번 더 클릭해야 동작하는 버그를 수정했습니다.
또한 여러 페이지에 중복 구현되어 있던 하트·찜 개수 UI를 공통 컴포넌트로 통합했습니다.


🔥 변경 사항

찜 토글 안정성 개선

  • 같은 기사님에 대한 찜 추가·해제 요청을 클릭 순서대로 처리하는 직렬 큐 적용
  • 요청 중에도 버튼을 비활성화하지 않고, 하트와 찜 개수가 클릭 즉시 반영되도록 개선
  • 요청마다 ID를 부여해, 이전 요청의 완료·실패 응답이 이후 사용자의 최종 찜 상태를 덮어쓰지 않도록 수정
  • React Query 캐시 무효화는 가장 마지막 요청이 완료된 시점에만 실행
  • authScope를 큐 키에 포함하고 실행 직전 세션을 재확인해, 계정 전환 시 이전 계정의 대기 요청이 새 계정의 인증으로 잘못 전송되지 않도록 수정
  • onMutate(낙관적 업데이트)를 기사별 큐로 직렬화해 빠른 연속 클릭 시 캐시 상태가 항상 마지막 클릭 값으로 수렴하도록 수정
  • onMutate, onError, onSettled에서 모두 요청 시점의 authScope를 기준으로 캐시 조회·롤백·무효화를 수행하도록 수정해 계정 전환 시 다른 세션의 캐시를 잘못 갱신하는 문제 해결
  • 요청 관리 키(latestRequestIds)도 authScope를 포함하도록 변경해 계정 간 요청이 서로 영향을 주지 않도록 수정
  • 이전 요청의 401 응답으로 로그인 모달이 불필요하게 열리던 문제 수정

FavoriteButton 공통 컴포넌트 분리

  • 하트 아이콘, 찜 개수, 접근성 속성, 클릭 처리 로직을 FavoriteButton 공통 컴포넌트로 분리
  • 기사님 찾기 카드, 기사님 상세, 대기 견적, 받은 견적, 견적 상세 화면에 적용 (MoverCard, MoverDetailProfile, EstimateOfferCard, PendingEstimateCard, EstimateDetailDriverSummary)
  • 화면별 아이콘 색상, 숫자 위치, 반응형 텍스트 크기는 기존 디자인 그대로 유지

FavoriteButton 스토리 추가

  • interactive, showCount, countPosition 등 주요 prop별 상태 문서화
  • Playground에서 클릭 시 하트 색·찜 개수가 함께 바뀌는 것을 확인 가능
  • play function으로 토글 인터랙션(aria-pressed, 개수 증감) 검증

✅ 체크리스트

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

📷 스크린샷 (선택)

https://6a7054bcd216f9a9c84655d6-syrhkdlilx.chromatic.com/?path=/docs/domain-favoritebutton--docs 에서도 확인할 수 있습니다

찜 취소 후 즉시 다시 등록

  • 키보드 포커스 스타일 추가 및 키보드 포커스를 통해서도 찜 등록/취소 가능
Adobe Express - 화면 기록 2026-08-05 오전 11 19 00

💬 To Reviewer

  • 연속된 찜 추가·해제 요청이 클릭 순서대로 처리되는지 확인 부탁드립니다.
  • 이전 요청의 에러 또는 캐시 무효화가 마지막 낙관적 상태를 덮어쓰지 않는지 확인 부탁드립니다.
  • 공통 FavoriteButton 컴포넌트로 분리한 방향이 적절한지 불필요한 props가 있지는 않은지 등 의견 주시면 감사하겠습니다!

Summary by CodeRabbit

  • 새로운 기능

    • 기사 카드와 상세 화면에 일관된 찜 버튼 UI를 적용했습니다.
    • 찜 상태와 찜 개수를 다양한 위치와 형태로 표시합니다.
    • 찜 버튼의 접근성과 상태 안내를 개선했습니다.
  • 버그 수정

    • 찜 버튼을 빠르게 여러 번 눌러도 요청 순서가 안정적으로 처리됩니다.
    • 최신 요청 기준으로 오류 처리와 화면 상태가 반영됩니다.
    • 계정 전환 중 이전 찜 요청이 잘못 반영되는 문제를 방지했습니다.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

찜 UI를 공통 FavoriteButton으로 통합했습니다. useFavoriteMover는 기사별 요청 큐와 requestId 검증을 사용해 요청을 순차 처리하고 최신 요청 기준으로 오류를 처리합니다.

찜 기능 통합

Layer / File(s) Summary
공통 FavoriteButton 계약
src/components/mover/FavoriteButton.tsx, src/stories/FavoriteButton.stories.tsx
찜 상태, 개수, 표시 위치, 스타일, 상호작용 및 접근성 속성을 지원하는 컴포넌트와 Storybook 검증을 추가했습니다.
기사별 찜 요청 큐
src/hooks/useFavoriteMover.ts, src/components/mover/detail/MoverDetailView.tsx, src/hooks/useBulkRemoveFavoriteMovers.ts
기사별 찜 요청을 순차 처리합니다. 최신 요청에만 오류 콜백, 롤백, 관련 쿼리 무효화를 적용합니다.
찜 UI 사용처 통합
src/components/estimate/..., src/components/mover/MoverCard.tsx, src/components/mover/detail/MoverDetailProfile.tsx
기존 인라인 버튼과 FavoriteSummary를 제거하고 FavoriteButton에 상태, 개수, 스타일, 토글 mutation을 전달합니다.

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

Sequence Diagram(s)

sequenceDiagram
  participant 사용자
  participant FavoriteButton
  participant useFavoriteMover
  participant favoriteMutation
  participant 관련 쿼리
  사용자->>FavoriteButton: 찜 상태 변경
  FavoriteButton->>useFavoriteMover: 다음 찜 상태 전달
  useFavoriteMover->>favoriteMutation: 기사별 순차 요청 실행
  favoriteMutation-->>useFavoriteMover: 성공 또는 오류 반환
  useFavoriteMover->>관련 쿼리: 최신 요청일 때 무효화
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 62.50% 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 제목이 연속 클릭 시 상태 불일치 수정과 찜 버튼 공통화라는 주요 변경 사항을 정확하고 간결하게 설명합니다.
✨ 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 fix/favorite-toggle-double-click

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: 2

Caution

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

⚠️ Outside diff range comments (2)
src/hooks/useFavoriteMover.ts (2)

264-266: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

이전 요청의 401 오류로 로그인 흐름을 시작하지 마세요.

이 분기는 isLatestRequest를 확인하지 않습니다. 이전 요청이 401로 실패한 뒤 최신 요청이 대기 또는 성공하는 경우에도 requireLogin()이 실행됩니다. 이 동작은 최신 상태만 오류를 처리한다는 요청 큐 계약을 깨고 사용자의 현재 작업을 중단합니다.

isUnauthorizedError(error) 처리도 isLatestRequest일 때만 실행하세요.

🤖 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/useFavoriteMover.ts` around lines 264 - 266, Update the
unauthorized-error branch in useFavoriteMover so isUnauthorizedError(error)
triggers requireLogin() and returns only when isLatestRequest is true. Preserve
the existing behavior for latest requests while ignoring 401 errors from
superseded requests.

121-132: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

기사별 전체 mutation lifecycle을 클릭 순서로 직렬화하세요.

onMutatemutationFn보다 먼저 실행되며, 현재 큐는 mutationFn 이후에만 적용됩니다. cancelQueries 대기 순서가 뒤집히면 낙관적 갱신과 enqueueFavoriteRequest 등록 순서가 모두 뒤집힐 수 있습니다. 초기값이 false일 때 true → false를 빠르게 클릭하면 마지막 요청이 false여도 화면과 서버가 true가 될 수 있습니다. onMutate부터 요청 완료까지 기사별 큐 또는 scope로 직렬화하세요.

🤖 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/useFavoriteMover.ts` around lines 121 - 132, Serialize the entire
favorite-mutation lifecycle per mover, starting before onMutate and continuing
through enqueueFavoriteRequest completion, so rapid clicks preserve click order.
Update the mutation flow around onMutate and mutationFn to use a mover-scoped
queue or serialization mechanism, ensuring cancelQueries, optimistic updates,
request registration, and completion execute sequentially for each mover while
allowing different movers to proceed independently.
🤖 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/mover/FavoriteButton.tsx`:
- Around line 46-55: Update FavoriteButton’s accessible-name logic to include
favoriteCount in the button aria-label whenever showCount is true, while
preserving the count Text element’s aria-hidden="true" behavior.

In `@src/hooks/useFavoriteMover.ts`:
- Around line 70-92: Update enqueueFavoriteRequest and its queue state to
include an authScope or session generation alongside moverId, so requests from
different accounts never share a queue. Before executing addFavoriteMover or
removeFavoriteMover, compare the request’s captured scope with the current scope
and discard mismatched queued requests; preserve ordering for requests within
the same session.

---

Outside diff comments:
In `@src/hooks/useFavoriteMover.ts`:
- Around line 264-266: Update the unauthorized-error branch in useFavoriteMover
so isUnauthorizedError(error) triggers requireLogin() and returns only when
isLatestRequest is true. Preserve the existing behavior for latest requests
while ignoring 401 errors from superseded requests.
- Around line 121-132: Serialize the entire favorite-mutation lifecycle per
mover, starting before onMutate and continuing through enqueueFavoriteRequest
completion, so rapid clicks preserve click order. Update the mutation flow
around onMutate and mutationFn to use a mover-scoped queue or serialization
mechanism, ensuring cancelQueries, optimistic updates, request registration, and
completion execute sequentially for each mover while allowing different movers
to proceed independently.
🪄 Autofix

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: 97b9ce3f-8567-4fff-8af6-8a1f7d82be83

📥 Commits

Reviewing files that changed from the base of the PR and between 47ab496 and 598a649.

📒 Files selected for processing (9)
  • src/components/estimate/detail/EstimateDetailDriverSummary.tsx
  • src/components/estimate/pending/PendingEstimateCard.tsx
  • src/components/estimate/received/EstimateOfferCard.tsx
  • src/components/mover/FavoriteButton.tsx
  • src/components/mover/MoverCard.tsx
  • src/components/mover/detail/MoverDetailProfile.tsx
  • src/components/mover/detail/MoverDetailView.tsx
  • src/hooks/useBulkRemoveFavoriteMovers.ts
  • src/hooks/useFavoriteMover.ts
💤 Files with no reviewable changes (1)
  • src/components/mover/detail/MoverDetailView.tsx

Comment thread src/components/mover/FavoriteButton.tsx
Comment thread src/hooks/useFavoriteMover.ts Outdated
- onMutate를 authScope:moverId 큐로 직렬화해 빠른 연속 클릭 시 마지막 클릭 상태로 캐시가 반영되도록 수정
- 이전 요청의 401로 requireLogin()이 호출되던 문제 수정 (isLatestRequest일 때만 처리)

@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: 2

🧹 Nitpick comments (1)
src/hooks/useFavoriteMover.ts (1)

301-303: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

latestFavoriteRequestIdsmoverId 키를 사용합니다.

큐는 authScope:moverId 키로 세션을 분리합니다(L88~90). 그러나 최신 요청 판별 맵은 moverId만 사용합니다. 계정 전환 시 서로 다른 세션의 요청이 같은 항목을 덮어씁니다. favoriteRequestId가 전역 증가하므로 현재 동작은 안전한 방향으로 기울지만, 두 자료구조의 키 규칙이 다르면 이후 변경에서 오판이 생기기 쉽습니다. getFavoriteQueueKey(variables.authScope, variables.moverId)로 통일하는 것을 권장합니다.

Also applies to: 341-346

🤖 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/useFavoriteMover.ts` around lines 301 - 303, Update the
latest-request tracking in the error and success handling around
latestFavoriteRequestIds so it uses the same session-scoped key as the favorite
queue: derive the key with getFavoriteQueueKey(variables.authScope,
variables.moverId) for both lookups and writes. Preserve the existing request-id
comparison behavior while applying this key consistently across the affected
handlers.
🤖 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/useFavoriteMover.ts`:
- Around line 173-189: Update the mutation cache-key calculations throughout the
favorite mutation callbacks to consistently use variables.authScope captured as
requestAuthScope, not the latest rendered authScope. Apply this to the list,
favorite-list, and mover-detail keys in onMutate and the corresponding rollback
and invalidation logic in onError and onSettled, preserving each callback’s
existing behavior.

In `@src/stories/FavoriteButton.stories.tsx`:
- Around line 108-125: Update the button lookup in the story’s play function to
query its accessibility name with a regular expression matching the “김무빙 기사님 찜”
prefix, so it also matches the dynamic count suffix. Keep the existing
aria-pressed and count assertions unchanged.

---

Nitpick comments:
In `@src/hooks/useFavoriteMover.ts`:
- Around line 301-303: Update the latest-request tracking in the error and
success handling around latestFavoriteRequestIds so it uses the same
session-scoped key as the favorite queue: derive the key with
getFavoriteQueueKey(variables.authScope, variables.moverId) for both lookups and
writes. Preserve the existing request-id comparison behavior while applying this
key consistently across the affected handlers.
🪄 Autofix

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: 2cda3478-8dd9-45f0-84d0-9aac50446832

📥 Commits

Reviewing files that changed from the base of the PR and between 598a649 and c342f3b.

📒 Files selected for processing (3)
  • src/components/mover/FavoriteButton.tsx
  • src/hooks/useFavoriteMover.ts
  • src/stories/FavoriteButton.stories.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/components/mover/FavoriteButton.tsx

Comment thread src/hooks/useFavoriteMover.ts
Comment thread src/stories/FavoriteButton.stories.tsx

@juengseulki juengseulki left a comment

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.

📋 PR 리뷰

👍 좋았던 점

  • 동일 기사에 대한 찜 요청을 Promise 큐로 직렬화해 서버 요청 순서를 클릭 순서와 일치시켰습니다.
  • 큐 키에 authScope를 포함해 다른 계정의 찜 요청이 같은 큐를 공유하지 않도록 분리했습니다.
  • 큐 실행 직전 현재 세션을 재확인해 이전 계정의 대기 요청이 새 계정 인증으로 실행되는 문제를 방지했습니다.
  • 세션 변경으로 폐기된 요청을 별도 Sentinel Error로 구분하고 일반 실패 처리와 사용자 알림에서 제외했습니다.
  • API 요청뿐 아니라 낙관적 업데이트 구간도 직렬화해 빠른 연속 클릭 시 캐시 반영 순서를 보장했습니다.
  • 요청별 ID와 최신 요청 ID를 비교해 이전 요청의 실패 롤백이 마지막 사용자 상태를 덮어쓰지 않도록 처리했습니다.
  • 이전 요청의 401이 뒤늦게 로그인 모달을 열지 않도록 최신 요청 여부를 함께 확인했습니다.
  • 가장 마지막 요청이 종료됐을 때만 관련 Query를 무효화해 중간 refetch가 최신 낙관적 상태를 덮어쓰는 문제를 줄였습니다.
  • FavoriteButton으로 하트, 찜 개수, 토글 이벤트, 접근성 속성을 공통화해 사용 화면 간 동작을 일관되게 정리했습니다.
  • 실제 button, aria-pressed, 기사명 기반 aria-label, 키보드 포커스 스타일을 적용해 접근성을 보완했습니다.
  • 카드 내부에서 버튼 클릭 시 상위 링크 이동이 발생하지 않도록 이벤트 처리를 공통 컴포넌트에 포함했습니다.
  • Storybook에서 주요 Props를 문서화하고 Play Function으로 토글 상태와 찜 개수 변경을 검증했습니다.
  • 기사 카드, 기사 상세, 견적 카드 및 견적 상세 등 여러 화면에 적용하면서 화면별 크기와 배치는 확장 Class Props로 유지했습니다.
  • PR 본문 기준 로컬 동작, 기존 기능 영향, lint, API 명세 확인까지 완료되었습니다.

🔍 확인 및 제안

필수 수정사항은 보이지 않았습니다.

가벼운 제안으로, latestFavoriteRequestIds는 현재 moverId만 키로 사용하고 있습니다. 요청 큐는 authScope:moverId로 분리되어 있지만 최신 요청 ID는 세션 간 공유되므로, 계정 전환 직후 동일 기사에 새 요청이 발생하면 이전 세션 요청이 최신 요청이 아닌 것으로 처리됩니다. 현재 의도상 이전 세션의 에러·롤백·무효화를 억제하는 결과이므로 동작에는 오히려 안전해 보입니다. 다만 큐와 동일한 기준으로 의미를 통일하고 싶다면 최신 요청 ID도 queueKey 기준으로 관리할 수 있습니다.

또한 FavoriteButtoninteractive={false} 상태에서는 onToggle이 사용되지 않지만 Props에서 필수입니다. 현재 모든 사용처가 동일한 API를 유지하기에는 단순한 구조이고 문제는 없지만, 읽기 전용 사용 사례가 늘어난다면 Discriminated Union으로 interactive: false일 때 onToggle을 선택적으로 만드는 방향도 고려할 수 있습니다.

전체적으로 연속 클릭, 이전 요청 실패, 세션 전환이라는 까다로운 케이스를 잘 분리했고, 공통 컴포넌트 추출도 기존 화면 디자인을 유지하는 선에서 적절하게 이뤄졌습니다. 수고하셨습니다! 😊


To Reviewer 내용 기준으로 연속 요청 처리와 공통 컴포넌트 구조를 중점적으로 확인했습니다!

연속된 찜 추가·해제 요청은 동일한 authScope + moverId 큐에 등록되고,
앞선 요청이 성공하거나 실패한 뒤 다음 요청이 실행되므로
서버에는 사용자의 클릭 순서대로 전달되는 구조입니다.

중간 요청이 실패하더라도 큐 Tail은 성공 상태로 정리되어
이후 요청이 막히지 않는 부분도 적절합니다.

낙관적 업데이트 역시 별도의 기사별 큐로 직렬화되어
cancelQueries, 이전 캐시 Snapshot 저장, 캐시 수정이 클릭 순서대로 실행됩니다.
따라서 빠르게 연속 클릭하더라도 마지막 클릭의 상태가 최종 UI에 반영될 수 있습니다.

이전 요청의 에러가 최신 상태를 덮어쓰는 문제도
요청별 requestIdlatestFavoriteRequestIds 비교로 방지하고 있습니다.

  • 최신 요청 실패: 해당 요청의 Snapshot으로 롤백
  • 이전 요청 실패: 롤백 및 사용자 오류 안내 생략
  • 이전 요청 401: 로그인 모달 미노출
  • 중간 요청 종료: Query 무효화 생략
  • 마지막 요청 종료: 관련 Query 무효화 실행

흐름으로 구성되어 있어 이전 응답이 최종 낙관적 상태를 덮어쓰지 않도록 잘 처리된 것으로 보입니다.

계정 전환 대응도 큐 키에 authScope를 포함하고,
실제 요청 실행 직전에 ref를 통해 현재 세션을 다시 검사하므로
이전 계정의 대기 요청이 새 계정의 인증으로 전송되는 문제를 막을 수 있습니다.

FavoriteButton 분리 방향도 적절합니다.

공통화 대상이 된 책임은 다음처럼 UI 공통 책임에 집중되어 있습니다.

  • 하트 아이콘
  • 찜 개수 표시
  • 개수 위치
  • 토글 이벤트
  • 카드 링크 이벤트 전파 차단
  • aria-label
  • aria-pressed
  • 키보드 포커스 스타일

화면별 차이는 className, iconClassName, countClassName, countVariant,
countPosition으로만 확장하고 있어 기존 디자인을 유지하면서 중복을 줄인 구조로 보입니다.

현재 Props 중 즉시 제거해야 할 정도로 불필요한 항목은 보이지 않았으며,
읽기 전용 사용 사례가 늘어나면 interactive: false일 때
onToggle을 선택값으로 만드는 타입 개선 정도를 추후 고려할 수 있습니다.

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.

2 participants