Skip to content

feat: 기록 상세 화면 감정 표시 개편#276

Merged
clxxrlove merged 3 commits intodevelopfrom
BOOK-507-feature/#271
Feb 15, 2026
Merged

feat: 기록 상세 화면 감정 표시 개편#276
clxxrlove merged 3 commits intodevelopfrom
BOOK-507-feature/#271

Conversation

@clxxrlove
Copy link
Member

@clxxrlove clxxrlove commented Feb 15, 2026

🔗 관련 이슈

📘 작업 유형

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

📙 작업 내역

✅ PR 체크리스트

  • 커밋 메시지가 명확합니다
  • PR 제목이 컨벤션에 맞습니다
  • 관련 이슈 번호를 작성했습니다
  • 기능이 정상적으로 작동합니다
  • 불필요한 코드를 제거했습니다

💬 추가 설명 or 리뷰 포인트 (선택)

  • 한 브랜치에서 작업하고 찢어 올리는 PR입니다
  • 506 -> 507 -> 508 순으로 커밋, 리베이스를 반복했으므로 리뷰 반영 등은 508에서 진행하겠습니다.
  • 커밋 히스토리가 더러워져 해당 브랜치는 스쿼시 예정입니다.

Summary by CodeRabbit

릴리스 노트

  • New Features

    • 감정 선택 시스템 개편: 주요 감정과 세부 감정을 분리하여 더 정확한 감정 표현 지원
    • 노트 작성 시 상세 감정 선택 기능 추가
    • 메모 필드 추가 및 페이지 번호를 선택 항목으로 변경
    • 감정 데이터 조회 API 신규 추가
  • UI/UX 개선

    • 노트 작성 화면 재설계로 감정 선택 과정 개선
    • 기록 카드에 세부 감정 표시 추가

@coderabbitai
Copy link

coderabbitai bot commented Feb 15, 2026

Warning

Rate limit exceeded

@clxxrlove has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 28 minutes and 48 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

Walkthrough

감정 데이터 관리 시스템을 재설계합니다. API 엔드포인트의 baseURL을 버전별로 분리하고, 감정 관련 저장소와 Use Case를 추가합니다. 기록 데이터 구조를 변경하여 primaryEmotion과 detailEmotions으로 구분하고, 페이지를 선택적으로 처리합니다.

Changes

