과거 MVP를 거쳐 주류가 된 MVVM, 이젠 또 MVI? 이건 뭔데?

시나리오: 복잡한 필터 상태 관리가 필요한 상품 목록 화면

이 예제에서는 MVVM 패턴과 MVI 패턴이 복잡한 상태를 어떻게 관리하는지 보여줍니다. 이 예제의 시나리오는 여러 개의 필터(가격, 카테고리, 정렬 등)를 적용할 수 있는 상품 목록 화면을 다룹니다. 필터가 동시에 적용될 수 있으며, 새로운 데이터를 서버에서 가져와 목록을 갱신해야 합니다.

1단계: MVVM 구현

ViewModel (MVVM)

MVVM 패턴에서는 ViewModel이 모든 상태를 관리하며, 필터가 변경될 때마다 상태가 업데이트됩니다. 각 필터는 별도로 관리되며, 필터가 많아질수록 로직이 복잡해질 수 있습니다.

class ProductListViewModel(
    private val repository: ProductRepository
) : ViewModel() {

    private val _products = MutableLiveData<List<Product>>()
    val products: LiveData<List<Product>> get() = _products

    private var priceFilter: PriceFilter = PriceFilter.NONE
    private var categoryFilter: CategoryFilter = CategoryFilter.ALL
    private var sortingOrder: SortingOrder = SortingOrder.NONE

    fun applyPriceFilter(newPriceFilter: PriceFilter) {
        priceFilter = newPriceFilter
        fetchProducts()
    }

    fun applyCategoryFilter(newCategoryFilter: CategoryFilter) {
        categoryFilter = newCategoryFilter
        fetchProducts()
    }

    fun applySorting(sorting: SortingOrder) {
        sortingOrder = sorting
        fetchProducts()
    }

    private fun fetchProducts() {
        viewModelScope.launch {
            val result = repository.getProducts(priceFilter, categoryFilter, sortingOrder)
            _products.value = result
        }
    }
}

View (MVVM)

뷰에서는 LiveData를 관찰하고 UI를 업데이트합니다.

class ProductListFragment : Fragment() {

    private lateinit var viewModel: ProductListViewModel

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        viewModel = ViewModelProvider(this).get(ProductListViewModel::class.java)

        viewModel.products.observe(viewLifecycleOwner, Observer { products ->
            // RecyclerView를 새로운 상품 목록으로 업데이트
        })

        // 필터 적용
        priceFilterButton.setOnClickListener {
            viewModel.applyPriceFilter(PriceFilter.LOW_TO_HIGH)
        }

        categoryFilterButton.setOnClickListener {
            viewModel.applyCategoryFilter(CategoryFilter.ELECTRONICS)
        }

        sortingButton.setOnClickListener {
            viewModel.applySorting(SortingOrder.BY_NAME)
        }
    }
}

MVVM의 문제점

이 MVVM 구현에서 상태가 복잡해질수록 관리가 어려워집니다. 필터가 변경될 때마다 새로운 메서드가 필요해지고, 동시에 여러 필터가 변경되면 로직이 복잡해져 코드 중복이 발생할 수 있습니다.


2단계: MVI 패턴으로 리팩토링

MVI 개요

MVI (Model-View-Intent)에서는 상태를 단일한 불변 State 객체로 모델링합니다. 모든 사용자 액션이나 이벤트는 Intent로 표현되며, 모든 상태 전환은 Reducer를 통해 이루어집니다. 이 패턴은 상태 관리를 중앙 집중화하여 복잡한 상태를 더 쉽게 처리할 수 있도록 합니다.

MVI 구현

상태 (MVI)

단일 상태는 모든 가능한 UI 상태를 나타냅니다.

data class ProductListState(
    val products: List<Product> = emptyList(),
    val priceFilter: PriceFilter = PriceFilter.NONE,
    val categoryFilter: CategoryFilter = CategoryFilter.ALL,
    val sortingOrder: SortingOrder = SortingOrder.NONE,
    val isLoading: Boolean = false,
    val errorMessage: String? = null
)

Intent (MVI)

Intent는 사용자의 액션을 나타냅니다. 일반적으로 Intent는 sealed class로 표현됩니다.

sealed class ProductListIntent {
    data class ApplyPriceFilter(val priceFilter: PriceFilter) : ProductListIntent()
    data class ApplyCategoryFilter(val categoryFilter: CategoryFilter) : ProductListIntent()
    data class ApplySorting(val sortingOrder: SortingOrder) : ProductListIntent()
    object LoadProducts : ProductListIntent()
}

