(Android/Compose) Compose ViewModel 연동 + StateFlow


1. Compose + ViewModel 아키텍처

Compose는 UI만 담당하고, 비즈니스 로직과 상태는 ViewModel이 관리합니다. ViewModel은 화면 회전에도 살아남고, Composable은 언제든 재구성될 수 있으므로 상태의 진실 공급원(Single Source of Truth)은 항상 ViewModel에 둡니다.

UI (Composable)  ←── StateFlow 구독 ──  ViewModel
      │                                     │
      └──────── 사용자 이벤트 전달 ─────────┘

의존성 추가:

implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.4")

2. UiState 패턴 — 화면 상태를 하나의 데이터 클래스로

여러 개의 상태 변수 대신, 화면에 필요한 모든 상태를 하나의 불변 데이터 클래스로 묶습니다.

// 화면에 필요한 모든 상태를 하나로 묶은 UiState
data class ProductListUiState(
    val products: List<Product> = emptyList(),
    val isLoading: Boolean = false,
    val errorMessage: String? = null
)

class ProductViewModel(private val repository: ProductRepository) : ViewModel() {
    // MutableStateFlow: ViewModel 내부에서만 값 변경 가능
    private val _uiState = MutableStateFlow(ProductListUiState())
    // StateFlow: 외부(Compose)에는 읽기 전용으로 노출 (캡슐화)
    val uiState: StateFlow<ProductListUiState> = _uiState.asStateFlow()

    init {
        loadProducts()
    }

    fun loadProducts() {
        // update: 이전 상태를 기반으로 새 상태 생성 (원자적 업데이트)
        _uiState.update { it.copy(isLoading = true, errorMessage = null) }

        viewModelScope.launch {
            try {
                val products = repository.fetchProducts()
                _uiState.update { it.copy(products = products, isLoading = false) }
            } catch (e: Exception) {
                _uiState.update { it.copy(isLoading = false, errorMessage = e.message) }
            }
        }
    }
}

왜 여러 상태 변수 대신 하나의 UiState?: 상태들이 서로 연관되어 있을 때(로딩 중이면서 동시에 에러가 있으면 안 됨 등) 하나의 데이터 클래스로 묶으면 불일치 상태를 방지하고, 화면에 필요한 상태를 한눈에 파악할 수 있습니다.


3. Compose에서 StateFlow 구독

collectAsState 또는 생명주기를 인식하는 collectAsStateWithLifecycle로 구독합니다.

import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel

@Composable
fun ProductListScreen(
    viewModel: ProductViewModel = viewModel()  // ViewModel 인스턴스 자동 획득
) {
    // collectAsStateWithLifecycle: 화면이 백그라운드일 때 자동으로 구독 중지
    // (collectAsState보다 배터리/리소스 효율적, 권장 방식)
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    Box(modifier = Modifier.fillMaxSize()) {
        when {
            uiState.isLoading -> {
                CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
            }
            uiState.errorMessage != null -> {
                ErrorMessage(
                    message = uiState.errorMessage,
                    onRetry = { viewModel.loadProducts() }  // 사용자 이벤트 → ViewModel에 위임
                )
            }
            else -> {
                LazyColumn {
                    items(uiState.products, key = { it.id }) { product ->
                        ProductItem(product)
                    }
                }
            }
        }
    }
}

collectAsState vs collectAsStateWithLifecycle: collectAsState는 Composable이 Composition에 있는 한 계속 구독합니다. collectAsStateWithLifecycle은 Activity/Fragment가 STOPPED 상태면 구독을 멈춰 불필요한 리소스 사용을 막습니다 — 실무에서는 후자를 권장합니다.


4. 일회성 이벤트 처리 — SharedFlow

토스트 메시지, 화면 이동처럼 한 번만 발생해야 하는 이벤트는 StateFlow가 아닌 SharedFlow로 처리합니다. StateFlow는 항상 최신 값을 유지하므로 화면 재구성 시 이벤트가 중복 발생할 수 있습니다.

sealed class UiEvent {
    data class ShowToast(val message: String) : UiEvent()
    data class NavigateToDetail(val productId: Int) : UiEvent()
}

class ProductViewModel : ViewModel() {
    // SharedFlow: 값을 유지하지 않고 이벤트처럼 흘려보냄 (구독 시점 이후 것만 수신)
    private val _uiEvent = MutableSharedFlow<UiEvent>()
    val uiEvent: SharedFlow<UiEvent> = _uiEvent.asSharedFlow()

