안드로이드 아키텍처 컴포넌트 실무 예제로 보는 동작 원리

2017년 Google I/O에서 안드로이드 아키텍처 컴포넌트(Android Architecture Components)가 처음 세상에 공개되었을 때, 저는 안드로이드 개발 생태계가 거대한 전환점을 맞이했음을 직감했습니다. 그 이전까지 안드로이드 개발은 마치 정해진 규칙 없는 서부 개척 시대와 같았습니다. 개발자들은 각자의 신념에 따라 MVC, MVP, MVVM 등의 아키텍처를 적용했고, 소스 코드는 각기 다른 방언처럼 파편화되어 있었습니다. 화면 회전 한 번에 공들여 불러온 데이터가 허무하게 사라지는 경험, 생명주기가 꼬여 발생하는 예측 불가능한 메모리 누수와 앱 비정상 종료는 개발자들을 밤새 괴롭히는 단골손님이었습니다.

이러한 혼돈 속에서 구글이 직접 제시한 공식 설계도, 그것이 바로 Jetpack의 심장인 아키텍처 컴포넌트입니다. 이제는 선택을 넘어 현대 안드로이드 개발의 표준으로 자리 잡은 Lifecycle, ViewModel, LiveData, Room. 이 네 가지 기둥이 어떻게 상호작용하며 견고하고 테스트 가능하며 유지보수가 용이한 애플리케이션을 만들어내는지, 그 깊은 동작 원리를 실무 예제와 함께 샅샅이 파헤쳐 보겠습니다. 이 글은 단순히 API 사용법을 나열하는 것을 넘어, 풀스택 개발자의 관점에서 '왜 이 기술이 필요했는가?'라는 근본적인 질문에 답하고, 여러분의 프로젝트를 한 단계 성장시킬 실질적인 통찰력을 제공하는 것을 목표로 합니다.

이 글에서 다루는 핵심 내용:
  • Lifecycle: 복잡한 생명주기 관리를 어떻게 우아하게 분리하고 자동화하는가?
  • ViewModel: 화면 회전에도 끄떡없는 UI 상태 데이터를 어떻게 안전하게 보존하는가?
  • LiveData & StateFlow: 생명주기를 인지하는 데이터 스트림은 어떻게 UI를 반응형으로 만드는가?
  • Room: 번거로운 SQLite 작업을 어떻게 현대적이고 안전한 ORM으로 대체하는가?
  • Synergy: 이 네 가지 컴포넌트가 결합하여 완성되는 현대적인 MVVM 아키텍처의 전체 그림

1. Lifecycle: 안드로이드 생명주기 혼돈의 종결자

안드로이드 개발의 입문 과정에서 가장 먼저 마주하는 거대한 산은 바로 생명주기(Lifecycle)입니다. ActivityFragment는 사용자의 상호작용, 시스템 이벤트에 따라 onCreate(), onStart(), onResume(), onPause(), onStop(), onDestroy()와 같은 복잡한 상태 전환을 겪습니다. 과거에는 이러한 생명주기 콜백 메서드 안에 비즈니스 로직을 직접욱여넣는 것이 일반적이었습니다.


// 과거의 방식: Activity가 모든 것을 책임진다.
class LegacyMapActivity : AppCompatActivity() {
    private var locationListener: LocationListener? = null
    private var camera: Camera? = null

    override fun onStart() {
        super.onStart()
        // 위치 리스너 등록
        locationListener = LocationListener { /* ... */ }
        LocationManager.getInstance().registerListener(locationListener)
    }

    override fun onResume() {
        super.onResume()
        // 카메라 리소스 획득
        try {
            camera = Camera.open()
        } catch (e: Exception) {
            // 예외 처리
        }
    }

    override fun onPause() {
        super.onPause()
        // 카메라 리소스 해제
        camera?.release()
        camera = null
    }

    override fun onStop() {
        super.onStop()
        // 위치 리스너 해제 (이걸 잊으면 메모리 누수!)
        locationListener?.let {
            LocationManager.getInstance().unregisterListener(it)
        }
        locationListener = null
    }
}

위 코드는 소위 'God Object'가 되어버린 Activity의 전형적인 모습입니다. 위치 업데이트, 카메라 제어 등 화면과 직접 관련 없는 로직들이 Activity의 생명주기 콜백에 덕지덕지 붙어있습니다. 이런 코드는 몇 가지 심각한 문제를 야기합니다.

  • 책임의 불분명: Activity는 UI를 그리는 역할과 비즈니스 로직을 처리하는 역할을 모두 떠안아 코드가 비대해지고 복잡해집니다.
  • 유지보수의 어려움: 새로운 기능(예: 블루투스 연결)을 추가하려면 또다시 onStart/onStop에 코드를 추가해야 합니다. 로직이 곳곳에 흩어져 있어 수정이 어렵고 실수를 유발하기 쉽습니다.
  • 테스트의 불가능: Activity의 생명주기에 강하게 결합된 코드는 안드로이드 프레임워크 의존성 때문에 단위 테스트가 거의 불가능합니다.

Lifecycle 컴포넌트는 바로 이 문제를 해결하기 위해 탄생했습니다. 핵심 아이디어는 생명주기 관련 로직을 Activity/Fragment로부터 분리하여 독립적인 객체로 만들고, 이 객체가 스스로 자신의 생명주기를 관리하도록 만드는 것입니다.

1.1. LifecycleOwner와 LifecycleObserver: 역할의 분리

