Skip to content

[feat] 내 책장 페이지 구현#60

Merged
choiyoungae merged 15 commits intodevelopfrom
feat/books-53
Feb 7, 2026
Merged

[feat] 내 책장 페이지 구현#60
choiyoungae merged 15 commits intodevelopfrom
feat/books-53

Conversation

@choiyoungae
Copy link
Contributor

@choiyoungae choiyoungae commented Feb 6, 2026

🚀 풀 리퀘스트 제안

📋 작업 내용

  • 내 책장 조회 기능 구현 (읽는 중/읽은 책 탭 분리)
  • 내 책장 편집 기능 구현 (책 삭제)
  • 책 검색 및 추가 기능 구현

🔧 변경 사항

내 책장 조회

  • BookList 컴포넌트: 읽는 중/읽은 책 목록 표시, 빈 상태 처리
  • BookCard 컴포넌트: 책 카드 UI (썸네일, 제목, 저자, 진행률/별점)
  • useBooks 훅: 내 책장 도서 목록 조회 API 연동

내 책장 편집

  • 편집 모드 토글 기능 추가
  • 책 선택 및 삭제 기능 구현
  • useDeleteBook 훅: 책 삭제 API 연동

책 추가

  • BookSearchModal 컴포넌트: 카카오 도서 검색 모달
  • useSearchBooks 훅: 카카오 도서 검색 API 연동
  • useCreateBook 훅: 내 책장에 책 추가 API 연동

변경된 파일

  • src/features/book/ - API, 타입, 컴포넌트, 훅 추가
  • src/pages/Books/BookListPage.tsx - 페이지 로직 구현

📸 스크린샷 (선택 사항)

image image image

📄 기타

  • api 수정 요청해두어 추후 api 업데이트 시 반영 예정입니다.
  • 디자인 관련 문의를 남겨두어 답변에 따라 수정 예정입니다.

Summary by CodeRabbit

  • 새로운 기능

    • 상태·책모임·평점 필터, 정렬과 커서 기반 무한 스크롤 책 목록(탭별 카운트 포함)
    • 디바운스·무한스크롤 책 검색 모달 — 검색 후 바로 등록/선택 가능한 흐름
    • 썸네일·저자·평점·모임 배지 표시 책 카드와 편집모드 다중선택 UI
    • 목록에서 다중 선택 일괄 삭제, 탭별 뷰 및 전체적인 북리스트 페이지 개선
  • 기타

    • 책 조회/검색/생성/삭제용 공개 API와 관련 쿼리 훅 추가(목록 캐시 자동 갱신)
    • 컴포넌트 재수출(바렐) 추가 및 일부 탭 스타일 소폭 조정
    • 상태 열거값 정리(대기 상태 제거)

@choiyoungae choiyoungae self-assigned this Feb 6, 2026
@choiyoungae choiyoungae linked an issue Feb 6, 2026 that may be closed by this pull request
6 tasks
@coderabbitai
Copy link

coderabbitai bot commented Feb 6, 2026

Walkthrough

책 기능에 책 조회·검색·생성·삭제 API(실/모크)와 관련 타입을 추가하고, 무한스크롤·필터·정렬·대량선택·삭제·검색(모달·디바운스) 기능을 갖춘 BookListPage 및 관련 컴포넌트·훅들을 구현했습니다. (50단어 내)

Changes

Cohort / File(s) Summary
API & Types
src/features/book/book.api.ts, src/features/book/book.types.ts
getBooks, searchBooks, createBook, deleteBook 추가. mock 기반 필터·정렬·커서 페이징 시뮬레이터 구현. BookListItem·GetBooksParams·GetBooksResponse·SearchBooksParams/Response·CreateBookBody 등 타입 추가 및 BookReadingStatus에서 PENDING 제거.
Components — 리스트/카드/검색/리뷰
src/features/book/components/BookList.tsx, src/features/book/components/BookCard.tsx, src/features/book/components/BookSearchModal.tsx, src/features/book/components/BookReviewModal.tsx
무한스크롤·필터·정렬·편집 모드 지원 BookList, 선택 가능한 BookCard, 디바운스+무한스크롤 BookSearchModal(검색→선택→등록), BookReviewModal에 default export 추가. 선택/편집/등록/삭제 흐름 포함.
Components — 인덱스 & 바렐
src/features/book/components/index.ts, src/features/book/index.ts
컴포넌트 바렐 추가로 여러 컴포넌트 재수출(예: BookCard, BookList, BookSearchModal 등).
Pages — BookListPage
src/pages/Books/BookListPage.tsx
탭(전체/읽는중/완료), 편집·대량선택·삭제, 검색 모달 통합 구현(무한스크롤·선택 로직·삭제 흐름·모달 연동).
Hooks & React Query
src/features/book/hooks/...
무한스크롤용 useBooks, 검색용 useSearchBooks, 생성/삭제 뮤테이션 훅(useCreateBook, useDeleteBook) 추가. 쿼리 키 생성과 캐시 무효화 로직 포함. 재수출 추가(src/features/book/hooks/index.ts).
UI 미세 변경
src/shared/ui/Tabs.tsx
Tabs 루트의 gap 제거 및 TabsList spacing 클래스 변경(스타일 조정).

