본문 바로가기

Android Architecture

MVVM에서 MVI로

 

오늘은 최근 제가 가장 관심 있는 MVI에 대해 이해해 나가는 첫 번째 여정이 될 글을 작성하려 합니다. 제가 MVI를 공부하게 된 계기는 특정 강의에서 MVI에 관한 강의를 듣고 나서 코드의 가독성과 흐름을 파악하기 굉장히 좋다고 느꼈기 때문입니다. 이전에 수행했던 다온길 프로젝트에서 제가 가장 아쉬운 점은 ViewModel에서의 상태관리 코드에 대한 흐름을 파악하기가 너무 복잡했다고 느꼈습니다. 그래서 MVI를 적용하면 이런 문제를 해결할 수 있지 않을까?라는 가정하에 MVI를 공부하기 시작했습니다. 해당글에서 계속해서 언급되는 프로젝트의 코드는 여기서 확인하실 수 있습니다.

 

GitHub - chanho0908/DaOnGil_CleanArchitecture: 다온길 프로젝트 클린 아키텍처(Hilt) ver 🧼

다온길 프로젝트 클린 아키텍처(Hilt) ver 🧼. Contribute to chanho0908/DaOnGil_CleanArchitecture development by creating an account on GitHub.

github.com

글의 순서는 이전에 진행했던 프로젝트의 경험을 바탕으로 MVVM에 대해 돌아보겠습니다. MVI가 무엇인지는 이전글을 참고 부탁드립니다. 그리고 현재 진행 중인 우아한 테크코스의 2주 차 과제를 부족하지만 MVI를 적용하여 수행해 보았는데요, 이를 소개하는 시간을 가져보겠습니다. 그리고 마지막으로 안드로이드에 MVI를 보다 손쉽게 적용할 수 있도록 도와주는 라이브러리인 Orbit에 대해 알아보겠습니다.

1. MVVM 돌아보기

 제가 생각하는 MVVM의 핵심은 View와 비즈니스 로직의 분리입니다. View는 사용자와 상호작용하는 UI의 역할을 담당하며, 예를 들어 버튼을 터치해 이미지를 가져오려는 경우 View는 '사진을 가져온다'라는 이벤트를 ViewModel에 전달합니다.


ViewModel은 이 요청을 비즈니스 로직에 따라 처리하고, 필요한 데이터를 Model에 요청합니다. Model은 데이터 소스(예: API, 데이터베이스)에서 데이터를 가져와 ViewModel에 전달하고, ViewModel은 필요에 따라 이 데이터를 가공하여 View에 전달하며 View는 이를 화면에 렌더링 하여 사용자에게 보여줍니다.

 

다만 주의할 점은 안드로이드의 AAC ViewModel과는 다소 다릅니다. 예전 동아리 면접 때 받았던 질문 중 MVVM ViewModel과 AAC ViewModel의 차이에 대한 질문을 받았던 기억이 나네요. 단순하게 생각했을 때 MVVM의 ViewModel은 아키텍처적인 요소로 코드를 어떻게 나눌 것인지, 어떤 역할을 주도록 약속할 것 인지 등 그 자체로 추상적인 개념입니다. 하지만 AAC ViewModel은 안드로이드 플랫폼에서 라이브러리를 통해 사용하는 구체화된 존재입니다. 앞서 설명한 MVVM에서의 ViewModel의 역할을 하지 않아도, Context를 주입해도(물론 안되지만) ViewModel을 상속해서 구현했다면 그 자체로 그냥 ViewModel이죠

비대한 ViewModel

프로젝트를 진행하면서 ViewModel이 아키텍처로서의 ViewModel과 View에 필요한 데이터 홀더로서의 역할을 함께 수행하다 보니 자연스럽게 비대해지는 문제가 발생했습니다. 이를 해결하기 위해 다음과 같은 방법을 사용했습니다.

  • Repository Pattern과 UseCase: DataSource를 추상화하여 ViewModel이 Model에 직접 API 호출을 요청하는 책임을 분리합니다.
  • Delegate Pattern: API 요청 과정에서 발생하는 에러들을 처리하는 임무를 위임합니다.
  • Non-Anemic Domain Model: 도메인 모델이 스스로의 책임을 가지도록 설계합니다.

