
📍 개발하면서 잊지 말아야 할 컴포즈 관련 핵심 개념들을 블로그에 기록해나갈 예정이다!
명령형 프로그래밍은, UI를 어떻게 그려야 하는지 단계별로 설명하는 방식이다.
아래와 같은 UI를 화면에 표시하려면 어떻게 해야 될까?

뷰 기반의 방식에서는, 우선 xml에서 UI를 정의하고

코드에서 findBiewById() 또는 ViewBinding으로 UI 트리 상의 뷰를 찾고

setter 함수를 호출하여, 원하는 UI 상태를 표시한다.

이벤트로 인해 UI 상태가 변경되는 경우, 해당 뷰에 대한 리스너를 설정하고 그에 영향을 받는 다른 뷰를 명시적으로 변경해야 한다. 이렇게 수동으로 뷰를 업데이트 하면 에러가 발생하기 쉽다.
- 데이터를 여러 위치에서 렌더링 할 때, 특정 뷰의 업데이트를 잊어버린 경우
- 방금 삭제된 UI 요소를 수정하려는 것처럼, 하나의 뷰에 대한 업데이트가 예기치 않게 충돌하는 경우
일반적으로 업데이트가 필요한 뷰의 개수가 많아질수록, 이러한 명령형 프로그래밍 방식은 소프트웨어 유지관리의 복잡성이 증가하게 된다.

Compose에서는 UI를 그릴 때부터 Kotlin을 사용하므로, xml과 코드 사이를 이동할 필요가 없다.
Compose에서 UI 요소는 객체가 아닌 함수이다. 이는 UI 요소에 대한 참조가 불가능하며, 해당 항목을 변경하는 메서드를 호출할 수 없음을 의미한다. 대신, UI 요소는 함수에 전달하는 상태 또는 인수에 의해 완전히 제어된다.
🌟 컴포즈는 UI를 어떻게 그려야 하는지 단계별로 명령하지 않으며, 무엇을 그려야 하는지만 선언한다.
Construct UI by describing what, not how.
UI가 작동하는 데 필요한 상태를 제공함으로써, UI가 어떤 모습이어야 하는지 선언하는 것이다!
@Composable
fun Greeting(name: String) {
Text("Hello $name")
}
@Composable 어노테이션으로 지정된다. 이를 통해 컴포즈 컴파일러에게 이 함수는 데이터를 UI로 변환하는 함수라는 걸 알릴 수 있다. 
앱 로직은 최상위의 컴포저블 함수에 데이터를 제공한다.
컴포저블 함수는 해당 데이터를 받아 다른 컴포저블을 호출함으로써 UI를 형성한다. (State는 UI를 제어한다.)
그리고 적절한 데이터를 해당 컴포저블 및 계층 구조 아래로 전달한다.

