이벤트 처리 Channel vs SharedFlow

이진욱·
개발추천AndroidKotlin

안드로이드에서 이벤트(사이드 이펙트)는 주로 channel 또는 sharedFlow를 사용해 처리한다.

channel을 이용한 이벤트 처리 예시 코드

private val _effect: Channel<A> = Channel()
val effect = _effect.receiveAsFlow()

sharedFlow를 이용한 이벤트 처리 예시 코드

private val _sideEffectFlow: MutableSharedFlow<SE>
val sideEffectFlow: SharedFlow<SE> = _sideEffectFlow.asSharedFlow()

그렇다면 SharedFlowChannel을 사용한 이벤트 처리의 차이점은 뭘까?

각각의 장단점에 대해 알아보자.

Channel

장단점

장점 : 백그라운드에서 발생한 이벤트도 수집 가능

단점 : 여러 개의 구독자를 가지기에는 적합하지 않음

테스트 1 - 백그라운드

MainViewModel

class MainViewModel : ViewModel() {
    private val _channel = Channel<Int>()
    val channel = _channel.receiveAsFlow()

    init {
        viewModelScope.launch {
            repeat(100) {
                Log.d("Channel", "MainViewModel - Send $it")
                _channel.send(it)
                delay(1000)
            }
        }
    }
}

MainActivity

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.channel.collect { number ->
                    Log.d("Channel","MainActivity - Collected $number from channel")
                }
            }
        }
        // ...
}

결과

pasted-image-1759713265068.png

  1. MainActivity - onStop (백그라운드 진입)

  2. MainViewModel - channel send 7 (백그라운드에서 send)

  3. MainActivity - onStart (포그라운드 진입)

  4. MainActivity - collect 7 (포그라운드에서 collect)

백그라운드에서 send한 7을 올바르게 collect한 모습을 볼 수 있다.

이게 가능한 이유는 channelsend()channel의 버퍼가 꽉 차있거나 존재하지 않으면 suspend되기 때문이다. (suspending the caller while the buffer of this channel is full or if it does not exist, or throws an exception if the channel is closed for send (see close for details).)

테스트 2 - 여러 개의 구독자를 가지는 경우

MainActivity

lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.channel.collect { number ->
                    Log.d("Subscriber","Subscriber[1] - Collected $number from channel")
                }
            }
        }

lifecycleScope.launch {
        repeatOnLifecycle(Lifecycle.State.STARTED) {
            viewModel.channel.collect { number ->
                Log.d("Subscriber","Subscriber[2] - Collected $number from channel")
            }
        }
    }

결과

pasted-image-1759713283873.png

Channel에서 여러 개의 구독자가 있다면, 각각의 구독자가 번갈아가면서 collect하게 된다.

  1. channel - send 1

  2. subscriber[1] - collect 1

  3. channel - send 2

  4. subscriber[2] - collect 2

공식 문서에 따르면 Channel은 평등하기 때문이다.

따라서 여러 개의 구독자를 가지는 경우, 각각의 구독자가 같은 이벤트를 수신하지 않으므로 Channel보다는 SharedFlow가 더 적합하다.

예를 들어, 앱 전체에 tick을 보내서 정기적으로 앱의 데이터를 refresh 해야한다면 Channel을 사용하는 것은 부적합하다.

sharedFlow

장점 : 여러 개의 구독자를 가질 수 있음

단점 : 백그라운드에서 발생한 이벤트 수집 불가능

테스트 1 - 백그라운드

MainActivity

lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.sharedFlow.collect { number ->
            Log.d("SharedFlow","MainActivity - Collected $number from sharedFlow")
        }
    }
}

결과

pasted-image-1759713296336.png

  1. MainActivity - onStop (백그라운드 진입)

  2. MainViewModel - sharedFlow emit 8, 9, 10, 11 (백그라운드에서 emit)

  3. MainActivity - onStart (포그라운드 진입)

  4. MainActivity - collect 12 (포그라운드에서 collect) - 8, 9, 10, 11은 유실됨

백그라운드에서 emit한 8, 9, 10, 11은 유실된 것을 확인할 수 있다.

테스트 2 - 여러 개의 구독자를 가지는 경우

MainActivity

lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.sharedFlow.collect { number ->
                    Log.d("Subscriber","Subscriber[1] - Collected $number from sharedFlow")
                }
            }
        }

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.sharedFlow.collect { number ->
                    Log.d("Subscriber","Subscriber[2] - Collected $number from sharedFlow")
                }
            }
        }

결과

pasted-image-1759713311261.png

여러 개의 구독자를 가져도 모든 구독자가 같은 이벤트를 collect하는 것을 확인할 수 있다.

단점 극복 방법

sharedFlow의 단점은 MVVM의 ViewModel에서 이벤트를 처리하는 방법 6가지를 통해 해결할 수 있지만 ... 이러면 여러 개의 구독자를 가질 수 없게 된다. 이게 무슨 말일까? 자세히 알아보자. (그 전에 MVVM의 ViewModel에서 이벤트를 처리하는 방법 6가지 포스팅을 읽고 오자.)

