Skip to content

[feat] #70 포즈 API 연동 및 스크랩 기능 구현#74

Merged
Ojongseok merged 23 commits into
developfrom
feat/#70-pose-api
Feb 2, 2026
Merged

[feat] #70 포즈 API 연동 및 스크랩 기능 구현#74
Ojongseok merged 23 commits into
developfrom
feat/#70-pose-api

Conversation

@ikseong00
Copy link
Copy Markdown
Contributor

@ikseong00 ikseong00 commented Jan 29, 2026

🔗 관련 이슈

📙 작업 설명

  • 포즈 목록/상세/랜덤 조회 API 연동
  • 포즈 목록 Paging 적용
  • 포즈 스크랩 API 구현 (PATCH /api/poses/{poseId}/scrap)
  • 포즈 상세화면 스크랩 토글 구현 (debounce, applicationScope, onCleared 패턴)
  • 랜덤 포즈 스크랩 토글 구현 (포즈별 독립적인 debounce Job)
  • 랜덤 포즈 좌우 탭 영역 클릭으로 포즈 이동 구현
  • 랜덤 포즈 다음 포즈 미리 캐싱 로직 개선
  • 포즈 상세화면 진입 시 Pose 객체 대신 ID를 전달하도록 변경
  • BottomSheet이 중간에 걸치지 않도록 수정
  • Toast 처리 연동

📸 스크린샷 또는 시연 영상

기능 미리보기
포즈 스크랩
default.mp4
랜덤 포즈
default.mp4

💬 추가 설명 or 리뷰 포인트

  • 스크랩 토글은 PhotoDetailViewModel의 즐겨찾기와 동일한 패턴으로 구현했습니다.
    • MutableSharedFlow + debounce(500ms)로 빠른 연속 클릭 시 API 중복 호출 방지
    • ApplicationScope를 사용하여 화면 이탈 시에도 마지막 상태 서버 동기화
    • onCleared에서 커밋되지 않은 스크랩 상태 최종 반영
  • 랜덤 포즈의 경우 여러 포즈가 존재하므로, Map<Long, Job>으로 포즈별 독립적인 debounce를 적용하여 서로 다른 포즈의 API 호출이 간섭하지 않도록 구현했습니다.
  • 랜덤 포즈 로딩 시, 빠른 이미지 로딩을 위해 다음 이미지를 미리 가져오고 Coil에 캐싱하는 형식으로 구현했습니다.
  • 랜덤 포즈 인원수 쿼리, 스크랩 포즈 조회 API는 아직 나오지 않아 미구현 상태입니다. 추후 반영하겠습니다.

Summary by CodeRabbit

  • 새로운 기능

    • 서버 기반 Compose 페이징으로 포즈 목록 로드 적용
    • 포즈 조회(단건/랜덤) 및 스크랩 업데이트(커밋/롤백) 추가
    • 인원수(헤드카운트) 필터링 지원
  • UX / 네비게이션

    • 상세 화면 네비게이션이 포즈 ID 기반으로 변경
    • 랜덤 포즈: 좌/우 스와이프로 탐색, 이미지 요청 및 토스트 효과 추가
  • UI

    • 하단 시트 초기 확장 동작이 부분 확장 허용으로 변경
  • 잡무

    • 페이징 라이브러리 및 Compose 페이징 의존성 추가

