[Android] Flow 기반 상태 관리

Daemon·2025년 11월 23일

Android

목록 보기
6/13
post-thumbnail

들어가며

프론트엔드 엔지니어라면 상태 관리는 항상 중요하게 다뤄지는 것 같다. 특히 여러 데이터 소스를 조합하고, 에러 처리를 하며, 재시도 로직을 구현하는 과정은 복잡한 명령형 코드로 이어지기 쉬운데 실용적으로 접근해보는 과정에 대해서 작성해보겠다.

일반적인 예시로 목록을 보여주는 화면을 구현한다고 가정해보자. 이 화면은 다음과 같은 요구사항을 가지고 있다. 먼저 어떤 목록과 그 하위에 해당하는 데이터를 각각 다른 API에서 가져와야 한다. 그리고 이 두 데이터를 조합하여 자동으로 계산해야 하며, 데이터를 로딩하는 동안 사용자에게 로딩 상태를 표시해야 한다. 네트워크 에러 등이 발생하면 적절히 처리하고, 에러가 발생했을 때 사용자가 재시도할 수 있는 기능도 필요하다.

전통적인 명령형 방식으로는 이런 로직들이 ViewModel 곳곳에 흩어져 있고, 상태 관리가 복잡해진다.

MVVM vs MVI: 왜 하이브리드인가?

본격적인 구현에 들어가기 전에, 현재 Android 개발에서 주로 사용되는 아키텍처 패턴을 간단히 정리해보자.

전통적인 MVVM의 한계

// 전통적인 MVVM - 상태가 분산되어 있음
class TraditionalViewModel : ViewModel() {
    private val _items = MutableStateFlow<List<Item>>(emptyList())
    val items: StateFlow<List<Item>> = _items.asStateFlow()
    
    private val _isLoading = MutableStateFlow(false)
    val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
    
    private val _error = MutableStateFlow<Throwable?>(null)
    val error: StateFlow<Throwable?> = _error.asStateFlow()
    
    // 상태 불일치 가능성: isLoading=true인데 error도 있는 경우?
}

전통적인 MVVM을 쓰다 보면 이런 생각이 든다. "아니 isLoading이 true인데 error도 있으면 어떻게 되는 거지?" 맞다. 상태가 여러 개의 StateFlow로 흩어져 있으면 이런 모순된 상태가 발생할 수 있다. UI에서 이런 여러 상태를 조합하는 로직도 점점 복잡해지고 말이다.

순수 MVI의 복잡성

// 순수 MVI - 과도한 보일러플레이트
sealed class Intent {
    object LoadData : Intent()
    object Retry : Intent()
    data class SelectItem(val id: Long) : Intent()
}

// 간단한 작업도 Intent로 감싸야 함
viewModel.processIntent(Intent.Retry)

그렇다고 MVI로 가자니 단순히 재시도 버튼 누르는 것도 Intent로 감싸야 한다. 이게 과연 생산적인가 싶은 생각이 들 때가 많다.

우리의 선택: Reactive MVVM

// Reactive MVVM - 단일 상태 + 간단한 API
val uiState: StateFlow<UiState> = retrySignal
    .flatMapLatest { /* ... */ }
    .stateIn(...)

fun onRetry() { retrySignal.value = Unit }  // 직관적인 함수

그래서 나는 이 둘의 장점만 가져온 방식을 선호한다. 복잡한 Intent 없이 직관적인 함수를 쓰면서도, 상태는 하나로 관리해서 일관성을 유지하는 거다.

선언적 접근: Flow 기반 상태 관리

1. 재시도 트리거

먼저 재시도 메커니즘의 핵심을 살펴보자:

private val retrySignal = MutableStateFlow(Unit)

fun onRetry() {
    retrySignal.value = Unit
}

Unit 값 자체는 의미 없고, 뭔가가 emit됐다는 사실만 중요하다. 이 간단한 트릭으로 복잡한 재시도 로직을 깔끔하게 처리할 수 있다.

2. flatMapLatest: 동적 데이터 스트림 전환

val uiState: StateFlow<ListUiState> = retrySignal
    .flatMapLatest {
        combine(
            fetchListsUseCase(),
            fetchCurrentProgressUseCase()
        ) { lists, currentProgress ->
            // 데이터 조합 로직
        }
    }
    .onStart { emit(ListUiState(isLoading = true)) }
    .catch { error -> emit(ListUiState(isLoading = false, error = error)) }
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000),
        initialValue = ListUiState(isLoading = true)
    )

이 코드가 어떻게 돌아가는지 하나씩 뜯어보자.

2-1. flatMapLatest의 역할

flatMapLatest가 뭐냐면, 새로운 신호가 오면 이전에 하던 작업을 취소하고 새로 시작하는 녀석이다.

