주완 님의 블로그
회원가입 플로우 아키텍처 분석: 다중 vs 단일 ViewModel , Navigation구조 본문
<앱잼 진행시 생각한 의문들>
해당 프로젝트에서 회원가입 플로우는 총 4단계로 구성되어있었다. 각 단계마다 screen,viewmodel,navigation을 독립적으로 관리를 했었다. 근데 회원가입이라는 하나의 흐름에서 계속해서 데이터를 저장한 상태 + Loginscreen 이라는 다른 presentation 에서 넘어온 데이터를 계속 관리해야되는데 , 데이터와 상태를 유지하는 것이 어려웠다.
각 구조들을 분리해서 진행하다보니, 데이터의 누락, 상태의 변화에 대해서 한 사이클 동안 정상적으로 이루어지지 않는다.
한 사이클이란 개념(?)에서 viewmodel 하나로 관리하는데, 구현해보니 이런식으로 서버연결과 상태관리가 가능하긴했다. 그러나 이 또한 하나의 viewmodel 에 대한 책임이 많아지고 코드가 길어지는 상황이 발생했다. (이러면 애써 패키지랑 구조를 분리한 이유가 좀 모호해질 수 있지 않을까..?)

1.1 문제점
다중 스크린 + 다중 ViewModel 구조의 한계
- 회원가입 4단계를 독립적인 Screen, ViewModel, Navigation으로 관리
- 단계별 데이터 누락 및 상태 불일치 문제 발생
- 로그인에서 전달된 데이터의 연속성 보장 어려움 (LoginScreen이라는 별도의 뷰를 의미합니다)
1.2 생각한 해결 방안
단일 ViewModel + 공유 상태 관리
// 권장 구조
class SignUpViewModel : ViewModel() {
private val _uiState = MutableStateFlow(SignUpUiState())
val uiState: StateFlow<SignUpUiState> = _uiState.asStateFlow()
// 단계별 데이터 누적 관리
fun updateSignUpActivityData(/*넘길 파라미터*/) { ... }
fun updateSignUpDog() { ... }
fun updateSignUpLevel() { ... }
}


기존 구조에서 위의 파일 처럼 각 Screen마다 연결하던 navigation, viewmodel 을 하나로 통합해보았다.
*// 기존 방식: 각 화면마다 독립적인 ViewModel*
SignUpScreen + SignUpViewModel
SignUpActivityScreen + SignUpActivityViewModel
SignUpDogScreen + SignUpDogViewModel
SignUpLevelScreen + SignUpLevelViewModel
<실제 코드 적용>
2.1 Navigation 구조
기존 구조와 비교하여 나열하면
*// 기존 Navigation Graph*
NavHost {
composable("signup") {
SignUpScreen(
viewModel = hiltViewModel<SignUpViewModel>(),
navigateNext = { navController.navigate("signup_activity") }
)
}
composable("signup_activity") {
SignUpActivityScreen(
viewModel = hiltViewModel<SignUpActivityViewModel>(),
navigateNext = { navController.navigate("signup_dog") }
)
}
composable("signup_dog") {
SignUpDogScreen(
viewModel = hiltViewModel<SignUpDogViewModel>(),
navigateNext = { navController.navigate("signup_level") }
)
}
composable("signup_level") {
SignUpLevelScreen(
viewModel = hiltViewModel<SignUpLevelViewModel>(),
navigateNext = { navController.navigate("home") }
)
}
}
2.2 데이터 전달 방식
*// Arguments를 통한 데이터 전달*
composable("signup_activity/{userName}/{userAge}") { backStackEntry ->
val userName = backStackEntry.arguments?.getString("userName") ?: ""
val userAge = backStackEntry.arguments?.getString("userAge") ?: ""
SignUpActivityScreen(
userName = userName,
userAge = userAge,
viewModel = hiltViewModel<SignUpActivityViewModel>()
)
}
*// 또는 SavedStateHandle 사용*
class SignUpActivityViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
private val userName: String = savedStateHandle.get<String>("userName") ?: ""
private val userAge: String = savedStateHandle.get<String>("userAge") ?: ""
}
3. 문제점 분석
3.1 데이터 누락 및 상태 불일치
문제 상황
*// SignUp에서 입력한 데이터*
class SignUpViewModel : ViewModel() {
val userName = MutableStateFlow("")
val userAge = MutableStateFlow("")
val userGender = MutableStateFlow(Gender.UNKNOWN)
}
*// SignUpActivity에서 다시 초기화되는 문제*
class SignUpActivityViewModel : ViewModel() {
*// 이전 단계 데이터를 받아야 하지만...//
// Arguments나 SavedStateHandle을 통해 전달받아야 함
// 복잡하고 누락 가능성 높음*
}