`rememberModalBottomSheetState`에 `skipPartiallyExpanded = true` 속성을 적용하여 `PeopleCountBottomSheet`과 `DoubleButtonOptionBottomSheet`이 완전히 확장된 상태로만 표시되도록 수정합니다.
- PosePagingSource 생성
- PoseRepository에 getPosesFlow 추가
- PoseViewModel에 flatMapLatest 패턴으로 headCount 필터 적용
- PoseScreen/PoseListContent에 LazyPagingItems 적용
- paging 라이브러리 의존성 추가
포즈 목록 아이템과 포즈 상세 조회 시 사용되는 데이터 모델이 동일하므로, `PoseDetailResponse`를 `PoseItemResponse`로 이름을 변경하여 재사용합니다. 이에 따라 `PoseResponse` 내부에 있던 `Item` 클래스를 제거하고, `PoseService`의 관련 API 반환 타입을 수정합니다.
`PagingConfig`에서 사용되는 상수들의 위치를 `companion object`에서 파일 최상단으로 이동시킵니다.
또한, `PagingConfig`에 `initialLoadSize`와 `prefetchDistance` 설정을 추가하여 페이징 동작을 명확히 정의합니다.
`RandomPoseScreen`에서 사용하던 `RandomPoseFloatingBarContent`의 `horizontal` 패딩을 제거하고, `RandomPoseFloatingBar`의 내부 컨테이너로 이동시킵니다. 이를 통해 플로팅 바 배경의 `horizontal` 패딩을 제거하여 화면 전체 너비를 차지하도록 수정했습니다.
포즈 목록에서 포즈 상세 화면으로 이동할 때 `Pose` 객체 전체를 직렬화하여 전달하던 방식에서, `poseId`만 전달하도록 수정합니다.
이를 통해 상세 화면에서는 전달받은 `poseId`로 포즈 데이터를 직접 조회하여 화면을 구성합니다.

- `PoseNavKey.PoseDetail`의 인자를 `Pose`에서 `Long` (poseId)으로 변경
- `PoseDetailViewModel`에서 `poseId`를 받아 `poseRepository.getPose()`를 호출하여 데이터를 가져오도록 수정
- 화면 진입 시 데이터를 불러오는 `EnterPoseDetailScreen` 인텐트 추가
- 좌/우 클릭 시 이전/다음 포즈로 이동
- 다음 포즈 미리 로드하여 즉시 표시
- PoseDetail 네비게이션 poseId로 변경
- 스크랩 API 추가
- debounce, applicationScope, onCleared 패턴 적용
- 스크랩 토글 시 Optimistic UI 업데이트 및 실패 시 롤백
- 포즈별 독립적인 debounce Job으로 스크랩 API 호출
- applicationScope, onCleared 패턴 적용
- 좌우 탭 영역 클릭으로 포즈 이동 구현
- 다음 포즈 미리 캐싱 로직 개선
- Toast 처리 연동
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jan 29, 2026

Caution

Review failed

The pull request is closed.

Walkthrough

포즈 API(서비스·요청·응답 모델), 페이징(PagingSource/Pager) 및 PoseRepository 구현 추가, 뷰모델·화면·네비게이션 시그니처(pose → poseId) 변경, 랜덤/상세 화면의 스크랩 디바운스·커밋/롤백 및 UI 컴포넌트 기본 동작 조정이 추가되었습니다.

Changes

