SingleLiveEvent, Event wrapper

chaeny·2025년 1월 26일

뷰(Activity 또는 Fragment)가 ViewModel과 통신하는 편리한 방법은 LiveData observables를 사용하는 것이다. 뷰는 LiveData의 변경 사항을 subscribe하고 이에 반응한다.
이 방식은 화면에 지속적으로 표시되는 데이터를 처리하는 데 적합히다.

그러나 일부 데이터는 단 한 번만 소비되어야 한다.
Snackbar 메시지, 네비게이션 이벤트, 다이얼로그 트리거와 같은 경우다.

이 문제를 Architecture Components에 라이브러리나 확장을 추가하여 해결하려고 하기보다는, 이를 디자인 문제로 접근해야 한다.
이벤트를 상태(State)의 일부로 취급할 것을 권장한다.

이 글에서는 일반적인 실수와 권장 접근법을 보여준다.


❌ 이벤트에 LiveData를 사용

이 접근 방식에서는 Snackbar 메시지 또는 네비게이션 신호를 LiveData 객체 내부에 직접 보관한다.
원칙적으로는 일반적인 LiveData 객체를 사용해도 괜찮아 보일 수 있지만, 실제로는 몇 가지 문제가 발생한다.

예를 들어, list/detail 앱에서 목록의 ViewModel이 있다고 가정해 보자

// Don't use this for events
class ListViewModel : ViewModel {
    private val _navigateToDetails = MutableLiveData<Boolean>()

    val navigateToDetails : LiveData<Boolean>
        get() = _navigateToDetails


    fun userClicksOnButton() {
        _navigateToDetails.value = true
    }
}

// view
myViewModel.navigateToDetails.observe(this, Observer {
    if (it) startActivity(DetailsActivity...)
})

이 접근 방식의 문제는 _navigateToDetails의 값이 오랫동안 true로 유지된다는 점이며, 이로 인해 첫 번째 화면(목록 화면)으로 돌아가는 것이 불가능해진다.

단계별로 살펴보면:
1. 사용자가 버튼을 클릭하여 Details Activity가 시작된다.
2. 사용자가 뒤로 가기 버튼을 눌러 목록 Activity로 돌아온다.
3. 목록 Activity가 백스택에서 다시 활성화되면서 LiveData의 Observers도 다시 활성화된다.
4. _navigateToDetails의 값이 여전히 true로 유지되고 있기 때문에 Details Activity가 잘못 다시 시작된다.

해결책 중 하나는 ViewModel에서 네비게이션 이벤트를 트리거한 뒤, 즉시 플래그를 false로 설정하는 것이다

fun userClicksOnButton() {
    _navigateToDetails.value = true
    _navigateToDetails.value = false // Don't do this
}

하지만, 중요한 점은 LiveData가 값을 유지하지만, 전달받은 모든 값을 반드시 방출(emit)한다고 보장하지는 않는다는 것이다.

관찰자가 활성화되지 않은 상태에서 값이 설정될 수 있다. 이 경우, 새로운 값이 기존 값을 덮어써서 이전 값이 무시될 수 있다.
다른 스레드에서 값을 설정하면 경쟁 상태(race condition)가 발생할 수 있으며, 이로 인해 observer가 단 한 번만 호출되는 문제가 생길 수 있다.

하지만 이 접근 방식의 가장 큰 문제는 이해하기 어렵고 코드가 명백히 보기 안 좋다는 점이다.

네비게이션 이벤트가 발생한 후에 값이 재설정되었는지 어떻게 보장할 수 있을까?

LiveData는 State 관리에 적합하지만, 단발성 Event 처리에는 부적합하다


❌ 이벤트에 LiveData를 사용하되, 관찰자에서 이벤트 값 재설정

이 접근 방식에서는 View에서 이벤트가 이미 처리되었음을 나타내고, 해당 이벤트를 재설정하도록 하는 방법을 추가한다.

// viewmodel에 메소드 추가
fun navigateToDetailsHandled() {
        _navigateToDetails.value = false // 이벤트 값 재설정
}


// view
listViewModel.navigateToDetails.observe(this, Observer {
    if (it) {
        myViewModel.navigateToDetailsHandled() // 이벤트 처리 후 값 재설정
        startActivity(DetailsActivity...)
    }
})

하지만 이 접근 방식의 문제는 보일러플레이트 코드(이벤트당 ViewModel에 새로운 메서드 추가)가 필요하다는 점과, 오류 발생 가능성이 있다.
특히, Observer에서 ViewModel의 메서드를 호출하는 것을 잊기 쉽다는 문제가 있다.

