ViewModel의 One Time Event를 다루는 다양한 솔루션

2025. 2. 7. 16:11·Android

서론

ViewModel에서 발생하는 이벤트를 소비하는 방법엔 Channel, SharedFlow, State 등 다양한 방법이 있습니다. 프로젝트를 Compose로 Migration 하는 과정에서 이에 대한 솔루션이 필요해 여러 자료를 통해 프로젝트에 채택한 방법을 소개하겠습니다. 해당 글은 Youtuber Phillipp Lackner님의 영상을 번역한 자료로 필자는 영어를 잘 못하기 때문에... 유튜브 영상의 스크립트를 해석한 내용으로 잘못된 내용이 있을 수 있습니다.

0. 필요 조건

One Time Event를 위한 API는 다음 조건이 필요합니다.

  • Event 발생 후 앱이 백그라운드로 내려갈 시 UI에선 구독을 중지해야 한다.
  • 동일한 Event를 중복해서 발생시키지 않아야 한다.(Ex. 같은 화면을 두 번 이동하는 문제)
  • 이벤트가 소비되지 않았다면 캐시 후 이벤트가 소비될 때까지 유실되지 않아야 한다.
  • ViewModel에서 발생한 이벤트는 UI의 상태를 즉시 업데이트 시켜야 한다.

첫 번째 조건은 UI에서 repeatOnLifecycle 함수를 사용해 OnStart 구독하고 OnStop일 때 구독을 해지해 해결할 수 있는 문제긴 합니다. 필자는 널리 알려진 방법으로 해당 함수를 확장 함수로 정의해 사용하고 있습니다.

fun LifecycleOwner.repeatOnStarted(block: suspend CoroutineScope.() -> Unit) {
    lifecycleScope.launch {
        lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED, block)
    }
}

1. 테스트 시나리오

로그인 상황을 가정한 이벤트 테스트를 위한 시나리오는 다음과 같습니다.

  • 버튼을 클릭하면 ViewModel에 click Event를 전달합니다.
  • ViewModel은 로그인 API 호출을 가정하여 3초간 Delay 합니다.
  • Delay 후 프로필 화면으로 이동하기 위한 One Time Event를 발생시킵니다.
  • UI에선 ViewModel의 이벤트가 발생하면 Login 화면에서 Profile 화면으로 이동합니다.
sealed interface NavigationEvent{
	data class NavigateToProfile(
		// 이벤트를 방출 후 수집까지 걸린 시간을 측정
		val sendTime: Long = System.currentTimeMillis()
	) : NavigationEvent
}

data class LoginState(
    val isLoading: Boolean = false,
    val isLoggedIn: Boolean = false
)

// UI
@Composable
fun LoginScreen(
    state: LoginState,
    onLoginClick: () -> Unit
) {
    Surface {
        Column( //... ) {
            Button(
                onClick = { onLoginClick() },
                content = { Text("Button") }
            )

            if (state.isLoading) {
                CircularProgressIndicator(
                    modifier = Modifier.size(30.dp),
                    color = Color.Blue,
                    strokeWidth = 4.dp
                )
            }
        }
    }
}
@Composable
fun ProfileScreen() { }

NavHost(
    navController = navController,
    startDestination = "login"
) {
    composable("login") {
        val viewModel = viewModel<MainViewModel>()
        LoginScreen(
            state = state,
            onLoginClick = viewModel::login
        )
    }

    composable("profile") {
        ProfileScreen()
    }
}

2. Channel 

첫 번째 선택지인 Channel입니다. capacity와 bufferOverFlow를 지정하지 않은 RENDEZVOUS Channel입니다.

이벤트가 단 한 번만 일어나는 경우 RENDEZVOUS Channel은 버퍼가 없기 때문에 이벤트가 발생할 경우 이벤트를 소비할 때까지 SUSPEND 되어 send() 이후에 코드가 실행되지 않기 때문에 혹여나 백그라운드에서 예상치 못한 동작이 발생하는 것을 예방할 수 있습니다.

 