Cohort / File(s) Summary
빌드·버전 관리
gradle/libs.versions.toml, core/data-api/build.gradle.kts, feature/pose/impl/build.gradle.kts
Paging 버전(paging = 3.3.6) 및 라이브러리(paging-common, paging-compose) 추가, core/data-api에서 paging.runtime → paging.common로 변경, feature 모듈에 paging-compose 의존 추가.
공용 레포지토리 계약
core/data-api/src/main/java/.../repository/PoseRepository.kt
페이징 Flow, 단건/랜덤 조회, 스크랩 업데이트를 노출하는 PoseRepository 인터페이스 추가.
원격 API 및 DTO
core/data/src/main/java/.../remote/api/PoseService.kt, .../remote/model/request/UpdateScrapRequest.kt, .../remote/model/response/PoseResponse.kt, .../remote/model/response/PoseDetailResponse.kt
Ktor 기반 PoseService 추가 및 요청/응답 DTO(UpdateScrapRequest, PoseResponse, PoseDetailResponse)와 도메인 변환(toModel) 구현.
데이터 레이어·페이징·DI
core/data/src/main/java/.../paging/PosePagingSource.kt, .../repository/impl/PoseRepositoryImpl.kt, .../repository/di/RepositoryModule.kt
PosePagingSourcePoseRepositoryImpl 구현, Pager 구성(PAGE_SIZE=20, PREFETCH_DISTANCE=10)과 DI 바인딩 등록.
모델·네비게이션 계약 변경
core/model/src/main/java/.../PoseContract.kt, feature/pose/api/src/main/java/.../PoseNavKey.kt
네비게이션 페이로드·Effect 타입을 PoseposeId: Long로 변경; 관련 시그니처·호출부 수정.
포즈 목록 화면 페이징 통합
feature/pose/impl/src/main/java/.../main/PoseViewModel.kt, .../main/PoseScreen.kt, .../main/component/PoseListContent.kt, .../main/component/PeopleCountBottomSheet.kt
ViewModel에 PoseRepository 기반 Flow<PagingData> 도입, UI를 LazyPagingItems<Pose>로 전환, headCount 필터 추가 및 관련 시그니처 변경.
포즈 상세 화면 리팩토링
feature/pose/impl/src/main/java/.../detail/PoseDetailContract.kt, .../detail/PoseDetailViewModel.kt, .../navigation/PoseEntryProvider.kt
PoseDetailViewModel 생성자가 poseId: Long을 받도록 변경, 스크랩 디바운스·커밋/롤백 로직 및 onCleared 시 영속화 추가.
랜덤 포즈 기능 개선
feature/pose/impl/src/main/java/.../random/RandomPoseContract.kt, .../random/RandomPoseScreen.kt, .../random/RandomPoseViewModel.kt, .../random/component/RandomPoseFloatingBar.kt
per-pose scrap cancellable job, 좌/우 스와이프, 동적 다음 포즈 로드, 이미지 빌더·토스트 효과 추가, 네비게이션 및 시그니처(poseId) 변경.
UI 컴포넌트 기본 동작 조정
core/ui/src/main/java/.../DoubleButtonOptionBottomSheet.kt, feature/pose/impl/src/.../main/component/PeopleCountBottomSheet.kt
Modal bottom sheet의 기본 sheetState에 skipPartiallyExpanded = true 적용(초기 확장 동작 변경).

Sequence Diagram(s)

sequenceDiagram
    participant UI as UI Layer (PoseScreen / RandomPoseScreen / PoseDetailScreen)
    participant VM as ViewModel (PoseViewModel / RandomPoseViewModel / PoseDetailViewModel)
    participant Repo as Repository (PoseRepositoryImpl)
    participant API as PoseService

    UI->>VM: 초기 로드 / 필터 변경 / 스크랩 토글 / 상세 진입(poseId)
    VM->>Repo: getPosesFlow(headCount, sortOrder) / getPose(id) / getRandomPose() / updateScrap(id, scrap)
    Repo->>Repo: Pager 생성 → PosePagingSource.load(page)
    Repo->>API: HTTP 호출 (getPoses / getPose / getRandomPose / updateScrap)
    API-->>Repo: 응답 (PoseResponse / PoseDetailResponse / BasicNullableResponse)
    Repo->>VM: Flow<PagingData<Pose>> 또는 Result<Pose>/Result<Unit>
    VM-->>UI: LazyPagingItems 제공 / 상태 업데이트 / 네비게이션(poseId)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰
포즈 길 따라 작은 발자국,
아이디 하나로 숲을 건너요,
스크랩은 톡톡, 실패면 되돌려,
랜덤은 한 땀씩 이어지네,
당근으로 축하해요! 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 8.16% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 제목은 주요 변경사항인 포즈 API 연동 및 스크랩 기능 구현을 명확하게 설명하고 있습니다.
Linked Issues check ✅ Passed PR의 코드 변경사항이 Issue #70의 모든 요구사항을 충족합니다. 포즈 목록, 포즈 상세, 랜덤 포즈 API 연동이 완전히 구현되었습니다.
Out of Scope Changes check ✅ Passed 모든 변경사항이 Issue #70의 포즈 API 구현과 관련된 범위 내에 있습니다. 추가적인 범위 외 변경은 없습니다.

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

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

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.

