|

안드로이드 앱 구조 입문 시리즈 (4편) – ViewModel은 왜 필요할까

안드로이드 앱 구조 입문 시리즈 4편 ViewModel은 왜 필요할까 대표 이미지
ViewModel이 화면 상태와 처리 흐름을 분리하는 이유를 쉽게 설명한다

ViewModel은 왜 필요할까라는 질문은 안드로이드 앱 구조를 이해할 때 꼭 한 번 부딪히게 됩니다. 이번 글에서는 ViewModel을 권장되는 클래스가 아니라 화면 상태와 UI 로직의 책임을 분리하는 구조적 도구로 설명해보겠습니다.


생명주기를 알아도 화면 코드는 왜 복잡해질까

앞선 글에서 생명주기를 알아야 설계가 쉬워지는 이유를 봤습니다. 화면은 만들어졌다가 사라지고, 다시 만들어질 수 있습니다. 그런데 생명주기를 안다고 해서 상태를 어디에 둘까라는 문제가 자동으로 해결되지는 않습니다.

실제로 더 어려운 질문은 이런 것들입니다. 사용자가 입력하던 검색어는 어디에 둘까, 로딩 중이라는 상태는 누가 기억할까, 검색 결과는 어디에서 보관할까, 에러 메시지는 누가 관리할까. 즉, 생명주기는 배경을 설명해주지만 상태와 로직의 자리까지 정해주지는 않습니다.


먼저 문제부터 보자: Fragment에 상태와 로직을 몰아두면 생기는 일

검색 화면 하나를 떠올려보겠습니다. 이 화면은 보통 검색어 입력, 버튼 클릭 처리, 로딩 표시, 검색 결과 표시, 에러 메시지 표시, 화면 재생성 이후 상태 복원을 함께 다뤄야 합니다. 처음에는 이걸 Fragment 안에 다 넣고 싶어집니다. 작을 때는 그게 제일 빨라 보이기 때문입니다.

class SearchFragment : Fragment() {

    private var query: String = ""
    private var isLoading: Boolean = false
    private var items: List<String> = emptyList()
    private var errorMessage: String? = null

    fun onSearchClicked() {
        isLoading = true
        showLoading()

        fakeSearch(
            query = query,
            onSuccess = { result ->
                isLoading = false
                items = result
                showItems(items)
            },
            onError = { message ->
                isLoading = false
                errorMessage = message
                showError(message)
            }
        )
    }
}

이 코드는 처음엔 이해하기 쉽습니다. 하지만 기능이 조금만 늘어나도 금방 불편해집니다. Fragment가 화면 표시, 상태 보관, 검색 처리 흐름, 성공/실패 분기, 결과 반영, 재생성 이후 상태 복구 고민까지 모두 떠안기 때문입니다.


이 구조가 왜 실제로 불편해질까

예를 들어 검색 화면에 아래 요구가 추가됐다고 해보겠습니다. 검색 중에는 버튼 비활성화, 결과가 비어 있으면 빈 상태 UI 표시, 에러가 나면 재시도 버튼 표시, 화면이 다시 만들어져도 마지막 검색 결과 유지. 그러면 Fragment 안에서 로딩이 끝났는가, 결과가 비었는가, 에러가 있는가, 기존 결과는 지워야 하는가를 한꺼번에 신경 써야 합니다.

이런 구조는 처음엔 빨리 만들 수 있어도, 나중에 수정할 때 어디를 바꿔야 하지가 점점 흐려집니다.

안 쓸 때와 쓸 때 차이를 한 번에 보자

같은 검색 화면을 기준으로 비교해보겠습니다.

  • ViewModel이 없을 때: Fragment가 검색어 저장, 로딩 상태 저장, 결과 저장, 에러 저장, 성공/실패 분기, 빈 결과 판단, 재생성 이후 상태 복구 고민까지 모두 맡는다
  • ViewModel이 있을 때: Fragment는 입력과 렌더링에 집중하고, ViewModel은 현재 화면 상태를 가지고 검색 흐름과 성공/실패/빈 결과를 상태로 정리한다
Fragment에 상태를 몰아둔 구조와 ViewModel로 분리한 구조 비교 다이어그램
Fragment에 책임이 몰린 구조와 ViewModel로 상태를 분리한 구조 비교

왼쪽 구조에서는 SearchFragment 하나가 상태 보관, 검색 처리, 화면 갱신을 모두 맡고 있습니다. 반면 오른쪽 구조에서는 Fragment는 입력과 렌더링에 집중하고, ViewModel은 상태와 처리 흐름을 맡습니다. 핵심 차이는 코드 줄 수보다 책임이 어디에 모여 있는가입니다.


ViewModel은 어떤 자리를 대신 맡아줄까

ViewModel은 화면을 그리는 객체가 아닙니다. 버튼을 직접 누르거나 TextView를 직접 바꾸는 객체도 아닙니다. 가장 쉽게 말하면 ViewModel은 화면을 위한 상태와 처리 흐름을 맡는 자리입니다.

  • Fragment / Activity: 사용자 입력 받기, 이벤트 전달하기, 상태를 읽고 화면 그리기
  • ViewModel: 화면 상태 보관하기, 사용자 액션 이후의 흐름 처리하기, 화면에 필요한 상태로 정리하기

이렇게 나누면 Fragment는 보여주는 일에 집중하고, ViewModel은 상태와 흐름 관리에 집중할 수 있습니다.


코드로 보면 차이가 더 분명하다

이번에는 같은 검색 화면을 ViewModel 구조로 바꿔보겠습니다. 먼저 화면 상태를 하나로 묶습니다.

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

