|

안드로이드 ViewModel 상태: UI 상태와 비즈니스 상태 경계

안드로이드 ViewModel 상태 경계를 설명하는 대표 이미지
ViewModel이 가져야 할 UI 상태와 넘겨야 할 비즈니스 상태의 경계를 정리한다

안드로이드 ViewModel 상태를 어디까지 가져가야 할지 헷갈릴 때가 많습니다. 이번 글에서는 UI 상태와 비즈니스 상태를 나누고, input state, loading state, domain state, one-time event를 어디에 두면 좋은지 실무 기준으로 정리해보겠습니다.


ViewModel은 화면 상태 holder다

Android 공식 문서는 ViewModel을 화면 수준의 state holder이자 관련 비즈니스 로직을 캡슐화하는 자리로 설명합니다. 실무적으로 보면 ViewModel은 화면이 지금 무엇을 보여줘야 하는지 설명하는 상태를 가지고, 사용자 액션 이후 어떤 흐름을 밟아야 하는지 조율하지만, 데이터의 최종 소유자나 비즈니스 규칙의 본체까지 전부 떠안는 자리는 아닙니다.

즉, ViewModel은 저장소라기보다 경계면에 가깝습니다. 도메인과 데이터 계층에서 올라온 결과를 화면이 읽기 좋은 상태로 정리하고, 화면에서 발생한 이벤트를 아래 계층으로 연결하는 역할입니다.


왜 경계가 흐려질까

현실의 화면에는 사용자가 입력 중인 검색어, 로딩 스피너 표시 여부, 검색 결과 목록, 빈 결과 화면 표시 여부, 에러 문구 표시 여부, 다음 화면으로 이동해야 하는 후속 동작까지 함께 존재합니다. 겉으로는 모두 상태처럼 보이지만 성격은 서로 다르기 때문에, 한 덩어리로 취급하면 금방 혼란이 생깁니다.


상태를 네 가지로 나누자

  • input state: 검색어, 체크박스, 폼 입력값처럼 사용자가 화면에서 직접 바꾸는 값
  • loading state: 작업 진행 여부, 새로고침 여부, 버튼 비활성화처럼 화면 피드백과 연결된 값
  • domain state: 사용자 정보, 주문 상태, 서버 원본 모델, 저장 규칙처럼 비즈니스 의미를 가진 데이터
  • one-time event에 가까운 UI action: 토스트, 스낵바, 특정 화면 이동처럼 바로 처리해야 하는 동작

중요한 포인트는 네 가지가 모두 ViewModel 소유는 아니라는 점입니다.


UI 상태와 비즈니스 상태를 나누는 이유

Android 공식 아키텍처 가이드는 UI state를 만드는 로직을 UI logic과 business logic으로 구분합니다. business logic은 데이터에 대해 무엇을 할지 결정하고, UI logic은 그 결과를 화면에 어떻게 보여줄지 결정합니다.

예를 들어 북마크를 실제로 저장하는 일은 business logic이고, 버튼을 비활성화할지, 로딩을 보여줄지, 성공 후 어떤 문구를 보여줄지는 UI logic입니다. 이 둘을 섞으면 ViewModel이 점점 비대해지고, 반대로 구분하면 ViewModel이 무엇을 가져야 하는지가 더 명확해집니다.


input state는 언제 ViewModel에 둘까

입력 상태는 많은 경우 ViewModel에 두는 편이 유리합니다. 입력값은 화면이 계속 읽고, 사용자가 바꾸고, 재구성 이후에도 유지되면 좋은 경우가 많기 때문입니다.

data class SearchUiState(
    val query: String = "",
    val isLoading: Boolean = false,
    val items: List<ArticleItem> = emptyList(),
    val errorMessage: String? = null
)

하지만 모든 입력 상태를 무조건 ViewModel에 올릴 필요는 없습니다. 아주 짧게 존재하고, 화면이 사라지면 같이 사라져도 문제 없는 값이라면 UI local state에 두는 편이 더 단순할 수 있습니다.

  • 화면 재생성과 함께 유지 가치가 큰가
  • 다른 UI 상태와 함께 판단되어야 하는가
  • 입력값 변화가 비즈니스 흐름과 연결되는가

이 질문에 대부분 예라면 ViewModel 쪽이 자연스럽습니다.


loading state는 보통 UI 상태다

로딩 여부는 서버나 데이터 계층의 진실 그 자체라기보다 화면이 지금 어떤 반응을 보여야 하는지에 대한 정보입니다. 그래서 loading state는 대개 ViewModel의 UI state에 들어가는 것이 자연스럽습니다.

fun search() = viewModelScope.launch {
    _uiState.update { it.copy(isLoading = true, errorMessage = null) }

    runCatching { repository.search(_uiState.value.query) }
        .onSuccess { result ->
            _uiState.update {
                it.copy(
                    isLoading = false,
                    items = result,
                    errorMessage = null
                )
            }
        }
        .onFailure { throwable ->
            _uiState.update {
                it.copy(
                    isLoading = false,
                    errorMessage = throwable.message ?: "검색에 실패했습니다."
                )
            }
        }
}

핵심은 로딩 상태가 데이터 저장 규칙이 아니라 화면 렌더링 규칙에 가깝다는 점입니다. 따라서 로딩, 빈 화면, 에러 표시 가능 여부는 UI state로 모아두는 편이 읽기 쉽습니다.