Anemic Domain Model에 대한 내용은 해당 영상을 보시면 세상에서 제일 잘 설명해 주십니다.

https://www.youtube.com/watch?v=3mR8_vT7m1U

 

Anemic Domain Model은 마틴 파울러가 2003년 발표한 이론으로 제가 진행한 프로젝트 기준으로 생각했을 때 도메인 모델이 도메인에서 데이터 레이어 방향으로 생기는 의존성을 제거, DTO 매핑 등 다른 객체들과 풍부한 릴레이션을 갖고 있는 것처럼 보이지만 가장 중요한 행위가 없다고 합니다.

 

실제로 제 프로젝트의 경우 지역에 대한 코드와 이름을 가진 도메인 모델이 있었고 ViewModel은 이 지역에 대한 정보를 List로 가진 리스트를 가지고 있었습니다. 이 리스트에서 특정한 지역의 이름이나 지역 코드를 출하는 로직이 ViewModel에 위치했고 이는 ViewModel을 비대하게 만드는 원인 중에 하나였습니다. 그래서 저는 이런 로직을 도메인 모델 스스로의 책임으로 가지도록 설계했습니다.

 

그럼에도 불구하고 여전히 제 프로젝트의 ViewModel엔 문제가 하나 있었습니다. 바로 UI에 렌더링 될 상태들을 관리하는 코드가 너무나 난잡해 가독성이 가히 최악이었습니다. 얼마나 엉망인진 여기서 확인하실 수 있습니다. 물론 아키텍처적인 문제가 아닌 제 실력에 관한 문제이기도 하지만 MVI를 사용하면 이런 문제를 개선할 수 있지 않을까라는 호기심에 MVI를 공부하게 되었습니다.

 

DaOnGil_CleanArchitecture/DaOnGil/presentation/src/main/java/kr/techit/lion/presentation/main/vm/search/SearchListViewModel.kt a

다온길 프로젝트 클린 아키텍처(Hilt) ver 🧼. Contribute to chanho0908/DaOnGil_CleanArchitecture development by creating an account on GitHub.

github.com

2. 우테코 과제를 MVI로 구현해 보자

https://github.com/chanho0908/kotlin-racingcar-7

 

GitHub - chanho0908/kotlin-racingcar-7

Contribute to chanho0908/kotlin-racingcar-7 development by creating an account on GitHub.

github.com

미션은 간단한 자동차 경주 게임을 구현하는 것입니다. 사용자의 액션에 의해 발생할 이벤트인 "자동차 이름 입력""시도 횟수 입력" 두 가지를 상태 관리 클래스로 정의해 주었습니다.

sealed class RacingViewEvent {
    data class InputPlayerName(val userNames: String) : RacingViewEvent()
    data class InputPlayCount(val playCount: String) : RacingViewEvent()
}

자동차 경주 게임을 진행하면서 변경될 상태들은 다음과 같이 정의해 주었습니다.

  • PlayerState : 게임을 진행할 플레이어들 각각의 상태를 나타냅니다. 자신의 이름과 이름과 스테이지 별로 게임을 진행한 위치를 가지도록 하며 매 스테이지가 끝날 때 마자 플레이어 자신의 게임 결과를 출력해야 하기 때문에 해당 책임은 자기 자신이 갖도록 설계했습니다.
sealed class CarRacingState {

    // 플레이어 한명 한명의 게임 진행 상태
    data class PlayerState(val playerName: String, var position: Int) : CarRacingState() {
        fun printExecutionByPhase() {
            println("$playerName : ${"-".repeat(position)}")
        }
    }

    // 게임 결과 상태
    data class PlayResultState(val result: List<PlayerState>) : CarRacingState() {
        fun printWinner() {
            val maxScore = result.maxOf { it.position }
            val winners = result
                .filter { it.position == maxScore }
                .map { it.playerName }
            print("$MESSAGE_LAST_WINNER ${winners.joinToString(", ")}")
        }
    }
}
  • GameState : ViewModel에서는 게임을 진행할 플레이어와 게임 진행 횟수 등 전반적인 게임의 상태를 관리하도록 설계했습니다.
  • MoveState : 해당 자동차 게임에선 랜덤 숫자를 추출한 후 이 값이 4 이상일 때 만 이동이 가능하기 때문에 이동 가능 여부 상태를 관리하기 위해 만들었습니다. 
