Develop/Kotlin

안드로이드에서 일회성 이벤트 처리, 어떻게 할 것인가?

JunJangE 2024. 10. 27. 17:40

안드로이드 개발을 하다 보면, UI에서 단 한 번만 실행되어야 하는 이벤트를 처리해야 하는 상황이 종종 발생한다.
예를 들어, 사용자에게 Toast 메시지를 한 번만 띄우거나, 특정 조건이 충족되었을 때 화면 이동을 한 번만 수행해야 하는 경우가 그렇다.

하지만 안드로이드의 생명주기(Lifecycle) 특성과 ViewModel, LiveData의 동작 방식 때문에, 이 단순해 보이는 요구조차 쉽게 구현하기 어렵다.

이번 글에서는 안드로이드에서 일회성 이벤트를 처리하는 다양한 방법을 살펴보고, 각각의 장단점을 정리하려고 한다.
또한 어떤 상황에서 어떤 방법이 적절한지 함께 고민해보자.

1. LiveData

일반적으로 LiveData는 데이터가 변경될 때, 활성화된 옵저버에게만 업데이트를 전달한다.
하지만 옵저버가 비활성 상태였다가 다시 활성 상태로 전환되면, 마지막으로 활성 상태였을 때의 값이 다시 전달된다.

즉, LiveData는 옵저버가 비활성에서 활성으로 전환될 때 마지막 값을 재전달하여 최신 상태로 유지한다.

그러나 이 특성 때문에 일회성 이벤트를 처리할 때 문제가 발생할 수 있다.

문제 발생 시나리오

1. MainActivity에서 Toast를 띄우라는 UI 이벤트가 발생한다.
2. 이후 DetailActivity로 이동했다가 다시 MainActivity로 돌아온다.
3. LiveData를 Observe하고 있던 옵저버가 비활성 상태에서 다시 활성 상태로 전환되며 관찰을 재개한다.
4. 이때 이전에 발생했던 Toast 이벤트가 다시 전달되어, 의도하지 않게 Toast가 중복으로 표시되는 문제가 발생한다.

이 문제를 해결하기 위해 고안된 것이 SingleLiveEvent이다.

2. SingleLiveEvent

SingleLiveEvent는 단발성 이벤트를 한 번만 전달하고 소비할 수 있도록 설계된 LiveData 기반의 이벤트 래퍼(Event Wrapper)이다.

open class Event<out T>(private val content: T) {

    var hasBeenHandled = false
        private set

    fun getContentIfNotHandled(): T? {
        return if (hasBeenHandled) {
            null
        } else {
            hasBeenHandled = true
            content
        }
    }

    fun peekContent(): T = content
}

실제로 이 이벤트 래퍼 개념은 LiveData 공식 문서에서도 추천 자료를 통해 권장되는 방법으로 소개되었다.

하지만 SingleLiveEvent는 LiveData 위에서 구현되기 때문에, 코루틴 환경과 자연스럽게 연동하기에는 다소 제한적이다.

3. Channel

Kotlin Coroutines의 Channel을 활용하면, 단일 소비자 패턴을 기반으로 코루틴 환경에서도 안전하게 단발성 이벤트를 전달할 수 있다.
Channel은 이벤트를 보내는 쪽(send)과 받는 쪽(receive)을 비동기적으로 연결하며, SingleLiveEvent가 제공하던 단발성 이벤트 처리 기능을 그대로 유지하면서 코루틴과 자연스럽게 통합할 수 있다는 장점이 있다.

즉, SingleLiveEvent로 처리하던 단발성 이벤트를 코루틴 환경에서도 안전하고 효율적으로 구현할 수 있는 방법이 바로 Channel이다.

이전 코드

// sealed interface
sealed interface Toast {
    data object ShowToast
    data object ShowXXX
    data object ShowYYY
}

// ViewModel
private val _showToastEvent: MutableLiveData<Event<Toast>> = MutableLiveData(null)
val showToastEvent: LiveData<Event<Toast>> get() = _showToastEvent

// UI
viewModel.showToastEvent.observeEvent(this) { toastEvent ->
    when (toastEvent) {
        is Event.ShowToast -> // TODO
        is Event.ShowXXX -> // TODO
        is Event.ShowYYY -> // TODO
    }
}

이후 코드

// ViewModel
private val _showToastEvent = Channel<Effect>(Channel.BUFFERED)
val showToastEvent = _showToastEvent.receiveAsFlow()

