지난 가이드에서 ViewModel과 DataBinding으로 MVVM의 기본기를 다졌습니다. 이제 안드로이드 아키텍처를 완성하는 마지막 퍼즐, Flow/StateFlow와 Hilt를 알아볼 차례입니다.
이 가이드는 LiveData를 Flow로 바꾸고, ViewModel 생성을 Hilt에 맡기는 과정을 상세히 다룹니다.

1. Flow & StateFlow (LiveData의 다음 세대)
LiveData는 훌륭하지만, 안드로이드 생명주기에 종속적이며 기능(결합, 변형 등)이 제한적입니다. Flow는 Kotlin 코루틴을 기반으로 한 비동기 데이터 스트림으로, 더 강력하고 유연합니다.
1-1. "대체 왜?" - LiveData 대신 StateFlow?
- LiveData: 안드로이드 전용. View(Activity)가 활성화(Started/Resumed)될 때만 데이터를 발행합니다.
- Flow: Kotlin 전용(모든 플랫폼). 데이터 '스트림' 자체를 정의합니다. (RxJava와 비슷합니다)
- StateFlow: Flow의 한 종류로, LiveData를 대체하기에 완벽합니다.
- '상태(State)'를 가지는 '핫(Hot)' 스트림입니다. (항상 최신 값 1개를 가지고 있음)
- LiveData와 달리 생명주기를 알지 못하지만, View에서 '구독'할 때 생명주기를 인지시킬 수 있습니다.
- 초기 값이 반드시 필요합니다.
결론: ViewModel의 상태(State)를 노출할 땐, LiveData 대신 StateFlow를 쓰는 것이 현대적인 방식입니다.
1-2. CounterViewModel을 StateFlow로 바꾸기
지난 가이드의 CounterViewModel을 StateFlow 버전으로 수정해 보겠습니다.
[기존 LiveData]
private val _count = MutableLiveData<Int>()
val count: LiveData<Int> = _count
init { _count.value = 0 }
fun increment() {
_count.value = (_count.value ?: 0) + 1
}
[새로운 StateFlow]
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
class CounterViewModel : ViewModel() {
// 1. MutableStateFlow로 변경 (초기값 0 필수)
private val _count = MutableStateFlow(0)
// 2. asStateFlow()로 읽기 전용 StateFlow 노출
val count: StateFlow<Int> = _count.asStateFlow()
// 3. 비즈니스 로직
fun increment() {
// .value를 직접 수정하거나,
// _count.value = _count.value + 1
// .update (권장: 원자성 보장, 스레드 안전)
_count.update { it + 1 }
}
}
복귀 개발자 Check!
- MutableLiveData -> MutableStateFlow(초기값)
- LiveData -> StateFlow
- val count: LiveData = _count -> val count: StateFlow = _count.asStateFlow()
1-3. Activity에서 StateFlow '구독'하기 (DataBinding 미사용 시)
LiveData는 .observe()로 관찰했습니다. StateFlow는 collect (수집)라는 용어를 씁니다.
[기존 LiveData 관찰]
viewModel.count.observe(this) { newCount ->
binding.textViewCounter.text = newCount.toString()
}
[새로운 StateFlow 구독 (가장 중요!)]
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.activity.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.example.app.databinding.ActivityMainBinding
import kotlinx.coroutines.launch
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)
// ... DataBinding 설정 ...
binding.vm = viewModel // XML에서 사용할 경우
// binding.lifecycleOwner = this // StateFlow는 이 설정과 별개
// ⭐️ StateFlow를 수동으로 구독하는 표준 방식
lifecycleScope.launch { // 코루틴 스코프 시작
// ⭐️ repeatOnLifecycle: 화면이 STARTED 상태일 때만 {} 안을 실행
repeatOnLifecycle(Lifecycle.State.STARTED) {
// count의 변경을 감지(collect)
viewModel.count.collect { newCount ->
// DataBinding을 쓰지 않는다면 여기서 UI 업데이트
// binding.textViewCounter.text = newCount.toString()
// (참고) DataBinding으로 바인딩했다면 이 코드는 불필요합니다.
}
}
}
}
}
복귀 개발자 Check!
- lifecycleScope.launch: Activity의 생명주기를 따르는 코루틴을 실행합니다.
- repeatOnLifecycle(Lifecycle.State.STARTED): 이게 마법입니다. 화면이 STARTED 상태가 되면 collect를 시작하고, STOPPED가 되면 자동으로 collect를 중지(취소)합니다. LiveData의 생명주기 관찰 기능을 완벽하게 대체하며, 리소스 낭비(백그라운드 실행)를 막아줍니다. 이 코드는 거의 공식처럼 사용됩니다.
1-4. DataBinding과 StateFlow 연결 (권장)
XML에서 LiveData를 사용했듯이, StateFlow도 바로 바인딩할 수 있습니다.
activity_main.xml (변경 없음!)
<layout ...>
<data>
<variable name="vm" type="com.example.app.CounterViewModel" />
</data>
...
<TextView
...
<!-- StateFlow도 LiveData와 동일하게 바인딩됩니다. -->
android:text="@{String.valueOf(vm.count)}"
... />
<Button
...
<!-- 클릭 이벤트도 동일 -->
android:onClick="@{() -> vm.increment()}" />
...
</layout>
MainActivity.kt (변경 없음!)
// ...
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
// 1. ViewModel 연결
binding.vm = viewModel
// 2. LiveData(또는 StateFlow)가 XML을 업데이트하도록 생명주기 설정
// ⭐️ StateFlow를 DataBinding에 사용해도 이 코드는 똑같이 필수입니다.
binding.lifecycleOwner = this
/*
* 끝입니다!
* DataBinding이 StateFlow를 자동으로 구독하고 해제합니다.
* (내부적으로는 1-3에서 설명한 repeatOnLifecycle과 유사하게 동작)
*/
}
2. Hilt - 정신 사나운 의존성 주입(DI) 해결사
ViewModel을 만들 때 by viewModels()를 썼습니다. 하지만 ViewModel이 Repository를 필요로 한다면? val repo = MyRepository() 처럼 ViewModel 안에서 직접 생성하면 테스트도 어렵고 결합도가 높아집니다.
의존성 주입(DI): ViewModel이 Repository를 직접 만드는 게 아니라, **외부(Hilt)에서 만들어서 '주입(Inject)'**해주는 기술입니다.
Hilt는 Dagger2를 안드로이드 전용으로 극도로 단순화시킨 DI 라이브러리입니다.
2-1. "대체 왜?" - Hilt를 왜 써야 하나요?
[Hilt가 없던 시절 (ViewModel Factory)]
// 1. ViewModel이 Repository를 필요로 함
class MyViewModel(private val repository: MyRepository) : ViewModel() { ... }
// 2. Activity에서 ViewModel을 만들려니...
// val viewModel: MyViewModel by viewModels() // 💥 에러!
// (기본 생성자가 없어서)
// 3. 팩토리를 직접 만들어야 함 (매우 번거로움)
class MyViewModelFactory(private val repository: MyRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return MyViewModel(repository) as T
}
}
// 4. Activity에서 사용
val factory = MyViewModelFactory(MyRepository()) // 😫
val viewModel: MyViewModel by viewModels { factory }
Repository가 또 다른 의존성(Retrofit, Room...)을 가진다면 지옥이 펼쳐집니다.
[Hilt가 있는 지금]
// 1. ViewModel에 어노테이션 추가
@HiltViewModel
class MyViewModel @Inject constructor(
private val repository: MyRepository // Hilt가 알아서 넣어줌
) : ViewModel() { ... }
// 2. Repository에도 어노테이션 추가
class MyRepository @Inject constructor() { ... } // Hilt가 만드는 법을 앎
// 3. Activity에 어노테이션 추가
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
// 4. 끝. Hilt가 팩토리를 자동으로 다 만들어 줌.
val viewModel: MyViewModel by viewModels()
}
보시다시피, Hilt는 **'반복적인 팩토리 코드'**를 완전히 제거해 줍니다.
2-2. Hilt 프로젝트 설정 (필수)
1. build.gradle (Project 수준)
plugins {
// ...
id("com.google.dagger.hilt.android") version "2.51" apply false
}
2. build.gradle (Module 수준)
plugins {
// ...
id("kotlin-kapt")
id("com.google.dagger.hilt.android")
}
android {
// ...
kapt {
correctErrorTypes = true
}
}
dependencies {
// Hilt
implementation("com.google.dagger:hilt-android:2.51")
kapt("com.google.dagger:hilt-compiler:2.51")
}
3. Application 클래스 생성 (필수) Hilt를 사용하려면 Application 클래스가 필수이며, @HiltAndroidApp을 붙여야 합니다.
MyApplication.kt
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class MyApplication : Application() {
// 앱이 살아있는 동안 Hilt가 DI 컨테이너를 관리합니다.
}
4. AndroidManifest.xml (필수) MyApplication을 사용하도록 <application> 태그의 android:name을 수정합니다.
<application
android:name=".MyApplication"
...>
<!-- @AndroidEntryPoint를 Activity/Fragment에 사용합니다 -->
<activity
android:name=".MainActivity"
...>
</activity>
</application>
2-3. Hilt 사용법: Module (가장 중요)
@Inject constructor는 Hilt가 클래스를 '만드는 법'을 알려줍니다. 하지만 Retrofit, Room 또는 인터페이스처럼 빌더(Builder)로 만들거나 우리가 직접 구현해야 하는 것들은 Hilt가 만들 수 없습니다.
이때 **Module**을 사용해 Hilt에게 "이건 내가 알려주는 대로 만들어!"라고 지시합니다.
예: Retrofit과 Repository 제공하기
di/AppModule.kt (di 폴더 생성)
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
// 1. 이 클래스가 '모듈'임을 알림
@Module
// 2. 이 모듈의 생명주기를 Application과 동일하게 설정 (앱 전체에서 싱글톤)
@InstallIn(SingletonComponent::class)
object AppModule {
// 3. Retrofit.Builder 같은 것을 제공
@Provides
@Singleton // Retrofit은 앱 전체에서 하나만 필요
fun provideRetrofit(): Retrofit {
return Retrofit.Builder()
.baseUrl("[https://api.example.com/](https://api.example.com/)")
.addConverterFactory(GsonConverterFactory.create())
.build()
}
// 4. ApiService 인터페이스 제공 (Retrofit이 만들어줌)
@Provides
@Singleton
fun provideApiService(retrofit: Retrofit): ApiService {
return retrofit.create(ApiService::class.java)
}
// 5. Repository 인터페이스(MyRepository)와 구현체(MyRepositoryImpl)
// (인터페이스를 주입하는 것이 좋은 설계)
@Provides
@Singleton
fun provideMyRepository(apiService: ApiService): MyRepository {
return MyRepositoryImpl(apiService)
}
}
// --- (별도 파일에 정의) ---
interface MyRepository {
suspend fun getData(): Flow // Flow로 데이터 반환
}
// 6. Hilt가 MyRepositoryImpl을 만들 때 ApiService가 필요함을 알고 주입
class MyRepositoryImpl @Inject constructor(
private val apiService: ApiService
) : MyRepository {
override suspend fun getData(): Flow {
// ... API 호출 후 Flow로 반환 ...
return flow { emit("Data from API") } // 예시
}
}
3. 총정리: Hilt + StateFlow + MVVM 실전 예제
이제 이 모든 것을 조합해 보겠습니다.
1. AppModule.kt (위 예제 참고)
- Retrofit, ApiService, MyRepository를 @Provides @Singleton으로 제공합니다.
2. MyViewModel.kt
- Hilt가 MyRepository를 주입합니다.
- API를 호출하고 결과를 StateFlow에 담습니다.
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
// 1. Hilt ViewModel로 선언
@HiltViewModel
class MyViewModel @Inject constructor(
private val repository: MyRepository // 2. Hilt가 AppModule에서 Repository를 찾아 주입
) : ViewModel() {
private val _uiState = MutableStateFlow<String>("Loading...")
val uiState: StateFlow<String> = _uiState.asStateFlow()
init {
fetchData()
}
private fun fetchData() {
// 3. ViewModel 전용 코루틴 스코프
viewModelScope.launch {
repository.getData()
.catch { exception ->
// 5. 에러 처리
_uiState.update { "Error: ${exception.message}" }
}
.collect { data ->
// 4. 성공 시 StateFlow 업데이트 -> UI 자동 변경
_uiState.update { data }
}
}
}
}
3. MainActivity.kt
- Hilt가 MyViewModel을 주입합니다.
- StateFlow를 DataBinding에 연결합니다.
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.activity.viewModels
import com.example.app.databinding.ActivityMainBinding
import dagger.hilt.android.AndroidEntryPoint
// 1. Hilt 진입점 선언
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
// 2. 끝. Hilt가 팩토리 없이 ViewModel을 주입
private val viewModel: MyViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
// 3. DataBinding에 ViewModel과 LifecycleOwner 연결
binding.vm = viewModel
binding.lifecycleOwner = this
}
}
4. activity_main.xml
<layout ...>
<data>
<variable name="vm" type="com.example.app.MyViewModel" />
</data>
...
<TextView
...
<!-- 4. StateFlow가 변경되면 자동으로 텍스트 업데이트 -->
android:text="@{vm.uiState}"
tools:text="Loading..." />
...
</layout>
결론
- **Flow/StateFlow**는 LiveData를 대체하는 강력한 비동기 스트림입니다. (DataBinding과 완벽 호환)
- **Hilt**는 ViewModelFactory 같은 보일러플레이트를 제거하고, @Inject 어노테이션만으로 의존성(객체)을 주입해 줍니다.
- **Module**은 Retrofit, Room처럼 Hilt가 만드는 법을 모르는 객체들을 Hilt에게 알려주는 '설명서'입니다.
이 세 가지(MVVM, Flow, Hilt)를 조합하면 테스트가 용이하고, 생명주기를 완벽하게 다루며, 코드가 깔끔한 현대적인 안드로이드 앱을 만들 수 있습니다. 복귀를 다시 한번 응원합니다!
'개발 이야기 > Android (안드로이드)' 카테고리의 다른 글
| Gemini가 말아주는 MVVM 시작 가이드 (0) | 2025.12.08 |
|---|---|
| GitHub Copilot 이 말아주는 MVVM 시작 가이드 (0) | 2025.12.08 |
| ChatGpt가 말아주는 MVVM 시작 가이드 (0) | 2025.12.08 |
| ViewModel, DataBinding 으로 ViewPager2/RecyclerView 제대로 쓰는 방법 정리 (1) | 2025.11.19 |
| [Android] 3년 만에 복귀한 개발자를 위한 MVVM, ViewModel, DataBinding 완벽 정복 가이드 (0) | 2025.11.18 |
| Jetpack Compose 초보자 가이드 (0) | 2024.12.23 |
| Android DataBinding과 ViewModel 적용하기 (6) | 2024.12.12 |
| Fragment 효율적인 코딩 방법에 대한 정리 (0) | 2024.12.12 |