화면이 그려야 할 모든 것을 하나의 불변 객체로 모은 것.
data class HomeUiState(
val isLoading: Boolean = false,
val users: List<User> = emptyList(),
val error: String? = null
)
왜 하나로 모으나
❌ 상태를 여러 개로 나누면 val isLoading = MutableStateFlow(false) val users = MutableStateFlow(emptyList<User>()) val error = MutableStateFlow<String?>(null)
- 세 값이 각각 갱신되며 중간의 모순된 조합이 화면에 그려진다
- (로딩 중인데 에러도 표시되는 한 프레임)
✅ 하나로 묶으면 항상 일관된 스냅샷이 그려진다
_uiState.update { it.copy(isLoading = false, users = result) } // 원자적으로 갱신
불가능한 상태를 없앤다
// 플래그 조합은 말이 안 되는 상태를 표현할 수 있다
// isLoading = true 인데 error 도 있는 상태가 만들어진다
// sealed 로 바꾸면 애초에 불가능해진다
sealed interface HomeUiState {
data object Loading : HomeUiState
data class Success(val users: List<User>) : HomeUiState
data class Error(val message: String) : HomeUiState
}
둘 중 무엇을 쓰나
플래그 조합 부분 갱신이 잦고 여러 요소가 동시에 존재한다 (당겨서 새로고침 + 목록)
sealed 상태가 배타적이다 (로딩 중에는 목록이 없다)
혼합도 흔하다 — 큰 틀은 sealed, 그 안에서 플래그
일회성 이벤트는 상태가 아니다
// ❌ 상태에 넣으면 회전할 때 토스트가 다시 뜬다
data class UiState(val toastMessage: String? = null)
// ✅ 이벤트는 별도 채널로
private val _events = MutableSharedFlow<UiEvent>()
val events = _events.asSharedFlow()
sealed interface UiEvent {
data class ShowToast(val msg: String) : UiEvent
data class Navigate(val route: String) : UiEvent
}
판별 기준
- "화면을 다시 그릴 때 이것도 다시 반영돼야 하나?"
- 그렇다 → 상태 (StateFlow)
- 아니다 → 이벤트 (SharedFlow · Channel)
토스트 · 네비게이션 · 스낵바는 전부 이벤트다
면접 함정
- ❌ "이벤트도 상태로 두고 소비 후 null로 되돌리면 된다" → 동작하지만 소비 처리를 잊기 쉽고 경합이 생긴다. 별도 스트림이 명확하다.
- ❌ "UiState를 잘게 나눠야 성능이 좋다" → 오히려 모순된 조합이 그려진다. Compose는 변경된 부분만 다시 그린다.
상태를 어디서 만드나 — ViewModel vs UI
간단한 변환은 UI 에서
- Text(text = "%,d원".format(state.price))
비즈니스 규칙이 섞이면 ViewModel 에서
- val canSubmit = name.isNotBlank() && agreed && !isLoading
판별: "이 판단이 틀리면 버그인가, 보기 나쁠 뿐인가?"
- 버그다 → ViewModel (테스트 대상)
여러 스트림을 하나로 합치기
val uiState: StateFlow<HomeUiState> = combine(
repository.observeUsers(),
savedStateHandle.getStateFlow("query", ""),
_isRefreshing
) { users, query, refreshing ->
HomeUiState(
users = users.filter { it.name.contains(query) },
query = query,
isRefreshing = refreshing
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), HomeUiState())
combine이 UiState를 만드는 유일한 자리가 되면, 상태 조합 로직이 한 곳에 모여 모순이 생길 수 없다.
안정성(stability)과 리컴포지션
Compose 는 파라미터가 안 바뀌면 다시 그리지 않는다 그런데 List<User> 는 불안정(unstable) 로 취급된다
- 인터페이스라 구현이 바뀔 수 있다고 컴파일러가 보수적으로 판단한다
해결
- kotlinx.collections.immutable 의 ImmutableList 사용
- 또는 @Immutable 애노테이션으로 약속
data class HomeUiState(val users: ImmutableList<User> = persistentListOf())