원샷(One-Shot) 소스와 스트림(Stream) 소스를 위한 상태 생산 파이프라인
원샷(One-Shot) 소스와 스트림(Stream) 소스를 위한 상태 생산 파이프라인
UI 상태 생산 파이프라인(state production pipeline)에서는 항목 삭제나 단일 레코드 조회와 같은 원샷(one-shot) 연산과, 리포지토리의 실시간 업데이트처럼 스트림 기반의 소스를 결합하는 경우가 흔히 발생합니다. 이때 권장하는 전략은 원샷 연산의 결과를 MutableStateFlow 인스턴스로 끌어올린 뒤, combine()을 사용하여 리포지토리 스트림과 결합하고, 그 결과를 viewModelScope에 스코핑된 단일 StateFlow로 노출하는 것입니다. 이러한 패턴은 실무에서 ViewModel을 설계할 때 매우 자주 활용되며, 면접에서도 상태 관리의 이해도를 평가하기 위해 빈번히 출제되는 주제이기도 합니다.
이번 면접 질문을 통하여 아래 내용들을 학습하실 수 있습니다.
- 원샷 연산을 상태 생산을 위해 스트림으로 끌어올려야 하는 이유를 설명할 수 있습니다.
MutableStateFlow를 사용하여 원샷 결과를 반응형 값으로 표현하는 방법을 이해합니다.combine()을 활용하여 원샷 소스와 스트림 소스를 통합된 UI 상태로 결합할 수 있습니다.stateIn()을 통해 결과 Flow를 ViewModel 생명주기에 스코핑하는 방법을 학습합니다.- 생명주기 인식 수집(lifecycle-aware collection)에 적합한
SharingStarted전략을 선택할 수 있게 됩니다.
원샷 연산을 끌어올려야 하는 이유
deleteTask()와 같은 원샷 연산은 "태스크가 삭제되었다"라는 단일 결과만을 생산합니다. 이 결과를 ViewModel 내의 일반 Boolean 필드에 저장하면, UI 측에서 이를 반응적으로 관찰할 방법이 없습니다. 개발자가 연산이 완료된 후 UI에 수동으로 알림을 보내야 하는데, 이는 반응형 계약(reactive contract)을 깨뜨리는 결과를 초래합니다.
결과를 MutableStateFlow로 감싸면 원샷 결과가 스트림 기반 데이터와 동일한 반응형 파이프라인의 일부가 됩니다. combine() 연산자가 두 소스를 병합하고, UI는 모든 소스의 최신 상태를 항상 반영하는 단일 StateFlow<UiState>를 수집하기만 하면 됩니다. 이렇게 하면 별도의 관찰 메커니즘을 관리할 필요가 없어지므로 코드가 훨씬 간결해집니다.
이 패턴이 중요한 이유는 ViewModel이 두 가지 타입의 데이터를 빈번하게 혼합하기 때문입니다. 가령, 태스크 상세 화면에서는 리포지토리로부터 태스크 데이터의 실시간 스트림(스트림 소스)이 필요하고, 동시에 사용자가 방금 태스크를 삭제했는지를 나타내는 플래그(원샷 소스)도 필요합니다. 원샷 결과를 Flow로 끌어올리지 않으면 개발자가 두 개의 별도 관찰 메커니즘을 관리해야 하며, 이는 UI를 복잡하게 만들고 상태 불일치가 발생할 여지를 만듭니다. 면접에서 이 부분을 질문받았을 때, "왜 단일 파이프라인으로 통합해야 하는지"를 상태 일관성(state consistency) 관점에서 설명하실 수 있다면 높은 평가를 받으실 수 있습니다.
설계 전략
각 원샷 결과를 MutableStateFlow로 표현합니다. 리포지토리가 제공하는 스트림과 combine()으로 결합한 뒤, stateIn()과 SharingStarted.WhileSubscribed를 사용하여 viewModelScope에 스코핑하면 UI가 수집 중일 때만 Flow가 활성화됩니다. 다음 예제 코드를 통해 구체적인 구현 방식을 살펴보겠습니다.
class TaskDetailViewModel(
private val tasksRepository: TasksRepository,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val taskId: String =
savedStateHandle["taskId"] ?: ""
// 원샷 결과를 MutableStateFlow로 관리
private val _isTaskDeleted = MutableStateFlow(false)
// 리포지토리의 스트림 소스
private val _task =
tasksRepository.getTaskStream(taskId)
val uiState: StateFlow<TaskDetailUiState> = combine(
_isTaskDeleted,
_task
) { isDeleted, taskResult ->
TaskDetailUiState(
task = taskResult.data,
isTaskDeleted = isDeleted
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = TaskDetailUiState()
)
fun deleteTask() = viewModelScope.launch {
tasksRepository.deleteTask(taskId)
// 삭제 완료 후 원샷 결과를 업데이트
_isTaskDeleted.update { true }
}
}