MVI — 모바일 앱에서 Model-View-Intent 패턴 이해하기

저자: IT Sectr 게시일: 2026-02-16 읽는 시간: 10 분

MVI (Model-View-Intent)는 단방향 데이터 흐름과 불변 상태를 기반으로 하는 반응형 아키텍처 패턴입니다. ViewModel이 여러 StateFlows를 가질 수 있는 MVVM과 달리, MVI는 단일 상태(State), 불변 의도(Intent), 순수 리듀서 함수(Reducer)를 정의합니다. MVI는 언제든지 화면 상태의 예측 가능성을 보장합니다. 이 패턴은 Mosby 및 Orbit 라이브러리에 의해 Android 커뮤니티에서 대중화되었습니다. 자세한 내용은 Arkadii Ivanov의 MVIKotlin에서 확인하세요.

주요 내용

  • MVI — Model(상태), View(표시), Intent(사용자 의도) — 반응형 사이클
  • Unidirectional data flow — 데이터는 한 방향으로 흐름: Intent → Reducer → State → View
  • Immutable State — 화면 상태는 불변 객체이며, 변경될 때마다 다시 생성됨
  • Reducer — 현재 상태와 Intent를 받아 새 상태를 반환하는 순수 함수
  • Side effects — 부작용(네트워크, DB)은 Reducer와 분리되어 Middleware를 통해 처리됨

MVI란 무엇인가: Model-View-Intent 패턴의 본질

MVI (Model-View-Intent)는 Redux와 Cycle.js의 원칙에 기반하여 구축된 반응형 아키텍처 패턴입니다. Model은 불변의 화면 상태, Intent는 사용자 또는 시스템의 의도, View는 상태를 구독하고 Intents를 전송합니다. 데이터는 사이클로 흐릅니다: 사용자가 View와 상호작용 → View가 Intent 생성 → Intent가 Reducer에 의해 처리 → Reducer가 새 상태 생성 → View가 새 상태를 받아 다시 렌더링.

MVI와 MVVM의 주요 차이점은 단일 진실 공급원(Single Source of Truth)입니다. MVVM에서 ViewModel은 여러 LiveData/StateFlow(userState, loadingState, errorState)를 가질 수 있어 loading=true와 user=null이 동시에 발생하는 불일치를 초래할 수 있습니다. MVI에서는 화면 전체 상태를 설명하는 sealed class/interface State가 정확히 하나 존재합니다. 언제든지 화면 상태는 고유하게 결정됩니다 — 데이터가 이미 로드된 상태에서 loading=true가 되는 것은 불가능합니다. IT Sectr에서는 상태 예측 가능성이 중요한 복잡한 로직이 있는 화면 — 주문 양식, 다단계 등록, 금융 화면 — 에 MVI를 적용합니다.

구성 요소MVI에서의 역할예시
Intent사용자 또는 시스템 의도LoadUser, Refresh, SubmitForm
State불변 화면 상태sealed class UserState
Reducer순수 함수: State + Intent → Statefun reduce(state, intent) -> state
Middleware부작용 처리네트워크 요청, DB 쓰기

MVI 사이클은 다섯 단계로 구성됩니다: 1) View가 Intent 전송(예: LoadUser(42)); 2) Middleware(EffectHandler)가 부작용 수행 — 네트워크 요청; 3) 결과가 새 Intent로 시스템에 반환; 4) Reducer가 현재 상태와 Intent를 받아 새 상태 생성; 5) View가 새 상태를 받아 다시 렌더링. 각 단계는 예측 가능하며 격리되어 테스트 가능합니다.

Android의 MVI: Kotlin의 Intent, Reducer, State

Android의 MVI는 Intent와 State에 sealed 클래스, MVI 로직이 있는 ViewModel, 그리고 반응형 렌더링을 위한 Jetpack Compose를 사용하여 구현됩니다. ViewModel은 View로부터 Intent를 받고, 부작용을 Middleware에 위임하며, Reducer를 실행하고, StateFlow를 통해 새 상태를 게시합니다. Jetpack Compose는 상태가 변경될 때 UI를 다시 렌더링 — MVI 사이클에 이상적입니다.

kotlin
// Intent — 사용자 의도
sealed interface UserIntent {
    data class LoadUser(val userId: Int) : UserIntent
    data object Refresh : UserIntent
}

// State — 단일 화면 상태
sealed interface UserState {
    data object Idle : UserState
    data object Loading : UserState
    data class Success(val user: User) : UserState
    data class Error(val message: String) : UserState
}

