[Android] ViewModel init 블록을 사용하면 안되는 이유 (feat. 테스트 코드)

김준영·2024년 7월 28일
0

Android

목록 보기
15/17
post-thumbnail

서론


Medium에 올라오는 글중 흥미로운 글을 발견했습니다
바로 Mastering Android ViewModels 시리즈물 중 첫번째인 ViewModel에 init{} 블록을 이용한 초기화를 하지마라라는 글인데요

솔직히 처음봤을땐 의아했습니다
해당 포스터를 자세히 보고싶으신 분들인 원본글을 참고해주세요 🤓

쓰라고 만든건데 왜 안됨?

너무 편하게 사용하던 기능이라 솔직히 그냥 이렇게 할수도 있구나하고 넘겼습니다
하지만 얼마후 해당 글이 문득 상기되는 순간이 왔습니다

init{} 블록을 지양해야 하는 이유

ViewModel 생성과의 강한 결합

흔히 게시글 화면이나 어떠한 정보를 가져오는 화면에대한 ViewModel에서
해당 초기화면의 데이터를 로드하기 위해 init{} 블록을 자주 사용하는 경우를 많이 볼 수 있습니다

하지만 이러한 방법은 ViewModel 생성과의 강한 결헙, 테스트 어려움, 유연성 제한 등등... 여러 문제를 야기 할 수 있다고 합니다.

테스트하기 어렵다

저는 이 부분에서 해당 글이 상기됬는데요 해당 상황은 이어지는 글에 자세히 기술하겠습니다!

유연성 제한

ViewModel이 인스턴스화 되자마자 데이터 로드를 자동으로 시작하면 다양한 사용자 흐름이나 UI 상태를 처리하는 유연성이 제한된다고 합니다
예를들어 특정 사용자 권한이 부여될때까지 데이터를 가져오는 것을 지연하는 경우를 들 수가 있다고 합니다

테스트 코드에서 마주친 init{} 블록의 문제점

로그인 관련 함수에 대한 테스트 코드를 작성하다 init관련 오류를 마주치게되었습니다

[테스트 코드]

@OptIn(ExperimentalCoroutinesApi::class)
    @Test
    fun `startKakaoLogin는 UseCase, checkMember, setUserData 호출해야함`() = runTest {
        // Given
        val userNum = "123"
        coEvery { kakaoLoginUseCase(any(), any()) } answers {
            secondArg<(String?) -> Unit>().invoke(userNum)
        }
        coEvery { userDataRepository.setUserData("num", userNum) } just Runs
        coEvery { checkMemberUseCase(userNum) } returns Result.success(true)

        // When
        viewModel.startKakaoLogin()

        // setUserData(코루틴 작업)이 끝날 때까지 대기
        advanceUntilIdle()

        // Then
        coVerify { kakaoLoginUseCase(any(), any()) }
        coVerify { userDataRepository.setUserData("num", userNum) }
        coVerify { checkMemberUseCase(userNum) }
    }

[오류]

getUserData를 보고 한참을 고민했습니다
startKakaoLogin 함수에는 UserData리포지토리에 존재하는 getUserData를 사용하지 않았기 때문입니다.

그러다가 뷰모델을 천천히 보고있는 도중

@HiltViewModel
class LoginViewModel @Inject constructor(
    (...)
): BaseViewModel<LoginContract.Event, LoginContract.State, LoginContract.Effect>() {
    private var userNumber = "" // 회원가입 하는 경우 사용

    init {
        initSetting()
    }
    
    (...)

    fun initSetting(){
        viewModelScope.launch {
            if(userDataRepository.getUserData().userNum.isNotBlank()){
                setState { copy(loginState = LoginState.LOADING) }
                isMember(userDataRepository.getUserData().userNum)
            }
        }
    }

자동로그인을 위해 설정해놓은 init 블록이 눈에 들어왔습니다

곰곰히 생각해보니 테스트 코드에 뷰모델 인스턴스를 만들자마자 getUserData()를 호출하게 구현해두었지만 테스트 코드에는 호출하는 코드가 존재하지 않았기 때문에 해당 오류가 발생했다는 것을 알 수 있었습니다

그럼 init블록을 사용하지 않고 어떻게 자동로그인을 구현하지?

방법은 그렇게 어렵지 않았습니다 (MVI 패턴으로 구현했습니다)

@Composable
fun LoginScreenDestination(
    context: Context,
    navController: NavController,
    viewModel: LoginViewModel = hiltViewModel()
) {
    LaunchedEffect(viewModel.viewState.value) {
        if(viewModel.viewState.value.loginState == LoginState.BLANK) viewModel.initSetting()
    }
    LoginScreen(...)
}

최초 상태를 BLANK로 해놓았기 때문에
LaunchEffect를 사용해 BLANK라면 init 블록에 사용한 함수를 호출하는 것으로 해결했습니다!

이제 테스트 코드가 정상적으로 빌드됨을 확인할 수 있었습니다!

결론

테스트 코드에 대한 불편함때문에 init{} 블록 사용을 지양해야하기엔 무리가 있어보이지만 이번 상황을 겪으면서 뷰모델 인스턴스를 만들자마자 비동기작업이나 기타작업을 한다면 예상치 못한 흐름을 가져갈 수 있을 것 같다는 생각을 하게되었습니다!

init{} 블록의 장점도 분명 존재하지만 꼭 사용해야하는 플로우가 아니라면 지양하는 것이 좋다고 느꼈습니다

참고

https://proandroiddev.com/mastering-android-viewmodels-essential-dos-and-donts-part-1-%EF%B8%8F-bdf05287bca9

profile
Android, Flutter를 공부하고 있습니다🧐

0개의 댓글

관련 채용 정보