2017년 Google I/O에서 처음 발표된 안드로이드 아키텍처 컴포넌트(Android Architecture Components)는 안드로이드 앱 개발의 패러다임을 바꾼 중요한 전환점이었습니다. 과거 개발자들은 MVC, MVP, MVVM 등 다양한 아키텍처 패턴을 각자의 방식으로 적용하며, 화면 회전과 같은 간단한 이벤트에도 데이터가 사라지거나, 생명주기 불일치로 인한 메모리 누수 및 비정상 종료 문제와 끊임없이 싸워야 했습니다. 이러한 '파편화된 아키텍처' 문제를 해결하고, 개발자들이 보다 견고하고, 테스트 가능하며, 유지보수가 용이한 앱을 만들 수 있도록 구글이 제시한 공식 가이드라인이 바로 Jetpack의 일부인 이 아키텍처 컴포넌트들입니다.
이제는 선택이 아닌 필수가 된 Lifecycle, ViewModel, LiveData, Room은 현대 안드로이드 개발의 근간을 이룹니다. 이 네 가지 핵심 컴포넌트가 어떻게 동작하고, 서로 어떻게 유기적으로 결합하여 효과적인 아키텍처를 구축하는지, 실제 코드 예제와 함께 깊이 있게 파헤쳐 보겠습니다. 이 글은 단순히 각 컴포넌트의 기능을 나열하는 것을 넘어, '왜' 이것들을 사용해야 하는지, 그리고 실제 프로젝트에서 어떻게 활용할 수 있는지에 대한 실질적인 이해를 돕는 것을 목표로 합니다.
1. Lifecycle: 안드로이드 생명주기의 지배자
안드로이드 개발에서 가장 골치 아픈 문제 중 하나는 단연 생명주기(Lifecycle) 관리입니다. Activity나 Fragment는 생성(created), 시작(started), 활성(resumed), 일시정지(paused), 중지(stopped), 파괴(destroyed) 등 복잡한 상태를 오갑니다. 기존에는 이러한 생명주기 변화에 대응하기 위해 onStart()
, onStop()
같은 콜백 메서드 안에서 리소스를 초기화하고 해제하는 코드를 직접 작성해야 했습니다.
// 과거의 방식: 모든 생명주기 콜백에 직접 로직을 구현
class MyLocationListener {
public void start() {
// 위치 업데이트 시작 로직
}
public void stop() {
// 위치 업데이트 중지 로직
}
}
class MyActivity : AppCompatActivity() {
private lateinit var myLocationListener: MyLocationListener
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
myLocationListener = MyLocationListener()
}
override fun onStart() {
super.onStart()
// Activity가 화면에 보일 때 리스너 시작
myLocationListener.start()
}
override fun onStop() {
super.onStop()
// Activity가 화면에서 사라질 때 리스너 중지 (메모리 누수 방지)
myLocationListener.stop()
}
}
위 코드는 간단해 보이지만, Activity/Fragment가 비대해질수록 생명주기 관련 코드가 곳곳에 흩어져 코드를 이해하고 유지보수하기 어렵게 만듭니다. 네트워크 요청, 센서 리스너, 애니메이션 등 생명주기에 종속적인 컴포넌트가 많아질수록 문제는 더욱 심각해집니다. 이 문제를 해결하기 위해 등장한 것이 바로 Lifecycle 컴포넌트입니다.
1.1. LifecycleOwner와 LifecycleObserver
Lifecycle 컴포넌트는 두 가지 주요 인터페이스를 통해 동작합니다.
- LifecycleOwner:
Activity
나Fragment
처럼 자체적인 생명주기를 가진 객체입니다.AppCompatActivity
와Fragment
는 이미LifecycleOwner
를 구현하고 있습니다.getLifecycle()
메서드를 통해 자신의Lifecycle
객체를 제공합니다. - LifecycleObserver:
LifecycleOwner
의 생명주기 변화를 관찰(observe)하는 객체입니다. 특정 생명주기 이벤트(예: ON_CREATE, ON_START, ON_STOP)가 발생했을 때 수행할 동작을 정의합니다.
1.2. DefaultLifecycleObserver를 이용한 개선
최신 안드로이드 개발에서는 어노테이션 기반의 @OnLifecycleEvent
보다 DefaultLifecycleObserver
인터페이스를 구현하는 방식이 권장됩니다. Java 8 이상을 사용하는 프로젝트에서는 이 방식이 더 깔끔하고 성능적으로 유리합니다.
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
// 생명주기를 인식하는 로케이션 리스너
class MyLocationListener(private val lifecycleOwner: LifecycleOwner) : DefaultLifecycleObserver {
private var enabled = false
init {
// 생성 시점에 스스로를 옵저버로 등록
lifecycleOwner.lifecycle.addObserver(this)
}
// LifecycleOwner가 onStart 상태가 되면 호출됨
override fun onStart(owner: LifecycleOwner) {
if (!enabled) {
enabled = true
// 위치 업데이트 시작 로직
println("위치 업데이트 시작")
}
}
// LifecycleOwner가 onStop 상태가 되면 호출됨
override fun onStop(owner: LifecycleOwner) {
if (enabled) {
enabled = false
// 위치 업데이트 중지 로직
println("위치 업데이트 중지")
}
}
// LifecycleOwner가 onDestroy 상태가 되면 옵저버 제거
override fun onDestroy(owner: LifecycleOwner) {
lifecycleOwner.lifecycle.removeObserver(this)
println("옵저버 제거됨")
}
}
// Activity는 훨씬 깔끔해진다.
class MyActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// MyLocationListener는 스스로 생명주기를 관리한다.
// Activity는 리스너의 시작/중지 시점에 대해 알 필요가 없다.
val locationListener = MyLocationListener(this)
}
}
위 코드처럼 MyLocationListener
가 스스로 생명주기를 인지하고 동작하도록 구현하면, Activity
는 단순히 이 컴포넌트를 생성하기만 하면 됩니다. 관심사의 분리(Separation of Concerns)가 명확해지면서 코드가 훨씬 모듈화되고 테스트하기 쉬워집니다. 이것이 Lifecycle 컴포넌트가 제공하는 핵심 가치입니다.
1.3. 코루틴과 함께 사용: lifecycleScope
현대 안드로이드 개발에서 비동기 처리는 코루틴(Coroutines)을 통해 이루어지는 경우가 많습니다. lifecycle-viewmodel-ktx
라이브러리는 LifecycleOwner
에 lifecycleScope
라는 확장 프로퍼티를 제공하여, 생명주기에 안전한 코루틴을 손쉽게 실행할 수 있게 해줍니다.
class MyFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// 이 코루틴은 프래그먼트의 생명주기에 연결됩니다.
// 프래그먼트가 DESTROYED 상태가 되면 자동으로 취소됩니다.
viewLifecycleOwner.lifecycleScope.launch {
val data = fetchDataFromNetwork() // suspend 함수
textView.text = data
}
}
}
lifecycleScope.launch
를 사용하면 개발자가 직접 코루틴 Job을 관리하고 onDestroyView
에서 취소하는 번거로운 작업을 하지 않아도 됩니다. 프래그먼트의 View가 파괴될 때(예: 백스택으로 이동) 해당 스코프의 모든 코루틴이 자동으로 취소되어 메모리 누수나 불필요한 작업을 완벽하게 방지합니다.
2. ViewModel: UI 상태의 안전한 보관소
사용자가 스마트폰을 가로로 돌릴 때마다(화면 회전), Activity는 파괴된 후 새로 생성됩니다. 이때 Activity가 가지고 있던 모든 멤버 변수와 데이터는 초기화됩니다. 사용자가 작성 중이던 텍스트나 네트워크에서 불러온 데이터 목록이 사라지는 최악의 사용자 경험을 초래할 수 있습니다. ViewModel은 이러한 구성 변경(Configuration Changes)에도 UI 관련 데이터를 안전하게 보존하기 위해 설계되었습니다.
2.1. ViewModel의 핵심 원리
ViewModel은 Activity나 Fragment의 생명주기를 초월하여 존재합니다. Activity 인스턴스가 화면 회전 등으로 인해 파괴되고 재생성되더라도, 동일한 ViewModel 인스턴스는 메모리에 그대로 유지됩니다. 새로운 Activity 인스턴스는 이 기존 ViewModel에 다시 연결되어 데이터를 이어받습니다.
ViewModel의 생명주기는 다음과 같습니다.
- Activity/Fragment가 처음 생성될 때 함께 생성됩니다.
- 화면 회전 등 구성 변경 시에도 파괴되지 않고 살아남습니다.
- Activity가 최종적으로 종료되거나(예:
finish()
호출) Fragment가 분리(detach)될 때, ViewModel의onCleared()
콜백이 호출되면서 함께 파괴됩니다.
2.2. ViewModel 생성 및 사용법
ViewModel은 직접 생성자를 호출하여 만들지 않습니다. ViewModelProvider
를 통해 시스템에 요청하여 가져옵니다. 이렇게 해야만 앞서 설명한 생명주기 관리의 이점을 누릴 수 있습니다.
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.activity.viewModels
// 1. UI 데이터를 보관할 ViewModel 클래스 정의
class UserProfileViewModel : ViewModel() {
var userId: String? = null
var userName: String? = null
// ... 기타 사용자 데이터
// ViewModel이 파괴될 때 호출됨
override fun onCleared() {
super.onCleared()
// 리소스 해제 로직 (예: Repository 리스너 해제)
println("ViewModel 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)
// viewModel.userId가 null이라면 첫 생성, 아니라면 재사용된 것
if (viewModel.userId == null) {
// 예를 들어 Intent에서 userId를 받아와서 ViewModel에 저장
val userIdFromIntent = intent.getStringExtra("USER_ID")
viewModel.userId = userIdFromIntent
// 이 시점에 네트워크 요청 등으로 데이터를 로드
}
// 이제 viewModel에 저장된 데이터를 사용해 UI를 렌더링
// 화면을 회전해도 viewModel.userName 데이터는 그대로 유지됨
displayUserData(viewModel.userName)
}
}
by viewModels()
위임 속성을 사용하는 것이 현재 가장 표준적인 방법입니다. 이 한 줄의 코드는 내부적으로 ViewModelProvider
를 사용하여 현재 Activity와 연결된 ViewModelStore
에서 UserProfileViewModel
인스턴스를 찾거나, 없다면 새로 생성하여 반환합니다.
2.3. ViewModel과 의존성 주입 (ViewModelProvider.Factory)
ViewModel이 단순히 데이터를 담는 컨테이너가 아니라, 데이터를 가져오는 로직(예: Repository 호출)까지 담당하는 경우가 많습니다. 이때 ViewModel은 생성자에 Repository와 같은 다른 객체를 필요로 하게 됩니다. 이런 의존성을 주입하기 위해 ViewModelProvider.Factory
를 사용합니다.
// 생성자에 UserRepository를 필요로 하는 ViewModel
class UserProfileViewModel(private val userRepository: UserRepository) : ViewModel() {
// ...
}
// 이 ViewModel을 생성하기 위한 Factory 클래스
class UserProfileViewModelFactory(private val userRepository: UserRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(UserProfileViewModel::class.java)) {
@Suppress("UNCHECKED_CAST")
return UserProfileViewModel(userRepository) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
// Activity에서 Factory를 사용하여 ViewModel 생성
class UserProfileActivity : AppCompatActivity() {
private lateinit var viewModel: UserProfileViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Repository 인스턴스를 직접 생성 (실제로는 Hilt/Dagger로 주입받음)
val userRepository = UserRepository()
val factory = UserProfileViewModelFactory(userRepository)
viewModel = ViewModelProvider(this, factory).get(UserProfileViewModel::class.java)
// ...
}
}
이러한 팩토리 패턴은 다소 번거로워 보일 수 있지만, ViewModel을 테스트 가능하게 만드는 핵심적인 역할을 합니다. 테스트 시에는 실제 UserRepository
대신 가짜(mock) Repository를 팩토리를 통해 주입할 수 있습니다. 최근에는 Hilt와 같은 의존성 주입 라이브러리를 사용하면 이러한 보일러플레이트 코드를 완전히 제거할 수 있습니다.
3. LiveData: 생명주기를 인지하는 데이터 홀더
ViewModel이 데이터를 보관한다면, UI(Activity/Fragment)는 어떻게 ViewModel의 데이터 변경을 알아챌 수 있을까요? 여기서 LiveData가 등장합니다. LiveData는 관찰 가능한(Observable) 데이터 홀더 클래스입니다. 일반적인 Observable과 가장 큰 차이점은 이름에서 알 수 있듯, 생명주기를 인지한다는 점입니다.
3.1. LiveData의 주요 특징
- UI와 데이터의 동기화 보장: LiveData 객체에 저장된 데이터가 변경되면, 이를 관찰(observe)하고 있는 UI는 자동으로 알림을 받아 화면을 갱신할 수 있습니다.
- 메모리 누수 방지: 옵저버(Observer)는
LifecycleOwner
(Activity/Fragment)에 바인딩됩니다. 만약LifecycleOwner
가 파괴(DESTROYED)되면, LiveData는 자동으로 옵저버를 제거하여 메모리 누수를 방지합니다. - 비활성 상태에서의 UI 업데이트 중단: Activity가 백그라운드에 있는 등 비활성(inactive) 상태일 때는 LiveData가 UI 업데이트 이벤트를 보내지 않습니다.
LifecycleOwner
가 다시 활성(active) 상태가 되었을 때 최신 데이터를 전달해줍니다. 이는ActivityNotFoundException
과 같은 예외를 방지하는 데 큰 도움이 됩니다. - 항상 최신 데이터 유지: 화면 회전 등으로 UI가 다시 생성되면, LiveData는 즉시 옵저버에게 가장 최신의 데이터를 다시 전달하여 UI를 복원합니다.
3.2. LiveData 사용법: MutableLiveData와 LiveData
실제 구현 시에는 두 가지 형태의 LiveData를 사용합니다.
- MutableLiveData:
setValue()
(메인 스레드) 또는postValue()
(백그라운드 스레드) 메서드를 통해 값을 변경할 수 있는 LiveData입니다. 보통 ViewModel 내부에서만 사용하여 데이터 변경을 제어합니다. - LiveData: 값을 변경할 수 없고 오직 읽기만 가능한(immutable) LiveData입니다. ViewModel은 외부(UI)에 데이터를 노출할 때
LiveData
타입을 사용해, UI가 실수로 데이터를 변경하는 것을 막고 단방향 데이터 흐름을 강제합니다.
// 1. ViewModel에서 LiveData 사용
class TickerViewModel : ViewModel() {
// ViewModel 내부에서만 수정 가능한 MutableLiveData
private val _currentTime = MutableLiveData<Long>()
// 외부에는 수정 불가능한 LiveData로 노출
val currentTime: LiveData<Long> = _currentTime
private val timer = Timer()
init {
// 1초마다 현재 시간을 업데이트
timer.scheduleAtFixedRate(object : TimerTask() {
override fun run() {
// 백그라운드 스레드이므로 postValue() 사용
_currentTime.postValue(System.currentTimeMillis())
}
}, 1000, 1000)
}
override fun onCleared() {
super.onCleared()
timer.cancel()
}
}
// 2. Fragment에서 LiveData 관찰
class TickerFragment : Fragment() {
private val viewModel: TickerViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// viewModel의 currentTime LiveData를 관찰 시작
// viewLifecycleOwner를 사용해 Fragment View의 생명주기에 맞춤
viewModel.currentTime.observe(viewLifecycleOwner, Observer { newTime ->
// 데이터가 변경될 때마다 이 블록이 호출됨
val formattedTime = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(newTime))
timeTextView.text = formattedTime
})
}
}
이 코드는 MVVM 패턴의 정석을 보여줍니다. View (Fragment)는 ViewModel의 데이터를 관찰하고, ViewModel은 데이터(Model)를 관리하며 변경 사항을 LiveData를 통해 View에게 알립니다. Fragment는 데이터가 어떻게, 왜 바뀌었는지 알 필요 없이 그저 최신 데이터를 받아서 화면에 그리기만 하면 됩니다.
3.3. LiveData의 한계와 대안: StateFlow
LiveData는 매우 훌륭한 도구이지만, 몇 가지 단점도 존재합니다. 데이터 스트림을 변환하는 연산자(map
, filter
등)가 제한적이고, 모든 이벤트가 메인 스레드로 전달되어 때로는 비효율적일 수 있습니다. Kotlin Coroutines가 대중화되면서, 이와 더 잘 통합되는 StateFlow와 SharedFlow가 LiveData의 강력한 대안으로 떠오르고 있습니다.
- StateFlow: LiveData와 거의 동일한 역할을 합니다. 항상 값을 가지고 있으며, 가장 최신의 값만 구독자에게 전달합니다. 코루틴 기반으로 동작하여 복잡한 비동기 데이터 스트림 처리에 더 강력한 기능을 제공합니다.
현대 안드로이드 개발에서는 점차 LiveData에서 StateFlow로 넘어가는 추세입니다. 하지만 기존 프로젝트와의 호환성 및 간단한 UI 상태 표시에는 여전히 LiveData가 유용하게 사용되고 있습니다.
4. Room: 로컬 데이터베이스의 혁신
안드로이드에서 로컬 데이터를 영구적으로 저장하기 위해 전통적으로 SQLite를 사용해왔습니다. 하지만 `SQLiteOpenHelper`, `Cursor`, SQL 쿼리 문자열 등을 직접 다루는 것은 매우 번거롭고, 사소한 오타 하나가 런타임 에러를 발생시키는 등 오류에 취약했습니다. Room은 SQLite 위에 존재하는 객체 관계 매핑(ORM, Object-Relational Mapping) 라이브러리로, 이러한 문제들을 해결하고 SQLite를 현대적이고 안전한 방식으로 사용할 수 있게 해줍니다.
4.1. Room의 3가지 핵심 구성 요소
- Entity (엔티티): 데이터베이스의 테이블(Table)을 나타내는 클래스입니다.
@Entity
어노테이션을 붙이고, 클래스의 필드들은 테이블의 컬럼(Column)에 해당합니다. - DAO (Data Access Object, 데이터 접근 객체): 데이터베이스에 접근하는 메서드를 정의하는 인터페이스 또는 추상 클래스입니다.
@Dao
어노테이션을 사용하며, SQL 쿼리를 메서드에 매핑합니다. - Database (데이터베이스): 데이터베이스의 홀더 역할을 하는 추상 클래스입니다.
@Database
어노테이션을 붙여 앱의 전체 데이터베이스를 정의하고, Entity와 DAO에 대한 정보를 포함하며, 데이터베이스 인스턴스를 생성하는 지점 역할을 합니다.
4.2. Room 사용 예제
간단한 사용자(User) 정보를 저장하는 예제를 통해 Room의 강력함을 확인해 보겠습니다.
1. Entity 정의
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "users") // 테이블 이름 지정
data class User(
@PrimaryKey(autoGenerate = true) val uid: Int = 0, // 기본 키, 자동 증가
val firstName: String?,
val lastName: String?
)
2. DAO 정의
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
@Dao
interface UserDao {
// 모든 사용자 정보를 Flow 형태로 반환. 데이터 변경 시 자동으로 새 목록을 emit.
@Query("SELECT * FROM users ORDER BY firstName ASC")
fun getAll(): Flow<List<User>>
@Query("SELECT * FROM users WHERE uid = :userId")
fun findById(userId: Int): User?
// 삽입. 충돌 시 무시 (OnConflictStrategy.IGNORE)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insert(user: User)
@Update
suspend fun update(user: User)
@Query("DELETE FROM users")
suspend fun deleteAll()
}
DAO를 주목할 필요가 있습니다. @Query
, @Insert
, @Update
, @Delete
어노테이션을 사용하면 Room이 컴파일 시점에 SQL 쿼리의 유효성을 검사합니다. 만약 쿼리에 오타가 있거나 존재하지 않는 테이블/컬럼을 참조하면, 앱이 실행되기도 전에 빌드 에러를 발생시켜 버그를 사전에 방지합니다. 또한, 반환 타입을 Flow
로 지정하면, `users` 테이블의 데이터가 변경될 때마다 새로운 데이터 목록이 Flow를 통해 자동으로 방출되어 UI를 리액티브하게 업데이트할 수 있습니다.>
3. Database 정의
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import android.content.Context
@Database(entities = [User::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao // Room이 이 메서드의 구현체를 자동으로 생성
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,
"app_database" // 데이터베이스 파일명
).build()
INSTANCE = instance
instance
}
}
}
}
4.3. 데이터베이스 마이그레이션 (Migration)
앱을 업데이트하면서 데이터베이스 스키마(테이블 구조)를 변경해야 할 때가 있습니다. 예를 들어 User
엔티티에 `email` 컬럼을 추가하는 경우입니다. 기존에 설치된 앱은 옛날 스키마를 가지고 있으므로, 스키마를 변경하지 않으면 앱이 충돌합니다. 이때 마이그레이션(Migration)을 통해 기존 데이터를 유지하면서 스키마를 안전하게 업데이트할 수 있습니다.
// 1. Database 버전 업데이트 및 Migration 객체 정의
@Database(entities = [User::class], version = 2) // 버전 1 -> 2
abstract class AppDatabase : RoomDatabase() { /*...*/ }
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
// User 테이블에 email 컬럼을 TEXT 타입으로 추가하고, 기본값은 NULL로 설정
database.execSQL("ALTER TABLE users ADD COLUMN email TEXT")
}
}
// 2. Database 빌더에 Migration 추가
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"app_database"
)
.addMigrations(MIGRATION_1_2) // 마이그레이션 경로 추가
.build()
Room은 이러한 복잡한 과정을 표준화하여 개발자가 로컬 데이터 관리에 들이는 노력을 획기적으로 줄여주고, 비즈니스 로직에 더 집중할 수 있도록 돕습니다.
5. 아키텍처 컴포넌트의 시너지: 현대적인 앱 아키텍처 구축
지금까지 살펴본 4개의 컴포넌트는 개별적으로도 강력하지만, 함께 사용될 때 진정한 가치를 발휘합니다. 구글이 권장하는 현대적인 안드로이드 앱 아키텍처는 보통 다음과 같은 계층 구조를 가집니다.
UI Layer (Activity/Fragment) ↔️ ViewModel ↔️ Repository ↔️ Data Sources (Room, Retrofit)
- UI Layer (View): 사용자와 직접 상호작용하는 화면(Activity, Fragment)입니다. 오직 UI 갱신과 사용자 입력 처리만 담당합니다. ViewModel의 데이터를 LiveData나 StateFlow를 통해 관찰(observe)하고 화면에 표시합니다.
- ViewModel: UI에 표시될 상태(State)를 관리하고 비즈니스 로직을 실행합니다. Lifecycle에 의존하지 않으므로 구성 변경에도 데이터를 보존할 수 있습니다. 데이터가 필요할 때는 Repository에 요청합니다.
- Repository: 앱에서 사용하는 모든 데이터의 '단일 소스(Single Source of Truth)' 역할을 합니다. 로컬 DB(Room)에서 데이터를 가져올지, 원격 서버(Retrofit 등)에서 가져올지를 결정하고 중재합니다. ViewModel은 데이터가 어디서 오는지 알 필요 없이 Repository에 요청하기만 하면 됩니다. 이 계층을 통해 데이터 로직이 UI로부터 완벽하게 분리됩니다.
- Data Sources: 실제 데이터를 제공하는 부분입니다. Room 데이터베이스, 네트워크 API(Retrofit), SharedPreferences 등이 여기에 해당합니다.
이 아키텍처는 각 컴포넌트의 역할을 명확히 분리하여 다음과 같은 장점을 제공합니다.
- 유지보수성: 각 계층이 독립적이므로 코드 수정이 다른 부분에 미치는 영향을 최소화합니다.
- 테스트 용이성: 각 계층을 개별적으로 테스트할 수 있습니다. 예를 들어 ViewModel을 테스트할 때 실제 UI나 Repository 없이 가짜(mock) 객체를 주입하여 로직을 검증할 수 있습니다.
- 확장성: 새로운 기능을 추가하거나 데이터 소스를 변경(예: Firebase를 Room으로 교체)할 때 유연하게 대처할 수 있습니다.
결론: 개발의 표준이 된 Jetpack 아키텍처 컴포넌트
Google이 안드로이드 아키텍처 컴포넌트를 발표한 이후, 안드로이드 생태계는 급격하게 안정화되고 발전했습니다. 이제 Lifecycle, ViewModel, LiveData, Room은 단순히 '유용한 라이브러리'를 넘어, 효율적이고 안정적인 앱을 만들기 위한 사실상의 표준(de facto standard)으로 자리 잡았습니다. 이 컴포넌트들은 복잡한 생명주기 관리와 데이터 보존, 비동기 처리, 데이터베이스 접근 등 안드로이드 개발의 고질적인 문제들을 우아하게 해결합니다.
여기에 더해 Navigation, Paging 3, WorkManager, Hilt와 같은 다른 Jetpack 라이브러리들과 함께 사용하면 더욱 강력하고 체계적인 애플리케이션을 구축할 수 있습니다. 만약 아직 이 아키텍처를 도입하지 않았다면, 지금이라도 적극적으로 학습하고 프로젝트에 적용해 보시길 강력히 권장합니다. 이는 단순히 코드를 작성하는 방식을 바꾸는 것이 아니라, 앱의 품질과 개발 생산성을 한 차원 높은 수준으로 끌어올리는 가장 확실한 방법입니다.
0 개의 댓글:
Post a Comment