상태는 아래로, 이벤트는 위로만 흐르게 하는 규칙.
Repository ──데이터──→ ViewModel ──상태──→ UI
↑ ↑
└────── 호출 ──────────┴──── 이벤트 ──── 사용자
한 바퀴 돈다. 역주행이 없다
양방향이면 무엇이 문제인가
양방향 바인딩 (예: XML 의 two-way binding)
-
화면이 값을 직접 고치고, 모델도 값을 고친다
-
"이 값이 왜 3이 됐지?" 를 추적할 수 없다
- 화면이 바꿨나, ViewModel 이 바꿨나, 다른 관찰자가 바꿨나
-
순환 갱신이 생긴다 (A 가 B 를 바꾸고 B 가 다시 A 를 바꾼다)
단방향의 실제 코드
// ViewModel — 밖으로는 읽기 전용만 노출한다
private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
fun onQueryChange(q: String) { _uiState.update { it.copy(query = q) } }
// UI — 상태를 읽고 이벤트만 올린다
@Composable
fun HomeScreen(viewModel: HomeViewModel = hiltViewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
SearchBar(text = state.query, onTextChange = viewModel::onQueryChange)
}
MutableStateFlow 를 private 으로 두는 것이 핵심이다
- 밖에 노출하면 아무나 state.value = ... 로 고칠 수 있다
- 단방향이 깨진다
collectAsStateWithLifecycle을 쓰는 이유
-
collectAsState() — 화면이 백그라운드로 가도 계속 수집한다
-
collectAsStateWithLifecycle() — STARTED 아래로 내려가면 수집을 멈춘다
-
안 멈추면 보이지도 않는 화면을 위해 계속 데이터를 받고 다시 그린다
-
배터리와 네트워크 낭비
텍스트 필드에서 생기는 함정
입력 → 이벤트 → ViewModel → 상태 → 다시 그리기 한 바퀴가 도는 동안 지연이 있으면 글자가 밀린다
대응
- 로컬 remember 로 텍스트를 두고 debounce 후에만 ViewModel 로 올린다
- 또는 Compose 의 TextFieldState 를 쓴다
면접 함정
- ❌ "UDF는 Compose 전용" → View 시스템에서도 동일하게 적용한다. 패러다임이지 API가 아니다.
- ❌ "UDF면 성능이 좋다" → 얻는 것은 예측 가능성이다. 성능은 별개 축이다.
액션을 하나의 타입으로 모으기 (MVI)
sealed interface HomeIntent {
data class QueryChanged(val q: String) : HomeIntent
data object Refresh : HomeIntent
data class ItemClicked(val id: Long) : HomeIntent
}
fun onIntent(intent: HomeIntent) = when (intent) {
is HomeIntent.QueryChanged -> _uiState.update { it.copy(query = intent.q) }
HomeIntent.Refresh -> refresh()
is HomeIntent.ItemClicked -> navigateTo(intent.id)
}
-
장점 — 화면에 들어오는 모든 입력이 한 타입으로 모인다 → 로깅·재현이 쉽다
- when 이 exhaustive 라 새 액션을 빠뜨릴 수 없다
-
단점 — 콜백 하나 추가하는 데 타입 선언이 늘어난다
-
MVVM 이 UDF 를 '권장' 한다면 MVI 는 '강제' 한다 — 정도의 차이다
상태가 되돌아 오르는 흔한 누수
// ❌ UI 가 ViewModel 의 상태를 직접 고친다
viewModel.uiState.value = viewModel.uiState.value.copy(query = "x")
// ❌ Composable 이 상태를 들고 ViewModel 도 들고 있어 둘이 어긋난다
var localQuery by remember { mutableStateOf(state.query) } // 동기화 지점이 둘
둘 다 '단일 출처' 를 깬 것이다 증상: 값이 튀거나, 갱신이 한 박자 늦거나, 회전 후 옛 값이 남는다