@ikseong00 ikseong00 changed the title feat: 포즈 API 연동 및 스크랩 기능 구현 #70 [feat] #70 포즈 API 연동 및 스크랩 기능 구현 Jan 29, 2026
Copy link
Copy Markdown

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

🤖 Fix all issues with AI agents
In
`@core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt`:
- Around line 23-35: Remove the unused getPoses implementation from
PoseRepositoryImpl and its corresponding declaration in the PoseRepository
interface: delete the override suspend fun getPoses(...) in PoseRepositoryImpl
(the method calling poseService.getPoses(...).data.toModels()) and remove the
method signature from the PoseRepository interface; also search for and delete
any unused imports and update or remove any references (e.g., ensure
PosePagingSource continues to call poseService.getPoses() directly) so the
codebase builds without the dead method.
- Around line 66-68: The updateScrap implementation in PoseRepositoryImpl
directly returns poseService.updateScrap(poseId, scrap).data which is nullable
because BasicNullableResponse<Unit>.data is Unit?; modify updateScrap to safely
handle null by checking the response (from poseService.updateScrap) and mapping
a null data to Unit (or returning a failure) before returning from
runSuspendCatching—e.g., extract the response into a val (response =
poseService.updateScrap(...)), then if response.data == null either return Unit
or throw/return an appropriate error so the Result<Unit> is non-null and
null-safety is preserved.

In
`@feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseScreen.kt`:
- Around line 138-152: In RandomPoseScreen update the two clickable Box
modifiers (the left/right swipe targets using noRippleClickableSingle and
callbacks onLeftSwipe/onRightSwipe) to include accessibility semantics: add
Modifier.semantics with a contentDescription like "Previous" for the left Box
and "Next" for the right Box and set role = Role.Button so screen readers can
identify them as actionable controls; keep the existing click behavior and
imports for semantics and Role.
- Around line 45-48: The ImageLoader is being recreated on every recomposition;
update the RandomPoseScreen composable to memoize ImageLoader by using
remember(context) { ImageLoader(context) } (or ultimately inject a singleton via
Hilt) and likewise make NekiToast stable with remember(context) {
NekiToast(context) } so both are recreated only when the context changes,
reducing duplicate caches and clients.

In
`@feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt`:
- Around line 136-149: handleMoveNext currently conflates prefetching and index
advancement which can skip increment on the second-last item and allow overflow
on last/single items; update RandomPoseViewModel.handleMoveNext so it always
advances the index when not already at the last item (use
RandomPoseUiState.currentIndex and poseList.lastIndex to check bounds) and
separately trigger fetchNextPose (calling fetchNextPose(reduce, postSideEffect))
when currentIndex == poseList.lastIndex - 1; also guard so when currentIndex >=
poseList.lastIndex you do not increment (prevent out-of-range), and keep reduce
{ copy(currentIndex = currentIndex + 1) } strictly for advancing the index only.
- Around line 69-122: The scrapJobs map retains completed Jobs causing unbounded
growth; in handleScrapToggle where you assign scrapJobs[poseId] =
viewModelScope.launch { ... } ensure the entry is removed when the coroutine
finishes: either attach a completion handler (job.invokeOnCompletion {
scrapJobs.remove(poseId) }) after creating the Job or wrap the launch body with
try/finally and remove scrapJobs[poseId] in finally; reference scrapJobs,
handleScrapToggle, viewModelScope.launch and poseId to locate where to add the
cleanup so entries are removed on success, failure, or cancellation.
🧹 Nitpick comments (6)
feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/component/PoseListContent.kt (2)

44-54: 페이징 아이템 로딩 시 placeholder 고려

posePagingItems[index]가 null일 때 (로딩 중) 아무것도 렌더링되지 않아 스크롤 시 레이아웃이 불안정하거나 빈 공간이 발생할 수 있습니다. Shimmer나 placeholder를 표시하면 UX가 개선될 수 있습니다.