Sequence Diagram

sequenceDiagram
    participant User as User
    participant Page as BookListPage
    participant List as BookList
    participant Modal as BookSearchModal
    participant API as Book API

    User->>Page: 페이지 진입 / 탭 선택
    Page->>API: getBooks(status, filters, cursor)
    API-->>Page: GetBooksResponse(items, nextCursor, counts)
    Page->>List: render(items, filters, editState)

    User->>List: 스크롤 -> 더보기 트리거
    List->>API: getBooks(cursor)
    API-->>List: next page

    User->>List: 편집모드에서 책 선택
    List->>Page: onSelectToggle(bookId)
    Page->>Page: selectedBookIds 업데이트

    User->>Page: 삭제 요청(확인)
    Page->>API: deleteBook(bookId) × N
    API-->>Page: 삭제 결과
    Page->>API: getBooks(...) (갱신)

    User->>Page: 검색 버튼 -> Modal 열기
    User->>Modal: 검색어 입력
    Modal->>API: searchBooks(query,page)
    API-->>Modal: SearchBooksResponse
    User->>Modal: 항목 선택 -> createBook(body)
    Modal->>API: createBook(body)
    API-->>Modal: BookDetail
    Modal->>Page: onSuccess (리스트 갱신 트리거)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • mgYang53
  • haruyam15
🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.87% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 제목이 'feat: 내 책장 페이지 구현'이며, 변경사항의 핵심인 BookListPage 전체 구현, BookList/BookCard 컴포넌트 추가, 책 조회/삭제/검색/추가 API 연동을 정확히 반영하고 있습니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/books-53

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In `@src/features/book/components/BookSearchModal.tsx`:
- Around line 118-125: The list rendering in BookSearchModal uses book.isbn as
the React key which can be duplicate or empty; update the books.map key to a
stable unique value by combining book.isbn with the iteration index or a unique
book identifier if available (e.g., book.id) and ensure you fallback when isbn
is falsy (e.g., `${book.isbn || 'no-isbn'}-${index}`) so BookSearchItem renders
reliably; locate the map where BookSearchItem is returned and change the key
there, leaving onClick={handleSelectBook} and disabled={isRegistering} intact.
- Around line 71-85: The handleSelectBook flow doesn't handle errors from
registerBook, so if mutateAsync rejects the modal stays open with no feedback;
wrap the await registerBook(...) call inside a try/catch in handleSelectBook,
call onOpenChange(false), onSuccess(), and resetState() only on success (inside
try), and in the catch block show a user-visible error (e.g., call an existing
toast/notification or invoke an onError callback) and leave the modal open (or
explicitly reset any loading state like isRegistering) so the user can retry;
reference handleSelectBook, registerBook, onOpenChange, resetState, onSuccess,
and isRegistering when making the changes.

