지난 5주 간 기획/디자인/개발이 협업하여 프로덕트를 만드는 SOPT의 핵심 행사 앱잼에 참여했습니다. 저는 수현이랑 팀에 안드로이드 개발자로 참가했습니다. 여러 기능 중 온보딩 플로우 파트를 담당했고, 이를 구현하며 마주쳤던 문제상황, 해결방식, 배웠던 점을 기록하고자 합니다.

우선 저는 안드로이드 개발을 앱잼에서 처음 접했고, 팀원분들께 도움을 많이 받았습니다. 회원가입 플로우의 여러 상태를 한 번에 처리해야 돼, 저와 비슷한 상황을 해결한 팀원분께 공유 뷰모델 사용법 관련하여 하단 코드를 받았습니다.
findSuhyeonNavGraph(
padding = padding,
onNavigateToFindSuheyonUpload = navigator::navigateToFindSuhyeonUpload,
onNavigateToFindSuhyeon = { navigator.navigateToFindSuhyeon() },
onNavigateToFindSuheyonUploadDetail = navigator::navigateToFindSuhyeonUploadDetail,
onNavigateToFindSuhyeonPost = { navigator.navigateToFindSuhyeonPost(it) },
getBackStackUploadViewModel = { navBackStackEntry ->
navigator.navController.previousBackStackEntry?.let { previousEntry ->
hiltViewModel<FindSuhyeonUploadViewModel>(previousEntry)
} ?: hiltViewModel(navBackStackEntry)
}
)
하지만 해당 코드를 통해 5시간 정도 상태를 공유하고자 노력했지만, 스크린이 변할 때, 뷰모델 내 상태관리가 전혀 되지 않는다는 문제를 발견할 수 있었습니다.
다른 NavGraph에서는 다 공유가 됐지만, 제가 진행하는 7개의 스크린이 하나의 플로우로 묶인 작업에서는 되지 않는다는 점이 의아했습니다.
제가 맡은 기능들에 대한 플로우를 따라가며, 다시 한 번 다른 팀원분의 코드와 저의 코드,그리고 뷰를 서로 비교하며 차이점을 찾고자 노력했습니다. 그 결과 하나의 가설 도출에 성공했습니다. 다른 팀원들은 두 스크린 간 상태를 공유하고 있다는 점이었습니다.
저는 위에 기술했듯이, 7개의 스크린에서 받은 상태를 마지막 회원가입 버튼에서 한 번에 서버통신을 해야하는 상황이었습니다. 여러 자료를 찾아보고 구글링을 통해, Compose의 NavHost는 기본적으로 화면 전환 시 새로운 NavBackStackEntry를 생성한다는 점을 알 수 있었습니다.
특히 근본적인 문제는 NavGraph의 구조 이해 부족과 ViewModel의 범위 이해 부족이 있었습니다.
팀원분의 코드에서는 previousBackStackEntry를 참조하여 두 스크린 간 ViewModel을 공유했지만, 제 코드에서는 7개의 스크린 모두가 동일한 부모 NavGraph의 BackStackEntry를 참조해야 했습니다. 하지만 스크린이 전환될 때마다 previousBackStackEntry를 참조하면 새로운 BackStackEntry가 생성되므로, ViewModel의 상태가 초기화되거나 제대로 공유되지 않는 문제가 발생했습니다.
즉, 제가 처리하고자 하는 작업은 참조가 지속적으로 하는 구조가 아니라, NavGraph 단위에서 ViewModel을 지속적으로 공유해야 하는 구조였던 것입니다.
그 결과, 회원가입에 필요한 정보를 각 스크린에서 제공받고 마지막 스크린에서 가입을 할 때, 뷰모델에서 공유된 상태를 서버에 POST해 회원가입을 문제없이 마무리 지을 수 있게 만들었습니다.
자세한 코드는 아래와 같습니다.
onBoardingNavGraph(
padding = padding,
onBoardingPadding = PaddingValues(
start = padding.calculateStartPadding(layoutDirection = LayoutDirection.Ltr),
end = padding.calculateEndPadding(layoutDirection = LayoutDirection.Ltr),
bottom = padding.calculateBottomPadding(),
top = 0.dp
),
onNavigateToLogin = navigator::navigateToLogin,
onNavigateToSignUp = navigator::navigateToSignUp,
onNavigateToPhoneNumberAuth = navigator::navigateToPhoneNumberAuth,
onNavigateToNickNameAuth = navigator::navigateToNicknameAuth,
onNavigateToSelectYearOfBirth = navigator::navigateToSelectYearOfBirth,
onNavigateToSelectGender = navigator::navigateToSelectGender,
onNavigateToPostProfileImage = navigator::navigateToPostProfileImage,
onNavigateToSelectLocation = navigator::navigateToSelectLocation,
onNavigateToSignUpFinish = navigator::navigateToOnboardingFinish,
onNavigateToLoginFinish = navigator::navigateToLoginFinish,
onNavigateToHome = navigator::navigateToHome,
getBackStackSignUpViewModel = { navBackStackEntry ->
val parentEntry = try {
navigator.navController.getBackStackEntry(Route.OnBoarding)
} catch (e: IllegalArgumentException) {
null
}
parentEntry?.let { hiltViewModel<SignUpViewModel>(it) } ?: hiltViewModel(
navBackStackEntry
)
},
getBackStackLoginViewModel = { navBackStackEntry ->
navigator.navController.previousBackStackEntry?.let { previousEntry ->
hiltViewModel<LoginViewModel>(previousEntry)
} ?: hiltViewModel(navBackStackEntry)
}
)
fun NavGraphBuilder.onBoardingNavGraph(
onBoardingPadding: PaddingValues,
padding: PaddingValues,
onNavigateToLogin: () -> Unit,
onNavigateToSignUp: () -> Unit,
onNavigateToPhoneNumberAuth: () -> Unit,
onNavigateToNickNameAuth: () -> Unit,
onNavigateToSelectYearOfBirth: () -> Unit,
onNavigateToSelectGender: () -> Unit,
onNavigateToPostProfileImage: () -> Unit,
onNavigateToSelectLocation: () -> Unit,
onNavigateToSignUpFinish: () -> Unit,
onNavigateToLoginFinish: () -> Unit,
onNavigateToHome: () -> Unit,
getBackStackSignUpViewModel: @Composable (NavBackStackEntry) -> SignUpViewModel,
getBackStackLoginViewModel: @Composable (NavBackStackEntry) -> LoginViewModel
) {
composable<OnBoardingRoute> {
OnBoardingRoute(
padding = onBoardingPadding,
navigateToSignUp = onNavigateToSignUp,
navigateToLogin = onNavigateToLogin,
)
}
composable<TermsOfUseRoute> {
TermsOfUseRoute(
navigateToNext = onNavigateToPhoneNumberAuth,
padding = padding,
viewModel = getBackStackUploadViewModel(it)
)
}
composable<PhoneNumberAuthRoute> {
PhoneNumberAuthenticationRoute(
navigateToNext = onNavigateToNickNameAuth,
padding = padding,
viewModel = getBackStackUploadViewModel(it)
)
}
composable<NickNameAuthRoute> {
NickNameAuthenticationRoute(
navigateToNext = onNavigateToSelectYearOfBirth,
padding = padding,
viewModel = getBackStackUploadViewModel(it)
)
}
composable<YearOfBirthRoute> {
YearOfBirthRoute(
navigateToNext = onNavigateToSelectGender,
padding = padding,
viewModel = getBackStackUploadViewModel(it)
)
}
composable<SelectGenderRoute> {
GenderSelectRoute(
navigateToNext = onNavigateToPostProfileImage,
padding = padding,
viewModel = getBackStackUploadViewModel(it)
)
}
composable<PostProfileImageRoute> {
SelectProfileRoute(
navigateToNext = onNavigateToSelectLocation,
padding = padding,
viewModel = getBackStackUploadViewModel(it)
)
}
composable<SelectLocationRoute> {
SelectLocationRoute(
navigateToNext = onNavigateToSignUpFinish,
padding = padding,
viewModel = getBackStackUploadViewModel(it)
)
}
composable<OnboardingFinishRoute> {
FinishSignUpRoute(
navigateToNext = onNavigateToHome,
padding = padding,
viewModel = getBackStackUploadViewModel(it)
)
}
composable<LoginRoute> {
LoginRoute(
navigateToLoginFinish = onNavigateToLoginFinish,
padding = padding,
)
}
composable<Route.LoginFinish> {
FinishLoginRoute(
padding = padding,
navigateToNext = onNavigateToHome,
viewModel = getBackStackLoginViewModel(it)
)
}
단순 구현에서 멈추지 않고, 많은 자료를 찾아보며 Compose 개발 중 저와 같은 고민을 했던 사람들의 아티클을 읽으며 아래와 같은 배움을 얻었습니다.
1. 여러 스크린에서 동일한 ViewModel을 참조하며 상태를 공유해야 하는 경우.
2. NavGraph 단위로 ViewModel의 범위를 정의하고 상태를 관리하고자 하는 경우.
3. 스크린 전환 시 ViewModel의 상태를 유지해야 하는 경우
위와 같은 케이스에서는 부모 NavGraph의 BackStackEntry 참조를 통한 ViewModel 공유를 통해 상태를 공유하고 관리하다는 것을 깨달았습니다.