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

Ted Park
PRND
Published in
14 min readSep 6, 2021

--

지금 개발하시는 코드에서 ViewModel의 이벤트 처리를 어떻게 하고 계신가요?
헤이딜러에서 LiveData -> SingleLiveData -> SharedFlow -> EventFlow로
이벤트 처리 방법을 변화 하기까지 과정을 소개합니다.
여러분은 어느 단계의 코드를 사용하고 계신가요?

안녕하세요
헤이딜러 안드로이드팀의 박상권입니다.

안드로이드에서 MVVM을 사용하는 것은 국룰이 되어 가고 있습니다.
그렇기 때문에 ViewModel을 당연히 사용하게 되었죠

Google의 Android Architecture Blueprints도 어느덧 모든 코드가 MVVM로 변경되었습니다.
(MVP 안녕..👋🏻)

각 프로젝트에서 ViewModel의 클릭이벤트나 여러가지 이벤트처리를 위해 많은 형태로 사용합니다.

지금 개발하시는 코드에서 ViewModel의 이벤트 처리를 어떻게 하고 계신가요?

이 글을 읽기전 현재 각자의 프로젝트에서 ViewModel 이벤트처리 방법을 한번 확인하시고 비교해보시면서 읽어보시면 더욱더 재밌고 유익할겁니다.

헤이딜러에서는 LiveData를 사용하는것을 시작으로 현재는 EventFlow를 사용해서 ViewModel의 이벤트처리를 하고 있습니다.

이벤트 처리 변경 역사를 함께 살펴보시면서
각자의 코드는 현재 몇번째 Step에 있는지 점검해보시고 더 발전된 코드로 향상 시켜보시기 바랍니다.

TL;DR

GitHub 프로젝트를 clone하셔서 직접 실행해보세요
최종버전만 궁금하시다면 step6의 코드를 보시면 됩니다.

STEP 1

LiveData + Event

ViewModel에서 LiveData를 사용해서 이벤트를 처리하면 이벤트가 한번만 발생시키는것이 아니라
observe할때마다 직전의 값이 발생하는 중복의 문제가 있습니다.

  1. ListActivity에서 Toast를 띄우라는 event 날림
  2. DetailActivity로 들어갔다가 다시 돌아옴
  3. LiveData를 Observe하고있던 Observer는 inactive되었다가 다시 active되면서 observe시작
  4. 1번의 Toast띄우라는 직전의 Event가 다시 날라옴
    (LiveData는 observe할때 항상 마지막 값을 emit함)
  5. 의도하지 않고 Toast가 발생하는 문제 발생

그래서 Event라는 1번만 emit하고 consume시키는 Event Wrapper개념을 만들어서 LiveData와 함께 사용하는것으로 해결합니다.

이 개념은 Google 직원이 포스팅하면서 많이들 알고 계시고 이렇게 사용하고 계실겁니다.

이 Event Wrapper 개념은 Google의 샘플 코드에서도 쓰일만큼 널리 알려진 패턴입니다.

그래서 저희 회사도 마찬가지 방법으로 LiveData + Event 패턴을 사용해서 이벤트 처리를 해오고 있었습니다.

STEP 2

SingleLiveData

STEP1의 방법을 사용하면서 매번 LiveData<Event<XXX>> 로 사용하는건 너무 귀찮았습니다.

그래서 이 Event Wrapper와 LiveData를 조합한 별도의 SingleLiveData라는 개념을 만들었습니다.

SingleLiveData를 사용해서 코드를 좀더 간결하게 작성할 수 있게 되었습니다.

🤦🏻‍♂️ 이전

private val _showToastEvent = MutableLiveData<Event<String>>()
val showToastEvent: LiveData<Event<String>> = _showToastEvent

🙆🏻‍♂️ 이후

private val _showToastEvent = MutableSingleLiveData<String>()
val showToastEvent: SingleLiveData<String>() = _showToastEvent