In `@src/pages/Books/BookListPage.tsx`:
- Around line 42-50: handleSelectAll currently uses allBooks (from useBooks)
which ignores the active tab filter; change it to operate only on the currently
visible/filtered books for the active tab. Track or read the active tab state
(e.g., tab state variable used by the BookList or parent) and use the list of
books shown in that tab (e.g., visibleBooks / filteredBooks prop returned by
useBooks or passed into BookList) instead of allBooks; then update
setSelectedBookIds to new Set(visibleBooks.map(b => b.bookId)) or clear it when
everything visible is selected. Update the handleSelectAll function and any
place where allBooks was assumed to be the UI list so selection aligns with the
current tab.
- Around line 53-70: The handleDelete function currently fires deleteBook calls
with Promise.all and lacks error handling and concurrency control; change it to
wrap the deletion logic in a try/catch, use Promise.allSettled (or a bounded
concurrency approach like batching or a p-limit) to call deleteBook for each id
so partial failures are captured, inspect results to collect and surface failed
ids (e.g., show a user-facing error/toast for failures), only clear
selectedBookIds and setIsEditMode(false) for the successfully deleted items (or
if all succeeded), and rethrow or log the aggregate error as appropriate; update
references in this flow (handleDelete, deleteBook, setSelectedBookIds,
setIsEditMode) so unhandled rejections are avoided and server load is limited.

In `@src/shared/ui/Tabs.tsx`:
- Line 29: The Tabs shared component change removed gap-2 from
TabsPrimitive.Root and changed TabsList gap from gap-4 to gap-medium, which
alters spacing for consumers like BookReviewModal; revert the breaking style
change by restoring the original spacing: re-add "gap-2" to the className on
TabsPrimitive.Root and change TabsList back to use "gap-4" (16px) instead of
"gap-medium" so existing defaults remain unchanged; if the intent is to change
spacing globally, instead update each consumer (e.g., BookReviewModal) to
explicitly pass a className (like "gap-4" or "gap-0") so their layout is
preserved—locate TabsPrimitive.Root and TabsList in src/shared/ui/Tabs.tsx and
make the className edits accordingly.
🧹 Nitpick comments (8)
src/features/book/components/BookCard.tsx (1)

85-89: gatheringNames에 중복 이름이 있으면 key 충돌 발생 가능

name을 key로 사용하고 있는데, 같은 이름이 두 번 들어오면 React key 충돌이 발생합니다. 서버에서 중복을 보장하지 않는다면 index를 조합하는 것이 안전합니다.