// 게임을 진행할 플레이어와 게임 진행 횟수 상태
data class GameState(
    val players: MutableList<PlayerState>,
    var playCount: Int
)

// 이동 가능 여부 상태
sealed class MoveState {
    data class Movable(val playCount: Int) : MoveState()
    data object Immovable : MoveState()
}

그다음으론 StateReducer를 정의해 주었습니다. State Reducer(상태 변환기)란 기존의 상태와 이벤트에 의해 만들어진 새로운 상태를 만드는 것입니다. 

interface Reducer<Mutation, State> {
    operator fun invoke(mutation: Mutation, state: State): State
}

class PlayerStateReducer : Reducer<Int, PlayerState> {
    override fun invoke(mutation: Int, state: PlayerState): PlayerState {
        return when (getMoveState(mutation)) {
            is MoveState.Movable -> state.copy(position = state.position + 1)
            is MoveState.Immovable -> state
        }
    }

    private fun getMoveState(moveCount: Int): MoveState {
        return if (moveCount >= MIN_MOVE_COUNT) {
            MoveState.Movable(moveCount)
        } else {
            MoveState.Immovable
        }
    }
}

 

게임에서 이동 여부를 결정하는 랜덤 한 값을 만들기 위한 함수를 functional interface를 통해 선언해 주었습니다. 하나의 추상 메서드를 가지는 Interface를 SAM(Single Abstract Method) Interface라고 합니다. 이런 SAM Interface를 Kotlin에선 functional Interface를 통해 람다식으로 간결하게 표현할 수 있습니다.

// Java
interface MoveCountFactory {
    fun create(): Int
}

// Kotlin
fun interface MoveCountFactory: () -> Int

private fun injectMoveCountFactory() = MoveCountFactory {
    Randoms.pickNumberInRange(MIN_RANDOM_NUMBER, MAX_RANDOM_NUMBER)
}

 

최종적으로 자동차 경주 게임을 수행하는 Class에서 State Reducer와 funtional Interface의 구현체를 주입받아 자동차 경주 게임을 수행합니다.

3. Orbit 알아보기

마지막으로 Orbit에 대해 알아보겠습니다. 이번 챕터에 들어가기 앞서 미리 양해의 말씀을 구합니다. 필자는 안드로이드를 공부한지 이제 막 1년이 돼 가는 주니어로써 아직 Orbit에 대한 이해가 많이 부족합니다. 때문에 내용이 다소 중구난방이거나 부정확한 정보가 있을 수 도 있습니다. 그래도 나름 열심히 라이브러리를 그냥 사용하는 것이 아닌 정확한 원리와 내부 구조를 이해하고 사용하기 위해 정리해 보았으니 이 점 감안해서 봐주시면 감사하겠습니다.

 

MVI의 단점으로 꼽는 것이 높은 러닝 커브와 다수의 보일러 플레이트 코드인데요, 이러한 장벽을 낮출 수 있게 도와주는 것이 Orbit입니다. Orbit은 3가지의 주요 DSL(intentreducepostSideEffect)을 활용하여 보일러 플레이트 코드 없이 상태와 사이드 이펙트를 관리할 수 있는 간결한 API를 제공합니다. 

 

Orbit은 기본적으로 stateSideEffect를 관리하는 Container라는 개념을 도입하여 애플리케이션의 UI 상태를 선언적으로 표현해 각 상태는 불변(immutable) 객체로 정의되고 상태가 변경될 때마다 새로운 상태 인스턴스가 생성됩니다.

 