Lifecycle 컴포넌트는 두 가지 핵심 추상화를 통해 동작합니다. 마치 매니저와 직원의 관계와 같습니다.

  • LifecycleOwner (매니저): ActivityFragment처럼 자신만의 생명주기를 가진 주체입니다. 이들은 "나는 지금 '시작' 상태야", "이제 '중지' 상태로 바뀌었어" 와 같이 자신의 상태를 외부에 알릴 수 있습니다. AppCompatActivityFragment는 이미 LifecycleOwner 인터페이스를 구현하고 있으며, getLifecycle() 메서드를 통해 자신의 Lifecycle 객체를 제공합니다.
  • LifecycleObserver (직원): LifecycleOwner의 상태 변화를 지켜보는 관찰자입니다. 매니저의 상태가 바뀔 때마다(예: ON_START, ON_STOP 이벤트 발생) 자신이 해야 할 일을 수행합니다.

1.2. DefaultLifecycleObserver를 통한 스마트한 리팩토링

최신 안드로이드 개발에서는 DefaultLifecycleObserver 인터페이스를 구현하여 생명주기를 감지하는 방식을 권장합니다. 이는 어노테이션 방식보다 타입 세이프하고, 내부적으로 추가 코드를 생성하지 않아 더 효율적입니다.


import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner

// 스스로 생명주기를 관리하는 똑똑한 위치 리스너 클래스
class MyLifecycleAwareLocationListener(
    private val lifecycleOwner: LifecycleOwner,
    private val onLocationChanged: (Location) -> Unit
) : DefaultLifecycleObserver {

    private var isListenerRegistered = false

    init {
        // 생성과 동시에 자기 자신을 '직원'으로 등록
        lifecycleOwner.lifecycle.addObserver(this)
    }

    // '매니저'(LifecycleOwner)가 onStart 상태가 되면 자동으로 호출됨
    override fun onStart(owner: LifecycleOwner) {
        if (!isListenerRegistered) {
            println("위치 리스너 등록: 화면이 활성화되었습니다.")
            LocationManager.getInstance().registerListener(onLocationChanged)
            isListenerRegistered = true
        }
    }

    // '매니저'(LifecycleOwner)가 onStop 상태가 되면 자동으로 호출됨
    override fun onStop(owner: LifecycleOwner) {
        if (isListenerRegistered) {
            println("위치 리스너 해제: 화면이 비활성화되었습니다.")
            LocationManager.getInstance().unregisterListener(onLocationChanged)
            isListenerRegistered = false
        }
    }

    // '매니저'(LifecycleOwner)가 완전히 파괴되면 스스로를 정리
    override fun onDestroy(owner: LifecycleOwner) {
        println("옵저버 제거: Activity/Fragment가 파괴되었습니다.")
        lifecycleOwner.lifecycle.removeObserver(this)
    }
}

// Activity는 이제 훨씬 더 깔끔하고 자신의 역할에만 집중할 수 있다.
class NewMapActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_map)

        // 리스너를 생성하기만 하면 끝!
        // 시작(onStart)과 중지(onStop)는 리스너가 알아서 처리한다.
        val locationListener = MyLifecycleAwareLocationListener(this) { location ->
            // 위치가 변경되면 UI 업데이트
            updateMapLocation(location)
        }
    }
}

결과를 보세요! NewMapActivity는 더 이상 onStart, onStop 콜백을 오버라이드할 필요가 없습니다. 그저 MyLifecycleAwareLocationListener를 생성하기만 하면, 이 리스너가 Activity의 생명주기에 맞춰 스스로 등록하고 해제합니다. 이것이 바로 관심사의 분리(Separation of Concerns)의 힘입니다. 코드는 재사용 가능하고, 예측 가능하며, 테스트하기 쉬운 형태로 발전했습니다.

1.3. 코루틴과의 완벽한 통합: lifecycleScope

현대 안드로이드 개발에서 비동기 처리는 코루틴(Coroutines)의 시대입니다. 하지만 코루틴을 잘못 사용하면 생명주기 문제에서 자유로울 수 없습니다. 예를 들어, Fragment에서 네트워크 요청을 시작했는데 사용자가 화면을 나가버리면 어떻게 될까요? 이미 파괴된 View에 결과를 그리려다 앱이 비정상 종료될 수 있습니다. lifecycle-viewmodel-ktx 라이브러리가 제공하는 lifecycleScope는 이 문제를 완벽하게 해결합니다.


import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch

class UserProfileFragment : Fragment() {

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        // 이 코루틴은 Fragment의 View 생명주기에 묶입니다.
        // viewLifecycleOwner는 onCreateView ~ onDestroyView 사이에서만 유효합니다.
        viewLifecycleOwner.lifecycleScope.launch {
            try {
                // 'launch' 블록은 STARTED 상태에서 시작되고,
                // Fragment View가 DESTROYED 상태가 되면 자동으로 취소(cancel)됩니다.
                val userProfile = ApiService.fetchUserProfile("userId") // suspend 함수
                textViewUserName.text = userProfile.name
                imageViewProfile.load(userProfile.avatarUrl)
            } catch (e: CancellationException) {
                // 코루틴이 취소될 때 발생하는 예외. 정상적인 상황이므로 무시해도 됨.
                println("네트워크 요청이 취소되었습니다.")
            } catch (e: Exception) {
                // 기타 네트워크 오류 처리
            }
        }
    }
}

viewLifecycleOwner.lifecycleScope.launch를 사용하면 개발자가 직접 코루틴의 Job을 관리하고 onDestroyView에서 수동으로 job.cancel()을 호출하는 번거로운 작업을 할 필요가 없습니다. 프레임워크가 생명주기에 맞춰 모든 것을 자동으로 처리해주므로, 메모리 누수나 불필요한 백그라운드 작업을 원천적으로 차단할 수 있습니다. 특히 Fragment에서는 lifecycleScope보다 viewLifecycleOwner.lifecycleScope를 사용하는 것이 더 안전하다는 점을 기억하세요. Fragment 인스턴스는 살아있지만 View만 파괴되는 경우(예: 백스택)를 완벽하게 처리할 수 있기 때문입니다.