다만 다수의 이벤트가 캐싱되어야 하는 경우 RENDEZVOUS Channel이 아닌 버퍼를 사용해 캐싱이 가능한 BUFFERED Channel을 사용해야 합니다.

class MainViewModel : ViewModel() {
    private val navigationChannel = Channel<NavigationEvent>()
    val navigationEventsChannelFlow = navigationChannel.receiveAsFlow()
    
    var state by mutableStateOf(LoginState())
        private set

    fun login(){
        viewModelScope.launch {
            state = state.copy(isLoading = true)
            delay(3000L)

            navigationChannel.send(NavigationEvent.NavigateToProfile)

            state = state.copy(isLoading = false)
        }
    }
}

// UI
LaunchedEffect(lifecycleOwner.lifecycle) {
    lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.navigationEventsChannel.collect { event ->
            when (event) {
                is NavigationEvent.NavigateToProfile -> {
                    val receiveTime = System.currentTimeMillis()
                    Log.d("MainViewModel", "Received event at ${(receiveTime - event.sendTime)}ms")
                    navController.navigate("profile")
                }
            }
        }
    }
}

 

앱이 백그라운드로 내려간 후 약 5초 후 다시 앱을 활성화해도 이벤트가 유실되지 않고 수집된 것을 확인할 수 있습니다. 또한 예상했던 대로 이벤트가 소비되지 않았을 때 SUSPEND 되어 send() 이후의 코드가 백그라운드에서 수행되지 않고 로그가 send() 후 출력 되는 것 또한 확인하였습니다. 

 

UI에서 이벤트를 구독할 때 LaunchEffect를 사용하고 있습니다. Key에 lifecycle을 전달해 lifecycle이 변경될 때마다 트리거 되며 STARTED일 때 만 이벤트를 구독하도록 했습니다. 이 코드를 분리해 재활용할 수 있도록 함수로 추출하겠습니다.

@Composable
private fun <T> ObserveAsEvents(flow: Flow<T>, onEvent: (T) -> Unit) {
    val lifecycleOwner = LocalLifecycleOwner.current
    LaunchedEffect(flow, lifecycleOwner) {
        lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
            flow.collect {
                onEvent(it)
            }

        }
    }
}

3. SharedFlow

여러 구독자가 동일한 이벤트를 받을 수 있도록 설계된 API입니다. Channel과 마찬가지로 SharedFlow는 재미있는 결과가 나타나 결과부터 확인해 보겠습니다.

class MainViewModel : ViewModel() {
    private val _navigationEventsSharedFlow = MutableSharedFlow<NavigationEvent>()
    val navigationEventsSharedFlow = _navigationEventsSharedFlow.asSharedFlow()

    var state by mutableStateOf(LoginState())
        private set

    fun login(){
        viewModelScope.launch {
            state = state.copy(isLoading = true)
            delay(3000L)
            val sendTime = System.currentTimeMillis()
            _navigationEventsSharedFlow.emit(NavigationEvent.NavigateToProfile())
            Log.d("MainViewModel", "navigationEventsSharedFlow emit")
            state = state.copy(isLoading = false)
        }
    }
}

 

SharedFlow는 백그라운드에서 이벤트가 유실됩니다.

 

SharedFlow는 replay와 extraBufferCapacity를 사용해 버퍼의 크기를 지정해주지 않으면 데이터를 바로 소비하지 않았을 때 이벤트가 유실됩니다. 또한 channel처럼 SUSPEND 되지 않기 때문에 emit() 이후의 코드가 실행됩니다. 이 말은 백그라운드에서 예상치 못한 동작이 발생할 가능성이 존재함을 의미합니다. 

SharedFlow가 백그라운드에서 데이터가 유실되지 않도록 하기 위해선 replay를 사용해 캐시를 설정하면 됩니다. 가장 최근 값을 전달하겠단 의미로 Channel처럼 백그라운드에서도 데이터가 유실되지 않습니다.

