
안드로이드 UDF를 이야기할 때 많은 글이 곧바로 MVI, reducer, intent 같은 단어로 들어갑니다. 그런데 실무에서 더 먼저 필요한 것은 패턴 이름이 아니라 상태가 어디서 어떻게 바뀌는지 한눈에 보이게 만드는 것입니다.
결론부터 말하면 UDF의 핵심은 화면 상태를 한 방향으로 읽게 하고, 상태 변경도 예측 가능한 경로로만 일어나게 만드는 데 있습니다. 꼭 모든 화면을 MVI처럼 다시 설계해야 하는 것은 아닙니다. MVVM을 쓰고 있어도 이 감각만 제대로 가져오면 상태 버그와 디버깅 비용이 꽤 줄어듭니다.
왜 UDF가 먼저 중요할까
UDF를 모른 채 MVVM을 쓰면 겉으로는 역할이 나뉜 것처럼 보여도, 실제 상태 변경 경로는 여러 갈래로 흩어지는 경우가 많습니다. Fragment가 로딩 상태를 바꾸고, ViewModel도 다른 타이밍에 같은 값을 바꾸고, 성공 이벤트를 상태 객체 안에 넣었다가 다시 지우는 식입니다.
- 어디서 상태가 바뀌었는지 추적하기 어렵다
- 토스트나 네비게이션 같은 이벤트가 재실행되기 쉽다
- 비동기 로직과 렌더링 로직이 뒤섞이며 디버깅 비용이 커진다
그래서 UDF는 유행 패턴이라기보다 상태 변경을 통제하기 위한 안전장치에 가깝게 이해하는 편이 좋습니다.
UDF를 한 문장으로 보면
- 화면은 현재 상태를 읽는다
- 사용자의 액션은 이벤트로 위에 전달된다
- 상태 변경은 한 곳에서 일어난다
- 새 상태가 다시 화면으로 내려온다
즉, 화면은 상태를 받아서 그리는 쪽에 가깝고, 상태를 바꾸는 판단은 ViewModel 같은 state holder 쪽으로 모입니다. 이 구조가 잡히면 읽는 방향과 바꾸는 방향이 섞이지 않고, 화면 코드도 렌더링에 더 집중할 수 있습니다.
MVVM에서도 UDF는 충분히 적용할 수 있다
UDF를 들으면 곧바로 MVI로 갈아타야 한다고 느끼기 쉽지만, 대부분의 팀은 그 단계까지 갈 필요가 없습니다. MVVM 안에서도 화면 상태를 단일 흐름으로 보고, 사용자 액션과 일회성 이벤트를 상태와 분리하면 UDF의 핵심 이점을 상당 부분 가져올 수 있습니다.
data class SearchUiState(
val query: String = "",
val isLoading: Boolean = false,
val items: List<Article> = emptyList(),
val errorMessage: String? = null
)
sealed interface SearchEvent {
data class ShowToast(val message: String) : SearchEvent
data class NavigateToDetail(val articleId: Long) : SearchEvent
}
class SearchViewModel(
private val repository: SearchRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(SearchUiState())
val uiState: StateFlow<SearchUiState> = _uiState
private val _event = MutableSharedFlow<SearchEvent>(
replay = 0,
extraBufferCapacity = 1
)
val event: SharedFlow<SearchEvent> = _event
fun onQueryChanged(query: String) {
_uiState.value = _uiState.value.copy(query = query)
}
fun onSearchClicked() {
val currentQuery = _uiState.value.query
viewModelScope.launch {
_uiState.value = _uiState.value.copy(
isLoading = true,
errorMessage = null
)
runCatching { repository.search(currentQuery) }
.onSuccess { result ->
_uiState.value = _uiState.value.copy(
isLoading = false,
items = result
)
}
.onFailure {
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = "검색에 실패했습니다."
)
_event.emit(SearchEvent.ShowToast("잠시 후 다시 시도해주세요."))
}
}
}
fun onArticleClicked(articleId: Long) {
viewModelScope.launch {
_event.emit(SearchEvent.NavigateToDetail(articleId))
}
}
}이 구조를 보면 거창한 reducer가 없어도 핵심은 이미 들어 있습니다. 화면이 읽는 상태는 `uiState`로 모이고, 사용자 액션은 명확한 메서드로 들어오며, 토스트와 네비게이션은 별도 event 흐름으로 분리됩니다.
StateFlow가 UDF와 잘 맞는 이유
StateFlow는 최신 상태를 계속 들고 있고 새 collector도 현재 값을 바로 받을 수 있습니다. 그래서 query, 로딩 여부, 결과 목록, 빈 상태, 에러 문구처럼 지금 화면이 어떤 모습이어야 하는지를 설명하는 값과 잘 맞습니다.
UDF에서 StateFlow가 자주 등장하는 이유는 최신 기술이라서가 아니라, 화면 상태를 한 덩어리로 읽게 만들기 쉽기 때문입니다.
상태는 나중에 다시 들어온 화면도 알아야 하지만, 이벤트는 한 번 처리하고 끝나는 경우가 많습니다. `NavigateToDetail(42)`나 토스트 메시지는 이미 한 번 처리됐다면 새 collector가 과거 값을 다시 받지 않는 편이 더 자연스럽습니다.
그래서 UDF에서 state와 event를 분리하는 이유는 예뻐 보이기 위해서가 아니라, 다시 받아야 하는 값과 다시 받으면 버그가 되는 값을 나누기 위해서입니다.
state와 event를 섞으면 왜 구조가 급격히 흔들릴까
data class SearchUiState(
val isLoading: Boolean = false,
val items: List<Article> = emptyList(),
val navigateToDetailId: Long? = null,
val toastMessage: String? = null
)- 이벤트를 소비한 뒤 다시 null로 초기화해야 한다
- 초기화 타이밍이 늦으면 재구독 때 또 실행될 수 있다
- 소비 플래그가 늘어나며 상태 객체가 현재 UI와 신호를 함께 들게 된다
작은 화면에서는 잠깐 돌아갈 수 있어도, 화면이 커질수록 ViewModel이 상태 보관자이면서 이벤트 큐 역할도 같이 하게 됩니다. 실무에서 UDF가 주는 큰 이점은 바로 이 혼선을 줄여준다는 점입니다.
Compose든 View 시스템이든 수집 방식까지 맞아야 한다
상태 모델을 잘 나눴더라도 화면에서 수집을 엉뚱하게 하면 이점이 줄어듭니다. Compose라면 상태는 보통 `collectAsStateWithLifecycle()` 같은 방식으로 읽고, 이벤트는 `LaunchedEffect` 안에서 따로 처리하는 편이 자연스럽습니다.
@Composable
fun SearchRoute(
viewModel: SearchViewModel,
navigateToDetail: (Long) -> Unit,
showToast: (String) -> Unit
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.event.collect { event ->
when (event) {
is SearchEvent.NavigateToDetail -> navigateToDetail(event.articleId)
is SearchEvent.ShowToast -> showToast(event.message)
}
}
}
SearchScreen(
query = uiState.query,
isLoading = uiState.isLoading,
items = uiState.items,
errorMessage = uiState.errorMessage,
onQueryChanged = viewModel::onQueryChanged,
onSearchClicked = viewModel::onSearchClicked,
onArticleClicked = viewModel::onArticleClicked
)
}View 시스템에서도 원리는 같습니다. 화면은 상태를 구독하고 그리되, 수집은 `repeatOnLifecycle` 같은 lifecycle-aware 패턴으로 연결하는 편이 안전합니다. 결국 UDF는 state holder, event 전달, lifecycle 수집이 같이 맞물려야 효과가 납니다.
안드로이드 UDF를 적용하면 실제로 좋아지는 점
- 상태가 한 곳에서 바뀌어 디버깅 경로가 짧아진다
- 화면 코드가 렌더링과 반응에 더 집중하게 된다
- 로딩, 성공, 실패, 재시도 같은 상태 전환을 읽기 쉬워진다
- 팀 내에서 state와 event를 구분하는 공통 기준이 생긴다
그런데 어디서부터 과하게 구조화될까
UDF가 좋다고 해서 모든 화면에 Action sealed class, Mutation, Reducer, Effect 계층을 풀세트로 넣을 필요는 없습니다. 복잡한 화면에서는 도움이 될 수 있지만, 단순 폼 화면이나 읽기 전용 화면에서는 오히려 코드가 더 멀어질 수 있습니다.
- 상태보다 액션 타입 수가 더 많다
- 단순 입력 변경도 여러 계층을 거쳐야 한다
- 한 번만 쓰는 reducer가 과하게 늘어난다
- 팀원이 화면 흐름보다 보일러플레이트를 더 많이 읽게 된다
UDF의 목적은 패턴 충성도가 아니라 상태 변경 경로를 단순하게 만드는 것입니다. 구조를 넣었는데 읽기가 더 어려워졌다면, 그건 적용 강도가 과한 것일 수 있습니다.
MVVM에서 어디까지면 충분할까
- UiState를 하나 둔다
- 상태는 StateFlow로 노출한다
- one-time event는 SharedFlow 등 별도 흐름으로 분리한다
- 사용자 액션은 ViewModel의 명확한 메서드로 모은다
- 화면은 상태를 읽고 이벤트를 전달하는 역할에 집중한다
여기까지 했는데도 상태 전환이 너무 복잡하고 액션과 결과를 더 엄격하게 추적해야 한다면, 그때 reducer나 intent 체계를 더 도입해도 늦지 않습니다. 즉, 좋은 순서는 처음부터 완전한 MVI가 아니라 MVVM 안에서 UDF 원칙을 먼저 적용하고 필요할 때만 더 강한 구조를 추가하는 것입니다.
빠르게 판단하는 체크리스트
- 이 값은 화면이 지금 어떤 상태인지 설명하는가
- 새 collector가 들어와도 이 값을 다시 받아야 하는가
- 한 번 처리한 뒤 다시 전달되면 버그인가
- 상태를 바꾸는 지점이 한눈에 보이는가
- 구조를 늘렸더니 오히려 읽기 비용이 커지지 않았는가
보통 1번과 2번에 가깝다면 state 후보이고, 3번에 가깝다면 event 후보입니다. 4번이 흐리면 UDF가 아직 약한 상태고, 5번이 문제라면 과구조화를 의심하면 됩니다.
마무리
안드로이드 UDF는 MVI를 쓰느냐 마느냐보다 먼저, 상태 변경을 아무 데서나 일어나게 두지 않는 감각으로 이해하는 편이 훨씬 실용적입니다.
MVVM 안에서도 `StateFlow
반대로 작은 화면인데도 reducer, intent, effect 계층을 무조건 풀세트로 넣으면 구조가 좋아지는 것이 아니라 멀어질 수 있습니다. 그래서 중요한 것은 상태는 예측 가능하게, 이벤트는 분리해서, 구조는 필요한 만큼만입니다.
같이 보면 흐름이 더 잘 이어지는 글로는 안드로이드 MVI 패턴은 언제 잘 맞을까, StateFlow와 SharedFlow 차이, repeatOnLifecycle vs launchWhenStarted, ViewModel은 왜 필요할까를 추천합니다.
공식 기준은 Android Developers의 StateFlow and SharedFlow 문서, ViewModel overview, Where to hoist state, lifecycle-aware coroutines guide를 함께 보면 더 선명합니다.