// UI
lifecycleScope.launch {
    viewModel.showToastEvent.collect { toastEvent ->
        when (toastEvent) {
            is Event.ShowToast -> // TODO
            is Event.ShowXXX -> // TODO
            is Event.ShowYYY -> // TODO
        }
    }
}

기존의 SingleLiveEvent를 Channel로 변경하고, observe 대신 collect 방식으로 처리하면 된다.
이제 UI에서는 하나의 showToastEvent를 collect하여, 이벤트 유형에 맞게 Toast를 간단히 표시할 수 있다.

하지만 Channel만 사용하는 경우에도 주의할 점이 있다.

문제 발생 시나리오

1. ViewModel에서 서버와 통신하며 위치 데이터를 주기적으로 emit한다.
2. UI에서는 위치 데이터 변화를 감지하고, 변경될 때마다 화면을 다시 그린다.
3. 이때 사용자가 홈 버튼을 눌러 앱을 백그라운드로 전환하면, UI는 보이지 않지만 위치 데이터를 계속 감지하고 화면을 다시 그리는 문제가 발생한다.

즉, 사용자가 UI를 보고 있지 않을 때도 데이터를 계속 관찰하기 때문에 메모리 누수가 발생하게 된다.

해결 방안: repeatOnLifecycle() 활용

안드로이드에서는 repeatOnLifecycle() 함수를 사용하여 이 문제를 해결할 수 있다.

'repeatOnLifecycle()'은 Lifecycle 상태에 맞춰 코루틴을 자동으로 관리해주는 기능을 제공한다.
지정된 Lifecycle.State(보통 STARTED나 RESUMED)에 도달하면 코루틴을 실행하고, 해당 상태에서 벗어나면 자동으로 중단된다.
이 덕분에 개발자는 코루틴의 시작과 중지를 일일이 관리할 필요 없이, UI가 활성화된 동안만 안전하게 이벤트를 수집할 수 있다.

이후 코드

// UI
lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.showToastEvent.collect { toastEvent ->
            when (toastEvent) {
                is Event.ShowToast -> // TODO
                is Event.ShowXXX -> // TODO
                 is Event.ShowYYY -> // TODO
            }
        }
    }
}

그러나 Channel은 여러 구독자에게 동일한 이벤트를 전달할 수 없다는 한계가 있다.

4. SharedFlow

이때 SharedFlow를 활용하면, 코루틴 기반의 Flow를 통해 여러 구독자에게 데이터를 동시에 전달할 수 있다.
SharedFlow는 브로드캐스트 방식으로 동작하기 때문에, 여러 구독자가 동일한 데이터를 받아 처리할 수 있다.

즉, SharedFlow를 사용하면 복수 구독자에게 데이터를 브로드캐스트 방식으로 전달할 수 있으며, 라이프사이클에 의존하지 않는 이벤트 처리가 가능하다는 장점이 있다.

이후 코드

// ViewModel
private val _showToastEvent = MutableSharedFlow<Toast>()
val showToastEvent = _showToastEvent.asSharedFlow()

// UI
lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.showToastEvent.collect { toastEvent ->
            when (toastEvent) {
                is Event.ShowToast -> // TODO
                is Event.ShowXXX -> // TODO
                is Event.ShowYYY -> // TODO
            }
        }
    }
}

기존의 Channel 대신 SharedFlow로 변경하면 된다.

하지만 SharedFlow만 사용하는 경우에도 주의할 점이 있다.

문제 발생 시나리오

목록에서 특정 아이템을 선택하고, 서버 응답에 따라 상세 화면으로 이동하는 로직을 가정해보자.

1. 사용자가 목록에서 아이템을 선택한다.
2. 서버에서 해당 아이템 상태를 확인하기 전에 홈 버튼을 눌러 앱을 백그라운드로 전환한다.
3. 이때, 상세 화면으로 이동하라는 이벤트를 emit하더라도, UI는 이미 onStop() 상태이므로 이벤트를 수신하지 못한다.
4. 결국 앱으로 돌아왔을 때, 상세 화면으로 이동하지 못하게 된다.

즉, 이벤트를 관찰하고 있는 구독자가 없는 상태라면 해당 이벤트는 유실될 수 있다.

그래서 등장한 것이 EventFlow다

5. EventFlow

EventFlow는 이벤트가 발생하면 이를 내부적으로 캐시하고, 해당 이벤트가 이미 소비(consumed)되었는지 여부에 따라 새로운 구독자에게 전달할지를 결정하는 구조이다.