private val _navigationEventsSharedFlow = MutableSharedFlow<NavigationEvent>(replay = 1)
val navigationEventsSharedFlow = _navigationEventsSharedFlow.asSharedFlow()

하지만 여전히 emit() 이후의 코드가 Suspend 되지 않고 실행됩니다. emit()은 기본적으로 replay 버퍼가 있으면 즉시 데이터를 저장하고 다음 코드가 실행됩니다.

 

그래서 백그라운드로 내려가도 emit() 후 딜레이 없이 바로 다음 코드가 실행되는 것입니다. 상황에 따라 이런 동작이 필요할 수 도 있겠지만 저는 이런 동작을 원치 않습니다.

 

또한 Channel이 이런 기능을 별도의 설정 없이 제공한다는 점, SharedFlow의 기능 중 하나인 다수의 구독자에게 동일한 데이터를 제공하지만 프로젝트에서 다수의 구독자가 필요하지 않다는 점, 백그라운드에서 예상치 못한 동작을 할 가능성이 있다는 3가지 이유로 SharedFlow는 사용하지 않기로 결정했습니다.


4. State

한 구글 엔지니어분의 아티클을 보면 Channel이나 SharedFlow가 이벤트 전달을 보장하지 않는다고 합니다.

 

때문에 "One-time 이벤트는 안티패턴이다"라고 주장합니다. 이 아티클에선 UI가 특정 이벤트를 반드시 수신하고 처리한다는 보장이 없기 때문에 이러한 One-time Event를 상태(State)로 모델링할 것을 제안하고 있습니다. 이를 반영해 isLoggedIn 상태를 compose의 State로 다뤄보겠습니다.

 

isLoggedIn 상태를 Compose의 State로 선언하고 UI에선 LaunchEffect 블록을 사용해 isLoggedIn 상태를 감지한 후 로그인 상태가 true라면 프로필 화면으로 이동합니다.

class MainViewModel : ViewModel() {
    var isLoggedIn by mutableStateOf(false)
        private set

    var state by mutableStateOf(LoginState())
        private set

    fun login(){
        viewModelScope.launch {
            state = state.copy(isLoading = true)
            state = state.copy(
                isLoading = false,
                isLoggedIn = true
            )
        }
    }
}

// UI
NavHost(
    navController = navController,
    startDestination = "login"
) {
    composable("login") {
        val viewModel = viewModel<MainViewModel>()
        val state = viewModel.state

        LaunchedEffect(state.isLoggedIn) {
            if (state.isLoggedIn){
                navController.navigate("profile")
            }
        }
	}
}

 

State의 경우 이벤트 발생 후 화면 이동에는 문제가 없지만 isLoggedIn 상태가 유지되기 때문에 이후 뒤로 가기 버튼을 눌러도 다시 로그인 화면으로 돌아갈 수 없습니다.

 

즉, ViewModel이 유지되면서 isLoggedIn = true 상태가 남아있고 화면이 다시 활성화될 때 LaunchedEffect가 실행되면서 프로필 화면으로 다시 이동하게 됩니다.

 

이를 해결하려면 추가적인 처리가 필요한데, 예를 들어 ViewModel에서 새로운 함수를 만들어 isLoggedIn 상태를 초기화할 수 있습니다. 이 경우 확실히 UI에서 이벤트가 손실되진 않겠지만 화면에서 하나의 이벤트만 처리하는 것도 아니며 이벤트가 늘어날수록 처리에 필요한 코드가 늘어난다는 점에서 개인적으로 맘에 들지 않습니다.

// viewModel에서 상태 변경
fun onNavigatedToLogin() {
    state = state.copy(isLoggedIn = false)
}

// UI에서 호출
viewModel.onNavigatedToLogin()
그래서 SharedFlow과 Channel는 유실되는 거야 안 되는 거야?

 

저는 3가지 방법(Channel, SharedFlow, State) 중 Channel을 사용하기로 결정했습니다.

 

