마스터Q&A 안드로이드는 안드로이드 개발자들의 질문과 답변을 위한 지식 커뮤니티 사이트입니다. 안드로이드펍에서 운영하고 있습니다. [사용법, 운영진]

Navigation Component 로그인 상태변화 질문2

0 추천

클린 아키텍쳐를 검색해서 읽어보고 패키지를 처음부터 다시 정리해보려고 하면서 코드도 천천히 수정해 나가려고 하고 있는데요, 와중에 LiveData를 활용해서 Login의 상태변화를 관차할때 좀 막히는 부분이 있어서 질문드립니다.

CalendarFragment를 startDestination으로 설정하고 앱시작시 여기서 로그인 상태를 검사합니다.

값이 없으면 LoginFragment로 가서 로그인을 진행합니다.

CalendarFragment에서는 크게 문제가 없는것같은데 LoginFragment에서 

로그인버튼을 누르면 LiveData를 이용해서 상태를 관찰을 하게 되는데 자꾸 두번(?) 진행하게됩니다. 

현재 로그인 버튼 클릭 리스너 내부에 뷰모델을 observe하도록 설정해놨습니다(이부분은 개발자 문서보고 했습니다..) 그러니까 로그인을 하기위해 버튼을 누르면 뷰모델을 observe하겠죠. 이때 문제가 있는데 버튼을 클릭하면 UNAUTHENTICATED 구문이 진행되고 AUTHENTICATED 구문이 진행됩니다.

정상적으로라면 아이디비밀번호 값이 정확하다면 AUTHENTICATED 구문만 진행되어야하죠.

로그인버튼 클릭리스너내에서 observe 하도록 설정해놨다고 했는데 일반적인 방법대로

observe 코드를 따로 분리하여 진행도 해보았습니다.

이경우에는 앱실행시 CalendarFragment에서 로그인 상태를 검사하고 

LoginFragment로 넘어왔을때 바로 UNAUTHENTICATED구문이 한번 진행되었습니다.

그런데 이 경우에는 로그인 버튼을 누르면 올바르게 AUTHENTICATED 구문을 바로 실행하더라구요.

왜 이런걸까요? 그리고 어떻게 해야할까요?

 

 

+) 그리고 클린아키텍쳐를 사용한 코드들을 보니까 Repository에도 LiveData를 두고 ViewModel에서도

LiveData를 두어서 뷰모델에서 Repository의 LiveData를 참조하는 방식으로 사용하던데

Repository에 LiveData를 두어도 상관이 없나요?

Repository

class AuthenticationRepository {
    private val databaseRef: DatabaseReference =
        FirebaseDatabase.getInstance().getReference("LightWeight")    // 실시간 데이터 베이스
    private val firebaseAuth: FirebaseAuth = FirebaseAuth.getInstance()
    private val userLiveData: MutableLiveData<FirebaseUser?> = MutableLiveData<FirebaseUser?>()

    fun getUserData() : MutableLiveData<FirebaseUser?> {
        userLiveData.postValue(firebaseAuth.currentUser)
        userLiveData.postValue(null) // 한번 로그인하면 계속 user 값이 있기때문에 임시방편으로 null로 강제 설정
//        firebaseAuth.signOut()
        return userLiveData
    }
    fun login(email: String, pwd: String) {
        firebaseAuth.signInWithEmailAndPassword(email, pwd)
            .addOnCompleteListener { task ->
                if(task.isSuccessful) { // 로그인 성공
                    userLiveData.postValue(firebaseAuth.currentUser)
                }
                else {

                }// 실패
            }
    }
}

ViewModel

class LoginViewModel : ViewModel() {
    private val repository: AuthenticationRepository = AuthenticationRepository()
    val authenticationState = repository.getUserData().map { user ->
        if (user != null) {
            AuthenticationState.AUTHENTICATED
        } else {
            AuthenticationState.UNAUTHENTICATED
        }
    }

    sealed class AuthenticationState {
        object AUTHENTICATED: AuthenticationState()
        object UNAUTHENTICATED : AuthenticationState()
    }
    
