안드로이드 아키텍처 용어 사전
MVVMUiState

UI 상태

화면 전체를 하나의 불변 객체로 표현한 것. 일회성 이벤트는 여기 넣지 않는다.

화면이 그려야 할 모든 것을 하나의 불변 객체로 모은 것.

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())

함께 보면 좋은 용어

노트에서 맥락과 함께 보기 — MVVM·단방향 데이터 흐름(UDF)