From 69268cb8abe381f558b0e211ac7df5e33332a472 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Thu, 8 Oct 2026 21:50:52 +0900 Subject: [PATCH] =?UTF-8?q?fix(profile):=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=A1=9C=EB=94=A9=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EC=8B=9C=20=ED=81=AC=EB=9E=98=EC=8B=9C=EB=A5=BC=20=EB=A7=89?= =?UTF-8?q?=EA=B3=A0,=20=EB=A1=9C=EB=94=A9=20=ED=98=B8=EC=B6=9C=EC=9D=84?= =?UTF-8?q?=20=EB=8F=99=EC=8B=9C=EC=97=90=20=EB=B6=80=EB=A5=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProfileEditViewModel.load: async 를 coroutineScope 로 감싼다. launch 에 바로 건 async 가 실패하면 부모 launch 까지 실패해 크래시했다 - MyTierHistoryViewModel.load: 티어 이력과 점수 변동을 함께 받는다(부분 성공 규칙 유지) - SettingsViewModel.load: 읽은 답변 기록을 기존 병렬 조회에 합친다 - InquiryListViewModel.load: 문의 목록과 읽은 답변 기록을 함께 받는다(기록 실패는 지금처럼 흡수) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../edit/viewmodel/ProfileEditViewModel.kt | 12 ++++++++---- .../settings/viewmodel/SettingsViewModel.kt | 8 ++++---- .../tier/viewmodel/MyTierHistoryViewModel.kt | 10 ++++++++-- .../edit/viewmodel/ProfileEditViewModelTest.kt | 16 +++++++++++++++- .../list/viewmodel/InquiryListViewModel.kt | 15 ++++++++++----- 5 files changed, 45 insertions(+), 16 deletions(-) diff --git a/profile/presentation/src/main/kotlin/com/ruleup/profile/presentation/edit/viewmodel/ProfileEditViewModel.kt b/profile/presentation/src/main/kotlin/com/ruleup/profile/presentation/edit/viewmodel/ProfileEditViewModel.kt index d152d49e8..ce0ff12ab 100644 --- a/profile/presentation/src/main/kotlin/com/ruleup/profile/presentation/edit/viewmodel/ProfileEditViewModel.kt +++ b/profile/presentation/src/main/kotlin/com/ruleup/profile/presentation/edit/viewmodel/ProfileEditViewModel.kt @@ -17,6 +17,7 @@ import com.ruleup.ui.error.userFacingMessage import com.ruleup.ui.mvi.MviViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import java.time.LocalDate import java.time.OffsetDateTime @@ -113,10 +114,13 @@ class ProfileEditViewModel dispatch(ProfileEditReducerEvent.Loading) viewModelScope.launch { runCatching { - val profileDeferred = async { profileRepository.getProfile() } - // 마스터 조회 실패는 기본 상한(6)으로 흡수한다. - val catalogDeferred = async { runCatching { profileRepository.getCategories() }.getOrNull() } - profileDeferred.await() to catalogDeferred.await() + // coroutineScope 없이 launch 에 바로 async 를 걸면 getProfile 실패가 launch 까지 무너뜨려 크래시한다. + coroutineScope { + val profileDeferred = async { profileRepository.getProfile() } + // 마스터 조회 실패는 기본 상한(6)으로 흡수한다. + val catalogDeferred = async { runCatching { profileRepository.getCategories() }.getOrNull() } + profileDeferred.await() to catalogDeferred.await() + } }.onSuccess { (profile, catalog) -> dispatch( ProfileEditReducerEvent.Loaded( diff --git a/profile/presentation/src/main/kotlin/com/ruleup/profile/presentation/settings/viewmodel/SettingsViewModel.kt b/profile/presentation/src/main/kotlin/com/ruleup/profile/presentation/settings/viewmodel/SettingsViewModel.kt index 9565a24e9..b71c0104e 100644 --- a/profile/presentation/src/main/kotlin/com/ruleup/profile/presentation/settings/viewmodel/SettingsViewModel.kt +++ b/profile/presentation/src/main/kotlin/com/ruleup/profile/presentation/settings/viewmodel/SettingsViewModel.kt @@ -93,20 +93,20 @@ class SettingsViewModel private fun load() { viewModelScope.launch { - // 넷은 서로 독립이라 함께 던진다. - val loaded = + // 다섯은 서로 독립이라 함께 던진다. + val (loaded, seenAnswers) = coroutineScope { val a = async { runCatching { accountRepository.getAgreements() }.getOrNull() } val s = async { runCatching { accountRepository.getSanctions() }.getOrNull() } val p = async { runCatching { profileRepository.getMyProfile() }.getOrNull() } val i = async { runCatching { inquiryRepository.getInquiries() }.getOrNull() } - SettingsLoad(a.await(), s.await(), p.await(), i.await()) + val seen = async { runCatching { inquiryReadStore.seenAnswers() }.getOrDefault(emptyMap()) } + SettingsLoad(a.await(), s.await(), p.await(), i.await()) to seen.await() } if (loaded.isEmpty) { dispatch(SettingsReducerEvent.LoadFinished) return@launch } - val seenAnswers = runCatching { inquiryReadStore.seenAnswers() }.getOrDefault(emptyMap()) dispatch( SettingsReducerEvent.Loaded( provider = diff --git a/profile/presentation/src/main/kotlin/com/ruleup/profile/presentation/tier/viewmodel/MyTierHistoryViewModel.kt b/profile/presentation/src/main/kotlin/com/ruleup/profile/presentation/tier/viewmodel/MyTierHistoryViewModel.kt index 6784d615e..9a1d5bb90 100644 --- a/profile/presentation/src/main/kotlin/com/ruleup/profile/presentation/tier/viewmodel/MyTierHistoryViewModel.kt +++ b/profile/presentation/src/main/kotlin/com/ruleup/profile/presentation/tier/viewmodel/MyTierHistoryViewModel.kt @@ -8,6 +8,8 @@ import com.ruleup.profile.domain.repository.MyPageRepository import com.ruleup.ui.mvi.MviViewModel import com.ruleup.ui.mvi.NoEffect import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import javax.inject.Inject @@ -66,8 +68,12 @@ class MyTierHistoryViewModel if (currentState.history != null || currentState.changes.isNotEmpty()) return dispatch(MyTierHistoryReducerEvent.Loading) viewModelScope.launch { - val history = runCatching { myPageRepository.getTierHistory() }.getOrNull() - val changes = runCatching { myPageRepository.getScoreChanges() } + val (history, changes) = + coroutineScope { + val history = async { runCatching { myPageRepository.getTierHistory() }.getOrNull() } + val changes = async { runCatching { myPageRepository.getScoreChanges() } } + history.await() to changes.await() + } changes .onSuccess { dispatch(loaded(history, it)) } diff --git a/profile/presentation/src/test/kotlin/com/ruleup/profile/presentation/edit/viewmodel/ProfileEditViewModelTest.kt b/profile/presentation/src/test/kotlin/com/ruleup/profile/presentation/edit/viewmodel/ProfileEditViewModelTest.kt index d36424da7..23f43ffc1 100644 --- a/profile/presentation/src/test/kotlin/com/ruleup/profile/presentation/edit/viewmodel/ProfileEditViewModelTest.kt +++ b/profile/presentation/src/test/kotlin/com/ruleup/profile/presentation/edit/viewmodel/ProfileEditViewModelTest.kt @@ -22,6 +22,8 @@ import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertTrue /** 프로필 편집. */ @@ -54,6 +56,17 @@ class ProfileEditViewModelTest { assertTrue(viewModel.uiState.value.maxSelectable > 0) } + @Test + fun `프로필 조회가 실패하면 크래시 없이 오류를 보여준다`() = + runTest { + val viewModel = viewModel(repo(loadProfile = { throw IllegalStateException("서버 오류") })) + + viewModel.onIntent(ProfileEditIntent.Load) + + assertNotNull(viewModel.uiState.value.errorMessage) + assertFalse(viewModel.uiState.value.isLoading) + } + @Test fun `바꾼 게 없으면 저장하지 않는다`() = runTest { @@ -350,6 +363,7 @@ class ProfileEditViewModelTest { } private fun repo( + loadProfile: (() -> Profile)? = null, categories: (() -> com.ruleup.profile.domain.entity.CategoryCatalog)? = { catalog() }, checkNickname: ((String) -> NicknameCheck)? = { NicknameCheck(valid = true, available = true, reason = null) }, updateProfile: (() -> Profile)? = { profile() }, @@ -357,7 +371,7 @@ class ProfileEditViewModelTest { uploadImage: ((String) -> String)? = { "https://example.com/new.jpg" }, deleteImage: () -> Unit = {}, ) = FakeProfileRepository( - profile = { profile().let { it.copy(user = it.user.copy(profileImageUrl = imageUrl)) } }, + profile = loadProfile ?: { profile().let { it.copy(user = it.user.copy(profileImageUrl = imageUrl)) } }, categories = categories, checkNickname = checkNickname, updateProfile = updateProfile, diff --git a/support/presentation/src/main/kotlin/com/ruleup/support/presentation/list/viewmodel/InquiryListViewModel.kt b/support/presentation/src/main/kotlin/com/ruleup/support/presentation/list/viewmodel/InquiryListViewModel.kt index 068bab37f..5ae72e2cc 100644 --- a/support/presentation/src/main/kotlin/com/ruleup/support/presentation/list/viewmodel/InquiryListViewModel.kt +++ b/support/presentation/src/main/kotlin/com/ruleup/support/presentation/list/viewmodel/InquiryListViewModel.kt @@ -11,6 +11,8 @@ import com.ruleup.support.domain.repository.InquiryRepository import com.ruleup.ui.mvi.MviViewModel import com.ruleup.ui.mvi.NoEffect import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import javax.inject.Inject @@ -51,12 +53,15 @@ class InquiryListViewModel private fun load() { dispatch(InquiryListReducerEvent.Loading) viewModelScope.launch { - runCatching { inquiryRepository.getInquiries() } - .onSuccess { items -> + runCatching { + coroutineScope { // 기록을 못 읽으면 전부 새 답변으로 보인다. 목록을 막을 일은 아니다 - val seen = runCatching { inquiryReadStore.seenAnswers() }.getOrDefault(emptyMap()) - dispatch(InquiryListReducerEvent.Loaded(items, seen)) - }.onFailure { dispatch(InquiryListReducerEvent.Failed(it.userMessage())) } + val seen = async { runCatching { inquiryReadStore.seenAnswers() }.getOrDefault(emptyMap()) } + inquiryRepository.getInquiries() to seen.await() + } + }.onSuccess { (items, seen) -> + dispatch(InquiryListReducerEvent.Loaded(items, seen)) + }.onFailure { dispatch(InquiryListReducerEvent.Failed(it.userMessage())) } } } }