본문 바로가기
Android/아키텍쳐

MVI는 왜 Compose 환경에서 피해야 할까?

by 안스 인민군 2025. 3. 25.

최근 안드로이드 개발에서는 Jetpack Compose를 기반으로 한 선언형 UI가 급속히 보급되고 있고, 동시에 MVI(Model-View-Intent) 아키텍처도 많이 회자되고 있습니다. 하지만 MVI는 Compose 환경에서는 오히려 안티패턴에 가까울 수 있다는 점에서 제가 이해 한 것으로 작성하게 되었습니다.


MVI라는 이름 자체의 어색함

MVI는 원래 Elm이라는 함수형 언어의 아키텍처에서 유래했습니다. 이를 React 진영에서 Redux라는 상태 관리 라이브러리를 통해 가져왔고, 여기서 increment/decrement 예시가 유명해졌습니다.

이 패턴이 이후 Jetpack Compose의 예시 코드에도 그대로 전파되면서, 많은 사람들이 이를 "정석"처럼 받아들이게 되었죠. 하지만 실제로는 "MV*" 패턴에 끼워맞춘 네이밍이며, Kotlin 및 Compose의 특성과는 맞지 않는 면이 많습니다.


Counter 앱의 increment/decrement 예시의 허상

sealed class CounterIntent {
    object Increment : CounterIntent()
    object Decrement : CounterIntent()
}

data class CounterState(val count: Int = 0)

fun reduce(state: CounterState, intent: CounterIntent): CounterState {
    return when (intent) {
        is CounterIntent.Increment -> state.copy(count = state.count + 1)
        is CounterIntent.Decrement -> state.copy(count = state.count - 1)
    }
}

이런 예시는 구조도 단순하고, UI 상태도 명확히 구분되어 있어 아주 예쁘게 보입니다. 하지만 실제 앱에서는 상태가 훨씬 복잡해지고, 그에 따라 Intent, Reducer, ViewState가 서로 얽히게 됩니다.


실제 앱에서 MVI가 망가지는 예시

요구사항: 프로필 편집 화면

  • 로그인 후 사용자 정보 불러오기
  • 프로필 사진 업로드
  • 닉네임 중복 검사
  • 저장 중 로딩 표시
  • 저장 성공/실패 처리

ViewState 예시 (MVI)

data class ProfileViewState(
    val isLoading: Boolean = false,
    val userProfile: UserProfile? = null,
    val isNicknameValid: Boolean = true,
    val errorMessage: String? = null,
    val isSaveSuccess: Boolean = false
)

문제점

  • ViewState에 UI 상태 + 비즈니스 상태가 혼재
  • .copy().copy().copy() 지옥
  • 비동기 처리 (로딩, 오류 등)를 ViewState에 과도하게 위임
  • Reducer는 점점 거대해지고 유지보수 어려움
더보기

* ".copy().copy().copy() 지옥"이란?

이 표현은 깊게 중첩된 불변 데이터 구조에서 상태를 바꾸기 위해 .copy()를 계속 반복 호출해야 하는 상황을 풍자하는 말입니다.

예를 들어 다음과 같은 구조가 있다고 해보죠:

data class Address(val city: String)
data class UserProfile(val name: String, val address: Address)
data class AppState(val user: UserProfile)

사용자의 도시(city)만 바꾸고 싶어도 이렇게 됩니다.

val newState = oldState.copy(
    user = oldState.user.copy(
        address = oldState.user.address.copy(
            city = "Seoul"
        )
    )
)

 

👉 단순한 값 하나를 바꾸기 위해 세 번의 .copy()를 중첩 호출해야 하며, 이런 식의 상태 갱신은 가독성과 유지보수 모두에 좋지 않습니다.

반면, Compose에서는 상태를 mutableStateOf StateFlow 등으로 관리하면 이런 중첩 copy 없이도 상태 변경과 UI 반응이 가능합니다.

class UserProfile {
    var city by mutableStateOf("Busan")
}

val user = remember { UserProfile() }

Button(onClick = { user.city = "Seoul" }) {
    Text("Update City")
}