Cohort / File(s) Summary
API baseURL 버전 분리
src/Projects/BKData/Sources/API/AuthAPI.swift, BookAPI.swift, HomeAPI.swift, UserAPI.swift
baseURL을 APIConfig.baseURLv1로 통일하여 API 버전을 명시적으로 관리합니다.
감정 API 추가
src/Projects/BKData/Sources/API/EmotionAPI.swift, RecordAPI.swift
EmotionAPI 새 파일 추가 및 RecordAPI의 baseURL 로직을 baseURLv2로 단순화; RecordAPI의 patch 메서드를 PUT으로 변경합니다.
APIConfig 리팩토링
src/Projects/BKData/Sources/Constant/APIConfig.swift
baseV2URL을 제거하고 baseURLv2를 baseURL에서 파생시켜 URL 버전 관리를 중앙화합니다.
Record 요청/응답 DTO 변경
src/Projects/BKData/Sources/DTO/Request/InsertRecordRequestDTO.swift, Response/DetailRecordResponseDTO.swift, Response/InsertRecordResponseDTO.swift
emotionTags를 primaryEmotion과 detailEmotionTagIds로 대체; pageNumber를 Int?로 변경; review를 memo로 이름 변경합니다.
감정 응답 DTO 새 추가
src/Projects/BKData/Sources/DTO/Response/EmotionResponseDTO.swift, PrimaryEmotionResponseDTO.swift
감정 데이터 구조(PrimaryEmotionDTO, DetailEmotionDTO, EmotionListResponseDTO)와 매핑 로직을 추가합니다.
데이터 계층 통합
src/Projects/BKData/Sources/DataAssembly.swift, Repository/DefaultEmotionRepository.swift, Repository/DefaultRecordRepository.swift
EmotionRepository DI 등록 추가; DefaultEmotionRepository 구현으로 캐싱된 감정 데이터 조회 제공; RecordRepository의 감정 매핑 로직 업데이트합니다.
도메인 엔티티 확장
src/Projects/BKDomain/Sources/Entity/DetailEmotion.swift, Emotion.swift, PrimaryEmotion.swift, RecordInfo.swift, VO/RecordDetails/RecordVO.swift
PrimaryEmotion과 DetailEmotion 새 공개 타입 추가; Emotion.etc를 Emotion.other로 변경; SubEmotion 새 열거형 추가; RecordInfo와 RecordVO에 primaryEmotion/detailEmotions 추가 및 pageNumber를 Int?로 변경합니다.
도메인 저장소/Use Case 인터페이스
src/Projects/BKDomain/Sources/Interface/Repository/EmotionRepository.swift, Interface/Usecase/FetchDetailEmotionsUseCase.swift, UseCase/DefaultFetchDetailEmotionsUseCase.swift
EmotionRepository 프로토콜과 FetchDetailEmotionsUseCase 프로토콜 추가; 구현체 DefaultFetchDetailEmotionsUseCase 추가합니다.
도메인 DI 통합
src/Projects/BKDomain/Sources/DomainAssembly.swift
FetchDetailEmotionsUseCase DI 바인딩 추가합니다.
디자인 이미지 자산
src/Projects/BKDesign/Resources/Assets.xcassets/graphics/note/*, recordcard_etc.imageset/*
note 감정별 이미지(joy, sad, warm, insight, default)와 recordcard_etc 이미지 자산 추가합니다.
디자인 컴포넌트 업데이트
src/Projects/BKDesign/Sources/Components/Chip/BKChip.swift, BKRemovableChip.swift
BKChip에 count 0일 때 숨김 처리 추가; BKRemovableChip 새 공개 컴포넌트 추가합니다.
디자인 시스템 확장
src/Projects/BKDesign/Sources/Foundation/GraphicSystem/BKImage.swift
BKImage.Graphics에 etcCard와 Note 중첩 열거형(joy, sad, warm, insight, default) 추가합니다.
프레젠테이션 감정 UI 확장
src/Projects/BKPresentation/Sources/Common/Extension/PrimaryEmotion+UI.swift, BKBottomSheetViewController+.swift
PrimaryEmotion UI 매핑(image, color, hashtag 등) 및 레거시 변환 로직 추가; DetailEmotion 선택 바텀시트 생성 메서드 추가합니다.
기록 상세 UI 변경
src/Projects/BKPresentation/Sources/MainFlow/BookDetail/Models/BookDetailItem.swift, EmotionSeed.swift, View/BookDetailView.swift, View/BookDetailViewCell.swift, View/SeedReportView.swift, View/SentenceCardView.swift
emotion을 primaryEmotion으로 변경; EmotionSeed.from(emotion:)을 옵션으로 반환하도록 수정; 감정 표시 로직 업데이트합니다.
노트 작성 흐름 UI 재설계
src/Projects/BKPresentation/Sources/MainFlow/Note/View/EmotionRegistrationView.swift, NoteView.swift, SentenceAppreciationView.swift, SentenceRegistrationView.swift
appreciation 폼 제거 및 memo 필드 추가; 감정 행 기반 UI로 전환; 세부 감정 선택 플로우 추가; page와 memo를 optional로 변경합니다.
노트 ViewModel/Coordinator 통합
src/Projects/BKPresentation/Sources/MainFlow/Note/ViewModel/NoteForm.swift, NoteViewModel.swift, View/NoteViewController.swift
NoteViewEvent에 didSelectEmotion 추가; FetchDetailEmotionsUseCase 주입; 세부 감정 로딩 및 선택 상태 관리; 바텀시트 플로우 추가합니다.
노트 완성 화면 업데이트
src/Projects/BKPresentation/Sources/MainFlow/NoteCompletion/View/AppreciationResultView.swift, CollectedSentenceView.swift, NoteCompletionView.swift
apply 메서드 시그니처를 primaryEmotion/detailEmotions로 변경; TagFlowView로 태그 레이아웃 개선; page optional 처리합니다.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant NoteViewController
    participant NoteViewModel
    participant EmotionRegistrationView
    participant FetchDetailEmotionsUseCase
    participant EmotionRepository
    participant DetailEmotionBottomSheet

    User->>NoteViewController: 감정 선택 클릭
    NoteViewController->>EmotionRegistrationView: 감정 행 선택 감지
    EmotionRegistrationView->>NoteViewController: onEmotionSelected 콜백
    NoteViewController->>NoteViewModel: didSelectEmotion(primaryEmotion) 액션
    
    NoteViewController->>NoteViewModel: fetchDetailEmotions(emotion) 액션
    NoteViewModel->>NoteViewModel: isLoadingEmotions = true
    NoteViewController->>NoteViewController: setLoadingEmotions(true)
    
    NoteViewModel->>FetchDetailEmotionsUseCase: execute(primaryEmotion)
    FetchDetailEmotionsUseCase->>EmotionRepository: getDetailEmotions(primaryEmotion)
    EmotionRepository-->>FetchDetailEmotionsUseCase: [DetailEmotion] 반환
    FetchDetailEmotionsUseCase-->>NoteViewModel: [DetailEmotion] 발행
    
    NoteViewModel->>NoteViewModel: fetchDetailEmotionsSuccessed 액션
    NoteViewModel->>NoteViewModel: isLoadingEmotions = false, detailEmotions 저장
    NoteViewController->>NoteViewController: setLoadingEmotions(false)
    NoteViewController->>NoteViewController: 세부 감정 바텀시트 표시
    
    DetailEmotionBottomSheet->>User: 세부 감정 선택 UI 표시
    User->>DetailEmotionBottomSheet: 세부 감정 선택 후 확인
    DetailEmotionBottomSheet->>NoteViewController: confirmAction 콜백
    NoteViewController->>EmotionRegistrationView: setDetailEmotions([DetailEmotion])
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

  • feat: 1차 API 연결 #134 — Record API 엔드포인트와 InsertRecordRequestDTO 구조가 동일하게 변경되어 코드 레벨에서 직접 연관됩니다.

Suggested labels

✨ feat

Suggested reviewers

  • doyeonk429
🚥 Pre-merge checks | ✅ 4 | ❌ 2
❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.54% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Merge Conflict Detection ⚠️ Warning ⚠️ Unable to check for merge conflicts: Invalid branch name format
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목 '기록 상세 화면 감정 표시 개편'은 변경사항의 주요 내용을 명확하게 설명하고 있습니다.
Linked Issues check ✅ Passed PR의 코드 변경사항이 연결된 이슈 #271의 요구사항을 충족합니다: 특정 필드 옵셔널 처리(pageNumber: Int → Int?) 및 감정 선택 플로우 수정이 구현되었습니다.
Out of Scope Changes check ✅ Passed 모든 변경사항이 이슈 #271의 범위 내에 있으며, 감정 표시 시스템 개편과 관련된 필드 및 UI 컴포넌트 수정에 집중되어 있습니다.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch BOOK-507-feature/#271

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

🤖 Fix all issues with AI agents
In `@src/Projects/BKData/Sources/Constant/APIConfig.swift`:
- Around line 19-22: 현재 baseURLv2 클로저는 baseURL.replacingOccurrences(of:
"/api/v1", with: "/api/v2") 만 호출하므로 치환이 실패하면 동일한 V1 URL을 반환합니다; baseURLv2 초기화
로직(심볼: baseURLv2, 참조: baseURL, replacingOccurrences)을 수정해 치환 결과가 실제로 "/api/v2"를
포함하는지 검사하는 assertion 또는 guard를 추가하고(예: 치환 전후 비교 또는 contains 검사), 실패 시 명확한 오류/로그
또는 런타임 실패를 발생시키도록 변경해 잘못된 환경설정이 조기에 드러나게 하세요.

In `@src/Projects/BKData/Sources/DTO/Response/EmotionResponseDTO.swift`:
- Around line 46-53: Remove the unused duplicate type PrimaryEmotionResponseDTO
and consolidate uses to the existing PrimaryEmotionDTO: delete the
PrimaryEmotionResponseDTO definition, update any references (if any) to use
PrimaryEmotionDTO, and ensure call sites like InsertRecordResponseDTO and
DetailRecordResponseDTO continue to call primaryEmotion.toDomain() ?? .other;
keep the PrimaryEmotionDTO struct (code, displayName, toDomain()) as the
canonical type.

In `@src/Projects/BKDesign/Sources/Components/Chip/BKChip.swift`:
- Line 55: Remove the duplicate SwiftLint disable for the empty_count rule in
BKChip.swift (the second `// swiftlint:disable empty_count`), leaving the single
disable at the top (already at line 23) or replace the duplicate with a targeted
`// swiftlint:disable:next empty_count` placed immediately before the specific
statement that needs it; ensure only one blanket disable remains to avoid
SwiftLint's blanket_disable_command warning and that any local exemptions use
the :next form.
- Around line 71-83: The titleLabel trailing constraint to superview created in
setupLayout() causes conflicts when count changes from 0 to non-zero; modify
setupLayout() to store that trailing NSLayoutConstraint (or SnapKit Constraint)
as a property (e.g., titleTrailingToSuperviewConstraint) and do not permanently
activate it, then in the count's didSet toggle constraints: if count == 0
activate the titleTrailingToSuperviewConstraint and deactivate the
titleLabel.trailing-to-countLabel constraint (or hide/disable countLabel
constraints), else deactivate titleTrailingToSuperviewConstraint and activate
the constraints that pin titleLabel to countLabel (or the existing countLabel
constraints); after toggling call setNeedsLayout()/layoutIfNeeded() or
updateConstraintsIfNeeded() to apply changes. Ensure you reference
setupLayout(), count's didSet, titleLabel, countLabel, and the saved
titleTrailingToSuperviewConstraint when making the change.

In
`@src/Projects/BKPresentation/Sources/Common/Extension/PrimaryEmotion`+UI.swift:
- Around line 121-141: The from(seedName:) method in PrimaryEmotion omits
handling the "other"/"기타" seed name so Seeds named "기타" return nil; update
PrimaryEmotion.from(seedName:) to include a case for "other" and "기타" that
returns .other (so PrimaryEmotion.from(seedName:) and by extension from(seed:)
behave consistently with EmotionSeed.from(seedName:)). Ensure the switch in
from(seedName:) adds: case "other", "기타": return .other.

In
`@src/Projects/BKPresentation/Sources/MainFlow/Note/View/EmotionRegistrationView.swift`:
- Around line 370-399: The code uses PrimaryEmotion.hashValue in rowView.tag and
then finds the emotion by matching tags in emotionRowTapped, which is fragile;
instead assign a stable identifier (e.g. the PrimaryEmotion rawValue or its
index) to the view tag or better yet avoid tag lookups entirely and locate the
tapped emotion via the emotionRows dictionary or by storing the PrimaryEmotion
on the view (e.g. a property on the row view). Update the places referencing
hashValue (where rowView.tag is set and in emotionRowTapped where
PrimaryEmotion.allCases.first(where:) is used) to use the chosen stable
identifier or direct mapping so tapping reliably resolves the correct
PrimaryEmotion.

In
`@src/Projects/BKPresentation/Sources/MainFlow/Note/ViewModel/NoteViewModel.swift`:
- Around line 115-119: 핵심 문제: fetchDetailEmotions 핸들러가 실패 시 self?.lastEffect를
갱신하지 않아 retryTapped로 재시도 불가능하거나 잘못된 이펙트가 재시도된다; 수정법은 fetchDetailEmotions 케이스
처리부에서 실행 전에 self?.lastEffect에 해당 이펙트(예: .fetchDetailEmotions(primaryEmotion))를
저장하거나, 대신 에러를 일반 재시도 다이얼로그를 트리거하는 Action.errorOccured로 보내지 말고 감정 전용 실패
액션(Action.fetchDetailEmotionsFailed 등)을 반환하도록 변경하여 재시도 흐름과 사용자 다이얼로그 동작을 분리하는
것(함수명/심볼: fetchDetailEmotionsUseCase.execute,
Action.fetchDetailEmotionsSuccessed, Action.errorOccured, retryTapped,
lastEffect).
- Around line 89-95: The failure path for fetchDetailEmotions leaves
isLoadingEmotions true and retries don't replay the emotion effect; update the
errorOccured handler to also set newState.isLoadingEmotions = false whenever an
error occurs and/or add specific error handling for emotion failures, and modify
the handle(.fetchDetailEmotions(primaryEmotion)) logic to record the lastEffect
(or store the specific fetchDetailEmotions effect) so the isRetrying flow can
re-dispatch the exact effect when retry is triggered (adjust usage of
lastEffect/isRetrying to include the fetchDetailEmotions effect).

In
`@src/Projects/BKPresentation/Sources/MainFlow/NoteCompletion/View/AppreciationResultView.swift`:
- Around line 267-299: Track the previously computed intrinsic height and only
call invalidateIntrinsicContentSize when the computed tag height actually
changes beyond a small threshold: add a private stored property (e.g.,
previousIntrinsicHeight: CGFloat = 0), compute newHeight in layoutTags(),
compare newHeight to previousIntrinsicHeight (use a small epsilon like 0.5), and
if different update previousIntrinsicHeight and call
invalidateIntrinsicContentSize(); keep calls confined to
layoutTags()/layoutSubviews but remove the direct comparison against
bounds.height to avoid the recursive layout loop. Ensure you reference
layoutSubviews, layoutTags, invalidateIntrinsicContentSize, and the new
previousIntrinsicHeight symbol when making the change.
🧹 Nitpick comments (23)
src/Projects/BKPresentation/Sources/MainFlow/NoteCompletion/View/CollectedSentenceView.swift (1)

83-87: page가 nil일 때 "-p" 표시가 의도된 디자인인지 확인 필요

페이지가 없을 때 "-p"를 표시하면 사용자에게 데이터 누락처럼 보일 수 있습니다. 빈 문자열이나 페이지 라벨 자체를 숨기는 방식도 고려해볼 수 있습니다. 의도된 디자인이라면 무시해주세요.

대안: 페이지 없을 때 라벨 숨기기
         if let page {
             pageLabel.setText(text: "\(page)p")
+            pageLabel.isHidden = false
         } else {
-            pageLabel.setText(text: "-p")
+            pageLabel.isHidden = true
         }
src/Projects/BKPresentation/Sources/MainFlow/Note/View/SentenceRegistrationView.swift (2)

30-36: sentenceLabelRow에 단일 요소만 포함 — 스택뷰가 필요한지 확인 필요.

sentenceLabelRowsentenceLabel 하나만 포함하고 있어, 다른 label row(pageLabelRow, memoLabelRow)처럼 badge가 없습니다. 향후 badge 추가 가능성을 고려한 일관성 유지라면 이해되지만, 현재로선 불필요한 래핑입니다. 의도적인 구조라면 무시해주세요.

Also applies to: 130-132


234-252: pageField에 숫자가 아닌 값이 입력될 경우 Int(trimmedPage)nil을 반환할 수 있습니다.

UITextFieldDelegate에서 숫자만 허용하도록 검증하고 있지만, setText 등 프로그래밍 방식으로 텍스트가 설정되면 delegate를 우회할 수 있습니다. Int(trimmedPage)nil을 반환하면 page가 nil로 처리되어 사용자가 입력한 값이 무시됩니다. 현재 흐름상 큰 문제는 아니지만, 방어적으로 처리하려면 파싱 실패 시 nil 반환 또는 에러 처리를 고려해보세요.

🛡️ 방어적 처리 제안
         let trimmedPage = pageField.text.trimmingCharacters(in: .whitespacesAndNewlines)
-        let page: Int? = trimmedPage.isEmpty ? nil : Int(trimmedPage)
+        let page: Int?
+        if trimmedPage.isEmpty {
+            page = nil
+        } else {
+            guard let parsed = Int(trimmedPage) else { return nil }
+            page = parsed
+        }
src/Projects/BKPresentation/Sources/MainFlow/BookDetail/Models/EmotionSeed.swift (2)

65-73: from(emotion:) 반환 타입이 옵셔널이지만 모든 케이스를 처리하고 있습니다.

현재 Emotion의 모든 케이스(.joy, .sad, .insight, .warmth, .other)가 매핑되어 있어 실제로 nil이 반환되는 경우는 없습니다. 향후 Emotion에 새로운 케이스가 추가될 경우를 대비한 의도라면 이해되지만, 관련 snippet의 PrimaryEmotion.from(emotion:)은 non-optional을 반환하고 있어 접근 방식이 다릅니다.

의도된 설계라면 무시해도 좋습니다.


15-33: .etc 케이스의 이미지 플레이스홀더 상태 확인 필요

image에서 BKImage.Graphics.sad, circleImage에서 BKImage.Graphics.insightCircle을 사용하고 있습니다. BKImage 에셋을 확인한 결과, 4가지 주요 감정(warm, joy, insight, sad)에 대한 전용 이미지는 모두 정의되어 있으나, .etc 케이스용 전용 에셋(etcCircle, etcGraphic 등)은 존재하지 않습니다.

다만 cardImage 속성에서는 전용 BKImage.Graphics.etcCard를 사용하고 있어, 설계 에셋 완성 상태가 속성별로 다른 것으로 보입니다. 카드 에셋은 완성되었으나 일반 이미지/서클 에셋은 아직 대기 중인 상황인지, 아니면 모두 함께 완성 예정인지 확인 부탁드립니다.

src/Projects/BKPresentation/Sources/MainFlow/BookDetail/View/SeedReportView.swift (1)

129-136: 옵셔널 반환에 대한 guard let 처리가 적절합니다.

EmotionSeed.from(emotion:)의 반환 타입이 옵셔널로 변경된 것에 맞춰 안전하게 처리하고 있습니다. 다만 매핑 실패 시 조용히 return하면 UI가 비어 보일 수 있는데, 이 경우 기본 상태 표시나 로그가 필요한지 검토해 보시면 좋겠습니다.

src/Projects/BKPresentation/Sources/MainFlow/Note/View/SentenceAppreciationView.swift (1)

146-151: 사용자 입력이 무시되는 상태에서 UI가 여전히 활성화되어 있습니다.

registrationForm()nil을 반환하지만, appreciationTextView는 여전히 편집 가능하고 inputChangedPublisher도 이벤트를 발생시킵니다. Phase 4에서 제거 예정이라면 현재 단계에서라도 텍스트뷰를 비활성화하거나 뷰 자체를 숨기는 것이 사용자 혼란을 줄일 수 있습니다.

Phase 4에서의 제거가 곧 이루어질 예정이라면 현재 상태로 유지해도 무방합니다.

src/Projects/BKData/Sources/API/RecordAPI.swift (1)

11-11: enum case 이름 .patch와 실제 HTTP 메서드 .put의 불일치

V2 API에서 PUT을 사용한다면, enum case 이름도 .update 또는 .put으로 변경하는 것이 코드 가독성에 도움됩니다. 현재는 주석으로 보완하고 있지만, 호출부에서 .patch를 보고 PATCH 요청이라고 오해할 수 있습니다.

Also applies to: 43-44

src/Projects/BKPresentation/Sources/MainFlow/BookDetail/View/BookDetailView.swift (1)

21-23: 정렬 안정성(stability) 저하: 동일 페이지 값에 대한 보조 정렬 기준 제거

이전 구현에서는 페이지가 동일할 때 createdAt을 보조 정렬 기준으로 사용했지만, 현재 구현에서는 페이지 값만으로 비교합니다. 페이지가 같거나 둘 다 nil인 경우 항목 순서가 비결정적(non-deterministic)이 될 수 있습니다.

♻️ 보조 정렬 기준 추가 제안
 case .pageDescending:
-    return { ($0.page ?? 0) > ($1.page ?? 0) }
+    return {
+        let lhs = $0.page ?? 0
+        let rhs = $1.page ?? 0
+        if lhs != rhs { return lhs > rhs }
+        return $0.createdAt > $1.createdAt
+    }
src/Projects/BKDesign/Sources/Components/Chip/BKRemovableChip.swift (2)

34-39: init에서 self.title = title 할당 시 didSet이 호출되지 않습니다.

Swift에서 같은 클래스의 init 내 프로퍼티 할당은 didSet을 트리거하지 않습니다. 현재 setupViews()의 Line 58에서 titleLabel.setText(text: title)로 보완하고 있어 동작에는 문제가 없지만, 향후 리팩토링 시 혼란의 소지가 있습니다.


52-56: 하드코딩된 hex 색상 #E3F8E9은 디자인 시스템과 일관성이 떨어집니다.

프로젝트 내 다른 컴포넌트들은 bkBaseColor(), bkContentColor() 등 시맨틱 컬러를 사용하고 있습니다. 가능하다면 디자인 시스템 토큰으로 교체하는 것을 권장합니다.

src/Projects/BKDomain/Sources/Entity/PrimaryEmotion.swift (1)

25-33: description 프로퍼티가 Swift 기본 출력 동작을 덮어씁니다.

descriptionCustomStringConvertible 프로토콜의 관례적 프로퍼티명입니다. PrimaryEmotion 인스턴스를 print()하거나 문자열 보간에 사용할 때, 케이스명 대신 한글 설명이 출력되어 디버깅 시 혼란을 줄 수 있습니다. emotionDescription 등 다른 이름을 고려해 보세요.

src/Projects/BKPresentation/Sources/MainFlow/Note/View/NoteViewController.swift (1)

160-176: scan 기반 로딩 완료 감지 로직에 대한 참고.

scan을 사용해 isLoadingEmotionstrue → false 전환을 감지하는 패턴은 기능적으로 올바르지만, 가독성이 다소 떨어집니다. 튜플 인덱스(prev.1, current.0, current.1)가 의미를 파악하기 어렵게 만듭니다.

참고: 이 파이프라인은 NoteViewModelerrorOccured에서 isLoadingEmotions = false가 처리되어야 에러 시에도 로딩 상태가 올바르게 해제됩니다 (ViewModel 리뷰 참조).

♻️ 가독성 개선을 위한 named tuple 사용 제안
         viewModel.statePublisher
             .map { ($0.isLoadingEmotions, $0.detailEmotions) }
-            .scan((false, false, [DetailEmotion]())) { prev, current in
-                // (wasLoading, isLoading, detailEmotions)
-                (prev.1, current.0, current.1)
+            .scan((wasLoading: false, isLoading: false, detailEmotions: [DetailEmotion]())) { prev, current in
+                (wasLoading: prev.isLoading, isLoading: current.0, detailEmotions: current.1)
             }
-            .filter { wasLoading, isLoading, _ in wasLoading && !isLoading }
-            .map { $0.2 }
+            .filter { $0.wasLoading && !$0.isLoading }
+            .map { $0.detailEmotions }
src/Projects/BKPresentation/Sources/Common/Extension/BKBottomSheetViewController+.swift (2)

360-454: layoutSubviewsintrinsicContentSize에서 행 그룹핑 로직이 중복됩니다.

칩 크기 계산 → 행 그룹핑 알고리즘이 layoutSubviews (Lines 366-389)와 intrinsicContentSize (Lines 423-444)에서 거의 동일하게 반복됩니다. 공통 로직을 private 헬퍼로 추출하면 유지보수성이 개선됩니다.

또한, Line 417에서 layoutSubviews 내에서 invalidateIntrinsicContentSize()를 호출하는 것은 부모 뷰가 intrinsic size 변경에 반응하여 bounds를 변경할 경우 레이아웃 루프를 유발할 수 있습니다. 이전 크기와 비교 후 변경 시에만 invalidate하는 것이 더 안전합니다.

♻️ 행 그룹핑 로직 추출 및 안전한 invalidation 제안
 private final class CenteredFlowLayoutView: UIView {
     private var chipViews: [UIView] = []
     private let horizontalSpacing: CGFloat = 8
     private let verticalSpacing: CGFloat = 8
+    private var lastComputedHeight: CGFloat = 0
+
+    private func computeRows(maxWidth: CGFloat) -> (rows: [[Int]], chipSizes: [CGSize]) {
+        let chipSizes = chipViews.map { $0.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) }
+        var rows: [[Int]] = []
+        var currentRow: [Int] = []
+        var currentRowWidth: CGFloat = 0
+
+        for (index, chipSize) in chipSizes.enumerated() {
+            let chipWidth = chipSize.width
+            let neededWidth = currentRow.isEmpty ? chipWidth : horizontalSpacing + chipWidth
+            if currentRowWidth + neededWidth > maxWidth, !currentRow.isEmpty {
+                rows.append(currentRow)
+                currentRow = [index]
+                currentRowWidth = chipWidth
+            } else {
+                currentRow.append(index)
+                currentRowWidth += neededWidth
+            }
+        }
+        if !currentRow.isEmpty { rows.append(currentRow) }
+        return (rows, chipSizes)
+    }

그리고 layoutSubviewsintrinsicContentSize에서 computeRows(maxWidth:)를 사용하세요:

     override func layoutSubviews() {
         super.layoutSubviews()
         let maxWidth = bounds.width
         guard maxWidth > 0 else { return }
-        let chipSizes = chipViews.map { ... }
-        // ... duplicated grouping logic ...
+        let (rows, chipSizes) = computeRows(maxWidth: maxWidth)
         // ... layout code using rows and chipSizes ...
-        invalidateIntrinsicContentSize()
+        let newHeight = /* computed total height */
+        if abs(newHeight - lastComputedHeight) > 0.5 {
+            lastComputedHeight = newHeight
+            invalidateIntrinsicContentSize()
+        }
     }

