[REFACTOR] 피드 메인 화면에서 스크롤 및 데이터 최신화 문제 해결#122
Conversation
Walkthrough백스택 SavedState 기반 처리 추가: 삭제된 피드 ID 관찰 및 정리, 프로필에서 복귀 여부(from_profile) 플래그로 초기 로딩 분기. 프로필 진입 시 플래그 설정. 초기 진입/복귀 시 전체 새로고침 vs 최근 작성자만 갱신 흐름이 분기됨. ViewModel의 fetchRecentWriters 공개화 및 pullToRefresh에 포함. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor User as 사용자
participant FeedUI as FeedScreen
participant Nav as NavController/SavedState
participant VM as FeedViewModel
rect rgba(200,230,255,0.25)
note over FeedUI,VM: 초기 진입 또는 복귀 처리 (from_profile 플래그)
FeedUI->>Nav: savedState.get(from_profile)
alt from_profile == true or updated_feed_id 존재
FeedUI->>VM: fetchRecentWriters()
FeedUI->>Nav: remove(from_profile)
else 초기 진입(프로필 아님)
FeedUI->>VM: 전체 새로고침(탭 피드 새로고침)
FeedUI->>FeedUI: 스크롤 상단 이동
end
end
rect rgba(220,255,220,0.25)
note over FeedUI,Nav: 프로필로 이동 시 원점 표시
User->>FeedUI: 작성자 프로필 탭
FeedUI->>Nav: set(from_profile=true)
FeedUI-->>Nav: navigate(UserProfile)
end
rect rgba(255,230,200,0.25)
note over FeedUI,Nav: 삭제된 피드 처리
Nav-->>FeedUI: deleted_feed_id
FeedUI->>VM: removeDeletedFeed(deletedId)
FeedUI->>Nav: remove(deleted_feed_id)
end
rect rgba(245,245,255,0.35)
note over User,VM: 풀투리프레시
User->>FeedUI: Pull to refresh
FeedUI->>VM: pullToRefresh()
VM->>VM: refresh current tab feeds
VM->>VM: fetchRecentWriters()
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
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 unit tests
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR/Issue comments)Type Other keywords and placeholders
CodeRabbit Configuration File (
|
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedViewModel.kt (2)
202-212: Pull-to-refresh 중 recentWriters 호출이 전체 로딩(isLoading)을 건드려 인디케이터가 꼬일 수 있습니다현재 pullToRefresh()에서 fetchRecentWriters()를 호출하는데, 해당 함수가 isLoading을 on/off합니다. 그 결과:
- 풀투리프레시 인디케이터(isPullToRefreshing)가 내려가기 전에 recentWriters 비동기 작업이 끝나지 않고,
- 현재 탭 목록이 비어있는 경우에는 전체 화면 로딩 UI가 뜨는 부자연스러운 상태 전이가 발생할 수 있습니다.
권장: fetchRecentWriters를 suspend로 변경하고, 기본적으로는 글로벌 로딩을 건드리지 않도록 하세요. pullToRefresh 내에서 순차적으로 await하여 모든 갱신이 끝난 후 isPullToRefreshing을 false로 내리면 일관됩니다. 실제 변경은 아래 291라인 코멘트의 diff를 참고해 주세요.
291-313: fetchRecentWriters를 suspend 함수로 전환하고 모든 호출부를 코루틴 컨텍스트로 감싸세요이 PR에서
fetchRecentWriters()시그니처를suspend fun fetchRecentWriters(withGlobalLoading: Boolean = false)로 변경한 경우, 현재 프로젝트 내 4곳의 호출부 중 1곳(FeedScreen.kt)이 코루틴 밖에서 직접 호출되고 있어 런타임 에러가 발생합니다. 다음 위치를 반드시 수정하세요:
FeedViewModel init 블록 (FeedViewModel.kt:68)
• 기존:init { loadAllFeeds() fetchRecentWriters() fetchMyFeedInfo() }• 수정:
init { loadAllFeeds() viewModelScope.launch { fetchRecentWriters(withGlobalLoading = true) } fetchMyFeedInfo() }pullToRefresh (FeedViewModel.kt:202-210)
• 이미viewModelScope.launch내부이므로,withGlobalLoading기본값(false)로 호출 가능합니다.refreshData (FeedViewModel.kt:284-289)
• 이미viewModelScope.launch내부이므로, 기본값(false)로 호출 가능합니다.FeedScreen.kt (앱 UI 화면, 약 160행)
• 기존:// 댓글 화면 또는 프로필에서 돌아온 경우 feedViewModel.fetchRecentWriters()• 수정 예시:
LaunchedEffect(Unit) { feedViewModel.fetchRecentWriters() } // 혹은 LaunchedEffect(key1 = isProfileReturned) { feedViewModel.fetchRecentWriters() }위 4곳 모두 코루틴 내부(또는 Compose
LaunchedEffect)에서 호출해야 컴파일·런타임 에러 없이 정상 동작합니다.app/src/main/java/com/texthip/thip/ui/feed/screen/FeedScreen.kt (1)
124-133: observeForever 대신 Flow(collect) 사용으로 메모리 누수 방지LiveData의 observeForever 사용은 수명주기를 무시해 콜백 누수 및 메모리 누수를 유발할 수 있습니다. SavedStateHandle.getStateFlow을 사용해 collect로 구독하도록 리팩토링해주세요.
수정 대상:
- app/src/main/java/com/texthip/thip/ui/feed/screen/FeedScreen.kt:126~133 (
deleted_feed_id)- app/src/main/java/com/texthip/thip/ui/feed/screen/FeedScreen.kt:205~212 (
updated_feed_id)예시 diff (deleted_feed_id 관찰부):
LaunchedEffect(navController) { - val handle = navController.currentBackStackEntry?.savedStateHandle ?: return@LaunchedEffect - handle.getLiveData<Long>("deleted_feed_id").observeForever { deletedId -> - if (deletedId != null) { - feedViewModel.removeDeletedFeed(deletedId) - handle.remove<Long>("deleted_feed_id") - } - } + val handle = navController.currentBackStackEntry?.savedStateHandle ?: return@LaunchedEffect + handle.getStateFlow<Long?>("deleted_feed_id", null).collect { deletedId -> + if (deletedId != null) { + feedViewModel.removeDeletedFeed(deletedId) + handle["deleted_feed_id"] = null + } + } }예시 diff (updated_feed_id 관찰부):
LaunchedEffect(navController) { - val handle = navController.currentBackStackEntry?.savedStateHandle ?: return@LaunchedEffect - handle.getLiveData<Long>("updated_feed_id").observeForever { updatedId -> - if (updatedId != null) { - feedViewModel.updateFeed(updatedId) - handle.remove<Long>("updated_feed_id") - } - } + val handle = navController.currentBackStackEntry?.savedStateHandle ?: return@LaunchedEffect + handle.getStateFlow<Long?>("updated_feed_id", null).collect { updatedId -> + if (updatedId != null) { + feedViewModel.updateFeed(updatedId) + handle["updated_feed_id"] = null + } + } }위와 같이 Flow 기반 구독으로 전환하면, 콜백 자동 해제와 수명 주기 안전성을 확보할 수 있습니다.
🧹 Nitpick comments (5)
app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedViewModel.kt (3)
333-387: 낙관적 업데이트 실패 시 전체 리스트 롤백은 경쟁 상황에서 다른 변경을 덮어쓸 수 있습니다현재 onFailure에서 allFeeds/myFeeds 전체를 이전 스냅샷으로 복구합니다. 사용자가 빠르게 여러 피드에 상호작용할 경우, 한 요청의 실패가 다른 요청의 성공/진행 중 변경까지 덮어써 데이터 손실/깜빡임이 생길 수 있습니다.
실패 시 해당 feedId에 한해 원소만 되돌리도록 부분 롤백을 권장합니다.
부분 롤백 diff(실패 핸들러만 교체):
- ).onFailure { - _uiState.update { - it.copy( - allFeeds = currentAllFeeds, - myFeeds = currentMyFeeds - ) - } - } + ).onFailure { + _uiState.update { state -> + state.copy( + allFeeds = state.allFeeds.map { feed -> + if (feed.feedId.toLong() == feedId) allFeedToUpdate ?: feed else feed + }, + myFeeds = state.myFeeds.map { feed -> + if (feed.feedId.toLong() == feedId) myFeedToUpdate ?: feed else feed + } + ) + } + }추가로, allFeeds/myFeeds를 각각 map으로 두 번 업데이트하는 로직이 changeFeedLike/Save, updateFeedStateFromResult에 중복되어 있습니다. feedId 기준으로 단일 아이템을 업데이트하는 헬퍼를 만들어 중복 제거를 고려해 주세요.
390-437: Save 토글 실패 시 전체 리스트 롤백 → 부분 롤백으로 변경 권장위 changeFeedLike와 동일한 문제입니다. 실패 시 해당 항목만 원복하도록 바꾸세요.
실패 핸들러 부분 diff:
- ).onFailure { - _uiState.update { it.copy(allFeeds = currentAllFeeds, myFeeds = currentMyFeeds) } - } + ).onFailure { + _uiState.update { state -> + state.copy( + allFeeds = state.allFeeds.map { feed -> + if (feed.feedId.toLong() == feedId) allFeedToUpdate ?: feed else feed + }, + myFeeds = state.myFeeds.map { feed -> + if (feed.feedId.toLong() == feedId) myFeedToUpdate ?: feed else feed + } + ) + } + }
453-468: 상태 동기화 로직은 타당합니다. 다만 중복 패턴을 헬퍼로 추출하면 가독성이 좋아집니다updatedAllFeeds/updatedMyFeeds 생성 로직이 changeFeedLike/Save와 유사합니다. feedId로 특정 아이템만 copy하는 헬퍼(ex. private fun List.updateIfId(...))를 도입해 중복을 제거하면 유지보수가 쉬워집니다.
app/src/main/java/com/texthip/thip/ui/feed/screen/FeedScreen.kt (2)
474-476: 프로필 진입 전 from_profile 플래그 설정: 접근 방향 맞습니다현재 화면(Feed)이 곧 이전 스택 엔트리가 되므로 currentBackStackEntry.savedStateHandle에 set하는 방식이 적절합니다. 동일 패턴을 사용하는 saved-state 이벤트 전반을 getStateFlow로 일원화하면 관측/해제 관리가 단순해집니다.
205-232: (참고) updated_feed_id LiveData 관찰도 Flow로 바꾸면 누수/중복 호출을 방지할 수 있습니다변경 라인 외 참고용 제안입니다. deleted_feed_id와 동일하게 getStateFlow로 치환하세요. 예시:
LaunchedEffect(navController) { val handle = navController.currentBackStackEntry?.savedStateHandle ?: return@LaunchedEffect handle.getStateFlow<Long?>("updated_feed_id", null).collect { feedId -> if (feedId != null) { val isLiked = handle.get<Boolean>("updated_feed_isLiked") ?: false val likeCount = handle.get<Int>("updated_feed_likeCount") ?: 0 val isSaved = handle.get<Boolean>("updated_feed_isSaved") ?: false val commentCount = handle.get<Int>("updated_feed_commentCount") ?: 0 val result = FeedStateUpdateResult( feedId = feedId, isLiked = isLiked, likeCount = likeCount, isSaved = isSaved, commentCount = commentCount ) feedViewModel.updateFeedStateFromResult(result) handle["updated_feed_id"] = null handle["updated_feed_isLiked"] = null handle["updated_feed_likeCount"] = null handle["updated_feed_isSaved"] = null handle["updated_feed_commentCount"] = null } } }
📜 Review details
Configuration used: CodeRabbit UI
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.
📒 Files selected for processing (2)
app/src/main/java/com/texthip/thip/ui/feed/screen/FeedScreen.kt(4 hunks)app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedViewModel.kt(11 hunks)
🔇 Additional comments (1)
app/src/main/java/com/texthip/thip/ui/feed/screen/FeedScreen.kt (1)
549-549: 변경 없음 — 포맷팅 변경으로 보입니다기능적 변화가 없어 스킵합니다.
| val hasUpdatedFeedData = | ||
| navController.currentBackStackEntry?.savedStateHandle?.get<Long>("updated_feed_id") != null | ||
| val fromProfile = | ||
| navController.currentBackStackEntry?.savedStateHandle?.get<Boolean>("from_profile") ?: false | ||
|
|
||
| if (!hasUpdatedFeedData) { | ||
| if (!hasUpdatedFeedData && !fromProfile) { | ||
| // 일반적인 경우: 전체 새로고침 + 스크롤 상단 이동 | ||
| feedViewModel.refreshData() | ||
| allFeedListState.scrollToItem(0) | ||
| } else { | ||
| // 댓글 화면 또는 프로필에서 돌아온 경우: recentWriters만 업데이트 | ||
| feedViewModel.fetchRecentWriters() | ||
| } | ||
|
|
||
| // 프로필 플래그 제거 | ||
| if (fromProfile) { | ||
| navController.currentBackStackEntry?.savedStateHandle?.remove<Boolean>("from_profile") | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion
최초 진입 시 refreshData가 이중 호출됩니다 (중복 네트워크 요청 가능)
같은 LaunchedEffect(Unit) 블록 내에서:
- (라인 144-148) 빈 목록이면 refreshData()
- 이어서 (라인 154-158) updated_feed_id 없음 && from_profile 아님 → 다시 refreshData()
초기 진입 케이스에서 두 조건이 동시에 참이므로 두 번 호출됩니다. 한 번만 호출되도록 정리하세요.
간단히 첫 번째 블록을 제거하는 diff:
- LaunchedEffect(Unit) {
- // 최초 진입시에만 데이터 로딩
- if (feedUiState.allFeeds.isEmpty() && feedUiState.myFeeds.isEmpty()) {
- feedViewModel.refreshData()
- }
-
+ LaunchedEffect(Unit) {
val hasUpdatedFeedData =
navController.currentBackStackEntry?.savedStateHandle?.get<Long>("updated_feed_id") != null
val fromProfile =
navController.currentBackStackEntry?.savedStateHandle?.get<Boolean>("from_profile") ?: false
if (!hasUpdatedFeedData && !fromProfile) {
// 일반적인 경우: 전체 새로고침 + 스크롤 상단 이동
feedViewModel.refreshData()
allFeedListState.scrollToItem(0)
} else {
// 댓글 화면 또는 프로필에서 돌아온 경우: recentWriters만 업데이트
- feedViewModel.fetchRecentWriters()
+ // suspend로 전환된 API를 호출한다고 가정
+ feedViewModel.fetchRecentWriters()
}
// 프로필 플래그 제거
if (fromProfile) {
navController.currentBackStackEntry?.savedStateHandle?.remove<Boolean>("from_profile")
}
}위 변경은 291라인에서 제안한 suspend 전환과 함께 적용 시 가장 자연스럽습니다.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| val hasUpdatedFeedData = | |
| navController.currentBackStackEntry?.savedStateHandle?.get<Long>("updated_feed_id") != null | |
| val fromProfile = | |
| navController.currentBackStackEntry?.savedStateHandle?.get<Boolean>("from_profile") ?: false | |
| if (!hasUpdatedFeedData) { | |
| if (!hasUpdatedFeedData && !fromProfile) { | |
| // 일반적인 경우: 전체 새로고침 + 스크롤 상단 이동 | |
| feedViewModel.refreshData() | |
| allFeedListState.scrollToItem(0) | |
| } else { | |
| // 댓글 화면 또는 프로필에서 돌아온 경우: recentWriters만 업데이트 | |
| feedViewModel.fetchRecentWriters() | |
| } | |
| // 프로필 플래그 제거 | |
| if (fromProfile) { | |
| navController.currentBackStackEntry?.savedStateHandle?.remove<Boolean>("from_profile") | |
| } | |
| LaunchedEffect(Unit) { | |
| val hasUpdatedFeedData = | |
| navController.currentBackStackEntry?.savedStateHandle?.get<Long>("updated_feed_id") != null | |
| val fromProfile = | |
| navController.currentBackStackEntry?.savedStateHandle?.get<Boolean>("from_profile") ?: false | |
| if (!hasUpdatedFeedData && !fromProfile) { | |
| // 일반적인 경우: 전체 새로고침 + 스크롤 상단 이동 | |
| feedViewModel.refreshData() | |
| allFeedListState.scrollToItem(0) | |
| } else { | |
| // 댓글 화면 또는 프로필에서 돌아온 경우: recentWriters만 업데이트 | |
| // suspend로 전환된 API를 호출한다고 가정 | |
| feedViewModel.fetchRecentWriters() | |
| } | |
| // 프로필 플래그 제거 | |
| if (fromProfile) { | |
| navController.currentBackStackEntry?.savedStateHandle?.remove<Boolean>("from_profile") | |
| } | |
| } |
➕ 이슈 링크
🔎 작업 내용
📸 스크린샷
😢 해결하지 못한 과제
[] TASK
📢 리뷰어들에게
Summary by CodeRabbit