    fun login(email: String, pwd: String) : LiveData<AuthenticationState> {
        repository.login(email, pwd)
        return authenticationState
    }
}

 

CalendarFragment

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        loginVM.authenticationState.observe(viewLifecycleOwner) { authState ->
            when(authState) {
                LoginViewModel.AuthenticationState.AUTHENTICATED ->
                    Toast.makeText(context, "로그인 되어있음",Toast.LENGTH_SHORT).show()
                else -> {
                    Toast.makeText(context, "로그인 안되어",Toast.LENGTH_SHORT).show()
                    findNavController().navigate(R.id.login_nav)
                }
            }
        }
    }

 

LoginFragment

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)

    // 이전 destination(CalendarFragment)의 BackStackEntry에 접근하여 결과 설정
    savedStateHandle = findNavController().previousBackStackEntry!!.savedStateHandle
    savedStateHandle.set(LOGIN_SUCCESSFUL, false)
    
    binding.btnLogin.setOnClickListener { _ ->
        login()
    }
}

private fun login() {
    //로그인 요청
    var email: String = binding.etId.text.toString()
    var pwd: String = binding.etPwd.text.toString()

    loginVM.login(email, pwd).observe(viewLifecycleOwner) { test ->
        when(test) {
            LoginViewModel.AuthenticationState.AUTHENTICATED -> {
                savedStateHandle.set(LOGIN_SUCCESSFUL, true)
                findNavController().popBackStack()
                Toast.makeText(context, "성공", Toast.LENGTH_SHORT).show()
            }
            LoginViewModel.AuthenticationState.UNAUTHENTICATED ->
                Toast.makeText(context, "실패", Toast.LENGTH_SHORT).show()
        }
    }
}

 

codeslave (3,940 포인트) 님이 2022년 1월 31일 질문
LiveData를 Repository에 둘지 말지는 구현하는 사람의 선호사항입니다. 엄격하게 따지자면 LiveData는 Android framework의 일부이므로 가능하면 Repository에는 놓지 않는 것이 좋지만, Reoistory에서 LiveData를 사용하는 주된 이유가 데이터 스트림을 지원하기 위한 겁니다. 한예로, 로걸 캐쉬가 존재할 때, 로컬 캐쉬에 데이터가 있으면 이걸 리턴하고 그사이 백엔드 API를 호출해서 응답이 올 때 데이터를 리턴해 주는 경우가 대표적입니다. 이 동작이 하나의 흐름으로 처리되어야 하는데, LiveData가 이걸 해줄 수있었기 때문에 사용하는 경우가 많습니다. 그런데 Coroutine이 Flow를 지원하고 있기 때문에 Repository에서 LiveData보다는 Flow를 사용하는 것이 좀 더 맞지않을가 생각합니다. 그렇다고 딱히 잘못되었다고 말하기는 좀 그렇습니다만, Flow가 더 나은 선택인 건 맞습니다.
LoginViewModel을 CalendarFragment와 LoginFragment에서 공유하도록 하시고 계시죠? LoginRepository가 Live를 사용하기 때문에 안그래도 될 것 같기도 하고, 테스트를 해봐야 정확한 동작을 알 수 있겠네요.

2개의 답변

0 추천

LoginFragment도 CalendarFragment처럼 LoginViewModel.authenticaitonState를 observe하게 바꿔보시겠어요?

// LoginFragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
 
        loginVM.authenticationState.observe(viewLifecycleOwner) { authState ->
            when(authState) {
                LoginViewModel.AuthenticationState.AUTHENTICATED ->
                    savedStateHandle.set(LOGIN_SUCCESSFUL, true)
                    findNavController().popBackStack()
                else -> {
                    Toast.makeText(context, "로그인 안되어",Toast.LENGTH_SHORT).show()
            
                }
            }
        }
    }

private fun login() {
    //로그인 요청
    var email: String = binding.etId.text.toString()
    var pwd: String = binding.etPwd.text.toString()
 
    loginVM.login(email, pwd)
}



class LoginViewModel : ViewModel() {
    ...
     
    fun login(email: String, pwd: String){
        repository.login(email, pwd)
    }
}

 

