실제 앱을 만들다 보면 remember와 mutableStateOf만으로는 해결되지 않는 상황이 생깁니다.
"API를 호출해서 받아온 데이터를 화면에 보여줘야 하는데, 이 State는 어디에 있어야 하지?"
"화면을 회전했더니 데이터가 날아갔어."
"버튼을 눌렀을 때 다른 화면으로 이동하고 싶은데, 컴포저블 안에서 처리하면 될까?"
이런 질문들은 대부분 State를 어디서 관리해야 하는지 기준이 없어서 생깁니다.
컴포저블 안에서 remember로 관리하는 것만으로는 한계가 있고, 그렇다고 무조건 ViewModel에 올리는 것도 정답이 아닙니다.
이번 글에서는 State가 어디에 있어야 하는지 기준을 잡고, ViewModel과 UDF 패턴을 활용해 실제 앱에서 어떻게 데이터 흐름을 설계하는지 살펴보겠습니다.
1. UDF란 무엇인가
이런 문제를 구조적으로 해결하는 방식이 UDF(Unidirectional Data Flow, 단방향 데이터 흐름)입니다.
UDF의 핵심은 다음과 같습니다.
- State는 위에서 아래로 흐릅니다
- Event는 아래에서 위로 올라갑니다

UI는 State를 보고 화면을 그리는 역할만 합니다. 사용자가 버튼을 누르는 등의 액션이 발생하면 UI는 직접 처리하지 않고 ViewModel에 Event를 전달합니다. ViewModel이 이를 처리해서 새로운 State를 만들어 내려보내면, UI는 그것을 받아 화면을 다시 그립니다.
이 구조 덕분에 State가 어디서 어떻게 변경되는지 항상 추적 가능해지고, 예측 가능한 UI를 만들 수 있습니다.
이는 Android 공식 아키텍처 가이드에서도 권장하는 방식입니다.
2. State를 ViewModel까지 올려야 하는 기준
1편에서 다뤘던 State Hoisting은 State를 하위 컴포저블에서 상위 컴포저블로 끌어올리는 패턴이었습니다.
"그렇다면 State는 항상 ViewModel에 있어야 할까?"
그렇지 않습니다.
ViewModel은 UI와 분리된 상태에서 비즈니스 로직과 UI State를 관리하는 클래스로, 구성 변경(화면 회전 등)에도 소멸되지 않는다는 특징이 있습니다. 그렇다고 모든 State를 ViewModel에 올리는 것이 정답은 아닙니다.
Android 공식 문서에서는 State를 소비하는 위치에 가장 가깝게 두는 것을 원칙으로 합니다.
여기서 "State를 소비한다"는 것은 해당 State를 읽어서 UI를 그리거나 로직에 활용한다는 의미이고,
"가장 가깝게 둔다"는 것은 그 State가 필요한 컴포저블들의 가장 가까운 공통 상위 위치에 State를 둔다는 의미입니다.
컴포저블 안에서 관리해도 되는 경우
@Composable
fun ExpandableCard() {
var expanded by remember { mutableStateOf(false) }
Card(onClick = { expanded = !expanded }) {
if (expanded) { /* 내용 */ }
}
}
expanded State는 다음 조건을 모두 만족합니다.
- 이 컴포저블 내부에서만 사용됨
- 다른 화면과 공유할 필요 없음
- 비즈니스 로직 없음
- 화면이 사라질 때 함께 사라져도 무방함
이런 경우에는 ViewModel까지 올릴 필요가 없습니다.
2-1. ViewModel까지 올려야 하는 경우
공식 문서에서는 다음 조건에 해당할 때 ViewModel에서 State를 관리하도록 안내합니다.
① 구성 변경(화면 회전 등)에도 State가 유지되어야 할 때
ViewModel은 구성 변경 시에도 소멸되지 않습니다. 사용자가 입력 중인 폼 데이터, API에서 불러온 목록 데이터처럼 화면이 재생성되어도 유지되어야 하는 데이터는 ViewModel이 관리하는 것이 적합합니다.
② 여러 컴포저블이 동일한 State를 공유해야 할 때
State를 공통 상위 컴포저블로 올리다 보면, 자연스럽게 ViewModel이 가장 적절한 위치가 되는 경우가 많습니다.
③ 비즈니스 로직이 포함될 때
API 호출, 데이터 변환, 유효성 검사 같은 비즈니스 로직은 컴포저블이 아닌 ViewModel에서 처리해야 합니다. 컴포저블은 UI를 그리는 역할에만 집중해야 합니다.
3. ViewModel에서 State 관리하는 패턴
3-1. UiState sealed class
sealed class UiState {
object Loading : UiState()
data class Success(val items: List<Item>) : UiState()
data class Error(val message: String) : UiState()
}
sealed class는 정해진 하위 타입만 가질 수 있는 클래스입니다. 이를 활용해 화면이 가질 수 있는 상태를 명시적으로 정의합니다.
로딩 중인지, 데이터가 있는지, 에러가 발생했는지를 하나의 타입으로 표현하고, UI에서 when 분기로 모든 상태를 빠짐없이 처리할 수 있습니다.
3-2. StateFlow로 State 내려보내기
ViewModel에서 UI로 State를 전달할 때는 StateFlow를 사용합니다.
StateFlow는 Kotlin의 Flow 중 하나로, 현재 값을 항상 보유하고 있으며 값이 변경될 때마다 구독자에게 새로운 값을 전달하는 데이터 스트림입니다. 항상 최신 값을 가지고 있기 때문에 새로운 구독자가 생기면 즉시 마지막 값을 받을 수 있습니다. 이 특성이 UI State를 관리하기에 적합합니다.
class MyViewModel : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
fun loadData() {
viewModelScope.launch {
try {
val result = repository.getData()
_uiState.value = UiState.Success(result)
} catch (e: Exception) {
_uiState.value = UiState.Error(e.message ?: "에러 발생")
}
}
}
}
_uiState는 ViewModel 내부에서만 변경 가능하고, 외부에는 읽기 전용인 uiState만 노출합니다. 이를 통해 State의 변경 주체를 ViewModel로 한정할 수 있습니다.
4. UI에서 State 받기
4-1. collectAsState()
@Composable
fun MyScreen(viewModel: MyViewModel = viewModel()) {
val uiState by viewModel.uiState.collectAsState()
when (uiState) {
is UiState.Loading -> LoadingView()
is UiState.Success -> SuccessView((uiState as UiState.Success).items)
is UiState.Error -> ErrorView((uiState as UiState.Error).message)
}
}
collectAsState()는 StateFlow를 Compose의 State로 변환해 줍니다. StateFlow의 값이 변경될 때마다 recomposition이 발생합니다.
4-2. collectAsStateWithLifecycle()
collectAsState()에는 한 가지 문제가 있습니다. 생명주기를 인식하지 못해서, 앱이 백그라운드로 전환되어 화면이 보이지 않는 상황에서도 Flow 수집이 계속됩니다. 사용자 눈에 보이지도 않는 화면을 위해 리소스를 계속 소비하는 셈입니다.
collectAsStateWithLifecycle()은 이 문제를 해결합니다. androidx.lifecycle:lifecycle-runtime-compose 라이브러리에서 제공하는 함수로, 생명주기를 인식해 UI가 백그라운드 상태일 때 수집을 자동으로 중단하고, 포그라운드로 복귀하면 재개합니다. 공식 문서에서는 Compose에서 Flow를 수집할 때 이 함수를 사용하도록 권장합니다.
// build.gradle에 의존성 추가 필요
// implementation "androidx.lifecycle:lifecycle-runtime-compose:2.x.x"
@Composable
fun MyScreen(viewModel: MyViewModel = viewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
when (uiState) {
is UiState.Loading -> LoadingView()
is UiState.Success -> SuccessView((uiState as UiState.Success).items)
is UiState.Error -> ErrorView((uiState as UiState.Error).message)
}
}
| collectAsState() | collectAsStateWithLifecycle() | |
| 생명주기 인식 | ❌ | ✅ |
| 백그라운드 수집 중단 | ❌ | ✅ |
| 공식 권장 여부 | - | ✅ |
5. Event 처리 방식
UI에서 사용자 액션이 발생하면 ViewModel에 전달해야 합니다.
가장 기본적인 방식은 UI가 ViewModel의 함수를 직접 호출하는 것입니다.
// ViewModel
fun onLoginClick(id: String, password: String) {
viewModelScope.launch {
_uiState.value = LoginUiState.Loading
// 로그인 처리
}
}
// UI
Button(onClick = { viewModel.onLoginClick(id, password) }) {
Text("로그인")
}
5-1. 일회성 이벤트와 SharedFlow
"로그인 성공 후 다음 화면으로 이동"과 같은 일회성 이벤트는 StateFlow로 처리하면 문제가 발생할 수 있습니다.
StateFlow는 마지막 값을 유지하므로, 화면이 재생성될 때 이미 처리된 이벤트가 다시 발생할 수 있기 때문입니다.
이런 경우 SharedFlow를 사용합니다. SharedFlow는 StateFlow와 달리 마지막 값을 유지하지 않는 데이터 스트림입니다.
값을 emit하면 현재 구독 중인 구독자에게만 전달되고 사라지기 때문에, 화면이 재생성되어도 이미 처리된 이벤트가 다시 발생하지 않습니다.
// ViewModel
private val _event = MutableSharedFlow<MyEvent>()
val event: SharedFlow<MyEvent> = _event.asSharedFlow()
sealed class MyEvent {
object NavigateToHome : MyEvent()
data class ShowSnackbar(val message: String) : MyEvent()
}
| StateFlow | SharedFlow | |
| 초기값 | 필요 | 불필요 |
| 마지막 값 유지 | ✅ | ❌ (기본값) |
| 적합한 용도 | UI State | 일회성 이벤트 |
다만 일회성 이벤트 처리 방식은 팀 컨벤션과 아키텍처 설계에 따라 다양한 접근법이 존재합니다. SharedFlow 외에도 Channel을 사용하는 방식 등이 있으며, 각각 트레이드오프가 다릅니다.
6. 실전 예시 - UDF 전체 흐름
지금까지 다룬 개념을 로그인 화면 예시로 연결해 보겠습니다.
// 1. UiState 및 Event 정의
sealed class LoginUiState {
object Idle : LoginUiState()
object Loading : LoginUiState()
data class Error(val message: String) : LoginUiState()
}
sealed class LoginEvent {
object NavigateToHome : LoginEvent()
}
// 2. ViewModel
class LoginViewModel : ViewModel() {
private val _uiState = MutableStateFlow<LoginUiState>(LoginUiState.Idle)
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
private val _event = MutableSharedFlow<LoginEvent>()
val event: SharedFlow<LoginEvent> = _event.asSharedFlow()
fun onLoginClick(id: String, password: String) {
viewModelScope.launch {
_uiState.value = LoginUiState.Loading
try {
repository.login(id, password)
_event.emit(LoginEvent.NavigateToHome)
} catch (e: Exception) {
_uiState.value = LoginUiState.Error(e.message ?: "로그인 실패")
}
}
}
}
// 3. UI
@Composable
fun LoginScreen(
viewModel: LoginViewModel = viewModel(),
onNavigateToHome: () -> Unit
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 일회성 이벤트 수신
LaunchedEffect(Unit) {
viewModel.event.collect { event ->
when (event) {
is LoginEvent.NavigateToHome -> onNavigateToHome()
}
}
}
when (uiState) {
is LoginUiState.Loading -> LoadingView()
is LoginUiState.Error -> ErrorView((uiState as LoginUiState.Error).message)
else -> LoginForm(onLoginClick = viewModel::onLoginClick)
}
}
전체 데이터 흐름은 다음과 같습니다.
사용자가 로그인 버튼 클릭
↓ Event (onLoginClick 호출)
ViewModel이 처리 (API 호출)
↓ State 업데이트 (Loading → Error 또는 성공)
UI가 State를 받아 화면 업데이트
↓ 성공 시 일회성 Event (NavigateToHome emit)
LaunchedEffect가 수신하여 화면 이동
7. 결론
이번 글에서는 State를 어디에 두어야 하는지 기준을 잡고, ViewModel과 UDF 패턴으로 실제 앱의 데이터 흐름을 어떻게 구성하는지 살펴봤습니다.
핵심은 State가 어디서 변경되는지를 항상 예측 가능하게 만드는 것입니다.
UDF는 이를 위한 구조적 원칙이고, ViewModel은 그 원칙을 실제 코드로 구현하기 위한 도구입니다.
State를 어디에 둘지, 어떻게 흐르게 할지를 명확히 설계하는 것이 유지보수 가능한 Compose 앱의 첫걸음입니다.
| UDF | State는 위에서 아래로, Event는 아래에서 위로 |
| State 위치 기준 | 생명주기, 공유, 비즈니스 로직 여부에 따라 결정 |
| UiState sealed class | 화면 상태를 명시적으로 표현 |
| collectAsStateWithLifecycle | 생명주기 인식, 공식 권장 방식 |
| 일회성 이벤트 | SharedFlow 활용 |
참고 자료
- Android 공식 문서 - UI Layer
- Android 공식 문서 - ViewModel Overview
- Android 공식 문서 - StateFlow and SharedFlow
- Android 공식 문서 - collectAsStateWithLifecycle
'Compose' 카테고리의 다른 글
| Jetpack Compose Recomposition - 왜 느리지 않을까? (2) | 2026.02.05 |
|---|---|
| Jetpack Compose 입문 : 선언형 UI와 State 관리 (0) | 2026.01.25 |