문제 발생 시나리오

event 객체가 있고 이를 AFragment, BFragment에서 collect하고 있다고 가정하자.

1.eventemit되면 AFragment, BFragment에서 collect 된다. (근소한 차이로 AFragment에서 먼저 collect 되었다고 가정)

pasted-image-1759713323502.png

2.이때 AFragment에서 eventcomsumedtrue가 된다.

pasted-image-1759713330677.png

3.그 이후 BFragment에서 eventcollect되어야하지만, event는 이미 comsumed되었기 때문에 collect되지 않는다.

pasted-image-1759713335897.png

해결 방안

slotStore라는 HashMap을 만들었다.

slotStorekey에는 현재 collect하고 있는 collector의 이름과 slottoString()값이 들어간다.
value에는 event와 동일한 값을 가지고 있는 새로운 event가 들어간다.

아까와 마찬가지로 event 객체가 있고 이를 AFragment, BFragment에서 collect하고 있다고 가정한다.

1. eventemit되면 AFragment, BFragment에서 collect 된다. (근소한 차이로 AFragment에서 먼저 collect 되었다고 가정)

2.이때 slotStore{ AFragment + event.toString() : Event(event.value) }와 같은 값이 저장된다.

pasted-image-1759713348116.png

pasted-image-1759713354736.png

3.그 이후 slotStorekey 값이 AFragment + event.toString()valueconsumedtrue가 된다.

pasted-image-1759713367369.png

4.BFragment에서도 동일한 동작이 수행된다.

pasted-image-1759713375343.png

pasted-image-1759713380810.png

5.collect 이후 BFragmenteventcomsumed처리 된다.

pasted-image-1759713386527.png

위 방법을 사용하여 2개 이상의 구독자를 가진 EventFlowemit되면 단 하나의 구독자만 collect하는 문제를 해결했다.
slotStore를 사용한 코드는 여기서 볼 수 있다.

해결방안 - 개선

Event 객체가 더 이상 사용되지 않아도 slotStore에서 계속 참조를 하고 있기 때문에 GC되지 않는 현상이 있었다. ArrayDeque를 사용하여 최대 20개까지의 EventFlowSlot을 저장하게끔 구현했다. 최종 코드는 다음과 같다.

private class EventFlowImpl<T>(
    replay: Int
) : MutableEventFlow<T> {

    private val flow: MutableSharedFlow<EventFlowSlot<T>> = MutableSharedFlow(replay = replay)

    private val slotStore: ArrayDeque<Slot<EventFlowSlot<T>>> = ArrayDeque()

    @InternalCoroutinesApi
    override suspend fun collect(collector: FlowCollector<T>) = flow
        .collect { slot ->

            val slotKey = collector.javaClass.name + slot

            if(isContainKey(slotKey)) {
                if(slotStore.size > MAX_CACHE_EVENT_SIZE) slotStore.removeFirst()
                slotStore.addLast(Slot(slotKey, EventFlowSlot(slot.value)))
            }

            val slotValue = slotStore.find { it.key == slotKey }?.value ?: slot

            if (slotValue.markConsumed().not()) {
                collector.emit(slotValue.value)
            }
        }

    override suspend fun emit(value: T) {
        flow.emit(EventFlowSlot(value))
    }

    fun isContainKey(findKey: String): Boolean {
        return slotStore.find { it.key == findKey } == null
    }
}

private data class Slot<T>(
    val key: String,
    val value: T
)

결론

이벤트(side-effect)는 보통 한 곳에서만 처리를 한다.

sharedFlow를 사용하여 백그라운드에서 발생한 이벤트를 처리하면 eventFlow를 만들어줘야한다. eventFlow를 만드는 것 자체만으로 별도의 코드를 추가해야한다는 단점이 있다. 또 eventFlow를 만드는 순간 sharedFlow의 장점(여러 개의 구독자를 가질 수 있음)이 사라진다. 이것 또한 위에서 설명한 방식으로 해결할 수 있다. 하지만 별도의 코드를 추가해야하며 코드를 쉽게 이해하기 어렵다.

따라서 이벤트는 보통 한 곳에서만 처리를 하므로 channel을 사용하여 이벤트를 수신하는게 가장 코드를 덜 작성하고 비교적 쉽게 이해할 수 있다.

필요한 경우에만 sharedFlow를 사용하는 것이 좋다 생각한다.

참고

[Kotlin] Coroutine의 SharedFlow 와 Channel

StateFlow vs SharedFlow 를 비교해보자 #이벤트 핸들링

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

이진욱

이진욱

안드로이드 개발자

Android 이벤트 처리 Channel vs SharedFlow 비교 - 백그라운드 이벤트 유실 해결법 | Jinukeu Blog