MVI in Project

t1nm1ksun·2025년 4월 17일

안드로이드

목록 보기
5/5

프로젝트에서 MVI를 어떻게 사용했을까요?

일단 세가지 Interface를 구현해야 합니다!

State, Event, SideEffect

package com.depromeet.team6.presentation.util.base

interface UiState

interface UiEvent

interface UiSideEffect

UiState

State는 UI에 표현되는 값 또는 데이터의 집합이며 불변의 속성(Immutable)을 가집니다!

기존에 MVVM 패턴을 많이 사용하셨을텐데요, MVVM 패턴에서는 LiveDataStateFlow를 통해 값을 구독하고 뷰모델에서 관리하였는데요 UiState가 이와 같은 역할을 하고 이 State(상태)가 업데이트 되면 Recomposition(화면의 재구성)이 일어나게 됩니다.

UiEvent

Event는 말 그대로 발생하는 동작인데요, SideEffect와 가장 큰 차이점은 State를 변화시킨다는 겁니다.

이벤트는 Contract에서 정의하고, 뷰모델에서 로직처리를 담당합니다. 간단히 흐름을 보면 아래와 같습니다.

[ Contract에서 State와 Event 정의 → ViewModel에서 Event에 의해 State 변경 → State 변경에 따라 UI 의 Recomposition ]

class OnboardingContract {
    data class OnboardingUiState(val searchText: String = "")
    
    sealed class OnboardingEvent : UiEvent { 
    data class UpdateSearchText(val text: String) : OnboardingEvent() 
		    }
	}
	
@HiltViewModel
class OnboardingViewModel @Inject constructor() : BaseViewModel<OnboardingContract.OnboardingUiState, OnboardingContract.OnboardingSideEffect, OnboardingContract.OnboardingEvent>() {
    override fun createInitialState(): OnboardingContract.OnboardingUiState =
        OnboardingContract.OnboardingUiState()

    override suspend fun handleEvent(event: OnboardingContract.OnboardingEvent) {
        when (event) {       
	        is OnboardingContract.OnboardingEvent.UpdateSearchText ->
		        setState { copy(searchText = event.text) }
		     }
		  }
	}

예를 들어 “사용자가 검색창에 단어를 입력한다” 와 같은 사용자의 동작을 의미하는데요, 예시의 Event에 의해서 searchText 라는 State의 값이 변하게 되고 화면은 변경된 State값을 반영해야 하기 때문에 Recomposition이 일어나게 될 것 입니다.

UiSideEffect

SideEffect는 State와 별개의 이벤트입니다. 즉, State에 영향을 주지 않는 동작입니다. 예를 들어 스낵바, 토스트 메시지나 화면 이동(Navigate)이 이에 해당합니다.

SideEffect도 똑같이 Event와 마찬가지로 Contract에서 선언하고 setSideEffect에 의해 트리거 되면 로직이 실행되는 흐름이에요!

sealed interface BusCourseSideEffect : UiSideEffect {
        data object NavigateToBackStack : BusCourseSideEffect
    }

LaunchedEffect(viewModel.sideEffect, lifecycleOwner) {
        viewModel.sideEffect.flowWithLifecycle(lifecycle = lifecycleOwner.lifecycle)
            .collect { sideEffect ->
                when (sideEffect) {
                    is BusCourseContract.BusCourseSideEffect.NavigateToBackStack 
			                    -> navigateToBackStack()
                }
            }
    }
    
 BusCourseScreen(
		backButtonClicked = { 
			viewModel.setSideEffect(BusCourseContract.BusCourseSideEffect.NavigateToBackStack) })

State는 불변(Immutable)이라며!

State는 분명 불변이라고 했는데…? 라고 생각하실 수 있습니다! 정확합니다~

상태(State)는 불변(Immutable)이어야 한다

  • MVI(Mutation → View → Intent) 아키텍처의 기본 전제:

    “상태는 절대 직접 변경하지 않는다”

  • 상태는 항상 새로운 객체로 만들어서 교체해야 한다.

    를 따르고 있기 때문에 아래와 같이 copy( ) 를 사용합니다~

setState { copy(state = event.newStateValue) }

1️⃣ Copy를 사용하는 이유

  • 상태 불변성 유지: 직접 수정X, 새로운 객체 생성
  • 필요한 필드만 변경하고 나머지는 그대로 복사하여 효율적인 값 변경 가능
  • UI가 변경을 감지하여 업데이트 하도록 유도
  • 구조적으로 안전함 : 얘기치 않은 사이드 이펙트 방지

2️⃣ 안쓰면 어떻게 되는데?

  • State의 불변성 유지 X, 값이 안전하게 변경 X
  • UI가 변경을 감지하지 못하거나, 상태관리가 꼬일 수 있음

BaseViewModel

BaseViewModel을 만들어 중복 코드를 줄이고 유지보수를 쉽게

AtCha 프로젝트에서는 MVI 구조를 더욱 깔끔하게 관리하기 위해, 공통된 부분을 묶어서 추상 클래스(BaseViewModel) 로 관리합니다.