하지만 분명 앞서 Channel을 테스트하면서 이벤트가 유실되지 않는 것을 확인했지만 여전히 위 아티클에서 말하는 이벤트가 유실되는 상황에 대한 의문이 남아 테스트를 보겠습니다.

 

기존 코드에 NavigationEvent에 발생한 이벤트를 카운트하는 이벤트 상태를 추가하고 login 함수에선 3초마다 Delay 되며 1000번의 이벤트를 발생시키도록 수정했습니다. UI에선 이벤트를 구독해 카운트를 출력, 화면이 파괴되는 시점에 로그를 출력하도록 수정했습니다.

class MainViewModel : ViewModel() {
    private val _navigationEventsChannel = Channel<NavigationEvent>()
    val navigationEventsChannel = _navigationEventsChannel.receiveAsFlow()

    fun login(){
        viewModelScope.launch {
            repeat(1000) {
                delay(3L)
                _navigationEventsChannel.send(NavigationEvent.CountEvent(it))
            }
        }
    }
}

sealed interface NavigationEvent{
    object NavigateToProfile : NavigationEvent
    // 발생된 이벤트의 수를 카운트하기 위한 이벤트
    data class CountEvent(val count: Int) : NavigationEvent
}

// UI
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        //....
        
ObserveAsEvents(viewModel.navigationEventsChannel) { events ->
    when (events) {
        is NavigationEvent.NavigateToProfile -> //Todo
        is NavigationEvent.CountEvent -> {
            	Log.d("CountEvent","${events.count}")
            }
        }
    }
}
   
    override fun onDestroy() {
        super.onDestroy()
        Log.d("CountEvent","COUNT INTERRUPT")
    }
}

 

 

테스트를 위해 이벤트가 모두 소비될 때까지 configuration Change(화면 전환)를 발생시켰습니다. 결과를 살펴보면 357번이 유실된 것을 확인할 수 있습니다. 이벤트가 유실되는 이유를 예상해 보면 UI가 이벤트를 처리하는 동안 새로운 이벤트가 도착하는 경우가 존재함을 예상해 볼 수 있습니다.

Channel은 내부적으로 버퍼링 된 큐를 사용해 이벤트를 처리합니다. 만약 UI에서 이벤트를 처리하는 동안 새로운 이벤트가 빠르게 도착하면 이벤트가 큐에 쌓이지 못하고 유실될 수 있습니다.

 

이 경우 UI가 이전 이벤트를 처리하는 동안 새로운 이벤트가 버려지거나 큐에서 빠저나 가는 상황이 발생할 수 있습니다.

@Composable
private fun <T> ObserveAsEvents(flow: Flow<T>, onEvent: (T) -> Unit) {
    val lifecycleOwner = LocalLifecycleOwner.current
    LaunchedEffect(flow, lifecycleOwner.lifecycle) {
        Log.d("ObserveAsEvents", "LaunchedEffect 실행 스레드 ${Thread.currentThread().name}")
        lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
            Log.d("ObserveAsEvents", "repeatOnLifecycle 실행 스레드 ${Thread.currentThread().name}")
            flow.collect {
                Log.d("ObserveAsEvents", "collect 실행 스레드 ${Thread.currentThread().name}")
                onEvent(it)
            }
        }
    }
}

 

핵심은 UI에서 이벤트를 처리하는 시간입니다. 현재 이벤트를 구독하는 함수는 Main Thread에서 동작하도록 구현되어 있습니다.

 

이 경우 Dispatchers.Main을 사용하며 Dispatchers.Main은 코루틴을 작업 대기열에 적재한 후 Main Thread가 비었을 때 코루틴을 Dispatch 합니다.

 

이때 작업 대기열에 남은 공간이 없다면 우선순위가 밀리고 UI 업데이트가 지연됩니다. 현재 발생하는 Event 또한 이러한 문제로 유실되는 것으로 예상됩니다.

출처 : https://www.inflearn.com/course/%EC%BD%94%ED%8B%80%EB%A6%B0-%EC%BD%94%EB%A3%A8%ED%8B%B4-%EC%99%84%EC%A0%84-%EC%A0%95%EB%B3%B5/dashboard

 

