본문 바로가기

Android/Tech

나의 MVI 적용기

Unsplash, Anders Jilden.

Architecture 에 대한 끝없는 고민

아키텍처는 프로그램 개발에서 굉장히 중요한 요소들 중 하나입니다. 어쩌면 가장 중요하다고 얘기할 수도 있을 것 같습니다. 제가 처음 접한 프로덕트에는 이렇다 할 아키텍처가 없었습니다. (물론 지금도 해당 프로덕트에는 아무런 아키텍처도 적용되어 있지 않습니다.)

 

같은 파트 동료분께 처음 해당 프로덕트에 대한 인계 및 설명을 받았을 때, MVC 아키텍처가 적용되어 있다는 이야기를 들었으나, 전혀 그렇지 않았습니다. MVC 아키텍처가 가장 기본적인 안드로이드 아키텍처이므로, 별도의 아키텍처(MVP, MVVM, MVI 등)가 적용되지 않으면 MVC 아키텍처인 것처럼 알려져 있는데, 이는 잘못된 지식입니다. 구덩이 파고 흙 쌓아 지붕 만든다고 다 집이 아닙니다. MVC 역시 Model, View, Controller 를 철저히 분리하여 관리하는 것이 핵심입니다.

 

어쨌든, 프로덕트의 코드는 뒤죽박죽이었고, 아무 것도 모르는 초짜의 입장에서 해당 코드를 이해하기란 그렇지 않은 코드에 비해 수 배로 어려웠습니다. 이를 이해하기 위해 아키텍처에 대한 공부를 시작했습니다. 처음 접한 것은 MVP 였습니다.

 

처음 MVP 를 개인 프로젝트에 적용했을 때 정말 깜짝 놀랐던 기억이 있습니다. MainActivity 내에 Retrofit 을 위한 Interface 를 구현하고, 또 그 안에서 통신을 수행하고, override 한 onResponse() 블럭 내에서 textView.setText(response.body()) 하는 엉망의 코드를 벗어나, 서로 다른 디렉토리에 나뉜 클래스와 데이터를 Contract 로 묶어주는 일은 정말 놀라움의 연속이었습니다.

 

이후, 제가 가장 애정하는 개인 프로젝트인 <에이펙싱> 에 100% Kotlin 과 MVVM 을 적용해 보기로 합니다. 그때 즈음에 어느 블로그에서 봤던 내용인데, MVVM 의 핵심은 그 무엇도 아닌 Databinding 과 DI 라는 말이 뇌리에 박혀 떠나지 않았습니다. 그래서 '내친김에 Databinding 과 Hilt 를 활용한 DI 까지 사용해 보자!'라는 생각도 했습니다.

 


나의 MVVM + Repository Pattern

깃허브에서 스타를 많이 받은 레포들을 찾아다니며, 어떻게 MVVM 코드를 작성해야 하는지 둘러봤습니다. 제게 적합한 레포를 찾는 것은 무척이나 어려웠는데, 그 이유는 다음과 같습니다.

 

- 고수준의 레포는 이해하기 어렵다.

- 저수준의 레포를 학습하는 것은 위험하다.

 

정말 빈틈없는 프로젝트의 경우, 제 수준에서는 도무지 파악할 수 없는 코드들로 점철된 경우가 많았고, 빈틈이 많은 프로젝트는 아키텍처를 적용했다고 말하기 민망한 경우가 많았습니다. 그래서, 그냥 직접 적용해 보기로 결정했습니다.

 

Apexing 패키지

 

Hilt 를 활용한 DI 를 적용하였고, MVVM 을 패키지로 잘 분리하여 구성하였습니다.

당연히 부족한 점이 많겠지만 그래도 나름 잘 적용하고 현재도 잘 작동하고는 있습니다. 다만, 별도의 모듈화는 진행하지 않았고, UI 역시 xml + Databinding 입니다. 시간이 나면 클린 아키텍처도 적용하고, 모듈화도 진행하고, UI 도 Jetpack Compose 로 마이그레이션하고 싶은데, 어째 시간이 안 나네요.

 

어쨌든 이렇게, 제 첫 MVVM 은 그리 나쁘지 않은 성적을 거두게 됐습니다. (스스로 판단하기에)

 


MVI,  그리고 Clean Architecture

