Skip to content

feat: 내 서재 검색 이후 액션 추가#189

Merged
clxxrlove merged 4 commits intodevelopfrom
BOOK-287-feature/#188
Aug 21, 2025
Merged

feat: 내 서재 검색 이후 액션 추가#189
clxxrlove merged 4 commits intodevelopfrom
BOOK-287-feature/#188

Conversation

@clxxrlove
Copy link
Member

@clxxrlove clxxrlove commented Aug 21, 2025

🔗 관련 이슈

📘 작업 유형

  • ✨ Feature (기능 추가)
  • 🐞 Bugfix (버그 수정)
  • 🔧 Refactor (코드 리팩토링)
  • ⚙️ Chore (환경 설정)
  • 📝 Docs (문서 작성 및 수정)
  • ✅ Test (기능 테스트)
  • 🎨 style (코드 스타일 수정)

📙 작업 내역

  • 내 서재 검색 화면에서 셀을 탭하면 독서 기록 화면으로 이동하는 기능 추가
  • 내 서재 검색 화면에서 셀이 터치 이벤트를 받지 못하던 현상 수정
  • 책 요약 화면 (SummaryView)에서 도서 정보에 대해 발생하던 레이아웃 이슈 수정

🧪 테스트 내역

  • 브라우저/기기에서 동작 확인
  • 엣지 케이스 테스트 완료
  • 기존 기능 영향 없음

🎨 스크린샷 또는 시연 영상 (선택)

Simulator.Screen.Recording.-.iPhone.SE.3rd.generation.-.2025-08-21.at.18.46.50.mp4

Summary by CodeRabbit

  • 신기능
    • 검색에 내 서재 도서 결과가 함께 표시되며, 구분된 스타일과 기록 수 배지를 지원합니다.
    • 내 서재 결과 항목 탭 시 도서 상세 화면으로 이동할 수 있습니다.
  • 개선
    • 저자 정보가 없을 때 “저자 정보 없음” 플레이스홀더가 표시됩니다.
    • 저자/출판사 라벨 가독성이 향상되어 잘림이 줄었습니다.
    • 검색 화면 재진입 시 기존 결과가 유지됩니다.
    • 빠른 연속 탭 시 상세 화면 중복 이동을 방지합니다.

@coderabbitai
Copy link

coderabbitai bot commented Aug 21, 2025

Walkthrough

라이브러리(BookInfo) 검색 결과를 추가하고 셀 탭 시 도서 상세로 이동하는 흐름을 도입했다. Diffable DataSource와 상태/액션을 확장했으며, 코디네이터에 상세 화면 전환 메서드를 추가했다. 검색 셀 스타일 파라미터를 확장하고, 요약 뷰의 레이아웃 우선순위와 저자 플레이스홀더를 조정했다. 일부 로깅을 제거했다.

Changes

Cohort / File(s) Summary
Search: Library results + navigation flow
src/Projects/BKPresentation/Sources/MainFlow/Search/ViewModel/SearchViewModel.swift, .../Search/View/SearchView.swift, .../Search/View/SearchViewController.swift, .../Search/Coordinator/SearchCoordinator.swift, .../Search/View/SearchResultCell.swift
- BookInfo 기반 라이브러리 결과 처리 추가(ResultState 확장, SearchItem.libraryResult 도입, onAppearWithoutReset, fetchLibrarySearchResultSuccessed 추가)
- Diffable DataSource 및 스냅샷에 libraryResult 반영, 라이브러리 셀 생성 로직 추가(isLibraryBook 사용)
- 항목 선택 시 libraryResult 탭 → goToBookDetail(isbn,userBookId) 이벤트 발행 → VC에서 스로틀 후 Coordinator.didTapBookDetail로 위임 → BookDetailCoordinator 시작
- 셀 configure 시 시그니처에 isLibraryBook: Bool = false 추가 및 스타일 분기 업데이트
UI: Book summary view adjustments
src/Projects/BKDesign/Sources/Components/Summary/BKBookSummaryView.swift
- authorLabel 우선순위(hugging/compression)를 높임, publisherLabel compression 낮춤
- author가 빈 경우 "저자 정보 없음" 플레이스홀더 적용
Domain: Hashability
src/Projects/BKDomain/Sources/Entity/BookInfo.swift
- BookInfo가 Equatable에 더해 Hashable 채택 (Diffable DataSource용)
Settings: Logging cleanup
src/Projects/BKPresentation/Sources/MainFlow/Setting/View/SettingViewController.swift
- 오류 처리 파이프라인에서 print 제거(기능 변화 없음)

Sequence Diagram(s)

