본문 바로가기

개발 이야기/Android (안드로이드)

[Android] 3년 만에 복귀한 개발자를 위한 MVVM, ViewModel, DataBinding 완벽 정복 가이드

320x100

안녕하세요! 오랜만에 안드로이드 프로젝트로 복귀하신 것을 환영합니다.

 

3년 전이라면 아마 MVP 패턴이나 findViewById를 주로 사용하셨을 텐데요, 현재 안드로이드 개발은 MVVM (Model-View-ViewModel) 아키텍처를 중심으로 완전히 재편되었습니다.

ViewModel, DataBinding, LiveData... 처음엔 낯설지만, 한번 익숙해지면 예전 방식으로 돌아갈 수 없을 만큼 강력하고 편리합니다.

이 가이드에서는 복귀 개발자의 시각에서 가장 헷갈리는 부분만 쏙쏙 뽑아, "왜 쓰는지"부터 "어떻게 쓰는지"까지 상세하게 알려드립니다.

 

1. "대체 왜?" - MVVM, 왜 써야 하나요?

예전에는 Activity나 Fragment(View)가 API 통신, 데이터 계산, UI 변경 등 모든 것을 처리했습니다. (Massive Activity)

문제점:

  1. 생명주기(Lifecycle) 지옥: 화면 회전(Configuration Change) 시 Activity가 파괴되고 재생성되면서 모든 데이터가 날아갔습니다. 이를 살리기 위해 onSaveInstanceState 같은 복잡한 처리가 필요했습니다.
  2. 테스트 불가능: UI 코드와 비즈니스 로직이 떡칠되어 있어 유닛 테스트가 거의 불가능했습니다.
  3. findViewById의 번거로움: TextView, Button 하나 쓸 때마다 findViewById를 호출하고 null 체크를 해야 했습니다.

MVVM이 해결한 것: MVVM은 이 문제들을 '관심사의 분리(Separation of Concerns)'로 해결합니다.

  • View (Activity/Fragment): 이제 정말 '멍청한' 껍데기가 됩니다. 오직 화면에 데이터를 보여주고 (Observe), 사용자 입력을 전달만 (Event) 합니다.
  • ViewModel: View가 필요로 하는 데이터를 보관하고, 비즈니스 로직을 처리하는 '뇌'입니다. 가장 큰 특징은 View의 생명주기(화면 회전 등)보다 오래 살아남아 데이터를 보존한다는 것입니다.
  • Model (Repository): Room DB, Retrofit API 등 실제 데이터 소스를 다룹니다.

[MVVM 아키텍처 다이어그램 이미지]

View (Activity) ➡️ ViewModel (로직 처리) ➡️ Model (데이터 요청)

View (Activity) ⬅️ ViewModel (데이터 반환) ⬅️ Model (데이터 획득)

 

2. 프로젝트 준비: build.gradle 설정 (필수)

가장 먼저 build.gradle.kts (또는 build.gradle) 파일에 ViewModel과 DataBinding을 활성화해야 합니다.

// build.gradle (Module 수준)

plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
    id("kotlin-kapt") // DataBinding에 필요할 수 있음
}

android {
    // ...
    
    // DataBinding 활성화
    buildFeatures {
        dataBinding = true
    }
}

dependencies {
    // ViewModel (KTX - 코틀린 확장 기능)
    implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3") // 최신 버전 확인
    
    // LiveData (KTX) - ViewModel과 짝꿍
    implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.8.3")
    
    // Activity에서 ViewModel을 쉽게 가져오기 위한 KTX
    implementation("androidx.activity:activity-ktx:1.9.0") 
}

 

3. 핵심 1: ViewModel - 화면 회전에도 살아남는 데이터 저장소

ViewModel은 View(Activity)가 화면 회전으로 파괴되어도, 메모리에 살아남아 데이터를 보존합니다.

3-1. ViewModel 생성하기

CounterViewModel.kt 라는 클래스를 만들어 보겠습니다.

import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel

class CounterViewModel : ViewModel() {

