Jetpack Compose 로 검색 진입 화면의 복잡한 상태 관리하기

Jinkwang Song
29CM TEAM
Published in
16 min readDec 1, 2023
Photo by Farzad on Unsplash

안녕하세요, 29CM Android 개발자 송진광입니다.

일반적으로 Android 팀은 오랜기간 XML 과 함께 명령형 프로그래밍 방식으로 UI 를 개발해 왔습니다. 단순한 화면에서는 문제가 없지만 복잡하고 고도화된 화면을 개발하는 경우 여러 상태를 잘 다루기 위해 많은 코드를 추가해야 합니다.

또한 코드가 복잡해 질수록 상태 관리의 어려움 때문에 실수가 발생할 가능성이 높은데요, 이번 글에서는 선언형 API를 제공하는 Jetpack Compose 를 사용해 기존 XML 파일과 클래스로 구성된 복잡했던 화면을 Jetpack Compose 기반의 간결하고 직관적인 코드로 변경해 빠르게 실험을 해나갈 수 있었던 사례를 소개하고자 합니다.

Jetpack Compose 의 장점

이 글 소개에 앞서 간단하게 Jetpack Compose 에 대해서 살펴보겠습니다. Jetpack Compose 는 구글에서 개발한 Android 네이티브 UI 를 구축하기 위한 선언형 UI 툴킷입니다. 2021년 7월에 Jetpack Compose 1.0.0 스테이블 버전 출시를 기준으로 2년 넘도록 꾸준히 업데이트 되고 있는 라이브러리입니다.

Jetpack Compose 의 장점은 다음과 같습니다.

  • 코드 감소 : 작성하는 코드를 Kotlin, XML 로 나누지 않고 Kotlin 으로만 작성하여 유지보수할 코드가 적어집니다.
  • 직관적 : 선언적 API 를 사용해서 명시적인 상태를 Composable 로 전달하여 UI 가 자동으로 업데이트되도록 하여 직관적입니다.
  • 빠른 개발 : Navigation, ViewModel, Kotlin Coroutine 과 같은 일반적인 라이브러리는 Compose 와 함께 작동되며 실시간 Preview 기능을 포함해 코드를 더 빠르게 개발할 수 있습니다.
  • 강력한 성능 : Material Deisign, Dark mode, Annimation 을 지원해서 쉽고 빠르게 작업할 수 있습니다.

Compose 를 도입하게 된 배경 : 29WAY 의 ‘빠른 실행’

저희 팀에서는 점점 더 복잡해지는 화면 상태를 관리하기 힘들었고 캐로셀처럼 커스텀 UI 컴포넌트에 상태를 관리하는 코드들이 추가되면서 재사용하고 유지보수하기에 어려움을 겪고 있었습니다.

그래서 저희 팀에서는 Jetpack Compose 를 단순히 메가 트렌드라서가 아닌 위에서 언급한 여러 장점들에 공감해 도입을 검토하게 되었습니다. 29CM 의 감도 높은 디자인을 위해 간결하게 애니메이션을 작성할 수 있는 것도 하나의 매력 포인트라 느꼈습니다.

29CM 프로덕트 조직은 제가 속해 있는 검색 스쿼드를 포함해 각 스쿼드에서 다양한 실험이 이루어지고 있는데요, 실험을 위한 대조군과 실험군 화면을 구성하기 위해 다양한 형태의 UI 를 구현할 필요가 있습니다. 초기엔 UI 작업이 오래 걸리지 않았으나, 점차 복잡하고 고도화된 화면이 되어 가면서 기존 방식으로는 빠르게 UI 작업을 하는데 있어 한계가 있었습니다.

특히나 Android 팀 내에서는 리스트 화면을 구성할 때 RecyclerView 혹은 Epoxy 를 주로 사용하고 있었는데요, 새로운 화면을 구성할 때 마다 Adapter(or Controller), ViewHolder(or EpoxyModel), XML 등을 추가, 수정해서 유지보수하는 비용이 적지 않았습니다.

문제를 어떻게 해결할까 고민하던 중 29CM 가 일하는 7가지 방식 중 하나인 빠른 실행을 위해 저희 팀에서는 더 적은 코드로 빠르게 개발 생산성을 높여줄 수 있는 Compose 의 도입을 논의하게 되었고, 내부에서 사용 중인 디자인 시스템을 Compose 를 활용한다면 생산성이 더 올라갈 수 있다라는 공감대도 형성되어 팀 내 합의가 이루어지며 도입을 시작하게 되었습니다.

