최근에 관심있는 기업들의 채용 공고에서 MVI 프레임워크들의 이름을 드문드문 확인할 수 있었다.
생각보다 적지 않은 회사들에서 MVI 프레임워크(Mavericks, Circuit, orbit 등)를 사용하고 있는 것을 확인할 수 있었고, 이를 참고하여 직접 커스텀하여 개발하는 곳도 몇몇 확인할 수 있었다.
오늘은 그 중에서 Circuit 이라는 Slack 에서 만든(실제 프로덕션 환경에서 사용중인) 프레임워크에 대해 소개하면서, 예제 프로젝트에 적용 해본 후기(기존의 개발방식과의 차이점과 장단점)를 적어보려고 한다.
말 그대로 찍먹 후 후기를 남기는 글이기 때문에, 상세한 설명들은 생략하도록 하겠다.(글의 깊이가 얕을 수 있으며, 학습 후 추가 글을 통해 보충할 예정)
예제 프로젝트 링크를 미리 첨부
이전 글을 위해 만들었던 프로젝트에 Circuit 을 적용 해보았다.
https://github.com/easyhooon/NavigationParcelableArgumentExample/tree/circuit
Circuit 공식 문서를 보면 Circuit 을 다음과 같이 소개하고 있다.
Circuit은 간단하고 가벼우면서 확장성 있는 Kotlin 앱 개발을 위한 프레임워크로, Compose를 기본 UI 시스템으로 채택하였다고 한다. (아직 unstable 하지만, 지속적으로 개선하고 발전시키는중!) 다른 MVI 프레임워크와 같이 멀티 플랫폼을 지원한다.
또한 github 를 확인해보면 circuit 을 A Compose-driven architecture for Kotlin and Android applications 라고 소개하고 있다.
컴포즈 주도 아키텍처? 왜 Compose driven architecture 라고 설명하는지, 기존의 사용했던 개발 방식들도 Compose 를 도입했을 때, 문제가 없었는데 '얘네들은 Compose driven architecture 가 아닌 건가?' 라는 의구심이 들어 여러 자료들을 찾아보았고, 다음과 같은 KotlinConf 영상을 확인할 수 있었다.
오픈소스를 이용하다보면 Jake Warton 만큼 자주 눈에 띄는 Zac Sweers 가 연사자로서 Circuit 에 대해 설명하는 영상으로, Circuit 에 관심이 있다면 풀영상을 보시는 것을 추천한다.
왜 Compose driven architecture 라고 소개하였는지, 영상을 보고 가장 설득력 있었던 부분은
다음과 같이 화면 뿐만 아니라 상태 관리를 위한 state holder 에서 또한 Composable 함수를 사용한다는 점이다. 영상에서는 "It(Compose) can be a good tool for business logic" 이라고 언급한다.
Compose 를 Android 개발에서 사용되는 UI 를 그리기 위한 툴킷이라고 알고 계신 분들이 있는데, Compose 는 Compose Compiler / Runtime 와 Compose UI 서로 독립적인 2개의 다른 라이브러리로 나뉘어진다.
그 중의 Compose Runtime 은 강력한 상태 관리 API 들을 제공한다.
관련해서 더 자세한 내용은 아래의 Jake Warton 의 글을 참고하면 좋을 것 같다.
https://jakewharton.com/a-jetpack-compose-by-any-other-name/
소개는 간단하게 여기까지 하도록 하고, 이를 프로젝트에 적용해보도록 하겠다.
기존의 작성했던 코드와 비교를 하는 방식으로 설명을 진행하도록 하겠다.
참고로 기존의 프로젝트에서 DI 라이브러리를 Hilt 를 사용하였고, Circuit 역시 Hilt 를 지원하기 때문에, Hilt 를 그대로 사용하였다.
circuit 관련 의존성 추가
libs.versions.toml
circuit-foundation = { module = "com.slack.circuit:circuit-foundation", 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" }
circuitx-android = { module = "com.slack.circuit:circuitx-android", version.ref = "circuit" }
// build.gradle.kts(proejct 모듈)
plugins {
...
alias(libs.plugins.hilt) apply false
alias(libs.plugins.ksp) apply false
}
// build.gradle.kts(circuit 을 사용할 모듈)
plugins {
...
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
}
// 빼먹지 않게 주의!
ksp {
arg("circuit.codegen.mode", "hilt")
}
dependencies {
...
implementation(libs.androidx.hilt.common)
implementation(libs.hilt.android)
ksp(libs.hilt.android.compiler)
implementation(libs.bundles.circuit)
api(libs.circuit.codegen.annotation)
ksp(libs.circuit.codegen.ksp)
}
다음과 같이 의존성들을 추가해주면, Circuit 을 사용하기 위한 준비는 마무리 되었다. 공식 문서에 자세히 설명되어있으니, 추가 설명 없이 빠르게 넘어가도록 하겠다.
기존의 개발 방식과 구현에 있어 가장 다른점이 많은 파트라고 생각한다. (우선 이름부터 ViewModel 이 아니다)
그래서 우선 Circuit 의 Presenter 와 AAC ViewModel 의 차이점 먼저 정리해보도록 하겠다.
목적 및 역할
Circuit Presenter 는 UI 비즈니스 로직과 데이터 레이어 사이의 변환 계층(translation layer)을 제공
AAC ViewModel 은 UI 컴포넌트에서 데이터를 유지하고 관리하는 역할
상태 관리
Circuit Presenter 는 remember, rememberSaveable, rememberRetained 등의 상태 관리 함수를 사용하여 상태를 관리
AAC ViewModel 은 LiveData / StateFlow 를 통해 상태를 관리
Lifecycle
Circuit Presenter 는 Compose Lifecycle 에 연결되어 있음(UI 의 Lifecycle 과 같기 때문에 Lifecycle 에 대해 고려할 필요 X)
AAC ViewModel 은 (Navigation 을 고려하지 않는 경우) 기본적으로 Activity / Fragment 의 Lifecycle 에 연결 (repeatOnLifecycle
, collectAsStateWithLifecycle
과 같은 함수를 이용)
DI
Circuit Presenter 는 주로 Dagger 를 통한 의존성 주입을 사용
AAC ViewModel 은 일반적으로 Hilt, Koin 등의 의존성 주입 라이브러리를 사용
기존의 HomeViewModel
@HiltViewModel
class ListViewModel @Inject constructor(
private val repository: LectureRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
init {
_uiState.update {
it.copy(
lectureList = repository.getLectureList().toImmutableList(),
studentList = repository.getStudentList().toImmutableList(),
studentGradeList = repository.getStudentGradeList().toImmutableList()
)
}
}
}
data class HomeUiState(
val lectureList: ImmutableList<Lecture> = persistentListOf(),
val studentList: ImmutableList<Student> = persistentListOf(),
val studentGradeList: ImmutableList<Int> = persistentListOf(),
)
Circuit HomePresenter
class HomePresenter @AssistedInject constructor(
private val repository: LectureRepository,
@Assisted private val navigator: Navigator,
) : Presenter<HomeScreen.State> {
@Composable
override fun present(): HomeScreen.State {
val lectureList by produceRetainedState<List<Lecture>>(initialValue = emptyList()) {
value = repository.getLectureList()
}
val studentList by produceRetainedState<List<Student>>(initialValue = emptyList()) {
value = repository.getStudentList()
}
val studentGradeList by produceRetainedState<List<Int>>(initialValue = emptyList()) {
value = repository.getStudentGradeList()
}
return HomeScreen.State(
lectureList = lectureList,
studentList = studentList,
studentGradeList = studentGradeList,
) { event ->
when (event) {
is HomeScreen.Event.OnLectureClick -> navigator.goTo(
DetailScreen(
lectureName = event.lectureName,
studentGradeList = event.studentGradeList,
lecture = event.lecture,
studentList = event.studentList,
),
)
}
}
}
@CircuitInject(HomeScreen::class, ActivityRetainedComponent::class)
@AssistedFactory
fun interface Factory {
fun create(navigator: Navigator): HomePresenter
}
}
간단한 예제이기에 일회성 이벤트인 화면 이동 이벤트(navigate)를 화면 내에서 호출하였으나, 평소의 진행하던 프로젝트에서는 UiEvent sealed interface 를 정의하여, UiState 와 UiEvent 를 별개로 구성해왔다.
예시
data class HomeUiState(
val isLoading: Boolean = false,
)
sealed interface HomeUiEvent {
data class ShowSnackBar(val message: UiText) : HomeUiEvent
}
하지만 Circuit 에서는 State 내에 Event 가 포함되어 있는 것을 확인할 수 있다.
Presenter 의 동작 방식은 다음과 같다.
기존 HomeScreen
@Composable
fun HomeRoute(
innerPadding: PaddingValues,
navigateToDetail: (String, List<Int>, Lecture, List<Student>) -> Unit,
viewModel: ListViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
HomeScreen(
innerPadding = innerPadding,
uiState = uiState,
navigateToDetail = navigateToDetail,
)
}
@Composable
fun HomeScreen(
innerPadding: PaddingValues,
uiState: HomeUiState,
navigateToDetail: (String, List<Int>, Lecture, List<Student>) -> Unit,
) { ... }
Circuit 적용 후 HomeScreen
@Parcelize
data object HomeScreen : Screen {
data class State(
val lectureList: List<Lecture>,
val studentGradeList: List<Int>,
val studentList: List<Student>,
val eventSink: (Event) -> Unit,
) : CircuitUiState
sealed interface Event : CircuitUiEvent {
data class OnLectureClick(
val lectureName: String,
val studentGradeList: List<Int>,
val lecture: Lecture,
val studentList: List<Student>,
) : Event
}
}
@CircuitInject(HomeScreen::class, ActivityRetainedComponent::class)
@Composable
fun Home(
state: HomeScreen.State,
modifier: Modifier = Modifier,
) { ... }
기존의 개발 방식에서는 Screen 의 파라미터로 viewModel 을 가지지 않도록, 별도의 Route Composable 을 정의하여, UiState 및 ViewModel 의 함수를 전달하도록 코드를 작성했는데, Circuit 을 적용한 이후에는, Route Composable 이 필요가 없어졌기에 제거하고, Circuit 관련 어노테이션들을 추가하였다.
Circuit 은 자체적인 커스텀 navigation 을 지원한다.
이는 compose-navigation 내부 구현에 있어, AAC ViewModel 과 밀접한 관련이 있기 때문에, AAC ViewModel 을 사용하지 않는 Circuit 에서는 적합하지 않기 때문이라고 추측된다.
지원하는 함수들은 다음과 같다.
@Stable
public interface Navigator : GoToNavigator {
public override fun goTo(screen: Screen): Boolean
public fun pop(result: PopResult? = null): Screen?
/** Returns current top most screen of backstack, or null if backstack is empty. */
public fun peek(): Screen?
/** Returns the current back stack. */
public fun peekBackStack(): ImmutableList<Screen>
public fun resetRoot(
newRoot: Screen,
saveState: Boolean = false,
restoreState: Boolean = false,
): ImmutableList<Screen>
}
또한 rememberAnsweringNavigator
API 를 통해 Navigation Result 처리를 지원한다.
기존의 MainActivity
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
NavigationParcelableArgumentExampleTheme {
MainScreen()
}
}
}
}
@Composable
fun MainScreen() {
val navController = rememberNavController()
Scaffold(
modifier = Modifier.fillMaxSize(),
) { innerPadding ->
NavHost(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
navController = navController,
startDestination = Route.Home,
) {
homeNavGraph(
innerPadding = innerPadding,
// 홈 -> 상세화면 이동
navigateToDetail = navController::navigateToDetail,
)
detailNavGraph(
innerPadding = innerPadding,
// 뒤로가기
popBackStack = navController::popBackStack,
)
}
}
}
Circuit 적용 후 MainActivity
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject
lateinit var circut: Circuit
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
NavigationParcelableArgumentExampleTheme {
// 네비게이션을 위한 백스택 생성 및 상태 저장/복원 관리
// configuration change 가 발생해도 유지되는 Screen 의 스택을 저장
val backStack = rememberSaveableBackStack(root = HomeScreen)
// 백스택을 기반으로 화면 간 이동을 처리
// 백스택에 대한 단순한 interface 를 제공하는 wrapper
val navigator = rememberCircuitNavigator(backStack)
CircuitCompositionLocals(circut) {
// BackStack 의 top screen 을 노출
NavigableCircuitContent(
navigator = navigator,
backStack = backStack,
)
}
}
}
}
}
기존의 compose navigation
// DetailNavigation.kt
fun NavController.navigateToDetail(
lectureName: String,
studentGradeList: List<Int>,
lecture: Lecture,
studentList: List<Student>,
) {
navigate(Route.Detail(lectureName, studentGradeList, lecture, studentList))
}
// Route.kt
fun NavGraphBuilder.detailNavGraph(
innerPadding: PaddingValues,
popBackStack: () -> Unit,
) {
composable<Route.Detail>(
typeMap = Route.Detail.typeMap,
) {
DetailRoute(
innerPadding = innerPadding,
popBackStack = popBackStack,
)
}
}
sealed interface Route {
@Serializable
data object Home : Route
@Serializable
@Parcelize
data class Detail(
val lectureName: String,
val studentGradeList: List<Int>,
val lecture: Lecture,
val studentList: List<Student>,
) : Route, Parcelable {
companion object {
val typeMap = mapOf(
typeOf<Lecture>() to LectureType,
typeOf<List<Student>>() to StudentListType,
)
}
}
}
@Serializable
val LectureType = object : NavType<Lecture>(isNullableAllowed = false) {
override fun get(bundle: Bundle, key: String): Lecture? {
return bundle.getString(key)?.let { Json.decodeFromString(it) }
}
override fun parseValue(value: String): Lecture {
return Json.decodeFromString(value)
}
override fun put(bundle: Bundle, key: String, value: Lecture) {
bundle.putString(key, Json.encodeToString(Lecture.serializer(), value))
}
override fun serializeAsValue(value: Lecture): String {
return Json.encodeToString(Lecture.serializer(), value)
}
}
@Serializable
val StudentListType = object : NavType<List<Student>>(isNullableAllowed = false) {
override fun get(bundle: Bundle, key: String): List<Student>? {
return bundle.getStringArray(key)?.map { Json.decodeFromString<Student>(it) }
}
override fun parseValue(value: String): List<Student> {
return Json.decodeFromString(ListSerializer(Student.serializer()), value)
}
override fun put(bundle: Bundle, key: String, value: List<Student>) {
val serializedList = value.map { Json.encodeToString(Student.serializer(), it) }.toTypedArray()
bundle.putStringArray(key, serializedList)
}
override fun serializeAsValue(value: List<Student>): String {
return Json.encodeToString(ListSerializer(Student.serializer()), value)
}
}
circuit navigation
class HomePresenter @AssistedInject constructor(
...
@Assisted private val navigator: Navigator,
) : Presenter<HomeScreen.State> {
@Composable
override fun present(): HomeScreen.State {
...
return HomeScreen.State(
lectureList = lectureList,
studentList = studentList,
studentGradeList = studentGradeList,
) { event ->
when (event) {
// goTo 함수를 통해 이동하려는 화면으로 이동
is HomeScreen.Event.OnLectureClick -> navigator.goTo(
DetailScreen(
lectureName = event.lectureName,
studentGradeList = event.studentGradeList,
lecture = event.lecture,
studentList = event.studentList,
),
)
}
}
}
}
class DetailPresenter @AssistedInject constructor(
...
@Assisted private val navigator: Navigator,
) : Presenter<DetailScreen.State> {
@Composable
override fun present(): DetailScreen.State {
return DetailScreen.State(
...
) { event ->
when (event) {
// pop() 함수를 통해 뒤로 가기
is DetailScreen.Event.OnBackClick -> navigator.pop()
}
}
}
}
기존과 같이, 리스트의 아이템을 클릭했을때, 상세화면으로 이동하면서 데이터가 정상적으로 전달되는 것을 확인할 수 있었다.
디테일한 차이점으로는 Navigation 애니메이션이 바뀌었다는 점인데, compose navigation 은 기본 애니메이션이 fade in, fade out 이다.
반면에 circuit navigation 의 경우 기본 애니메이션으로 다음 화면으로 이동시 오른쪽으로 이동(->), 이전 화면으로 이동(뒤로가기)시 왼쪽으로 이동(<-)하는 애니메이션을 지원한다.
Circuit 을 사용해보고 나서 개인적으로 느낀 장점과 단점을 공유해보도록 하겠다.
마치 HiltViewModel 처럼, Factory 클래스를 자동 생성해주는 것들 뿐만 아니라, @CircuitInject 어노테이션을 통해 코드를 생성 해줌으로써, 개발자가 작성해야 하는 보일러플레이트 코드를 최소화해준다.
다양한 DI 라이브러리들을 지원하는 것은 덤이다.(Anvil, Dagger/Hilt, kotlin-inject) Koin 은..?
Circuit 을 사용하면서 가장 의외였던 점인데, navigation 관련한 코드가 드라마틱하게 줄었다. 함수들을 사용하기에도 매우 쉽고 직관적이며, 위에 예제에서는 사용되지 않았지만 resetRoot()
함수를 통해, Single Activity 환경에서 startDestination 을 동적으로 변경해줘야 하는 경우에 쉽게 대응할 수 있을 것으로 보인다.
compose-navigation 관련한 여러 글들을 작성하고, 공부했지만 이정도 편의성이면 갈아타도 될지도...?
그밖에 AAC ViewModel 을 사용하지 않고, Composable 함수들을 이용하기에, 테스트 코드를 작성에 있어 난이도가 많이 낮아진다고 한다. 아직 이 부분은 직접 경험해보지 못한 부분이라, 테스트 코드를 각각 작성해보고 비교를 해봐야겠다.(TODO 추가)
공식문서의 tutorial 을 참고하여, Circuit 으로 기존 프로젝트를 migration 하고 그 결과를 확인하기 위해 설레는 마음으로 빌드를 해봤는데 다음과 같은 화면이 출력되는 것을 확인할 수 있었다.
Route not available: HomeScreen..?
'Circuit 에서 의미하는 Route 가 뭐지?' 라는 생각이 먼저 들었고, 문제의 원인을 파악하고 해결하는데, 생각보다 오랜 시간이 걸렸다.
위에서 코드를 확인해봤을 때, 알 수 있듯이, Circuit 관련한 어노테이션을 붙혀줄 곳이 많기 때문에, 이 중에 어느 것 하나라도 누락할 경우, 위에 화면처럼 Screen Route 가 연결되지 않아, 에러가 발생하게 된다.
내 경우엔 Screen Composable 상단에 붙여줘야 할 @CircuitInject(HomeScreen::class, ActivityRetainedComponent::class)
를 누락한 케이스였고, 어느 것을 누락했는지 Logcat 내에 에러 메세지를 통해서는 알 수 없는 점이 아쉬웠다.
Circuit 이 Hilt 를 지원한다고는 하나, Android 프로젝트에서 사용해왔던 익숙한 Hilt 의 어노테이션이 아닌, 낯선, Dagger 스러운 어노테이션들을 사용해야한다는 점을 꼽을 수 있겠다. (이는 orbit 을 제외한 다른 MVI 프레임워크도 마찬가지 인듯하다.)
이를 이해하고 사용하기 위해선 Hilt 와 Dagger 에 대한 깊은 이해가 필요할 것으로 보인다.(이건 Circuit 의 문제라기 보단 내 문제인듯)
다른 MVI 프레임워크, 대표적으로 orbit 의 경우에는 AAC ViewModel 과 androidx-navigation 을 그대로 사용하되, state 와 event 를 handling 하는 부분만 변경 해주면 된다. 하지만 Circuit 의 경우, AAC ViewModel 과 androidx-navigation 을 둘다 사용하지 않고 Circuit 의 Presenter, circuit-navigation 을 사용하기 때문에, 기존 로직에서 변경해줘야 할 부분이 상~당히 많다. 다른 MVI 프레임워크들에 비해 설득의 비용이 높을 것으로 예상된다.
Circuit 은 다른 MVI 프레임워크들과는 달리 AAC ViewModel 을 사용하지 않는 새로운 접근 방식을 보여준다.
최근 일본을 비롯한 여러 국가들에서 AAC ViewModel 을 사용하지 않고 개발하는 방식이 대두되고 있던데, Circuit 을 통해 Android 개발의 새로운 트렌드를 경험할 수 있었다.
Google 의 Jetpack Compose 개발자이신 분도 X 를 통해 Android 개발에 ViewModel 이 필요 없다는 의견을 밝힌 바 있다.
https://x.com/JimSproch/status/1561024830786322433
추가적으로 궁금한 것들로는 두가지 정도 있었는데,
먼저, AAC ViewModel을 사용하는 주요 이유 중 하나는 savedStateHandle을 통해 프로세스 종료 시에도 데이터를 보존할 수 있다는 점이다.
하지만 Circuit은 AAC ViewModel을 사용하지 않기 때문에, savedStateHandle을 사용할 수 없는데, 이를 대체할 수 있는 수단이 존재하는지 알아봐야 할 것 같다.
이미 쓰고 있을지도 모른다! 아직 이해도가 부족한 시점이라...
아마도 rememberRetained, produceRetainedState 와 같은 함수들의 대해 명확히 이해한다면, 정답을 알 수 있을 듯 하다.
-> 관련해서 학습을 진행 후, 동작 원리에 대한 분석 글을 작성해보았다.
마지막으로 KMP 개발의 관점에서, Circuit 이 개발된 초기 시점(22년 이전)에는 AAC ViewModel 및 savedStateHandle, navigation 이 멀티 플랫폼을 지원하지 않았었다. 따라서 멀티 플랫폼을 지원하려면 이를 사용하지 않는 방향으로 개발했어야 했다.
하지만, 이제는 시간이 흘러 멀티 플랫폼을 지원하게 되었기 때문에, 기존의 개발 방식과 크게 다르지 않은, 익숙한 방식으로 MVI 프레임워크를 개발해볼 수 있지 않을까? 라는 생각도 들었다.
UI composable functions must have a Modifier parameter!
Circuit 을 사용할 경우 위와 같이 Screen 컴포저블에 modifier 파라미터를 추가하지 않으면, 빌드 타임에 modifier 파라미터를 추가하라고 에러를 뱉는다!
그리고 발표 영상에서 Compose 를 소개할 때, 대놓고 React 스타일의 선언형 UI 프레임워크라고 소개하는 것이 인상적이었다.
국내 개발자 컨퍼런스에서도 Circuit 을 도입한 배경과 이를 소개하는 발표 영상이 11월 6일자로 업로드되었다.
더 많은 회사들이 Circuit 을 개발하게된 배경과, 사용했을 때 얻을 수 있는 장점들에 공감하여, 프로덕트에 도입을 해보면 좋을 것 같다.
끝!
레퍼런스)
https://slackhq.github.io/circuit/
https://www.youtube.com/watch?v=ZIr_uuN8FEw
https://chrisbanes.me/posts/retaining-beyond-viewmodels/
https://github.com/jisungbin/dog-browser-circuit
https://github.com/chrisbanes/tivi
https://github.com/takahirom/Rin
https://x.com/JimSproch/status/1561024830786322433
https://qiita.com/karamage/items/9b2b5a79c364b72836d4
https://youtu.be/53fmU990oGE?si=F3Z3Fx3Vcxu-i897
https://qiita.com/takahirom/items/8888b324b40bf5eb252b
https://qiita.com/takahirom/items/5b18d5d9f310e1bd1957