들어가며
안녕하세요. LINE Android를 개발하고 있는 Service Client Dev 1 팀의 조세영입니다. 저희 팀은 최근 Yahoo!검색 모듈의 UI를 모두 Android의 선언형 UI 툴킷인 Jetpack Compose를 사용해 제작했습니다. Yahoo!검색 모듈은 검색 시 Yahoo!검색 엔진을 이용해 검색을 수행하는 모듈로, 검색어 저장과 검색어 추천, Yahoo!검색 등의 기능을 지원합니다. 아래는 작동 화면입니다.
이번 글에서는 Yahoo!검색 모듈에 Jetpack Compose를 도입하기 위해 어떤 고민을 했는지, 도입 후 안정적인 구조로 효율적으로 협업하기 위해 어떤 규칙을 설정했는지 살펴보겠습니다.
Jetpack Compose 등장
오랫동안 UI는 명령형 프로그래밍 방식으로 개발해 왔습니다. 명령형 프로그래밍 방식은 UI를 구축할 때 단계마다 어떻게, 어떤 순서로 작동해야 하는지 명시적으로 정의하는 방식으로 개발자가 직접 UI 상태와 작동을 관리합니다.
이 방식은 기능이 많지 않은 간단한 UI에서는 괜찮지만 기능이 많고 복잡해지면 문제가 발생합니다. 기능이 많고 복잡한 UI에는 여러 상태가 존재하며 상태 간 의존성도 복잡해지는데요. 이런 UI에 특정 동작을 추가하려면 각 상태를 모두 문제없이 처리하기 위해 많은 코드를 추가해야 하며, 그 과정에서 실수가 발생해 버그로 번질 가능성이 높아집니다.
이 문제를 해결하기 위해 선언형 UI 개발 패러다임이 등장했습니다. 사실 선언형 프로그래밍 자체는 새로운 것이 아닙니다. 선언형 프로그래밍은 컴퓨터에 어떤 일을 해야 하는지 지시하면 컴퓨터가 알아서 내부 알고리즘으로 결과물을 만드는 방식으로, 이미 SQL이나 HTML 등에서 사용해 왔습니다. 예를 들어 SQL을 작성해 실행하면 RDBMS가 내부에서 SQL 을 파싱 및 처리해서 결과를 반환하며, 우리는 RDBMS 내부에서 어떤 처리를 하는지 알 필요가 없습니다. 결과만 받습니다. 이와 마찬가지로 선언형 UI 개발에서는 개발자는 어떻게 보여야 하는지 상태만 정의하면 알아서 UI가 그려집니다. 더는 상태 변화를 개발자가 직접 처리하지 않아도 됩니다.
Android에서는 2021년 Jetpack Compose(이하 Compose)를 통해 선언형 UI 툴킷을 지원하기 시작했습니다. Compose는 출시 후 Android 개발자들에게 신선한 충격을 안겼습니다. 명령형 UI 툴킷으로는 복잡하게 개발해야 했던 커스텀 뷰를 Compose를 사용하면 단기간에 개발할 수 있었고, 애니메이션 처리도 너무 쉽게 가능했습니다. 코드는 짧아졌고, 선언적으로 작성됐기 때문에 어떻게 작동하는지 이해하기도 쉬웠습니다. 이처럼 Compose는 Android 개발 생산성을 대폭 향상시킬 수 있었기에 여러 회사에서 도입하기 시작했습니다. 예를 들어 트위터(현 X)는 UI를 전부 Compose로 교체했으며(참고), Meta에서 새로 개발된 Threads 앱은 UI를 대부분 Compose로 구성했습니다(참고).
Compose 도입 검토 - LINE 앱 안정성을 유지하기 위한 두 가지 조건
저희도 Compose 도입 관련 논의를 지속해 왔으며 일부 디버깅 메뉴나 뷰를 Compose로 만든 경우는 있었지만, 이번처럼 모듈 전부를 Compose로 개발한 사례는 없었습니다.
그동안 쉽게 도입하지 못했던 이유는, 전 세계에서 수억 명이 사용하는 메신저 앱인 LINE은 안정성이 가장 중요했기 때문입니다. LINE 앱은 사용자의 기기가 매우 다양하고, 전체 사용자 중 0.1%만 오류를 겪는다고 해도 수십만 명의 사람들이 오류를 겪는 앱입니다. 따라서 출시된 지 얼마 되지 않은 Compose를 도입하는 것은 쉽지 않았습니다.
그럼에도 생산성을 향상시키고 코드의 유지 보수성을 높일 수 있는 Compose는 분명히 도입할 필요가 있었습니다. 이에 안정성을 놓치지 않으면서 Compose를 도입하기 위해서 두 가지 조건을 정의했습니다.
- 기존 뷰를 Compose로 바꾸면 뷰의 상태 관리 방법을 바꿔야 하기 때문에 오류를 발생시킬 가능성이 있으므로 기존 뷰가 아닌 새로 만드는 뷰에서 사용해야 한다.
- 사용하는 컴포저블(Composable)의 안정성을 확인해야 한다.
첫 번째 조건을 살펴보겠습니다. Compose에서는 기존 뷰와 호환되도록 AndroidView
컴포저블과 ComposeView 컴포넌트를 제공하는데요. 저희는 특정 모듈의 일부 뷰에서만 Compose를 사용하면 뷰 중 일부는 명령형 방식으로, 일부는 선언적으로 작성되기 때문에 유지 보수 비용이 더욱 올라갈 것이라고 판단했습니다. 최소한 화면 단위(Activity 혹은 Fragment)에서는 같은 UI 구성 방식을 취하는 것이 좋다고 생각했습니다. 그렇다고 이미 안정적으로 작동하고 있는 기존 UI를 통째로 바꾸는 것은 배보다 배꼽이 더 큰 상황을 만드는 꼴이라서 적절하지 않다고 판단했는데요. 그때 Yahoo!검색 모듈 제작 요청이 들어왔고, 해당 모듈의 뷰를 모두 Compose로 구성하는 것으로 결정했습니다.
두 번째 조건인 컴포저블의 안정성 확인은 Yahoo!검색 모듈에 Compose를 도입하기 위해서 꼭 필요한 조건이었습니다. 이에 제작할 화면에서 사용할 컴포저블들을 정리하고 문제를 미리 파악하는 작업을 진행했고, LazyColumn
과 TextField
, 이 두 컴포저블에서 많은 이슈가 보고된 것을 확인했습니다. Yahoo!검색에서는 중첩 스크롤을 사용하지 않았기 때문에 LazyColumn 사용에는 문제가 없었지만, TextField
는 아래와 같이 여러 문제가 보고된 것을 확인했습니다.
따라서 이 부분을 AndroidView
를 이용해 기존 EditText
로 만들었을 때 상태 관리에 문제가 없을지 확인했고, 확인 결과 문제가 발생하지 않아 Compose 도입을 결정했습니다.
Compose 도입 - 구조적 안정성과 효율적인 협업을 위한 네 가지 규칙
Compose 도입 후 가장 먼저 진행한 것은 규칙을 만드는 작업이었습니 다. 아직 Compose를 사용한 적이 없었기 때문에 공식 기술 문서와 관련 글들을 참고해 하나부터 열까지 다 정해야 했으며, 작업 순서에 따라 다음 네 가지 규칙을 만들었습니다.
- UI 상태 관리 지점을 일원화하기
- 상태 호이스팅(state hoisting)을 사용해 컴포저블을 스테이트리스(stateless)하게 만들기
- 만든 컴포저블은 미리 보기(preview) 기능을 사용해 상태별로 미리 보기 만들기
- 풀 리퀘스트(pull request)에 미리 보기 첨부하기
왜 이 네 가지 규칙이 중요하다고 판단했고 어떻게 사용했는지 하나씩 살펴보겠습니다.
1. UI 상태 관리 지점을 일원화하기
기존 명령형 UI 툴킷에서는 UI 상태가 뷰에 있었습니다. 이에 따라 UI 상태를 ViewModel
에서 관리하더라도 ViewModel
에서 관리하는 UI 상태가 변경되면 뷰의 상태를 업데이트해야 했습니다.
예를 들어 mainViewModel
에서 화면 제목을 StateFlow<String>
타입의 titleStateFlow
로 관리한다면, 다음 코드와 같이 titleStateFlow
를 수집하는 코드를 만들어 textViewTitle
에 업데이트해야 했습니다.
class MainActivity : ComponentActivity() {
private val mainViewModel: MainViewModel by viewModels()
...
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
...
lifecycleScope.launch {
mainViewModel.titleStateFlow.collect {
textViewTitle.text = it
}
}
...
}
...
}
이런 코드의 문제는 UI 상태를 여러 곳에서 관리한다는 점입니다. 위 코드에서 textViewTitle
의 text
는 mainViewModel
에 있는 titleStateFlow
의 value
값과 같은 상태입니다. 하지만 textViewTitle
의 text
상태는 titleStateFlow
를 수집하는 부분 외에 다른 곳에서도 얼마든지 변경할 수 있습니다.
예를 들어 아래와 같이 한 줄만 추가해 textViewTitle
의 text
상태를 명시적으로 "Dummy Text"
로 변경하면 textViewTitle
의 text
상태 값과 mainViewModel
의 titleStateFlow
value
값의 일관성이 깨집니다.
class MainActivity : ComponentActivity() {
private val mainViewModel: MainViewModel by viewModels()
...
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
...
lifecycleScope.launch {
mainViewModel.titleStateFlow.collect {
textViewTitle.text = it
}
}
textViewTitle.text = "Dummy Text"
...
}
...
}
이처럼 상태를 여러 곳에서 수정하고 갱신하면 상태의 일관성이 깨질 수 있는데요. UI가 복잡해질수록 여러 곳에서 상태를 변경하거나 갱신하기 때문에 UI가 어떤 상태일지 예측하기 어려워집니다.
앞서 말씀드렸듯 Compose는 선언형 UI 툴킷입니다. 상태를 받아 UI를 만들고, 상태가 변경되면 Recomposition 과정을 거쳐 UI를 다시 구성합니다. 이 때문에 Compose에서는 그 어느 때보다 상태 관리가 중요합니다. 만약 명령형 UI에서처럼 상태를 여러 곳에서 관리한다면 추적하기 어려울 것입니다.
이에 저희는 Compose의 UI 상태 관리 지점을 일원화하기로 결정했고, 그 방법으로 ViewModel
로 UI 상태 관리 지점을 일원화하고 외부에 노출하는 방식을 선택했습니다. ViewModel
내부에서는 MutableStateFlow
을 사용해 데이터를 업데이트했고, 이 MutableStateFlow
를 StateFlow
로 타입 캐스팅해 외부에 데이터를 노출하도록 만들었습니다.
class YJSearchActivityViewModel(
... // Use Case 주입 받기
) : ViewModel() {
private val searchTextMutableStateflow: MutableStateFlow<TextFieldValue> =
MutableStateFlow(TextFieldValue(""))
val searchTextStateFlow: StateFlow<TextFieldValue> = searchTextMutableStateflow // 검색어 데이터 외부 노출
private val pageDataMutableStateFlow: MutableStateFlow<PageData> =
MutableStateFlow(PageData.Loading)
val pageDataStateFlow: StateFlow<PageData> = pageDataMutableStateFlow // 결과 페이지 데이터 외부 노출
...
}
그런데 여기서 문제가 발생했습니다. 만약 모든 컴포저블이 UI 상태를 가져오기 위해 ViewModel
을 직접 참조한다면, 각 컴포저블은 상태를 가져오기 위해 ViewModel
에게 StateFlow
구독을 요청해야 하므로 ViewModel
에 의존하게 됩니다. 즉, 다음과 같은 구조가 됩니다.
이런 구조에서는 각 컴포저블이 ViewModel
에 의존하기 때문에 Compose의 장점인 재사용성을 잃습니다. 이 의존성을 끊기 위해 상태 값을 ViewModel
에서 받아와 자식 컴포저블에 전달하는 역할을 담당하는 컴포저블을 만들었습니다.
아래 코드의 YJSearchResultPage
가 그 예시입니다. YJSearchResultPage
는 ViewModel
을 주입받아 collectAsState
를 통해 상태를 구독하고, 해당 상태를 자식 컴포저블들에 전달하는 역할을 합니다.
@Composable
fun YJSearchResultPage(
modifier: Modifier = Modifier,
yjSearchActivityViewModel: YJSearchActivityViewModel,
...
) {
val pageData by yjSearchActivityViewModel.pageDataStateFlow.collectAsState() // 이 데이터를 사용해 자식 컴포저블을 업데이트
...
}
이 방식으로 ViewModel
을 주입받는 상위 컴포저블을 제외한 하위 컴포저블들은 ViewModel
을 직접 참조하지 않도록 만들어 의존성을 끊었습니다.
위와 같이 상위 컴포저블이 자식 컴포저블들에게 상태를 전달하도록 만들기 위해서는 자식 컴포저블들이 직접 상태 관리를 하지 않도록 만들어야 합니다. 즉, 자식 컴포저블들을 모두 스테이트리스하게 만들어 상위 컴포저블에서 상태를 주입할 수 있게 만들어야 했고, 이를 위해 상태 호이스팅을 사용했습니다.
2. 상태 호이스팅을 사용해 컴포저블을 스테이트리스하게 만들기
상태 호이스팅(state hoisting)을 직역하면 '상태 끌어올리기(hoisting)'입니다. Compose에서 상태 호이스팅이란 자식 컴포저블의 상태를 부모 컴포저블로 끌어올려서 자식 컴포저블을 스테이트리스하게 만드는 패턴을 뜻합니다.
상태 호이스팅을 알아보기 위해 먼저 아래 예제 코드와 함께 스테이트풀(stateful) 컴포저블인 TitleTextField
를 살펴보겠습니다.
@Composable
fun TitleTextField(
modifier: Modifier = Modifier,
title: String
) {
var value by remember { mutableStateOf("") }
Column(modifier = modifier) {
Text(text = title)
TextField(
modifier = Modifier.fillMaxWidth(),
value = value,
onValueChange = { value = it }
)
}
}
TitleTextField
내부에서는 value
라는 String
타입 상태를 관리합니다. 이렇게 내부에서 상태를 관리하는 컴포저블을 스테이트풀 컴포저블이라고 합니다.
TitleTextField
를 스테이트리스하게 만들려면 TitleTextField
내부에서 직접 value
상태를 관리하지 않게 만들어야 합니다. 이때 상태 호이스팅을 사용해 value
상태를 상위 컴포저블로 끌어올릴 수 있습니다. value
상태를 끌어올리기 위해서는 해당 상태를 두 변수로 나눠야 합니다.
- value: T
- onValueChange: (T) -> Unit
value
는 상태 값이고 onValueChange
는 해당 상태를 바꾸는 람다식입니다. 만약 컴포저블에 상태를 바꾸는 작동이 없다면 onValueChange
는 필요하지 않지만, TitleTextField
의 value
는 가변 변수 var
로 선언돼 상태를 바꾸는 작동이 있기 때문에 둘 모두 필요합니다. 이 둘을 사용해 TitleTextField
를 다음과 같이 스테이트리스하게 바꿀 수 있습니다.
@Composable
fun TitleTextField(
modifier: Modifier = Modifier,
title: String,
value: String,
onValueChange: (String) -> Unit
) {
Column(modifier = modifier) {
Text(text = title)
TextField(
modifier = Modifier.fillMaxWidth(),
value = text,
onValueChange = { onValueChange(it) }
)
}
}
상태 호이스팅을 사용한 TitleTextField
에서는 더 이상 상태를 관리하지 않습니다. 부모 컴포저블이 상태를 주입하는 부분만 있습니다. 상태 호이스팅을 사용해 자식 컴포저블을 스테이트리스하게 만드는 방식은 Compose에서 많이 사용하는 방식입니다. 실제로 TitleTextField
안에서 사용한 TextField
에서도 볼 수 있습니다. TextField
는 value
를 통해 부모 컴포저블에게 상태를 전달받으며, onValueChange
함수를 호출해서 상태 변경 이벤트를 부모에게 알립니다. 전체 과정을 그림으로 표현하면 다음과 같습니다.
아래 그림은 상태 호이스팅을 사용해 만든 컴포저블의 전체 구조입니다. 자식 컴포저블들을 모두 스테이트리스하게 만들었고, ViewModel
에게 상태를 전달받는 공통 부모를 만들어 해당 부모 컴포저블이 ViewModel
에서 전달받은 상태를 자식 컴포저블들로 전달하도록 설계했습니다.
아래는 저희가 만든 컴포저블 중 하나입니다. 컴포저블 내부에서 어떤 상태도 관리하지 않는 것을 확인할 수 있습니다.
fun HistoryKeywordItem(
modifier: Modifier = Modifier,
historyKeyword: HistoryKeyword,
onHistoryKeywordClicked: (HistoryKeyword) -> Unit,
onDeleteHistoryKeywordClicked: (HistoryKeyword) -> Unit
) {
Row(
modifier = modifier
.clickableDefaultRipple {
onHistoryKeywordClicked(historyKeyword)
}
...
) {
...
Text(
...
text = historyKeyword.text,
....
)
...
DeleteHistoryIcon(
...
onIconClicked = {
onDeleteHistoryKeywordClicked(historyKeyword)
},
)
}
}
여기까지 저희가 Compose를 안정적으로 다루기 위해 자식 컴포저블들을 스테이트리스하게 만들고, 상태 관리 지점을 일원화한 방식을 살펴봤습니다. 다음은 조금 더 쉽고 편하게 협업하기 위해 컴포저블의 미리 보기를 어떻게 활용했는지 살펴보겠습니다.
3. 제작한 컴포저블은 미리 보기 기능을 사용해 상태별로 미리 보기 만들기
저희는 더욱 편하게 협업할 수 있도록 각자 작성하는 컴포저블의 미리 보기 작성을 표준화했습니다. 저희가 정한 미리 보기 작성 규칙은 다음과 같습니다.
- 컴포저블에서 가능한 각 상태별로 미리 보기 그리기
- 라이트 모드와 다크 모드 각각 미리 보기 만들기
먼저 첫 번째 규칙을 살펴보겠습니다. 기존 XML에서도 관련 도구를 사용해 text와 같은 일부 상태를 넣어 볼 수 있었지만 한 번에 하나의 값만 넣을 수 없었는데요. Compose는 하나의 미리 보기에 같은 컴포저블을 여러 개 만들어서 각각 서로 다른 상태 값을 넘겨 여러 상태를 미리 볼 수 있습니다. 이를 이용해 미리 보기에 같은 컴포저블을 여러 개 만든 후, UI 요구 사항에 따라 가능한 상태 값을 입력해서 요구 사항에 맞게 그려지는지 확인했습니다. 이렇게 제작한 미리 보기는 컴포저블을 제대로 만들었는지 확인하는 데 사용할 수 있을 뿐 아니라 협업하는 동료들이 해당 컴포저블을 쉽게 이해할 수 있도록 만드는 역할도 담당할 수 있었습니다.
다음으로 두 번째 규칙을 살펴보겠습니다. 기존에 XML을 사용할 때 다크 모드 색상이 적용되지 않은 부분이 종종 발견돼 QA 때 수정했던 경험이 있었는데요. 이를 해결하기 위해 아래 코드와 같이 @Preview
의 인자로 uiMode
를 사용해 기기가 다크 모드일 때의 모습을 미리 볼 수 있게 만들어 색상이 적용됐는지 쉽게 확인할 수 있도록 만들었습니다.
@Preview(
...
uiMode = Configuration.UI_MODE_NIGHT_YES
)
private fun HistoryKeywordItemPreviewDark() {
...
}
이 두 가지 규칙을 적용해 만든 미리 보기는 컴포저블 코드를 파악하는 시간을 줄이고, UI 상태별로 어떤 모양인지 단번에 파악할 수 있게 만들었습니다.
아래 그림은 미리 보기를 만드는 규칙을 적용한 HistoryKeywordItem
컴포저블입니다.
HistoryKeywordItem
은 Text
컴포저블이 그릴 수 있는 범위를 넘어서면 말줄임(Ellipsis) 기호가 나타나는 것이 요구 사항이었습니다. 이를 확인하기 위해 말줄임 기호가 나타날 수 있도록 충분히 긴 문자열과, 짧은 문자열 상태를 포함한 미리 보기를 만들었고, 그 아래에 다크 모드 미리 보기도 만들었습니다. 이를 통해 HistoryKeywordViewItem
컴포저블이 어떻게 생겼는지, 상태 값에 따라 어떤 모양을 지 니는지 한눈에 파악할 수 있습니다.
4. 풀 리퀘스트에 미리 보기 첨부하기
앞서 제작한 미리 보기는 디자인 요구 사항 요약과 디자인 스펙 링크와 함께 풀 리퀘스트에 첨부합니다. XML로 UI를 작성했을 때에는 어떤 UI를 그렸는지 설명하기 쉽지 않았지만, Compose의 미리 보기 기능을 사용하니 어떤 UI를 그렸는지 동료에게 설명하기 편했습니다. 리뷰 요청을 받아 제가 리뷰할 때에도 미리 보기를 통해 컴포저블의 모양을 파악한 다음 상태에 따라 어떻게 그려지는지에 집중해 코드를 보면 돼 편하게 리뷰할 수 있었습니다.
아래는 제가 실제로 만든 풀 리퀘스트 예시입니다(중요하지 않은 부분은 ... 처리했습니다).
위와 같이 풀 리퀘스트를 올리면 어떤 것을 개발했는지 한눈에 알아볼 수 있고 어떤 디자인 스펙을 어떻게 컴포저블에 반영했는지 쉽게 알아볼 수 있습니다. 따라서 리뷰어가 컴포저블 구현체를 보면서 리뷰하기가 더욱 편해집니다.
마치며
이번 글에서는 전 세계에서 수억 명이 사용하는 LINE 애플리케이션에 선언형 UI 툴킷, Jetpack Compose를 안전하게 도입하기 위해 어떤 고민을 하고 어떻게 시도했으며, 도입 후 보다 안정적인 구조로 효율적으로 협업하기 위해 어떤 규칙을 세웠는지 알아봤습니다. 저희 팀은 이런 과정을 통해 LINE 앱에 안정적으로 Compose를 도입할 수 있었는데요. Compose를 처음 사용해 봤지만 좋은 아키텍처로 만들 수 있었고, 쉽고 편하게 협업할 수 있는 기 반도 마련할 수 있었습니다. Compose가 얼마나 강력한지 이번에 직접 체험했기 때문에, 앞으로 Compose를 도입하는 것이 유용할 것으로 판단되는 곳에는 적극적으로 Compose를 도입할 계획입니다.
명령형 UI 툴킷에서 발생하는 수많은 문제를 선언형 UI 툴킷으로 해결할 수 있기에 선언형 UI 패러다임으로의 변화는 피할 수 없는 흐름입니다. 이 글이 Jetpack Compose 도입을 고민하는 많은 분께 도움이 되길 바라며 글을 마칩니다. 긴 글 읽어주셔서 감사합니다.