421-421: 매직 넘버 40 사용.

UIScreen.main.bounds.width - 40 에서 40은 의도가 불분명합니다. 양쪽 20pt 마진을 의미하는 것으로 보이는데, 이름이 있는 상수나 BKInset/BKSpacing 값으로 대체하면 의도가 더 명확해집니다.

src/Projects/BKPresentation/Sources/Common/Extension/PrimaryEmotion+UI.swift (2)

95-116: toLegacyEmotion이 도메인의 toEmotion()과 중복됩니다.

PrimaryEmotion.swift(도메인)에 이미 동일한 매핑을 수행하는 toEmotion() -> Emotion 메서드가 존재합니다. toLegacyEmotion은 이 메서드와 완전히 동일한 로직입니다. 또한 init?(from legacyEmotion:)은 모든 케이스를 처리하므로 실패할 수 없는 failable initializer입니다.

♻️ 중복 제거 및 failable init 개선 제안
-    /// 기존 Emotion에서 변환
-    init?(from legacyEmotion: Emotion) {
+    /// 기존 Emotion에서 변환
+    init(from legacyEmotion: Emotion) {
         switch legacyEmotion {
         case .warmth: self = .warmth
         case .joy: self = .joy
         case .sad: self = .sadness
         case .insight: self = .insight
         case .other: self = .other
         }
     }
 
-    /// 기존 Emotion으로 변환 (하위 호환성)
-    var toLegacyEmotion: Emotion {
-        switch self {
-        case .warmth: return .warmth
-        case .joy: return .joy
-        case .sadness: return .sad
-        case .insight: return .insight
-        case .other: return .other
-        }
-    }
+    /// 기존 Emotion으로 변환 (하위 호환성)
+    var toLegacyEmotion: Emotion {
+        toEmotion()
+    }

81-90: 하드코딩된 hex 색상 값 확인.

shareBackgroundColor에서 hex 값이 직접 사용되고 있습니다. 프로젝트의 다른 색상들은 BKSemanticColor 등 디자인 시스템을 통해 관리되는데, 이 부분만 하드코딩되어 있어 다크모드 등에서 일관성 문제가 있을 수 있습니다. 인스타 스토리 공유 전용 색상이라 의도적일 수 있으나, 향후 관리 차원에서 참고해주세요.

src/Projects/BKData/Sources/Repository/DefaultEmotionRepository.swift (2)

24-28: .map 내 사이드 이펙트(캐시 저장)는 .handleEvents가 더 적절합니다.

Combine의 .map은 순수 변환 용도이므로, 캐시 저장과 같은 부수 효과는 .handleEvents(receiveOutput:)에서 처리하는 것이 의미적으로 더 명확합니다.

♻️ handleEvents 사용 제안
         .mapError { $0.toDomainError() }
         .debugError(logger: AppLogger.network)
-        .map { [weak self] response in
-            let emotions = response.emotions.compactMap { $0.toDomain() }
-            self?.cacheQueue.sync {
-                self?.cachedEmotions = emotions
-            }
-            return emotions
-        }
+        .map { response in
+            response.emotions.compactMap { $0.toDomain() }
+        }
+        .handleEvents(receiveOutput: { [weak self] emotions in
+            self?.cacheQueue.sync {
+                self?.cachedEmotions = emotions
+            }
+        })

8-47: 캐시 무효화(invalidation) 전략 부재.

현재 캐시가 한번 설정되면 앱 생명주기 동안 절대 만료되지 않습니다. 감정 목록이 서버에서 변경되더라도 반영되지 않을 수 있습니다. DI에서 싱글톤으로 등록된다면 더욱 문제가 될 수 있습니다.

향후 필요 시 TTL 기반 만료나 강제 리프레시 메서드 추가를 고려해주세요.

src/Projects/BKPresentation/Sources/MainFlow/NoteCompletion/View/AppreciationResultView.swift (3)

301-329: intrinsicContentSize에서 fallback 너비 UIScreen.main.bounds.width - 100은 매직 넘버입니다.

이 값은 실제 부모 뷰의 레이아웃과 무관하게 임의로 설정되어, 다양한 화면 크기나 레이아웃 변경 시 정확하지 않을 수 있습니다. EmotionRegistrationView.swiftChipFlowLayoutView에서도 유사한 패턴(UIScreen.main.bounds.width - 32 - 32)이 사용됩니다.


343-344: badgeCornerRadius 상수가 선언되었지만 사용되지 않습니다.

Line 155에서 emotionBadge.bounds.height / 2를 직접 사용하고 있어, badgeCornerRadius 상수는 데드 코드입니다.

♻️ 미사용 상수 제거
         static let emotionInfoSpacing: CGFloat = 4
         static let emotionIconSize: CGFloat = 40
-        static let badgeCornerRadius: CGFloat = 12
         static let minSpacingToDate: CGFloat = 16

237-330: TagFlowViewEmotionRegistrationViewChipFlowLayoutView가 거의 동일한 로직을 가집니다.

두 클래스 모두 flow layout 계산(가로 배치 → 줄바꿈)과 intrinsicContentSize 오버라이드를 동일한 패턴으로 구현하고 있습니다. 공통 베이스 클래스 또는 유틸리티로 통합하면 유지보수성이 향상됩니다.

src/Projects/BKPresentation/Sources/MainFlow/Note/View/EmotionRegistrationView.swift (1)

97-103: cornerRadius = 30 하드코딩은 아이콘 크기(60)에 종속됩니다.

아이콘 크기가 변경되면 cornerRadius도 같이 수정해야 합니다. 상수나 크기 기반 계산으로 연결하면 유지보수가 용이합니다.

♻️ 크기 기반 계산 제안
+    private static let iconSize: CGFloat = 60
+
     private let iconImageView: UIImageView = {
         let imageView = UIImageView()
         imageView.contentMode = .scaleAspectFill
         imageView.clipsToBounds = true
-        imageView.layer.cornerRadius = 30
+        imageView.layer.cornerRadius = iconSize / 2
         return imageView
     }()

Comment on lines 19 to 22
static let baseURLv2: String = {
// V1 URL에서 v2로 변경
return baseURL.replacingOccurrences(of: "/api/v1", with: "/api/v2")
}()
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

문자열 치환 방식이 취약합니다 — baseURL/api/v1이 없으면 V2 URL이 V1과 동일해집니다.

replacingOccurrences는 패턴이 없을 경우 원본 문자열을 그대로 반환합니다. 환경 설정 오류나 URL 형식 변경 시 V2 엔드포인트가 V1 API를 호출하게 되어 런타임에 발견하기 어려운 버그가 발생할 수 있습니다.

치환 결과를 검증하는 assertion을 추가하는 것을 권장합니다.

🛡️ 방어적 검증 추가 제안
 static let baseURLv2: String = {
-    // V1 URL에서 v2로 변경
-    return baseURL.replacingOccurrences(of: "/api/v1", with: "/api/v2")
+    let v2URL = baseURL.replacingOccurrences(of: "/api/v1", with: "/api/v2")
+    assert(v2URL != baseURL, "baseURL에 '/api/v1' 경로가 포함되어 있지 않습니다. BASE_API_URL 환경 설정을 확인하세요.")
+    return v2URL
 }()
🤖 Prompt for AI Agents
In `@src/Projects/BKData/Sources/Constant/APIConfig.swift` around lines 19 - 22,
현재 baseURLv2 클로저는 baseURL.replacingOccurrences(of: "/api/v1", with: "/api/v2") 만
호출하므로 치환이 실패하면 동일한 V1 URL을 반환합니다; baseURLv2 초기화 로직(심볼: baseURLv2, 참조: baseURL,
replacingOccurrences)을 수정해 치환 결과가 실제로 "/api/v2"를 포함하는지 검사하는 assertion 또는 guard를
추가하고(예: 치환 전후 비교 또는 contains 검사), 실패 시 명확한 오류/로그 또는 런타임 실패를 발생시키도록 변경해 잘못된 환경설정이
조기에 드러나게 하세요.

updateAppearance()
}

