Android

MVI 를 찍먹해보자.

최데브 2025. 1. 19. 19:27

mvi 이야기는 한참전부터 나왔던거 같은데

대충 아~ 이런게 있다더라 너 써봤니?

하고 말았던 mvi

 

이제는 현업에서도 꽤 많이 사용하고 있다는 소식이 들린다.

mvi 는 쉽게 개발해주는 라이브러리도 많이 쓰는거 같은데

역시 튜닝의 끝은 순정이라고

나는 기본 개념을 익히는 느낌으로 만들어볼까한다.

이렇게 익히고나서 라이브러리를 쓰는게 편해지는거 같고 기분이 좋으니까

 

그래서 mvi 가 뭘까

 

짠.

View ( Model ( Intent( ) ) )

 

첨에 이거 봤을때는 그래서 이게 뭔데 라고 생각했지만

알고나니까 아! 역시는 역시다 라고 생각한다.

하지만 나는 친절한 개발자니까 좀 더 풀어서 적어보자.

 

대충 요렇게 돌아가는건데

 

view : 우리가 맨날 만드는 화면이다. view 나 compose 모두가 될 수 있다.

intent : 앱의 상태를 변경하는 요청이다. 유저의 요청 or 유저가 발생시킨 event 라고 할 수 있다.

model : 앱의 유일한 상태와 데이터를 가진다. mvi 는 이 model 을 불변객체로 만드는게 필수다.  직접 변경하지 못하고 정확하게 event 로만 변경하는걸 원칙으로 한다.

 

이걸 읽고 다시 그림을 봐보자.

그러면 흐름이 이렇게 되는걸 알 수 있다.

1. 유저가 앱에서 특정 동작을 한다.

2. 그 동작의 이벤트가 intent를 타고 간다 (viewModel 로 간다)

3. model 에서 필요한 데이터를 만들어내고 

4. model 의 데이터를 토대로 view 를 갱신한다.

 

이쯤되면 갑자기 의문이 든다.

에? 그냥 mvvm 랑 비슷한거 같은데 mvi 왜씀?

 

자자 진정하세요.

 

mvi 를 쓰면 

1. 불변성

2. 디버깅 용이

3. 로직 분리

4. state problem X

5. 단방향 데이터 흐름

 

나도 어디서 읽은거지만 mvi 가 나온 배경에는 state problem 이 큰 영향을 줬다고 한다.

mvvm 에서 상태관리를 할때 여러곳에서 데이터를 받아서 상태동기화가 충돌하기도 하기 때문에

이게 지금 나오면 안되는데 갑자기 나온다던지 개발자의 실수로 상태관리에 있어서 예상치 못한 버그가 생기는 경우가 종종 있었는데 mvi 는 문제 발생 지점을 빠르게 찾고 사전에 방지할 수 있게 해준다.

 

여러 input 과 여러 스레드가 돌아갈때 state 충돌이 일어나면 안된다는것도 중요한 점이다.

 

이제 코드로 알아보자.

data class ScreenState(
    val isLoading: Boolean = false, // ProgressBar 상태
    val items: List<String> = emptyList(), // 리스트 데이터
    val errorMessage: String? = null // 에러 메시지 (옵션)
)

// 사용자 의도를 정의
sealed class ScreenIntent {
    object LoadItems : ScreenIntent()
    object RefreshItems : ScreenIntent()
}

// 부수효과(Side Effect)를 정의
sealed class ScreenSideEffect {
    data class ShowError(val message: String) : ScreenSideEffect() // 에러 메시지 표시
    object NavigateToNextScreen : ScreenSideEffect() // 네비게이션 이벤트
}

 

여기서 state 는 모델의 역할을 한다.  

하나의 모델만을 이용하여 화면의 모든 상태를 명확하게 표현한다.

상태의 모든 가능한 조합을 model 로 명확하게 정의하고 UI 는 항상 이 Model 을 기반으로 렌더링 된다.

상태변화는 Intent 에 의해서만 이루어진다.

 

아 그리고 SideEffect 라는건 위에서 설명을 하지 않았는데 intent 와는 아래와 같은 차이점이 있다.

이후 viewModel에서 예시를 보면 이해가 편하다

intent Side Effect
목적 : 사용자의 의도를 나타냄 목적 : 상태 변경 외의 부수적인 작업 수행.
예시 : 버튼 클릭, 스위치토클 예시: 네비게이션, 토스트 , api 호출
상태 변경의 트리거 역할 일회성 이벤트 처리
순수함수로 처리가능 비순수 작업(외부/IO) 포함
viewModel 내부에서 상태를 업데이트 외부 시스템/ UI와 상호작용

 