간단한 예시 코드를 살펴보면 다음과 같습니다. ContainerHost는 인터페이스로 container<State, SideEffect>(…) 팩토리 함수를 활용해 state와 SideEffect를 한 곳에서 관리할 수 있습니다. initialState는 초기값을 세팅하며 buildSettings는 Orbit 라이브러리에서 Container를 설정할 때 사용하는 구성 블록으로 Container의 동작 방식을 조정할 수 있는 다양한 설정을 제공합니다. 

intentviewModelScope.launch를 열고 코루틴을 사용하는 것 처럼 intent 람다식을 사용해 코루틴이 필요한 작업을 수행할 수 있습니다. 저도 여기서 한 가지 의문이 들었습니다. 과연 이 intent가 viewModelScope를 대체할 수 있을 만큼 안전함이 보장되었는가? 이에 대해선 밑에서 더 자세히 살펴보겠습니다. 

 

postSideEffect 함수를 사용하면 ContainerHost에 정의했던 SideEffect Class Type의 SideEffect를 전달할 수 있습니다. 마지막으로 앞서 살펴보았던 stateReducer의 역할을 Orbit에선 reduce 함수로 정의되었어 이 블럭 내에서 기존 상태와 이벤트에 의해 만들어진 상태가 합쳐 저 새로운 상태를 만들어냅니다.

Container 파해치기

 

Container 코드의 출처입니다. Container는 인터페이스로 구현되어 있으며 한 가지 재미있는 부분이 있습니다. 바로 이 Container가 MVI 시스템의 심장이라고 합니다. 

The heart of the Orbit MVI system. 

 

이 컨테이너는 MVI 패턴에서 입력과 출력을 처리하는 역할을 합니다. 컨테이너는 orbit 함수를 통해 조작할 수 있습니다.

  • STATE: 컨테이너의 상태 타입을 나타냅니다.
  • SIDE_EFFECT: 이 컨테이너가 발생시키는 부수 효과의 타입을 나타냅니다. 부수 효과를 발생시키지 않는다면 이 타입은 Nothing 일 수 있습니다.

즉, 이 컨테이너는 상태(State)를 관리하며 필요에 따라 부수 효과(Side Effect)를 발생시킬 수 있는 구조입니다. 그리고 orbit 함수는 이를 조작하는 주요 함수라고 합니다. Container는 ContainerHost 인터페이스의 프로퍼티로 선언되어있습니다.

/**
 * 이 인터페이스는 orbit 컨테이너의 호스트가 되길 원하는 객체에 적용할 수 있습니다.
 * 일반적으로 이것은 Android의 ViewModel이 될 수 있지만, 간단한 프리젠터(presenter) 등에도 적용할 수 있습니다.
 *
 * 확장 함수인 `intent`와 `orbit`은 컨테이너에서 orbit 인텐트를 실행하는 편리한 방법을 제공합니다.
 */
public interface ContainerHost<STATE : Any, SIDE_EFFECT : Any> {
    /**
     * orbit [Container] 인스턴스.
     * 팩토리 함수를 사용하여 쉽게 [Container] 인스턴스를 얻을 수 있습니다.
     * ```
     * override val container = scope.container<MyState, MySideEffect>(initialState)
     * ```
     */
    public val container: Container<STATE, SIDE_EFFECT>
}

 

그리고 ContainerHost는 Container를 사용하는 호스트 인터페이스로 ViewModel이 이를 구현하여 상태를 관리합니다.

 

지금까지의 내용을 정리해 보겠습니다.

  • Container는 Orbit의 심장으로 상태(State)와 SideEffect를 관리하는 핵심 역할을 합니다.
  • 이를 위한 핵심 함수는 orbit과 intent입니다.
  • ContainerHost는 Container를 사용하는 호스트 인터페이스입니다.
  • ViewModel은 ContainerHost를 구현해 상태 관리를 위한 컨테이너가 됩니다.

2. intent & orbit 파해치기

 

intent의 기본 구조입니다. ContainerHost의 확장 함수로 정의되어 있습니다. 해당 코드는 여기서 확인 하실 수 있습니다. 여기서 한 가지 궁금한 점이 생겼습니다. 왜 ViewModelScope가 아닌 runBlocking일까? 이 내용에 대해선 Orbit-Mvi GitHub Discussion에서 언급되었던 주제입니다.

 