아래는 실제 AtCha에서 사용하는 BaseViewModel 코드 예시입니다.

abstract class BaseViewModel<State : UiState, SideEffect : UiSideEffect, Event : UiEvent>() :
    ViewModel() {
    private val initialState: State by lazy { createInitialState() }
    abstract fun createInitialState(): State

    private val _uiState = MutableStateFlow<State>(initialState)
    val uiState: StateFlow<State>
        get() = _uiState.asStateFlow()
    val currentState: State
        get() = uiState.value

    private val _event: MutableSharedFlow<Event> = MutableSharedFlow()
    val event: SharedFlow<Event>
        get() = _event.asSharedFlow()

    private val _sideEffect: MutableSharedFlow<SideEffect> = MutableSharedFlow()
    val sideEffect: Flow<SideEffect>
        get() = _sideEffect.asSharedFlow()

    fun setState(reduce: State.() -> State) {
        _uiState.value = currentState.reduce()
    }

    open fun setEvent(event: Event) {
        dispatchEvent(event)
    }

    private fun dispatchEvent(event: Event) = viewModelScope.launch {
        handleEvent(event)
    }

    protected abstract suspend fun handleEvent(event: Event)

    fun setSideEffect(sideEffect: SideEffect) {
        viewModelScope.launch { _sideEffect.emit(sideEffect) }
    }
}

이 BaseViewModel은 위에서 말씀드린 세 가지를 효율적으로 관리합니다.

  1. State 관리
    • UiState는 MutableStateFlow로 관리됩니다.
    • setState {} 함수를 사용하여 State를 쉽게 변경하고 Compose가 즉시 변경을 감지하도록 합니다.
    • currentState로 현재 State의 값을 쉽게 조회할 수 있습니다.
    • Reducer를 사용해 상태(State)를 변경하는 모든 로직이 오직 ViewModel 내의 이 리듀서(reduce) 를 통해서만 이루어지게 되고, 외부에서 직접적으로 상태를 변경할 수 있는 요인이 없어져서 RaceCondition을 예방합니다
  2. Event 처리
    • Event가 발생하면 setEvent(event)를 통해 dispatch되어 ViewModel 내부의 로직(handleEvent)이 처리하도록 구성됩니다.
    • 모든 ViewModel은 이 로직을 정의(override)해서 Event가 발생했을 때 어떤 행동을 취할지 직접 결정할 수 있습니다.
  3. SideEffect 처리
    • State와 무관하게 발생하는 UI 작업(토스트 메시지, 화면 이동 등)은 SideEffect로 관리됩니다.
    • SideEffect는 setSideEffect(sideEffect)를 호출하면 Flow로 전달되어 UI에서 별도의 로직으로 실행됩니다.

그래서 MVI 좋았나요??

AtCha가 MVI를 채택하고 Jetpack Compose를 적극 도입한 것은 단순히 트렌드를 따르기 위해서가 아닌, 명확한 구조와 유지보수의 용이성, 그리고 성능 향상이라는 실질적인 목표를 달성하기 위함이었고 아래와 같은 장점이 있었어요~

  • 명확한 데이터 흐름: UI 상태가 어디에서 변경되었는지 추적하기 쉬워졌습니다.
  • 높은 상태관리 안정성: 상태를 불변으로 유지하면서 사이드 이펙트와 버그 발생을 방지합니다. 또한 의도한 시점에서만 정확히 상태가 변경되어 UI가 일관성을 유지할 수 있습니다.
  • 유지보수 및 확장성 향상: 상태 변경, 이벤트 처리, 사이드 이펙트 처리가 모두 통일된 구조라 코드 변경 및 기능 추가가 편리해졌습니다.

사용하면서도 Compose는 MVVM보다 MVI가 적합하다고 생각했습니다!

앞으로…

성능개선!

이라는 말은 너무 광범위 하다고 생각해요~

Compose Compiler에서 제공하는 Stability Report (Compose Compiler Metrics) 라는 녀석이 있는데요, 이 기능을 사용하면 State의 안정성(Stability) 여부를 확인하고 불필요한 Recomposition(리컴포지션) 이 발생하는 원인을 쉽게 분석할 수 있습니다.

아무래도 Compose를 사용하며 중요한 요소 중 하나가 불필요한 Recomposition을 방지하는 것!

  • 왜요? Compose에서 리컴포지션은 State가 변경될 때만 발생해야 합니다. 하지만 State가 불필요하게 자주 변경되거나, 안정적이지 않은 객체가 사용될 경우, 불필요한 Recomposition이 빈번하게 발생하여 앱의 성능을 저하시킵니다. 즉, Stable 객체만을 사용하는 것이 앱의 성능과 안정성 측면에서 중요합니다!

라고 생각해서 최대한 안전하게(Stable), 최대한 효율적이게 (필요할 때만 Recomposition) 코드를 개선하는것이 계획입니다~

profile
KU CSE

0개의 댓글