2. ViewModel: UI 상태 데이터의 영원한 안식처

긴 회원가입 폼을 열심히 작성하다가 무심코 스마트폰을 가로로 돌렸을 때, 모든 내용이 사라져버린 끔찍한 경험을 해본 적이 있으신가요? 이는 안드로이드의 구성 변경(Configuration Changes)이라는 특성 때문에 발생합니다. 화면 회전, 언어 변경, 다크 모드 전환 등이 일어나면 안드로이드 시스템은 현재 Activity를 파괴(destroy)하고 새로운 설정에 맞춰 다시 생성(recreate)합니다. 이 과정에서 Activity의 멤버 변수에 저장된 데이터는 모두 초기화됩니다. ViewModel은 바로 이 문제를 해결하기 위해 태어난, 구성 변경에도 살아남는 데이터 보관소입니다.

2.1. ViewModel의 생존 비결: ViewModelStore

ViewModel은 어떻게 Activity의 생명주기를 초월하여 존재할 수 있을까요? 그 비밀은 ActivityFragment가 내부적으로 가지고 있는 ViewModelStore라는 객체에 있습니다.

  1. Activity가 처음 생성될 때, 프레임워크는 이 Activity에 대한 ViewModelStore를 함께 생성합니다. ViewModelStore는 해시맵처럼 ViewModel들을 저장하는 단순한 컨테이너입니다.
  2. 개발자가 ViewModelProvider를 통해 ViewModel을 요청하면, 시스템은 이 ViewModelStore에서 해당 타입의 ViewModel이 있는지 확인합니다. 없으면 새로 생성하여 저장하고, 있으면 기존 인스턴스를 반환합니다.
  3. 화면 회전으로 Activity가 파괴될 때, ViewModelStore는 파괴되지 않고 메모리에 그대로 남아있습니다.
  4. 새로 생성된 Activity 인스턴스는 시스템으로부터 이전 Activity가 사용하던 바로 그 ViewModelStore를 물려받습니다.
  5. 새로운 Activity가 ViewModel을 다시 요청하면, ViewModelStore에 이미 저장되어 있던 기존 ViewModel 인스턴스가 반환됩니다. 데이터는 그대로 보존된 상태입니다!

이러한 메커니즘 덕분에 ViewModel은 Activity가 완전히 종료될 때(예: 사용자가 뒤로가기 버튼을 누르거나 finish()가 호출될 때) 비로소 onCleared() 콜백을 호출하며 메모리에서 해제됩니다.

ViewModel은 UI를 위한 데이터를 준비하고 관리하는 역할을 합니다. 절대로 View(Activity/Fragment)나 Context에 대한 참조를 가져서는 안 됩니다. 만약 ViewModel이 View를 참조하면, 화면 회전 후 파괴된 과거의 Activity가 메모리에서 해제되지 못하는 심각한 메모리 누수를 유발할 수 있습니다.

2.2. ViewModel의 현대적 사용법 (feat. KTX)

activity-ktxfragment-ktx 라이브러리 덕분에 ViewModel을 사용하는 것은 매우 간결해졌습니다. by viewModels()라는 프로퍼티 위임(property delegate)을 사용하면 됩니다.


import androidx.lifecycle.ViewModel
import androidx.activity.viewModels

// 1. UI 데이터를 보관할 ViewModel 클래스를 정의합니다.
// 이 클래스는 순수한 비즈니스 로직과 데이터만 다루며, 안드로이드 프레임워크에 대한 의존성이 거의 없습니다.
class UserProfileViewModel : ViewModel() {
    // LiveData나 StateFlow를 사용하여 UI에 노출할 데이터를 관리합니다. (다음 섹션에서 자세히 다룹니다)
    private val _user = MutableLiveData<User>()
    val user: LiveData<User> = _user

    private val _isLoading = MutableLiveData<Boolean>()
    val isLoading: LiveData<Boolean> = _isLoading
    
    // ViewModel이 처음 생성될 때만 데이터를 로드하도록 플래그를 관리할 수 있습니다.
    private var isDataLoaded = false

    fun loadUser(userId: String) {
        if (isDataLoaded) return // 이미 로드되었다면 다시 로드하지 않음
        
        _isLoading.value = true
        viewModelScope.launch { // ViewModel 고유의 코루틴 스코프
            val result = UserRepository.fetchUser(userId)
            _user.value = result
            _isLoading.value = false
            isDataLoaded = true
        }
    }

    // ViewModel이 최종적으로 파괴될 때 호출됩니다.
    override fun onCleared() {
        super.onCleared()
        // 리소스 해제 로직 (예: 실시간 리스너 해제 등)
        println("UserProfileViewModel onCleared()")
    }
}

// 2. Activity에서는 그저 ViewModel을 가져와 사용하기만 하면 됩니다.
class UserProfileActivity : AppCompatActivity() {

    // KTX 라이브러리가 제공하는 가장 간편하고 표준적인 방법입니다.
    private val viewModel: UserProfileViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.user_profile)

        val userId = intent.getStringExtra("USER_ID") ?: return

        // 화면이 처음 생성될 때만 데이터 로드를 요청합니다.
        // 화면을 회전해도 loadUser는 다시 호출되지 않습니다.
        viewModel.loadUser(userId)

        // ViewModel의 데이터를 관찰하여 UI를 업데이트합니다.
        viewModel.user.observe(this) { user ->
            textViewName.text = user.name
            // ...
        }
        viewModel.isLoading.observe(this) { isLoading ->
            progressBar.visibility = if (isLoading) View.VISIBLE else View.GONE
        }
    }
}

by viewModels() 한 줄의 코드가 내부적으로 ViewModelProvider(this).get(UserProfileViewModel::class.java)와 같은 복잡한 코드를 대신 처리해줍니다. 이로써 Activity는 UI 렌더링과 사용자 이벤트 처리에만 집중할 수 있게 되었고, 상태 관리는 ViewModel에게 완전히 위임되었습니다.