즉, EventFlow는 소비되지 않은 이벤트를 보관했다가, 구독자가 이를 수신할 수 있을 때 전달하는 방식으로 동작한다.

하지만 EventFlow만 사용하는 경우에도 한계가 있다.

문제 발생 시나리오

이벤트 객체가 있고, 이를 AFragment와 BFragment에서 collect하고 있다고 가정해보자.

1. 이벤트가 emit되면, 근소한 시간 차이로 AFragment에서 먼저 수신되고 소비(consumed)된다.
2. 이후 BFragment에서 이벤트를 collect하려고 하지만, 이벤트는 이미 소비되었기 때문에 전달되지 않는다.

즉, EventFlow를 사용할 경우, 여러 구독자에게 데이터를 동시에 전달할 수 있는 SharedFlow의 장점이 사라지게 된다.

6. EventFlow + HashMap

EventFlow + HashMap은 이벤트가 발생하면 이를 내부적으로 캐시하고, 이벤트가 소비(consumed)되었는지 여부에 따라 새로 구독하는 옵저버에게 전달할지를 결정하는 구조이다.

즉, 소비되지 않은 이벤트를 보관하고 있다가, 새로운 옵저버가 구독할 때 해당 이벤트를 전달하는 방식으로 동작한다.

HashMap의 역할

EventFlow + HashMap 구조에서 HashMap은 각 이벤트와 이를 소비하는 옵저버의 상태를 관리하는 데 사용된다.

키(Key) : 현재 collect 중인 옵저버의 이름과 해당 슬롯의 toString() 값을 결합하여 생성된다. 이를 통해 각 옵저버를 고유하게 식별할 수 있으며, 어떤 옵저버가 어떤 이벤트를 수신할 수 있는지를 명확히 알 수 있다.

값(Value) : 이벤트와 동일한 값을 가지는 새로운 이벤트 객체가 저장된다.

이 구조 덕분에, 새로운 옵저버가 구독할 때 이전에 발생한 이벤트도 적절히 전달될 수 있다.

정리하면

LiveData

- 구독자가 활성화될 때 마지막 값 재전달
- 단발성 이벤트 처리에는 적합하지 않음

SingleLiveEvent

- 이벤트를 한 번만 전송 가능
- 코루틴 환경과 자연스럽게 연동하기에는 다소 제한적

Channel

- 단일 소비자에게 효율적 전달
- 여러 소비자에게 이벤트 전달 불가

SharedFlow

- 여러 소비자에게 브로드캐스트 방식 전달 가능
- 구독자가 없으면 이벤트 유실 가능

EventFlow

- 소비되지 않은 이벤트 캐시 후 새로운 옵저버에 전달
- 복수 소비자 환경에서는 한계 존재

EventFlow + HashMap

- 캐시된 이벤트를 새로운 옵저버에게 안전하게 전달
- 복수 소비자 환경에서도 이벤트 관리 가능
- 다만, 구현이 다소 복잡함

결론적으로

이벤트 처리는 보통 한 곳에서 이루어지므로, 코드가 간결하고 이해하기 쉬운 Channel을 사용하는 것이 적합하다. 다만, 특별한 요구 사항이 있거나 복수 소비자를 지원해야 하는 경우에는 EventFlow + HashMap을 사용하는 것이 더 적절하다.

참고문헌

 

UI 이벤트  |  App architecture  |  Android Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. UI 이벤트 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. UI 이벤트는 UI 레이어에서 UI 또는 ViewModel로

developer.android.com

 

LiveData 개요  |  App architecture  |  Android Developers

LiveData를 사용하여 수명 주기를 인식하는 방식으로 데이터를 처리합니다.

developer.android.com

 

LiveData with SnackBar, Navigation and other events (the SingleLiveEvent case)

2021 Update: This guidance is deprecated in favor of the official guidelines.

medium.com

 

ViewModels and LiveData: Patterns + AntiPatterns

A collection of patterns and recommendations that we’ve been collecting since we released the first alpha version of the Architecture Components.

medium.com

 

MVVM의 ViewModel에서 이벤트를 처리하는 방법 7가지

ViewModel의 이벤트 처리를 어떻게 하고 계신가요? 헤이딜러에서 LiveData -> SingleLiveData -> SharedFlow -> EventFlow -> Channal로 이벤트 처리 방법을 변화 하기까지 과정을 소개합니다

medium.com