그렇게 한동안 저희는 SingleLiveData라는 개념을 사용해왔습니다.

STEP 3

SharedFlow

저희 프로젝트 코드는 Clean Architecture 패턴으로 구현되어 있고
모든 layer를 module로 나누어서 관리중입니다.
(헤이딜러의 Clean Architecture와 관련된 글도 추후 작성하겠습니다.)

원칙적으로 ViewModel은 presentation layer에 위치하고 있으므로 특정 플랫폼과의 관계가 없어야 합니다.🙅🏻

즉, import android.xxx 인 코드가 없도록 유지되어야 한다는 뜻입니다.

이와 관련된 내용은 안드로이드 공식 블로그 글에도 작성되어 있습니다.

그래서 android 패키지에 해당하는 LiveData를 ViewModel에서 사용하는것이 너무나 마음에 안 들었습니다.
하지만 대체할 수 있는 대안이 없었기에 SingleLiveData는 예외로 하여
꾸욱 참고 사용하고 있었습니다.🤦🏻‍♂️

그러던중 StateFlow, SharedFlow 를 만났습니다.

코루틴을 사용하신다면 Flow를 많이 사용하실겁니다.

결론적으로 LiveData -> StateFlow, SingleLiveData -> SharedFlow 로 대체할 수 있었습니다.

ViewModel은 LiveData(안드로이드 프레임 워크) 종속성으로부터 벗어날 수 있게 되는 것입니다.

기존에 SingleLiveData를 SharedFlow로 변경하고 observe대신 collect하는 코드로만 변경해주면 됐었습니다.

🤦🏻‍♂️ 이전

// VieWModel
private val _showToastEvent = MutableSingleLiveData<String>()
val showToastEvent: SingleLiveData<String> = _showToastEvent

// UI
viewModel.showToastEvent.observe { text ->
// TODO

}

🙆🏻‍♂️ 이후

// ViewModel
private val _showToastEvent = MutableSharedFlow<String>()
val showToastEvent = _showToastEvent.asSharedFlow()
// UI
lifecycleScope.launch {
viewModel.showToastEvent.collect { text ->
// TODO

}
}

STEP 4

SharedFlow + Sealed class

처리해야 할 Event가 3개인 경우,
기존에는 각각 3개의 SharedFlow를 만들어줬었습니다.

private val _showToastEvent = MutableSharedFlow<String>()
val showToastEvent = _showToastEvent.asSharedFlow()
private val _aaaEvent = MutableSharedFlow<String>()
val aaaEvent = _aaaEvent.asSharedFlow()
private val _bbbEvent = MutableSharedFlow<Int>()
val bbbEvent = _bbbEvent.asSharedFlow()

또한 각각의 Event를 collect하는 코드도 3벌을 작성해야 했죠.

이것은 너무나 귀찮은 일이었습니다.🤷🏻‍♂️

그래서 Event를 전파하는 단 1개의 eventFlow만을 만들고
이 Event를 Sealed class형태로 만들어서 상황에 맞게 처리하도록 개선하였습니다.

UI에서는 1개의 eventFlow를 collect하고 Event유형에 맞게 처리하도록 분기하기만 하면 됩니다.

lifecycleScope.launch {
viewModel.eventFlow.collect { event -> handleEvent(event) }
}
...private fun handleEvent(event: Event) = when (event) {
is Event.ShowToast -> // TODO
is Event.Aaa -> // TODO
is Event.Bbb -> // TODO
}

STEP 5

SharedFlow + Sealed class + Lifecycle

하지만 STEP4의 방식도 문제가 있었습니다.

문제가 되는 예시 상황

  1. ViewModel에서 서버와 통신하면서 위치데이터를 주기적으로 emit
  2. UI에서는 위치데이터가 변경되는것을 감지하고 있다가 변경될때마다 화면에 새로 그림
  3. 홈버튼을 눌러서 화면이 Background에 있을때는 화면에 새로 그릴 필요 없는데 어쩌지?
  4. UI가 안보이고 있을때는 데이터를 observe하고 있을 필요 없잖아?

