
동기
저는 안드로이드 관련 지식을 Medium 에서 주로 얻곤 하는데요. MVI Architecture 적용 시 UiEffect 의 구현을 위해 SharedFlow<T> 대신 Channel<T> 을 사용하는 방식에 관한 컬럼을 보게 되었습니다.
사실, ViewModel 과 Screen-Level-Composable 은 1:1 대응 되는 경우가 많은데, 왜 굳이 SharedFlow<T> 를 사용해야 하는지에 대한 의문이 항상 남아 있었습니다. 오늘은 해당 컬럼에서 얻은 지식에 관해 정리하고자 합니다.
UiEffect
MVI Architecture 를 안드로이드 프로젝트에 처음 적용하려고 하면 마주하는 문제가 있습니다.
바로 UiState 를 변경하지 않고, 애플리케이션에 특정한 액션을 취해주어야 하는 상황에 대한 처리인데요. 예를 들어 화면 이동, Toast, Snackbar 등이 있겠습니다.
화면 이동은 전형적인 UiEffect 액션에 해당합니다. UiState 를 업데이트하여 구현할 수는 있지만, 이러한 방식으로 구현한다면 화면 이동을 위해 변경한 값을 다시 원상 복구 시켜야 한다는 애로사항이 있습니다. 원상 복구 시키지 않으면 백 버튼 등의 동작을 통해 다시 원래의 화면으로 돌아가는 경우, 다시 화면이 바로 이동되는 문제가 발생할 수 있기 때문입니다.
Toast, Snackbar 는 일회성 상태임과 동시에 표시되는 시간에 제한이 있기 때문에, UiState 를 변경하는 방식으로 구현하기 다소 까다로울 수 있습니다. 물론 UiState 를 변경하여 구현할 수도 있겠지만, 별도의 딜레이를 주어 이들을 화면에서 제거해주는 작업이 추가되어야 하기 때문인데요.
이러한 상황들을 간편하게 처리하기 위해 사용되는 개념이 UiEffect 입니다. UiEffect 는 ViewModel 에서 관리되며, View 로 액션이 전달되어 View 에서 이를 처리하도록 하는 것이 일반적입니다.
SharedFlow<T>
많은 블로그 포스트와 컬럼, 그리고 소스 코드에서 UiEffect 의 전달을 위해 SharedFlow<T> 를 사용합니다.
class MyViewModel : ViewModel() {
private val _state = MutableStateFlow<UiState>()
val state: StateFlow<UiState> = _state.asStateFlow()
private val _effect = MutableSharedFlow<UiEffect>(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val effect: SharedFlow<UiEffect> = _effect.asSharedFlow()
... // reducer
private fun sendEffect(uiEffect: UiEffect) {
viewModelScope.launch {
_effect.emit(uiEffect)
}
}
}
Reducer 를 통해 전달된 UiEvent 에 의해 UiEffect 가 생성되고, 이는 MutableSharedFlow<T> 로 방출됩니다.
View 에서는 SharedFlow<T> 에 대한 수집을 진행하고 있으므로, 방출되는 UiEffect 는 View 로 전달, 이에 맞는 작업을 수행하게 됩니다.
이 때, 우리는 replay 와 extraBufferCapacity, onBufferOverflow 에 대한 값을 지정하여 Buffer 사양을 설정할 수 있는데요.
replay 는 새로운 구독자가 받을 수 있는 과거 값의 개수를 지정합니다. 기본값은 0이며, 이 경우 새로운 구독자는 기존에 발생한 이벤트를 받을 수 없습니다. 즉, 비동기 처리 오류로 뒤늦게 수집이 시작되면 값을 놓치게 될 수 있습니다. 이를 위해 값을 따로 설정하면, 값을 수집하여 사용한 뒤 별도로 캐시를 비워줘야 합니다.
extraBufferCapacity 는 replay 외에 추가적으로 버퍼링할 수 있는 값의 개수를 지정합니다.
replay 가 지난 값을 보관하는 반면, extraBufferCapacity 는 새로운 값이 Overflow 되지 않도록 합니다. 기본값은 0이며, 이 경우에는 버퍼가 없고 추가적인 emit 이 중단됩니다.
보통 1로 설정하는데, 이렇게 설정하면 여러 값이 한 번에 들어와도 단 하나의 값만 방출됩니다. 해당 값이 중요한 이유는 몽키 테스트를 진행해보면 쉽게 알 수 있는데요. 같은 버튼을 아주 빠르게 여러 번 터치하는 경우, 같은 UiEvent 가 연속적으로 전달되고, 이로 말미암아 같은 UiEffect 가 연속적으로 실행 될 수 있습니다.
그래서 1로 설정하여 한 번의 이벤트만 발생할 수 있도록 제한하는 것입니다.
onBufferOverflow 는 BufferOverflow.DROP_OLDEST 를 사용하는 것이 일반적입니다. 값 방출과 수집의 속도 차이가 있을 수 있고, 이 경우 Buffer 에 어떤 값을 보관할지 정할 수 있습니다.
BufferOverflow.DROP_OLDEST 는 가장 마지막에 방출된 값을 버퍼링합니다. UiEffect 의 경우, 최신의 값을 유지하는게 일반적이므로 가장 적합한 옵션입니다.
BufferOverflow.DROP_LATEST 는 가장 먼저 들어온 값을 버퍼링합니다. 이후에 방출되는 값들은 유실됩니다.
BufferOverflow.SUSPEND 는 값의 방출을 지연시킵니다. 즉, 값의 유실 없이 모두 수집할 수 있습니다. 유저 경험을 위해 모든 UiEffect 를 안전하게 실행할 수 있지만, 이 역시 별도로 cache 를 비워줘야 합니다.
Channel<T>
최근 알게 된 Channel<T> 를 이용하여 UiEffect 를 전달하는 코드는 다음과 같습니다.
class MyViewModel : ViewModel() {
private val _state = MutableStateFlow<UiState>()
val state = _state.asStateFlow()
private val _effect = Channel<UiEffect>(capacity = Channel.BUFFERED)
val effect = _effect.receiveAsFlow()
... // reducer
private fun sendEffect(uiEffect: UiEffect) {
viewModelScope.launch {
_effect.send(uiEffect)
}
}
}
UiEffect 가 생성되어 View 로 전달되는 과정은 SharedFlow<T> 를 사용하는 방식과 정확하게 같습니다. 단지 UiEffect 를 View 로 전달하기 위해 사용되는 클래스 차이 뿐입니다.
SharedFlow<T> 와 같이 Channel<T> 에도 capacity 와 관련된 다양한 옵션이 있습니다.
Channel.RENDEZVOUS 는 버퍼가 없으며, send 와 receive 가 동시에 호출되지 않으면 값이 손실될 수 있습니다. 즉, 구독자가 무조건 하나 이상 있는 상황을 상정하고 사용되어야 합니다. receiver 가 없는 경우엔 send 가 무한정 대기하게 됩니다. 그러므로, send 메서드를 실행하는 Coroutine 이 일시 지연되어 추후 작성된 코드가 동작하지 않습니다.
Channel.BUFFERED 는 내부적으로 충분한 버퍼를 제공하여, send 가 receive 보다 빠르게 실행되는 경우에도 이벤트가 손실되지 않습니다. Blocking Queue 형태로 동작하기 때문에 추가적으로 Buffer 를 비우지 않아도 됩니다.
Channel.UNLIMITED 는 무제한 버퍼를 사용합니다.
UiEffect 를 처리하기에 가장 적합한 capacity 설정값은 Channel.BUFFERED 입니다.
최소 64개의 버퍼를 제공하여 값의 손실을 방지할 수 있습니다.
비동기 처리 시 안정성에 대한 보장이 가능하기 때문입니다. 다양한 문제로 인해 반드시 구독자가 있으리란 보장이 불가능하니까요.
차이는?
SharedFlow<T> 와 Channel<T> 의 차이는 두 가지 정도로 볼 수 있습니다.
첫 째로, '다중 구독이 가능한가' 입니다. SharedFlow<T> 의 경우 다중 구독을 지원하지만 Channel<T> 는 그렇지 않습니다.
보통 Screen-Level-Composable 과 ViewModel 은 1:1 대응을 이루는 경우가 많으므로, 이러한 부분에서는 Channel<T> 를 이용하는 것이 상황 상 맞다고 판단됩니다.
둘 째로, cache 후 처리 방식입니다.
UiEffect 에 대한 수집은 View 에서 이루어지고, 보통 우리는 LifecycleScope 를 통해 이를 구현하는데요. 이 경우, SharedFlow<T> 를 수집하는 것과 Channel<T> 를 수집하는 것에서 차이가 있습니다.
SharedFlow<T> 는 기본적으로 Hot-Stream 이기 때문에, 구독자가 없어도 데이터를 발행합니다. 이는 필요한 데이터가 유실 될 가능성이 있따는 건데, 앞서 말한대로 Buffer 의 설정을 통해 이를 해결할 수 있습니다. 그러나 이 경우, 별도의 처리가 없다면 앱이 백그라운드에서 포그라운드로 돌아왔을 때, 최초 1회 수집 이후에도 Buffer 에 불필요한 값이 남아있기 때문에, 이를 연속적으로 수집하여 의도치 않은 UiEffect 가 반복되는 경우가 있습니다. 즉, 다시 수집이 시작되면 Buffer 의 값을 수집하지만 그 값이 소비되지 않습니다. cache 를 의도적으로 비워줘야 하고, 이 과정이 복잡도를 상승시킬 우려가 있습니다.
Channel<T> 역시 Hot-Stream 이고, 구독자가 없어도 데이터를 발행할 수 있습니다. 다만, SharedFlow<T> 와 다르게 Buffer 에 쌓인 데이터가 수집되는 즉시 소비되기 때문에, SharedFlow<T> 에서 마주할 수 있는 '지나간 값을 다시 수집하는 문제'를 회피할 수 있습니다. 앱이 백그라운드에서 포그라운드로 돌아왔을 때 Buffer 에 쌓인 데이터를 즉각적으로 수집하여 놓칠 수 있는 UiEffect 를 실행하고, 이 과정에 값이 소비되어 불필요한 반복이 없게 됩니다.
즉, 구현 측면에서는 Channel<T> 가 훨씬 깔끔하고 간편하다고 사료됩니다.
그래서...
사견이 포함되어 있으며, 언제든 달라질 수 있는 내용입니다.
여러 개의 화면에서 동시에 하나의 UiEffect 를 수집하려 하는 경우를 제외하고는 Channel<T> 를 사용하는 편이 바람직해 보입니다.
직접 SharedFlow<T> 대신 Channel<T> 를 적용해 본 결과, 별다른 문제점을 찾지 못했습니다. 오히려 의도치않은 UiEffect 가 반복되는 상황이 사라져, 훨씬 간편하고 안정적인 느낌을 받았습니다. 아마 앞으로는 특별한 상황이 없다면 Channel<T> 를 주로 사용할 것 같습니다. 이 글을 보시는 여러분께서도, Channel<T> 사용을 고려해보시면 좋을 것 같습니다.
다만, UiEffect 를 이와 같이 처리하는 행위 자체를 안티 패턴으로 보는 견해도 존재합니다. Channel<T>, SharedFlow<T> 모두 데이터의 발행 및 구독을 보장하지 않으므로, 앱 상태가 일관적이지 않을 수 있습니다. 사실 개발을 진행하면서 이와 관련한 이슈를 겪어 본 적은 없지만, 분명히 신경 써야 할 부분은 맞습니다.
UiEffect 는 사실 큰 문제가 있지 않다면 View 에서 바로 처리해도 되는 사안이라, ViewModel 로 액션을 넘기는 행위 자체가 무의미하다 느껴질 때도 있습니다. '내가 너무 구조에 집착하나' 싶을 때도 있고요.
판단과 선택은 각자의 몫입니다. 저는 거시적 관점의 가독성을 위해서 UiEffect 를 따로 처리하고 있습니다. 보다 익숙한 방식이기도 하고요. 그러나 앞으로는 또 달라질 수도 있다고 생각합니다. 어느 팀에 합류하냐에 따라서도 달라질 수 있고요.
'Android > Tech' 카테고리의 다른 글
[Jetpack Compose] UI 테스트 작성하기 (0) | 2025.01.15 |
---|---|
안드로이드의 MVI 와 Reducer (0) | 2025.01.15 |
[Jetpack Compose] composed {} 와 Modifier.Node 로 Modifier Chain 최적화하기 (0) | 2025.01.15 |
LazyList 과 RecyclerView 의 메커니즘 알아보기 (0) | 2025.01.13 |
[Jetpack Compose] CompositionLocal 로 이벤트 처리하기 (0) | 2024.02.29 |