💡 Placeholder 추가 예시
         items(
             count = posePagingItems.itemCount,
             key = posePagingItems.itemKey { it.id },
         ) { index ->
-            posePagingItems[index]?.let { pose ->
+            val pose = posePagingItems[index]
+            if (pose != null) {
                 PoseItem(
                     pose = pose,
                     onClickItem = onClickItem,
                 )
+            } else {
+                PoseItemPlaceholder() // Shimmer 또는 skeleton UI
             }
         }

74-74: Preview 복원 가능성 고려

LazyPagingItems도 Preview에서 사용할 수 있습니다. flowOf(PagingData.from(sampleList)).collectAsLazyPagingItems()를 활용하면 됩니다. 개발 중 UI 확인이 필요하다면 복원을 고려해 보세요.

💡 Preview 복원 예시
`@ComponentPreview`
`@Composable`
private fun PoseListContentPreview() {
    val samplePoses = listOf(
        Pose(id = 1, poseImageUrl = "...", /* ... */),
        Pose(id = 2, poseImageUrl = "...", /* ... */),
    )
    val pagingItems = flowOf(PagingData.from(samplePoses))
        .collectAsLazyPagingItems()
    
    NekiTheme {
        PoseListContent(
            topPadding = 16.dp,
            posePagingItems = pagingItems,
        )
    }
}
core/data/src/main/java/com/neki/android/core/data/remote/model/response/PoseDetailResponse.kt (1)

17-22: headCount 매핑 실패 시 기본값 처리 검토 필요

PeopleCount.entries.find { it.name == headCount } 매핑이 실패할 경우 기본값 1을 사용하고 있습니다. 서버에서 예상치 못한 headCount 값이 내려올 경우 데이터 불일치가 발생할 수 있으며, 이를 감지하기 어렵습니다.

로깅을 추가하거나, 명시적으로 처리하는 것을 고려해 주세요.

💡 매핑 실패 시 로깅 추가 제안
     internal fun toModel() = Pose(
         id = poseId,
         isScrapped = scrap,
         poseImageUrl = imageUrl,
-        peopleCount = PeopleCount.entries.find { it.name == headCount }?.value ?: 1,
+        peopleCount = PeopleCount.entries.find { it.name == headCount }?.value ?: run {
+            Timber.w("Unknown headCount: $headCount, defaulting to 1")
+            1
+        },
     )
feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailViewModel.kt (3)

41-59: debounce collect 내에서 committedScrap 캡처 시점 확인 필요

Line 46에서 store.uiState.value.committedScrap을 collect 시점에 읽고 있습니다. debounce 후 collect될 때의 최신 상태를 읽으므로 대부분의 경우 정상 동작하지만, 빠른 연속 토글 시 예상치 못한 동작이 발생할 수 있습니다.

현재 구현은 debounce 특성상 마지막 값만 처리되므로 큰 문제는 없어 보이지만, 의도한 동작인지 확인해 주세요.


86-96: 데이터 로딩 실패 시 사용자 피드백 부재

fetchPoseData에서 실패 시 로깅만 수행하고 사용자에게 피드백을 제공하지 않습니다. 에러 상태를 UI에 반영하거나 Toast를 표시하는 것을 고려해 주세요.

💡 에러 상태 추가 제안
     private fun fetchPoseData(reduce: (PoseDetailState.() -> PoseDetailState) -> Unit) {
         viewModelScope.launch {
+            reduce { copy(isLoading = true) }
             poseRepository.getPose(poseId = id)
                 .onSuccess { data ->
-                    reduce { copy(pose = data, committedScrap = data.isScrapped) }
+                    reduce { copy(pose = data, committedScrap = data.isScrapped, isLoading = false) }
                 }
                 .onFailure { error ->
                     Timber.e(error)
+                    reduce { copy(isLoading = false, errorMessage = "포즈 정보를 불러오는데 실패했습니다.") }
                 }
         }
     }

98-109: onCleared에서 applicationScope 사용은 적절합니다.

화면 이탈 시 미커밋 상태를 동기화하기 위해 applicationScope를 사용하는 패턴이 올바르게 구현되어 있습니다. 다만, updateScrap 실패 시 에러 처리가 없으므로 실패 로깅을 추가하는 것이 디버깅에 도움이 될 수 있습니다.

💡 에러 로깅 추가 제안
     override fun onCleared() {
         super.onCleared()

         val currentScrap = store.uiState.value.pose.isScrapped
         val committedScrap = store.uiState.value.committedScrap

         if (currentScrap != committedScrap) {
             applicationScope.launch {
-                poseRepository.updateScrap(id, currentScrap)
+                poseRepository.updateScrap(id, currentScrap)
+                    .onFailure { error ->
+                        Timber.e(error, "Failed to sync scrap on cleared")
+                    }
             }
         }
     }

Copy link
Copy Markdown
Member

@Ojongseok Ojongseok left a comment

Choose a reason for hiding this comment

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

랜덤 포즈의 경우 여러 포즈가 존재하므로, Map<Long, Job>으로 포즈별 독립적인 debounce를 적용하여 서로 다른 포즈의 API 호출이 간섭하지 않도록 구현했습니다.

랜덤 포즈를 돌아다니더라도 스크랩 상태를 변경할 poseId에 대해서 이전 Job이 완료되지 않았을 경우를 고려한 부분도 잘 보았습니다!

고생 많으셨습니다!!

val poses = mutableListOf<Pose>()

// 초기에 3개 로드
repeat(3) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

초기에 3개를 로드하는 것은 임의로 3개를 정하신건가요? 임의로 정하신 것이라면 조금 더 여유롭게 로드해도 되지 않을까요?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

원격 URL 이미지를 리퀘스트로 사전 캐싱하는 작업이라 무거울 것 같다고 생각했고,
뒤의 PreLoading 기능에서, Next 이미지에 2개를 캐싱하도록 적용해서 최초 값을 3개로 설정했습니다!
2개로 괜찮을 것 같다고 생각했는데 조금 더 늘리는 게 좋을까요?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

말씀하신대로 캐싱하는 작업이 무거운 작업이라면 2개로 괜찮을 것 같다고 생각을 했는데

랜덤 포즈 특정 상 다음 포즈를 빠르게 넘길 것이라고 생각했고, 그 과정에서 이미지 로딩으로 인한 딜레이가 되지 않도록 다음 이미지를 미리 로드하는 형식으로 구현했습니다.

다른 코멘트에서 언급하신 것 처럼 다음 포즈를 빠르게 넘긴다면 오히려 좀 더 넉넉한 프리로딩이 필요하지 않을까 싶네요..!

val uiState by viewModel.store.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
val nekiToast = remember { NekiToast(context) }
val imageLoader = remember { ImageLoader(context) }
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

RequestImageBuilder SideEffect가 발생하면 해당 ImageLoader를 통해 다음 포즈의 url을 캐싱하는 것으로 이해했습니다.

관련하여 Coil의 캐싱 방식을 조금 조사해보니 작성해주신 방법은 Coil에서 기본적으로 지원하는 디스크에 캐시를 저장하는 방식으로 보입니다.

ImageLoader 객체를 컴포지션마다 val imageLoader = remember { ImageLoader(context) } 생성하지 않고, val imageLoader = LocalContext.current.imageLoader 이렇게 applicationContext에 있는 imageLoader는 싱글톤으로 정의되어 있어 랜덤포즈 화면을 이탈한 뒤 다시 들어와 컴포지션이 발생하더라도 이전에 저장된 이미지 캐시를 그대로 사용할 수 있다고 하더라구요.

이러한 방법을 메모리 캐싱이라 하고 디스크 캐싱보다 성능 면에서 좋다고 합니다. 참고하셔 메모리 캐싱을 지원하도록 변경하는 것도 고려해보면 좋을 것 같습니다!

다만, 이미지 캐싱을 적용해보는 것은 큰 의미가 있지만 이번에 적용하신 랜덤 포즈라는 기능의 특성 상 랜덤하게 조회되는 이미지에 대해서 랜덤 포즈 데이터가 아주 많아지게 되면 캐싱해둔 이미지를 다시 로드할 수 있을까? 라는 생각이 드네요 ㅎㅎ.. 그래도 캐싱을 지원하는 것 자체는 아주 좋은 것 같습니다!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

설명 감사드립니다.
캐싱의 의미가 있지만 정확히는 PreLoad 의 목적으로 구현했습니다.
랜덤 포즈 특정 상 다음 포즈를 빠르게 넘길 것이라고 생각했고, 그 과정에서 이미지 로딩으로 인한 딜레이가 되지 않도록 다음 이미지를 미리 로드하는 형식으로 구현했습니다.

이미지 로드 관련해서 문서랑 참고자료 확인해서 더 나은 정책으로 적용하는 방향으로 진행해야할 것 같습니다.
관련하여 숙지 후 이슈업해서 이미지 로드 관련해서 성능 향상하는 방향으로 진행해도 괜찮을까요??

랜덤 포즈 데이터가 아주 많아지게 되면 캐싱해둔 이미지를 다시 로드할 수 있을까?
이 부분은 랜덤 포즈이긴 하지만, 포즈 조회 화면에서도 쓰이는 이미지들은 사용하는 것이라서 캐싱을 밀지 않고 그대로 사용해야할 것 같습니다!

+) 리뷰를 보면서 Pager로 미리 인접한 이미지를 로드하면 되지 않을까?? 라고 생각이 드네요..!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