Compose 의 적용 : 검색 진입 화면

Compose 로 가장 먼저 전환해야겠다 생각한 화면은 검색 진입 화면 입니다. 검색 진입 화면은 고객들이 원하는 상품을 검색해서 찾기 위해 진입해야 하는 첫번째 화면인데요, 검색 과정의 첫 화면인만큼 상품 상세 화면과 더불어 고객들이 가장 많이 방문하는 화면이기에 다양한 실험이 이루어지고 있었습니다.

검색 진입 화면에서 여러 실험을 개발하면서 생산성에 저하를 발생시키는 포인트를 경험했는데 대표적으로는 전체적인 디자인 가이드를 맞추기위해 Adapter 가 가지고 있는 ViewHolder 개수들이 점점 많아졌고 동시에 이를 수정하는 일도 빈번했습니다.

이렇게 실험 진행에 따라 ViewHolder 의 개수가 많아지고 또 View 로직들도 파편화되며 여러 상태 값들을 컨트롤하기가 어려워졌고, 대조군/실험군 UI도 분리하면서 상태관리 코드들도 계속해서 복잡해져서 유지보수를 하기 어려운 상황이 되었습니다.

총 7개의 타입으로 구성된 검색 진입 화면

먼저 검색 진입 화면의 UI 상태를 일원화 하기 위해서 상태를 관리하는 객체를 ViewModel 에서 처리할 수 있도록 Composable 들을 상태 호이스팅(state hoisting)을 사용해 Stateless 하게 아래와 같이 만들 수 있습니다.

@Composable
fun SearchKeyword(
rank: Int,
keyword: String,
..
)

@Composable
fun SearchHistory(
historyItems: SearchHistoryUiState,
..
)

@Composable
fun SearchBrandPage(
items: List<SearchEntranceBrandItems>,
..
)

이제 리스트에 사용할 여러 Type 을 구현하기 위해 sealed interface 로 구현한 SearchEntranceItem 를 추가하고 각 Type 별 data class 를 생성하고 SearchEntranceItem 를 상속받아 줍니다.

SearchEntranceViewModel 이 인스턴스화 될 때 검색 진입 화면의 최근 검색어, 브랜드 랭킹, 인기 검색어 데이터를 가져오는 UseCase 를 주입받고 실행하여 받아온 데이터를 SearchEntranceItem 으로 변환하여 SearchEntranceUiState 타입으로 정의한 MutableStateFlow 의 update 함수를 이용해 SearchEntranceUiState 상태값을 업데이트합니다.

class SearchEntranceViewModel(
private val fetchSearchEntranceListUseCase: FetchSearchEntranceListUseCase
) : ViewModel() {

private val _uiState = MutableStateFlow<SearchEntranceUiState>(SearchEntranceUiState.Loading)
val uiState = _uiState.asStateFlow()

fun loadData() {
val result = fetchSearchEntranceListUseCase(..)
val items = result.toItem()
_uiState.update { items }
}
}

sealed interface SearchEntranceItem {
data class SearchHistory(..) : SearchEntranceItem
data class SearchBrand(..) : SearchEntranceItem
data class SearchKeywordHeader(..) : SearchEntranceItem
data class SearchKeyword(..) : SearchEntranceItem
}

화면을 구성하는 최상단의 부모 Composable 함수인 SearchEntranceRoute 를 선언하고 ViewModel 의 스테이트 홀더인 uiState에 collectAsStateWithLifecycle 를 통해 상태를 구독하고 SearchEntranceUiState 값을 자식 Composable 에게 전파하도록 코드를 추가합니다.

@Composable
internal fun SearchEntranceRoute(viewModel: SearchEntranceViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()

SearchEntranceScreen(
uiState = uiState,
..
)
}