    // 1. [외부 변경 불가 / 내부 변경 가능] LiveData 생성 (Backing Property)
    // _count는 ViewModel 내부에서만 값을 변경 (private)
    private val _count = MutableLiveData<Int>()

    // 2. [외부 공개용 / 변경 불가] LiveData (Read-Only)
    // View(Activity)는 이 count를 '관찰'만 할 수 있음 (public)
    val count: LiveData<Int> = _count

    // 3. 초기값 설정
    init {
        _count.value = 0
    }

    // 4. 비즈니스 로직 (View가 호출할 함수)
    fun increment() {
        // LiveData의 값을 변경. 
        // 이 값을 '관찰'하는 모든 View는 자동으로 UI가 업데이트됨.
        _count.value = (_count.value ?: 0) + 1
    }
    
    // ViewModel이 파괴될 때 호출됨 (Activity가 완전히 종료될 때)
    override fun onCleared() {
        super.onCleared()
        // 리소스 해제 등
    }
}

 

복귀 개발자 Check!

  • LiveData vs MutableLiveData: LiveData는 값 변경이 안 되는 '읽기 전용'입니다. MutableLiveData는 value 프로퍼티로 값 변경이 가능합니다.
  • Backing Property (뒷받침 속성): _count (private, Mutable)와 count (public, Read-Only)로 나누는 것은 MVVM의 표준 패턴입니다. View가 ViewModel의 데이터를 직접 수정하는 것을 막기 위함입니다.

3-2. Activity에서 ViewModel 사용하기

MainActivity.kt에서 ViewModel을 가져옵니다.

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.activity.viewModels // ⭐️ KTX 라이브러리 임포트
import com.example.app.databinding.ActivityMainBinding // ⭐️ DataBinding이 자동 생성한 클래스

class MainActivity : AppCompatActivity() {

    // 1. ⭐️ DataBinding 객체 선언 (null 체크 필요 없음!)
    private lateinit var binding: ActivityMainBinding

    // 2. ⭐️ 'by viewModels()' KTX를 사용해 ViewModel 인스턴스화
    //      이러면 화면 회전 시에도 같은 ViewModel 인스턴스를 재사용합니다.
    private val viewModel: CounterViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        // 3. DataBinding으로 View 인플레이트 (예전 setContentView(R.layout...))
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root) // ⭐️ binding.root를 content view로 설정
        
        // ...
    }
}

 

복귀 개발자 Check!

  • by viewModels(): 이게 마법입니다. 예전처럼 ViewModelProvider(this).get(..) 안 써도 됩니다. 이 KTX 델리게이트가 알아서 ViewModel의 생명주기를 관리해 줍니다.
  • ActivityMainBinding: activity_main.xml 레이아웃 파일에 DataBinding을 적용하면, 카멜 케이스로 변환된 Binding 클래스가 자동으로 생성됩니다. 이 객체가 TextView, Button 등 모든 View의 참조를 갖고 있습니다. (No findViewById!)

 

4. 핵심 2: DataBinding - View와 ViewModel을 연결하는 '풀'

DataBinding은 XML 레이아웃에서 ViewModel의 데이터를 직접 사용하거나, View의 이벤트를 ViewModel의 함수에 바로 연결하는 '풀' 역할을 합니다.

4-1. 레이아웃(XML) 수정

activity_main.xml을 수정합니다.

<!-- 1. ⭐️ 최상위 태그가 <layout>이어야 합니다. -->
<layout xmlns:android="[http://schemas.android.com/apk/res/android](http://schemas.android.com/apk/res/android)"
    xmlns:app="[http://schemas.android.com/apk/res-auto](http://schemas.android.com/apk/res-auto)"
    xmlns:tools="[http://schemas.android.com/tools](http://schemas.android.com/tools)">

    <!-- 2. ⭐️ 이 레이아웃에서 사용할 변수(ViewModel)를 선언합니다. -->
    <data>
        <variable
            name="vm"
            type="com.example.app.CounterViewModel" /> 
            <!-- 방금 만든 ViewModel 클래스 경로 -->
    </data>

    <!-- 기존 레이아웃 (예: ConstraintLayout) -->
    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".MainActivity">

        <TextView
            android:id="@+id/textViewCounter"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textSize="40sp"
            
            <!-- 3. ⭐️ 데이터 바인딩: vm.count(LiveData<Int>) 값을 text에 바인딩 -->
            <!-- Int를 String으로 변환해야 함 (중요!) -->
            android:text="@{String.valueOf(vm.count)}" 
            
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            tools:text="100" />

        <Button
            android:id="@+id/buttonIncrement"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="32dp"
            android:text="증가"
            
            <!-- 4. ⭐️ 이벤트 바인딩: 클릭 이벤트를 vm의 onIncrement 함수에 바로 연결 -->
            android:onClick="@{() -> vm.increment()}"
            
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/textViewCounter" />

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

 