이 부분은 랜덤 포즈이긴 하지만, 포즈 조회 화면에서도 쓰이는 이미지들은 사용하는 것이라서 캐싱을 밀지 않고 그대로 사용해야할 것 같습니다!

아하 캐싱된 이미지 공통으로 사용한다면 더욱이나 싱글톤으로 정의된 메모리 캐싱 방식을 이용하면 좋을 것 같습니다.

이슈업을 해두고 이후에 조금 더 숙지한채로 다시 의논해보면 좋을 것 같네요!

Comment on lines +204 to +216
override fun onCleared() {
super.onCleared()

val state = store.uiState.value
state.poseList.forEach { pose ->
val currentScrap = pose.isScrapped
val committedScrap = state.committedScraps[pose.id]

if (committedScrap != null && currentScrap != committedScrap) {
applicationScope.launch {
poseRepository.updateScrap(pose.id, currentScrap)
}
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

이전 PR에서도 의논을 했지만applicationScope로 정말 ViewModel이 제거된 뒤 "API 요청을 보장할 수 있는지" 의문이 남는 것 같습니다.

물론 아주 극단적이지만 스크랩 버튼을 누른 뒤 0.5초 이내 ViewModel이 제거된 후 즉시 앱의 프로세스가 종료되어 applicationScope가 종료된다거나 네트워크 상태에 따라 API 요청이 지연되는 경우 보장이 어렵지 않을까요..?

어플리케이션 생명주기와 관계 없이 정말 API 호출을 보장하려 한다면 WorkManager를 사용해야 하나? 생각이 드네요.

고려하지 않아도 될 정도로 극히 드물겠지만 익성님 의견이 궁금합니다.

랜덤 포즈를 조회하는 `getRandomPose` API 호출 시 `headCount` 파라미터를 추가하여, 선택된 인원 수에 맞는 포즈만 가져오도록 수정합니다.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In
`@feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseScreen.kt`:
- Around line 133-136: In RandomPoseScreen the external modifier param is being
reused on both the parent Box and the AsyncImage causing duplicate layout
modifiers (e.g., weight, fillMaxWidth) and layout bugs; update the AsyncImage
usage so it does NOT reuse the incoming modifier but instead starts with
Modifier.matchParentSize().chain only image-specific modifiers (e.g.,
clip(RoundedCornerShape(20.dp)), contentScale) and keep the incoming modifier
applied solely to the Box to avoid conflicting layout behavior.

In
`@feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt`:
- Around line 94-95: The early-return condition is inverted: in
RandomPoseViewModel where you read committedScrap and compare it to
newScrapStatus inside the coroutine/launch, change the check so you return only
when they are equal (no-op) and proceed to update when they differ; i.e.,
replace the current `if (committedScrap != newScrapStatus) return@launch` with a
check that returns when `committedScrap == newScrapStatus` so the server update
runs when values differ.
🧹 Nitpick comments (1)
feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt (1)

179-186: 초기 포즈 로딩을 병렬로 처리하면 성능이 개선됩니다.

현재 repeat(3)으로 순차적으로 3번의 API 호출을 수행하고 있습니다. async를 사용하여 병렬로 처리하면 초기 로딩 시간을 단축할 수 있습니다.

⚡ 병렬 처리 제안
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll

 private fun fetchInitialPoses(
     reduce: (RandomPoseUiState.() -> RandomPoseUiState) -> Unit,
     postSideEffect: (RandomPoseEffect) -> Unit,
 ) {
     viewModelScope.launch {
         reduce { copy(isLoading = true) }

-        val poses = mutableListOf<Pose>()
-
-        // 초기에 3개 로드
-        repeat(3) {
-            poseRepository.getRandomPose(headCount = peopleCount)
-                .onSuccess { pose -> poses.add(pose) }
-                .onFailure { error -> Timber.e(error) }
-        }
+        val poses = (1..3).map {
+            async {
+                poseRepository.getRandomPose(headCount = peopleCount)
+                    .getOrNull()
+            }
+        }.awaitAll().filterNotNull()

         if (poses.isNotEmpty()) {

스크랩 API 호출에 실패했을 때, 롤백하는 `isScrapped` 상태 값을 `committedScrap` (API 호출 전의 원래 상태)에서 `committedScrap` (의도했던 상태)의 nullable 타입 `Boolean?`으로 변경합니다. 이는 스크랩 상태를 옵티미스틱 업데이트 이전 값으로 정확히 되돌리기 위함입니다.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In
`@feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt`:
- Around line 106-116: The code uses a nullable committedScrap when updating
each pose's isScrapped (inside reduce -> copy -> poseList.map), which can cause
type mismatch or unintended state; update the mapping to handle nulls by
applying a safe fallback (e.g., use committedScrap ?: pose.isScrapped to
preserve current value or committedScrap ?: false if you prefer default false)
so pose.copy(isScrapped = ...) always receives a non-null Boolean; locate the
mapping in RandomPoseViewModel where poseList.map and pose.copy(isScrapped =
committedScrap) are used and replace the direct nullable assignment with the
null-coalescing expression.
🧹 Nitpick comments (1)
feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt (1)

176-184: 초기 포즈 로딩을 병렬 처리하면 성능을 개선할 수 있습니다.

현재 repeat(3)으로 순차적으로 API를 호출하고 있어 전체 로딩 시간이 3배가 됩니다. async/awaitAll을 사용하면 병렬로 요청할 수 있습니다.

⚡ 병렬 로딩 제안
-            val poses = mutableListOf<Pose>()
-
-            // 초기에 3개 로드
-            repeat(3) {
-                poseRepository.getRandomPose(headCount = peopleCount)
-                    .onSuccess { pose -> poses.add(pose) }
-                    .onFailure { error -> Timber.e(error) }
-            }
+            val poses = (1..3).map {
+                kotlinx.coroutines.async {
+                    poseRepository.getRandomPose(headCount = peopleCount)
+                }
+            }.awaitAll()
+                .mapNotNull { result ->
+                    result.getOrNull().also { 
+                        if (it == null) Timber.e(result.exceptionOrNull()) 
+                    }
+                }

@Ojongseok Ojongseok merged commit 8340ee9 into develop Feb 2, 2026
0 of 2 checks passed
@ikseong00 ikseong00 deleted the feat/#70-pose-api branch February 12, 2026 14:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feat] 포즈 API 구현

2 participants