@Composable
internal fun SearchEntranceScreen(
uiState: SearchEntranceUiState,
..
) {
LazyColumn(..) {
when (uiState) {
is SearchEntranceUiState.Success -> {
itemsIndexed(uiState.searchItems) { index, item ->
when (item) {
is SearchEntranceItem.SearchHistory ->
item(..) { SearchKeywordHistory(...) }
is SearchEntranceItem.SearchBrand ->
item(..) { SearchBrandPage(...) }
is SearchEntranceItem.SearchKeywordHeader ->
item(..) { SearchKeywordHeader(...) }
is SearchEntranceItem.SearchKeyword ->
item(..) { SearchKeyword(...) }
}
}
}
is SearchEntranceUiState.Loading -> item { Loading() }
is SearchEntranceUiState.Error -> item { Error() }
}
}
}

여기까지 Compose 로 마이그레이션 과정을 간단하게 소개해드렸는데요, ViewModel 에서는 상태를 관리하는 코드에 집중하고 Compose 코드에서는 상태와 데이터들을 이용해 UI 에 무엇을 표현할 지에 대한 책임만 가지고 있어 Success 케이스 이외에도 Loading, Error 에 대한 케이스도 직관적으로 표현이 가능해 가독성이 증가하는 이점이 있습니다.

성능에 더욱 집착하기

Compose 로 마이그레이션 하는 과정에서 기존에 동작하던 화면과 성능적인 측면에서 문제가 없는지 확인을해야 합니다. 생산적 요소를 감안하더라도 고객이 사용하는 화면에서 사용성이 저하된다면 비즈니스 관점에서는 Compose 를 사용하지 않는 것이 더 좋다는 팀의 합의점도 존재하기 때문입니다.

ViewHolder 에서 ComposeView 사용

Compose 1.2 버전부터는 리스트를 다루는 기능들에 많은 개선점이 생겼습니다. 기존에 ViewHolder 에서 ComposeView 를 사용할 때 수동으로 dispose 하거나 윈도우에서 detach 되었을 때 불필요한 컴포지션이 발생했던 문제가 RecyclerView 1.3.0-alpha02 이상, compose-ui 1.2.0-beta02 이상 버전에서는 자동으로 pooling container 를 이용해 자동으로 처리를 도와줍니다. 저희 29CM Android 팀도 compose-ui 1.4.3Recyclerview 1.3.0-rc01 버전을 사용하여 성능에 대한 문제도 함께 챙겨나가고 있습니다.

Key와 ContentType 사용

Compose 1.2 부터 Lazy 레이아웃의 퍼포먼스를 극대화 하기 위해서 contentType 을 추가할 수 있습니다. contentType 이 지정되어있으면 동일 type 의 item 인경우 composition 을 재사용 할 수 있어 LazyLayout 의 성능의 이점을 극대화 할 수 있습니다. 또한 Key 값을 이용해서 불필요한 Recomposition 을 줄일 수 있는데 검색 진입 화면의 경우 최근 검색어 — 모두 지우기 클릭 시 최근 검색어 타입이 제거된 후 재정렬하거나 여러 타입이 스크롤 될 때 Recomposition 과정을 최소화 할 수 있습니다.

contentTypekey 를 이용해 실제로 Recomposition 카운트를 측정한 결과 많이 개선 된 것을 볼 수 있습니다.

Preview 의 적극적인 활용

Compose 의 강력한 기능 중 하나인 Preview 는 개발 생산성에 도움을 줍니다. XML 에서는 모든 케이스에 대한 Preview 를 구현하기 어렵지만 Compose 는 하나의 Composable 함수 에서 여러 형태의 State 가 표시될 때 케이스들을 Preview 에서 확인할 수 있어 mock 데이터를 넣어 실제 디바이스 실행 환경에서 확인하지 않고도 구현할 수 있습니다.

예를들어 검색어 랭킹에 들어온 키워드가 매우 긴 상황이 발생할 수 있는데 해당 케이스에 맞게 말줄임 처리가 구현된 모습을 Preview 에서 확인할 수 있습니다.

또한 Preview 에서 제공하는 uiMode 에 다크모드로 지정하여 다크모드인 경우 화면에 어떻게 나타날지 표현도 가능합니다.

하지만 Preview 를 윈도우 창에서 표시하기 위해서는 빌드가 필수적으로 실행되어야 합니다. 새로운 파일을 추가하고 Composable 함수를 구현하거나 Preview Composable 의 수정이 발생했을 때 개발도구에서 리빌드를 요구할 때가 있습니다.