실제로 로그을 찍으면서 단계를 진행해 보았는데
하나의 통일된 StateContract 로 관리하고 있어서 연속적으로 상태가 저장되는 줄 알았지만, 데이터가 누락 된 로그가 자주 보였다.
발생하는 문제들
- 데이터 전달 누락: Arguments 전달 시 필수 데이터 누락
- 타입 안전성 부족: String 형태로 전달되어 타입 변환 오류 발생
- 상태 동기화 실패: 이전 단계로 돌아가 수정 시 상태 불일치
- 메모리 누수: 각 ViewModel이 독립적으로 생성되어 메모리 효율성 떨어짐
3.2 로그인 데이터 연속성 문제
*// 로그인에서 전달된 데이터가 모든 단계에서 필요하지만...// 각 ViewModel마다 다시 주입해야 하는 문제*
class SignUpViewModel : ViewModel() {
*// 로그인 데이터를 어떻게 받아야 할까?*
private var loginEmail: String = ""
private var loginPassword: String = ""
}
class SignUpActivityViewModel : ViewModel() {
*// 또 다시 로그인 데이터가 필요...*
private var loginEmail: String = ""
private var loginPassword: String = ""
}
3.3 API 통신 문제
*// 마지막 단계에서 회원가입 API 호출 시// 모든 단계의 데이터가 필요하지만 수집이 어려움*
class SignUpLevelViewModel : ViewModel() {
fun signUp() {
*// SignUp, SignUpActivity, SignUpDog의 데이터가 모두 필요하지만// 어떻게 가져와야 할까?*
val request = SignUpRequest(
name = ?, *// SignUp 데이터*
age = ?, *// SignUp 데이터*
location = ?, *// SignUpActivity 데이터*
dogInfo = ?, *// SignUpDog 데이터*
traits = ? *// SignUpLevel 데이터*
)
}
}
4. 해결책: 단일 ViewModel 구조
4.1 새로운 구조 개요
*// 개선된 방식: 하나의 ViewModel로 모든 단계 관리*
SignUpScreen ──────┐
SignUpActivityScreen ──┼── SignUpViewModel (단일)
SignUpDogScreen ──────┤
SignUpLevelScreen ─────┘
4.2 구현 방식
Navigation Graph 개선
*// 개선된 Navigation Graph - Nested Graph 사용*
fun NavGraphBuilder.signUpNavGraph(
navController: NavHostController,
navigateToHome: () -> Unit,
) {
navigation<SignUpFlow>(
startDestination = SignUp
) {
composable<SignUp> { backStackEntry ->
*// 상위 BackStackEntry에서 ViewModel 가져오기*
val parentEntry = remember(backStackEntry) {
navController.getBackStackEntry<SignUpFlow>()
}
val signUpViewModel: SignUpViewModel = hiltViewModel(parentEntry)
SignUpRoute(
navigateNext = { navController.navigate(SignUpActivity) },
viewModel = signUpViewModel
)
}
composable<SignUpActivity> { backStackEntry ->
val parentEntry = remember(backStackEntry) {
navController.getBackStackEntry<SignUpFlow>()
}
val signUpViewModel: SignUpViewModel = hiltViewModel(parentEntry)
SignUpActivityRoute(
navigateNext = { navController.navigate(SignUpDog) },
viewModel = signUpViewModel
)
}
composable<SignUpDog> { backStackEntry ->
val parentEntry = remember(backStackEntry) {
navController.getBackStackEntry<SignUpFlow>()
}
val signUpViewModel: SignUpViewModel = hiltViewModel(parentEntry)
SignUpDogRoute(
navigateNext = { navController.navigate(SignUpLevel) },
viewModel = signUpViewModel
)
}
composable<SignUpLevel> { backStackEntry ->
val parentEntry = remember(backStackEntry) {
navController.getBackStackEntry<SignUpFlow>()
}
val signUpViewModel: SignUpViewModel = hiltViewModel(parentEntry)
SignUpLevelRoute(
navigateNext = navigateToHome,
viewModel = signUpViewModel
)
}
}
}
단일 ViewModel 구조
@HiltViewModel
class SignUpViewModel @Inject constructor(
private val repository: OnboardingRepository,
private val regionRepository: OnboardingRegionRepository,
private val infoRepository: OnboardingInfoRepository,
) : ViewModel() {
*// 통합 상태 관리*
private val _state = MutableStateFlow(SignUpState())
val state: StateFlow<SignUpState> = _state.asStateFlow()
*// 로그인 정보 (한 번만 설정)*
private var loginEmail: String = ""
private var loginPassword: String = ""
*// SignUp: 개인정보 관리*
fun onNameChanged(name: String) {
_state.update { it.copy(name = name) }
}
fun onAgeChanged(age: String) {
_state.update { it.copy(age = age) }
}
fun selectGender(gender: Gender) {
_state.update { it.copy(selectedGender = gender) }
}
*// SignUpActivity: 지역정보 관리*
fun onGuSelected(guName: String, guId: Int) {
_state.update { currentState ->
currentState.copy(
selectedGu = guName,
selectedGuId = guId,
selectedDong = "",
selectedDongId = 0
)
}
}
fun onDongSelected(dongName: String, dongId: Int) {
_state.update { it.copy(selectedDong = dongName, selectedDongId = dongId) }
}
*// SignUpDog: 반려견 정보 관리*
fun onDogNameChanged(dogName: String) {
_state.update { it.copy(dogName = dogName) }
}
fun onDogImageSelected(uri: Uri) {
_state.update { it.copy(dogImage = uri) }
}
fun selectDogGender(dogGender: DogGender) {
_state.update { it.copy(dogGender = dogGender) }
}
*// SignUpLevel: 성향 정보 관리*
fun selectEnergyLevel(energyLevel: String) {
_state.update { it.copy(selectedEnergyLevel = energyLevel) }
}
fun selectSocialLevel(socialLevel: String) {
_state.update { it.copy(selectedSocialLevel = socialLevel) }
}
*// 로그인 정보 설정 (한 번만 호출)*
fun setLoginCredentials(email: String, password: String) {
loginEmail = email
loginPassword = password
}
*// 최종 회원가입 API 호출*
fun signUp(context: Context) {
viewModelScope.launch {
val state = _state.value
*// 모든 데이터가 한 곳에 있어서 쉽게 접근 가능*
val request = OnboardingInfoRequest(
loginId = loginEmail,
password = loginPassword,
name = state.name,
gender = when (state.selectedGender) {
Gender.MALE -> "M"
Gender.FEMALE -> "F"
Gender.UNKNOWN -> "M"
},
age = state.age.toIntOrNull() ?: 0,
regionId = state.selectedDongId,
pet = PetInfoDto(
name = state.dogName,
gender = when (state.dogGender) {
DogGender.MALE -> "M"
DogGender.FEMALE -> "F"
DogGender.UNKNOWN -> "M"
},
*// ... 기타 반려견 정보*
)
)
*// API 호출*
val result = infoRepository.postOnboardingInfo(
userId = userId.first(),
image = imagePart,
onboardingInfoRequest = request
)
*// 결과 처리*
result.onSuccess { response ->
_sideEffect.emit(SignUpSideEffect.NavigateNext)
}.onFailure { error ->
_sideEffect.emit(SignUpSideEffect.ShowSnackBar("회원가입 실패: ${error.message}"))
}
}
}
*// 각 단계별 유효성 검사*
fun isSignUpValid(): Boolean {
val state = _state.value
return state.name.isNotBlank() &&
state.age.isNotBlank() &&
state.selectedGender != Gender.UNKNOWN
}
fun isSignUpActivityValid(): Boolean {
val state = _state.value
return state.selectedGu.isNotEmpty() && state.selectedDong.isNotEmpty()
}
fun isSignUpDogValid(): Boolean {
val state = _state.value
return state.dogName.isNotEmpty() &&
state.dogBreed.isNotEmpty() &&
state.dogImage != null
}
fun isSignUpLevelValid(): Boolean {
val state = _state.value
return state.selectedEnergyLevel.isNotEmpty() &&
state.selectedSocialLevel.isNotEmpty()
}
}
통합 상태 관리
@Immutable
data class SignUpState(
*// SignUp: 개인정보*
val selectedGender: Gender = Gender.UNKNOWN,
val name: String = "",
val age: String = "",
*// SignUpActivity: 지역정보*
val selectedGu: String = "",
val selectedGuId: Int = 0,
val selectedDong: String = "",
val selectedDongId: Int = 0,
val isLocationMenuVisible: Boolean = false,
*// SignUpDog: 반려견 정보*
val dogName: String = "",
val dogGender: DogGender = DogGender.UNKNOWN,
val dogBreed: String = "",
val dogAge: String = "",
val dogImage: Uri? = null,
val isNeutered: Boolean = false,
val ageKnown: AgeKnown = AgeKnown.NONE,
*// SignUpLevel: 성향 정보*
val selectedEnergyLevel: String = "",
val selectedSocialLevel: String = "",
val petTraitCategoryList: List<PetTraitCategoryDto> = emptyList(),
*// 공통 상태*
val selectedDistrict: List<String> = emptyList(),
val selectedLocation: String = "",
)
5. 장단점 비교
5.1 다중 ViewModel 구조
장점
- 단일 책임 원칙: 각 ViewModel이 하나의 화면만 담당
- 메모리 효율성: 필요한 화면의 ViewModel만 생성
- 독립성: 각 화면이 독립적으로 개발 가능
- 테스트 용이성: 각 단계별로 독립적인 테스트 가능
단점
- 데이터 전달 복잡성: Arguments나 SavedStateHandle 필요
- 상태 동기화 어려움: 이전 단계 수정 시 상태 불일치
- 코드 중복: 유사한 로직이 여러 ViewModel에 중복
- API 통신 복잡성: 최종 단계에서 모든 데이터 수집 어려움
5.2 단일 ViewModel 구조
장점
- 상태 일관성: 모든 데이터가 하나의 상태로 관리
- 데이터 접근 용이성: 언제든지 모든 단계의 데이터 접근 가능
- API 통신 단순화: 최종 단계에서 쉽게 모든 데이터 수집
- 상태 동기화: 이전 단계 수정 시에도 상태 유지
단점
- ViewModel 비대화: 하나의 ViewModel에 많은 책임 집중
- 메모리 사용량: 전체 플로우 동안 모든 상태 유지
- 테스트 복잡성: 전체 플로우를 고려한 테스트 필요
- 의존성 증가: 하나의 ViewModel에 많은 Repository 의존
6. ViewModel 선언 위치
그럼 ViewModel은 어디쪽에서 선언헤애될까?
6.1 Parent BackStackEntry 활용
*// Navigation Graph에서 상위 Entry에서 ViewModel 선언*
composable<SignUp> { backStackEntry ->
val parentEntry = remember(backStackEntry) {
navController.getBackStackEntry<SignUpFlow>()
}
val signUpViewModel: SignUpViewModel = hiltViewModel(parentEntry)
SignUpRoute(viewModel = signUpViewModel)
}
6.2 장점
- 생명주기 관리: SignUpFlow가 살아있는 동안 ViewModel 유지
- 상태 공유: 모든 하위 화면에서 동일한 ViewModel 인스턴스 사용
- 메모리 효율성: 플로우 완료 시 자동으로 ViewModel 해제
6.3 주의사항
*// 올바른 방식: parentEntry 사용*
val parentEntry = remember(backStackEntry) {
navController.getBackStackEntry<SignUpFlow>()
}
val signUpViewModel: SignUpViewModel = hiltViewModel(parentEntry)
*// 잘못된 방식: 각 화면마다 새로운 인스턴스 생성*
val signUpViewModel: SignUpViewModel = hiltViewModel()
7. 로그인 데이터 연동
7.1 PreferenceDataStore 활용
로그인 이후에 필요한 데이터를 앱 내에서 필요할 떄 사용해야한다.
*// LoginScreen에서 로그인 성공 후 데이터 저장*
PreferenceDataStore.saveLoginInfo(
email = loginEmail,
password = loginPassword
)
*// SignUpScreen에서 데이터 읽기*
val loginInfo by PreferenceDataStore.getLoginInfo().collectAsState(
initial = PreferenceDataStore.LoginInfo("", "")
)
LaunchedEffect(loginInfo) {
if (loginInfo.email.isNotEmpty() && loginInfo.password.isNotEmpty()) {
signUpViewModel.setLoginCredentials(
email = loginInfo.email,
password = loginInfo.password
)
}
}
7.2 데이터 연속성 보장
*// ViewModel에서 한 번만 설정하면 전체 플로우에서 사용 가능*
class SignUpViewModel : ViewModel() {
private var loginEmail: String = ""
private var loginPassword: String = ""
fun setLoginCredentials(email: String, password: String) {
loginEmail = email
loginPassword = password
}
*// 최종 API 호출 시 활용*
fun signUp(context: Context) {
val request = OnboardingInfoRequest(
loginId = loginEmail, *// 로그인 화면에서 받은 데이터*
password = loginPassword, *// 로그인 화면에서 받은 데이터// ... 회원가입 단계에서 입력한 데이터들*
)
}
}
8. 실제 적용 결과
8.1 개선된 점
- 데이터 무결성: 모든 회원가입 데이터가 하나의 상태에서 관리되어 누락 방지
- 개발 효율성: 데이터 전달 로직 제거로 개발 시간 단축
- 유지보수성: 회원가입 로직이 한 곳에 집중되어 수정 용이
- 사용자 경험: 이전 단계로 돌아가서 수정해도 데이터 유지
8.2 성능 개선
*// 이전: 각 단계마다 ViewModel 생성*
SignUpViewModel() + SignUpActivityViewModel() + SignUpDogViewModel() + SignUpLevelViewModel()
*// 현재: 하나의 ViewModel로 통합*
SignUpViewModel()
8.3 코드 복잡도 감소
*// 이전: Arguments 전달 코드*
navController.navigate("signup_activity/$userName/$userAge/$userGender")
*// 현재: 단순 화면 전환*
navController.navigate(SignUpActivity)
9. 결론 및 적용 예시
9.1 언제 단일 ViewModel을 사용할 것인가?
단일 ViewModel 이 필요한 경우:
- 다단계 플로우에서 데이터 연속성이 중요한 경우
- 최종 단계에서 모든 데이터를 종합해야 하는 경우
- 이전 단계 수정이 자주 발생하는 경우
- 복잡한 상태 동기화가 필요한 경우
데이터 연속성과 마지막에 API 를 요청하는 현재 구조로썬 이쪽이 적합하다.
다중 ViewModel 권장 상황:
- 각 화면이 완전히 독립적인 경우
- 메모리 사용량이 매우 중요한 경우
- 각 화면의 비즈니스 로직이 복잡하고 분리가 필요한 경우
9.2 결론
회원가입과 같은 연속된 플로우에서는 단일 ViewModel 구조가 적합하다.
이유:
- 데이터 일관성: 모든 단계의 데이터가 하나의 상태에서 관리
- 개발 효율성: 복잡한 데이터 전달 로직 제거
- 사용자 경험: 단계 간 이동 시 데이터 유지
- 유지보수성: 비즈니스 로직이 한 곳에 집중
단, ViewModel이 비대해지지 않도록 적절한 책임 분리와 도메인 로직 분리를 통해 관리하는 것이 중요하다. (현재 ViewModel 을 보면 코드가 300~400줄인 상황..)
9.3 개선 방향
*// 향후 개선 방향: UseCase 패턴 도입*
class SignUpViewModel @Inject constructor(
private val validateUserInfoUseCase: ValidateUserInfoUseCase,
private val validateRegionUseCase: ValidateRegionUseCase,
private val validatePetInfoUseCase: ValidatePetInfoUseCase,
private val signUpUseCase: SignUpUseCase
) : ViewModel() {
*// ViewModel은 UI 상태 관리에 집중// 비즈니스 로직은 UseCase로 분리*
}
본 프로젝트에서 domain 계층을 따로 분리하지 않았기 때문에 따로, usecase를 만들어 사용하지 않았지만, 앞서 고민하던 한 ViewModel한 책임을 나누기 위한 개선방향으로 생각해보았다. 아키텍처 선택시 Domain 계층의 Usecase까지 분리해서 관리하는 구조는 처음 앱잼하는 입장으로써 많이 부담일 것 같아 구글 권장 아키텍쳐를 선택했는데, 이렇게 프로젝트를 진행해보며 코드를 짜고 구조를 잡아보니, CleanArchiteture에 대한 이론이 실제 경험으로 다가왔다.
'Android' 카테고리의 다른 글
| android- 이미지 라이브러리 Coil (1) | 2025.11.07 |
|---|---|
| Android - Paging 라이브러리 (0) | 2025.11.05 |
| 구조 분해 선언과 component 함수 (kotlin in action) (1) | 2025.06.11 |
| 안드로이드 테스트 코드 작성 + Jetpack Compose UI 테스트 (1) | 2025.06.08 |
| DI - koin & Hilt (0) | 2025.05.25 |