오늘의 포스팅은 아래 링크의 번역본입니다.
ViewModel: One-off event antipatterns
또한, 이 글은 UILayer
아키첵처 배경지식을 필요로 하니 공식문서 링크도 아래에 첨부했으니 참고하면 좋을 거 같습니다.
https://developer.android.com/topic/architecture/ui-layer
UI 레이어 아키텍처 가이드에서는 아래의 포인트들을 가이드하고 있습니다.
UI 상태를 정의하는 방법
UI 상태를 생성하고 관리하기 위한 단방향 데이터 흐름(UDF)
UDF 원칙에 따라 관찰 가능한 데이터 유형으로 UI 상태를 노출하는 방법
관찰 가능한 UI 상태를 사용하는 UI를 구현하는 방법
왜 구글에서는 이와 같은 가이드를 줬을까요? 이 레이어드 아키텍처에서의 이벤트 관리를 자칫 잘못하면 안티패턴이라고 말하는데 그 이유가 궁금 + 흥미로워서 갖고오게 됐습니다.
실제로 이 포스팅은 공식 문서에도 링크로 걸려 있는 부분이기도 합니다.
ViewModel 이벤트는 ViewModel에서 UI가 수행해야 하는 것에 대한 작업입니다. 예를 들어, 사용자에게 보여줘야하는 메시지를 표시하거나 app 상태가 변경될 때 다른 화면으로 이동합니다.
특히 UDF(단방향 데이터 흐름)에서는 이벤트를 생산자보다 오래 살아 있는 소비자에게만 전송할 수 있는 이점을 설명합니다.
ViewModel 이벤트에 대해 우리는 2가지 방식으로 처리하는 걸 권장합니다.
ViewModel에서 일회성 이벤트가 발생할 때마다 ViewModel은 상태 업데이트를 유발하는 해당 이벤트를 즉시 처리해야 합니다.
코틀린 Channels
나 SharedFlow
와 같은 reactive stream을 사용해서 ViewModel 이벤트를 UI에 노출하는 것은 다른 프로젝트에서 본 패턴일 수 있습니다. 생산자(ViewModel)가 소비자(UI-View or Compose)보다 오래 사는 경우(ViewModel의 이벤트)에 API는 해당 이벤트의 전달 및 처리를 보장하지 않습니다.
이는 개발자에게 버그 혹은 이슈를 야기할 수 있고 대부분의 앱에서 수용할 수 없는 UX입니다.
UI 상태 업데이트가 일어날 때 ViewModel 이벤트를 즉시 처리해야 합니다. Channel / SharedFlow와 같은 다른 reactive 솔루션을 사용해서 이벤트를 노출하려해도 이벤트의 전달 및 처리가 보장되지 않기 때문입니다.
여기 앱에서 일어나는 전형적인 결제 flow을 ViewModel에서 구현한 예시가 있습니다.
코드 스니펫을 보면, MakePaymentViewModel
은 결제 요청에 대한 response가 들어오면 결제 결과 화면으로 이동하도록 UI에 직접적으로 지시합니다. 여기 예시에서 이와 같은 일회성 ViewModel 이벤트를 처리하면 문제가 발생하고 유지보수 비용이 높아지는 이유를 확인할 수 있습니다.
class MakePaymentViewModel(...) : ViewModel() {
val uiState: StateFlow<MakePaymentUiState> = /* ... */
// ⚠️⚠️ DO NOT DO THIS!! ⚠️⚠️
// This one-off ViewModel event hasn't been handled nor reduced to state
// Boolean represents whether or not the payment was successful
private val _navigateToPaymentResultScreen = Channel<Boolean>()
// `receiveAsFlow` makes sure only one collector will process each
// navigation event to avoid multiple back stack entries
val navigateToPaymentResultScreen = _navigateToPaymentResultScreen.receiveAsFlow()
// Protecting makePayment from concurrent callers
// If a payment is in progress, don't trigger it again
private var makePaymentJob: Job? = null
fun makePayment() {
if (makePaymentJob != null) return
makePaymentJob = viewModelScope.launch {
try {
_uiState.update { it.copy(isLoading = true) } // Show loading spinner
val isPaymentSuccessful = paymentsRepository.makePayment(...)
_navigateToPaymentResultScreen.send(isPaymentSuccessful)
} catch (ioe: IOException) { ... }
finally { makePaymentJob = null }
}
}
}
UI코드는 이 이벤트를 아래와 같이 소비합니다.
class MakePaymentActivity : AppCompatActivity() {
private val viewModel: MakePaymentViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.navigateToPaymentResultScreen.collect { isPaymentSuccessful ->
val intent = Intent(this, PaymentResultActivity::class.java)
intent.putExtra("PAYMENT_RESULT", isPaymentSuccessful)
startActivity(intent)
finish()
}
}
}
}
}
그리고 이 뷰에는 여러가지 결함들이 보입니다.
채널(reative stream)은 이벤트의 전달 및 처리를 보장하지 않습니다. 따라서 이벤트가 손실되어 UI가 일관되지 않은 상태가 될 수 있습니다. 이러한 예는 UI(소비자)가 백그라운드로 이동하여 ViewModel(생산자)이 이벤트를 보낸 직후에 채널 수집을 중지하는 경우에 발생할 수 있습니다. SharedFlow와 같이 관찰 가능한 데이터 홀더 유형이 아닌 다른 API도 마찬가지이며, 이를 듣는 소비자가 없어도 이벤트를 내보낼 수 있습니다.
만약 ACID transaction 관점(원자성, 일관성, 독립성, 지속성)에서 생각한다면 UI 계층에서 모델링된 payment result state가 지속성이 없거나 원자성 보장되지 않기 때문에 이것은 안티 패턴 입니다. repository에서 api를 찔러서 한 결제는 성공했을지 모르지만, 적절한 다음 화면으로 이동하지는 않았습니다.
그러나 이 안티 패턴은
Dispatchers.Main.immediate
를 사용해서 보완할 수 있습니다. 그러나 link check를 통해 이 방법을 강제적으로 시행하고자 커스텀하지 않으면 개발자는 쉽게 잊어버리기 때문에 실수할 수 있습니다.
여러 화면 크기를 지원하는 앱의 경우 ViewModel 이벤트가 발생하면 화면 크기에 따라 수행하는 UI 작업이 다를 수 있습니다. 예를 들어 사례 연구 앱은 휴대폰에서 실행할 때 결제 결과 화면으로 이동해야 하지만, 앱이 태블릿에서 실행 중인 경우 같은 화면의 다른 부분에 결과가 표시될 수 있습니다.
ViewModel은 UI에 앱 상태를 알려주어야 하며 UI는 이를 반영하는 방법을 결정해야 합니다. ViewModel은 UI에 어떤 작업을 수행해야 하는지 알려주면 안 됩니다.
이벤트를 그냥 쓰다가는 조그만 불씨가 번져가듯 나중에 걷잡을 수 없는 문제가 됩니다. ACID 속성을 준수하기 어렵기 때문에 데이터 신뢰성과 무결성을 보장할 수 없습니다.
State is, events happen.
이벤트가 처리되지 않는 시간이 길어질수록 문제는 더 어려워집니다. ViewModel 이벤트의 경우 이벤트를 최대한 빨리 처리하고 해당 이벤트에서 새 UI 상태를 생성합니다.
그리고 이 case study
에서 이벤트 객체를 만들었습니다. -Boolean으로 표현하고- Channel을 이용해서 노출했습니다.
// Create Channel with the event modeled as a Boolean
val _navigateToPaymentResultScreen = Channel<Boolean>()
// Trigger event
_navigateToPaymentResultScreen.send(isPaymentSuccessful)
이렇게 하면, 정확히 한 번 배달하고 처리하는 것과 같은 것들을 확실히 해야 할 책임이 있습니다. 어떤 이유로 이벤트를 객체로 모델링해야 하는 경우 이벤트가 손실되지 않도록 가능한 짧게 수명을 제한합니다.
ViewModel에서 일회성 이벤트를 처리하는 것은 일반적으로 메서드 호출(예: UI 상태 업데이트)로 연결됩니다. 이 메서드를 호출하면 해당 메서드가 성공적으로 완료되었는지 아니면 예외를 발생했는지 알 수 있으며, 해당 메서드가 정확히 한 번 발생했음을 알 수 있습니다.
-> sealed class
가 생각나지 않나요?
이러한 상황 중 하나일 경우 일회성 View Model 이벤트가 실제로 UI에 어떤 영향을 미치는지 다시 생각해 보십시오. 즉시 처리하고 StateFlow
또는 mutableStateOf
와 같은 옵저빙 가능한 데이터 홀더를 사용하여 노출되는 UI 상태로 안티패턴을 줄여봅시다.
UI state better represents the UI at a given point in time, it gives you more delivery and processing guarantees, it’s usually easier to test, and it integrates consistently with the rest of your app.
UI 상태는 지정된 시점의 UI를 더 잘 나타내고, 더 많은 전송 및 처리를 보장하며, 테스트하기 더 쉬우며, 앱의 나머지 부분과 일관되게 통합됩니다.
일회성 View Model 이벤트를 상태까지 줄이는 방법을 찾는 데 어려움을 겪는 경우 해당 이벤트가 UI에 실제로 어떤 의미인지 다시 생각해 보십시오.
위의 예에서 ViewModel은 UI에 수행할 작업을 지시하는 대신 실제 애플리케이션 데이터(이 경우 결제 관련 데이터)를 노출해야 합니다. 다음은 ViewModel 이벤트를 처리하여 상태를 이용하고 observable data holder type을 사용하여 더 잘 나타낸 것입니다.
data class MakePaymentUiState(
val paymentInformation: PaymentModel,
val isLoading: Boolean = false,
// PaymentResult models the application state of this particular payment attempt,
// `null` represents the payment hasn't been made yet.
val paymentResult: PaymentResult? = null
)
class MakePaymentViewModel(...) : ViewModel() {
private val _uiState = MutableStateFlow<MakePaymentUiState>(...)
val uiState: StateFlow<MakePaymentUiState> = _uiState.asStateFlow()
// Protecting makePayment from concurrent callers
// If a payment is in progress, don't trigger it again
private var makePaymentJob: Job? = null
fun makePayment() {
if (makePaymentJob != null) return
makePaymentJob = viewModelScope.launch {
try {
_uiState.update { it.copy(isLoading = true) }
val isPaymentSuccessful = paymentsRepository.makePayment(...)
// 이벤트는 새 paymentResult 데이터로 _uiState.update를 호출하여 즉시 처리됩니다
_uiState.update {
it.copy(
isLoading = false,
paymentResult = PaymentResult(it.paymentInfo, isPaymentSuccessful)
)
}
} catch (ioe: IOException) { ... }
finally { makePaymentJob = null }
}
}
}
이벤트는 state로 축소되었으며, MakePaymentUiState의 paymentResult 필드는 결제 결과 적용 데이터를 반영합니다.
이를 통해 UI는 결제 결과 변경에 대응하고 그에 따라 작동합니다.
class MakePaymentActivity : AppCompatActivity() {
private val viewModel: MakePaymentViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { uiState ->
if (uiState.paymentResult != null) {
val intent = Intent(this, PaymentResultActivity::class.java)
intent.putExtra(
"PAYMENT_RESULT",
uiState.paymentResult.isPaymentSuccessful
)
startActivity(intent)
finish()
}
}
}
}
}
}
case study에서 액티비티가 완료되지 않고 백스택에 보관되어 있는 경우, ViewModel은 다른 액티비티가 시작된 후 호출되는 paymentResult를 UiState에서 지우는 기능(즉, 필드를 null로 설정)을 노출해야 합니다. 이 예제는 문서의 상태 업데이트를 트리거할 수 있는 이벤트 소비 섹션에서 확인할 수 있습니다.
UI 계층의 추가 고려 사항 섹션에서 언급했듯이 여러 스트림으로 화면의 UI 상태를 노출할 수 있습니다. 중요한 것은 이러한 스트림들이 observable data holder type이라는 것입니다.
위의 예에서는 isLoading 플래그
와 paymentResult 속성
이 고도로 얽혀 있기 때문에 고유한 UI 상태 스트림이 노출됩니다.
예를 들어, isLoading이 true이고 paymentResult가 null이 아닌 경우 UI에 불일치가 발생할 수 있습니다. 그것들을 같은 UiState 클래스에 함께 두면, 우리는 더 적은 버그만 신경쓸 수 있습니다.
이 블로그 게시물을 통해 1) 일회성 View Model 이벤트를 즉시 처리하여 상태로 축소하고 2) 관찰 가능한 데이터 홀더 유형을 사용하여 상태를 노출할 것을 권장하는 이유를 이해할 수 있기를 바랍니다.
참고로!!! 아키텍처 지침의 나머지 부분과 마찬가지로 이 지침을 지침으로 간주하고 필요에 따라 요구사항에 맞게 조정하세요
이에 대한 자세한 내용은 UI 이벤트에 대한 공식 문서를 보길 바랍니다.
번역투와 백그라운드 때문에 이해가 자칫 어려울 수 있는데, 한마디로 정리하면 UI 레이어에서는 UI가 중요하기 때문에 이를 위한 이벤트 타이밍을 잘 관리해야한다고 이해했습니다.
이벤트를 처리하는데 가장 안티패턴이라고 외치는 주범 중 하나는 reative stream처럼 이벤트를 UI에서 받고 처리하는 것이 아닌, 시간적 딜레이가 있거나 UI lifecycle이 이미 destroyed 되면 반영이 안될 수 있다는 단점이 있는 친구들을 안티 패턴이라고 말하는 거 같네요.
글 저자가 마지막에도 말했듯이 굳이 이걸 완전한 가이드로, "이렇게 따라야해!!"로 강제성이나 의무성은 없지만 어느정도 왜 안티 패턴이라고 보는지 이해는 갔습니다.
실제로 저희 회사 프로젝트에서도 어쩔 수 없이 글로벌한 이벤트로 쓰이는 경우도 있으니깐요. 다만 잘 고심해서 사용해야할 것 같습니다!
(이 글도 번역본이라 천천히 수정될 수 있음을 미리 말하고 마무리하겠습니다 수정됐습니다! 여기까지 정독하시느라 고생하셨습니다)
좋은 번역 감사드립니다!