[Android] 이벤트 유실 - Channel + receiveAsFlow() 와 수명 주기의 교차점

이도연·2025년 8월 15일
0

android studio

목록 보기
30/32
post-thumbnail




📌 UI 이벤트 유실 문제

최근 프로젝트에서 ViewModel과 Activity 사이의 UI 이벤트 전달을 테스트하면서,
가끔 이벤트가 Activity에 도달하지 않는 문제를 발견했다.
테스트에서는 실패, 실제 앱에서도 일부 UI가 이상하게 동작하곤 했다.



1️⃣ 문제 상황: 이벤트가 안 오는 이유

// ViewModel
private val _eventChannel = Channel<UiEvent>(Channel.BUFFERED)
val eventFlow = _eventChannel.receiveAsFlow()

fun onExpandTextClick() {
    viewModelScope.launch {
        _eventChannel.send(UiEvent.ExpandText)
    }
}

// Activity
lifecycleScope.launchWhenStarted {
    viewModel.eventFlow.collect { event ->
        when(event) {
            UiEvent.ExpandText -> binding.description.maxLines = Int.MAX_VALUE
        }
    }
}

발생 문제

launchWhenStarted는 STARTED 이상일 때만 collect → 타이밍에 따라 이벤트 유실
Unit Test 에서는 이벤트를 수집하지 못함.



2️⃣ 문제 원인 분석


Channel은 즉시 소비 구조이다.
버퍼가 비어있거나 소비자가 준비되지 않으면 이벤트가 유실될 수 있다.
launchWhenStarted는 Activity의 STARTED 상태에서만 수집 → 이벤트를 놓치면 끝

즉, 타이밍과 수명주기 교차점에서 이벤트가 사라지는 것이다.


3️⃣ 개선 패턴: SharedFlow + repeatOnLifecycle

// UI 이벤트용 SharedFlow
private val _ui = MutableSharedFlow<UiEvent>(replay = 1)
val ui = _ui.asSharedFlow()

fun onExpandTextClick() {
    _ui.tryEmit(UiEvent.ExpandText) // 즉시 전달, 유실 방지
}

replay = 1 → 늦게 구독해도 마지막 이벤트 보존한다.
tryEmit → 코루틴 블록 밖에서도 즉시 이벤트 전달한다.

lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.ui.collect { event ->
            when(event) {
                UiEvent.ExpandText -> {
                    binding.description.maxLines = Int.MAX_VALUE
                    binding.showMore.visibility = View.GONE
                }
            }
        }
    }
}

repeatOnLifecycle → STARTED 상태에서만 반복적으로 안전하게 collect
Activity가 일시정지됐다가 돌아와도 이벤트를 놓치지 않는다.



4️⃣ 테스트 코드 비교

실패 테스트 (Channel)

@Test
fun `showMore 클릭 시 ExpandText 이벤트 발생`() = runTest {
    val events = mutableListOf<UiEvent>()
    val job = launch(UnconfinedTestDispatcher()) {
        viewModel.eventFlow.toList(events)
    }

    viewModel.onExpandTextClick()
    advanceUntilIdle()

    assertTrue(events.contains(UiEvent.ExpandText)) // 가끔 실패
    job.cancel()
}

성공 테스트 (SharedFlow)

@Test
fun `showMore 클릭 시 ExpandText 이벤트 발생`() = runTest {
    val events = mutableListOf<UiEvent>()
    val job = launch(UnconfinedTestDispatcher()) {
        viewModel.ui.toList(events)
    }

    viewModel.onExpandTextClick()
    advanceUntilIdle()

    assertTrue(events.contains(UiEvent.ExpandText)) // 안정적으로 성공
    job.cancel()
}

SharedFlow를 사용하면 Activity 수명주기와 상관없이 이벤트가 안전하게 전달된다.



UI 이벤트는 단발성 이벤트 → Channel보다는 SharedFlow/SingleLiveEvent 권장되고 있다.
수명주기와 이벤트 타이밍이 맞지 않으면 이벤트 유실이 발생한다.

0개의 댓글