class ScreenReducer {
    fun reduce(state: ScreenState, intent: ScreenIntent): ScreenState {
        return when (intent) {
            is ScreenIntent.LoadItems -> state.copy(isLoading = true, errorMessage = null)
            is ScreenIntent.RefreshItems -> state.copy(isLoading = true, errorMessage = null)
            is ScreenIntent.UpdateItems -> when (intent.result) {
                is Result.Success -> state.copy(
                    isLoading = false,
                    items = intent.result.data
                )
                is Result.Failure -> state.copy(
                    isLoading = false,
                    errorMessage = intent.result.error.message
                )
            }
        }
    }
}

sealed class Result<out T> {
    data class Success<T>(val data: T) : Result<T>()
    data class Failure(val error: Throwable) : Result<Nothing>()
}

 

Reducer 라는 이름으로 많이 사용하는게 있는데

Reducer는 현재 상태와 Intent를 기반으로 새로운 상태를 반환하는 함수다.

ViewModel에서 상태를 직접 업데이트하지 않고, Reducer를 사용하여 상태를 업데이트한다.
Reducer는 ViewModel에서 상태 업데이트 로직을 캡슐화하는 역할을 한다.

 

Reducer의 이점

  1. 상태 관리의 중앙화:
    • 모든 상태 변경 로직이 Reducer에 집중되므로, 상태 변화의 흐름을 명확히 추적할 수 있다
    • ViewModel에서 상태를 직접 변경하지 않으므로 상태 변경의 책임이 분리된다
  2. 테스트 용이성:
    • Reducer는 순수 함수로 작성되기 때문에 상태 변경 로직을 독립적으로 테스트할 수 있다
    • 예: 특정 Intent와 초기 State를 넣어 예상 결과를 검증 가능.
  3. 일관성 보장:
    • 상태 변경은 항상 Reducer를 통해 이루어지므로, 예상치 못한 상태 변화가 발생할 가능성이 줄어든다.
    • 예를 들어, isLoading과 items의 상태가 불일치하는 경우를 방지
  4. 코드 재사용성:
    • Reducer를 별도의 객체로 정의하면 여러 ViewModel에서 동일한 상태 관리 로직을 재사용할 수 있다.

 

class ScreenViewModel : ViewModel() {
    // Event 스트림
    //Channel 을 도입하면서 서로 다른 스레드에서 호출됐을때 생길 수 있는 동시성 이슈를 해결
    private val events = Channel<ScreenIntent>(Channel.BUFFERED)

    // 상태 관리
    private val reducer = ScreenReducer()
    val state: StateFlow<ScreenState> = events.receiveAsFlow()
    	// Reducer를 사용해 상태 업데이트, 외부에서 상태를 변경할 요인을 없애준다.
        .runningFold(ScreenState(), reducer::reduce)
        .stateIn(viewModelScope, SharingStarted.Eagerly, ScreenState())

    // Event를 처리
    fun handleEvent(event: ScreenIntent) {
        viewModelScope.launch {
            events.send(event)
        }
    }

    // 비동기 작업 (예: API 호출)
    fun loadItems() {
        viewModelScope.launch {
            handleEvent(ScreenIntent.LoadItems)
            try {
                val items = fetchItemsFromApi()
                handleEvent(ScreenIntent.UpdateItems(Result.Success(items)))
            } catch (e: Exception) {
                handleEvent(ScreenIntent.UpdateItems(Result.Failure(e)))
            }
        }
    }

    private suspend fun fetchItemsFromApi(): List<String> {
        kotlinx.coroutines.delay(2000) // API 호출 시뮬레이션
        return listOf("Item 1", "Item 2", "Item 3")
    }
}

 

import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel

@Composable
fun Screen(viewModel: ScreenViewModel = viewModel()) {
    val state by viewModel.state.collectAsState()

    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        when {
            state.isLoading -> {
                CircularProgressIndicator()
            }
            state.errorMessage != null -> {
                Column(horizontalAlignment = Alignment.CenterHorizontally) {
                    Text("Error: ${state.errorMessage}", color = MaterialTheme.colorScheme.error)
                    Spacer(modifier = Modifier.height(8.dp))
                    Button(onClick = { viewModel.handleIntent(ScreenIntent.LoadItems) }) {
                        Text("Retry")
                    }
                }
            }
            state.items.isNotEmpty() -> {
                LazyColumn(
                    modifier = Modifier.fillMaxSize(),
                    contentPadding = PaddingValues(16.dp)
                ) {
                    items(state.items) { item ->
                        Text(item, modifier = Modifier.padding(8.dp))
                    }
                }
            }
        }
    }

    // 초기 로드 트리거
    LaunchedEffect(Unit) {
        viewModel.handleIntent(ScreenIntent.LoadItems)
    }
}

 

간단한 예제를 통해 알아봤다.

사실 뜯어보면 mvvm 이랑 크게 다르지도 않다. 

좀 더 명확하게 코드흐름을 파악하기 위해 구조적인 장치가 추가된 느낌이다.

 

나중에 mvi 로 프로젝트를 해보게 된다면 그때는 mvi 라이브러리도 한번 도입해봐야겠다.

 

반응형