    fun onProductClick(productId: Int) {
        viewModelScope.launch {
            _uiEvent.emit(UiEvent.NavigateToDetail(productId))
        }
    }

    fun onDeleteError() {
        viewModelScope.launch {
            _uiEvent.emit(UiEvent.ShowToast("삭제에 실패했습니다"))
        }
    }
}

@Composable
fun ProductListScreen(viewModel: ProductViewModel = viewModel()) {
    val context = LocalContext.current

    // LaunchedEffect(Unit): Composable이 처음 컴포지션에 들어올 때 한 번 실행
    // → 이벤트 Flow를 구독하는 코루틴 시작
    LaunchedEffect(Unit) {
        viewModel.uiEvent.collect { event ->
            when (event) {
                is UiEvent.ShowToast ->
                    Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
                is UiEvent.NavigateToDetail ->
                    { /* navController.navigate("detail/${event.productId}") */ }
            }
        }
    }

    // 화면 UI...
}

5. SavedStateHandle — 프로세스 종료 후에도 상태 복원

시스템에 의해 프로세스가 종료되었다가 재시작될 때도 상태를 복원하고 싶다면 SavedStateHandle을 사용합니다.

class SearchViewModel(
    private val savedStateHandle: SavedStateHandle,  // ViewModel 생성자에 자동 주입
    private val repository: SearchRepository
) : ViewModel() {

    // SavedStateHandle의 값을 StateFlow로 노출 — 프로세스 재생성 후에도 복원됨
    val query: StateFlow<String> = savedStateHandle.getStateFlow("query", "")

    fun onQueryChange(newQuery: String) {
        // set()으로 저장 → 프로세스가 죽어도 Bundle에 보존되어 복원 가능
        savedStateHandle["query"] = newQuery
    }

    // Navigation 인자도 SavedStateHandle로 받을 수 있음
    val productId: Int = savedStateHandle.get<Int>("productId") ?: -1
}

6. 실전 — combine으로 여러 Flow 합성

여러 데이터 소스(검색어 + 필터 + 원본 목록)를 조합해 최종 UiState를 만드는 패턴입니다.

class ProductListViewModel(private val repository: ProductRepository) : ViewModel() {
    private val _searchQuery = MutableStateFlow("")
    private val _selectedCategory = MutableStateFlow<String?>(null)

    fun onSearchChange(query: String) { _searchQuery.value = query }
    fun onCategorySelect(category: String?) { _selectedCategory.value = category }

    // combine: 세 Flow 중 하나라도 바뀌면 자동으로 재계산되는 파생 상태
    val uiState: StateFlow<ProductListUiState> = combine(
        repository.observeProducts(),  // 원본 상품 목록 (DB Flow)
        _searchQuery,
        _selectedCategory
    ) { products, query, category ->
        // 세 값을 조합해 최종 필터링된 목록 계산
        val filtered = products
            .filter { query.isBlank() || it.name.contains(query, ignoreCase = true) }
            .filter { category == null || it.category == category }
        ProductListUiState(products = filtered)
    }.stateIn(
        scope = viewModelScope,
        // WhileSubscribed: 구독자가 있을 때만 활성, 화면 이탈 5초 후 자동 정지 (리소스 절약)
        started = SharingStarted.WhileSubscribed(5000),
        initialValue = ProductListUiState(isLoading = true)
    )
}

stateIn의 SharingStarted.WhileSubscribed(5000): 화면 회전처럼 짧은 순간 구독자가 0이 되는 경우를 대비해 5초의 유예 시간을 둡니다. 이 시간이 지나야 Flow가 실제로 정지되어, 화면 회전 때마다 데이터를 재조회하는 낭비를 막습니다.


7. 정리

개념 용도
UiState 데이터 클래스 화면에 필요한 모든 상태를 하나로 관리
StateFlow 지속적인 상태 구독 (현재 값 유지)
SharedFlow 일회성 이벤트 (토스트, 네비게이션)
collectAsStateWithLifecycle 생명주기를 인식하는 안전한 구독 방식
SavedStateHandle 프로세스 종료 후에도 상태 복원
combine + stateIn 여러 Flow를 조합한 파생 상태 생성


Related Posts