Development/Android

[Android / Kotlin] MVI 패턴을 활용한 Compose + Api 호출 처리

SeungYong.Lee 2024. 10. 13. 17:08
반응형

MVI 패턴이란?

Model - View - Intent의 약자로, 단방향 데이터 흐름을 가지는 패턴입니다.

State를 중심으로 UI에 전달하는 방식으로서 여기서 Intent는 기존에 알고 있는 Intent와는 다른 개념입니다.

 

  • Model: 상태(State)로, UI에서 보일 데이터 또는 상태입니다.
  • View: 화면(UI)을 구성하는 요소로, Model의 상태 변화를 관찰하여 이를 반영합니다.
  • Intent: 사용자의 액션이나 이벤트를 정의한 것으로, 이를 통해 Model이 업데이트됩니다.

 

상태가 중심인 MVI에서는 데이터는 불변의 구조로 구성되며, 그로 인해 상태가 변할 때 필요한 부분만 갱신되면서 효율성이 증가합니다.

 

MVI 패턴 장점

1. 단방향 데이터 흐름 (Unidirectional Data Flow)

  • 상태(State)가 한 방향으로만 흐르기 때문에 코드의 예측 가능성이 높아지고, 디버깅이 쉬워집니다.
  • Intent → ViewModel → Model → View 순으로 흐름이 고정되어 있어 데이터 관리가 명확합니다.

2. 상태 기반 UI (State-driven UI)

  • 상태를 중심으로한 UI 렌더링 구조로 인해 UI와 데이터의 불일치 문제를 방지합니다.
  • 모든 UI는 특정한 상태에 의해 결정되기 때문에 일관된 화면 구성이 가능합니다.

3. Testability (테스트 용이성)

  • View와 비즈니스 로직이 분리되어 있어 단위 테스트UI 테스트가 쉬워집니다.
  • Intent와 State를 분리하여 테스트 시 의존성을 제거할 수 있습니다.

4. 확장성 및 유지보수 용이 (Scalable and Maintainable)

  • 상태(State)가 하나의 Immutable 객체로 관리되기 때문에 상태 전이가 명확하고 유지보수가 용이합니다.
  • 기능이 추가될 때도 Intent와 State를 쉽게 추가할 수 있습니다.

5. 비동기 작업 처리에 강함 (Effective Handling of Asynchronous Operations)

  • 네트워크 요청 같은 비동기 작업을 처리할 때, 로딩 상태(Loading), 성공(Success), 실패(Failure) 같은 상태를 명확하게 정의하여 비동기 처리를 직관적으로 구현할 수 있습니다.

6. 모든 상태의 중앙 집중화 (Centralized State Management)

  • 상태를 ViewModel 등에서 중앙에서 관리하므로 어느 시점에서든 상태를 쉽게 추적할 수 있습니다.
  • 이로 인해 상태 관리가 일관성 있게 유지됩니다.

7. UI와 비즈니스 로직의 분리 (Separation of Concerns)

  • View와 비즈니스 로직이 분리되어 서로 독립적으로 개발 및 유지보수가 가능합니다.
  • UI는 상태를 구독하고 그리기만 할 뿐, 비즈니스 로직에 대한 관심사를 가지지 않습니다.

8. 구독과 자동 업데이트 (Reactive Updates)

  • StateFlow, LiveData 같은 반응형 스트림과 함께 사용하면 UI가 상태 변화를 자동으로 구독하고 업데이트합니다.
  • 사용자가 액션을 수행하면 즉각적으로 화면에 반영됩니다.

위와 같은 장점이 있지만.. 무엇보다 러닝 커브가 높고, 상태 관리와 구성을 위해 단순 MVP 방식에 비해 많은 코드가 필요할 수 있습니다.

 

MVI 패턴 적용 예시

무료로 랜덤 Advice를 제공하는 api를 활용하여 Compose Text에 보여주는 로직을 구성해 보겠습니다.

https://api.adviceslip.com/

 

Advice Slip JSON API

The Advice Slip JSON API is provided for free. 😎 It currently gives out over 10 million pieces of advice every year. If you would like to say thank-you to the creator, then please buy them a coffee or beer! ☕️🍺 A slip object is a simple piece of

api.adviceslip.com

interface ApiService {
    @GET("/advice")
    suspend fun fetchAdvice(): Response<AdviceResponse>
}

 

State Model을 선언하여 호출 처리에 대한 개별 상태 값을 구성해 줍니다.

sealed class AdviceState {
    object Loading : AdviceState()
    data class Success(val advice: String) : AdviceState()
    data class Error(val errorMessage: String) : AdviceState()
}

 

Intent class를 선언하여 사용자의 동작에 대해 정의해 줍니다.

//Intent : 사용자의 액션이나 이벤트를 정의
sealed class AdviceIntent {
    object FetchAdvice : AdviceIntent() // API 요청을 보내는 Intent
}

 

ViewModel에서는 init 시점에 API 호출을 처리하고 uiState에 따라 로딩 -> 성공 or 실패에 따른 UI 처리를 진행하게 됩니다.

@HiltViewModel
class AdviceViewModel @Inject constructor(
    @NetworkModule.Advice private val apiService: ApiService
) : ViewModel() {
    private val _uiState = MutableStateFlow<AdviceState>(AdviceState.Loading)
    val uiState: StateFlow<AdviceState> = _uiState

    init {
        callIntent(AdviceIntent.FetchAdvice)
    }

    private fun callIntent(intent: AdviceIntent) {
        when (intent) {
            is AdviceIntent.FetchAdvice -> fetchAdvice()
        }
    }

    private fun fetchAdvice() {
        viewModelScope.launch {
            _uiState.value = AdviceState.Loading
            try {
                val response = apiService.fetchAdvice()
                if (response.isSuccessful) {
                    val advice = response.body()?.slip?.advice ?: "No advice found"
                    _uiState.value = AdviceState.Success(advice)
                } else {
                    _uiState.value = AdviceState.Error("Failed to fetch advice")
                }
            } catch (e: Exception) {
                _uiState.value = AdviceState.Error(e.message ?: "Unknown error")
            }
        }
    }
}

 

 

ViewModel을 통해 Advice Intent가 요청되고 State에 따른 구성을 진행합니다.

@Composable
fun AdviceScreen(viewModel: AdviceViewModel) {
    val uiState by viewModel.uiState.collectAsState()
    when (uiState) {
        is AdviceState.Loading -> {
            CircularProgressIndicator() // 로딩 상태 UI
        }
        is AdviceState.Success -> {
            Text(text = (uiState as AdviceState.Success).advice) // 성공 상태 UI
        }
        is AdviceState.Error -> {
            Text(text = (uiState as AdviceState.Error).errorMessage) // 에러 상태 UI
        }
    }
}

반응형