Notice
Recent Posts
Recent Comments
Link
Tags
more
관리 메뉴

주완 님의 블로그

회원가입 플로우 아키텍처 분석: 다중 vs 단일 ViewModel , Navigation구조 본문

Android

회원가입 플로우 아키텍처 분석: 다중 vs 단일 ViewModel , Navigation구조

vvan2 2025. 8. 27. 17:53

<앱잼 진행시 생각한 의문들>

해당 프로젝트에서 회원가입 플로우는 총 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 로 관리하고 있어서 연속적으로 상태가 저장되는 줄 알았지만, 데이터가 누락 된 로그가 자주 보였다.

 

발생하는 문제들

  1. 데이터 전달 누락: Arguments 전달 시 필수 데이터 누락
  2. 타입 안전성 부족: String 형태로 전달되어 타입 변환 오류 발생
  3. 상태 동기화 실패: 이전 단계로 돌아가 수정 시 상태 불일치
  4. 메모리 누수: 각 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 개선된 점

  1. 데이터 무결성: 모든 회원가입 데이터가 하나의 상태에서 관리되어 누락 방지
  2. 개발 효율성: 데이터 전달 로직 제거로 개발 시간 단축
  3. 유지보수성: 회원가입 로직이 한 곳에 집중되어 수정 용이
  4. 사용자 경험: 이전 단계로 돌아가서 수정해도 데이터 유지

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 구조가 적합하다.

이유:

  1. 데이터 일관성: 모든 단계의 데이터가 하나의 상태에서 관리
  2. 개발 효율성: 복잡한 데이터 전달 로직 제거
  3. 사용자 경험: 단계 간 이동 시 데이터 유지
  4. 유지보수성: 비즈니스 로직이 한 곳에 집중

단, 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에 대한 이론이 실제 경험으로 다가왔다.