Conversation
연동에 따른 로직 변경
searchBooks -> getSearchBooks
Walkthrough도서 검색/상세 기능을 서버 연동으로 전환했다. 신규 Books API 클라이언트를 추가하고, 검색/랭킹/상세/그룹 페이지를 해당 API에 맞게 리팩터링했다. 라우트에 ISBN 파라미터를 도입했고, 저장(북마크) 토글 및 로딩/에러 상태 처리와 클릭 내비게이션을 추가했다. Changes
Sequence Diagram(s)sequenceDiagram
actor User
participant SearchPage as Search Page
participant API as Books API
participant Result as BookSearchResult
User->>SearchPage: 입력(q) 변경
SearchPage-->>SearchPage: 디바운스 타이머
SearchPage->>API: GET /books?keyword=q&page=1&isFinalized=false
API-->>SearchPage: SearchBooksResponse
SearchPage->>Result: props(type='searching'/'searched', results)
User->>Result: 항목 클릭
Result->>SearchPage: navigate(/search/book/:isbn)
sequenceDiagram
actor User
participant Detail as SearchBook Page
participant API as Books API
participant Group as SearchBookGroup Page
User->>Detail: 접속(/search/book/:isbn)
Detail->>API: GET /books/{isbn}
Detail->>API: GET /books/{isbn}/recruiting-rooms
API-->>Detail: BookDetailResponse
API-->>Detail: RecruitingRoomsResponse
User->>Detail: 저장 버튼 클릭
Detail->>API: POST /books/{isbn}/saved { type: !isSaved }
API-->>Detail: SaveBookResponse (isSaved)
User->>Detail: 모집그룹 보기 클릭
Detail->>Group: navigate(/search/book/group, state={recruitingRooms, bookInfo})
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related issues
Possibly related PRs
Suggested reviewers
Poem
✨ Finishing Touches
🧪 Generate unit tests
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. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR/Issue comments)Type Other keywords and placeholders
CodeRabbit Configuration File (
|
|
The latest updates on your projects. Learn more about Vercel for Git ↗︎
|
There was a problem hiding this comment.
Actionable comments posted: 13
🔭 Outside diff range comments (2)
src/components/search/BookSearchResult.tsx (2)
33-43: 클릭 가능한 div → Link로 교체 및 key 안정화, ISBN URL 인코딩 적용현재 div+onClick은 키보드 접근성/스크린리더 친화성이 떨어집니다. 의미상 링크이므로 Link 사용이 적절합니다. 또한 key를
id가 아닌 고유한isbn으로 사용하는 게 안전합니다. URL에는encodeURIComponent를 적용하세요.- searchedBookList.map(book => ( - <BookItem key={book.id} onClick={() => navigate(`/search/book/${book.isbn}`)}> + searchedBookList.map(book => ( + <BookItem key={book.isbn} to={`/search/book/${encodeURIComponent(book.isbn)}`}> <Cover src={book.coverUrl} alt={`${book.title} 커버`} /> <BookInfo> <Title>{book.title}</Title> <Subtitle> {book.author} 저 · {book.publisher} </Subtitle> </BookInfo> </BookItem> ))추가로, Link 사용을 위해 import가 필요합니다(아래 스타일 변경 코멘트에 포함).
62-67: BookItem을 Link로 스타일링하고 기본 링크 스타일 제거Link 컴포넌트에 스타일을 입혀 시맨틱을 유지합니다.
-const BookItem = styled.div` +const BookItem = styled(Link)` display: flex; border-bottom: 1px solid ${colors.darkgrey.dark}; padding: 12px 0; cursor: pointer; + text-decoration: none; + color: inherit; `;아래 보조 변경(파일 외 범위)도 함께 적용해 주세요:
// import 보완 (파일 상단) import { Link, useNavigate } from 'react-router-dom';
🧹 Nitpick comments (11)
src/api/books/getMostSearchedBooks.ts (1)
11-14: 응답 메타데이터(페이지네이션) 필드 반영 고려API가 전체 개수/커서/마지막 여부 등의 메타데이터를 제공한다면 인터페이스에 옵션으로 반영해 둬야 후속 기능(무한스크롤 등) 구현이 수월합니다.
export interface MostSearchedBooksData { bookList: MostSearchedBook[]; + totalCount?: number; + nextCursor?: string | null; + isLast?: boolean; }src/pages/searchBook/SearchBookGroup.tsx (1)
29-31: null 병합 연산자로 의도 명확화0과 빈 배열은 유효한 값입니다. OR(||) 대신 nullish 병합(??)을 사용하면 의도가 더 명확해지고 잠재적 오해를 줄일 수 있습니다.
적용 diff:
- const groupList = recruitingRooms?.recruitingRoomList || []; - const totalCount = recruitingRooms?.totalRoomCount || 0; + const groupList = recruitingRooms?.recruitingRoomList ?? []; + const totalCount = recruitingRooms?.totalRoomCount ?? 0;src/api/books/postSaveBook.ts (1)
3-6: 불명확한 필드명(type) 검토 권장요청 바디의
type: boolean은 의미가 모호합니다(예: 저장/해제인지, 토글인지). 백엔드 계약이 허용한다면action: 'save' | 'unsave'또는isSaved: boolean처럼 의미가 명확한 필드명으로 교체하는 것을 권장합니다.src/api/books/getRecruitingRooms.ts (1)
29-33: ISBN 경로 파라미터 인코딩ISBN에 특수문자가 포함될 가능성에 대비해 인코딩하는 것이 안전합니다.
적용 diff:
- const response = await apiClient.get<RecruitingRoomsResponse>( - `/books/${isbn}/recruiting-rooms`, - ); + const response = await apiClient.get<RecruitingRoomsResponse>( + `/books/${encodeURIComponent(isbn)}/recruiting-rooms`, + );src/api/books/getSearchBooks.ts (1)
41-59: 빈 검색어 처리 가드 추가 제안
query.trim()결과가 빈 문자열이면 서버 호출을 생략하고 빈 결과를 반환/처리하도록 상위 레이어와 계약을 맞추는 것이 UX와 트래픽 측면에서 유리합니다. 구현 방식은 팀 합의에 따라 결정하세요(예: 호출 생략, 에러 throw, 클라이언트 캐시 반환 등).src/components/search/MostSearchedBooks.tsx (2)
13-34: 언마운트 시 setState 호출 방지비동기 응답 도중 언마운트되면 경고가 발생할 수 있습니다. 마운트 플래그로 방지하세요.
적용 diff:
useEffect(() => { - const fetchMostSearchedBooks = async () => { + let mounted = true; + const fetchMostSearchedBooks = async () => { try { setIsLoading(true); const response = await getMostSearchedBooks(); if (response.isSuccess) { - setBooks(response.data.bookList); + if (mounted) setBooks(response.data.bookList); } else { - setError(response.message); + if (mounted) setError(response.message); } } catch (error) { console.error('인기 검색 도서 조회 오류:', error); - setError('인기 검색 도서를 불러오는데 실패했습니다.'); + if (mounted) setError('인기 검색 도서를 불러오는데 실패했습니다.'); } finally { - setIsLoading(false); + if (mounted) setIsLoading(false); } }; fetchMostSearchedBooks(); - }, []); + return () => { + mounted = false; + }; + }, []);
111-122: 접근성: 클릭 가능한 li에 키보드 접근성 추가 고려li를 클릭 가능하게 만들었으므로 role, tabIndex, 키보드 이벤트(Enter/Space)를 추가하거나, 시맨틱하게 버튼/앵커로 구조화하는 것을 권장합니다.
원하시면 버튼/앵커로 마크업 전환하는 패치 제안 드리겠습니다.
src/pages/search/Search.tsx (1)
247-254: 로딩 메시지 UX 개선 필요현재 "검색 중..." 텍스트만 표시되고 있는데, 실제 로딩 스피너나 스켈레톤 UI를 추가하면 사용자 경험이 향상될 것입니다.
로딩 컴포넌트를 개선하거나 스켈레톤 UI를 도입하는 것을 고려해보세요. 예를 들어:
- 로딩 스피너 추가
- 책 검색 결과의 스켈레톤 UI 표시
- 프로그레스 바 표시
src/pages/searchBook/SearchBook.tsx (3)
128-145: 북마크 토글 시 낙관적 업데이트 적용 권장현재는 API 응답을 기다린 후 상태를 업데이트하고 있어 사용자 경험이 느릴 수 있습니다.
낙관적 업데이트를 적용하여 UX를 개선하세요:
const handleSaveButton = async () => { if (!isbn || isSaving) return; + const previousSavedState = isSaved; + setIsSaved(!isSaved); // 낙관적 업데이트 try { setIsSaving(true); const response = await postSaveBook(isbn, !isSaved); if (response.isSuccess) { setIsSaved(response.data.isSaved); } else { + setIsSaved(previousSavedState); // 실패 시 롤백 console.error('북마크 실패:', response.message); } } catch (error) { + setIsSaved(previousSavedState); // 에러 시 롤백 console.error('북마크 중 오류 발생:', error); } finally { setIsSaving(false); } };
203-208: mock 데이터와 실제 API 데이터 혼용책 상세 정보는 API에서 가져오면서 피드 데이터는 여전히 mock 데이터를 사용하고 있습니다. 이는 데이터 불일치를 야기할 수 있습니다.
피드 데이터도 API에서 가져오도록 수정이 필요합니다. 피드 API가 준비되어 있다면 연동 작업을 도와드릴 수 있습니다. 새로운 이슈를 생성할까요?
147-159: 로딩/에러 UI 개선 필요인라인 스타일을 사용하고 있고, 로딩과 에러 상태에 대한 적절한 컴포넌트가 없습니다.
별도의 로딩/에러 컴포넌트를 만들어 사용하세요:
- <div style={{ padding: '100px 20px', textAlign: 'center', color: 'white' }}> - {isLoading ? '로딩 중...' : error || '책 정보를 찾을 수 없습니다.'} - </div> + <LoadingErrorContainer> + {isLoading ? ( + <LoadingSpinner /> + ) : ( + <ErrorMessage>{error || '책 정보를 찾을 수 없습니다.'}</ErrorMessage> + )} + </LoadingErrorContainer>styled-components를 SearchBook.styled.ts 파일에 추가하세요.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (2)
src/assets/common/SaveIcon.svgis excluded by!**/*.svgsrc/assets/common/filledSaveIcon.svgis excluded by!**/*.svg
📒 Files selected for processing (12)
src/api/books/getBookDetail.ts(1 hunks)src/api/books/getMostSearchedBooks.ts(1 hunks)src/api/books/getRecruitingRooms.ts(1 hunks)src/api/books/getSearchBooks.ts(1 hunks)src/api/books/postSaveBook.ts(1 hunks)src/components/search/BookSearchResult.tsx(2 hunks)src/components/search/MostSearchedBooks.tsx(3 hunks)src/pages/index.tsx(1 hunks)src/pages/search/Search.tsx(4 hunks)src/pages/searchBook/SearchBook.styled.ts(2 hunks)src/pages/searchBook/SearchBook.tsx(4 hunks)src/pages/searchBook/SearchBookGroup.tsx(2 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (10)
src/api/books/getBookDetail.ts (1)
src/api/index.ts (1)
apiClient(27-33)
src/api/books/getRecruitingRooms.ts (1)
src/api/index.ts (1)
apiClient(27-33)
src/components/search/BookSearchResult.tsx (1)
src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts (1)
BookItem(172-179)
src/api/books/postSaveBook.ts (1)
src/api/index.ts (1)
apiClient(27-33)
src/api/books/getSearchBooks.ts (2)
src/pages/search/Search.tsx (1)
SearchedBook(14-21)src/api/index.ts (1)
apiClient(27-33)
src/api/books/getMostSearchedBooks.ts (1)
src/api/index.ts (1)
apiClient(27-33)
src/pages/search/Search.tsx (3)
src/api/books/getSearchBooks.ts (3)
SearchedBook(13-20)getSearchBooks(41-59)convertToSearchedBooks(61-70)src/components/search/BookSearchResult.tsx (1)
BookSearchResult(11-48)src/styles/global/global.ts (2)
colors(4-53)typography(56-77)
src/components/search/MostSearchedBooks.tsx (5)
src/api/books/getMostSearchedBooks.ts (2)
MostSearchedBook(4-9)getMostSearchedBooks(24-32)src/components/today-words/EmptyState.styled.ts (1)
EmptyMessage(14-19)src/pages/searchBook/SearchBook.styled.ts (1)
SubText(98-107)src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts (2)
BookList(166-170)BookItem(172-179)src/styles/global/global.ts (2)
colors(4-53)typography(56-77)
src/pages/searchBook/SearchBookGroup.tsx (2)
src/api/books/getRecruitingRooms.ts (1)
RecruitingRoomsData(14-19)src/components/group/GroupCard.tsx (1)
GroupCard(14-41)
src/pages/searchBook/SearchBook.tsx (5)
src/api/books/getBookDetail.ts (2)
BookDetail(4-14)getBookDetail(24-33)src/api/books/getRecruitingRooms.ts (2)
RecruitingRoomsData(14-19)getRecruitingRooms(29-40)src/api/books/postSaveBook.ts (1)
postSaveBook(22-32)src/mocks/searchBook.mock.ts (1)
mockSearchBook(1-76)src/components/search/IntroModal.tsx (1)
IntroModal(11-23)
🔇 Additional comments (4)
src/pages/searchBook/SearchBook.styled.ts (2)
129-129: 버튼에 포인터 커서 추가 LGTM인터랙션 요소의 가시성이 좋아집니다.
154-154: 포인터 커서 추가 LGTM클릭 가능성이 명확해졌습니다.
src/api/books/getRecruitingRooms.ts (1)
14-19: nextCursor null 가능성 확인페이지네이션에서 마지막 페이지면
nextCursor가 null로 내려오는 API도 많습니다. 현재는string으로만 선언되어 있어 런타임 불일치가 발생할 수 있습니다. 백엔드 스펙을 확인하고 필요 시string | null로 조정해 주세요.src/pages/search/Search.tsx (1)
106-114: useEffect 내 무한 루프 위험
isInitialized상태가 useEffect 내부에서 변경되고 있어, 잠재적인 무한 루프 위험이 있습니다.
isInitialized를 ref로 변경하여 불필요한 리렌더링을 방지하세요:- const [isInitialized, setIsInitialized] = useState(false); + const isInitializedRef = useRef(false); useEffect(() => { const query = searchParams.get('q') || ''; - if (query && !isInitialized) { + if (query && !isInitializedRef.current) { setSearchTerm(query); setIsSearching(true); handleSearch(query, true); - setIsInitialized(true); + isInitializedRef.current = true; } - }, [searchParams, handleSearch, isInitialized]); + }, [searchParams, handleSearch]);Likely an incorrect or invalid review comment.
| export const getBookDetail = async (isbn: string): Promise<BookDetailResponse> => { | ||
| try { | ||
| const response = await apiClient.get<BookDetailResponse>(`/books/${isbn}`); | ||
|
|
||
| return response.data; | ||
| } catch (error) { | ||
| console.error('책 상세 정보 API 오류:', error); | ||
| throw error; | ||
| } | ||
| }; |
There was a problem hiding this comment.
🛠️ Refactor suggestion
ISBN을 URL 인코딩하고 로그에 컨텍스트 추가
특수문자(하이픈 등) 포함 가능성을 고려해 경로 파라미터는 인코딩하는 게 안전합니다. 에러 로그에 isbn을 포함해 디버깅 가시성을 높이십시오.
export const getBookDetail = async (isbn: string): Promise<BookDetailResponse> => {
try {
- const response = await apiClient.get<BookDetailResponse>(`/books/${isbn}`);
+ const response = await apiClient.get<BookDetailResponse>(`/books/${encodeURIComponent(isbn)}`);
return response.data;
} catch (error) {
- console.error('책 상세 정보 API 오류:', error);
+ console.error(`책 상세 정보 API 오류 (isbn: ${isbn}):`, error);
throw error;
}
};📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export const getBookDetail = async (isbn: string): Promise<BookDetailResponse> => { | |
| try { | |
| const response = await apiClient.get<BookDetailResponse>(`/books/${isbn}`); | |
| return response.data; | |
| } catch (error) { | |
| console.error('책 상세 정보 API 오류:', error); | |
| throw error; | |
| } | |
| }; | |
| export const getBookDetail = async (isbn: string): Promise<BookDetailResponse> => { | |
| try { | |
| const response = await apiClient.get<BookDetailResponse>( | |
| `/books/${encodeURIComponent(isbn)}` | |
| ); | |
| return response.data; | |
| } catch (error) { | |
| console.error(`책 상세 정보 API 오류 (isbn: ${isbn}):`, error); | |
| throw error; | |
| } | |
| }; |
🤖 Prompt for AI Agents
In src/api/books/getBookDetail.ts around lines 24 to 33, the ISBN path parameter
isn't URL-encoded and the error log lacks context; update the request to use an
encoded ISBN (e.g., apiClient.get(`/books/${encodeURIComponent(isbn)}`)) and
modify the catch to log the isbn alongside the error (e.g., console.error('책 상세
정보 API 오류', { isbn, error })) before rethrowing so debugging has the
encoded-safe path and clear context.
| // 검색된 책 타입 (컴포넌트에서 사용하는 형태) | ||
| export interface SearchedBook { | ||
| id: number; | ||
| title: string; | ||
| author: string; | ||
| publisher: string; | ||
| coverUrl: string; | ||
| isbn: string; | ||
| } |
There was a problem hiding this comment.
💡 Verification agent
🧩 Analysis chain
SearchedBook 타입 중복 정의 제거
UI 레이어(src/pages/search/Search.tsx)에서도 같은 이름의 SearchedBook 인터페이스가 존재합니다. 중복 정의는 드리프트를 유발합니다. 공용 타입 모듈(예: src/types/book.ts)로 옮기고 단일 출처를 사용하세요.
중복 정의 및 사용처를 점검하는 스크립트:
🏁 Script executed:
#!/bin/bash
# SearchedBook 타입 선언 및 사용 위치 점검
rg -n --no-heading "interface SearchedBook|type SearchedBook" -A 2 -B 2
rg -n --no-heading "SearchedBook" -A 2 -B 2Length of output: 8084
SearchedBook 타입 공통 모듈로 이동 및 중복 정의 제거 필요
UI 레이어와 API 레이어에서 각각 정의된 SearchedBook 인터페이스가 아래 세 곳에 중복되어 있습니다. 단일 소스에 정의를 모아두고, 각 파일에서는 해당 타입을 import 하도록 리팩터링하세요.
• src/api/books/getSearchBooks.ts
• src/pages/search/Search.tsx
• src/components/search/BookSearchResult.tsx
제안된 작업 순서:
-
src/types/book.ts(또는src/types/index.ts)에SearchedBook인터페이스 정의 추가// src/types/book.ts export interface SearchedBook { id: number; title: string; author: string; publisher: string; coverUrl: string; isbn: string; }
-
모든 파일에서 로컬 정의 제거 후 공통 타입 모듈에서 import
- src/api/books/getSearchBooks.ts
- // 검색된 책 타입 (컴포넌트에서 사용하는 형태) - export interface SearchedBook { … } + import type { SearchedBook } from '@/types/book';
- src/pages/search/Search.tsx
- export interface SearchedBook { … } + import type { SearchedBook } from '@/types/book';
- src/components/search/BookSearchResult.tsx
- import type { SearchedBook } from '@/pages/search/Search'; + import type { SearchedBook } from '@/types/book';
- src/api/books/getSearchBooks.ts
-
convertToSearchedBooks의 반환 타입도 공통SearchedBook[]으로 유지되는지 확인
이후 빌드 에러가 없는지, 컴포넌트들이 올바르게 타입을 참조하는지 검증해 주세요.
🤖 Prompt for AI Agents
In src/api/books/getSearchBooks.ts around lines 12 to 20, the SearchedBook
interface is duplicated across multiple files; move the interface to a single
shared module (e.g., src/types/book.ts exporting SearchedBook), remove the local
SearchedBook declarations in src/api/books/getSearchBooks.ts,
src/pages/search/Search.tsx, and src/components/search/BookSearchResult.tsx, and
update those files to import { SearchedBook } from the new types module; also
ensure convertToSearchedBooks and any function signatures use the shared
SearchedBook[] type and run the build to verify no type errors remain.
| export const convertToSearchedBooks = (apiBooks: BookSearchItem[]): SearchedBook[] => { | ||
| return apiBooks.map((book, index) => ({ | ||
| id: index + 1, | ||
| title: book.title, | ||
| author: book.authorName, | ||
| publisher: book.publisher, | ||
| coverUrl: book.imageUrl, | ||
| isbn: book.isbn, | ||
| })); | ||
| }; |
There was a problem hiding this comment.
💡 Verification agent
🧩 Analysis chain
id로 index 사용은 불안정 — 키/식별자 용도로 ISBN 사용 권장
id를 index 기반으로 생성하면 페이지네이션/정렬 변경 시 불안정합니다. 가능하면 React key와 식별자는 ISBN을 사용하세요. 현재 UI에서 key로 id를 쓰지 않는지 확인해 주세요.
검증 스크립트(리스트 렌더링에서 book.id를 key로 쓰는지 검색):
🏁 Script executed:
#!/bin/bash
# book.id를 React key로 사용하는 구문 탐색
rg -n --no-heading "key=\{[^}]*book\.id[^}]*\}" -A 2 -B 2Length of output: 1167
페이지네이션·정렬 안정화를 위해 id를 ISBN으로 변경하세요
현재 convertToSearchedBooks에서 id를 index + 1로 생성하고,
BookSearchResult.tsx(34)·BookSearchBottomSheet.tsx(216)에서 key={book.id}로 사용하고 있습니다.
인덱스 기반 ID는 리스트가 변경될 때 React key 충돌을 일으킬 수 있으므로, 고유 불변 값인 ISBN을 사용하도록 수정하세요.
수정 제안:
// File: src/api/books/getSearchBooks.ts
-export const convertToSearchedBooks = (apiBooks: BookSearchItem[]): SearchedBook[] => {
- return apiBooks.map((book, index) => ({
- id: index + 1,
+export const convertToSearchedBooks = (apiBooks: BookSearchItem[]): SearchedBook[] => {
+ return apiBooks.map((book) => ({
+ id: book.isbn, // index → ISBN으로 대체
title: book.title,
author: book.authorName,
publisher: book.publisher,
coverUrl: book.imageUrl,
- isbn: book.isbn,
+ isbn: book.isbn, // id와 중복되지 않도록, 필요한 경우 타입도 함께 검토
}));
};• BookSearchResult.tsx (34)
• BookSearchBottomSheet.tsx (216)
– 위 파일들의 key={book.id}를 key={book.isbn} 또는 key={book.isbn}로 변경하면 안전합니다.
– 필요 시 SearchedBook.id 타입을 string으로 조정하세요.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export const convertToSearchedBooks = (apiBooks: BookSearchItem[]): SearchedBook[] => { | |
| return apiBooks.map((book, index) => ({ | |
| id: index + 1, | |
| title: book.title, | |
| author: book.authorName, | |
| publisher: book.publisher, | |
| coverUrl: book.imageUrl, | |
| isbn: book.isbn, | |
| })); | |
| }; | |
| export const convertToSearchedBooks = (apiBooks: BookSearchItem[]): SearchedBook[] => { | |
| return apiBooks.map((book) => ({ | |
| id: book.isbn, // index → ISBN으로 대체 | |
| title: book.title, | |
| author: book.authorName, | |
| publisher: book.publisher, | |
| coverUrl: book.imageUrl, | |
| isbn: book.isbn, // id와 중복되지 않도록, 필요한 경우 타입도 함께 검토 | |
| })); | |
| }; |
🤖 Prompt for AI Agents
In src/api/books/getSearchBooks.ts around lines 61-70, the function creates id
using index+1 which can cause React key collisions; change id to use the
immutable ISBN (id: book.isbn), update the SearchedBook type so id is a string
(or remove id and rely on isbn field), and then update both BookSearchResult.tsx
(line ~34) and BookSearchBottomSheet.tsx (line ~216) to use key={book.isbn} (or
key={book.id} if you keep id as isbn) to ensure stable keys and correct typing
throughout.
| export const postSaveBook = async (isbn: string, type: boolean): Promise<SaveBookResponse> => { | ||
| try { | ||
| const response = await apiClient.post<SaveBookResponse>(`/books/${isbn}/saved`, { | ||
| type: type, | ||
| }); | ||
| return response.data; |
There was a problem hiding this comment.
🛠️ Refactor suggestion
요청 바디 타입 적용 및 ISBN URL 인코딩
- SaveBookRequest 타입을 실제 요청 바디에 적용해 타입 안정성을 높이세요.
- 경로 파라미터인 ISBN은 안전하게 encodeURIComponent로 인코딩하는 것이 좋습니다.
적용 diff:
-export const postSaveBook = async (isbn: string, type: boolean): Promise<SaveBookResponse> => {
+export const postSaveBook = async (isbn: string, type: boolean): Promise<SaveBookResponse> => {
try {
- const response = await apiClient.post<SaveBookResponse>(`/books/${isbn}/saved`, {
- type: type,
- });
+ const payload: SaveBookRequest = { type };
+ const response = await apiClient.post<SaveBookResponse>(
+ `/books/${encodeURIComponent(isbn)}/saved`,
+ payload,
+ );
return response.data;
} catch (error) {
console.error('책 저장 API 오류:', error);
throw error;
}
};📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export const postSaveBook = async (isbn: string, type: boolean): Promise<SaveBookResponse> => { | |
| try { | |
| const response = await apiClient.post<SaveBookResponse>(`/books/${isbn}/saved`, { | |
| type: type, | |
| }); | |
| return response.data; | |
| export const postSaveBook = async (isbn: string, type: boolean): Promise<SaveBookResponse> => { | |
| try { | |
| const payload: SaveBookRequest = { type }; | |
| const response = await apiClient.post<SaveBookResponse>( | |
| `/books/${encodeURIComponent(isbn)}/saved`, | |
| payload, | |
| ); | |
| return response.data; | |
| } catch (error) { | |
| console.error('책 저장 API 오류:', error); | |
| throw error; | |
| } | |
| }; |
🤖 Prompt for AI Agents
In src/api/books/postSaveBook.ts around lines 22 to 27, the function currently
posts a plain object and inserts raw isbn into the URL; update it to construct a
payload typed as SaveBookRequest (e.g. const body: SaveBookRequest = { type })
and pass that to apiClient.post to ensure type safety, and encode the isbn in
the path using encodeURIComponent(isbn) so the URL is safe; keep returning
response.data as before.
| <Route path="search" element={<Search />} /> | ||
| <Route path="search/applybook" element={<ApplyBook />} /> | ||
| <Route path="search/book" element={<SearchBook />} /> | ||
| <Route path="search/book/:isbn" element={<SearchBook />} /> |
There was a problem hiding this comment.
💡 Verification agent
🧩 Analysis chain
동적 라우트 전환 LGTM. 경로 충돌 및 URL 인코딩 점검 권장
- React Router v6는 정적 세그먼트가 파라미터보다 우선 매칭되므로,
search/book/group과search/book/:isbn는 충돌하지 않습니다. 그래도 회귀가 없는지 한 번만 확인해 주세요. - 이동 시 ISBN에 특수문자가 포함될 수 있으니
encodeURIComponent적용을 권장합니다.
다음 스크립트로 링크/네비게이션에서 인코딩 누락 가능성을 훑어볼 수 있습니다.
🏁 Script executed:
#!/bin/bash
# /search/book/${...} 사용 지점 나열
rg -n -S "/search/book/\\$\\{" src -g "src/**/*.tsx" -H
echo
echo "encodeURIComponent 사용 지점"
rg -n -S "encodeURIComponent\\(" src -g "src/**/*.tsx" -HLength of output: 403
동적 라우트 전환 LGTM – URL 인코딩 적용 필요
- React Router v6는 정적 세그먼트가 파라미터보다 우선 매칭하므로
search/book/group과search/book/:isbn는 충돌하지 않습니다. 다만 회귀가 없는지 한 번만 확인해 주세요. - 특수문자가 포함된 ISBN을 안전하게 전달하려면
encodeURIComponent를 적용해야 합니다.
수정 대상 위치:
- src/components/search/MostSearchedBooks.tsx:36
- src/components/search/BookSearchResult.tsx:34
예시 변경(diff):
- navigate(`/search/book/${isbn}`);
+ navigate(`/search/book/${encodeURIComponent(isbn)}`);- onClick={() => navigate(`/search/book/${book.isbn}`)}
+ onClick={() => navigate(`/search/book/${encodeURIComponent(book.isbn)}`)}Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In src/pages/index.tsx around line 66 and in the two components
src/components/search/MostSearchedBooks.tsx:36 and
src/components/search/BookSearchResult.tsx:34, ensure ISBN values are
URL-encoded when constructing links or navigate calls (use
encodeURIComponent(isbn)) so special characters are passed safely to the dynamic
route /search/book/:isbn; update any <Link> or navigate(...) usages to wrap the
isbn with encodeURIComponent, and verify the static route /search/book/group
still matches (no change needed for route order), running a quick sanity check
to avoid regressions.
| export const SaveButton = styled.button` | ||
| width: 48px; | ||
| height: 48px; | ||
| background: transparent; | ||
| border: 1px solid ${colors.grey[200]}; | ||
| border: none; | ||
| border-radius: 12px; | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| cursor: pointer; | ||
| `; |
There was a problem hiding this comment.
🛠️ Refactor suggestion
SaveButton 포커스 가시성 보완 권장 (:focus-visible 추가)
border 제거로 키보드 포커스가 화면에 드러나지 않습니다. 접근성 관점에서 포커스 링을 추가하는 게 안전합니다. 약한 hover/active 피드백도 함께 제안합니다.
export const SaveButton = styled.button`
width: 48px;
height: 48px;
background: transparent;
- border: none;
+ border: 1px solid transparent;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
- cursor: pointer;
+ cursor: pointer;
+ transition: background-color 0.2s ease, transform 0.05s ease;
+ &:hover {
+ background: rgba(255, 255, 255, 0.06);
+ }
+ &:active {
+ transform: translateY(1px);
+ }
+ &:focus-visible {
+ outline: 2px solid ${colors.purple.main};
+ outline-offset: 2px;
+ }
`;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export const SaveButton = styled.button` | |
| width: 48px; | |
| height: 48px; | |
| background: transparent; | |
| border: 1px solid ${colors.grey[200]}; | |
| border: none; | |
| border-radius: 12px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| cursor: pointer; | |
| `; | |
| export const SaveButton = styled.button` | |
| width: 48px; | |
| height: 48px; | |
| background: transparent; | |
| border: 1px solid transparent; | |
| border-radius: 12px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| cursor: pointer; | |
| transition: background-color 0.2s ease, transform 0.05s ease; | |
| &:hover { | |
| background: rgba(255, 255, 255, 0.06); | |
| } | |
| &:active { | |
| transform: translateY(1px); | |
| } | |
| &:focus-visible { | |
| outline: 2px solid ${colors.purple.main}; | |
| outline-offset: 2px; | |
| } | |
| `; |
🤖 Prompt for AI Agents
In src/pages/searchBook/SearchBook.styled.ts around lines 157 to 167, the
SaveButton removes the border so keyboard focus is invisible; add an accessible
focus indicator and subtle hover/active feedback: keep border: none but add a
:focus-visible rule that applies a visible outline or box-shadow with an
outline-offset (e.g., 2px) and sufficient contrast, add :hover and :active
styles that slightly change background or transform for pointer feedback, and
include a short transition for smoothness; ensure these styles only affect
visual states and do not change layout.
| useEffect(() => { | ||
| const fetchBookDetail = async () => { | ||
| if (!isbn) { | ||
| setError('ISBN이 필요합니다.'); | ||
| setIsLoading(false); | ||
| return; | ||
| } | ||
|
|
||
| try { | ||
| setIsLoading(true); | ||
|
|
||
| const [bookResponse, recruitingResponse] = await Promise.all([ | ||
| getBookDetail(isbn), | ||
| getRecruitingRooms(isbn), | ||
| ]); | ||
|
|
||
| if (bookResponse.isSuccess) { | ||
| setBookDetail(bookResponse.data); | ||
| setIsSaved(bookResponse.data.isSaved); | ||
| } else { | ||
| setError(bookResponse.message); | ||
| } | ||
|
|
||
| if (recruitingResponse.isSuccess) { | ||
| setRecruitingRoomsData(recruitingResponse.data); | ||
| } else { | ||
| console.error('모집중인 모임방 조회 실패:', recruitingResponse.message); | ||
| } | ||
| } catch (error) { | ||
| console.error('데이터 조회 오류:', error); | ||
| setError('정보를 불러오는데 실패했습니다.'); | ||
| } finally { | ||
| setIsLoading(false); | ||
| } | ||
| }; | ||
|
|
||
| fetchBookDetail(); | ||
| }, [isbn]); |
There was a problem hiding this comment.
🛠️ Refactor suggestion
에러 처리 로직 개선 필요
API 호출 실패 시 부분적으로만 에러를 처리하고 있습니다. recruitingResponse 실패는 console.error만 하고 있어 사용자에게 적절한 피드백이 없습니다.
모든 API 실패에 대해 일관된 에러 처리를 적용하세요:
if (recruitingResponse.isSuccess) {
setRecruitingRoomsData(recruitingResponse.data);
} else {
- console.error('모집중인 모임방 조회 실패:', recruitingResponse.message);
+ // 모집중인 모임방은 optional이므로 실패해도 기본값 설정
+ setRecruitingRoomsData({
+ recruitingRoomList: [],
+ totalRoomCount: 0,
+ nextCursor: '',
+ isLast: true,
+ });
+ console.warn('모집중인 모임방 조회 실패:', recruitingResponse.message);
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| useEffect(() => { | |
| const fetchBookDetail = async () => { | |
| if (!isbn) { | |
| setError('ISBN이 필요합니다.'); | |
| setIsLoading(false); | |
| return; | |
| } | |
| try { | |
| setIsLoading(true); | |
| const [bookResponse, recruitingResponse] = await Promise.all([ | |
| getBookDetail(isbn), | |
| getRecruitingRooms(isbn), | |
| ]); | |
| if (bookResponse.isSuccess) { | |
| setBookDetail(bookResponse.data); | |
| setIsSaved(bookResponse.data.isSaved); | |
| } else { | |
| setError(bookResponse.message); | |
| } | |
| if (recruitingResponse.isSuccess) { | |
| setRecruitingRoomsData(recruitingResponse.data); | |
| } else { | |
| console.error('모집중인 모임방 조회 실패:', recruitingResponse.message); | |
| } | |
| } catch (error) { | |
| console.error('데이터 조회 오류:', error); | |
| setError('정보를 불러오는데 실패했습니다.'); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| fetchBookDetail(); | |
| }, [isbn]); | |
| useEffect(() => { | |
| const fetchBookDetail = async () => { | |
| if (!isbn) { | |
| setError('ISBN이 필요합니다.'); | |
| setIsLoading(false); | |
| return; | |
| } | |
| try { | |
| setIsLoading(true); | |
| const [bookResponse, recruitingResponse] = await Promise.all([ | |
| getBookDetail(isbn), | |
| getRecruitingRooms(isbn), | |
| ]); | |
| if (bookResponse.isSuccess) { | |
| setBookDetail(bookResponse.data); | |
| setIsSaved(bookResponse.data.isSaved); | |
| } else { | |
| setError(bookResponse.message); | |
| } | |
| if (recruitingResponse.isSuccess) { | |
| setRecruitingRoomsData(recruitingResponse.data); | |
| } else { | |
| // 모집중인 모임방은 optional이므로 실패해도 기본값 설정 | |
| setRecruitingRoomsData({ | |
| recruitingRoomList: [], | |
| totalRoomCount: 0, | |
| nextCursor: '', | |
| isLast: true, | |
| }); | |
| console.warn('모집중인 모임방 조회 실패:', recruitingResponse.message); | |
| } | |
| } catch (error) { | |
| console.error('데이터 조회 오류:', error); | |
| setError('정보를 불러오는데 실패했습니다.'); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| fetchBookDetail(); | |
| }, [isbn]); |
🤖 Prompt for AI Agents
In src/pages/searchBook/SearchBook.tsx around lines 56 to 93, the
recruitingResponse failure is only logged to console so the user gets no
feedback; update the effect to handle all API failures consistently by setting
setError when recruitingResponse.isSuccess is false (either set a combined
message if bookResponse also failed or a specific recruiting error message),
ensure setIsSaved/setBookDetail only run on success, and keep the existing
try/catch/finally so loading state is cleared; in short, propagate recruiting
API errors into state (setError) instead of only console.error and present a
user-friendly message.
| const handleRecruitingGroupButton = () => { | ||
| navigate('./group'); | ||
| if (bookDetail) { | ||
| navigate('/search/book/group', { | ||
| state: { | ||
| recruitingRooms: recruitingRoomsData || { | ||
| recruitingRoomList: [], | ||
| totalRoomCount: 0, | ||
| nextCursor: '', | ||
| isLast: true, | ||
| }, | ||
| bookInfo: { | ||
| isbn: bookDetail.isbn, | ||
| title: bookDetail.title, | ||
| author: bookDetail.authorName, | ||
| imageUrl: bookDetail.imageUrl, | ||
| }, | ||
| }, | ||
| }); | ||
| } | ||
| }; |
There was a problem hiding this comment.
🛠️ Refactor suggestion
navigate state 전달 시 데이터 검증 부족
recruitingRoomsData가 null일 경우 기본값을 제공하고 있지만, bookDetail의 필드들이 undefined일 가능성을 체크하지 않습니다.
데이터 검증을 강화하세요:
const handleRecruitingGroupButton = () => {
- if (bookDetail) {
+ if (bookDetail && bookDetail.isbn && bookDetail.title) {
navigate('/search/book/group', {
state: {
recruitingRooms: recruitingRoomsData || {
recruitingRoomList: [],
totalRoomCount: 0,
nextCursor: '',
isLast: true,
},
bookInfo: {
isbn: bookDetail.isbn,
title: bookDetail.title,
- author: bookDetail.authorName,
+ author: bookDetail.authorName || '작가 미상',
imageUrl: bookDetail.imageUrl,
},
},
});
}
};📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const handleRecruitingGroupButton = () => { | |
| navigate('./group'); | |
| if (bookDetail) { | |
| navigate('/search/book/group', { | |
| state: { | |
| recruitingRooms: recruitingRoomsData || { | |
| recruitingRoomList: [], | |
| totalRoomCount: 0, | |
| nextCursor: '', | |
| isLast: true, | |
| }, | |
| bookInfo: { | |
| isbn: bookDetail.isbn, | |
| title: bookDetail.title, | |
| author: bookDetail.authorName, | |
| imageUrl: bookDetail.imageUrl, | |
| }, | |
| }, | |
| }); | |
| } | |
| }; | |
| const handleRecruitingGroupButton = () => { | |
| if (bookDetail && bookDetail.isbn && bookDetail.title) { | |
| navigate('/search/book/group', { | |
| state: { | |
| recruitingRooms: recruitingRoomsData || { | |
| recruitingRoomList: [], | |
| totalRoomCount: 0, | |
| nextCursor: '', | |
| isLast: true, | |
| }, | |
| bookInfo: { | |
| isbn: bookDetail.isbn, | |
| title: bookDetail.title, | |
| author: bookDetail.authorName || '작가 미상', | |
| imageUrl: bookDetail.imageUrl, | |
| }, | |
| }, | |
| }); | |
| } | |
| }; |
🤖 Prompt for AI Agents
In src/pages/searchBook/SearchBook.tsx around lines 105 to 124, the navigate
call assumes bookDetail fields (isbn, title, authorName, imageUrl) are always
defined; validate and sanitize those fields before passing them in state: ensure
bookDetail exists and each field is checked and replaced with safe defaults
(e.g., empty string) or bail out (do not navigate) if required identifiers like
isbn are missing; keep the existing recruitingRoomsData fallback but apply the
same null/undefined checks to nested fields if needed; update the navigate state
to use the validated/sanitized values so undefined never propagates.
| const location = useLocation(); | ||
| const { recruitingRooms, bookInfo } = (location.state as LocationState) || {}; |
There was a problem hiding this comment.
🛠️ Refactor suggestion
useLocation state 안전 분해: any 유출 및 런타임 안전성 개선 제안
현재 (location.state as LocationState) || {}로 분해하면 TS가 속성 타입을 좁히지 못해 any로 흐르거나, 런타임에서 state가 없을 때 의도치 않은 동작이 생길 수 있습니다. 명시적으로 state를 변수로 받고 optional 체이닝으로 접근해 주세요.
적용 diff:
- const location = useLocation();
- const { recruitingRooms, bookInfo } = (location.state as LocationState) || {};
+ const location = useLocation();
+ const state = location.state as LocationState | undefined;
+ const recruitingRooms = state?.recruitingRooms;
+ const bookInfo = state?.bookInfo;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const location = useLocation(); | |
| const { recruitingRooms, bookInfo } = (location.state as LocationState) || {}; | |
| const location = useLocation(); | |
| const state = location.state as LocationState | undefined; | |
| const recruitingRooms = state?.recruitingRooms; | |
| const bookInfo = state?.bookInfo; |
🤖 Prompt for AI Agents
In src/pages/searchBook/SearchBookGroup.tsx around lines 21-22, instead of
destructuring directly from (location.state as LocationState) || {} (which can
leak any and is unsafe at runtime), first assign a typed state variable like
const state = location.state as LocationState | undefined and then safely access
properties with optional chaining and nullish coalescing (e.g., const
recruitingRooms = state?.recruitingRooms ?? []; const bookInfo = state?.bookInfo
?? undefined) so TypeScript narrows types and runtime access is protected.
| {groupList.map((room, index) => ( | ||
| <GroupCard | ||
| key={room.roomId || index} | ||
| group={{ | ||
| id: room.roomId, | ||
| title: room.roomName, | ||
| participants: room.memberCount, | ||
| maximumParticipants: room.recruitCount, | ||
| deadLine: 0, | ||
| coverUrl: room.bookImageUrl || bookInfo?.imageUrl, | ||
| }} | ||
| isOngoing={true} | ||
| type={'modal'} | ||
| /> |
There was a problem hiding this comment.
🛠️ Refactor suggestion
키 안정성과 마감 D-day 계산 보완
- key에서 OR(||)는 0을 무효로 취급합니다. nullish 병합(??)로 전환해 예외를 방지하세요.
- deadLine을 0으로 고정하면 잘못된 UI가 노출됩니다. API의 deadlineEndDate를 기반으로 남은 일수를 계산하세요.
적용 diff:
- {groupList.map((room, index) => (
+ {groupList.map((room, index) => (
<GroupCard
- key={room.roomId || index}
+ key={room.roomId ?? index}
group={{
id: room.roomId,
title: room.roomName,
participants: room.memberCount,
maximumParticipants: room.recruitCount,
- deadLine: 0,
+ deadLine: getDaysLeft(room.deadlineEndDate),
coverUrl: room.bookImageUrl || bookInfo?.imageUrl,
}}
isOngoing={true}
type={'modal'}
/>
))}파일 내 보조 유틸(추가 코드):
// 남은 일수 계산: 마감일이 과거면 0일로 처리
function getDaysLeft(isoDate: string | undefined): number {
if (!isoDate) return 0;
const due = new Date(isoDate).getTime();
if (Number.isNaN(due)) return 0;
const msPerDay = 1000 * 60 * 60 * 24;
const diff = Math.ceil((due - Date.now()) / msPerDay);
return Math.max(0, diff);
}🤖 Prompt for AI Agents
In src/pages/searchBook/SearchBookGroup.tsx around lines 43 to 56, the code uses
|| for the GroupCard key (which treats 0 as falsy) and hardcodes deadLine to 0;
change the key to use nullish coalescing (??) so room.roomId of 0 is preserved,
add a small helper in the file to compute remaining days from the API field
deadlineEndDate (validate undefined and invalid dates and clamp to 0), and pass
deadLine={getDaysLeft(room.deadlineEndDate)} (or the correct API property name)
and coverUrl as now to GroupCard; ensure the helper handles ISO strings, NaN
dates, and returns Math.max(0, computedDays).
#️⃣연관된 이슈
[API] Books API 연동 #86
📝작업 내용
Summary by CodeRabbit