domain state는 ViewModel의 원본이 아니다

서버에서 받아온 데이터가 화면에 보이더라도, domain state의 본체는 보통 domain layer나 data layer에 있습니다. Repository는 원본 데이터 흐름을 관리하고, UseCase는 비즈니스 규칙을 적용하며, ViewModel은 그 결과를 화면용 UI state로 바꾸는 쪽이 자연스럽습니다.

class ArticleViewModel(
    private val observeArticle: ObserveArticleUseCase
) : ViewModel() {

    val uiState: StateFlow<ArticleUiState> =
        observeArticle()
            .map { article ->
                ArticleUiState(
                    title = article.title,
                    summary = article.summary,
                    isBookmarked = article.isBookmarked
                )
            }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = ArticleUiState(isLoading = true)
            )
}

이 구분이 없으면 ViewModel이 작은 캐시 저장소처럼 커지기 쉽습니다.


one-time event는 어디에 둘까

저장 성공 후 뒤로 이동, 로그인 실패 후 스낵바 표시, 결제 완료 후 완료 화면 이동처럼 일회성 동작은 특히 자주 흔들리는 주제입니다. Android 공식 UI events 가이드는 가능한 한 이런 동작도 UI state 업데이트로 환원하라고 권장합니다.

이유는 단발성 신호가 설정 변경 시 재현이 어렵고, 타이밍에 따라 UI가 놓칠 수 있기 때문입니다. 먼저 이것이 정말 상태로 표현할 수 없는지 검토하는 편이 더 안전합니다.

data class PaymentUiState(
    val isLoading: Boolean = false,
    val isPaymentCompleted: Boolean = false,
    val errorMessage: String? = null
)

UI는 이 상태를 보고 이동을 실행한 뒤, 필요하면 ViewModel에 처리 완료를 다시 알려 상태를 정리할 수 있습니다.


ViewModel에 두기 좋은 것과 아닌 것

  • ViewModel에 두기 좋은 것: 화면이 읽어야 하는 UI state, 유지 가치가 있는 입력값, 로딩 여부, 빈 상태 표시 여부, 에러 표시 정보, 도메인 결과를 화면용으로 가공한 값
  • ViewModel에 두지 않는 편이 좋은 것: TextView나 Context 같은 View 참조, 화면 안에서만 잠깐 필요한 아주 지역적인 표현 상태, 데이터 저장 규칙의 본체, 영속 상태를 직접 책임지는 로직, UI가 놓치면 위험한 단발성 신호를 무심코 흘려보내는 패턴

ViewModel은 모든 것을 들고 있는 큰 상자가 아니라, 화면이 필요한 사실을 안정적으로 노출하는 자리입니다.


실무 판단 질문 5개

  1. 이 값은 화면을 그리기 위해 필요한가
  2. 이 값은 화면 재생성 이후에도 유지 가치가 있는가
  3. 이 값은 비즈니스 규칙의 원본인가, 아니면 화면용 표현인가
  4. 이 값은 오래 유지되는 상태인가, 아니면 지금 한 번 처리하면 되는 액션인가
  5. UI가 이 값을 놓치면 버그가 될 수 있는가

이 질문에 답하면 보통 위치가 정해집니다. 화면용 표현이면 ViewModel UI state, 원본 비즈니스 데이터면 domain/data layer, 화면 안에서만 잠깐 쓰는 지역 상태면 UI local state, 단발성 액션처럼 보여도 놓치면 안 되면 상태로 환원할 방법을 먼저 검토하면 됩니다.


로그인 화면 예시

로그인 화면을 예로 들면 이메일과 비밀번호 입력값은 ViewModel UI state 또는 UI local state 후보가 되고, 로그인 버튼 로딩 여부와 성공 여부는 ViewModel UI state가 되며, 실제 토큰 저장은 data layer 책임, 로그인 정책 검증은 domain layer 책임, 성공 후 홈 화면 이동은 가능한 한 성공 상태를 기반으로 UI가 처리하는 구조가 자연스럽습니다.


마무리

안드로이드 ViewModel 상태를 어디까지 가져가야 하는지 헷갈릴 때는 ViewModel을 만능 저장소로 보지 않는 것이 출발점입니다. ViewModel은 화면을 위한 상태 holder이므로 input state와 loading state처럼 화면 렌더링에 직접 필요한 값은 많이 가져갈 수 있지만, domain state의 원본과 비즈니스 규칙의 본체는 아래 계층에 남아야 합니다.

그리고 one-time event처럼 보이는 것도 먼저 UI state로 바꿔 설명할 수 있는지 검토하는 편이 더 안전합니다. 이 기준만 잡혀도 ViewModel은 가벼워지고, 화면 코드는 읽기 쉬워집니다.

관련 흐름을 먼저 정리하고 싶다면 Activity와 Fragment는 왜 나뉘어 있을까, 생명주기를 알아야 설계가 쉬워지는 이유, 안드로이드 코루틴 기초 정리도 함께 보면 연결이 더 잘 됩니다.

공식 기준을 직접 확인하고 싶다면 ViewModel overview, State holders and UI state, UI events 문서도 함께 참고해보세요.

함께보면 좋은 글