2.3. ViewModel과 의존성 주입: ViewModelProvider.Factory의 역할

실제 애플리케이션에서 ViewModel은 혼자 동작하지 않습니다. 데이터를 가져오기 위해 Repository를, 특정 로직을 위해 UseCase를 필요로 하는 등 다른 객체에 의존하는 경우가 대부분입니다. ViewModel은 시스템이 생성해주기 때문에, 생성자에 파라미터를 직접 전달할 수 없습니다. 이때 필요한 것이 바로 ViewModelProvider.Factory입니다.

팩토리는 말 그대로 ViewModel을 '만드는 공장'입니다. 시스템에게 "이 타입의 ViewModel을 만들 땐, 이 팩토리를 사용해서 내가 원하는 의존성을 넣어줘!"라고 알려주는 역할을 합니다.


// 생성자에 UserRepository를 필요로 하는 ViewModel
class UserProfileViewModel(private val userRepository: UserRepository) : ViewModel() {
    // ...
}

// 이 ViewModel을 생성하기 위한 커스텀 팩토리
class UserProfileViewModelFactory(private val userRepository: UserRepository) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        // 요청된 ViewModel 클래스가 UserProfileViewModel과 호환되는지 확인
        if (modelClass.isAssignableFrom(UserProfileViewModel::class.java)) {
            // 호환된다면, userRepository를 주입하여 인스턴스를 생성하고 반환
            @Suppress("UNCHECKED_CAST")
            return UserProfileViewModel(userRepository) as T
        }
        // 모르는 타입의 ViewModel을 요청받으면 예외 발생
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}

// Activity에서 팩토리를 사용하여 ViewModel 생성
class UserProfileActivity : AppCompatActivity() {

    private val userRepository = UserRepository() // 실제로는 Hilt/Dagger로 주입받음
    private val factory = UserProfileViewModelFactory(userRepository)

    // viewModels() 델리게이트에 factory를 전달
    private val viewModel: UserProfileViewModel by viewModels { factory }

    override fun onCreate(savedInstanceState: Bundle?) {
        // ...
    }
}

이 팩토리 패턴은 ViewModel을 테스트 가능하게 만드는 핵심적인 열쇠입니다. 단위 테스트를 작성할 때, 실제 네트워크 통신을 하는 UserRepository 대신, 미리 정해진 데이터를 반환하는 가짜(mock) UserRepository를 팩토리를 통해 주입할 수 있습니다. 이로써 외부 환경에 의존하지 않는 안정적인 테스트가 가능해집니다.

물론, 매번 이렇게 팩토리 보일러플레이트 코드를 작성하는 것은 번거롭습니다. 그래서 등장한 것이 Hilt와 같은 의존성 주입(Dependency Injection) 라이브러리입니다. Hilt를 사용하면 이런 팩토리 코드를 완전히 제거하고, 어노테이션 몇 개만으로 의존성 주입을 자동화할 수 있습니다.

항목 수동 Factory 구현 Hilt 사용
구현 방식 ViewModelProvider.Factory를 직접 구현하고, Activity/Fragment에서 팩토리 인스턴스를 생성하여 viewModels에 전달. ViewModel 생성자에 @Inject, 클래스에 @HiltViewModel 어노테이션만 추가. Activity/Fragment는 @AndroidEntryPoint 어노테이션만 추가하면 끝.
보일러플레이트 ViewModel이 늘어날수록 팩토리 클래스도 함께 늘어나 관리 포인트가 증가. 거의 없음. Hilt가 컴파일 시점에 필요한 모든 팩토리 코드를 자동으로 생성해줌.
테스트 테스트용 팩토리를 별도로 만들어 가짜 의존성을 주입해야 함. Hilt가 제공하는 테스트용 어노테이션(@HiltAndroidTest)을 통해 의존성을 손쉽게 교체(replace)할 수 있음.
추천 상황 의존성 주입 라이브러리를 사용하지 않는 소규모 프로젝트나, DI의 기본 원리를 학습하고자 할 때. 대부분의 현대적인 안드로이드 프로젝트. 생산성과 테스트 용이성을 극대화할 수 있음.

3. LiveData: 생명주기를 아는 똑똑한 데이터 스트림

ViewModel이 UI 데이터를 안전하게 보관하는 금고라면, LiveData는 금고의 내용물에 변화가 생겼을 때 UI에게 실시간으로 알려주는 똑똑한 비서와 같습니다. LiveData는 본질적으로 관찰 가능한(Observable) 데이터 홀더입니다. 하지만 일반적인 옵저버 패턴과 구별되는 가장 강력한 특징은, 이름 그대로 생명주기를 인지한다(Lifecycle-Aware)는 점입니다.

3.1. LiveData가 해결하는 문제들

LiveData가 없던 시절, ViewModel의 데이터 변경을 UI에 알리기 위해서는 콜백 인터페이스나 RxJava 같은 라이브러리를 사용해야 했습니다. 이 방식들은 모두 생명주기를 수동으로 관리해야 하는 치명적인 약점이 있었습니다.

  • 메모리 누수: Fragment가 파괴되었는데도 ViewModel에 등록된 콜백 참조를 해제하지 않으면, Fragment 인스턴스가 가비지 컬렉션되지 못하고 메모리에 계속 남아있게 됩니다.
  • 앱 비정상 종료: 백그라운드 스레드에서 데이터를 받아와 UI를 업데이트하려는데, 그 사이 사용자가 화면을 벗어나 Fragment의 View가 파괴된 상태라면? NullPointerException이나 IllegalStateException이 발생하며 앱이 죽게 됩니다.