spark (224,800 포인트) 님이 2022년 1월 31일 답변
spark님이 2022년 1월 31일 수정
네 activityViewModels()를 사용하고 있는것 맞습니다. 아래코드는 아직 시도안해봤고 LoginFragment도 authenticaitonState를 observe하게 바꿨습니다.. 여전히 똑같은 문제가 발생하네요.
앱실행 -> CalendarFragment 진입 -> 로그인 상태 검사 -> 안되어있으니 토스트 메시지 띄움 -> LoginFragment 진입 -> 바로 Toast 메시지 "실패" 띄움 -> 로그인버튼 클릭 ->  토스트 메시지 "성공" -> CalendarFragment로 이동
이렇게 되네요.. FirebaseUser LiveData를 map해서 새로운 AuthState LiveData를 만들어내는게 잘못된건지..

그리고 이건 Repository에 LiveData를 둬도 되냐에 관한 답변에 대한 말입니다만, 혹시나 제가 뭐 변심해서 Repo에 두지않고 다시 ViewModel로 옮긴다면..
그때넌 ViewModel에 있는 AuthenticationState 클래스를 따로 파일화 시켜서 Repo에서 FirebaseUser 데이터와 AuthenticationState만 사용하는 식으로해서 리턴? 해야겠네요.. 아니면 AuthenticationState 클래스는 그냥 ViewModel 두고 지금처럼 map을 FirebaseUser 유무에 따른 방식으로 사용하던지요..
0 추천

해당 코드를 간단하게 테스트 해봤습니다. 상태값은 UNAUTHENTICATED와 AUTHENTICATED 두가지로 나누어도 될 수 있지만, 이렇게 하면 에러처리가 문제가 될 수 있어서 아래처럼 상태값을 세가지로 두었습니다.

sealed class AuthState {
    object LoggedOut: AuthState()
    data class Success(val user: FirebaseUser?) : AuthState()
    data class Error(val e: Exception) : AuthState()
}

그리고 AuthenticationRepository 에서 이 값을 사용합니다.

class AuthenticationRepository {
    private val firebaseAuth: FirebaseAuth = FirebaseAuth.getInstance()
    private val authStateLiveData = MutableLiveData<AuthState>(AuthState.LoggedOut)
    val authState: LiveData<AuthState> get() = authStateLiveData

    fun login(email: String, pwd: String) {
        firebaseAuth.signInWithEmailAndPassword(email, pwd)
            .addOnCompleteListener { task ->
                if (task.isSuccessful) {
                    authStateLiveData.postValue(AuthState.Success(firebaseAuth.currentUser!!))
                } else {
                    authStateLiveData.postValue(AuthState.Error(task.exception!!))
                }
            }
    }

    suspend fun logout() = withContext(Dispatchers.IO) {
        firebaseAuth.signOut()
        authStateLiveData.postValue(AuthState.LoggedOut)
    }
}

그리고 Singleton을 사용해도 되기에 간단하게 object class를 만들어 Singleton을 사용할 수 있도록 했습니다. 이제 AuthenticationRepository는 RepositoryModule.authRepo로 접근하여 가져옵니다.

object RepositoryModule {

    val authRepo by lazy { AuthenticationRepository() }

}

LongViewModel은 다음처럼 변경합니다.

...
import kotlinx.coroutines.NonCancellable


class LoginViewModel(
    private val repository: AuthenticationRepository = RepositoryModule.authRepo
) : ViewModel() {
    val authState: LiveData<AuthState> get() = repository.authState

    fun login(email: String, pwd: String) {
        repository.login(email, pwd)
    }

    fun logout() {
        viewModelScope.launch(NonCancellable) {
            repository.logout()
        }
    }
}

 

spark (224,800 포인트) 님이 2022년 1월 31일 답변
선생님 짜신 코드는 제 방법인 repository.getUserData().map {} 을 해서 새로운 AuthState 타입의 LiveData를 만드는 방식이 아니라 바로 AuthState를 관리하는 sealed  클래스를 만들고 거기에  FirebaseUser 값을 보내서 관리하는 방법이네요.. 이렇게하면 map을 사용안해서 따로 LiveData<AuthState>를 안만들어도되고 더 간결해지는것 같네요..