예를 들어보자. 사용자가 재시도 버튼을 빠르게 연속으로 세 번 눌렀다고 치자. 일반적이라면 API 요청이 3번 다 날아가겠지만, flatMapLatest를 쓰면 마지막 요청만 실행된다. 앞의 두 개는 알아서 취소되는 것이다. 쓸데없는 네트워크 낭비도 막고, 예상치 못한 상태 꼬임도 방지할 수 있다.

2-2. combine: 다중 데이터 소스 조합

combine(
    fetchListUseCase(),
    fetchCurrentProgressUseCase()
) { list, currentProgress ->
    val activeListId = currentProgress?.listId
        ?: list.firstOrNull()?.id
        ?: -1
    val activeSubListId = currentProgress?.subListId ?: -1

    ListUiState(
        list = list,
        activeListId = activeListId,
        activeSubListId = activeSubListId,
        isLoading = false,
        error = null
    )
}

combine은 여러 Flow를 합치는 연산자다. 모든 Flow가 최소 한 번은 값을 내보내야 결과가 나온다. 그 다음부터는 어느 하나라도 새 값이 나오면 최신 값들을 조합해서 결과를 만든다.

실제로 어떻게 동작하는지 보면, 목록 API는 빨리 응답이 왔는데 진행 상황 API는 느리게 온다고 치자. combine은 둘 다 올 때까지 기다렸다가 한 번에 UI 상태를 만들어낸다.

3. 상태 머신으로서의 Flow

사실 Flow 체인은 그 자체로 상태 머신이다. 각 연산자가 상태 전이를 담당하는데, 이게 시각적으로 보면 이렇다:

명령형으로 짜면 if-else 지옥이 될 텐데, Flow로 하면 이런 흐름이 자연스럽게 코드에 녹아든다.

4. onStart: 초기 로딩 상태

.onStart {
    emit(ListUiState(isLoading = true))
}

onStart는 Flow가 시작될 때 딱 한 번만 실행된다. 덕분에 사용자가 버튼 누르자마자 바로 로딩 표시를 띄울 수 있다.

5. catch: 선언적 에러 처리

.catch { error ->
    emit(
        ListUiState(
            isLoading = false,
            error = error
        )
    )
}

catch는 위에서 발생한 모든 예외를 잡아낸다. 네트워크 에러든, JSON 파싱 에러든, 뭐든 여기서 처리된다. 중요한 건 에러가 나도 Flow가 죽지 않는다는 것이다. 에러 상태를 보여주고도 여전히 살아있어서, 사용자가 재시도하면 다시 작동할 수 있다.

6. stateIn: StateFlow로 변환

.stateIn(
    scope = viewModelScope,
    started = SharingStarted.WhileSubscribed(5000),
    initialValue = ListUiState(isLoading = true)
)

왜 5초일까? 화면 회전할 때를 생각해보자. Activity가 죽었다가 다시 살아나는 그 짧은 순간, 구독이 끊어진다. 만약 바로 취소하면? 화면 돌아왔을 때 데이터를 처음부터 다시 가져와야 한다. 하지만 5초 정도 기다리면 화면 회전 같은 일시적인 끊김은 무시하고 상태를 유지할 수 있다.

반대로 사용자가 진짜로 화면을 떠났다면? 5초 후에 깔끔하게 정리해서 메모리를 아낀다.

추가 기능: 확장/축소 상태 관리

private val _expandedListIds = MutableStateFlow<Set<Long>>(emptySet())
val expandedListIds: StateFlow<Set<Long>> = _expandedListIds.asStateFlow()

fun toggleList(listId: Long) {
    _expandedListIds.update { currentSet ->
        if (currentSet.contains(listId)) {
            currentSet - listId  // 축소
        } else {
            currentSet + listId  // 확장
        }
    }
}

update 함수가 좋은 이유는 원자적으로 처리된다는 것이다. 여러 스레드에서 동시에 건드려도 안전하게 처리된다. Kotlin의 불변 Set과 함께 쓰면 thread-safe한 상태 관리까지 따라온다.

private val _navigationEvents = Channel<NavigationEvent>(Channel.BUFFERED)
val navigationEvents = _navigationEvents.receiveAsFlow()

네비게이션에는 StateFlow 대신 Channel을 쓴다. 왜일까?

StateFlow의 한계

StateFlow는 현재 상태를 항상 가지고 있어야 하고, 같은 값은 연속해서 못 보낸다.

// StateFlow 사용 시 문제
_navigationEvent.value = NavigateToDetail(listId = 1)
_navigationEvent.value = NavigateToDetail(listId = 1) // 같은 값! 무시됨

// null로 리셋? 그것도 문제
_navigationEvent.value = null
_navigationEvent.value = NavigateToDetail(listId = 1)
// 화면 회전하면? null → NavigateToDetail 또 실행!

사용자가 같은 아이템을 두 번 탭했는데 두 번째는 무시된다? 이상하지 않나?

Channel의 장점