LiveData는 이러한 문제들을 프레임워크 수준에서 우아하게 해결합니다.

  • 자동 구독 관리: LiveData를 observe(lifecycleOwner, observer) 메서드로 구독할 때, LiveData는 내부적으로 lifecycleOwner의 생명주기에 자신을 연결합니다. lifecycleOwnerDESTROYED 상태가 되면, LiveData는 자동으로 옵저버를 제거하여 메모리 누수를 원천적으로 방지합니다.
  • 안전한 UI 업데이트: LiveData는 lifecycleOwner가 활성 상태(STARTED 또는 RESUMED)일 때만 옵저버에게 변경 사항을 전달합니다. Activity가 백그라운드에 있거나 화면이 꺼진 비활성 상태에서는 이벤트를 보내지 않고 대기합니다. 이후 Activity가 다시 활성 상태가 되면, 그동안 쌓인 변경사항 중 가장 최신의 데이터 딱 하나만 전달하여 불필요한 업데이트를 막고 안정성을 보장합니다.
  • 구성 변경 후 데이터 자동 복원: 화면 회전으로 UI가 재생성되면, 새로운 UI가 LiveData를 다시 구독하는 순간 LiveData는 자신이 가지고 있던 가장 최신 데이터를 즉시 전달해줍니다. 덕분에 UI는 항상 최신 상태를 유지할 수 있습니다.

3.2. 실전 LiveData 사용 패턴: Backing Property

실무에서는 데이터의 무결성을 지키고 단방향 데이터 흐름(Unidirectional Data Flow)을 강제하기 위해 Backing Property라는 디자인 패턴을 널리 사용합니다. 핵심은 '데이터 수정은 ViewModel 내부에서만, 데이터 관찰은 외부(UI)에서만' 가능하도록 역할을 분리하는 것입니다.

  • MutableLiveData: 이름처럼 변경 가능한 LiveData입니다. value 프로퍼티를 통해 값을 직접 변경하거나, setValue()(메인 스레드용) / postValue()(백그라운드 스레드용) 메서드를 호출할 수 있습니다. 이 객체는 ViewModel 내부에 `private`으로 선언하여 외부 노출을 막습니다.
  • LiveData: 읽기 전용 LiveData입니다. 외부에서는 이 객체를 통해 값의 변경을 관찰할 수만 있고, 직접 수정할 수는 없습니다. ViewModel은 `public` 프로퍼티로 이 `LiveData` 객체를 외부에 노출합니다.

// 1. ViewModel에서는 Backing Property 패턴을 사용한다.
class TimerViewModel : ViewModel() {

    // 1. ViewModel 내부에서만 접근 가능하고, 값 변경이 가능한 MutableLiveData
    private val _elapsedTime = MutableLiveData<Long>()

    // 2. 외부(Activity/Fragment)에는 수정 불가능한 LiveData 타입으로 노출
    val elapsedTime: LiveData<Long> = _elapsedTime

    private val timer = Timer()
    private var initialTime: Long = 0

    init {
        initialTime = System.currentTimeMillis()
        timer.scheduleAtFixedRate(object : TimerTask() {
            override fun run() {
                val newValue = (System.currentTimeMillis() - initialTime) / 1000
                // 백그라운드 스레드에서 값을 업데이트하므로 postValue() 사용
                _elapsedTime.postValue(newValue)
            }
        }, 1000, 1000)
    }

    override fun onCleared() {
        super.onCleared()
        timer.cancel() // ViewModel이 소멸될 때 타이머 리소스 정리
    }
}

// 2. Fragment에서는 노출된 LiveData를 관찰하여 UI를 그린다.
class TimerFragment : Fragment() {
    
    private val viewModel: TimerViewModel by viewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        // viewModel의 elapsedTime LiveData를 관찰 시작
        // viewLifecycleOwner를 사용하여 Fragment View의 생명주기에 안전하게 바인딩
        viewModel.elapsedTime.observe(viewLifecycleOwner, Observer { seconds ->
            // 데이터가 변경될 때마다 이 람다 블록이 메인 스레드에서 호출됨
            // Fragment는 데이터가 어떻게 만들어지는지 신경 쓸 필요 없이 그리기만 하면 된다.
            timerTextView.text = "$seconds 초 경과"
        })
    }
}

이 패턴을 통해 ActivityFragment가 실수로 ViewModel의 상태를 변경하는 것을 막을 수 있습니다. 모든 상태 변경은 ViewModel의 메서드를 통해서만 일어나도록 강제함으로써, 데이터 흐름을 예측 가능하고 디버깅하기 쉽게 만듭니다. 이것이 바로 MVVM (Model-View-ViewModel) 아키텍처의 핵심 원리입니다.

3.3. LiveData의 진화: StateFlow의 등장

LiveData는 안드로이드 개발에 혁신을 가져왔지만, 코루틴이 지배하는 현대 생태계에서는 몇 가지 아쉬운 점이 있습니다. 데이터 스트림을 변환하는 map, filter, combine 같은 고차 함수(연산자) 지원이 제한적이고, 모든 처리가 메인 스레드를 거쳐야 하는 구조는 복잡한 비동기 로직에 적용하기에 다소 경직되어 있습니다.
이러한 배경 속에서 코루틴의 Flow API를 기반으로 한 StateFlow가 LiveData의 강력한 대안으로 떠올랐습니다.

StateFlow는 LiveData와 매우 유사한 '상태 홀더' 역할을 하는 뜨거운(Hot) Flow입니다. 가장 최신의 값 하나만 저장하고 있다가 새로운 구독자가 생기면 즉시 최신 값을 전달해줍니다.