그런데 궁금한게 있습니다.
authStateLiveData.postValue(AuthState.Success(firebaseAuth.currentUser!!) 이 코드에서 AuthState.Success(firebaseAuth.currentUser!!)를 진행하는데
여기서 currentUser가 보장되는가가 궁금합니다. !!이 붙여져있어서 null이 아님을 확신?한다는 것이지만,

이전 코드에서는 currentUser가 null 여부를 검사하고 그에 맞는 값을 설정을 했는데, 이건 AuthState.Success에 null여부와 관계없이 바로 firebaseAuth.currentUser를 한다면 만약 값이 null 일때도 Success가 되겠죠.
물론 !! 때문에 에러가 날것입니다.
이것은 그냥 무조건 null이 아니기에 이렇게 해도 되는것인가요?
질문하신 부분은 님이 다시한번 체크하실 내용입니다. 상식적으로 로그인이 성공했는데 currentUser == null 이 된다면, 제가 보기에는 FirebaseAuth 버그입니다. 왜냐하면 FireAuth문서에 currentUser == null 이면 로그아웃된 상태라고 나와있으니까요. 상식적으로도 그렇게 생각이 들구요. 만약 정말 그런 상황이 온다면, 에러로 처리 처리하실 수도 있겠죠.
개인적으로는 FireAuth API 디자인이 nullable 타입을 사용해서 코틀린에서 사용할 때는 좀 문제가 되는 것 같습니다. 명료하게 status를 같는 enum타입 같은 것을 가지고 처리하면 좋았을 텐데 말이죠. 위처럼 Java와 Kotlin의 interoperation시 null 타입이 아닌테 null이 넘어올 때는 !!나 requiredNull 쓰는게 좋습니다. 그래도 이게 꺼림직하시면 null이 들어오면 에러를 리턴하세요. authStateLiveData.postValue(AuthState.Error(FirebaseAuthBugException("말도 안돼는 FirebaseAuth 버그."))
그리고 님의 경우는 굳이 Repository에 LiveData를 쓰지 않고 로그인 상태값만 가지고 계셔도 됩니다.

class AuthenticationRepository(
    private val firebaseAuth: FirebaseAuth = FirebaseAuth.getInstance(),
    private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) {
    private var authState: AuthState = AuthState.LoggedOut

    fun getAuthState(): AuthState = authState
    
    suspend fun login(email: String, pwd: String) :  AuthState {
        // 요구사항에 따라 필요한 로직 추가
        if (getAuthState() is AuthState.Success) {
            return authState
        }

        return suspendCoroutine { continuation ->
            firebaseAuth.signInWithEmailAndPassword(email, pwd)
                .addOnCompleteListener { task ->
                    val state = if (task.isSuccessful) {
                        AuthState.Success(firebaseAuth.currentUser!!)
                    } else {
                        AuthState.Success(AuthState.Error(task.exception!!))
                    }
                    
                   authState = state
                   continuation.resumeWith(state)
               }
           }
       }
    }
 
    suspend fun logout() :  AuthState = withContext(dispatcher) {
        firebaseAuth.signOut()
        AuthState.LoggedOut.also {
            authState = it
        }
    }
}

suspendCoroutine를 사용하시면 콜백을 suspend function으로 바꿀 수가 있습니다. 위처럼 authState를 메모리 또는 storage에 저장하시고,  ViewModel에서는 먼저 getAuthState로 현재상태를 체크해서 처리하면 됩니다. 이렇게 하면 LiveData에 굳이 의존할 필요가 없죠. 물론 이경우는 authState를 저장하는 로직을 별도의 클래스에서 글로벌한 상태가 유지되도록 처리를 해주어야 합니다. authState를 saveStateHandle이나 파일같은 곳에 보관한다던가 하는.
아직 문제도 해결못한터라.. 차근차근 코루틴쪽은 아직 정말 이해가 잘안되고 적용시켜본적이 없어서요 ㅠ 지금 개념부터 읽어고보고 있는데 어렵네요 ㅎㅎ.. 감사합니다
...