Channel은 다르다. 이벤트를 큐에 넣었다가 하나씩 꺼내 쓰는 방식이다.

// 같은 이벤트 여러 번? OK
_navigationEvents.send(NavigateToDetail(listId = 1))
_navigationEvents.send(NavigateToDetail(listId = 1)) // 둘 다 전달됨

// 화면 회전? 이미 소비한 이벤트는 다시 안 나옴

간단히 정리하면, 현재 상태를 나타내는 것은 StateFlow, 일회성 이벤트는 Channel이다.

정리하자면

언제 어떤 패턴을 쓸까?

전통 MVVM은 설정 화면처럼 각 옵션이 독립적인 경우에 좋다. 다크모드 켜기/끄기, 알림 설정 같은 거 말이다. 서로 영향 안 주니까 굳이 복잡하게 조합할 필요가 없다.

순수 MVI는 결제나 복잡한 폼처럼 한 단계씩 검증이 필요한 화면에 쓴다. 상태 전이를 명확하게 추적해야 하거나, 디버깅을 위해 상태 히스토리를 기록해야 한다면 MVI가 답이다.

Reactive MVVM(지금 소개한 방식)은 그 중간이다. 여러 API를 조합해야 하고 반응형 업데이트가 필요한데, MVI처럼 빡빡한 구조까지는 필요 없을 때. 사실 대부분의 화면이 여기에 해당하는 것 같다.

전체 코드

@HiltViewModel
class ListViewModel @Inject constructor(
    private val fetchListUseCase: FetchListUseCase,
    private val fetchProgressUseCase: FetchProgressUseCase
) : ViewModel() {
    
    private val retrySignal = MutableStateFlow(Unit)
    private val _expandedIds = MutableStateFlow<Set<Long>>(emptySet())
    private val _navigationEvents = Channel<NavigationEvent>()
    
    val uiState: StateFlow<ListUiState> = retrySignal
        .flatMapLatest {
            combine(
                fetchListUseCase(),
                fetchProgressUseCase()
            ) { items, progress ->
                ListUiState(
                    items = items,
                    activeId = progress?.itemId ?: items.firstOrNull()?.id ?: -1,
                    isLoading = false
                )
            }
        }
        .onStart { emit(ListUiState(isLoading = true)) }
        .catch { emit(ListUiState(error = it)) }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = ListUiState(isLoading = true)
        )
    
    val expandedIds = _expandedIds.asStateFlow()
    val navigationEvents = _navigationEvents.receiveAsFlow()
    
    fun onRetry() {
        retrySignal.value = Unit
    }
    
    fun toggleItem(id: Long) {
        _expandedIds.update { if (it.contains(id)) it - id else it + id }
    }
    
    fun onItemClick(id: Long) {
        viewModelScope.launch {
            _navigationEvents.send(NavigationEvent.ToDetail(id))
        }
    }
}

이 패턴의 장점

선언적 코드의 명확성

명령형으로 짜면 isLoading을 true로 바꾸고, 데이터 가져오고, 성공하면 이거하고 저거하고 이런 식으로 단계별로 다 써야 한다.

반면 Flow 체인은? 데이터가 어떻게 흐르고 변환되는지만 선언하면 된다. 실제 실행은 프레임워크가 알아서 한다. 코드 읽을 때도 "어떻게"보다 "무엇을"에 집중할 수 있어서 의도가 명확하다.

자동 메모리 관리

StateFlow가 마지막 값을 캐싱하고, WhileSubscribed가 구독자 없으면 알아서 정리하고, viewModelScope가 ViewModel 죽을 때 코루틴 다 취소하고 메모리 관리를 직접 할 일이 거의 없다. 메모리 누수 걱정이 줄어든다.

상태 일관성 보장

전통 MVVM에서는 여러 StateFlow를 하나씩 업데이트하다가 중간에 터지면 일부만 업데이트된 이상한 상태가 된다.

// 문제: 중간에 터지면?
_isLoading.value = true
_error.value = null
// 여기서 크래시! isLoading=true, error=null, items=empty 상태로 남음
_items.value = fetchItems()
_isLoading.value = false

하지만 combine을 쓰면? 모든 데이터가 준비된 후 한 번에 새 상태를 만들어내니까 항상 일관성이 유지된다.

결론

이건 단순히 flow를 사용하는 것은 아니고 데이터 흐름을 선언하고 부작용을 최소화하는 함수형 프로그래밍 철학을 Android에 녹여낸 것이다. 동시에 MVVM의 실용성과 MVI의 안전성을 적절히 섞은 Reactive MVVM이라고 볼 수 있다. Best Practice라고 볼 수는 없지만 적어도 내가 가용할 수 있는 자원 내에서 그나마 효율적인 방법을 선택한 것이기 때문에 숙련도에 따라서 취사 선택하면 될 것 같다.

0개의 댓글