
재작년쯤에 Android 개발자 커뮤니티에서 많은 논란이 되었던 아티클이 있었다.

Android UI 이벤트 공식 문서에서도 reference로 소개되는 글이며,

위의 글을 3줄로 요약하면 다음과 같다.
Channel/SharedFlow로 일회성 이벤트 처리하는 것은 안티패턴 - 이벤트 전달이 보장되지 않아(유실 가능성 존재) UI 상태 불일치 발생 가능
ViewModel은 UI에게 행동 지시가 아닌 현재 상태만 제공해야 하며, UI가 상태에 따라 반응 방식을 결정
일회성 이벤트는 즉시 처리하여 UI 상태로 변환하고 StateFlow 등 관찰 가능한 타입으로 노출
더 요약하면, 상태와 더불어 이벤트 역시 StateFlow로 처리하라는 내용이었다.
기존의 Android 개발자들이 가지고 있던
StateFlow는 상태, SharedFlow는 이벤트
라는 주된 관념을 깨부수는 아티클이었기에, 나 역시도 처음엔 해당 아티클에 반대되는 입장을 가졌었다.
하지만, 이벤트가 유실될 가능성이 있고, 그 문제를 해결할 수 있는(피할 수 있는) 방법이 있다면 택해도 되지 않겠는가? 라는 주장엔 어느정도 동의를 한다.
또한, 최근에 팀프로젝트에서 적용해서 사용하고 있는 Circuit이라는 프레임워크도 위 아티클의 주장에 공감하였는지(추측), 이벤트를 상태로서 처리하는 방식을 취하고 있는 것을 확인할 수 있었다.