사용자가 UI 요소와 상호작용하며 이벤트가 트리거되면, 앱 로직이 해당 이벤트에 응답한다.
그러면 컴포저블 함수는 새 데이터와 함께 다시 호출되며, UI 요소가 다시 그려진다. (Event는 State를 제어한다.)
이러한 프로세스를 Recomposition 이라고 부른다.
컴포저블 함수는 XML이 아닌 Kotlin으로 작성되므로 다른 Kotlin 코드와 마찬가지로 동적일 수 있다.
예를 들어 사용자 목록으로 환영하는 UI를 빌드한다고 가정해보자.
@Composable
fun Greeting(names: List<String>) {
for (name in names) {
Text("Hello $name")
}
}
이처럼 Kotlin 언어가 갖는 유연성을 완전히 활용할 수 있다.
명령형 UI 모델에서 위젯을 변경하려면, 위젯에서 setter 함수를 호출하여 내부 상태를 명시적으로 변경해야 한다.
반면에, 컴포즈에서는 새 데이터를 사용하여 컴포저블 함수를 다시 호출하면 된다. 이를 통해 함수가 재구성되며, 필요한 경우 함수에서 내보낸 위젯이 새 데이터로 다시 그려진다. 이러한 프로세스를 Recomposition이라고 부른다.
전체 UI 트리를 재구성 하는 작업은 컴퓨팅 성능 및 배터리 수명을 사용한다는 점에서 비용이 많이 들 수 있다. Compose는 지능적인 재구성(intelligent recomposition)을 통해 이 문제를 해결한다.
Compose는 새 입력을 기반으로 재구성할 때, 변경되었을 수 있는 함수 또는 람다만 호출하고 나머지는 건너뛴다! 이를 통해 재구성에 드는 비용을 최소화 할 수 있다!
이렇게 리컴포지션을 스킵하면 컴포저블 함수는 언제, 몇 번 호출될지 보장할 수 없다. 따라서, 컴포저블 내부에서 앱의 상태나 외부 시스템을 변경하는 행동(=부작용)은 예측 불가능한 오류를 일으킬 수 있다.
부작용(Side Effect)의 예시는 다음과 같다.
- 공유 객체의 프로퍼티에 대한 쓰기 작업
someSharedObject.value = ...- ViewModel에서 관찰 가능한 요소 업데이트
viewModel.someLiveData.value = ...- SharedPreferences 업데이트
sharedPreferences.edit().putString("key", "value").apply()
이러한 작업은 모두 앱의 다른 부분에도 영향을 주기 때문에, Compose는 SideEffect, LaunchedEffect, DisposableEffect 같은 명시적인 부작용 API를 제공한다.
컴포저블 함수는 애니메이션이 렌더링 될 때와 같이 모든 프레임에서 같은 빈도로 재실행될 수 있다. 애니메이션 도중에 버벅거림을 방지하려면 컴포저블 함수가 빨라야 한다.
SharedPreferences에서 읽기 작업을 수행하는 것처럼 비용이 많이 드는 작업은, 백그라운드 코루틴에서 실행하고 그 결과를 컴포저블 함수에 매개변수로 전달해야 한다.
예를 들어, 아래 컴포저블 함수 자체에서는 SharedPreferences에 대한 읽기, 쓰기 작업을 수행해서는 안 된다. 그 대신, 해당 작업을 백그라운드 코루틴의 ViewModel로 이동시킨다. 그리고 컴포저블 함수에서는 콜백과 함께 값을 전달하여 ViewModel의 업데이트 함수를 트리거한다.
@Composable
fun SharedPrefsToggle(
text: String,
value: Boolean,
onValueChanged: (Boolean) -> Unit
) {
Row {
Text(text)
Checkbox(checked = value, onCheckedChange = onValueChanged)
}
}
재구성을 지원하는 컴포저블 함수가 갖는 특징에 대해 알아보자. 어떤 경우든 best practice는 컴포저블 함수를 빠르고, 멱등성이며, 부작용이 없도록 만드는 것이다.
@Composable
fun ButtonRow() {
MyFancyNavigation {
StartScreen()
MiddleScreen()
EndScreen()
}
}
위의 코드에서 StartScreen, MiddleScreen, EndScreen 함수는 순서대로 실행되지 않을 수 있다.
따라서, StartScreen이 설정한 전역 변수를 MiddleScreen에서 활용하는 등의 부작용을 실행할 수 없다.
이처럼 모든 컴포저블 함수는 독립적이어야 한다.
Compose는 컴포저블 함수를 병렬로 실행하여 재구성을 최적화 할 수 있다. 이렇게 하면 컴포즈가 멀티 코어를 활용하고, 화면에 표시되지 않는 컴포저블 함수는 낮은 우선순위로 실행할 수 있다.
이러한 최적화는 컴포저블 함수가 백그라운드 스레드 풀 내에서 실행될 수 있음을 의미한다. 컴포저블 함수가 ViewModel에서 함수를 호출하면, Compose는 동시에 여러 스레드에서 해당 함수를 호출할 수 있다.
앱이 올바르게 동작하려면, 모든 컴포저블 함수에 부작용이 없어야 한다. 대신, UI 스레드에서 항상 실행되는 onClick 같은 콜백에서 부작용을 트리거한다.
컴포저블 함수가 호출될 때, 호출자와 다른 스레드에서 호출이 발생할 수 있다. thread-safe한 코드를 작성하려면 (멀티 스레드 환경에서 공유 데이터의 일관성이 보장되도록 하려면) 컴포저블 함수의 지역 변수를 수정하는 코드는 피해야 한다. 이는 컴포저블 함수에서 허용되지 않는 부작용이기도 하다.
@Composable
fun ListComposable(myList: List<String>) {
Row(horizontalArrangement = Arrangement.SpaceBetween) {
Column {
for (item in myList) {
Text("Item: $item")
}
}
Text("Count: ${myList.size}")
}
}
위의 코드는 부작용이 없으며, 입력 목록을 UI로 변환한다.
@Composable
@Deprecated("Example with bug")
fun ListWithBug(myList: List<String>) {
var items = 0
Row(horizontalArrangement = Arrangement.SpaceBetween) {
Column {
for (item in myList) {
Text("Item: $item")
items++ // Avoid! Side-effect of the column recomposing.
}
}
Text("Count: $items")
}
}
위의 코드에서 items라는 지역 변수는 컴포저블이 재구성 될 때마다 수정된다.
따라서, 여러 스레드가 동시에 items 변수에 접근하여 쓰기 작업을 수행하면, 비일관적인 결과가 나올 수 있다.
UI의 일부가 잘못된 경우, Compose는 업데이트 해야 하는 부분만 재구성하기 위해 최선을 다한다.
/**
* 헤더와 함께 유저가 클릭할 수 있는 이름 목록 표시
*/
@Composable
fun NamePicker(
header: String,
names: List<String>,
onNameClicked: (String) -> Unit
) {
Column {
// [header]가 바뀔 때만 재구성 된다.
Text(header, style = MaterialTheme.typography.bodyLarge)
Divider()
// LazyColumn은 리사이클러뷰의 컴포즈 버전
// items()에 전달되는 람다식은 리사이클러뷰의 뷰홀더와 유사
LazyColumn {
items(names) { name ->
// 아이템의 [name]이 변경될 때, 해당 아이템의 어댑터가 재구성 된다.
// [header]가 변경될 때는 재구성 되지 않는다. (skip)
NamePickerItem(name, onNameClicked)
}
}
}
}
/**
* 유저가 클릭할 수 있는 하나의 아이템 표시
*/
@Composable
private fun NamePickerItem(name: String, onClicked: (String) -> Unit) {
Text(name, Modifier.clickable(onClick = { onClicked(name) }))
}
Compose는 컴포저블의 매개변수가 변경되었을 수 있다고 판단될 때마다 재구성을 트리거한다.
재구성은 낙관적이다. 즉, Compose는 매개변수가 다시 변경되기 전에 재구성을 완료할 것이라고 예상한다.
- 낙관적 (Optimistic): 미래에 대해 희망과 확신이 있는
- 낙관적 UI 관련 아티클: https://story.pxd.co.kr/1193
재구성이 완료되기 전에 매개변수가 변경되면, Compose는 재구성을 취소하고 새 매개변수를 사용하여 재구성을 다시 시작할 수 있다.
재구성이 취소되면 Compose는 재구성에서 UI 트리를 삭제한다. 표시되는 UI에 종속되는 부작용이 있다면, 재구성이 취소된 경우에도 부작용이 실행될 수 있다. 이로 인해 일관적이지 않은 앱 상태가 발생할 수 있다.
낙관적 재구성을 처리할 수 있도록, 모든 컴포저블 함수 및 람다가 멱등원이고 부작용이 없는지 확인해야 한다.
경우에 따라 컴포저블 함수는 UI 애니메이션의 모든 프레임에서 실행될 수 있다.
컴포저블 함수가 로컬 저장소에서 읽기와 같이 비용이 많이 드는 작업을 수행하면, 이 함수로 인해 UI 버벅거림이 발생할 수 있다. 예를 들어, 위젯이 디바이스 설정을 읽으려고 하면 1초에 수백번씩 해당 설정을 읽게 되어, 앱 성능에 치명적인 영향을 미칠 수 있다.
컴포저블 함수에 데이터가 필요하다면, 데이터의 매개변수를 정의해야 한다. 그리고 비용이 많이 드는 작업을 컴포저블 외부의 다른 스레드로 이동시키고, mutableStateOf(), LiveData 등을 사용하여 Compose에 데이터를 전달할 수 있다.