728x90
https://superohinsung.tistory.com/148
[Android] MVI 패턴이란?
MVI 패턴이란 Android MVI 패턴은 Model-View-Intent의 약자로, 안드로이드 앱 개발에서 사용되는 아키텍처 패턴 중 하나입니다. MVI 패턴은 기존의 MVP, MVVM 등 다른 패턴과는 다르게 데이터 흐름이 단방향
superohinsung.tistory.com
이전에 한번 위와 같은 글을 작성한 적이 있다.
하지만 워낙 오래되기도 하였고, 이후에 MVI와 Orbit을 접하는 일이 크게 없었다.
그래서 이번 기회에 프로젝트를 앞두고 Orbit을 정리해보자.

1. Orbit 개요
- Orbit: Kotlin MPP(Multiplatform Project)용 타입 안전(Type-safe) MVI 프레임워크
- 핵심 아이디어: Redux/MVI의 단방향 데이터 흐름(Unidirectional Data Flow) + MVVM
- 주요 장점
- 멀티플랫폼 지원: Android, iOS, 데스크탑에서 동일한 비즈니스 로직 재사용
- 라이프사이클 안전한 Flow: 무한 Flow 수집 시 메모리 누수 방지
- ViewModel & SavedState: MPP 환경에서도 UI 상태 보존 가능
- Compose Multiplatform: 공통 선언형 UI 구축
- 테스트 & 도구: state/event 흐름 검증 유틸, Espresso idling 리소스 지원
2. Gradle 의존성
// 상태 관리 및 단방향 데이터 흐름 (멀티플랫폼)
implementation("org.orbit-mvi:orbit-core:10.0.0")
// Android/iOS/데스크탑에서 Lifecycle-aware ViewModel 지원
implementation("org.orbit-mvi:orbit-viewmodel:10.0.0")
// Jetpack Compose 및 Compose Multiplatform 지원
implementation("org.orbit-mvi:orbit-compose:10.0.0")
// State/SideEffect 흐름 테스트 유틸
testImplementation("org.orbit-mvi:orbit-test:10.0.0")
3. 계약 정의 (Contract)
- State
- 앱이 가지는 모든 화면 상태(데이터)를 불변(immutable) 데이터 클래스 형태로 정의
data class CalculatorState( val total: Int = 0 ) - SideEffect
- 화면 전환, 토스트, 알림 등 일회성 이벤트를 sealed class 로 정의
sealed class CalculatorSideEffect { data class Toast(val text: String) : CalculatorSideEffect() }
Tip: State·SideEffect 모두 비교 가능(comparable)해야 하며, data/sealed class를 추천합니다.
4. ViewModel 생성
- ContainerHost 인터페이스 구현
- container<State, SideEffect>(initialState) 팩토리로 Orbit Container 생성
- intent { … } 블록 안에서 postSideEffect(...) 및 reduce { } 호출
- ViewModel이 아닌 일반 클래스에서도 사용 가능
- UI 독립 컴포넌트나 완전한 MPP 환경에서도 동일 패턴 적용
class CalculatorViewModel
: ViewModel(),
ContainerHost<CalculatorState, CalculatorSideEffect> {
// ① 초기 상태 전달
override val container =
container<CalculatorState, CalculatorSideEffect>(CalculatorState())
// ② 비즈니스 로직(intent)
fun add(number: Int) = intent {
// 부수 효과 발행
postSideEffect(CalculatorSideEffect.Toast(
"Adding $number to ${state.total}!"
))
// 상태 변경
reduce {
state.copy(total = state.total + number)
}
}
}
5. Activity/Fragment와 연결
5.1 orbit-viewmodel 확장 함수 이용
class CalculatorActivity: AppCompatActivity() {
private val viewModel by viewModel<CalculatorViewModel>()
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
// 버튼 클릭 시 동작
addButton.setOnClickListener { viewModel.add(1234) }
// 라이프사이클 STARTED 상태에서만 자동 구독
viewModel.observe(
state = ::render,
sideEffect = ::handleSideEffect
)
}
private fun render(state: CalculatorState) {
// UI 업데이트
}
private fun handleSideEffect(effect: CalculatorSideEffect) {
when (effect) {
is CalculatorSideEffect.Toast ->
Toast.makeText(this, effect.text, Toast.LENGTH_SHORT).show()
}
}
}
5.2 직접 Flow 수집
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
launch {
viewModel.container.refCountStateFlow.collect { render(it) }
}
launch {
viewModel.container.refCountSideEffectFlow.collect {
handleSideEffect(it)
}
}
}
}
6. orbit-core 모듈 소개
- 의존성 추가
- implementation("org.orbit-mvi:orbit-core:10.0.0")
- 이 모듈은 Orbit MVI 시스템의 기본이 되는 모든 기능을 제공합니다.
- Container 생성, 상태(State)·사이드 이펙트(Effect) 스트림, 비즈니스 로직 실행 DSL 등이 포함되어 있습니다.
7. Orbit Container
7.1 Container 개념
- Container는 Orbit MVI의 핵심으로,
- 애플리케이션의 상태를 보관(state retention)
- 상태 변경(state updates)과 사이드 이펙트(side effects)를 스트림으로 내보냄
- orbit { … } DSL 내부에서 상태를 갱신하거나(리듀스), 부수 효과를 발생시킴
7.2 구독(subscribing)
- stateFlow
- 현재 상태를 방출하는 StateFlow
- Conflated: 가장 최신 상태만 유지
- sideEffectFlow
- 사이드 이펙트를 방출하는 SharedFlow
- 기본적으로 옵저버가 없을 때도 캐싱하여, 재구독 시 놓친 이벤트를 받을 수 있음
data class ExampleState(val seen: List<String> = emptyList())
sealed class ExampleSideEffect { data class Toast(val text: String): ExampleSideEffect() }
class ExampleContainerHost(scope: CoroutineScope)
: ContainerHost<ExampleState, ExampleSideEffect> {
override val container =
scope.container<ExampleState, ExampleSideEffect>(ExampleState())
fun doSomethingUseful() = intent {
// 비즈니스 로직
}
}
// 구독 예시
scope.launch { viewModel.container.stateFlow.collect { state -> /* UI 갱신 */ } }
scope.launch { viewModel.container.sideEffectFlow.collect { effect -> /* 부수 효과 처리 */ } }
8. ContainerHost
- ContainerHost<S, E> 인터페이스를 구현하면 Orbit DSL(intent {} 등)을 편리하게 사용할 수 있습니다.
- Android에서는 보통 ViewModel을 상속받아 구현합니다.
class ExampleViewModel(
savedStateHandle: SavedStateHandle
) : ViewModel(), ContainerHost<ExampleState, ExampleSideEffect> {
override val container =
container<ExampleState, ExampleSideEffect>(ExampleState(), savedStateHandle)
// ...
}
9. 핵심 연산자(Core Operators)
MVI 개념 Orbit DSL 설명
| 블록(block) | intent { … } | 비즈니스 로직 실행 블록. 내부에서 다른 연산자 호출 가능. |
| 변환(transformation) | suspend 함수 호출 | API 호출, 데이터 매핑 등 상태 변화 전 작업 수행. |
| 사이드 이펙트 | postSideEffect(...) | 내비게이션, 토스트, 로깅 등 일회성 UI 효과를 발생시킴. |
| 축소(reduction) | reduce { } | 현재 상태와 이벤트를 조합해 새 상태를 원자적으로 생성. |
| 구독제어 | repeatOnSubscription {} | UI가 구독 중일 때만 흐름을 수집하도록 제어. 비용이 큰 Flow에 유용. |
| 서브 인텐트 | subIntent { } | 큰 intent를 작은 단위로 분리하거나 병렬 실행. |
| 상태별 실행 | runOn(StateType::class) | 상태가 특정 타입일 때만 블록 실행, 상태 변경 시 자동 취소. |
9.1 Transformation (변환)
- intent { … } 내부에서 일반적인 suspend 함수 호출로 처리
- 예: 네트워크 API를 호출하거나 Flow를 맵핑하는 로직
fun loadData() = intent {
val data = repository.fetchData()
reduce { state.copy(items = data) }
}
9.2 Reduction (축소)
- reduce { } 블록 내부에서 state를 기반으로 새 상태를 반환
- **불변성(immutability)**을 유지하며 상태를 갱신
fun simpleExample(number: Int) = intent {
val result = apiCall()
reduce { state.copy(results = result) }
}
9.3 Side Effect (사이드 이펙트)
- 상태 변경 없이 일회성 UI 효과나 이벤트를 발생
- 옵저버가 없어도 캐시되어, 구독이 생기면 다시 전달
fun showToast() = intent {
val message = repository.getMessage()
postSideEffect(ExampleSideEffect.Toast("메시지: $message"))
}
9.4 repeatOnSubscription
- UI가 stateFlow 또는 sideEffectFlow를 구독 중일 때만 블록을 실행/취소
- 배터리·자원 소모가 큰 Flow 수집에 적합
fun observeLocation() = intent {
repeatOnSubscription {
locationFlow.collect { location ->
reduce { state.copy(location = location) }
}
}
}
9.5 subIntent
- 큰 intent를 여러 개의 작은 작업(subIntent)으로 나누거나, 병렬 처리할 때 사용
- subIntent 내부에서도 reduce, postSideEffect 등을 호출할 수 있음
suspend fun loadPartA() = subIntent { /* ... */ }
suspend fun loadPartB() = subIntent { /* ... */ }
fun loadAll() = intent {
coroutineScope {
launch { loadPartA() }
launch { loadPartB() }
}
}
9.6 runOn
- sealed class 상태를 다룰 때, 특정 상태일 경우에만 블록 실행
- 상태가 바뀌면 블록이 자동으로 취소
fun processReady() = intent {
runOn(ExampleState.Ready::class) { readyState ->
// readyState 사용
}
}
10. Operator Context
- intent, reduce, postSideEffect 등 각 연산자 블록 내부에서는 state 프로퍼티로 현재 상태를 참조 가능
- reduce 블록 내의 state는 캡처 시점의 상태가 보장됨
fun logState() = intent {
println("현재 상태: $state")
}
11. Container 팩토리
- container(initialState) { onCreateIntent } 형태로 생성 시 자동 실행할 intent를 지정할 수 있음
- 보통 ViewModel 초기화 시 바로 Flow 수집 작업 등을 등록
override val container = container<ExampleState, ExampleSideEffect>(
ExampleState()
) {
// 생성 시 자동 실행
reduce { /* 초기 상태 변경 */ }
coroutineScope {
launch { observeFlow1() }
launch { observeFlow2() }
}
}
12. 스레딩 모델(Threading)
- intent { } 호출 자체는 호출 스레드를 차단하지 않고, 백그라운드 코루틴에서 실행됨
- 장시간 작업은 적절히 withContext(Dispatchers.IO) 등으로 컨텍스트 전환 권장
- Orbit 내부는 이벤트 루프(event-loop) 형태로 동작
13. 에러 처리(Error Handling)
- Orbit은 기본적으로 예외를 처리하지 않으므로, 각 intent 안에서 try/catch로 핸들링 권장
- 전역 예외 처리기를 설정하고 싶으면 Settings Builder를 통해 exceptionHandler 등록 가능
- 예외 발생 시에도 컨테이너가 정상 동작을 유지하도록 할 수 있음
14. 참고
Orbit Multiplatform | Orbit
Logo
orbit-mvi.org
728x90
'Android > Study' 카테고리의 다른 글
| [Android] Orbit으로 MVI 구현기 (0) | 2025.08.19 |
|---|---|
| [Android] Orbit Multiplatform ViewModel, ComposeUI, Test 탐구하기 (0) | 2025.07.05 |
| [Android] Android 앱 아키텍처 가이드 정리 (2) | 2025.07.03 |
| [Jetpack Compose로 개발하는 안드로이드 UI] 4장. UI 요소 배치 (0) | 2025.05.13 |
| [Jetpack Compose로 개발하는 안드로이드 UI] 3장. 컴포즈 핵심 원칙 자세히 알아보기 (0) | 2025.05.07 |