이 문제의 가장 원시적이지만 확실한 해결 방법은
onStart()에서 collect를 시작하고, onStop()에서 cancel 하는 것이었습니다.

이 고민을 하던 그때!! 바로 그 시점에!!

Lifecycle에서 repeatOnLifecycle() 이라는 함수가 추가되었습니다.🎉🎉
(lifecycle:lifecycle-runtime-ktx:2.4.0-alpha01 이상 버전부터 사용 가능)

repeatOnLifecycle()을 사용하면 번거롭게 OnStart()/onStop()에서 작성을 하지 않아도
Lifecycle에 맞게 알아서 collect/cancel을 반복 해주었습니다.

저희는 추가로 repeatOnStarted() 라는 확장함수를 만들어서 더 간결하게 사용될 수 있도록 했습니다.

그렇게 변경된 코드는 아래와 같이 사용하게 됩니다

STEP 6

EventFlow + Sealed class + Lifecycle

그렇게 그들은 행복하게 오래오래 개발하며 살았습니다..
였다면 좋겠지만 또 다른 문제를 만나게 되었습니다.

목록에서 특정 item을 선택하면, 서버에서 상태를 체크한뒤 상세화면을 실행하는 시나리오
  1. 만약, 특정 item을 선택한뒤
  2. 서버 상태 체크가 끝나기전 홈버튼을 눌러 앱이 백그라운드로 내려갔다면
  3. 상세화면을 실행하는 event를 emit해도 onStop() 상태이기 때문에 이벤트를 받지 못함

즉, event를 observe하고 있는 곳이 아무데도 없다면,
해당 event는 유실되어서 event가 발생했던것을 나중에라도 알 수 없다는 것입니다.

그래서 Event가 발생했을때 이를 캐시하고 있다가
event의 consume 여부에 따라서 새로 구독하는 observer가 있을때 event를 전파할지 여부를 결정해주는 별도의 EventFlow를 만들었습니다.

(복잡해보이지만 대략 consume되지 않은 Event 1개를 캐시하고 있다는 뜻)

private val _eventFlow = MutableEventFlow<Event>()
val eventFlow = _eventFlow.asEventFlow()

잘 동작하는지는 아래와 같이 테스트해볼 수 있습니다.

  1. 버튼을 클릭했을때 임의로 2초뒤에 event를 발생시키도록 delay
  2. 버튼을 클릭하자마자 홈버튼을 눌러 백그라운드로 보내서 onStop() 만듦
  3. 다시 화면에 들어왔을때 pending하고 있던 event를 잘 전파하는지 확인

어때요? 참 쉽죠?

STEP1 ~ STEP6의 모든 코드 변화과정을 아래 GitHub에서 확인해보실 수 있습니다.

지금까지 헤이딜러 ViewModel Event처리의 과거와 현재를 공유해드려보았습니다.

지금까지 그래왔던 것처럼 지금의 코드도 완벽할것이라고 생각하지 않습니다.
늘 그래왔던것처럼 예상하지 못했던 이슈는 생길것이고
우리는 또한 해결하고 발전해 나갈것입니다.

여러분의 프로젝트에서의 Event처리는 어떤가요?
소개드렸던 STEP중 몇번째 STEP에 계신가요?

글에서 소개드렸던 내용이외에 공유해주실만한 팁이나 Event처리 방법이 있으시다면 댓글로 공유해주시면 감사하겠습니다.

ViewModel 이벤트 처리관련 함께 읽어보면 좋은 글

저희와 함께 헤이딜러 서비스를 발전 시켜나가실 분들을 기다리고 있습니다.

http://bit.ly/prnd-hiring

위의 채용링크로 많은 지원부탁드립니다.

감사합니다.

--

--