// Reducer — 순수 함수
object UserReducer {
    fun reduce(state: UserState, intent: UserIntent): UserState = when (intent) {
        is UserIntent.LoadUser -> UserState.Loading
        is UserIntent.Refresh -> UserState.Loading
    }
}

// MVI를 사용한 ViewModel
class UserViewModel(
    private val repository: UserRepository
) : ViewModel() {

    private val _state = MutableStateFlow<UserState>(UserState.Idle)
    val state: StateFlow<UserState> = _state.asStateFlow()

    fun process(intent: UserIntent) {
        val newState = UserReducer.reduce(_state.value, intent)
        _state.value = newState
        when (intent) {
            is UserIntent.LoadUser -> loadUser(intent.userId)
            is UserIntent.Refresh -> loadUser(/* 이전 ID */)
        }
    }

    private fun loadUser(userId: Int) {
        viewModelScope.launch {
            repository.getUser(userId)
                .onSuccess { user ->
                    _state.value = UserState.Success(user)
                }
                .onFailure { e ->
                    _state.value = UserState.Error(e.message ?: "Error")
                }
        }
    }
}

// View가 Intent 전송
fun UserScreen(viewModel: UserViewModel) {
    val state by viewModel.state.collectAsState()
    LaunchedEffect(Unit) { viewModel.process(UserIntent.LoadUser(42)) }
    when (state) {
        is UserState.Loading -> CircularProgressIndicator()
        is UserState.Success -> UserCard((state as UserState.Success).user)
        is UserState.Error -> ErrorView((state as UserState.Error).message)
        is UserState.Idle -> Text("Press to load")
    }
}

Middleware와 부작용 — MVI에서 순수 Reducer는 네트워크 요청을 수행할 수 없습니다. Middleware(EffectHandler 또는 Bootstrapper라고도 함)는 Intent를 처리하고, 부작용을 수행하며, 새 Intent를 사이클로 다시 내보냅니다. Orbit MVI 및 MVIKotlin 라이브러리는 테스트 가능한 효과와 함께 내장 Middleware 지원을 제공합니다. Middleware가 없으면 MVI는 추가 Intent 및 State 구조를 가진 MVVM으로 퇴화합니다.

Arkadii Ivanov의 MVIKotlin은 Kotlin Multiplatform을 위한 가장 인기 있는 MVI 라이브러리입니다. Android, iOS, 웹, JVM을 지원합니다. Store(ViewModel), Bootstrapper(초기 효과), Reducer, Middleware 구성 요소를 제공합니다. 2025년 10월 기준, 이 라이브러리는 GitHub에서 2.5K 스타를 모았으며 주요 러시아 은행의 애플리케이션을 포함한 상용 프로젝트에서 사용됩니다. IT Sectr에서는 공유 비즈니스 로직을 가진 크로스 플랫폼 KMP 프로젝트에 MVIKotlin을 사용합니다.

iOS의 MVI: Swift의 단방향 흐름

iOS의 MVI는 Combine-ViewModel 없이 Intent → State 사이클을 통해 구현됩니다. View는 클로저를 통해 Intent를 전송하고, Reducer는 순수 함수이며, State는 불변 필드를 가진 구조체입니다. State가 변경되면 SwiftUI가 View를 다시 렌더링하여 추가 @Published 속성 없이 MVI 사이클에 완벽하게 맞습니다. iOS의 MVI는 Redux(JavaScript)에서 전환한 SwiftUI 개발자들 사이에서 특히 인기가 있습니다.

swift
// State — 불변 구조
struct UserState: Equatable {
    var user: User?
    var isLoading = false
    var errorMessage: String?
}

// Intent — 의도가 있는 enum
enum UserIntent {
    case loadUser(id: Int)
    case refresh
    case userLoaded(User)
    case loadFailed(Error)
}

// Reducer — 순수 함수
func userReducer(state: UserState, intent: UserIntent) -> UserState {
    var newState = state
    switch intent {
    case .loadUser, .refresh:
        newState.isLoading = true
        newState.errorMessage = nil
    case .userLoaded(let user):
        newState.isLoading = false
        newState.user = user
    case .loadFailed(let error):
        newState.isLoading = false
        newState.errorMessage = error.localizedDescription
    }
    return newState
}

// Store — 상태 소유 및 효과 관리
final class UserStore: ObservableObject {
    @Published private(set) var state = UserState()
    private let service: UserService

    init(service: UserService) {
        self.service = service
    }