한 가지 차이점은 Circuit에서는 Compose State와 Compose Runtime에서 제공하는 SideEffect API들을 사용하여 이벤트 및 사이드 이펙트들을 처리하기 때문에 Presentation Layer에서 flow를 직접 사용하진 않는다.
반박 글, 영상들에서도 언급하는 것 처럼, StateFlow를 사용하여 이벤트를 처리하는 경우, 가장 큰 문제점은 상태를 다시 원상 복구해야한다는 점이다.
기존에 사용해왔던 SingleLiveData, SharedFlow, 그리고 Channel을 통해 이벤트를 구현했던 것 처럼 발행된 이벤트가 소비되지 않기 때문에, 기존의 상태로 되돌리는 초기화 과정을 개발자가 직접 코드로 작성해줘야 한다.
수 많은 이벤트를 처리하는 복잡한 화면에서 휴먼 에러에 의해 하나라도 원상 복구 작업(초기화)이 누락될 경우, 버그가 발생할 수 있을 것이다.
예시 코드)
// 이벤트를 상태에 포함
data class PaymentUiState(
val isLoading: Boolean = false,
val paymentResult: PaymentResult? = null, // 이벤트를 상태로 처리
val showError: String? = null
)
sealed interface PaymentResult {
data class Success(val transactionId: String) : PaymentResult()
data class Failed(val reason: String) : PaymentResult()
}
// ViewModel
class PaymentViewModel : ViewModel() {
private val _uiState = MutableStateFlow(PaymentUiState())
val uiState = _uiState.asStateFlow()
fun processPayment() {
_uiState.update { it.copy(isLoading = true) }
// API 호출 후 결과를 상태로 업데이트
viewModelScope.launch {
val result = paymentRepository.processPayment()
_uiState.update {
it.copy(
isLoading = false,
paymentResult = result // 이벤트를 즉시 상태로 변환
)
}
}
}
// 이벤트 소비 처리
fun clearPaymentResult() {
_uiState.update { it.copy(paymentResult = null) }
}
}
// UI
@Composable
fun PaymentScreen(viewModel: PaymentViewModel) {
val state by viewModel.uiState.collectAsState()
// LaunchedEffect로 이벤트 처리
LaunchedEffect(state.paymentResult) {
state.paymentResult?.let { result ->
when (result) {
is PaymentResult.Success -> {
// 성공 시 네비게이션
navController.navigate(PaymentSuccess(event.transactionId))
// 직접 이벤트 소비 처리
viewModel.clearPaymentResult()
}
is PaymentResult.Failed -> {
// 실패 시 스낵바 표시
snackbarHostState.showSnackbar(event.reason)
// 직접 이벤트 소비 처리
viewModel.clearPaymentResult()
}
}
}
}
// UI 렌더링
if (state.isLoading) {
CircularProgressIndicator()
} else {
Button(onClick = { viewModel.processPayment() }) {
Text("결제하기")
}
}
}
이벤트를 명시적으로 소비시켜줘야 했던 이유는 다음과 같다.
LaunchedEffect는 key 기반으로 동작한다.
key가 달라지지 않으면, LaunchedEffect 내부의 블럭은 리컴포지션이 발생하더라고 다시 호출되지 않는다.
따라서 다시 null로 초기화 해준 후, 새 이벤트를 생성해야 key가 변경되고, 이를 감지하여 내부 블럭을 호출시킬 수 있다.
사실 반드시 null 일 필요는 없다. 이전 값과 다르기만 하면 된다.
따라서
// UUID 기반 이벤트 정의
sealed interface PaymentEvent {
val id: String
data class Success(
val transactionId: String,
override val id: String = UUID.randomUUID().toString()
) : PaymentEvent
data class Failed(
val reason: String,
override val id: String = UUID.randomUUID().toString()
) : PaymentEvent
}
다음과 같이 각 이벤트에 해당하는 data class에 고유값인 UUID를 추가해주면, 매번 새로운 UUID를 가진 이벤트를 생성할 수 있다.
data class PaymentUiState(
val isLoading: Boolean = false,
val paymentEvent: PaymentEvent? = null // clear 불필요!
)
// ViewModel - clear 함수 제거
class PaymentViewModel : ViewModel() {
private val _uiState = MutableStateFlow(PaymentUiState())
val uiState = _uiState.asStateFlow()
fun processPayment() {
_uiState.update { it.copy(isLoading = true) }
viewModelScope.launch {
val result = paymentRepository.processPayment()
_uiState.update {
it.copy(
isLoading = false,
// 매번 새로운 UUID로 새 이벤트 생성
paymentEvent = if (result.isSuccess) {
PaymentEvent.Success(result.transactionId)
} else {
PaymentEvent.Failed(result.error)
}
)
}
}
}
}
// UI
@Composable
fun PaymentScreen(viewModel: PaymentViewModel) {
val state by viewModel.uiState.collectAsState()
// 새 이벤트마다 다른 UUID key로 인해 LaunchedEffect 블럭이 실행됨
LaunchedEffect(state.paymentEvent?.id) {
state.paymentEvent?.let { event ->
when (event) {
is PaymentEvent.Success -> {
navController.navigate(PaymentSuccess(event.transactionId))
// clear 호출 불필요!
}
is PaymentEvent.Failed -> {
snackbarHostState.showSnackbar(event.reason)
// clear 호출 불필요!
}
}
}
}
Button(onClick = { viewModel.processPayment() }) {
Text("결제하기")
}
}
LaunchedEffect의 key는 매번 달라지기 때문에, 이전 이벤트에 대한 소비 처리를 명시적으로 하지 않아도 새로운 이벤트를 실행시킬 수 있다!
UUID가 설마 연속으로 중복된 값이 생성되어, LaunchedEffect 내부 블럭이 호출되지 않을...근들갑이다.
일반적으로 사용하는 UUID4의 총 가능한 경우의 수는 2^122로, 대략 5.3 x 10^36개 정도이다.
같은 값이 연속해서 중복으로 생성될 확률은 없다고 봐도 무방하며, UUID에 대한 더 자세한 설명은 해당 블로그를 참고하면 될듯하다.
하지만, 버튼을 클릭하는 등의 상황에 발생하는 이벤트가 아닌, 화면에 진입했을 때 발생하는 이벤트(API 호출 실패시 스낵바, 토스트 호출)는 위의 방식을 적용했을 경우 문제가 발생한다.
예시 코드)
@Composable
fun UserProfileScreen(viewModel: UserViewModel) {
val state by viewModel.uiState.collectAsState()
// 1. 화면 진입시 초기 데이터 로딩 (한 번만 실행)
LaunchedEffect(Unit) {
viewModel.loadUserProfile()
}
// 2. 로딩 실패 이벤트 처리 (UUID 기반)
LaunchedEffect(state.loadEvent?.id) {
state.loadEvent?.let { event ->
when (event) {
is LoadEvent.Failed -> {
snackbarHostState.showSnackbar(event.message)
// clear 없음 - UUID로 자동 소비 기대
}
}
}
}
}
// 문제 발생 과정:
// 1. 화면 진입 → loadUserProfile() 호출
// 2. API 실패 → LoadEvent.Failed(message="네트워크 오류", id="uuid-123")
// 3. 스낵바 "네트워크 오류" 표시
// 4. 다른 화면으로 이동(UserProfileScreen 파괴)
// 5. 다시 UserProfileScreen 돌아옴
// 6. LaunchedEffect(Unit) { viewModel.loadUserProfile() } 실행(Composition 부터 다시 시작)
// 7. 마찬가지로 LaunchedEffect(state.loadEvent?.id) 실행 (같은 uuid-123, 실패 처리된 상태)
// 8. 스낵바 다시 표시!
sealed interface LoadEvent {
val id: String
data class Failed(
val message: String,
override val id: String = UUID.randomUUID().toString()
) : LoadEvent
}
data class UserUiState(
val user: User? = null,
val loadEvent: LoadEvent? = null // 여기에 실패 이벤트가 계속 남아있음
)
화면 진입 → loadUserProfile() 호출
API 실패 → LoadEvent.Failed(message="네트워크 오류", id="uuid-123") 이벤트 새로 발행
스낵바 "네트워크 오류" 표시
다른 화면으로 이동(UserProfileScreen 파괴)
다시 UserProfileScreen 돌아옴
LaunchedEffect(Unit) 실행(컴포지션부터 다시 시작)
마찬가지로 LaunchedEffect(state.loadEvent?.id) 실행 (같은 uuid-123, 실패 처리된 상태)
스낵바 다시 표시!
요약하자면,
뷰모델이 컴포저블 스크린보다 생명주기가 길기 때문에,
상태에 남은 이벤트로 인한 부수효과(스낵바)는 화면 재진입시마다 발생하게 된다.
따라서, 한 번이라도 초기 로딩을 실패하면 이후 API 호출이 성공한다할지라도, 해당 화면 진입시 마다, 영원히 스낵바가 나타나는 상태가 유지된다...좀비 스낵바
sideEffect를 rememberRetained 로 관리하기 때문에 Presenter가 파괴되어도, Circuit의 내부 저장소(정확힌 RetainedStateRegistry와 ContinuityViewModel로 구성된 내부 저장 시스템, 숨겨진 AAC ViewModel의 정체)에 보관했다가 같은 Screen으로 다시 돌아올 때 백스택에서 해당 Screen의 상태를 복원한다.
따라서 이전의 SideEffect도 그대로 유지된다.
API 호출이 성공할 경우, (runCatching을 사용한다면) onSuccess 블럭내에서 uiState내에 LoadEvent를 clear해주면 된다.(null로 변경)
근데 이러면 사실상 UUID 방법과 명시적 clear처리를 둘다 해줘야하기에, clear처리를 하지 않기위한 목적인 UUID 도입의 의미가 사라졌다...
이벤트를 clear해주는 작업을 하는 행위자체는 문제는 아니기 때문에, 다른 방법을 찾지못했다면 명시적으로 clear해주는 방식으로 돌아가면 된다.
더 나은 방식이 있다면 적용해보고 싶다.
해결법을 댓글로 남겨주시면 정말 큰 도움이 될 것 같습니다 ㅎㅎ
일회성 이벤트를 StateFlow, Compose State로 처리하는 경우 발생하는 '명시적으로 이벤트를 소비' 처리해줘야하는 단점을 해결할 수 있는 방법을 제안해보았고, 이에 대한 한계를 언급해보았다.
사실 Channel/SharedFlow를 사용하는 경우의 문제인 이벤트가 유실되는 문제는 다음과 같은 방법들로 해결이 가능하다.