✔️ OK : SingleLiveEvent 사용

SingleLiveEvent 클래스는 해당 특정 시나리오에 적합한 솔루션으로 샘플용으로 만들어졌다. 업데이트를 한 번만 보내는 LiveData 이다.

class ListViewModel : ViewModel {
    private val _navigateToDetails = SingleLiveEvent<Any>()

    val navigateToDetails : LiveData<Any>
        get() = _navigateToDetails


    fun userClicksOnButton() {
        _navigateToDetails.call() // 이벤트 트리거
    }
}

//view
myViewModel.navigateToDetails.observe(this, Observer {
    startActivity(DetailsActivity...) // 이벤트 발생 시 동작
})

SingleLiveEvent는 일반적인 LiveData와 달리 값의 변경 여부가 아니라 call()이 호출되었을 때만 Observer의 콜백을 트리거

SingleLiveEvent의 문제점은 한 명의 관찰자로 제한된다. 실수로 둘 이상을 추가하면 하나만 호출되며 어느 것이 호출되는지 보장할 수 없다.

SingleLiveEvent는 단발성 이벤트를 처리하기 위해 적합한 솔루션이지만, 관찰자가 하나로 제한되는 구조적 한계를 가지고 있다. 복잡한 시나리오에서는 적합하지 않다.


이 접근 방식에서는 이벤트 처리 여부를 명시적으로 관리하여 실수를 줄인다.

/*
 * 이벤트를 나타내는 LiveData를 통해 노출되는 데이터의 래퍼로 사용
 */
open class Event<out T>(private val content: T) {

    var hasBeenHandled = false
    // 이벤트가 이미 처리됐는지(true), 아직 처리가 안됐는지(false)를 저장
    
        private set // 외부 읽기는 허용하지만 쓰기는 허용하지 않음

    // 만약 이벤트가 이미 처리됐다면 null 반환
	//아직 처리되지 않았다면 데이터 반환
    fun getContentIfNotHandled(): T? {
        return if (hasBeenHandled) {
            null
        } else {
            hasBeenHandled = true
            content
        }
    }

    // 데이터를 항상 반환
    fun peekContent(): T = content
}


// ViewModel
class ListViewModel : ViewModel {
    private val _navigateToDetails = MutableLiveData<Event<String>>()

    val navigateToDetails : LiveData<Event<String>>
        get() = _navigateToDetails


    fun userClicksOnButton(itemId: String) {
        _navigateToDetails.value = Event(itemId)  
        // 새 이벤트를 새 값으로 설정하여 이벤트를 트리거
    }
}


// view
myViewModel.navigateToDetails.observe(this, Observer {
    it.getContentIfNotHandled()?.let { 
    	// 이벤트가 처리된 적이 없는 경우에만 진행
        startActivity(DetailsActivity...)
    }
})

이 접근 방식의 장점은 사용자가 getContentIfNotHandled() 또는 peekContent()를 사용하여 의도를 명시적으로 지정해야 한다.
이 방법은 이벤트를 상태의 일부로 모델링하며, 이제 이벤트는 단순히 처리되었는지 여부를 포함하는 메시지로 취급된다.

"이벤트를 상태(State)의 일부로 설계하세요. LiveData 옵저버블에서 사용할 커스텀 Event wrapper를 만들어, 자신의 필요에 맞게 이를 조정하세요. 만약 이벤트가 많아져 반복적인 코드가 생긴다면, 이를 줄이기 위해 EventObserver를 활용하세요."

한 번만 처리할 이벤트와 항상 확인 가능한 이벤트를 구분해서 사용할 수 있다.
이벤트를 단순히 한 번 실행해야 하는 신호로만 보는 것이 아니라, State로 설계한다.
이벤트는 단순한 데이터만 전달하는 것이 아니라, 이 데이터가 이미 사용되었는지 상태도 포함한 메시지처럼 취급된다

https://medium.com/androiddevelopers/livedata


state 구독과 event 구독의 차이점

State 구독은 지속적으로 업데이트되는 데이터를 관리한다.
구성 변경 시 상태 복원이 필요하다.

Event 구독은 한 번만 실행되는 작업을 처리한다.
단발성 작업들은 이벤트로 관리하며, 한 번 처리된 이벤트는 다시 실행되지 않도록 설계한다.
구성 변경 시이벤트가 재실행되지 않아야 한다.

Event Wrapper나 SharedFlow 같은 도구를 사용해 단발성 이벤트를 처리한다

etc

https://medium.com/prnd/mvvm
https://itstory1592.tistory.com/96
https://kenel.tistory.com/123

0개의 댓글