    func dispatch(_ intent: UserIntent) {
        // 1. Reducer가 상태 업데이트
        state = userReducer(state: state, intent: intent)
        // 2. Side effects (필요한 경우)
        switch intent {
        case .loadUser(let id), .refresh:
            service.fetchUser(id: id) { [weak self] result in
                switch result {
                case .success(let user):
                    self?.dispatch(.userLoaded(user))
                case .failure(let error):
                    self?.dispatch(.loadFailed(error))
                }
            }
        default: break
        }
    }
}

TCA (The Composable Architecture)는 Point-Free의 iOS용 가장 인기 있는 MVI 구현체로, SwiftUI와 Combine 위에 구축되었습니다. TCA는 Store, Reducer, Effect, Environment를 제공합니다. 2025년 10월 기준, GitHub 스타가 13K를 초과합니다 — iOS에서 MVI의 사실상 표준입니다. TCA는 Starbucks 앱, Airbnb(부분적으로) 및 많은 인디 프로젝트에서 사용됩니다. 사용자 정의 MVI와 달리 TCA는 테스트, 탐색, 부작용을 즉시 해결합니다.

iOS에서 MVI vs MVVM — TCA/MVI는 상태 예측 가능성을 제공하지만 더 많은 보일러플레이트 코드(Reducer, State, Action)가 필요합니다. @Published를 사용한 MVVM은 기본 화면에서 더 간단합니다. IT Sectr에서는 80%의 화면에 MVVM을, 20%의 복잡한 화면 — 금융 거래, 다단계 양식, 드래그 앤 드롭 인터페이스 — 에 MVI(TCA)를 사용합니다. 상태 오류가 사용자에게 금전적 손실을 초래할 수 있는 경우입니다.

MVI vs MVVM: MVI를 선택해야 하는 경우

MVI와 MVVM은 동일한 문제를 해결합니다 — 프레젠테이션 계층 구성 — 하지만 상태 관리에 대한 접근 방식이 다릅니다. MVVM은 여러 반응형 소스(LiveData, @Published)를 허용하여 불일치를 초래할 수 있습니다. MVI는 언제든지 정확히 하나의 상태를 보장하여 더 엄격하고 예측 가능하지만 코드 양이 증가합니다.

기준MVVMMVI
상태여러 LiveData/StateFlow단일 sealed class State
데이터 흐름양방향(View → ViewModel, LiveData → View)단방향(Intent → Reducer → State → View)
부작용ViewModel에서 직접Middleware/EffectHandler 통해
테스트ViewModel 단위 테스트Reducer + Middleware 단위 테스트
보일러플레이트 코드최소Reducer + State + Intent + Middleware

MVI를 선택해야 하는 경우 — 상태가 엄격하게 결정론적이어야 하는 화면: 금융 작업, 쇼핑 카트, 각 단계에서 검증이 있는 다단계 양식. 이러한 시나리오에서는 상태 오류의 비용(예: 두 LiveData 간의 경합 조건으로 인해 하나의 항목이 누락된 장바구니 총액 표시)이 추가 코드 비용보다 큽니다. MVVM에서는 팀 규율에 의존하지만 MVI에서는 아키텍처에 의존합니다.

MVVM으로 충분한 경우 — 표준 화면의 80%: 사용자 목록, 프로필, 설정, 뉴스 피드. 여기서 단일 상태는 과도하며 추가 MVI 구조는 개발 속도를 늦춥니다. IT Sectr의 규칙: 화면에 전환(로딩 → 데이터 → 오류 → 재시도 → 로딩 → 데이터)이 있는 3개 이상의 가능한 상태가 있는 경우 — MVI 사용. 화면에 1-2개의 비동기 작업이 있는 경우 — MVVM 사용.

MVI 모범 사례 및 일반적인 실수

Sealed State — MVI 모범 사례. 상태는 Loading, Success(data), Error(message)의 변형을 가진 sealed class/interface로 정의됩니다. 이렇게 하면 View가 불일치 상태에 빠지지 않도록 보장합니다 — Loading과 Success는 다른 클래스이므로 loading=true일 때 데이터를 표시할 수 없습니다. 상태와 관련된 모든 데이터는 sealed 변형 내에 있습니다: Success는 사용자를 포함하고, Error는 오류 메시지를 포함합니다.

Reducer는 순수 함수로 유지되어야 합니다 — API, DB 또는 SharedPreferences 호출 없이. 순수 함수는 State와 Intent를 받아 State를 반환합니다. 부작용(네트워크, DB, 탐색, 토스트)은 Middleware에서 또는 Reducer 호출 후 Store.dispatch에서 처리됩니다. Reducer가 부작용으로 오염되면 MVI는 테스트 가능성과 예측 가능성을 잃습니다 — 이점 없이 추가 구조만 있는 MVVM이 됩니다.