특징 LiveData StateFlow
기반 기술 자체적인 옵저버 패턴 구현 Kotlin Coroutines & Flow
기본값(초기값) 초기값이 없을 수 있음 (null 상태) 반드시 생성 시점에 초기값을 가져야 함
스레딩 setValue는 메인 스레드, postValue는 백그라운드 스레드. 관찰은 항상 메인 스레드에서 이루어짐. 코루틴 컨텍스트(Dispatcher)를 통해 스레드를 자유롭게 제어할 수 있음. flowOn, launchIn 등 연산자 활용.
생명주기 인식 라이브러리 자체에 내장 (observe 메서드) 코루틴 스코프와 함께 사용해야 함. lifecycleScope.launchWhenStarted 또는 더 안전한 repeatOnLifecycle API를 사용.
데이터 스트림 연산 Transformations.map 등 제한적인 연산자 제공. map, filter, combine, flatMapConcat 등 Flow의 풍부하고 강력한 연산자들을 모두 사용 가능.
Java 호환성 매우 우수함. 코루틴 기반이므로 Kotlin에서 사용하는 것이 가장 자연스러움. Java에서는 사용이 다소 번거로움.

// StateFlow를 사용한 ViewModel
class SearchViewModel(private val repository: SearchRepository) : ViewModel() {
    private val _query = MutableStateFlow("")
    
    // 사용자가 입력한 검색어(_query)가 변경될 때마다 자동으로 네트워크 검색을 수행하는 복잡한 로직
    val searchResult: StateFlow<List<Item>> = _query
        .debounce(500) // 500ms 동안 추가 입력이 없으면 검색 실행
        .filter { it.isNotBlank() } // 빈 문자열은 무시
        .distinctUntilChanged() // 이전과 동일한 검색어는 무시
        .flatMapLatest { query -> // 새로운 검색어 입력 시 이전 검색 요청은 취소
            repository.searchItemsFlow(query)
        }
        .stateIn( // 이 Flow를 StateFlow로 변환
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000), // 구독자가 없으면 5초 후 중단
            initialValue = emptyList()
        )
        
    fun setQuery(query: String) {
        _query.value = query
    }
}

// Fragment에서 StateFlow 구독 (최신 권장 방식)
class SearchFragment : Fragment() {
    private val viewModel: SearchViewModel by viewModels()
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                // 이 블록은 STARTED 상태에서 실행되고, STOPPED 상태가 되면 자동으로 취소(suspend)됨
                viewModel.searchResult.collect { items ->
                    adapter.submitList(items)
                }
            }
        }
    }
}
언제 무엇을 써야 할까?
- LiveData: Java와의 상호 운용성이 중요하거나, 데이터 스트림 변환이 거의 없는 단순한 UI 상태를 표시할 때 여전히 훌륭한 선택입니다. 보일러플레이트가 적어 입문자에게 친숙합니다. - StateFlow: 프로젝트가 100% Kotlin 기반이고, 복잡한 비동기 로직이나 데이터 스트림 조작이 필요할 때 훨씬 강력하고 유연한 성능을 발휘합니다. 현대적인 코루틴 기반 프로젝트의 표준으로 자리 잡고 있습니다.

4. Room: SQLite를 품은 현대적 ORM

안드로이드 앱에서 데이터를 영구적으로 저장하는 표준적인 방법은 SQLite 데이터베이스를 사용하는 것입니다. 하지만 순수한 SQLite API를 직접 다루는 것은 고통스러운 경험에 가깝습니다. SQLiteOpenHelper를 상속받아 테이블 생성 및 버전 관리 코드를 작성하고, 쿼리 결과를 받기 위해 Cursor 객체를 일일이 순회하며 컬럼 인덱스를 찾아 값을 파싱해야 합니다. 무엇보다 가장 큰 문제는, 모든 SQL 쿼리가 단순한 문자열(String)이라는 점입니다. 컬럼 이름에 오타가 있거나 SQL 문법 오류가 있어도 컴파일러는 아무것도 알려주지 않습니다. 모든 오류는 앱이 실행된 후에야 끔찍한 런타임 예외로 나타납니다.

Room은 SQLite 위에 세련된 추상화 계층을 제공하는 객체 관계 매핑(ORM, Object-Relational Mapping) 라이브러리입니다. Room을 사용하면 자바나 코틀린 객체를 통해 데이터베이스와 상호작용할 수 있으며, 앞서 언급한 모든 문제들을 해결해줍니다.

4.1. Room의 3가지 핵심 건축 자재

Room은 세 가지 주요 컴포넌트로 구성됩니다. 이들의 역할을 이해하면 Room의 전체 그림을 쉽게 그릴 수 있습니다.

  1. @Entity (엔티티): 데이터베이스의 테이블(Table)을 정의하는 클래스입니다. 이 클래스의 각 인스턴스는 테이블의 한 행(Row)에 해당하고, 클래스의 필드(프로퍼티)들은 각 열(Column)에 매핑됩니다. @PrimaryKey 어노테이션으로 기본 키를 지정할 수 있습니다.
  2. @Dao (Data Access Object, 데이터 접근 객체): 데이터베이스에 대한 CRUD(Create, Read, Update, Delete) 작업을 수행하는 메서드를 정의하는 인터페이스 또는 추상 클래스입니다. 개발자는 SQL 쿼리를 어노테이션 안에 작성하기만 하면, Room이 컴파일 시점에 해당 쿼리를 검증하고 실제 구현 코드를 자동으로 생성해줍니다.
  3. @Database (데이터베이스): 앱의 전체 데이터베이스를 대표하는 홀더 클래스입니다. RoomDatabase를 상속받는 추상 클래스로 만들며, 어떤 엔티티들이 포함되는지, DAO는 무엇인지, 데이터베이스 버전은 몇인지를 명시합니다. 데이터베이스 인스턴스를 생성하는 중앙 지점 역할을 합니다.

4.2. Room 실전 예제: 할 일(Todo) 목록 앱

간단한 할 일 목록을 관리하는 앱을 만든다고 가정하고 Room을 적용해 보겠습니다.