제안
-            {gatheringNames.map((name) => (
-              <Badge key={name} color={name === selectedGatheringName ? 'green' : 'grey'}>
+            {gatheringNames.map((name, index) => (
+              <Badge key={`${name}-${index}`} color={name === selectedGatheringName ? 'green' : 'grey'}>
                 {name}
               </Badge>
             ))}
src/features/book/book.types.ts (1)

314-356: SearchBookItem.authorsstring[], CreateBookBody.authorsstring — 의도된 차이인지 확인 필요

카카오 검색 API 응답(authors: string[])과 내부 등록 API(authors: string)의 타입이 다릅니다. useCreateBook 예제에서 join(', ')으로 변환하는 흐름은 확인되나, 이 변환 책임이 컴포넌트에 분산되면 일관성 유지가 어려울 수 있습니다.

변환 로직을 CreateBookBody 생성 유틸이나 API 레이어에 두는 것도 고려해보세요.

src/features/book/book.api.ts (1)

834-836: searchBookscreateBookUSE_MOCK 분기가 없음

다른 API 함수들(getBooks, deleteBook 등)은 모두 USE_MOCK 분기가 있는데, searchBooks(Line 834)와 createBook(Line 857)에는 없습니다. 목데이터 모드에서 이 두 함수 호출 시 실제 API를 치게 됩니다.

의도된 것이라면 주석으로 사유를 남겨두면 좋겠습니다.

src/features/book/hooks/useBooks.ts (1)

12-17: bookListKeysbookKeys를 spread하여 all 키를 공유하는 점 확인 필요

bookListKeys.allbookKeys.all (['books'])과 동일하므로, useDeleteBook/useCreateBookinvalidateQueries({ queryKey: bookListKeys.all })가 book detail 쿼리까지 무효화합니다.

삭제 시에는 합리적이지만, 책 등록 시에는 불필요한 detail 쿼리 refetch가 발생할 수 있습니다. 현재 규모에서는 문제 없으나, 향후 list만 무효화하려면 키를 분리하는 것을 고려해보세요.

src/features/book/components/BookSearchModal.tsx (1)

98-134: onOpenChangehandleClose의 중복 호출 가능성 점검.

ModalonOpenChange={onOpenChange}ModalContentonEscapeKeyDown={handleClose} / onPointerDownOutside={handleClose}가 동시에 발동하면 onOpenChange(false)가 두 번 호출될 수 있습니다. Radix Dialog 동작상 기능적 문제는 없지만, resetState()도 중복 실행됩니다.

onOpenChange에서 resetState를 통합 처리하면 깔끔합니다:

- <Modal open={open} onOpenChange={onOpenChange}>
-   <ModalContent variant="wide" onEscapeKeyDown={handleClose} onPointerDownOutside={handleClose}>
+ <Modal open={open} onOpenChange={(nextOpen) => { onOpenChange(nextOpen); if (!nextOpen) resetState(); }}>
+   <ModalContent variant="wide">
src/features/book/components/BookList.tsx (2)

187-187: 반응형 그리드 미적용.

grid-cols-6이 고정되어 있어 작은 화면에서는 카드가 지나치게 좁아질 수 있습니다. 필요 시 반응형 브레이크포인트 추가를 고려해 주세요.

- <div className="grid grid-cols-6 gap-large mt-large">
+ <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-large mt-large">

참고 사항으로만 남겨두겠습니다.


209-224: 스켈레톤도 동일하게 반응형 적용 필요.

BookListSkeletongrid-cols-6도 실제 목록과 동일한 그리드 설정을 사용해야 로딩 → 데이터 전환 시 레이아웃 시프트가 없습니다.

src/pages/Books/BookListPage.tsx (1)

112-112: 주석 처리된 코드 제거 권장.

불필요한 주석 코드는 정리해 주세요. 필요 시 Git 히스토리에서 복원할 수 있습니다.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@src/pages/Books/BookListPage.tsx`:
- Around line 25-35: The handleSelectAll behavior uses currentTabBooks (from
useBooks({ status: currentStatus })) which does not include BookList's internal
filters (meeting/rating), so selecting all picks hidden items; fix by exposing
the currently-visible book IDs from BookList to the parent and using those for
handleSelectAll or by moving the select-all logic into BookList itself: update
BookList to provide a prop callback or ref (e.g., onVisibleIdsChange or
getVisibleIdsRef) that returns the filtered bookId array, then change
handleSelectAll to read from that callback/ref instead of currentTabBooks
(alternatively, implement handleSelectAll inside BookList and call a parent
handler for the resulting selection).
- Around line 96-101: The forEach callback implicitly returns the boolean from
Set.delete due to the arrow function's concise body, causing a Biome lint error;
update the callback in the setSelectedBookIds updater to use a block body so it
does not return a value (e.g., replace succeededIds.forEach((id) =>
next.delete(id)) with succeededIds.forEach((id) => { next.delete(id) })) or
alternatively use a for...of loop over succeededIds to call next.delete(id)
inside a statement block; keep the surrounding setSelectedBookIds and local
variable next unchanged.
🧹 Nitpick comments (6)
src/features/book/components/BookSearchModal.tsx (2)

109-145: 검색 결과가 없을 때 빈 상태 UI 누락.

debouncedQuery가 존재하고 books.length === 0일 때, 사용자에게 "검색 결과 없음" 안내가 표시되지 않습니다. 빈 화면만 보이면 로딩 중인지 결과가 없는지 구분할 수 없습니다.

♻️ 빈 상태 메시지 추가 제안
             {isFetchingNextPage && (
               <div className="py-base text-center text-grey-500 typo-body2">로딩 중...</div>
             )}
+            {!isFetchingNextPage && debouncedQuery && books.length === 0 && (
+              <div className="py-xlarge text-center text-grey-500 typo-body2">
+                검색 결과가 없습니다.
+              </div>
+            )}
           </div>

110-111: Modal onOpenChangehandleClose의 중복 호출 가능성.

shadcn Dialog 기반이라면, Escape/외부 클릭 시 Dialog primitive가 onOpenChange(false)를 자동 호출합니다. 동시에 onEscapeKeyDown={handleClose} / onPointerDownOutside={handleClose}에서도 onOpenChange(false)를 호출하므로 부모의 상태 setter가 두 번 실행됩니다. 기능상 idempotent하지만, resetState()가 불필요하게 두 번 실행될 수 있습니다.

handleClose 대신 e.preventDefault()로 기본 닫힘을 막고 직접 제어하거나, onOpenChange에서 resetState를 처리하는 방식이 더 깔끔합니다.

♻️ onOpenChange에서 상태 초기화 통합
+ const handleOpenChange = (nextOpen: boolean) => {
+   onOpenChange(nextOpen)
+   if (!nextOpen) resetState()
+ }
+
  return (
-   <Modal open={open} onOpenChange={onOpenChange}>
-     <ModalContent variant="wide" onEscapeKeyDown={handleClose} onPointerDownOutside={handleClose}>
+   <Modal open={open} onOpenChange={handleOpenChange}>
+     <ModalContent variant="wide">
src/features/book/components/BookList.tsx (2)

51-56: 탭 전환 시 필터 상태가 초기화되지 않음.

status prop이 변경되어도 selectedGathering, rating, sortType 등 내부 필터 상태가 유지됩니다. "기록 중" 탭에서 별점 필터를 설정한 뒤 "전체" 탭으로 전환하면 해당 필터가 그대로 적용되어 사용자가 혼란을 느낄 수 있습니다.

의도된 동작이라면 무시해도 되지만, 탭별 독립 필터를 원한다면 status 변경 시 초기화가 필요합니다.

♻️ status 변경 시 필터 초기화
+ useEffect(() => {
+   setSelectedGathering('')
+   setRating(null)
+   setSortType('LATEST')
+ }, [status])

110-116: 에러 시 필터 UI가 사라지는 점 참고.

isError 조건의 early return으로 필터/정렬 UI 전체가 렌더링되지 않습니다. 사용자가 필터 조건을 변경해 재시도할 수 없게 됩니다. 에러 상태를 필터 아래에 표시하는 것이 UX상 더 나을 수 있습니다.

src/pages/Books/BookListPage.tsx (2)

104-106: 중복 조건 정리 가능.

failedCount === 0이면 succeededIds.size === bookIds.length도 항상 참이므로 || 뒤 조건은 불필요합니다.

♻️ 조건 단순화
- if (failedCount === 0 || succeededIds.size === bookIds.length) {
+ if (failedCount === 0) {

157-157: 주석 처리된 코드 제거 권장.

{/* <TextButton onClick={handleEditModeToggle}>취소</TextButton> */} — 사용하지 않는 코드는 제거하고, 필요 시 git history에서 복원하는 것이 깔끔합니다.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@src/pages/Books/BookListPage.tsx`:
- Around line 147-160: The UI leaves users stuck in edit mode because the cancel
button is commented out; restore cancel functionality by uncommenting the
TextButton that calls handleEditModeToggle (or re-add an equivalent button)
inside the isEditMode branch so users can exit edit mode without deleting items;
ensure the handler handleEditModeToggle is imported/defined and wired to update
isEditMode state and clear selectedBookIds (consistent with
handleSelectAll/handleDelete behavior).
- Around line 50-59: The current isAllSelected logic and handleSelectAll rely
only on Set.size which can misclassify selection after filters change; update
isAllSelected to check that every id in filteredBookIds exists in
selectedBookIds (i.e., filteredBookIds.every(id => selectedBookIds.has(id))) and
change handleSelectAll to use that membership check (if all visible selected ->
clear only visible, else add all visible), and add a useEffect that runs on
filteredBookIds changes to prune selectedBookIds to the intersection with
filteredBookIds (setSelectedBookIds(prev => new Set([...prev].filter(id =>
visible.has(id))))), ensuring stale/off-screen selections are removed.
🧹 Nitpick comments (2)
src/pages/Books/BookListPage.tsx (2)

28-35: 탭 카운트용 useBooks 호출이 별도 쿼리를 발생시킵니다.

useBooks({ status: currentStatus })는 BookList 내부의 useBooks 호출과 별개의 쿼리 키를 가지므로, 탭당 최소 두 번의 API 호출이 발생합니다. 만약 API 응답이 status 필터와 무관하게 항상 세 가지 카운트를 반환한다면, useBooks({})로 한 번만 호출하고 카운트를 공유하는 것이 효율적입니다.


62-109: Promise.allSettled + 부분 실패 처리 잘 적용되었습니다 👍

이전 리뷰 피드백이 반영되어 에러 핸들링이 견고해졌습니다. 한 가지 작은 참고사항:

Line 106의 조건 failedCount === 0 || succeededIds.size === bookIds.length에서, succeededIds.size + failedCount === bookIds.length이므로 두 조건은 논리적으로 동치입니다. failedCount === 0만으로 충분합니다.

♻️ 조건 단순화
-   if (failedCount === 0 || succeededIds.size === bookIds.length) {
+   if (failedCount === 0) {
      setIsEditMode(false)
    }

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@src/pages/Books/BookListPage.tsx`:
- Line 1: The file BookListPage.tsx has Prettier formatting issues flagged by
CI; run the formatter and commit the changes (e.g., run prettier --write on the
file or your repo) so the import line using useCallback and useState and the
rest of the file conform to project Prettier rules; ensure saved/committed
formatted output removes the CI warning.
- Around line 108-111: The condition checking "if (failedCount === 0 ||
succeededIds.size === bookIds.length) setIsEditMode(false)" is redundant because
succeededIds.size + failedCount === bookIds.length for Promise.allSettled
results; replace the OR with a single simplified check (e.g., failedCount === 0
or succeededIds.size === bookIds.length — pick one) and update the block around
failedCount, succeededIds, bookIds and setIsEditMode to use only that one
condition to exit edit mode.
🧹 Nitpick comments (3)
src/pages/Books/BookListPage.tsx (3)

25-35: 탭 전환 시 카운트 배지가 0으로 깜빡일 수 있음.

currentStatus가 탭에 따라 바뀌면서 queryKey가 변경됩니다. 첫 방문 탭의 데이터가 아직 캐시에 없으면 dataundefined?? 0으로 배지가 잠깐 0을 표시합니다.

카운트는 탭 필터와 무관하게 항상 동일한 값이어야 하므로, status 없는 별도 쿼리에서 가져오는 게 안정적입니다.

♻️ 카운트 전용 쿼리 분리 제안
- // 탭에 따른 status 매핑
- const currentStatus =
-   activeTab === 'all' ? undefined : activeTab === 'reading' ? 'READING' : 'COMPLETED'
-
- // 현재 탭의 책 목록 조회 (전체선택용)
- const { data } = useBooks({ status: currentStatus })
-
- // 첫 페이지에서 카운트 정보 가져오기
- const firstPage = data?.pages[0]
- const totalCount = firstPage?.totalCount ?? 0
- const readingCount = firstPage?.readingCount ?? 0
- const completedCount = firstPage?.completedCount ?? 0
+ // 탭에 따른 status 매핑
+ const currentStatus =
+   activeTab === 'all' ? undefined : activeTab === 'reading' ? 'READING' : 'COMPLETED'
+
+ // 카운트 정보는 필터 무관 쿼리에서 안정적으로 가져오기
+ const { data: countData } = useBooks()
+ const firstPage = countData?.pages[0]
+ const totalCount = firstPage?.totalCount ?? 0
+ const readingCount = firstPage?.readingCount ?? 0
+ const completedCount = firstPage?.completedCount ?? 0

79-80: N개 삭제 시 개별 mutation마다 invalidateQueries 발생.

useDeleteBookonSuccess에서 매번 bookListKeys.all을 invalidate합니다. 10권 삭제 → 최대 10회 invalidation + refetch 시도.

모든 삭제 완료 후 한 번만 invalidate하도록 onSuccess를 여기서 수동 호출하거나, mutation 옵션에서 조건부로 처리하는 편이 효율적입니다.

♻️ 삭제 완료 후 수동 invalidate 제안
+ import { useQueryClient } from '@tanstack/react-query'
+ import { bookListKeys } from '@/features/book'
  export default function BookListPage() {
-   const { mutateAsync: deleteBook } = useDeleteBook()
+   const queryClient = useQueryClient()
+   const { mutateAsync: deleteBook } = useDeleteBook()
    ...

    const results = await Promise.allSettled(bookIds.map((id) => deleteBook(id)))

+   // 삭제 완료 후 한 번만 invalidate
+   await queryClient.invalidateQueries({ queryKey: bookListKeys.all })

이 경우 useDeleteBookonSuccess invalidation은 제거하거나, mutateAsync 호출 시 { onSuccess: undefined }로 오버라이드할 수 있습니다.


131-145: useCallbackisEditMode 의존성으로 편집 모드 토글 시 BookList 리렌더 유발.

onFilteredBooksChange 참조가 매번 새로 생성되어, BookList 내부에서 이 prop을 useEffect 의존성 등에 사용하면 불필요한 재실행이 발생합니다.

useRefisEditMode를 참조하면 콜백 identity를 안정적으로 유지할 수 있습니다.

♻️ ref 활용 제안
+ const isEditModeRef = useRef(isEditMode)
+ isEditModeRef.current = isEditMode
+
  const handleFilteredBooksChange = useCallback((bookIds: number[]) => {
    setFilteredBookIds(bookIds)

-   if (isEditMode) {
+   if (isEditModeRef.current) {
      const filteredSet = new Set(bookIds)
      setSelectedBookIds((prev) => {
        const cleaned = new Set([...prev].filter((id) => filteredSet.has(id)))
        if (cleaned.size !== prev.size) {
          return cleaned
        }
        return prev
      })
    }
- }, [isEditMode])
+ }, [])

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/pages/Books/BookListPage.tsx`:
- Around line 28-35: Tab switches cause data to be undefined for useBooks({
status: currentStatus }) leading totalCount/readingCount/completedCount to
briefly show 0; fix by decoupling the counts from the status-specific
query—either keep a separate call to useBooks() without status (e.g., call
useBooks() and read pages[0] for counts into
totalCount/readingCount/completedCount) or enable
keepPreviousData/placeholderData on the statused useBooks query so data isn't
briefly undefined when currentStatus changes; update the references where
totalCount, readingCount, and completedCount are derived to use the persistent
countData (or the keepPreviousData-enabled data) instead of the transient
status-specific data.
🧹 Nitpick comments (2)
src/pages/Books/BookListPage.tsx (2)

50-62: isAllSelected 로직이 두 곳에서 중복 계산됨.

Line 52-53의 allSelected와 Line 151-152의 isAllSelected가 동일한 조건입니다. isAllSelected 선언을 handleSelectAll 위로 올리면 중복을 제거할 수 있습니다.

♻️ 제안
+ // 멤버십 기반 전체 선택 여부 확인
+ const isAllSelected =
+   filteredBookIds.length > 0 && filteredBookIds.every((id) => selectedBookIds.has(id))
+
  // 전체 선택 (현재 화면에 표시된 책 기준, 멤버십 기반)
  const handleSelectAll = () => {
-   const allSelected =
-     filteredBookIds.length > 0 && filteredBookIds.every((id) => selectedBookIds.has(id))
-
-   if (allSelected) {
+   if (isAllSelected) {
      // 전체 해제
      setSelectedBookIds(new Set())
    } else {
      // 전체 선택
      setSelectedBookIds(new Set(filteredBookIds))
    }
  }
  
  // ... (handleDelete, handleEditModeToggle, handleTabChange, handleFilteredBooksChange)

- // 멤버십 기반 전체 선택 여부 확인
- const isAllSelected =
-   filteredBookIds.length > 0 && filteredBookIds.every((id) => selectedBookIds.has(id))

Also applies to: 150-152


65-112: handleDelete 전체를 감싸는 try/catch가 없어 unhandled rejection 위험.

Promise.allSettled 자체는 throw하지 않지만, openConfirm(Line 68, 92)이나 이후 상태 업데이트 중 예외가 발생하면 잡히지 않습니다. async 이벤트 핸들러에서 예외가 탈출하면 콘솔에 unhandled rejection이 남습니다.

🛡️ top-level try/catch 추가 제안
  const handleDelete = async () => {
+   try {
      if (selectedBookIds.size === 0) return

      const confirmed = await openConfirm(/* ... */)
      if (!confirmed) return

      // ... Promise.allSettled, cleanup 로직 ...
+   } catch (error) {
+     console.error('삭제 처리 중 오류:', error)
+   }
  }

@choiyoungae choiyoungae added the feat 새로운 기능 추가 label Feb 7, 2026

export default function BookListPage() {
return <div>Book List Page</div>
const { mutateAsync: deleteBook } = useDeleteBook()
Copy link
Contributor

Choose a reason for hiding this comment

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

api가 프론트에서 하나씩 여러번 보내야하는 구조군요........?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

네 api 상 한 개의 요청만 보내도록 되어있습니다...
담당자분께 문의 한번 드려보겠습니다!

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@src/features/book/components/BookSearchModal.tsx`:
- Around line 91-98: The Modal is receiving onOpenChange directly so closing via
built-in controls won't call resetState; wrap the existing onOpenChange prop
with a new handler (e.g., handleModalOpenChange) that calls the original
onOpenChange(value) and, when value === false, also calls resetState(); keep the
existing handleClose for onEscapeKeyDown/onPointerDownOutside and replace
Modal's onOpenChange prop with this wrapper, ensuring types/signature match the
original onOpenChange.

In `@src/pages/Books/BookListPage.tsx`:
- Around line 225-238: The onSelectBook callback currently awaits
createBook(...) without error handling, causing unhandled rejections to bubble
into BookSearchModal.handleSelectBook; wrap the await call in a try/catch inside
the onSelectBook passed to <BookSearchModal> (or add error handling inside
BookSearchModal.handleSelectBook) and on error surface a user-visible feedback
(e.g., call your toast/error handler or set an error state and keep the modal
open). Ensure you reference createBook, onSelectBook, BookSearchModal, and
handleSelectBook when implementing the try/catch so failures are caught and
reported to the user instead of propagating.
- Around line 127-145: The callback handleFilteredBooksChange currently depends
on isEditMode causing unnecessary re-creations; create a ref (e.g.,
isEditModeRef) that you update whenever isEditMode changes, remove isEditMode
from the useCallback dependency array, and have handleFilteredBooksChange read
isEditModeRef.current when deciding whether to prune setSelectedBookIds; keep
the existing calls to setFilteredBookIds and setSelectedBookIds but ensure the
ref is kept in sync via a small useEffect that assigns isEditModeRef.current =
isEditMode.
🧹 Nitpick comments (1)
src/features/book/book.types.ts (1)

26-35: BookListItemBookDetail의 필드 중복.

BookListItemBookDetail의 모든 필드를 포함하고 rating, gatheringNames만 추가합니다. BookDetail을 extend하면 중복을 줄일 수 있습니다.

♻️ 제안
-export interface BookListItem {
-  bookId: number
-  title: string
-  publisher: string
-  authors: string
-  bookReadingStatus: BookReadingStatus
-  thumbnail: string
-  rating: number
-  gatheringNames: string[]
-}
+export interface BookListItem extends BookDetail {
+  rating: number
+  gatheringNames: string[]
+}

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@src/pages/Books/BookListPage.tsx`:
- Around line 200-225: The issue is that all three BookList instances call
onFilteredBooksChange while inactive TabsContent remain mounted; modify
handleFilteredBooksChange to ignore updates from non-active tabs by checking the
current active tab (e.g., compare a stored activeTab state) before setting
filteredBookIds, or alternatively pass the current active tab into each BookList
(prop name like activeTab or tabValue) and have BookList only invoke
onFilteredBooksChange when its tab matches that activeTab; reference: BookList,
TabsContent, handleFilteredBooksChange, filteredBookIds, activeTab.
- Around line 240-245: The catch block currently rethrows the error which
prevents the modal from closing; instead remove the `throw error` and ensure the
modal is explicitly closed after showing the error UI. Update the catch in
BookListPage (the block that calls openConfirm) to not rethrow and call the
modal close handler (e.g., `handleOpenChange(false)` or the relevant close
function used for BookSearchModal) so the error modal is shown and the
BookSearchModal is closed without propagating the error.
🧹 Nitpick comments (1)
src/pages/Books/BookListPage.tsx (1)

42-52: handleSelectToggle이 매 렌더마다 새로 생성되어 BookList에 불필요한 리렌더 유발 가능.

handleFilteredBooksChange처럼 useCallback으로 감싸면 BookList의 리렌더를 줄일 수 있습니다. setSelectedBookIds는 안정 참조이므로 의존성 배열은 빈 배열로 충분합니다.

♻️ useCallback 적용
- const handleSelectToggle = (bookId: number) => {
+ const handleSelectToggle = useCallback((bookId: number) => {
    setSelectedBookIds((prev) => {
      const next = new Set(prev)
      if (next.has(bookId)) {
        next.delete(bookId)
      } else {
        next.add(bookId)
      }
      return next
    })
- }
+ }, [])

@choiyoungae choiyoungae merged commit 7b897fd into develop Feb 7, 2026
2 checks passed
@choiyoungae choiyoungae deleted the feat/books-53 branch February 7, 2026 12:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat 새로운 기능 추가

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feat] 내 책장 페이지 구현

2 participants