https://youtu.be/njchj9d_Lf8?si=dF8T-pSNPG1FfoTO
따라서, 일회성 이벤트를 어떤 방식으로 처리할지는 사실 정답은 없는 것이기 때문에, 왜 그 방식을 채택했는지에 대한 근거만 확실하다면 본인이 선호하는 방식을 통해 이벤트를 처리하면 될 듯하다.
reference)
https://github.com/Kotlin/kotlinx.coroutines/issues/2886
https://medium.com/androiddevelopers/viewmodel-one-off-event-antipatterns-16a1da869b95
https://proandroiddev.com/viewmodel-events-as-state-are-an-antipattern-35ff4fbc6fb6
https://youtu.be/njchj9d_Lf8?si=dF8T-pSNPG1FfoTO
https://slackhq.github.io/circuit/states-and-events/
https://github.com/next-step/android-github-compose/pull/74
https://proandroiddev.com/android-one-off-events-approaches-evolution-anti-patterns-add887cd0250
https://blog.shreyaspatil.dev/understanding-dispatchers-main-and-mainimmediate
구글 형님들이 말씀하신 Channel이 데이터 유실 가능성이 있다는 내용은 잘 공감이 안가네요..
Conflated + Buffer 오버플로우 상황이라면 모르겠는데 이런 상황을 만들기조차 쉽지가 않죠.
대부분 이런 1회성 이벤트는 여러 화면에서 Common하게 사용하는 경우도 있는데 이런 케이스일수록 이벤트를 한곳에서만 소비할 수 있게 Channel을 사용하는게 맞다고 생각합니다.
Channel은(일반적으로) 버퍼가 가득 찰 경우 버퍼가 비워질 때까지 suspend 되는 특성이 있어서 신뢰성있는 데이터전송엔 더 좋을 것 같아요ㅎㅎ
https://medium.com/prnd/viewmodel에서-더이상-eventflow를-사용하지-마세요-3974e8ddffed
저는 이 글을 보고 Channel.BUFFERED를 사용하면 이벤트가 유실되지 않는다고 생각해서, Channel을 해당 글에 나와있는 방식대로 사용하는 게 현재까지는 그래도 정답에 제일 가깝다라고 생각했어요.
그런데 해당 글에 나와있는대로 Channel을 사용해도 이벤트 유실 문제가 발생할 수가 있는 건가요?