Reducer (MVI)

Reducer는 현재 상태와 Intent를 받아 새로운 상태를 반환합니다.

class ProductListReducer {
    fun reduce(intent: ProductListIntent, currentState: ProductListState): ProductListState {
        return when (intent) {
            is ProductListIntent.ApplyPriceFilter -> {
                currentState.copy(priceFilter = intent.priceFilter, isLoading = true)
            }
            is ProductListIntent.ApplyCategoryFilter -> {
                currentState.copy(categoryFilter = intent.categoryFilter, isLoading = true)
            }
            is ProductListIntent.ApplySorting -> {
                currentState.copy(sortingOrder = intent.sortingOrder, isLoading = true)
            }
            is ProductListIntent.LoadProducts -> {
                currentState.copy(isLoading = true)
            }
        }
    }
}

ViewModel (MVI)

이제 ViewModel은 상태를 보유하며, Reducer를 통해 Intent를 처리합니다.

class ProductListViewModel(
    private val repository: ProductRepository
) : ViewModel() {

    private val _state = MutableLiveData<ProductListState>()
    val state: LiveData<ProductListState> get() = _state

    private val reducer = ProductListReducer()

    init {
        _state.value = ProductListState()
    }

    fun dispatch(intent: ProductListIntent) {
        val currentState = _state.value ?: ProductListState()
        _state.value = reducer.reduce(intent, currentState)

        if (intent is ProductListIntent.LoadProducts) {
            loadProducts(currentState.priceFilter, currentState.categoryFilter, currentState.sortingOrder)
        }
    }

    private fun loadProducts(priceFilter: PriceFilter, categoryFilter: CategoryFilter, sortingOrder: SortingOrder) {
        viewModelScope.launch {
            try {
                val products = repository.getProducts(priceFilter, categoryFilter, sortingOrder)
                _state.value = _state.value?.copy(products = products, isLoading = false)
            } catch (e: Exception) {
                _state.value = _state.value?.copy(isLoading = false, errorMessage = e.message)
            }
        }
    }
}

View (MVI)

뷰에서는 Intent를 ViewModel에 보내고 상태를 관찰합니다.

class ProductListFragment : Fragment() {

    private lateinit var viewModel: ProductListViewModel

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        viewModel = ViewModelProvider(this).get(ProductListViewModel::class.java)

        viewModel.state.observe(viewLifecycleOwner, Observer { state ->
            // RecyclerView를 새로운 상품 목록으로 업데이트
            if (state.isLoading) {
                // 로딩 인디케이터 표시
            } else if (state.errorMessage != null) {
                // 오류 메시지 표시
            } else {
                // 상품 목록 업데이트
            }
        })

        // Intent 전송
        priceFilterButton.setOnClickListener {
            viewModel.dispatch(ProductListIntent.ApplyPriceFilter(PriceFilter.LOW_TO_HIGH))
        }

        categoryFilterButton.setOnClickListener {
            viewModel.dispatch(ProductListIntent.ApplyCategoryFilter(CategoryFilter.ELECTRONICS))
        }

        sortingButton.setOnClickListener {
            viewModel.dispatch(ProductListIntent.ApplySorting(SortingOrder.BY_NAME))
        }
    }
}

MVI가 MVVM보다 나은 점

  1. 단일 상태 관리: MVI는 모든 상태를 단일 State 객체로 중앙 집중화하여 복잡한 UI 상호작용을 더 쉽게 관리하고 디버깅할 수 있습니다.
  2. 불변성: MVI에서 상태는 불변이므로 애플리케이션이 더 예측 가능해지고, 부작용의 위험이 줄어듭니다.
  3. 명확한 흐름: 데이터와 액션의 흐름이 더 명확합니다. 각각의 액션이 Intent로 표현되고, Reducer를 통해 상태가 변화하므로 흐름이 더 명시적입니다.

복잡한 앱 구조에서는 MVI 쓰긴 써야 한다.

이 예제에서 MVVM으로 복잡한 상태를 관리하는 것이 얼마나 어려울 수 있는지 확인한 후, MVI로 리팩토링하여 상태 관리를 중앙 집중화하고 더 예측 가능하고 유지보수하기 쉬운 흐름을 만들었습니다.

profile
클린코드와 UX를 생각하는 비즈니스 드리븐 소프트웨어 엔지니어입니다.

0개의 댓글