// swiftlint:disable empty_count
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

중복된 swiftlint:disable empty_count 제거 필요

Line 23에서 이미 동일한 규칙이 비활성화되어 있습니다. SwiftLint에서도 blanket_disable_command 경고를 발생시키고 있습니다. 하나만 유지하거나, // swiftlint:disable:next empty_count로 필요한 곳에서만 개별 적용하세요.

🧰 Tools
🪛 SwiftLint (0.63.2)

[Warning] 55-55: The disabled 'empty_count' rule was already disabled

(blanket_disable_command)


[Warning] 55-55: Use 'next', 'this' or 'previous' instead to disable the 'empty_count' rule once, or re-enable it as soon as possible`

(blanket_disable_command)

🤖 Prompt for AI Agents
In `@src/Projects/BKDesign/Sources/Components/Chip/BKChip.swift` at line 55,
Remove the duplicate SwiftLint disable for the empty_count rule in BKChip.swift
(the second `// swiftlint:disable empty_count`), leaving the single disable at
the top (already at line 23) or replace the duplicate with a targeted `//
swiftlint:disable:next empty_count` placed immediately before the specific
statement that needs it; ensure only one blanket disable remains to avoid
SwiftLint's blanket_disable_command warning and that any local exemptions use
the :next form.

Comment on lines 71 to 83
private func setupLayout() {
titleLabel.snp.makeConstraints {
$0.leading.top.bottom.equalToSuperview()
if count == 0 {
$0.trailing.equalToSuperview()
}
}

countLabel.snp.makeConstraints {
$0.leading.equalTo(titleLabel.snp.trailing).offset(BKSpacing.spacing1)
$0.trailing.top.bottom.equalToSuperview()
$0.trailing.equalToSuperview()
$0.centerY.equalTo(titleLabel)
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

count가 0에서 non-zero로 변경될 때 Auto Layout 충돌 가능성

setupLayout()은 초기화 시점에 count == 0이면 titleLabeltrailing.equalToSuperview() 제약을 추가합니다 (Line 75). 이후 count가 0이 아닌 값으로 변경되면, countLabel이 보이게 되지만 titleLabel의 trailing 제약은 여전히 superview에 고정되어 있어, countLabel의 leading/trailing 제약과 충돌합니다.

countLabel.isHidden만으로는 제약 충돌을 해결할 수 없습니다 — hidden된 뷰도 Auto Layout에 참여합니다.

🛠️ 제안: 제약 조건을 동적으로 관리
+    private var titleTrailingToSuperview: Constraint?
+
     private func setupLayout() {
         titleLabel.snp.makeConstraints {
             $0.leading.top.bottom.equalToSuperview()
-            if count == 0 {
-                $0.trailing.equalToSuperview()
-            }
+            titleTrailingToSuperview = $0.trailing.equalToSuperview().constraint
         }
+        titleTrailingToSuperview?.isActive = (count == 0)

         countLabel.snp.makeConstraints {

그리고 countdidSet에서:

     public var count: Int = 0 {
         didSet {
             countLabel.setText(text: "\(count)")
             countLabel.isHidden = (count == 0)
+            titleTrailingToSuperview?.isActive = (count == 0)
         }
     }
🤖 Prompt for AI Agents
In `@src/Projects/BKDesign/Sources/Components/Chip/BKChip.swift` around lines 71 -
83, The titleLabel trailing constraint to superview created in setupLayout()
causes conflicts when count changes from 0 to non-zero; modify setupLayout() to
store that trailing NSLayoutConstraint (or SnapKit Constraint) as a property
(e.g., titleTrailingToSuperviewConstraint) and do not permanently activate it,
then in the count's didSet toggle constraints: if count == 0 activate the
titleTrailingToSuperviewConstraint and deactivate the
titleLabel.trailing-to-countLabel constraint (or hide/disable countLabel
constraints), else deactivate titleTrailingToSuperviewConstraint and activate
the constraints that pin titleLabel to countLabel (or the existing countLabel
constraints); after toggling call setNeedsLayout()/layoutIfNeeded() or
updateConstraintsIfNeeded() to apply changes. Ensure you reference
setupLayout(), count's didSet, titleLabel, countLabel, and the saved
titleTrailingToSuperviewConstraint when making the change.

Comment on lines 121 to 141
public extension PrimaryEmotion {
/// Seed 이름에서 변환
static func from(seedName: String) -> Self? {
switch seedName.lowercased() {
case "warmth", "따뜻함": return .warmth
case "joy", "즐거움": return .joy
case "sad", "sadness", "슬픔": return .sadness
case "insight", "깨달음": return .insight
default: return nil
}
}

/// Seed 엔티티에서 변환
static func from(seed: Seed) -> Self? {
return from(seedName: seed.name)
}

/// 기본 4가지 감정 (other 제외)
static var displayCases: [PrimaryEmotion] {
return [.warmth, .joy, .sadness, .insight]
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Seed 데이터에서 "etc"나 "기타"/"other" 이름이 사용되는지 확인
echo "=== Seed usage with etc/기타/other ==="
rg -n '"etc"|"기타"|"other"' --type swift -C3

echo ""
echo "=== PrimaryEmotion.from(seed) 호출처 확인 ==="
rg -n 'PrimaryEmotion\.from\(seed' --type swift -C3

Repository: YAPP-Github/Reed-iOS

Length of output: 2768


🏁 Script executed:

# EmotionSeed와 PrimaryEmotion의 from(seedName:) 구현 확인
echo "=== EmotionSeed.from(seedName:) 구현 확인 ==="
rg -n 'extension.*EmotionSeed|Seed.*extension' --type swift -A30 | head -80

echo ""
echo "=== PrimaryEmotion 정의 및 cases 확인 ==="
rg -n 'enum PrimaryEmotion' --type swift -A15

Repository: YAPP-Github/Reed-iOS

Length of output: 1562


🏁 Script executed:

# PrimaryEmotion.displayCases 사용처 확인
echo "=== PrimaryEmotion.displayCases 사용처 ==="
rg -n 'displayCases' --type swift -C3

echo ""
echo "=== Seed entity 구조 확인 ==="
fd '.*Seed.*\.swift' | head -20 | xargs rg -l 'class Seed|struct Seed'

Repository: YAPP-Github/Reed-iOS

Length of output: 1199


🏁 Script executed:

# Seed entity 정의와 name 값 확인
echo "=== Seed entity 정의 ==="
cat src/Projects/BKDomain/Sources/Entity/Seed.swift

echo ""
echo "=== PrimaryEmotion.from 모든 호출처 ==="
rg -n '\.from\(' src/Projects/BKPresentation/Sources/Common/Extension/PrimaryEmotion+UI.swift -C2

echo ""
echo "=== 다른 곳에서 PrimaryEmotion 변환 로직 ==="
rg -n 'PrimaryEmotion' src/Projects/BKPresentation --type swift | grep -E 'from|map|convert' | head -20

Repository: YAPP-Github/Reed-iOS

Length of output: 453


🏁 Script executed:

# Seed -> PrimaryEmotion 변환이 실제로 일어나는 곳
echo "=== Seed to PrimaryEmotion conversion ==="
rg -n 'EmotionSeed|PrimaryEmotion' src/Projects/BKPresentation/Sources/MainFlow/BookDetail/Models/EmotionSeed.swift -C5

echo ""
echo "=== PrimaryEmotion.from(seed:) 메서드 사용 확인 ==="
rg -n 'from.*seed|from.*Seed' --type swift -C3 | grep -A3 -B3 'PrimaryEmotion'

Repository: YAPP-Github/Reed-iOS

Length of output: 2648


from(seedName:) 메서드에서 "other"/"기타" 케이스 누락.

PrimaryEmotion 열거형에 .other 케이스(displayName: "기타")가 존재하지만, from(seedName:) 메서드에서는 처리하지 않아 "기타" 이름의 Seed가 전달될 경우 nil을 반환합니다. from(seed:) 메서드가 from(seedName: seed.name)을 호출하므로, API에서 name 값이 "기타"인 Seed가 오면 변환에 실패합니다. EmotionSeed.from(seedName:)에서 "etc", "기타".etc 매핑을 제공하는 것과 일관성 있게, 다음 케이스를 추가하세요:

case "other", "기타": return .other
🤖 Prompt for AI Agents
In `@src/Projects/BKPresentation/Sources/Common/Extension/PrimaryEmotion`+UI.swift
around lines 121 - 141, The from(seedName:) method in PrimaryEmotion omits
handling the "other"/"기타" seed name so Seeds named "기타" return nil; update
PrimaryEmotion.from(seedName:) to include a case for "other" and "기타" that
returns .other (so PrimaryEmotion.from(seedName:) and by extension from(seed:)
behave consistently with EmotionSeed.from(seedName:)). Ensure the switch in
from(seedName:) adds: case "other", "기타": return .other.

Comment on lines +370 to 399
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(emotionRowTapped(_:)))
rowView.addGestureRecognizer(tapGesture)
rowView.tag = emotion.hashValue

switch emotion {
case .warmth:
imageView.image = BKImage.Graphics.warmEmotion
case .joy:
imageView.image = BKImage.Graphics.joyEmotion
case .sad:
imageView.image = BKImage.Graphics.sadEmotion
case .insight:
imageView.image = BKImage.Graphics.insightEmotion
default:
imageView.image = BKImage.Graphics.warmEmotion
}
// Set up chip removal callback
rowView.onDetailEmotionRemoved = { [weak self] detailEmotion in
self?.removeDetailEmotion(detailEmotion)
}

imageView.layer.cornerRadius = 12
imageView.clipsToBounds = true
imageView.contentMode = .scaleAspectFit
emotionRows[emotion] = rowView
emotionListStack.addArrangedSubview(rowView)
}
}

wrapperView.addSubview(imageView)
imageView.snp.makeConstraints {
$0.edges.equalToSuperview()
$0.height.equalTo(imageView.snp.width).multipliedBy(1.325)
private func removeDetailEmotion(_ detailEmotion: DetailEmotion) {
selectedDetailEmotions.removeAll { $0 == detailEmotion }
if let selectedPrimaryEmotion, let rowView = emotionRows[selectedPrimaryEmotion] {
rowView.selectedDetailEmotions = selectedDetailEmotions
}
inputChangedSubject.send(())
}

wrapperView.isUserInteractionEnabled = true
let gesture = UITapGestureRecognizer(target: self, action: #selector(emotionTapped(_:)))
wrapperView.addGestureRecognizer(gesture)
wrapperView.tag = emotion.hashValue
@objc private func emotionRowTapped(_ sender: UITapGestureRecognizer) {
// 로딩 중에는 탭 무시
guard !isLoadingEmotions else { return }

return wrapperView
}

@objc func emotionTapped(_ sender: UITapGestureRecognizer) {
guard let tappedView = sender.view,
let emotion = Emotion.allCases.first(where: { $0.hashValue == tappedView.tag }) else {
let emotion = PrimaryEmotion.allCases.first(where: { $0.hashValue == tappedView.tag }) else {
return
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

hashValue를 뷰 tag로 사용하는 것은 안정적이지 않습니다.

hashValue는 프로그램 실행 간 값이 달라질 수 있고, 이론적으로 해시 충돌이 발생할 수 있습니다. 5개 케이스에서 실제 충돌 가능성은 매우 낮지만, 올바른 식별 방법이 아닙니다.

🐛 안정적인 식별 방식으로 변경 제안

PrimaryEmotionCaseIterable이라면 index를 사용하거나, rawValueString인 경우 별도 매핑을 사용할 수 있습니다:

-            rowView.tag = emotion.hashValue
+            if let index = PrimaryEmotion.allCases.firstIndex(of: emotion) {
+                rowView.tag = index
+            }
 
             // ...
 
-              let emotion = PrimaryEmotion.allCases.first(where: { $0.hashValue == tappedView.tag }) else {
+              let emotion = PrimaryEmotion.allCases[safe: tappedView.tag] else {

또는 tag 대신 emotionRows 딕셔너리에서 직접 조회하는 방식이 더 안전합니다:

     `@objc` private func emotionRowTapped(_ sender: UITapGestureRecognizer) {
         guard !isLoadingEmotions else { return }
-        guard let tappedView = sender.view,
-              let emotion = PrimaryEmotion.allCases.first(where: { $0.hashValue == tappedView.tag }) else {
+        guard let tappedView = sender.view,
+              let emotion = emotionRows.first(where: { $0.value === tappedView })?.key else {
             return
         }
🤖 Prompt for AI Agents
In
`@src/Projects/BKPresentation/Sources/MainFlow/Note/View/EmotionRegistrationView.swift`
around lines 370 - 399, The code uses PrimaryEmotion.hashValue in rowView.tag
and then finds the emotion by matching tags in emotionRowTapped, which is
fragile; instead assign a stable identifier (e.g. the PrimaryEmotion rawValue or
its index) to the view tag or better yet avoid tag lookups entirely and locate
the tapped emotion via the emotionRows dictionary or by storing the
PrimaryEmotion on the view (e.g. a property on the row view). Update the places
referencing hashValue (where rowView.tag is set and in emotionRowTapped where
PrimaryEmotion.allCases.first(where:) is used) to use the chosen stable
identifier or direct mapping so tapping reliably resolves the correct
PrimaryEmotion.

Comment on lines +89 to +95
case .fetchDetailEmotions(let primaryEmotion):
newState.isLoadingEmotions = true
effects.append(.fetchDetailEmotions(primaryEmotion))

case .fetchDetailEmotionsSuccessed(let detailEmotions):
newState.isLoadingEmotions = false
newState.detailEmotions = detailEmotions
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

fetchDetailEmotions 실패 시 isLoadingEmotionsfalse로 리셋되지 않습니다.

fetchDetailEmotions 실패 → errorOccured 액션이 실행되지만, errorOccured 핸들러(Line 72-79)는 isLoadingfalse로 설정하고 isLoadingEmotions는 건드리지 않습니다. 결과적으로 감정 로딩 실패 시 로딩 상태가 영구적으로 유지됩니다.

또한, handle(.fetchDetailEmotions) (Line 115-119)에서 lastEffect를 저장하지 않기 때문에, 에러 발생 시 isRetrying 플로우의 재시도 버튼이 실제로 감정 fetch를 다시 실행하지 못합니다. 감정 fetch 에러는 별도로 처리하거나, errorOccured에서 isLoadingEmotions를 리셋해야 합니다.

🐛 제안: errorOccured에서 isLoadingEmotions 리셋 추가
         case .errorOccured(let error):
             newState.isLoading = false
+            newState.isLoadingEmotions = false
             if newState.isRetrying == false {
                 newState.isRetrying = true
             } else {

감정 fetch에 대한 별도 에러 처리가 필요하다면, 전용 에러 액션 도입도 고려해보세요.

🤖 Prompt for AI Agents
In
`@src/Projects/BKPresentation/Sources/MainFlow/Note/ViewModel/NoteViewModel.swift`
around lines 89 - 95, The failure path for fetchDetailEmotions leaves
isLoadingEmotions true and retries don't replay the emotion effect; update the
errorOccured handler to also set newState.isLoadingEmotions = false whenever an
error occurs and/or add specific error handling for emotion failures, and modify
the handle(.fetchDetailEmotions(primaryEmotion)) logic to record the lastEffect
(or store the specific fetchDetailEmotions effect) so the isRetrying flow can
re-dispatch the exact effect when retry is triggered (adjust usage of
lastEffect/isRetrying to include the fetchDetailEmotions effect).

Comment on lines +115 to +119
case .fetchDetailEmotions(let primaryEmotion):
return fetchDetailEmotionsUseCase.execute(for: primaryEmotion)
.map { Action.fetchDetailEmotionsSuccessed($0) }
.catch { 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.

⚠️ Potential issue | 🟠 Major

감정 fetch 실패 시 lastEffect 미저장으로 재시도 불가.

submit 핸들러(Line 109-110)와 달리 fetchDetailEmotions 핸들러에서는 self?.lastEffect를 저장하지 않습니다. 에러가 errorOccured를 통해 재시도 알림을 트리거하더라도, retryTappedlastEffect가 감정 fetch가 아니라 이전 submit effect이거나 nil이 됩니다.

사용자가 감정을 다시 탭하면 되므로 의도적일 수 있지만, 그 경우 errorOccured 대신 감정 전용 에러 처리를 사용하여 재시도 다이얼로그가 표시되지 않도록 하는 것이 좋습니다.

🤖 Prompt for AI Agents
In
`@src/Projects/BKPresentation/Sources/MainFlow/Note/ViewModel/NoteViewModel.swift`
around lines 115 - 119, 핵심 문제: fetchDetailEmotions 핸들러가 실패 시 self?.lastEffect를
갱신하지 않아 retryTapped로 재시도 불가능하거나 잘못된 이펙트가 재시도된다; 수정법은 fetchDetailEmotions 케이스
처리부에서 실행 전에 self?.lastEffect에 해당 이펙트(예: .fetchDetailEmotions(primaryEmotion))를
저장하거나, 대신 에러를 일반 재시도 다이얼로그를 트리거하는 Action.errorOccured로 보내지 말고 감정 전용 실패
액션(Action.fetchDetailEmotionsFailed 등)을 반환하도록 변경하여 재시도 흐름과 사용자 다이얼로그 동작을 분리하는
것(함수명/심볼: fetchDetailEmotionsUseCase.execute,
Action.fetchDetailEmotionsSuccessed, Action.errorOccured, retryTapped,
lastEffect).

Comment on lines +267 to +299
override func layoutSubviews() {
super.layoutSubviews()
layoutTags()
}

private func layoutTags() {
var x: CGFloat = 0
var y: CGFloat = 0
var lineHeight: CGFloat = 0

for label in tagLabels {
label.sizeToFit()
let labelWidth = label.bounds.width
let labelHeight = label.bounds.height

// 현재 줄에 맞지 않으면 다음 줄로
if x + labelWidth > bounds.width && x > 0 {
x = 0
y += lineHeight + verticalSpacing
lineHeight = 0
}

label.frame = CGRect(x: x, y: y, width: labelWidth, height: labelHeight)
x += labelWidth + horizontalSpacing
lineHeight = max(lineHeight, labelHeight)
}

// 높이가 변경되면 intrinsicContentSize 업데이트
let newHeight = y + lineHeight
if abs(newHeight - bounds.height) > 1 {
invalidateIntrinsicContentSize()
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

layoutSubviews 내에서 invalidateIntrinsicContentSize 호출은 재귀적 레이아웃 사이클을 유발할 수 있습니다.

layoutSubviews()layoutTags()invalidateIntrinsicContentSize()setNeedsLayout()layoutSubviews() 순환이 발생할 수 있습니다. abs(newHeight - bounds.height) > 1 가드가 있지만, 부동소수점 반올림으로 인해 1px 이상 차이가 반복적으로 발생하면 레이아웃 루프에 빠질 수 있습니다.

🛡️ 안전한 패턴으로 변경 제안
     private func layoutTags() {
+        let previousHeight = _calculatedHeight
         var x: CGFloat = 0
         var y: CGFloat = 0
         var lineHeight: CGFloat = 0
 
         for label in tagLabels {
             // ... existing layout code ...
         }
 
-        // 높이가 변경되면 intrinsicContentSize 업데이트
-        let newHeight = y + lineHeight
-        if abs(newHeight - bounds.height) > 1 {
-            invalidateIntrinsicContentSize()
-        }
+        _calculatedHeight = y + lineHeight
+        if abs(_calculatedHeight - previousHeight) > 0.5 {
+            invalidateIntrinsicContentSize()
+        }
     }
+
+    private var _calculatedHeight: CGFloat = 0

이전 높이를 별도로 추적하면 bounds.height와의 비교 대신 실제 계산 결과의 변화만 감지하여 불필요한 사이클을 방지할 수 있습니다.

🤖 Prompt for AI Agents
In
`@src/Projects/BKPresentation/Sources/MainFlow/NoteCompletion/View/AppreciationResultView.swift`
around lines 267 - 299, Track the previously computed intrinsic height and only
call invalidateIntrinsicContentSize when the computed tag height actually
changes beyond a small threshold: add a private stored property (e.g.,
previousIntrinsicHeight: CGFloat = 0), compute newHeight in layoutTags(),
compare newHeight to previousIntrinsicHeight (use a small epsilon like 0.5), and
if different update previousIntrinsicHeight and call
invalidateIntrinsicContentSize(); keep calls confined to
layoutTags()/layoutSubviews but remove the direct comparison against
bounds.height to avoid the recursive layout loop. Ensure you reference
layoutSubviews, layoutTags, invalidateIntrinsicContentSize, and the new
previousIntrinsicHeight symbol when making the change.

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.

수고하셨습니다 👍 (508 리뷰에 통합)

@clxxrlove clxxrlove force-pushed the BOOK-507-feature/#271 branch from 0712714 to 2d863c9 Compare February 15, 2026 04:36
@clxxrlove clxxrlove merged commit ccb185e into develop Feb 15, 2026
0 of 2 checks passed
@clxxrlove clxxrlove deleted the BOOK-507-feature/#271 branch February 15, 2026 04:37
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-507/feat] 도서 기록 플로우 UI 개선작업

2 participants

Comments