안드로이드 외부 Flow 처리 개선

손현수·2025년 6월 1일
post-thumbnail

현재 운영 중인 앱을 개선하는 과정에서 Composable 구조가 점점 복잡해졌고, 외부로부터 전달받는 Flow의 개수 또한 증가했다.
이로 인해 코드의 가독성이 떨어지고 유지보수가 어려워지는 문제가 발생하였다.
또한 외부 Flow를 수신하고 처리하는 로직을 컴포저블에서 수행하였는데, 이는 책임 분리 관점에서 잘못된 구조이다.
따라서 개별적으로 전달되고 있던 Flow들을 sealed class로 통합하고 Flow 처리 로직을 ViewModel 내부로 옮기는 개선 작업을 해보았다.

BacklogExternalEvent 정의

구현하기 전에, 이 클래스를 어디에 둬야 할지 잠시 고민했다.
:feature:main, :feature:backlog 두 모듈에서 모두 접근할 수 있는 상위 모듈이어야 하기 때문에 :domain, :core 모듈을 먼저 떠올렸다.

하지만 UI 이벤트를 처리하는 용도로 사용되는 클래스를 domain 모듈에 정의한다면 domain layer가 presentation layer를 알게 되는 역방향 의존 구조가 되므로 적절하지 않다.

따라서 BacklogExternalEvent는 :core:ui 모듈에 정의하였다. 이후에 다른 Composable에 대한 외부 이벤트 클래스도 이 위치에 함께 정의할 계획이다.

sealed class BacklogExternalEvent {
    data class UpdateDeadline(val deadline: String?): BacklogExternalEvent()
    data class DeleteTodo(val id: Long): BacklogExternalEvent()
    data class ActiveItem(val id: Long): BacklogExternalEvent()
    data class UpdateBookmark(val id: Long): BacklogExternalEvent()
    data class UpdateCategory(val id: Long): BacklogExternalEvent()
    data class ToggleRepeat(val id: Long): BacklogExternalEvent()
    data class UpdateTime(val info: Pair<Long, String>): BacklogExternalEvent()
}

sealed class를 활용하여 BacklogScreen으로 전달되는 외부 이벤트들을 정의했다.

Kotlin의 sealed class는 계층 구조의 클래스를 정의할 수 있는 문법으로, 모든 하위 타입이 컴파일 시점에 고정되는 것이 특징이다.
이 덕분에 when 문으로 이벤트를 처리할 때 컴파일러가 모든 분기를 강제하므로, 실수로 이벤트 처리를 누락하는 상황을 방지할 수 있다.

또한 모든 이벤트를 하나의 sealed class로 그룹화할 수 있기 때문에,
한 곳에서 이벤트 정의와 처리 로직을 관리할 수 있어 확장과 유지보수가 용이하고, 코드의 가독성도 향상된다.

MainViewModel Flow 선언 로직 변경

기존에는 다음과 같이 여러 Flow를 개별적으로 선언했다.