Dispatchers.Main.immediate 사용하면 Dispatch 하는 과정 없이 바로 Main Thread에 이벤트를 보내 룰처럼 UI 응답 속도를 개선하고 데이터를 유실하지 않을 수 있습니다.

@Composable
fun <T> ObserveAsEvents(flow: Flow<T>, onEvent: (T) -> Unit) {
    val lifecycleOwner = LocalLifecycleOwner.current
    LaunchedEffect(flow, lifecycleOwner.lifecycle) {
        lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
            withContext(Dispatchers.Main.immediate) {
                flow.collect(onEvent)
            }
        }
    }
}

6. 정리

이전에 헤이딜러의 이벤트 처리 방식에 대한 아티클을 읽고서 Channel을 처음 접했는데 BUFFERED Channel을 사용하고 있었다. 아직 BUFFERED Channel을 사용할지 RENDEZVOUS Channel을 사용해야할지 살짝 햇갈리는 부분이 있는것 같다. 개인적으로 해석하건데 RENDEZVOUS Channel 채널을 사용하는 것이 맞는 것 같다.

 

BUFFERED Channel을 사용하면 화면 이동을 발생시키는 이벤트가 발생한다면 화면을 중복해서 이동하는 현상이 발생할 수 있으며 스낵바나 토스트를 보여준다면 이 또한 다수의 컴포넌트가 생성될 수 있기 때문이다.

 

또한 UI에선 이벤트가 유실되지 않고 UI에 바로 적용될 수 있도록 Main.immediate를 사용한다 !

 

참조 

https://www.youtube.com/watch?v=njchj9d_Lf8&t=1278s

 

'Android' 카테고리의 다른 글

Retrofit Internals - Retrofit In Coroutine  (0) 2025.06.20
Retrofit Internals - Retrofit은 어떻게 인터페이스의 구현체를 만들까 ?  (1) 2025.06.16
안드로이드 클린아키텍처 에러 핸들링 (부제: 상속을 지양하자)  (2) 2024.07.28
[Android]프로젝트를 클린 아키텍처로 마이그레이션해보자  (0) 2024.07.12
ViewLifeCycleOwner 제대로 알고 사용해보자  (1) 2024.04.28
'Android' 카테고리의 다른 글
  • Retrofit Internals - Retrofit In Coroutine
  • Retrofit Internals - Retrofit은 어떻게 인터페이스의 구현체를 만들까 ?
  • 안드로이드 클린아키텍처 에러 핸들링 (부제: 상속을 지양하자)
  • [Android]프로젝트를 클린 아키텍처로 마이그레이션해보자
빨주노초잠만보
빨주노초잠만보
  • 빨주노초잠만보
    과거의 나를 통해 미래의 나를 성장시키자
    빨주노초잠만보
  • 전체
    오늘
    어제
    • 분류 전체보기 (105)
      • 우아한테크코스 (5)
      • TEKHIT ANDROID SCHOOL (4)
      • Android Architecture (8)
      • Android (36)
      • PROJECT (11)
      • KOTLIN (10)
        • 코루틴의 정석 (3)
      • BACK END (12)
      • CS (3)
      • 컨퍼런스 (4)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    coroutine Context Switching
    MVI
    ThrottleFirst
    repository
    Throttle
    android Room
    orbit
    sealed class
    flow
    2025우아콘
    retrofit coroutine
    process Context Switching
    android view lifecylce
    STATEFLOW
    callbackflow
    retrofit call
    retrofit awit
    Room
    Two pass process
    코틀린 코루틴의 정석
    view 생명주기
    thread Context Switching
    의존성 주입
    DataSource
    Clean Architecture
    android clean architecture
    DI
    2025 우아콘 후기
    Repository Pattern
    value class
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
빨주노초잠만보
ViewModel의 One Time Event를 다루는 다양한 솔루션
상단으로

티스토리툴바