이제 ViewModel이 이 상태를 관리합니다.

class SearchViewModel : ViewModel() {

    private val _uiState = MutableStateFlow(SearchUiState())
    val uiState: StateFlow<SearchUiState> = _uiState

    fun onQueryChanged(newQuery: String) {
        _uiState.value = _uiState.value.copy(query = newQuery)
    }

    fun onSearchClicked() {
        val currentQuery = _uiState.value.query

        _uiState.value = _uiState.value.copy(
            isLoading = true,
            errorMessage = null,
            isEmpty = false
        )

        fakeSearch(
            query = currentQuery,
            onSuccess = { result ->
                _uiState.value = _uiState.value.copy(
                    isLoading = false,
                    items = result,
                    isEmpty = result.isEmpty()
                )
            },
            onError = { message ->
                _uiState.value = _uiState.value.copy(
                    isLoading = false,
                    errorMessage = message
                )
            }
        )
    }
}

그리고 Fragment는 상태를 보고 그립니다.

class SearchFragment : Fragment() {

    private val viewModel: SearchViewModel by viewModels()

    fun bind() {
        observe(viewModel.uiState) { state ->
            render(state)
        }
    }

    fun onSearchButtonClicked(query: String) {
        viewModel.onQueryChanged(query)
        viewModel.onSearchClicked()
    }

    private fun render(state: SearchUiState) {
        showLoading(state.isLoading)
        showItems(state.items)
        showError(state.errorMessage)
        showEmpty(state.isEmpty)
    }
}

이 구조에서는 역할이 더 선명합니다. Fragment는 입력 받고 상태를 보여주고, ViewModel은 상태를 만들고 바꿉니다. 즉, 읽는 사람도 이 코드는 어디를 봐야 하지가 더 명확해집니다.

수정할 때 차이는 더 크게 드러난다

이제 새 요구사항을 넣어보겠습니다. 검색 결과가 비어 있으면 빈 상태 화면을 보여주고, 재검색 시 이전 에러 메시지는 지운다고 해보겠습니다.

  • ViewModel이 없을 때: Fragment 안에서 검색 성공 분기 수정, 에러 분기 수정, 빈 상태 분기 추가, 렌더링 코드 수정, 상태 초기화 로직 추가가 함께 흔들린다
  • ViewModel이 있을 때: ViewModel의 uiState 변경 로직을 수정하고, Fragment의 render()에 빈 상태 표시만 추가하면 된다

둘 다 수정은 필요합니다. 하지만 어디를 보면 되는지가 훨씬 분명합니다. 이게 실제 유지보수에서 체감되는 차이입니다.


왜 이 코드가 더 배우기 쉬울까

이 구조가 좋은 이유는 최신 방식이라서가 아닙니다. 입문자에게도 역할이 더 눈에 잘 보이기 때문입니다. 검색 실패를 수정하고 싶다면 ViewModel의 상태 변경 흐름을 먼저 보면 되고, 검색 결과를 화면에 어떻게 보여주는지 보고 싶다면 Fragment의 render()를 보면 됩니다.

즉, 상태를 바꾸는 코드와 상태를 보여주는 코드가 분리되기 때문에 코드를 읽고 익히기가 더 쉬워집니다.


ViewModel에 무엇을 두면 좋을까

  • ViewModel에 두기 좋은 것: 화면 상태, 사용자 액션 이후의 처리 흐름, 로딩/성공/실패 상태, 데이터를 화면에 맞게 정리하는 일
  • ViewModel에 두지 않는 것이 좋은 것: View 직접 제어 코드, Button/TextView/RecyclerView 같은 화면 객체 참조, Activity나 Fragment에 강하게 묶인 코드

즉, ViewModel은 화면을 직접 만지는 곳이 아니라 화면이 어떤 상태여야 하는지 정리하는 곳에 가깝습니다.


그렇다고 ViewModel이 만능은 아니다

ViewModel을 쓴다고 해서 자동으로 구조가 좋아지는 것은 아닙니다. 잘못 쓰면 이번에는 ViewModel이 비대해질 수도 있습니다. 한 화면의 모든 책임을 무조건 한 ViewModel에 몰아넣거나, 상태를 정리하지 않고 값만 계속 늘리거나, UI 로직과 데이터 로직을 다 한곳에 섞어두는 경우입니다.

즉, 중요한 것은 ViewModel 자체보다 책임 분리 기준입니다. ViewModel은 그 기준을 잘 적용했을 때 힘을 내는 도구입니다.


이번 글에서 꼭 기억하면 좋은 한 문장

ViewModel은 권장 클래스라서 필요한 것이 아니라, 화면 상태와 UI 로직의 책임을 분리하기 위해 필요하다. 이 관점이 잡히면 왜 안드로이드 구조에서 ViewModel이 자주 등장하는지 훨씬 자연스럽게 이해할 수 있습니다.


마무리

Activity와 Fragment에 상태와 로직을 모두 몰아두면 처음에는 빨리 만들 수 있어도 화면이 커질수록 읽기 어렵고 수정하기도 어려워집니다. ViewModel은 바로 그 문제를 줄이기 위한 구조적 도구입니다.

이전 흐름을 먼저 정리하고 싶다면 Activity와 Fragment는 왜 나뉘어 있을까생명주기를 알아야 설계가 쉬워지는 이유를 함께 읽어보는 것도 좋습니다.

다음 글에서는 여기서 한 걸음 더 나아가 화면 상태는 어디에 두는 게 맞을까를 더 구체적으로 다뤄보겠습니다.

함께보면 좋은 글