일반적인 실수 — sealed class 대신 nullable 필드가 있는 data class로 State 선언: data class UserState(val user: User?, val isLoading: Boolean, val error: String?). 이것은 MVI가 아닌 MVVM과 동등합니다 — View는 유효성을 위해 필드 조합을 확인해야 합니다. sealed 접근 방식에서는 잘못된 조합(isLoading=true 및 user!=null)이 유형 수준에서 불가능합니다. 두 번째 실수는 비즈니스 로직을 Middleware에 배치하는 대신 Intent(Intent.LoadUserBeforeXHours)에 비즈니스 로직을 배치하는 것입니다.

자주 묻는 질문

MVI와 MVVM의 주요 차이점은 무엇인가요?

MVI는 단일 불변 sealed State 클래스와 Reducer를 통한 단방향 데이터 흐름을 사용합니다. MVVM은 양방향 바인딩을 가진 여러 LiveData/StateFlow를 허용합니다. MVI는 유형 수준에서 상태 일관성을 보장합니다 — loading=true와 user=null이 동시에 발생하는 것은 불가능합니다. MVVM은 개발자 규율에 의존합니다.

Android용 MVI 라이브러리에는 무엇이 있나요?

주요 라이브러리: MVIKotlin(Arkadii Ivanov, 2.5K 스타, Kotlin Multiplatform), Orbit MVI(BabyJ, 1.3K 스타), Mobius(Spotify, Kotlin/Java). MVIKotlin은 Kotlin에서 가장 인기 있고, Orbit은 배우기 가장 쉽습니다. 세 가지 모두 테스트 가능한 Reducer와 Middleware를 지원합니다. Jetpack Compose의 경우 sealed State + Reducer를 사용하여 라이브러리 없이 간단한 MVI를 작성할 수 있습니다.

MVI에 별도 라이브러리가 필요한가요?

아니요 — sealed Intent + sealed State + ViewModel + StateFlow로 종속성 없이 작동하는 MVI를 얻을 수 있습니다. 라이브러리(MVIKotlin, Orbit, TCA)는 Middleware, 부작용 테스트, DI 통합을 추가합니다. 간단한 프로젝트에서는 라이브러리의 무게가 정당화되지 않습니다. 20개 이상의 화면이 있는 복잡한 프로젝트에서는 구조화된 효과 처리를 통해 라이브러리가 충분한 가치를 제공합니다.

MVI는 iOS에 적합한가요, 아니면 Android 전용 패턴인가요?

MVI는 TCA(The Composable Architecture) — SwiftUI 커뮤니티에서 가장 인기 있는 아키텍처 — 를 통해 iOS에서 훌륭하게 작동합니다. TCA는 기본적으로 MVI + Redux + Combine입니다. iOS에서는 ObservableObject와 순수 리듀서 함수를 사용하여 TCA 없이 MVI를 구현할 수 있습니다. 불변 State를 가진 SwiftUI는 MVI 사이클에 완벽하게 맞습니다.

MVI를 테스트하는 방법은?

Reducer는 순수 함수로 단위 테스트됩니다: 초기 State 설정, Intent 전송, 결과 State 확인. Middleware는 모크 저장소로 테스트됩니다: LoadUser 후 getUser가 호출되었는지 확인. ViewModel 테스트: Intent 전송, StateFlow 확인. MVI는 Reducer가 숨겨진 종속성 없는 순수 함수이므로 MVVM보다 테스트하기 쉽습니다.

요약

  • MVI (Model-View-Intent) — 단방향 흐름과 단일 상태를 가진 반응형 패턴
  • Sealed State — 유형 수준에서 일관성을 보장, 잘못된 조합 제거
  • Reducer — 순수 함수 State + Intent → State, 모크 객체 없이 테스트 가능
  • Middleware — 부작용(네트워크, DB, 탐색)을 위한 별도 계층
  • MVI vs MVVM — MVI는 더 엄격하고 예측 가능, MVVM은 더 간단하고 빠름
  • Android — 복잡한 화면에는 MVIKotlin 또는 Orbit; 단순한 화면에는 MVVM
  • iOS — TCA(The Composable Architecture)가 SwiftUI의 MVI 표준

턴키 방식의 모바일 애플리케이션을 개발해 드립니다

IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.

프로젝트 논의

더 읽어보기