앱 모듈 사이즈가 작다면 Preview 코드들이 앱 모듈에 있더라도 금방 빌드가 되겠지만 사이즈 큰 경우에는 작은 UI 수정/추가에도 긴 빌드타임을 감내해야 합니다.

29CM Android 팀은 작년부터 클린 아키텍처를 기반으로 모듈화를 진행해 오고 있는데 검색 진입 Presentation 코드들도 모듈로 구성되어있어 Preview 를 수정하거나 추가하더라도 부담없는 빌드타임이 유지하고 있습니다.

앱 모듈에서 Preview 의 빌드타임
검색 모듈에서 Preview 의 빌드타임

Compose 도입의 효과

검색 진입 화면을 Compose 로 전환하고 이후 카테고리 브랜드 랭킹을 고객들에게 노출시키는 실험을 진행했었고 기존 Composable 함수를 재사용했기 때문에 빠르게 실험코드를 구현한 경험을 할 수 있었습니다.

대조군에서 브랜드 랭킹을 영역을 표시하기 위해 다음과 같은 코드로 작성되어 있습니다.

@Composable
fun SearchBrandPage(..) {
Column(..) {
Header(..)
HorizontalPager(..)
Indicator(..)
}
}

브랜드 랭킹 컴포넌트는 타이틀 헤더가 포함된 하나의 Composable 함수에서 관리하도록 작성했었는데 대조군 코드와 실험군 코드에서 동일한 UI 영역은 재사용할 수 있도록 브랜드 랭킹의 일부분을 별도의 Composable 분리하는 작업이 필요했습니다.

먼저, HorizontalPager 와 Indicator 를 BrandPage 라는 Composable 함수로 분리한 후 대조군 코드에는 SearchBrandPage Composable 함수에 Header 와 BrandPage를 선언하고, 실험군 코드에는 SearchCategoryBrandPageExperiment Composable 함수를 생성 후 Column 스코프에 Text() 와 BrandPage Composable 함수를 선언하는 것으로 개발시간을 단축하고 빠르게 실험 코드 구현할 수 있었습니다.

@Composable
fun SearchBrandPage(..) {
Column(..) {
Header(..)
BrandPage(..)
}
}

@Composable
fun SearchCategoryBrandPageExperiment(..) {
Column(..) {
Text(..)
BrandPage(..)
}
}

@Composable
fun BrandPage(..) {
HorizontalPager(..)
Indicator(..)
}

기존코드에서 XML 기반의 작업을 했더라면 재사용을 위한 커스텀 UI 구현한 후 실험군 Type 을 추가하고 ViewHolder 를 생성하는 등 적지 않은 개발비용이 발생했을 텐데 이번 실험으로 인해 Compose 도입이 개발 생산성이 향상되었다고 체감할 수 있었습니다.

마치며

Jetpack Compose 를 도입부터 적용하기까지의 과정을 소개해 드렸는데요. 도입을 위해 동료들을 설득하는 과정부터 실제 코드로 반영되어 배포되기까지 쉬운 과정은 아니었지만 개발 생산성 향상과 함께 상태 관리를 보다 더 직관적으로 할 수 있게 되며 Compose 도입의 효과를 누리고 있습니다 :)

저희는 앞으로 모든 화면을 Jetpack Compose 로 변경하는 것을 목표로 하고 있고, Compose 전환과 함께 화면 간 전환에 대해서도 Navigation 컴포넌트를 적용하는 것 역시 목표로 하고 있습니다.

이 글에서 소개해 드린 내용처럼 상태 관리와 개발 생산성 향상을 위해 Jetpack Compose 도입을 고려하고 계신다면 이 글이 도움이 되셨으면 좋겠습니다.

[함께 성장할 동료를 찾습니다]

29CM (무신사) 는 3년 연속 거래액 2배의 성장을 이루었습니다.

Android 팀은 여러 도메인을 고도화하고 고객에게 더 나은 가치를 제공하기 위해 Kotlin, Coroutine, Clean Architecture, Jetpack Compose 등 비즈니스 성장을 위한 기술을 도입해왔고 웹뷰 고도화, Navigation 컴포넌트 멀티 모듈등 여러 도전 과제들과 함께 고민들을 나누고 같이 해결해 나갈 Android 엔지니어를 찾습니다.

많은 지원 부탁드립니다!

--

--