1. Entity 정의: `Todo` 테이블 설계


import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "todo_table") // 테이블 이름은 명시적으로 지정하는 것이 좋습니다.
data class Todo(
    @PrimaryKey(autoGenerate = true)
    val id: Int = 0, // 기본 키, 자동으로 1씩 증가

    @ColumnInfo(name = "task_content") // 컬럼 이름도 명시적으로 지정 가능
    var task: String,

    var isDone: Boolean = false,

    val createdAt: Long = System.currentTimeMillis()
)

2. DAO 정의: 데이터베이스와 소통하는 창구


import androidx.room.*
import kotlinx.coroutines.flow.Flow

@Dao
interface TodoDao {
    // 모든 할 일을 생성 시간 내림차순으로 가져옵니다.
    // 반환 타입을 Flow로 지정하면, 데이터가 변경될 때마다 새로운 목록이 자동으로 방출됩니다.
    @Query("SELECT * FROM todo_table ORDER BY createdAt DESC")
    fun getAllTodos(): Flow<List<Todo>>

    // 새로운 할 일을 추가합니다. suspend 함수로 만들어 코루틴에서 안전하게 호출할 수 있습니다.
    @Insert(onConflict = OnConflictStrategy.REPLACE) // 만약 id가 겹치면 덮어씁니다.
    suspend fun insert(todo: Todo)

    // 할 일의 내용을 업데이트합니다.
    @Update
    suspend fun update(todo: Todo)

    // 할 일을 삭제합니다.
    @Delete
    suspend fun delete(todo: Todo)

    // 완료된 모든 할 일을 한 번에 삭제하는 커스텀 쿼리
    @Query("DELETE FROM todo_table WHERE isDone = 1")
    suspend fun clearCompleted()
}
컴파일 시점 쿼리 검증의 위력!
만약 위 코드에서 @Query("SELECT * FROM todo_table ORDER BY createdAt DESC")@Query("SELECT * FROM todos_table ...")처럼 존재하지 않는 테이블 이름으로 잘못 작성했다면, 앱을 빌드하는 시점에 Room이 "Error: Cannot find table: todos_table"과 같은 명확한 오류 메시지를 보여줍니다. 덕분에 어이없는 오타로 인한 런타임 버그를 사전에 완벽하게 차단할 수 있습니다.

3. Database 정의: 데이터베이스의 청사진


import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import android.content.Context

@Database(entities = [Todo::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {

    // 이 추상 메서드를 통해 DAO 인스턴스를 얻을 수 있습니다.
    abstract fun todoDao(): TodoDao

    // 싱글톤 패턴으로 데이터베이스 인스턴스가 앱 전체에 하나만 존재하도록 보장
    companion object {
        @Volatile
        private var INSTANCE: AppDatabase? = null

        fun getDatabase(context: Context): AppDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    AppDatabase::class.java,
                    "todo_database" // DB 파일 이름
                ).build()
                INSTANCE = instance
                instance
            }
        }
    }
}

이제 Repository나 ViewModel에서 AppDatabase.getDatabase(context).todoDao()를 호출하여 DAO를 얻고, 정의된 메서드들을 통해 안전하고 간편하게 데이터베이스 작업을 수행할 수 있습니다.

4.3. 피할 수 없는 과제: 데이터베이스 마이그레이션(Migration)

앱을 출시하고 운영하다 보면 기능 추가로 인해 데이터베이스 스키마를 변경해야 하는 일이 반드시 생깁니다. 예를 들어, `Todo` 엔티티에 '마감일(dueDate)' 정보를 추가하고 싶다고 가정해 봅시다. 이미 앱을 설치해서 사용 중인 유저의 기기에는 `dueDate` 컬럼이 없는 `version 1`의 데이터베이스가 저장되어 있습니다. 이 상태에서 그냥 `@Database`의 버전을 2로 올리고 앱을 업데이트하면, Room은 스키마가 일치하지 않는다며 IllegalStateException을 발생시키고 앱을 강제 종료시킵니다. 사용자의 소중한 데이터는 모두 날아가 버릴 수 있습니다.
마이그레이션(Migration)은 기존 데이터를 그대로 유지하면서 데이터베이스 스키마를 새로운 버전으로 안전하게 업그레이드하는 과정입니다.


// 1. Entity에 dueDate 컬럼 추가
@Entity(tableName = "todo_table")
data class Todo(
    // ... 기존 필드
    val dueDate: Long? = null // 마감일 (Nullable)
)

// 2. Database 버전을 1에서 2로 올림
@Database(entities = [Todo::class], version = 2, exportSchema = false)
abstract class AppDatabase : RoomDatabase() { /* ... */ }

// 3. Migration 경로 정의 (1 -> 2)
val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        // SQL 쿼리를 직접 실행하여 스키마를 변경
        // 'todo_table'에 'dueDate'라는 INTEGER 타입의 컬럼을 추가한다.
        database.execSQL("ALTER TABLE todo_table ADD COLUMN dueDate INTEGER")
    }
}

// 4. Database 빌더에 Migration 객체 추가
fun getDatabase(context: Context): AppDatabase {
    return INSTANCE ?: synchronized(this) {
        val instance = Room.databaseBuilder(
            context.applicationContext,
            AppDatabase::class.java,
            "todo_database"
        )
        .addMigrations(MIGRATION_1_2) // 마이그레이션 경로를 등록
        .build()
        INSTANCE = instance
        instance
    }
}

이렇게 마이그레이션 경로를 명시해주면, Room은 앱 업데이트 시 데이터베이스 버전이 다른 것을 감지하고, 등록된 `MIGRATION_1_2`의 `migrate` 메서드를 실행하여 스키마를 안전하게 변경해줍니다. Room은 이처럼 복잡하고 실수하기 쉬운 데이터베이스 관리 작업을 체계적이고 안정적인 방식으로 처리할 수 있도록 도와줍니다.