이 내용부턴 해당 Discussion의 내용을 바탕으로 개인적으로 해석한 내용입니다.

 

1. runBlocking 코루틴은 intent 함수를 suspend로 만들지 않기 위해 사용된 것으로 추측됩니다. 

2. container는 ViewModel의 확장 함수로 선언되어 viewModelScope을 사용하는 Container를 반환합니다.(ViewModelScope를 사용하는 것을 확인했음)

fun <STATE : Any, SIDE_EFFECT : Any> ViewModel.container(
    initialState: STATE,
    settings: Container.Settings = Container.Settings(),
    onCreate: ((state: STATE) -> Unit)? = null
): Container<STATE, SIDE_EFFECT> {
    return viewModelScope.container(initialState, settings, onCreate)
}

 

3. intent 함수에서 runBlocking 코루틴을 사용한 이유는 container 함수가 viewModelScope을 사용하기  때문입니다.

4. orbit 함수는 Container Interface에 선언되어 intent를 실행하는 suspend 함수 이므로 코루틴 내에서 수행되어야 합니다.

 

그렇다면 여기서 한 가지 의문점이 생깁니다. runBlocking은 호출한 스레드를 Blocking 하며  intent는 별도의 코루틴 디스패처를 지정해 주거나 스레드를 만들지 않습니다. 그렇다면 intent 함수는 메인 스레드를 Blocking 한다는 말인데 이로 인해 메인 스레드가 하는 작업에 대한 페널티는 없을까요?

 

runBlocking은 intent 함수 호출 시 메인 스레드를 잠깐 멈추지만 실제로는 내부에서 container.orbit {... }로 넘어가서 작업이 다른 디스패처로 "오프로드"됩니다.

runBlocking은 코루틴 내에서 일시적으로 메인 스레드를 멈추지만 이는 매우 짧고 빠르게 끝나는 작업으로 container.orbit로 비동기 작업을 보내는 것이 주요 목적입니다.
/**
 * orbit intent를 실행합니다. 이 intent는 [ContainerHost] 내에서 선택한 구문을 사용하여 빌드됩니다.
 *
 * @param orbitIntent lambda가 suspend 함수로서 intent를 나타냅니다.
 */
public suspend fun orbit(orbitIntent: suspend ContainerContext<STATE, SIDE_EFFECT>.() -> Unit)

override suspend fun orbit(orbitIntent: suspend ContainerContext<STATE, SIDE_EFFECT>.() -> Unit) {
   initialiseIfNeeded()
   dispatchChannel.send(orbitIntent)
}

📕 정리

"오프로드" : 비동기 작업이나 프로세스를 다른 스레드나 프로세스로 넘기는 것
  1. runBlocking을 사용한 이유
    runBlocking은 코루틴 내에서 일시적으로 메인 스레드를 멈추지만, 이는 매우 짧고 빠르게 끝나는 작업으로 container.orbit로 비동기 작업을 보내는 것이 주요 목적입니다.
  2. 왜 viewModelScope.launch가 아닌가?
    viewModelScope.launch는 메인 스레드에서 코루틴을 실행합니다. intent 함수에서 runBlocking을 사용한 이유는 일단 메인 스레드를 잠시 멈추더라도 빠르게 다음 작업을 비동기적으로 오프로드하기 때문입니다. 따라서 runBlocking을 사용해도 성능적으로 문제가 되지 않을 것입니다.

🤔 아직 풀리지 않은 의문점

  • 꼭 runBlocking 이여만 했을까요? suspend 함수로 만들 수 도 있었고 viewModelScpope안에서 동작하는 코루틴이라면 CoroutineScope로도 만들 수 있지 않았을까요?
  • Intent 함수는 ViewModelScope.launch(Dispatcher.IO)처럼 별도의 디스패처를 지정하지 않기 때문에 메인 스레드에서만 동작한다고 추측할 수 있습니다. 만약 IO Dispatcher를 사용해야 하는 상황이라면 어떻게 해야 할까요?
  • 영문으로 구글링해도 Orbit에 대해 찾을 수 있는 정보가 많지 않은데 안정적인 API가 맞을까요?