@HiltViewModel
class MainViewModel @Inject constructor(
    private val getYesterdayListUseCase: GetYesterdayListUseCase
) : BaseViewModel<MainPageState>(MainPageState()) {
    val updateDeadlineFlow = MutableSharedFlow<String?>()
    val deleteTodoFlow = MutableSharedFlow<Long>()
    val activateItemFlow = MutableSharedFlow<Long>()
    val updateBookmarkFlow = MutableSharedFlow<Long>()
    val updateCategoryFlow = MutableSharedFlow<Long?>()
    val updateTodoRepeatFlow = MutableSharedFlow<Long>()
    val updateTodoTimeFlow = MutableSharedFlow<Pair<Long, String>>()
    // 생략..

이제는 이 이벤트들을 하나의 sealed class로 통합했기 때문에 다음과 같이 간소화할 수 있다.

@HiltViewModel
class MainViewModel @Inject constructor(
    private val getYesterdayListUseCase: GetYesterdayListUseCase
) : BaseViewModel<MainPageState>(MainPageState()) {
    val backlogEventFlow = MutableSharedFlow<BacklogExternalEvent>()
    // 생략..

또한 NavGraph에 Flow를 전달하는 코드도 함께 개선할 수 있다.

backlogNavGraph(
    navController = navController,
    showBottomSheet = showBottomSheet,
    updateDeadlineFlow = viewModel.updateDeadlineFlow,
    deleteTodoFlow = viewModel.deleteTodoFlow,
    activateItemFlow = viewModel.activateItemFlow,
    updateBookmarkFlow = viewModel.updateBookmarkFlow,
    updateCategoryFlow = viewModel.updateCategoryFlow,
    updateTodoRepeatFlow = viewModel.updateTodoRepeatFlow,
    updateTodoTimeFlow = viewModel.updateTodoTimeFlow,
    // 생략
backlogNavGraph(
    navController = navController,
    showBottomSheet = showBottomSheet,
    backlogExternalEvent = viewModel.backlogEventFlow,
    // 생략

BacklogScreen Flow 수집 및 처리 로직 개선

LaunchedEffect(activateItemFlow) {
    activateItemFlow.collect { id ->
        activeItemId = id
    }
}

LaunchedEffect(deleteTodoFlow) {
    deleteTodoFlow.collect {
        viewModel.deleteBacklog(it)
    }
}

LaunchedEffect(updateDeadlineFlow) {
    updateDeadlineFlow.collect {
        viewModel.setDeadline(it, uiState.selectedItem.todoId)
    }
}

LaunchedEffect(updateBookmarkFlow) {
    updateBookmarkFlow.collect {
        viewModel.updateBookmark(it)
    }
}

LaunchedEffect(updateCategoryFlow) {
    updateCategoryFlow.collect {
        viewModel.updateCategory(uiState.selectedItem.todoId, it)
    }
}

LaunchedEffect(updateTodoRepeatFlow) {
    updateTodoRepeatFlow.collect {
        viewModel.updateTodoRepeat(it)
    }
}

LaunchedEffect(updateTodoTimeFlow) {
    updateTodoTimeFlow.collect {
        viewModel.updateTodoTime(it.first, it.second)
    }
}

기존에는 위처럼 개별 Flow를 Composable에서 수집하고 그에 맞는 뷰모델 함수를 호출하는 형식으로 구현했다. 하지만 이는 Composable이 로직을 가지게 되는 것이기 때문에 ViewModel에 Flow를 전달하고 뷰모델 내부에서 처리하도록 개선했다.

LaunchedEffect(backlogExternalEvent) {
    viewModel.observeExternalEvents(backlogExternalEvent)
}

개선 후에는 이렇게 뷰모델의 메서드 하나를 호출하는 형태로 변경할 수 있다.

BacklogViewModel

fun observeExternalEvents(events: SharedFlow<BacklogExternalEvent>) {
    viewModelScope.launch {
        events.collect { event ->
            when(event) {
                is BacklogExternalEvent.ActiveItem -> { updateActiveItemId(id = event.id) }
                is BacklogExternalEvent.DeleteTodo -> { deleteBacklog(event.id) }
                is BacklogExternalEvent.ToggleRepeat -> { toggleRepeat(event.id) }
                is BacklogExternalEvent.UpdateBookmark -> { updateBookmark(event.id) }
                is BacklogExternalEvent.UpdateCategory -> { updateCategory(uiState.value.selectedItem.todoId, event.id) }
                is BacklogExternalEvent.UpdateDeadline -> { setDeadline(event.deadline, uiState.value.selectedItem.todoId) }
                is BacklogExternalEvent.UpdateTime -> { updateTodoTime(event.info.first, event.info.second) }
            }
        }
    }
}

뷰모델에서는 이벤트에 따른 분기를 통해 각 이벤트마다 적절한 메서드를 호출할 수 있고, 모든 이벤트를 누락 없이 처리할 수 있다. sealed class를 활용했기 때문에 when 문에서 누락된 이벤트가 존재한다면 에러가 발생한다.

결론

이번 구조 개선을 통해 Composable은 상태와 UI 표현에 집중하고,
ViewModel은 로직과 외부 이벤트 처리의 책임을 명확히 담당하도록 분리할 수 있었다.

또한 개별적으로 전달되던 여러 Flow를 하나의 sealed class로 통합함으로써
코드의 간결성과 확장성, 그리고 유지보수성까지 확보할 수 있었다.

profile
안녕하세요.

0개의 댓글