0. Orbit의 도입 배경
이전 프로젝트 경험에서 얻은 교훈을 바탕으로, 이번 싸피 공통 프로젝트에서는 Orbit 라이브러리를 도입하기로 결정했다.
Orbit에 대해서는 한번 정리한 경험이 있다.
https://superohinsung.tistory.com/428
[Android] Orbit Multiplatform 개요와 Core 탐구하기
https://superohinsung.tistory.com/148 [Android] MVI 패턴이란?MVI 패턴이란 Android MVI 패턴은 Model-View-Intent의 약자로, 안드로이드 앱 개발에서 사용되는 아키텍처 패턴 중 하나입니다. MVI 패턴은 기존의 MVP, MVVM
superohinsung.tistory.com
https://superohinsung.tistory.com/429
[Android] Orbit Multiplatform ViewModel, ComposeUI, Test 탐구하기
https://superohinsung.tistory.com/428 [Android] Orbit Multiplatform 개요와 Core 탐구하기https://superohinsung.tistory.com/148 [Android] MVI 패턴이란?MVI 패턴이란 Android MVI 패턴은 Model-View-Intent의 약자로, 안드로이드 앱 개
superohinsung.tistory.com
Orbit은 MVI 패턴을 보다 쉽고 안전하게 구현할 수 있도록 도와주는 라이브러리로, 다음과 같은 장점들을 제공한다.
- 간단함: Mutable/Immutable, 코루틴 스타일, 타입 세이프(type-safe), 확장 가능한 DSL.
- 테스트 용이: 각 블록의 함수형 설계로 유닛, 통합 테스트가 쉽고 유연.
- 생명주기 안전: CoroutineScope 기반으로 UI 생명주기와 안전하게 동작.
- SavedState 지원: Android SavedStateHandle과 연동해 복잡한 UI도 안전하게 복구.
- 사이드 이펙트 관리: UI에서 분리된 사이드 이펙트 흐름으로 실질적 상태 변화와 이벤트 분리.
내부 코드는 Kotlin DSL, 코루틴, 타입 세이프티를 적극적으로 활용한다.
1. Orbit 사용기
이번에 프로젝트를 진행하며 프로필 화면을 맡은바 있는데, 그것을 예시로 한번 작성해보자.
ProfileViewModel
Orbit의 최상위 구조는 ContainerHost<STATE, SIDE_EFFECT> 인터페이스다.
ViewModel 등에서 다음처럼 Container를 선언한다.
class ProfileViewModel: ViewModel(), ContainerHost<ProfileUiState, ProfileSideEffect> {
override val container = container<ProfileUiState, ProfileSideEffect>(ProfileUiState())
// intent 패턴 예시
fun loadProfileData() = intent {
// 상태 갱신(불변)
reduce { state.copy(isRefreshing = true, error = false) }
coroutineScope {
try {
val user = async { runCatching { getUserByIdUseCase() } }.await()
// ... 병렬 API fetch 생략
if (user.isSuccess /* && ... */) {
reduce { state.copy(userName = user.getOrThrow().nickname, isRefreshing = false) }
} else {
reduce { state.copy(error = true, isRefreshing = false) }
}
} catch (_: Exception) {
reduce { state.copy(error = true, isRefreshing = false) }
postSideEffect(ProfileSideEffect.ShowError(R.string.error_load_profile_failed))
}
}
}
}
내부 코드(Orbit 핵심 DSL)
Orbit 내부에는 아래와 같이 상태, intent, subIntent, reduce, sideEffect, repeatOnSubscription 등이 고수준 DSL로 구현되어 있다.
public interface ContainerHost<STATE : Any, SIDE_EFFECT : Any> {
public val container: Container<STATE, SIDE_EFFECT>
@OrbitDsl
public fun intent(transformer: suspend Syntax<STATE, SIDE_EFFECT>.() -> Unit): Job =
container.intent { Syntax(this).transformer() }
// 병렬 하위 intent 처리 가능 (subIntent)
@OrbitExperimental
public suspend fun subIntent(transformer: suspend Syntax<STATE, SIDE_EFFECT>.() -> Unit): Unit =
container.inlineOrbit { Syntax(this).transformer() }
}
Syntax DSL에서의 State/SideEffect 갱신
public class Syntax<S : Any, SE : Any>(val containerContext: ContainerContext<S, SE>) {
public val state: S get() = containerContext.state
@OrbitDsl
public suspend fun reduce(reducer: IntentContext<S>.() -> S) {
containerContext.reduce { IntentContext(it).reducer() }
}
@OrbitDsl
public suspend fun postSideEffect(sideEffect: SE) {
containerContext.postSideEffect(sideEffect)
}
@OrbitDsl
public suspend fun repeatOnSubscription(block: suspend CoroutineScope.() -> Unit) {
coroutineScope {
launch {
containerContext.subscribedCounter.subscribed.mapLatest {
if (it.isSubscribed) block() else null
}.collect()
}
}
}
@OrbitExperimental
@OrbitDsl
public suspend inline fun <reified T : S> runOn(
crossinline predicate: (T) -> Boolean = { true },
crossinline block: suspend SubStateSyntax<S, SE, T>.() -> Unit
) {
containerContext.stateFlow
.runOn<S, T>(predicate) { SubStateSyntax(containerContext.toSubclassContainerContext(predicate, it)).block() }
}
}
즉, 모든 Intent(비동기 명령)들은 Syntax DSL 안에서
- 상태 변화(reduce),
- SideEffect(토스트, 네비 등 이벤트 전달),
- repeatOnSubscription(구독자 기반 생명주기 관리),
- runOn(타입 기반 조건 실행)dm로 안전하게 분리·관리된다.
Profile ComposeUI
@Composable
fun ProfileRoute(
viewModel: ProfileViewModel = hiltViewModel(),
) {
val state by viewModel.collectAsState()
val context = LocalContext.current
viewModel.collectSideEffect { sideEffect ->
when (sideEffect) {
is ProfileSideEffect.ShowError -> Toast.makeText(context, context.getString(sideEffect.messageRes), Toast.LENGTH_SHORT).show()
}
}
ProfileScreen(
state = state,
onLogOutClick = viewModel::onLogOutClick,
onLogoutConfirm = viewModel::onLogoutConfirm,
onLogoutCancel = viewModel::onLogoutCancel,
// ... 생략
)
}
- collectAsState: Container의 상태를 Compose에 연결
- collectSideEffect: SideEffect를 1회성 UI 이벤트로 안전하게 처리
- ProfileScreen 등에서 인텐트별 콜백을 직접 연결해 UI 액션 및 상태 전이를 명확히 관리
2. Orbit 느낀점
상태(불변)/이벤트(1회성)/비동기(코루틴)/구독(생명주기)
- 복잡한 API(fetch), 다이얼로그 전이, 에러 복구, 로그아웃/탈퇴 등 중요한 사용자 플로우에서
- “reduce”로 순수 함수적 상태 관리 → 버그, 레이스 컨디션(동시성) 최소화
- “postSideEffect”로 네비게이션, Toast, Alert 등 SideEffect를 명확히 분리
- “subIntent”와 “repeatOnSubscription”으로 화면 재생성·구독 해제 등 Compose/LiveData까리 안심 사용
- “runOn”으로 sealed class 기반 UI 타입별 로직 분기·자동 취소 처리
테스트
Orbit MVI는 모든 intent가 함수형 구조이기 때문에,
ViewModel의 intent 호출만으로 상태와 SideEffect를 쉽게 검증할 수 있어 테스트 작성이 압도적으로 용이함.
프로젝트 도입 시 느낀 점
- 복잡한 UI도 코드 생산성과 관리가 매우 좋아짐
- 코드 일관성, 협업·테스트 편의, 생명주기(구독/해제) 제어가 깔끔
- 코드베이스가 커질수록 Orbit MVI의 장점이 더 두드러짐
'Android > Study' 카테고리의 다른 글
| [Android] `UiState–Intent–SideEffect` MVI 구조를 자체 설계·적용해보자. (0) | 2025.08.24 |
|---|---|
| [Android] Compose Navigation은 어떻게 하면 좋을까? (0) | 2025.08.19 |
| [Android] Orbit Multiplatform ViewModel, ComposeUI, Test 탐구하기 (0) | 2025.07.05 |
| [Android] Orbit Multiplatform 개요와 Core 탐구하기 (0) | 2025.07.03 |
| [Android] Android 앱 아키텍처 가이드 정리 (2) | 2025.07.03 |