복귀 개발자 Check!

  • @{...}: DataBinding의 표현식입니다.
  • android:text="@{String.valueOf(vm.count)}": vm.count는 LiveData<Int>입니다. android:text는 String을 받습니다. String.valueOf()로 감싸거나 vm.count.toString()을 써야 합니다. (가장 많이 하는 실수!)
  • android:onClick="@{() -> vm.increment()}": 람다식(Lambda)을 사용해 onClick 이벤트를 vm.increment() 메서드에 바로 연결합니다. Activity에 setOnClickListener가 필요 없어집니다.

4-2. Activity에서 완성하기

이제 MainActivity.kt로 돌아와 ViewModel과 LiveData를 연결하는 마지막 한 줄을 추가합니다.

// MainActivity.kt

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.activity.viewModels
import com.example.app.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding
    private val viewModel: CounterViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // 1. ⭐️ DataBinding에게 ViewModel 변수(vm)가 누구인지 알려줌
        binding.vm = viewModel

        // 2. ⭐️⭐️⭐️ [가장 중요] DataBinding이 LiveData를 관찰할 수 있도록
        //      생명주기 소유자(LifecycleOwner)를 설정합니다.
        //      이 코드가 없으면 LiveData가 변경되어도 XML이 업데이트되지 않습니다!
        binding.lifecycleOwner = this
        
        /*
         * 끝입니다!
         * * 이제 버튼을 누르면 XML(@onClick) -> ViewModel(increment) -> LiveData(_count.value)
         * -> XML(@{vm.count})이 자동으로 업데이트됩니다.
         * * Activity는 아무 코드도 작성하지 않았습니다.
         */
    }
}

복귀 개발자 Check!

  • binding.lifecycleOwner = this: 이 코드를 빠뜨려서 "데이터가 왜 안 바뀌죠?"라고 질문하는 경우가 90%입니다. LiveData가 View의 생명주기(Active/Inactive)를 알아야만 자동으로 UI를 갱신할 수 있기 때문에 필수입니다.

 

5. 결론: 왜 이게 더 좋은가?

  1. 생명주기 완전 정복: 화면을 100번 회전해도 ViewModel의 count 값은 보존됩니다.
  2. findViewById 제거: binding 객체가 모든 View를 소유하며, 타입도 안전합니다.
  3. setOnClickListener 제거: 이벤트가 XML을 통해 ViewModel로 바로 전달됩니다.
  4. 관심사 완벽 분리:
    • MainActivity는 binding.vm = viewModel, binding.lifecycleOwner = this 단 두 줄의 '연결' 코드 외에 아무것도 하지 않습니다. (멍청한 View)
    • CounterViewModel은 '증가' 로직만 담당합니다. (똑똑한 ViewModel)
  5. 테스트 용이: CounterViewModel은 안드로이드 프레임워크 의존성이 거의 없는 순수 Kotlin 클래스입니다. JVM에서 바로 유닛 테스트를 돌릴 수 있습니다.

이제 API 통신이 필요하다면 ViewModel 안에서 Retrofit을 호출하고 그 결과를 MutableLiveData에 담기만 하면 됩니다. View는 알아서 갱신될 것입니다.

이 기본기만 탄탄히 잡으시면, Flow, StateFlow, Hilt 등 다음 스텝으로 나아가기 훨씬 수월하실 겁니다. 복귀를 응원합니다!

 


 

다음 가이드 바로가기

https://elka.tistory.com/559

반응형