5. 시너지: 4대 컴포넌트로 완성하는 견고한 아키텍처

지금까지 살펴본 Lifecycle, ViewModel, LiveData(StateFlow), Room은 각자 맡은 역할에서도 강력하지만, 이들이 함께 모였을 때 비로소 진정한 시너지를 발휘하며 구글이 권장하는 현대적인 안드로이드 앱 아키텍처를 완성합니다. 이 아키텍처는 일반적으로 다음과 같은 계층 구조를 가집니다.

UI Layer (Activity/Fragment)
↑↓
ViewModel
↑↓
Repository
↑↓
Data Sources (Room, Retrofit)
  • UI Layer (View): 사용자와의 상호작용을 담당하는 최전선입니다. Activity, Fragment, 그리고 Jetpack Compose의 Composable 함수가 여기에 해당합니다. 이 계층의 유일한 임무는 ViewModel의 상태(State)를 관찰(observe)하여 화면에 그리고, 사용자 입력(버튼 클릭, 텍스트 입력 등)을 ViewModel에 전달하는 것입니다. 절대로 직접 비즈니스 로직을 처리하거나 데이터를 가져오지 않습니다.
  • ViewModel: UI에 표시될 데이터를 가공하고 상태를 관리하는 지휘 본부입니다. Lifecycle과 무관하게 동작하므로 구성 변경에도 데이터를 안전하게 보존합니다. UI에 필요한 데이터를 Repository에 요청하고, 결과를 LiveDataStateFlow에 담아 UI Layer에 노출합니다.
  • Repository: 앱의 모든 데이터를 총괄하는 유일한 창구, 즉 '단일 진실 공급원(Single Source of Truth)' 역할을 합니다. ViewModel은 데이터가 로컬 DB에서 오는지, 원격 서버에서 오는지, 아니면 메모리 캐시에서 오는지 전혀 알 필요가 없습니다. 그저 Repository에 데이터를 요청할 뿐입니다. Repository는 내부적으로 Room DAO를 호출하여 로컬 데이터를 가져오거나, Retrofit 같은 네트워크 라이브러리를 통해 원격 데이터를 가져오는 로직을 캡슐화합니다. 이 계층 덕분에 데이터 소스를 교체하거나(예: SQLite -> Firebase), 캐싱 전략을 추가하는 등의 변경이 상위 계층(ViewModel, UI)에 아무런 영향을 주지 않습니다.
  • Data Sources: 실제 데이터가 존재하는 원천입니다. 로컬 데이터베이스를 위한 Room, 원격 API 통신을 위한 Retrofit/Ktor, 간단한 키-값 저장을 위한 DataStore/SharedPreferences 등이 여기에 속합니다.

이 아키텍처가 제공하는 가장 큰 가치는 '관심사의 분리'를 통한 코드의 품질 향상입니다.

장점 설명
테스트 용이성 (Testability) 각 계층이 독립적이므로 개별적으로 단위 테스트를 작성하기 매우 용이합니다. 예를 들어, ViewModel을 테스트할 때는 실제 Repository 대신 가짜(Mock) Repository를 주입하여 UI나 네트워크 없이도 비즈니스 로직을 완벽하게 검증할 수 있습니다.
유지보수성 (Maintainability) UI 디자인 변경은 UI Layer에서, 데이터 캐싱 로직 변경은 Repository에서, DB 스키마 변경은 Room Entity에서만 수정하면 됩니다. 한 부분의 수정이 다른 부분에 미치는 영향을 최소화하여 유지보수가 쉽고 버그 발생 가능성이 낮아집니다.
확장성 (Scalability) 새로운 기능을 추가하거나 데이터 소스를 변경할 때 유연하게 대처할 수 있습니다. 예를 들어 오프라인 지원 기능을 추가한다면, Repository 계층에서 네트워크 연결 상태를 확인하고 Room의 데이터를 우선적으로 반환하도록 로직을 수정하면 됩니다.

결론: 새로운 시대의 개발 표준, Jetpack

구글이 안드로이드 아키텍처 컴포넌트를 세상에 내놓은 이후, 안드로이드 개발 생태계는 과거의 혼란을 뒤로하고 놀라운 속도로 성숙해졌습니다. 이제 Lifecycle, ViewModel, LiveData/StateFlow, Room은 더 이상 '써보면 좋은 라이브러리'가 아니라, 견고하고 효율적인 앱을 만들기 위한 사실상의 표준(de facto standard)이자 필수 교양이 되었습니다.

이 컴포넌트들은 안드로이드 개발의 오랜 난제였던 생명주기 관리, 상태 보존, 비동기 처리, 데이터베이스 접근을 명쾌하고 우아한 방식으로 해결합니다. 여기서 멈추지 않고, 화면 전환을 관리하는 Navigation, 대용량 데이터를 효율적으로 로드하는 Paging 3, 의존성 주입을 자동화하는 Hilt, 그리고 UI 패러다임을 혁신하는 Jetpack Compose와 같은 다른 Jetpack 라이브러리들과 결합했을 때, 그 잠재력은 극대화됩니다.

만약 당신의 프로젝트가 아직도 Activity에 모든 로직이 뒤섞여 있거나, 생명주기 문제로 골머리를 앓고 있다면, 더 이상 주저할 이유가 없습니다. 이 아키텍처를 학습하고 점진적으로 도입하는 것은 단순히 코딩 스타일을 바꾸는 것을 넘어, 앱의 품질과 개발자로서의 생산성을 한 차원 다른 수준으로 끌어올리는 가장 확실하고 검증된 투자입니다. 변화를 받아들이고, Jetpack이 제시하는 새로운 개발의 표준 위에서 더 나은 안드로이드 앱을 만들어 나가시길 바랍니다.

Post a Comment