Jetpack Compose의 버전이 올라가면서 필수 기술스택이 되어가고 있습니다.
그러면서 Compose를 활용하기 좋은 아키텍처에 대한 관심도도 자연스럽게 증가하고 있으며,
그러한 아키텍처로 MVI가 자리잡고 있는 듯 합니다.
저 또한 프로젝트에서 Compose를 최대한 활용하려 하며,
Compose를 사용하지 않더라도 Compose와의 마이그레이션을 고려하여 MVI를 주요 아키텍처로 사용하고 있습니다.
이러한 덕분에 MVI 아키텍처를 위한 다양한 라이브러리들에 대한 관심도 매우 높아지고 있는데,
주로 언급되는 라이브러리는 orbit-mvi, mavericks 정도가 있고, 최근 주목받기 시작한 circuit이 있습니다.
Circuit?
Circuit은 Slack에서 만든 프레임워크로, 실제 슬랙의 프로덕션에서 사용중이라고 합니다.
또한 Circuit은 Kotlin과 Compose를 빌드하기 위해 만들어진 프레임워크로 소개되고 있습니다.
자세한 소개는 아래 링크를 참고해보면 좋겠습니다.
https://slackhq.github.io/circuit/
Github 저장소에서는 "A Compose-driven architecture for Kotlin and Android applications." 로 소개하고 있으며,
UDF, MVI와 같은 언급으로 볼 때, Compose와 궁합이 아주 좋겠다는 기대를 하게 합니다.
Concept of Circuit
다음은 Citcuit 공식 사이트에서 볼 수 있는 예시 다이어그램입니다.
MVP에서 볼 수 있었던 Presenter라는 컴포넌트가 나타났습니다.
지금까지 ViewModel을 기본적으로 사용하던 아키텍처와는 사뭇 다른 모습입니다.
다이어그램을 볼 때, Circuit은 Screen, Presenter, UI 세 가지로 구성되어 있음을 짐작할 수 있고, Presenter와 UI는 State, Events를 통해 어떠한 상호작용을 하고 있음을 볼 수 있습니다.
이것은 UDF(Unidirectional Data Flow)을 연상케 하고, 이러한 구조를 통해 MVI 아키텍처를 구현한다는 것을 알 수 있습니다.
Screen, Presenter, UI
자, 그럼 Circuit을 이루고 있는 핵심 컴포넌트 세 가지가 어떤 것인지 알아보겠습니다.
Screen
https://slackhq.github.io/circuit/screen/
Screen은 Presenter와 UI를 페어링하기 위한 키 역할입니다.
코드적으로는 Parcelable을 구현하고 있는 인터페이스입니다.
interface Screen : Parcelable
정의만으로는 역할을 알기가 어려울 수 있습니다. 나중에 코드를 보면 이해가 더 쉬우니 넘어갑시다.
Presenter
https://slackhq.github.io/circuit/presenter/
Presenter는 MVP 아키텍처를 경험해봤다면 의미를 추론하기 쉬울 것입니다.
Presenter는 UI의 비즈니스 로직을 수행하여 UI가 그려내야 할 상태를 만들어내는 역할을 하며,
코드적으로는 아래와 같습니다.
interface Presenter<UiState : CircuitUiState> {
@ComposableTarget("presenter")
@Composable
fun present(): UiState
}
MVVM 아키텍처에 익숙하다면, ViewModel과 비슷한 역할로 이해하면 쉽습니다.
UI
https://slackhq.github.io/circuit/ui/
가장 이해하기 쉬운 컴포넌트일 듯 합니다.
UI는 Presenter에서 만들어내는 상태를 바탕으로 Composable을 그려내는 역할을 하며,
코드적으로는 아래와 같습니다.
interface Ui<UiState : CircuitUiState> {
@Composable fun Content(state: UiState, modifier: Modifier)
}
직관적으로 Composable 함수들로 UI를 그려내야겠다는 것을 알 수 있습니다.
Implementation
자, 이제 Circuit을 사용해서 실제 구현을 해봅시다.
이 예제에서는 개인적으로 좋아하는 주제인 Pokedex를 활용할 것입니다.
전체 코드는 https://github.com/duitddu/android-pokedex-circuit 에 있습니다.
이 블로그 글에서는 Screen, Presenter, UI를 구현하는 것에 집중이 되어 있으니, 전체 코드는 깃허브를 참고해주세요.
의존성 추가
개발을 시작하기 전에 Circuit 의존성을 추가하겠습니다.
[libraries]
circuit-foundation = { module = "com.slack.circuit:circuit-foundation", version.ref = "circuit" }
circuitx-android = { module = "com.slack.circuit:circuitx-android", version.ref = "circuit" }
circuit-codegen-annotation = { module = "com.slack.circuit:circuit-codegen-annotations", version.ref = "circuit" }
circuit-codegen-ksp = { module = "com.slack.circuit:circuit-codegen", version.ref = "circuit" }
이 프로젝트는 Hilt를 사용하기 때문에, 이를 위해 몇 가지 의존성이 추가로 필요합니다.
자세히 설명하지는 않겠습니다.
의존성과 관련해서는 아래를 참고해보시는 것도 좋겠습니다.
https://slackhq.github.io/circuit/setup/
Pokemon List 구현
가장 먼저 Pokemon List를 나타낼 Screen을 구현해야 합니다.
이 Screen은 이 화면이 어떤 상태와 어떤 이벤트를 가져야 하는지를 알 수 있어야 합니다.
코드를 보겠습니다.
@Parcelize
data object PokemonListScreen: Screen {
sealed interface State: CircuitUiState {
data object Idle: State
data class Success(
val pokemons: ImmutableList<Pokemon> = emptyList<Pokemon>().toImmutableList(),
val eventSink: (Event) -> Unit
): State
}
sealed interface Event: CircuitUiEvent {
data class OnPokemonClicked(val pokemon: Pokemon): Event
}
}
이 화면은 화면에 포켓몬의 목록과 포켓몬을 클릭했을 때 상세 화면으로 이동할 이벤트가 존재합니다.
상태는 CircuitUiState를 구현하고, 이벤트는 CircuitUiEvent를 구현해야 합니다.
그런데 한 가지 의문이 듭니다. event를 처리하는 콜백이 상태에 존재합니다.
이것에 대해선 https://www.youtube.com/watch?v=ZIr_uuN8FEw&t=916s 를 참고하는 것을 추천합니다.
동작을 요약하자면, 사용자의 행위에 따라 이벤트가 발생하면 새로운 상태가 만들어지고, 새로운 상태로 리컴포지션하게 되는 동작을 하게 됩니다.
자 다음으로, Pokemon Screen의 비즈니스 로직을 수행하고 상태를 만들어내는 Presenter를 구현해 보겠습니다.
class PokemonListPresenter @AssistedInject constructor(
@Assisted private val navigator: Navigator,
private val pokemonRepository: PokemonRepository,
) : Presenter<PokemonListScreen.State> {
@Composable
override fun present(): PokemonListScreen.State {
var state by rememberRetained {
mutableStateOf<PokemonListScreen.State>(PokemonListScreen.State.Idle)
}
LaunchedEffect(Unit) {
// TODO : Need to implement paging
val pokemons = pokemonRepository.getPokemons(offset = 0, limit = POKEMON_LIMIT)
state = PokemonListScreen.State.Success(
pokemons = pokemons.toImmutableList(),
eventSink = {
when (it) {
is PokemonListScreen.Event.OnPokemonClicked -> {
navigator.goTo(
PokemonDetailScreen(pokemonName = it.pokemon.name)
)
}
}
}
)
}
return state
}
@CircuitInject(PokemonListScreen::class, ActivityRetainedComponent::class)
@AssistedFactory
fun interface Factory {
fun create(
navigator: Navigator
): PokemonListPresenter
}
companion object {
private const val POKEMON_LIMIT = 151
}
}
present 함수에서 상태를 만들어 냅니다.
이 Presenter는 Composable 함수가 만들어질 때, 최초 1회에 한해 Repository를 통해 포켓몬 리스트를 가져오고 상태를 변경합니다.
(페이징 처리와 같은 것은 TODO로 남깁니다.)
이 프로젝트에서는 Hilt를 사용하였으므로 @CircuitInject, @AssistedInject, @AssistedFactory와 같은 어노테이션이 등장합니다.
Compose를 사용해보신 분이라면, rememberRetained을 보고 의아할 것 같습니다.
우리가 주로 사용해왔던 것은 remember, rememberSavable이었기 때문이죠.
rememberRetained은 Circuit에서 제공해주는 상태관리 함수로, 커스텀한 Saver 같은 것 없이 화면 전환과 같은 경우에도 상태를 유지하고 복원해줍니다.
이것에 대한 자세한 내용은 다음에 다룰 기회를 기대해보겠습니다.
또 Circuit의 한 가지 특징은 자체적인 Navigation을 제공하며, 제공되는 함수는 아래와 같습니다.
@Stable
public interface Navigator : GoToNavigator {
public override fun goTo(screen: Screen): Boolean
public fun pop(result: PopResult? = null): Screen?
public fun peek(): Screen?
public fun peekBackStack(): ImmutableList<Screen>
public fun resetRoot(
newRoot: Screen,
saveState: Boolean = false,
restoreState: Boolean = false,
): ImmutableList<Screen>
}
Circuit을 사용하면 앞으로 이 Navigator를 사용해서 화면 전환을 제어하게 됩니다.
https://slackhq.github.io/circuit/navigation/
이제 마지막으로, 상태로부터 Composable을 그려내는 UI를 작성해보겠습니다.
@CircuitInject(PokemonListScreen::class, ActivityRetainedComponent::class)
@Composable
fun PokemonList(
state: PokemonListScreen.State,
modifier: Modifier = Modifier
) {
Scaffold(
modifier = modifier,
topBar = {
TopBar()
}
) {
Surface(
modifier = Modifier
.fillMaxSize()
.padding(it)
) {
when (state) {
is PokemonListScreen.State.Idle -> {
Idle()
}
is PokemonListScreen.State.Success -> {
Success(
pokemons = state.pokemons,
onClick = { clicked ->
state.eventSink.invoke(PokemonListScreen.Event.OnPokemonClicked(clicked))
}
)
}
}
}
}
}
정말 간단합니다.전달받아야 할 state와 modifier를 파라미터로 가지는 Composable 함수를 작성하고,
어노테이션을 통해 PokemonListScreen과 연결해주기만 하면 됩니다.
여기서 Modifier 파라미터가 없으면 빌드시에 에러를 발생하므로 주의해야 합니다.
여기서 상세 화면까지 다루면 글이 너~무 길어지므로, 상세 화면은 생략하겠습니다.
직접 구현해보시거나 글 초반의 깃허브에서 참고해주시면 되겠습니다.
자, 이제 빌드를 하고 실행을 하기 위해 Circuit을 세팅해주어야 합니다.
MainActivity로 이동해서 다음과 같은 코드를 작성합시다.
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject
lateinit var circuit: Circuit
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
CircuitPokdexTheme {
val backStack = rememberSaveableBackStack(root = PokemonListScreen)
val navigator = rememberCircuitNavigator(backStack)
CircuitCompositionLocals(circuit) {
NavigableCircuitContent(
navigator = navigator,
backStack = backStack,
)
}
}
}
}
}
Navigation을 위한 코드들을 작성하고, Circuit에서 제공하는 CompositionLocals에 circuit을 전달함으로써
Circuit과 Navigation을 사용할 준비를 마쳤습니다.
참고로, 이 프로젝트는 Hilt를 사용하기 때문에 Circuit은 Hilt에 의해 주입되며, 이를 위한 Circuit모듈은 다음과 같습니다.
@Module
@InstallIn(ActivityRetainedComponent::class)
public abstract class CircuitModule {
@Multibinds
public abstract fun presenterFactories(): Set<Presenter.Factory>
@Multibinds
public abstract fun uiFactories(): Set<Ui.Factory>
public companion object {
@[Provides ActivityRetainedScoped]
public fun provideCircuit(
presenterFactories: @JvmSuppressWildcards Set<Presenter.Factory>,
uiFactories: @JvmSuppressWildcards Set<Ui.Factory>,
): Circuit = Circuit.Builder()
.addPresenterFactories(presenterFactories)
.addUiFactories(uiFactories)
.build()
}
}
이 코드들은 Dagger와 연관이 있는 코드이므로, 자세히 알고 싶으시다면 Dagger를 참고해보세요.
(저도 참고하러 가야합니다. ㅋㅋ)
장점과 단점
저는 개인적으로 아키텍처는 코드 컨벤션의 역할까지 한다고 생각합니다.
즉, 아키텍처가 정해져 있다면 팀의 구성원들이 만들어낸 코드는 비슷한 형태를 유지하게 되어야 한다고 생각하는 것이죠.
이러한 측면으로 봤을 때, Circuit은 구현해야 할 컴포넌트들이 정해져있고 그 방식이 어느 누가 작성해도 비슷할 것으로 보입니다.
이런 점은 장점으로 느꼈습니다.
그리고 MVI의 장점이나 특징인 UDF를 잘 살릴 수 있을 것 같다는 생각이 들었습니다.
그리고 그것을 위해 어려운 뭔가를 할 필요는 없기 때문에 구현도 용이한 편인 것 같습니다.
어노테이션을 잘 설정하면 알아서 코드를 생성해주기 때문이죠.
하지만 어노테이션을 활용해서 코드를 생성하는 프로젝트의 단점은 어떤 것 하나를 놓쳤을 때 발생합니다.
Circuit에 익숙하지 않다면, 어떤 어노테이션을 어디에 달아야하는지 헷갈리기 쉽고 어디서 놓쳤는지 멘붕이 올 수도 있을 것 같습니다.
(실제로 개발하면서 빌드 실패를 많이 경험했습니다.)
가장 큰 단점은 아직 세상에 나온지 얼마 안되었다는 점 입니다.
글 작성 기준, 버전이 0.25.0 입니다. 그리고 참고할만한 블로그 글이나 깃허브 등도 아직 매우 부족합니다.
이것은 어떤 문제가 발생하면 직접 기여를 하거나, 고쳐주길 기다리거나, 다른 우회 방법을 찾거나 해야할 가능성이 높다는 뜻입니다.
슬랙이라는 크고 기술력있는 회사에서 프로덕션에 사용중이긴 하지만, 우리의 프로덕트에 도입하기엔 시기상조일 수 있습니다.
결론
당장 Circuit을 프로덕션에 사용할지 말지는 제가 속한 팀에 따라 달라질 것이라 생각합니다.
무엇보다 Circuit이 주는 장점들이 제게는 긍정적으로 느껴지고 있는데,
Compose와 MVI를 주력으로 활용하는 현재의 트렌드에 잘 맞다고 생각하기 때문입니다.
앞으로 Circuit이 잘 발전하길 바라고, 기회가 된다면 기여하는 것도 고려해야겠습니다.
'개발 > Android' 카테고리의 다른 글
[Android] APK 파헤치기 (0) | 2025.01.05 |
---|---|
[Android] Jetpack Compose - UI Test. 요약본 리뷰 (0) | 2024.12.09 |
[Android/안드로이드] Jetpack Compose - Stability와 Recomposition 그리고 최적화 (0) | 2024.06.27 |
[Android/안드로이드] Jetpack Compose - 폰트 크기 고정하기 (0) | 2024.06.17 |
Android - EncryptedSharedPreferences 로 데이터 암호화하기 (0) | 2023.11.14 |