본문 바로가기

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

[Android] MVVM 다음 스텝: Flow, StateFlow, Hilt 초보자 완벽 가이드

320x100

지난 가이드에서 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>

 

결론

  1. **Flow/StateFlow**는 LiveData를 대체하는 강력한 비동기 스트림입니다. (DataBinding과 완벽 호환)
  2. **Hilt**는 ViewModelFactory 같은 보일러플레이트를 제거하고, @Inject 어노테이션만으로 의존성(객체)을 주입해 줍니다.
  3. **Module**은 Retrofit, Room처럼 Hilt가 만드는 법을 모르는 객체들을 Hilt에게 알려주는 '설명서'입니다.

이 세 가지(MVVM, Flow, Hilt)를 조합하면 테스트가 용이하고, 생명주기를 완벽하게 다루며, 코드가 깔끔한 현대적인 안드로이드 앱을 만들 수 있습니다. 복귀를 다시 한번 응원합니다!

반응형