이후 몇 개의 프로젝트를 수시로 진행했으나, 코드 베이스에 큰 변화는 없었습니다. 디테일에 신경을 썼으며, 비즈니스 로직을 UI 및 데이터 모델과 잘 분리하는 것에 집중했기 때문입니다. 그러던 도중, DND 8기에 참여하게 되었고, DND 프로젝트에서 MVI 와 Clean Architecture 에 도전하게 되었습니다.

 

사실 MVI 는 제 계획에 없었던 아키텍처입니다. 현재도 그러하지만, 그 때 그 시점에서 가장 메이저 한 아키텍처는 MVVM 이고, MVI 는 정립되지 않았다는 느낌을 받았기 때문입니다.

 

그럼에도 불구하고 MVI 를 도입했던 이유

DND 프로젝트 회의에서 MVI 얘기가 나왔던 것도 이유가 되겠지만, DND 프로젝트 이전에 진행했던 프로젝트에서 처음으로 Jetpack Compose 를 도입하면서 상태 관리에 대한 고민이 매우 컸기 때문입니다. 결론부터 말하자면, MVI 는 Jetpack Compose 와 환상적인 궁합을 자랑합니다. 이유는 다음과 같습니다.

 

- Jetpack Compose 의 코드 특성상 코드의 Hierarchy Depth 가 깊고, 그 깊고 긴 코드들에 클릭 이벤트를 포함한 UI 이벤트가 코드 전체에 산개해 있게 됩니다. 물론 고차 함수를 활용하여 콜 사이트로 이벤트를 끌어 올릴 순 있겠지만, 한계가 있으며 코드가 지저분해집니다. 

- UI 를 통해 유저에게 보여 줄 데이터 역시 UI 이벤트와 같이 코드에 산개해 있을 수 있습니다.

 

즉, 상태와 이벤트를 별도로 관리하지 않고 UI 코드에서 이를 처리하는 경우 가독성이 떨어지고 이는 곧 유지보수의 용이성 저하를 불러오기 쉽습니다. 저도 실제로 굉장히 많이 겪은 문제점이었고, 이를 해결하기 위해 MVI 적용을 단행했습니다.

 

이와 동시에 Three-Layer-Clean Architecture 를 통한 모듈화까지 진행하게 되었습니다. 클린 아키텍처는 원래부터 적용해보고 싶었는데, 이번 기회에 적용해볼 수 있어서 좋았습니다. 더욱 좋았던 것은 함께 해주신 팀원분께서 클린 아키텍처에 관해 잘 아시는 분이었던 점입니다.

 

아키텍처와 스택에 관한 이야기를 충분히 나누고 개발에 착수했습니다. MVI 를 처음 적용해보다 보니, State 와 Ui Event 에 대한 개념을 스스로 정립하지 못한 채 개발했던 기억이 납니다. 클린 아키텍처도 처음이라 다소 낯설었습니다.

 


그래서 지금은?

다행히도, 모듈화나 각종 아키텍처에 대한 연습과 사전 지식이 조금 있었어서 어느 정도 적응하는 데에는 그렇게 긴 시간이 필요하지는 않았습니다.

 

Icon(
    modifier = Modifier.clickableWithoutRipple { viewModel.onClickSkipToPrev() },
    painter = painterResource(id = R.drawable.ic_skip_prev),
    contentDescription = "skipToPrevIcon",
    tint = Color.LightGray
)

Icon(
    modifier = Modifier.clickableWithoutRipple { viewModel.onClickPlayOrPause() },
    painter = painterResource(id = if (isPlaying) R.drawable.ic_pause else R.drawable.ic_play),
    contentDescription = "playOrPauseIcon",
    tint = MainColor
)

Icon(
    modifier = Modifier.clickableWithoutRipple { viewModel.onClickSkipToNext() },
    painter = painterResource(id = R.drawable.ic_skip_next),
    contentDescription = "skipToNextIcon",
    tint = Color.LightGray
)

Icon(
    modifier = Modifier.clickableWithoutRipple { viewModel.onClickRepeatOne() },
    painter = painterResource(id = R.drawable.ic_repeat_one),
    contentDescription = "repeatIcon",
    tint = if (isRepeatingOne) MainColor else Color.LightGray
)

 

모든 Ui Event (클릭 이벤트 등) 는 ViewModel 에 작성한 함수를 Composable 자체에서 호출하도록 구현하였습니다. Ui Event 를 정의한 sealed class 는 다음과 같이 정의되어 있습니다.

 