sequenceDiagram
  participant U as User
  participant V as SearchView
  participant VC as SearchViewController
  participant C as SearchCoordinator
  participant B as BookDetailCoordinator

  U->>V: 탭 on libraryResult(BookInfo)
  V-->>VC: event .goToBookDetail(isbn, userBookId)
  Note over VC: throttle(800ms)
  VC->>C: didTapBookDetail(isbn, userBookId)
  C->>B: init + start(isbn, userBookId)
  B-->>C: pushes BookDetail
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Assessment against linked issues

Objective Addressed Explanation
내 서재 검색 이후 셀 액션 추가 → 책 상세 화면 전환 (#188)

Assessment against linked issues: Out-of-scope changes

Code Change Explanation
저자/출판사 레이아웃 우선순위 변경 및 빈 저자 플레이스홀더 추가 (src/Projects/BKDesign/Sources/Components/Summary/BKBookSummaryView.swift) 셀 액션(탭 → 상세 이동) 요구와 직접적 관련 없음. 스타일/레이아웃 개선에 해당.
오류 로그 print 제거 (src/Projects/BKPresentation/Sources/MainFlow/Setting/View/SettingViewController.swift) 링크된 이슈의 범위(내 서재 검색 셀 액션)와 무관한 로깅 정리.

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch BOOK-287-feature/#188

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
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

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

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary or @coderabbitai 요약 to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

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

🧹 Nitpick comments (7)
src/Projects/BKDesign/Sources/Components/Summary/BKBookSummaryView.swift (2)

195-201: 저자/출판사 라벨 우선순위 조정 의도는 타당 — separator의 Required 우선순위가 과도할 수 있음

authorLabel에 높은 Hugging/Compression을 주고 publisherLabel의 Compression을 낮춘 구조는 “저자 우선 노출” 의도에 부합합니다. 다만 현재 separatorLabel은 Hugging/Compression 모두 .required로 고정되어 있어, 긴 저자/출판사 조합에서 불필요한 잘림을 유발할 수 있습니다(한 글자 폭임에도 공간 경쟁에서 항상 이김).

  • 권장: separatorLabel의 Hugging만 .defaultLow로 낮추고 Compression은 그대로 .required 유지. 이러면 폭 부족 시 저자/출판사 텍스트가 먼저 줄어들고, 구분자 유지도 가능합니다.
-        separatorLabel.setContentHuggingPriority(.required, for: .horizontal)
+        separatorLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
         separatorLabel.setContentCompressionResistancePriority(.required, for: .horizontal)

또한 Dynamic Type(큰 글자 크기), RTL, 긴 한글/영문 혼합 케이스로 실제 트렁케이션 동작을 한 번만 더 점검해 주세요.


248-251: 공백 전처리 및 구분자 노출 조건 개선 제안

빈 문자열만 처리하면 공백-only(" ")은 놓칩니다. 또한 저자 또는 출판사가 비어있을 때는 구분자 “ | ”를 숨기는 편이 자연스럽습니다. 아래처럼 미세 개선을 고려해 주세요.

-        let wrappedAuthor = author.isEmpty ? "저자 정보 없음" : author
+        let normalizedAuthor = author.trimmingCharacters(in: .whitespacesAndNewlines)
+        let normalizedPublisher = publisher.trimmingCharacters(in: .whitespacesAndNewlines)
+        let wrappedAuthor = normalizedAuthor.isEmpty ? "저자 정보 없음" : normalizedAuthor
         titleLabel.setText(text: title)
-        authorLabel.setText(text: wrappedAuthor)
-        publisherLabel.setText(text: publisher)
+        authorLabel.setText(text: wrappedAuthor)
+        publisherLabel.setText(text: normalizedPublisher)
+        separatorLabel.isHidden = wrappedAuthor.isEmpty || normalizedPublisher.isEmpty

문자열 리터럴(“저자 정보 없음”)은 로컬라이제이션 자원으로 이관하는 것도 고려 바랍니다.

src/Projects/BKPresentation/Sources/MainFlow/Search/Coordinator/SearchCoordinator.swift (1)

50-59: 도서 상세 전환 흐름 추가: 구현은 명확합니다 — child coordinator 정리 루틴 확인

BookDetailCoordinator 생성→child 추가→start의 플로우는 타당합니다. 다만 push로 진행된 상세 화면 종료 시 childCoordinators에서 제거되는 경로가 준비되어 있는지 확인 부탁드립니다(메모리/중복 start 방지).

  • 보유 중인 공통 패턴(예: Coordinator.removeChild(_:)) 또는 delegate/closure(onFinish)를 통해 pop 시점에 self.removeChildCoordinator(...) 호출 권장.
  • 메서드 인자명(userBookId)이 도메인에서 사용하는 bookId와 혼용되지 않도록 명확한 네이밍 합의도 체크해 주세요.
src/Projects/BKPresentation/Sources/MainFlow/Search/View/SearchViewController.swift (1)

93-102: 탭 폭주 방지 throttle(800ms) 적용은 OK — latest 전략/시간값 재검토 제안

latest: false로 첫 탭만 처리합니다. 사용자가 800ms 내 재탭하면 무시됩니다. 상세 화면 네비게이션 UX 측면에서

  • latest: true로 “마지막 탭”을 반영하거나,
  • 윈도를 500ms로 단축하거나,
  • 버튼 단위로 isEnabled 토글(네비게이션 중 비활성)로 보완

을 고려해 보세요. 현재도 큰 문제는 없어 선택 사항입니다.

src/Projects/BKPresentation/Sources/MainFlow/Search/View/SearchResultCell.swift (1)

52-56: 라이브러리 결과 스타일 분기 로직: 의도는 명확 — UX 톤과의 정합성 점검 권장

isLibraryBook && recordCount != nil → .record, isLibraryBook → .alreadyEnroll 분기는 “내 서재 결과”에서 기록 유무에 따른 뱃지/문구 노출을 잘 표현합니다. 다만 .alreadyEnroll 스타일은 배경/텍스트를 disable 톤으로 처리하여(디자인 컴포넌트 구현상) “탭 불가” 인상을 줄 수 있습니다. 현재 canSelect=true로 탭 이동을 허용하므로, 시각적 affordance와 상충할 수 있습니다.

옵션:

  • 라이브러리 전용의 selectable 이미-등록 스타일(색상만 유지/감쇠 제거) 신설
  • 또는 isLibraryBook && recordCount == nil일 때도 .regular을 사용하고 “이미 등록됨” 문구만 extraLabel로 표시

로직 자체는 간결합니다만, 가독성을 위해 분기를 함수로 추출하는 것도 고려해 주세요.

-        let style: BKBookSummaryViewStyle = {
-            if isLibraryBook && recordCount != nil {
-                return .record
-            } else if isLibraryBook {
-                return .alreadyEnroll
-            } else if recordCount != nil {
-                return .record
-            } else if canSelect {
-                return .regular
-            } else {
-                return .alreadyEnroll
-            }
-        }()
+        let style: BKBookSummaryViewStyle = {
+            if isLibraryBook {
+                return (recordCount != nil) ? .record : .alreadyEnroll
+            }
+            if recordCount != nil { return .record }
+            return canSelect ? .regular : .alreadyEnroll
+        }()
src/Projects/BKPresentation/Sources/MainFlow/Search/View/SearchView.swift (1)

219-245: 라이브러리 결과 셀 구성 적절. 스타일 플래그 적용이 명확합니다.

  • isLibraryBook: true와 recordCount 유무로 BKBookSummaryView 스타일이 안정적으로 선택됩니다.
  • 셀 탭 가능(canSelect: true) 설정도 요구사항과 맞습니다.

선택사항:

  • 이미 등록된 항목에 대해 extraText(예: "내 서재") 등의 배지를 노출하고 싶다면 SearchResultCell.configure의 extraText 매개변수를 활용하는 방안을 고려할 수 있습니다.
src/Projects/BKPresentation/Sources/MainFlow/Search/ViewModel/SearchViewModel.swift (1)

11-12: 결과 모델 구조 확장(.libraryResult, ResultState에 bookInfos 추가) — 현재 요구에는 적합합니다. 구조 단순화는 선택사항.

  • View에서 두 경로(Book, BookInfo)를 동시에 처리하기 위해 병렬 배열(books/bookInfos)을 둔 점은 이해됩니다.
  • 다만 장기적으로는 ResultState가 [SearchItem] 하나로만 스냅샷 소스에 직접 대응하도록 단순화하면 중복 분기/초기화가 줄어듭니다. 이번 PR 범위를 넘는 개선이므로 선택적으로 고려해 주세요.

Also applies to: 20-34

📜 Review details

Configuration used: .coderabbit.yaml
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between f36a1cb and 1e40ad2.

📒 Files selected for processing (8)
  • src/Projects/BKDesign/Sources/Components/Summary/BKBookSummaryView.swift (2 hunks)
  • src/Projects/BKDomain/Sources/Entity/BookInfo.swift (1 hunks)
  • src/Projects/BKPresentation/Sources/MainFlow/Search/Coordinator/SearchCoordinator.swift (1 hunks)
  • src/Projects/BKPresentation/Sources/MainFlow/Search/View/SearchResultCell.swift (1 hunks)
  • src/Projects/BKPresentation/Sources/MainFlow/Search/View/SearchView.swift (5 hunks)
  • src/Projects/BKPresentation/Sources/MainFlow/Search/View/SearchViewController.swift (3 hunks)
  • src/Projects/BKPresentation/Sources/MainFlow/Search/ViewModel/SearchViewModel.swift (9 hunks)
  • src/Projects/BKPresentation/Sources/MainFlow/Setting/View/SettingViewController.swift (0 hunks)
💤 Files with no reviewable changes (1)
  • src/Projects/BKPresentation/Sources/MainFlow/Setting/View/SettingViewController.swift
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-08-11T12:49:16.942Z
Learnt from: doyeonk429
PR: YAPP-Github/Reed-iOS#169
File: src/Projects/BKPresentation/Sources/MainFlow/Search/View/SearchResultCell.swift:31-41
Timestamp: 2025-08-11T12:49:16.942Z
Learning: In Reed-iOS project's SearchResultCell, a new BKBookSummaryView instance is created each time configure() is called rather than reusing the existing view. The old view is properly removed and set to nil in prepareForReuse(), allowing deinitialization to handle all cleanup including image downloads. Calling clearView() before removal is unnecessary in this pattern.

Applied to files:

  • src/Projects/BKPresentation/Sources/MainFlow/Search/View/SearchResultCell.swift
  • src/Projects/BKPresentation/Sources/MainFlow/Search/View/SearchView.swift
📚 Learning: 2025-07-11T12:12:48.911Z
Learnt from: clxxrlove
PR: YAPP-Github/Reed-iOS#79
File: src/Projects/BKPresentation/Sources/MainFlow/Setting/ViewModel/SettingViewModel.swift:78-85
Timestamp: 2025-07-11T12:12:48.911Z
Learning: AppVersionUseCase.execute() returns AnyPublisher<String, Never> where the Failure type is Never, making error handling unnecessary. The app version is retrieved from the main bundle which is always available, and the implementation already handles missing version by defaulting to "-".

Applied to files:

  • src/Projects/BKPresentation/Sources/MainFlow/Search/ViewModel/SearchViewModel.swift
🧬 Code graph analysis (5)
src/Projects/BKDesign/Sources/Components/Summary/BKBookSummaryView.swift (1)
src/Projects/BKDesign/Sources/Components/Label/BKLabel.swift (1)
  • setText (109-113)
src/Projects/BKPresentation/Sources/MainFlow/Search/View/SearchViewController.swift (2)
src/Projects/BKPresentation/Sources/MainFlow/Search/ViewModel/SearchViewModel.swift (1)
  • send (172-180)
src/Projects/BKPresentation/Sources/MainFlow/Search/Coordinator/SearchCoordinator.swift (1)
  • didTapBookDetail (50-59)
src/Projects/BKPresentation/Sources/MainFlow/Search/Coordinator/SearchCoordinator.swift (2)
src/Projects/BKPresentation/Sources/Common/Coordinator/Coordinator.swift (1)
  • addChildCoordinator (47-52)
src/Projects/BKPresentation/Sources/MainFlow/BookDetail/Coordinator/BookDetailCoordinator.swift (1)
  • start (25-34)
src/Projects/BKPresentation/Sources/MainFlow/Search/View/SearchView.swift (2)
src/Projects/BKPresentation/Sources/MainFlow/Search/View/SearchResultCell.swift (1)
  • configure (43-82)
src/Projects/BKDesign/Sources/Components/Summary/BKBookSummaryView.swift (1)
  • configure (240-283)
src/Projects/BKPresentation/Sources/MainFlow/Search/ViewModel/SearchViewModel.swift (1)
src/Projects/BKData/Sources/Repository/DefaultBookRepository.swift (1)
  • search (15-40)
🔇 Additional comments (9)
src/Projects/BKPresentation/Sources/MainFlow/Search/View/SearchViewController.swift (2)

13-13: 이벤트 추가 자체는 적절합니다

검색 셀에서 상세 이동을 분리된 이벤트로 명시한 점이 명확하고 추후 테스트/트래킹도 수월합니다.


44-45: onAppearWithoutReset 전환: 상태 유지 의도 확인 필요

화면 복귀 시 기존 검색 상태를 보존하려는 의도로 보입니다. 초기화가 필요했던 경로(첫 진입, 탭 재선택 등)와의 동작 차이를 ViewModel 단에서 충분히 커버하는지 확인해 주세요.

src/Projects/BKPresentation/Sources/MainFlow/Search/View/SearchResultCell.swift (1)

48-50: API 확장은 안전합니다

기본값을 부여해 기존 호출부 호환성을 유지했습니다. isLibraryBook 플래그 추가로 스타일 분기가 명확해졌습니다.

src/Projects/BKPresentation/Sources/MainFlow/Search/View/SearchView.swift (2)

146-148: DataSource에 .libraryResult 분기 추가 — 좋습니다.

셀 타입 분기가 명확하고, 재사용 셀(SearchResultCell)을 활용해 구현 일관성이 유지됩니다.


103-116: count 전달 흐름 추가 확인 필요

SearchViewController에서 contentView.applySnapshot 호출부는 snapshot.count를 count 인자로 넘기고 있음이 확인되었습니다.
다음 두 가지를 검증해 주세요:

  • snapshot.count가 ViewModel의 totalResults 값을 그대로 전달하는지 확인
    (ViewModel에서 스냅샷 생성 시 state.totalResults를 그대로 사용하고 있는지)
  • SearchState.totalResults에 실제 결과 건수가 올바르게 세팅되는지 확인

확인 위치:

  • src/Projects/BKPresentation/Sources/MainFlow/Search/View/SearchViewController.swift
    • applySnapshot 호출부 (134–136번 줄)
  • src/Projects/BKPresentation/Sources/MainFlow/Search/View/SearchView.swift
    • applySnapshot 정의 (85–88번 줄)
src/Projects/BKPresentation/Sources/MainFlow/Search/ViewModel/SearchViewModel.swift (4)

99-99: onAppearWithoutReset 추가 — UX 관점에서 적절합니다.

검색 결과 화면 복귀 시 목록을 보존하고, 최근 검색은 초기 상태에서만 로드하도록 한 분리가 명확합니다.

Also applies to: 192-198


217-217: 기본 검색 결과 수신 시 allBookInfos 초기화 — 충돌 방지에 필요합니다.

기본 검색과 내 서재 검색의 소스 혼재를 예방하는 안전한 처리입니다.


228-240: 내 서재 검색 결과 수신 처리 — 상태 전환과 페이지 가능 여부 계산 적절.

bookInfos로 ResultState를 구성하고 totalResults/hasMoreData를 일관되게 갱신합니다.


336-371: 리뷰 코멘트 무시: myLibrarySearch도 1-based 인덱스를 사용합니다.

  • SearchViewModel.swift (132행)에서 private var currentPage = 1로 선언되어 있어, .myLibrarySearch 분기에서도 첫 페이지가 1로 전달됩니다.
  • MyLibrarySearchBookUseCase.execute(query:startIndex:)startIndex 값은 MyLibraryParameters.pageNumber로 바로 매핑되며, LibraryRequestDTO(pageNumber:)를 통해 page 파라미터로 전송됩니다.
  • 따라서 “myLibrary가 0-based로 시작”한다는 가정은 코드상 맞지 않습니다.

Likely an incorrect or invalid review comment.

import Foundation

public struct BookInfo: Equatable {
public struct BookInfo: Equatable, Hashable {
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Hashable 합성은 모든 필드를 포함합니다 — DiffableDataSource 식별자 안정성 보장 필요

BookInfo를 Hashable로 확장한 목적이 DiffableDataSource의 item 식별자로 쓰기 위함이라면, 현재의 자동 합성(Hashable/Equatable)은 모든 저장 프로퍼티를 포함합니다. 이는 recordCount/updatedAt 등 변동 가능한 값이 바뀔 때마다 “다른 아이템”으로 간주되어 불필요한 삭제/삽입 애니메이션, 셀 깜빡임, 스크롤 위치 튐 등이 발생할 수 있습니다. 스냅샷 식별자는 변하지 않는 키(예: bookId)로 고정하는 것이 안전합니다.

권장(둘 중 택1):

  • 방법 A: BookInfo의 동치성과 해시를 bookId(또는 bookId+isbn) 기준으로 재정의
  • 방법 B: Diffable에 사용할 전용 Item(예: BookInfoItem: Hashable)을 만들어 id만으로 식별하고, 셀 구성에는 원본 BookInfo를 보관

방법 A 예시:

 public struct BookInfo: Equatable, Hashable {
@@
     public let recordCount: Int
@@
     ) {
         self.bookId = bookId
@@
     }
+
+    // id 기반으로 동치성/해시를 고정해 스냅샷 안정성 확보
+    public static func == (lhs: BookInfo, rhs: BookInfo) -> Bool {
+        return lhs.bookId == rhs.bookId
+    }
+
+    public func hash(into hasher: inout Hasher) {
+        hasher.combine(bookId)
+    }

참고: 기존에 BookInfo의 Equatable(자동 합성)을 “내용 비교”로 사용하던 코드가 있다면 의미가 바뀌므로 영향 범위를 점검하세요. 그 경우 방법 B가 더 안전합니다.
또한 BookStatus가 Hashable을 만족하는지 확인해 주세요(URL/Date는 Hashable입니다).

다음 스크립트로 BookStatus 선언/프로토콜을 빠르게 확인할 수 있어요.


🏁 Script executed:

#!/bin/bash
# BookStatus 선언 위치와 프로토콜 확인
rg -n -C2 -g 'src/**' -P '^(public\s+)?(enum|struct|class)\s+BookStatus\b'
# Hashable/Equatable 채택 여부
rg -n -C1 -g 'src/**' -P 'BookStatus\s*:\s*[^\\{]*\bHashable\b'

Length of output: 1028


안정적인 DiffableDataSource 식별자 확보를 위해 BookInfo의 Hashable/Equatable 커스텀 구현 필요

  • 문제: public struct BookInfo: Equatable, Hashable의 자동 합성은 bookId 외의 모든 저장 프로퍼티(recordCount, updatedAt 등)를 포함합니다. 이 값들이 변경되면 DiffableDataSource가 “다른 아이템”으로 간주하여 삽입/삭제 애니메이션, 셀 깜빡임, 스크롤 위치 튐 등이 발생할 수 있습니다.

  • 해결 방안 (둘 중 택1):

    1. 방법 A: BookInfo 내에서 ==hash(into:)bookId 기준으로만 재정의
       public struct BookInfo: Equatable, Hashable {
      @@
           // id 기반으로 동치성/해시를 고정해 스냅샷 안정성 확보
           public static func == (lhs: BookInfo, rhs: BookInfo) -> Bool {
               return lhs.bookId == rhs.bookId
           }
      
           public func hash(into hasher: inout Hasher) {
               hasher.combine(bookId)
           }
       }
    2. 방법 B: DiffableDataSource 전용 식별자 타입(BookInfoItem: Hashable)을 정의하여 식별은 id만으로 처리하고, 셀 구성에는 원본 BookInfo를 보관
  • 추가 확인 사항:

    • 기존에 BookInfo의 자동 Equatable/Hashable을 “내용 비교” 용도로 사용하던 코드가 있다면, 동작 변경에 따른 영향 범위를 반드시 점검해주세요.
    • BookInfo가 사용하는 BookStatussrc/Projects/BKDomain/Sources/VO/BookStatus.swift
      public enum BookStatus: String, Codable {  }
      로 선언되어 있으며 현재 Hashable 채택이 없습니다.
      • 자동 합성을 유지하려면 BookStatus에도 Hashable 채택을 추가하거나
      • 방법 A를 선택하여 BookStatus를 해시 계산에서 제외해도 무방합니다.
🤖 Prompt for AI Agents
In src/Projects/BKDomain/Sources/Entity/BookInfo.swift around line 5, the
automatic Equatable/Hashable synthesis includes all stored properties which
makes DiffableDataSource treat items as different when non-identifier fields
change; fix by either (A) overriding == and hash(into:) on BookInfo to use only
bookId for equality/hash (so DiffableDataSource identity is stable) or (B)
create a separate DiffableDataSource identifier type (e.g., BookInfoItem:
Hashable) that contains only the id and use that as the item identifier while
keeping the original BookInfo for cell configuration; also check for any code
relying on full-value equality and, if keeping synthesis, add Hashable to
BookStatus or exclude it from hashing per the chosen approach.

Comment on lines +322 to 336

switch item {
case .result(let book):
switch book.userBookStatus {
case .beforeRegistration, nil:
eventPublisher.send(.upsertBook(book.isbn))
default:
break
}

case .libraryResult(let bookInfo):
eventPublisher.send(.goToBookDetail(isbn: bookInfo.isbn, userBookId: bookInfo.bookId))

case .query: break
}
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

탭 액션 분기(기본: 업서트, 내 서재: 상세 이동) 구성은 요구사항에 부합합니다. 업서트 status 매핑만 확인 필요.

  • .result(book)에서만 업서트를 보내고, .libraryResult(bookInfo)는 상세로 이동시키는 분리는 자연스럽습니다.
  • 다만 eventPublisher.send(.upsertBook(book.isbn))에서 status 인자가 없는 이벤트가 컨트롤러에서 ViewModel.Action.upsertBook(isbn:status:)로 정상 매핑되는지 확인해 주세요. 기본 상태값이 무엇인지도 함께 점검을 권장합니다.

다음 스크립트로 이벤트 → 액션 매핑을 확인해 주세요.


🏁 Script executed:

#!/bin/bash
# SearchViewEvent 정의와 컨트롤러 매핑 확인
rg -nP -C3 'enum\s+SearchViewEvent\b|case\s+upsertBook|case\s+goToBookDetail' --type=swift
rg -nP -C3 'didReceive\(event:|bind\(|eventPublisher' --type=swift

Length of output: 47247


기본 상태값 확인을 위해 SearchViewModel.Action 정의를 살펴보았습니다.

  • enum Action 내부에 case upsertBook(isbn: String, status: BookRegistrationStatus)만 선언되어 있으며, 기본값을 지정한 오버로드나 편의 생성자가 없습니다.
  • SearchViewController에서는 .upsertBook(isbn: isbn) 형태로 status 인자를 전달하지 않고 호출하고 있어 컴파일 오류가 발생합니다.

따라서, 아래 중 하나를 적용해야 합니다:

  • Action enum에 기본 status 제공
    enum Action {
        case upsertBook(isbn: String, status: BookRegistrationStatus = .beforeRegistration)
        // ...
    }
  • SearchViewController에서 명시적 status 전달
    .sink { [weak self] isbn in
        self?.viewModel.send(.upsertBook(isbn: isbn, status: .beforeRegistration))
    }
  • 편의 메서드 추가
    extension SearchViewModel.Action {
        static func upsertBook(_ isbn: String) -> Self {
            .upsertBook(isbn: isbn, status: .beforeRegistration)
        }
    }

위 중 한 가지 방안을 적용하여 status 매핑을 보장해 주세요.

🤖 Prompt for AI Agents
In src/Projects/BKPresentation/Sources/MainFlow/Search/View/SearchView.swift
around lines 322-336 the call eventPublisher.send(.upsertBook(book.isbn)) no
longer matches SearchViewModel.Action which defines upsertBook(isbn: String,
status: BookRegistrationStatus) causing a compile error; fix by either (A)
adding a default parameter to the Action case so it can be called with only
isbn, (B) adding a convenience factory/extension on SearchViewModel.Action like
static func upsertBook(_ isbn: String) -> Self that supplies a default status,
or (C) update this call site to pass an explicit status value (e.g.
.beforeRegistration); choose one approach and apply it consistently across
usages.

Comment on lines +377 to 403
if searchViewType == .myLibrarySearch {
return myLibrarySearchUseCase.execute(
query: query,
startIndex: currentPage
)
.map { result in
let books = result.books.map(self.mapBookInfoToBook)
return Action.fetchNextPageSuccessed(books)
}
.catch { [weak self] in
self?.lastEffect = .loadNextPage
return Just(Action.errorOccured($0))
}
.eraseToAnyPublisher()
} else {
return defaultSearchUseCase.execute(
query: query,
startIndex: currentPage,
isGuestMode: AccessModeCenter.shared.mode.value == .guest
)
.map { Action.fetchNextPageSuccessed($0.books) }
.catch { [weak self] in
self?.lastEffect = .loadNextPage
return Just(Action.errorOccured($0))
}
.eraseToAnyPublisher()
}
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

⚠️ Potential issue

내 서재 다음 페이지가 Book으로 변환되어 UI/동작이 바뀌는 버그

문제:

  • myLibrarySearch의 loadNextPage에서 BookInfo를 Book으로 변환(mapBookInfoToBook)한 뒤 Action.fetchNextPageSuccessed로 내려보냅니다.
  • reducer의 .fetchNextPageSuccessed는 books만 갱신하고, View는 .result(Book)로 렌더링합니다.
  • 결과적으로 첫 페이지는 .libraryResult(BookInfo) 셀(상세 이동), 다음 페이지부터는 .result(Book) 셀(업서트 시도)로 섞여 UI/행동이 바뀝니다.

해결 지향:

  • 라이브러리 경로는 페이지네이션 전 과정에서 BookInfo를 유지해야 합니다.
  • 별도의 액션(.fetchLibraryNextPageSuccessed([BookInfo]))을 도입하고, reducer에서 allBookInfos를 append하도록 수정하세요.

패치(1/2): 액션 추가

 enum Action {
   case onAppear
   case onAppearWithoutReset  // 검색 결과 유지한 채로 appear
   case search(String)
   case loadNextPage
   case loadNoteFlow
   case deleteRecentQuery(String)
   case upsertBook(isbn: String, status: BookRegistrationStatus)
   case fetchRecentQueriesSuccessed([String])
   case fetchSearchResultSuccessed((books: [Book], totalResults: Int))
   case fetchLibrarySearchResultSuccessed((bookInfos: [BookInfo], totalResults: Int))
   case fetchNextPageSuccessed([Book])
+  case fetchLibraryNextPageSuccessed([BookInfo])
   case upsertBookSuccessed(isbn: String, bookId: String)
   case noteSuggestionShown
   case errorOccured(DomainError)
   case errorHandled
   case retryTapped
   case lastRetryTapped
 }

패치(2/2): myLibrary 페이지네이션에서 BookInfo를 유지

         case .loadNextPage:
           guard let query = currentQuery else {
             return Empty().eraseToAnyPublisher()
           }
-          if searchViewType == .myLibrarySearch {
-              return myLibrarySearchUseCase.execute(
-                  query: query,
-                  startIndex: currentPage
-              )
-              .map { result in
-                  let books = result.books.map(self.mapBookInfoToBook)
-                  return Action.fetchNextPageSuccessed(books)
-              }
-              .catch { [weak self] in
-                  self?.lastEffect = .loadNextPage
-                  return Just(Action.errorOccured($0))
-              }
-              .eraseToAnyPublisher()
-          } else {
+          if searchViewType == .myLibrarySearch {
+              return myLibrarySearchUseCase.execute(
+                  query: query,
+                  startIndex: currentPage
+              )
+              .map { result in
+                  // NOTE: result의 프로퍼티명이 구현에 따라 books 또는 bookInfos일 수 있습니다.
+                  //       BookInfo 배열을 반환하도록 맞춰 주세요.
+                  let infos = result.bookInfos
+                  return Action.fetchLibraryNextPageSuccessed(infos)
+              }
+              .catch { [weak self] in
+                  self?.lastEffect = .loadNextPage
+                  return Just(Action.errorOccured($0))
+              }
+              .eraseToAnyPublisher()
+          } else {
               return defaultSearchUseCase.execute(
                   query: query,
                   startIndex: currentPage,
                   isGuestMode: AccessModeCenter.shared.mode.value == .guest
               )
               .map { Action.fetchNextPageSuccessed($0.books) }
               .catch { [weak self] in
                   self?.lastEffect = .loadNextPage
                   return Just(Action.errorOccured($0))
               }
               .eraseToAnyPublisher()
           }

추가로 reducer에 다음 케이스를 추가해야 합니다(참고용 코드 스니펫):

// reducer switch 내부 어딘가(예: fetchNextPageSuccessed 바로 아래)
case .fetchLibraryNextPageSuccessed(let infos):
    let unique = infos.filter { info in
        !allBookInfos.contains { $0.isbn == info.isbn }
    }
    allBookInfos += unique
    newState.isLoading = false
    newState.searchState = .result(
        ResultState(
            bookInfos: allBookInfos,
            placeholder: searchViewType.resultPlaceholder
        )
    )
    newState.hasMoreData = allBookInfos.count < newState.totalResults

위 수정으로 라이브러리 검색의 1페이지와 이후 페이지가 동일한 셀/동작(.libraryResult → 상세 이동)로 유지됩니다.

원하시면 위 변경을 반영한 전체 패치(PR)와 뷰모델 단위 테스트(라이브러리 페이지네이션 시 BookInfo 유지 여부 검증)까지 함께 준비해 드리겠습니다.

🤖 Prompt for AI Agents
src/Projects/BKPresentation/Sources/MainFlow/Search/ViewModel/SearchViewModel.swift
around lines 377-403: the myLibrary pagination branch maps BookInfo to Book
(mapBookInfoToBook) and emits Action.fetchNextPageSuccessed, causing later pages
to render as Book cells; instead keep BookInfo across pagination by emitting a
new action (e.g. .fetchLibraryNextPageSuccessed([BookInfo])) from this branch
(do not map to Book), preserve lastEffect on error as currently done, and
eraseToAnyPublisher as before; update the rest of the codebase (actions enum and
reducer) to handle .fetchLibraryNextPageSuccessed by appending unique BookInfo
entries to allBookInfos and updating searchState/hasMoreData per the provided
reducer snippet.

Copy link
Member

@doyeonk429 doyeonk429 left a comment

Choose a reason for hiding this comment

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

수고하셨습니다 👍

@doyeonk429
Copy link
Member

게스트로 검색 접근 후 로그인 모달창 dismiss되면 최근검색어 셀의 높이가 길어지는 버그가 간헐적으로 발생한다고 디자인 파트에서 버그 리포트해주셨는데 여유 되시면 검색 쪽 작업하시면서 이것도 확인 부탁드립니다

@clxxrlove
Copy link
Member Author

@doyeonk429 네~

@clxxrlove clxxrlove merged commit d851344 into develop Aug 21, 2025
5 checks passed
@clxxrlove clxxrlove deleted the BOOK-287-feature/#188 branch August 21, 2025 11:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BOOK-287/feat] 내 서재 검색 이후 셀 액션 추가

2 participants