이처럼 더 단순하고 선언적인 방식이 존재하는 환경에서 굳이 .copy() 지옥을 반복하는 것은 불필요한 ceremony일 뿐입니다.


Compose + Kotlin에서는 더 자연스러운 방식이 있다

Compose는 상태를 관찰 가능한 객체(mutableStateOf, StateFlow, SnapshotStateList)로 관리할 수 있고, 이는 불변성이 없어도 변화 감지가 가능합니다.

class ProfileViewModel : ViewModel() {
    var isLoading by mutableStateOf(false)
    var userProfile by mutableStateOf(UserProfile())
    var errorMessage by mutableStateOf<String?>(null)

    fun loadProfile() { /* 생략 */ }
    fun saveProfile() { /* 생략 */ }
}

이런 구조에서는 ViewModel이 명확한 책임을 갖고, 상태도 목적별로 잘게 나뉘며 유지보수가 쉬워집니다.


클린 아키텍처와 MVI의 충돌

클린 아키텍처에서는 각 계층(Layer)마다 서로 다른 책임을 갖는 모델을 사용해야 합니다.

레이어 역할 데이터 예시
UI Layer 화면 출력용 UserUiModel
Domain Layer 비즈니스 로직 처리 User
Data Layer DB/Network 처리용 UserDto, UserEntity

각 계층마다 서로 다른 모델을 사용하는 이유는 책임 분리유지보수의 유연성 때문입니다. 화면을 바꾸기 위해 도메인이나 DB 관련 코드까지 수정하지 않아도 되도록 만드는 거죠.

하지만 MVI에서는 ViewState 하나에 모든 상태를 우겨넣고, 이 ViewState가 다음과 같은 곳에서 동시에 사용되기 쉽습니다.

  • 화면의 상태 관리
  • Reducer의 기준 상태
  • Repository의 파라미터
  • 서버 응답 혹은 DB 저장용 데이터 구조

결국, ViewState가 UI 전용임에도 앱 전체를 지배하는 상태 모델로 전락하게 되고, 이는 클린 아키텍처의 핵심인 계층 간 분리 원칙을 깨뜨리는 구조가 됩니다.

Kotlin언어와 맞지 않음

TypeScript는 구조적 타이핑(structural typing)을 사용하여 속성만 같으면 타입이 달라도 할당이 가능합니다.

type A = { name: string }
type B = { name: string }

const a: A = { name: "Alice" }
const b: B = a // ✅ OK! 구조가 같으므로 타입도 호환됨

하지만 Kotlin은 명시적 타입 기반(nominal typing) 언어이기 때문에, 이름이 다르면 속성이 같아도 다른 타입으로 간주합니다.

data class A(val name: String)
data class B(val name: String)

val a = A("Alice")
val b: B = a // ❌ 컴파일 에러: 타입 불일치

즉, Kotlin에서는 계층마다 타입을 명확히 나눠서 구조화하는 것이 자연스러우며, 클린 아키텍처에서도 UserUiModel, UserDomainModel, UserDto 등 각 레이어별로 분리된 타입을 사용하는 것이 일반적입니다.

해서 MVI 의 ViewState 를 하나로 엮는 구조는 Kotlin의 타입 시스템 특성과 충돌 하게 됩니다,

결론: MVI는 Kotlin + Compose에 적합하지 않다

  • MVI는 학습 예제로서 구조를 설명하기엔 좋지만, 실제 앱 개발에는 맞지 않는다
  • Kotlin + Compose 환경에서는 불변성을 강제하지 않아도 안전하게 상태를 추적 가능하다
  • 복잡한 앱 구조에서는 MVI가 오히려 레이어 분리와 유지보수를 어렵게 만든다
  • SwiftUI도 비슷한 이유로 클래스 기반 상태 관리를 택하고 있다

👉 결국, MVI는 자바스크립트 생태계에서 기원한 뗀석기 같은 구조일 뿐이며, Compose 환경에서는 더 자연스럽고 선언적인 방식이 존재한다는 걸 기억하자