sealed class MainEvent {
    object SkipToPrev : MainEvent()
    object PlayOrPause : MainEvent()
    object SkipToNext : MainEvent()
    object RepeatOne : MainEvent()
    object TrackCadence : MainEvent()
    object AssignCadence : MainEvent()
    object ShowSnackBar : MainEvent()
    object HideSnackBar : MainEvent()
    object StartRunning : MainEvent()
    object StopRunning : MainEvent()
    data class SetAssignedCadence(val cadence: Int) : MainEvent()
    data class SetMeasuredCadence(val cadence: Int) : MainEvent()
}

 

Ui Event 를 통해 넘어 온 Event 값에 따라 State 의 값을 갱신해줘야 하고, State 값을 이어주는 함수가 필요했습니다.

 

private val eventChannel = Channel<MainEvent>()
private val _sideEffectChannel = Channel<MainSideEffect>()
val sideEffectChannelFlow = _sideEffectChannel.receiveAsFlow()

val state: StateFlow<MainState> = eventChannel.receiveAsFlow()
    .runningFold(MainState(), ::reduceState)
    .stateIn(viewModelScope, SharingStarted.Eagerly, MainState())

private fun reduceState(state: MainState, event: MainEvent): MainState {
    return when (event) {
        is MainEvent.SkipToPrev -> {
            state.copy(isLoading = true)
        }
        is MainEvent.PlayOrPause -> {
            state.copy(isPlaying = !state.isPlaying)
        }
        is MainEvent.SkipToNext -> {
            state.copy()
        }
        is MainEvent.RepeatOne -> {
            state.copy(isRepeatingOne = !state.isRepeatingOne)
        }
        is MainEvent.TrackCadence -> {
            state.copy(cadenceType = TRACKING)
        }
        is MainEvent.AssignCadence -> {
            state.copy(cadenceType = ASSIGN)
        }
        is MainEvent.ShowSnackBar -> {
            state.copy(isSnackBarVisible = true)
        }
        is MainEvent.HideSnackBar -> {
            state.copy(isSnackBarVisible = false)
        }
        is MainEvent.StartRunning -> {
            state.copy(isRunning = true)
        }
        is MainEvent.StopRunning -> {
            state.copy(isRunning = false)
        }
        is MainEvent.SetAssignedCadence -> {
            state.copy(assignedCadence = event.cadence)
        }
        is MainEvent.SetMeasuredCadence -> {
            state.copy(measuredCadence = event.cadence)
        }
    }
}

 

state 는 eventChannel  에 대해 각 원소마다 reduceState() 메서드를 호출하여 MainState() 를 갱신합니다. state 는 stateIn() 메서드를 통해 StateFlow 로 변환되므로, Composable 내에서 collectAsStateWithLifecycle() 를 통해 원소를 수집해주면 됩니다. state 값에 대한 변화가 있으니 자연스럽게 Recomposition 됩니다.

 

이로써 ViewModel 은 UI 에 필요한 데이터를 전달해주고, UI Event 를 처리하는 기능만 수행합니다.

 

제대로 된 MVI 구현을 위해서는 어떻게 구현하는 것이 좋을까에 대해 깊게 고민하고, 또 많이 살펴 보았습니다. 그중 가장 도움이 되었던 것은 찰스님의 블로그였습니다. (https://www.charlezz.com/?p=46365)

 

 

Android 프로젝트에 MVI 도입하기 | 찰스의 안드로이드

MVI 도입배경 프로젝트에 Jetpack Compose를 도입하고 1년정도 적극 쓰면서 '상태' 관리의 중요성을 머리가 아닌 몸으로 느껴버렸다. 상태 관리를 어떻게 하면 좋을까 고민하던 중 동료 개발자가 이전

www.charlezz.com


후기

MVP, MVVM 을 처음 앱에 적용했을 때와 같이, 이번에도 아키텍처의 효험을 경험할 수 있었습니다. 깔끔하게 분리된 앱 패키지가 주는 정갈함은 프로젝트를 열 때마다 좋은 기분을 느끼게 합니다. 

 

요새는 MVW (Model-View-Whatever) 라는 아키텍처도 등장하였다고 하니, 이에 대해 학습해보는 것도 좋지 않을까 싶습니다.