[feat] #70 포즈 API 연동 및 스크랩 기능 구현#74
Conversation
`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 처리 연동
|
Caution Review failedThe pull request is closed. Walkthrough포즈 API(서비스·요청·응답 모델), 페이징(PagingSource/Pager) 및 PoseRepository 구현 추가, 뷰모델·화면·네비게이션 시그니처(pose → poseId) 변경, 랜덤/상세 화면의 스크랩 디바운스·커밋/롤백 및 UI 컴포넌트 기본 동작 조정이 추가되었습니다. Changes
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)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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") + } } } }
Ojongseok
left a comment
There was a problem hiding this comment.
랜덤 포즈의 경우 여러 포즈가 존재하므로, Map<Long, Job>으로 포즈별 독립적인 debounce를 적용하여 서로 다른 포즈의 API 호출이 간섭하지 않도록 구현했습니다.
랜덤 포즈를 돌아다니더라도 스크랩 상태를 변경할 poseId에 대해서 이전 Job이 완료되지 않았을 경우를 고려한 부분도 잘 보았습니다!
고생 많으셨습니다!!
| val poses = mutableListOf<Pose>() | ||
|
|
||
| // 초기에 3개 로드 | ||
| repeat(3) { |
There was a problem hiding this comment.
초기에 3개를 로드하는 것은 임의로 3개를 정하신건가요? 임의로 정하신 것이라면 조금 더 여유롭게 로드해도 되지 않을까요?
There was a problem hiding this comment.
원격 URL 이미지를 리퀘스트로 사전 캐싱하는 작업이라 무거울 것 같다고 생각했고,
뒤의 PreLoading 기능에서, Next 이미지에 2개를 캐싱하도록 적용해서 최초 값을 3개로 설정했습니다!
2개로 괜찮을 것 같다고 생각했는데 조금 더 늘리는 게 좋을까요?
There was a problem hiding this comment.
말씀하신대로 캐싱하는 작업이 무거운 작업이라면 2개로 괜찮을 것 같다고 생각을 했는데
랜덤 포즈 특정 상 다음 포즈를 빠르게 넘길 것이라고 생각했고, 그 과정에서 이미지 로딩으로 인한 딜레이가 되지 않도록 다음 이미지를 미리 로드하는 형식으로 구현했습니다.
다른 코멘트에서 언급하신 것 처럼 다음 포즈를 빠르게 넘긴다면 오히려 좀 더 넉넉한 프리로딩이 필요하지 않을까 싶네요..!
| val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() | ||
| val context = LocalContext.current | ||
| val nekiToast = remember { NekiToast(context) } | ||
| val imageLoader = remember { ImageLoader(context) } |
There was a problem hiding this comment.
RequestImageBuilder SideEffect가 발생하면 해당 ImageLoader를 통해 다음 포즈의 url을 캐싱하는 것으로 이해했습니다.
관련하여 Coil의 캐싱 방식을 조금 조사해보니 작성해주신 방법은 Coil에서 기본적으로 지원하는 디스크에 캐시를 저장하는 방식으로 보입니다.
ImageLoader 객체를 컴포지션마다 val imageLoader = remember { ImageLoader(context) } 생성하지 않고, val imageLoader = LocalContext.current.imageLoader 이렇게 applicationContext에 있는 imageLoader는 싱글톤으로 정의되어 있어 랜덤포즈 화면을 이탈한 뒤 다시 들어와 컴포지션이 발생하더라도 이전에 저장된 이미지 캐시를 그대로 사용할 수 있다고 하더라구요.
이러한 방법을 메모리 캐싱이라 하고 디스크 캐싱보다 성능 면에서 좋다고 합니다. 참고하셔 메모리 캐싱을 지원하도록 변경하는 것도 고려해보면 좋을 것 같습니다!
다만, 이미지 캐싱을 적용해보는 것은 큰 의미가 있지만 이번에 적용하신 랜덤 포즈라는 기능의 특성 상 랜덤하게 조회되는 이미지에 대해서 랜덤 포즈 데이터가 아주 많아지게 되면 캐싱해둔 이미지를 다시 로드할 수 있을까? 라는 생각이 드네요 ㅎㅎ.. 그래도 캐싱을 지원하는 것 자체는 아주 좋은 것 같습니다!
There was a problem hiding this comment.
설명 감사드립니다.
캐싱의 의미가 있지만 정확히는 PreLoad 의 목적으로 구현했습니다.
랜덤 포즈 특정 상 다음 포즈를 빠르게 넘길 것이라고 생각했고, 그 과정에서 이미지 로딩으로 인한 딜레이가 되지 않도록 다음 이미지를 미리 로드하는 형식으로 구현했습니다.
이미지 로드 관련해서 문서랑 참고자료 확인해서 더 나은 정책으로 적용하는 방향으로 진행해야할 것 같습니다.
관련하여 숙지 후 이슈업해서 이미지 로드 관련해서 성능 향상하는 방향으로 진행해도 괜찮을까요??
랜덤 포즈 데이터가 아주 많아지게 되면 캐싱해둔 이미지를 다시 로드할 수 있을까?
이 부분은 랜덤 포즈이긴 하지만, 포즈 조회 화면에서도 쓰이는 이미지들은 사용하는 것이라서 캐싱을 밀지 않고 그대로 사용해야할 것 같습니다!
+) 리뷰를 보면서 Pager로 미리 인접한 이미지를 로드하면 되지 않을까?? 라고 생각이 드네요..!
There was a problem hiding this comment.
이 부분은 랜덤 포즈이긴 하지만, 포즈 조회 화면에서도 쓰이는 이미지들은 사용하는 것이라서 캐싱을 밀지 않고 그대로 사용해야할 것 같습니다!
아하 캐싱된 이미지 공통으로 사용한다면 더욱이나 싱글톤으로 정의된 메모리 캐싱 방식을 이용하면 좋을 것 같습니다.
이슈업을 해두고 이후에 조금 더 숙지한채로 다시 의논해보면 좋을 것 같네요!
| 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) | ||
| } | ||
| } |
There was a problem hiding this comment.
이전 PR에서도 의논을 했지만applicationScope로 정말 ViewModel이 제거된 뒤 "API 요청을 보장할 수 있는지" 의문이 남는 것 같습니다.
물론 아주 극단적이지만 스크랩 버튼을 누른 뒤 0.5초 이내 ViewModel이 제거된 후 즉시 앱의 프로세스가 종료되어 applicationScope가 종료된다거나 네트워크 상태에 따라 API 요청이 지연되는 경우 보장이 어렵지 않을까요..?
어플리케이션 생명주기와 관계 없이 정말 API 호출을 보장하려 한다면 WorkManager를 사용해야 하나? 생각이 드네요.
고려하지 않아도 될 정도로 극히 드물겠지만 익성님 의견이 궁금합니다.
랜덤 포즈를 조회하는 `getRandomPose` API 호출 시 `headCount` 파라미터를 추가하여, 선택된 인원 수에 맞는 포즈만 가져오도록 수정합니다.
There was a problem hiding this comment.
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?`으로 변경합니다. 이는 스크랩 상태를 옵티미스틱 업데이트 이전 값으로 정확히 되돌리기 위함입니다.
There was a problem hiding this comment.
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()) + } + }
🔗 관련 이슈
📙 작업 설명
📸 스크린샷 또는 시연 영상
default.mp4
default.mp4
💬 추가 설명 or 리뷰 포인트
PhotoDetailViewModel의 즐겨찾기와 동일한 패턴으로 구현했습니다.MutableSharedFlow+debounce(500ms)로 빠른 연속 클릭 시 API 중복 호출 방지ApplicationScope를 사용하여 화면 이탈 시에도 마지막 상태 서버 동기화onCleared에서 커밋되지 않은 스크랩 상태 최종 반영Map<Long, Job>으로 포즈별 독립적인 debounce를 적용하여 서로 다른 포즈의 API 호출이 간섭하지 않도록 구현했습니다.Summary by CodeRabbit
새로운 기능
UX / 네비게이션
UI
잡무