안드로이드 개발자 노트
Jetpack Compose 이해하기 본문
Compose는 프런트엔드 뷰를 명령형으로 변형하지 않고도 앱 UI를 렌더링할 수 있게 하는 선언형 API를 제공합니다.
- 선언형 UI: "UI가 어떻게 보일지를 선언"하고, 상태(State) 변화에 따라 자동으로 갱신함
- 명령형 UI: "UI를 어떻게 변경할지를 직접 명령"해야 하며, 상태 변화에 따라 명시적으로 UI를 갱신해야 함
선언형 프로그래밍 패러다임
기존 Android UI는 명령형 방식으로, findViewById()로 UI 요소를 찾고 button.setText() 등의 메서드로 직접 변경해야 했습니다. 그러나 이 방식은 업데이트 누락, 충돌, 상태 불일치 등의 문제가 발생하기 쉽고, 관리가 복잡해집니다.
최근에는 선언형 UI 모델이 도입되면서 UI 업데이트가 훨씬 간결해졌습니다. 이 방식은 화면 전체를 개념적으로 다시 생성한 후, 필요한 부분만 갱신하여 복잡성을 줄입니다. Jetpack Compose는 이러한 선언형 UI 프레임워크로, UI 변경을 자동으로 처리해 유지보수를 쉽게 만듭니다.
다만, 화면을 다시 그리는 것은 성능과 배터리 소모에 영향을 줄 수 있습니다. 이를 최적화하기 위해 Compose는 필요한 부분만 선택적으로 재구성(Recomposition) 하여 성능을 효율적으로 관리합니다.
Composable 함수 개요
@Composable
fun Greeting(name: String) {
Text("Hello $name")
}
- @Composable 애노테이션을 사용해 Compose 컴파일러에 UI를 생성하는 함수임을 알립니다.
- 매개변수를 통해 데이터를 받아 UI를 구성하며, 다른 @Composable 함수를 호출해 UI 계층 구조를 형성합니다.
- UI 상태를 설명할 뿐, 값을 반환하지 않으며 부작용이 없습니다.
- 동일한 입력값으로 호출될 때 항상 같은 결과를 반환해야 하며, 전역 변수나 random() 호출과 같은 외부 값을 사용하지 않습니다.
이러한 특성 덕분에 Compose는 빠르고 예측 가능한 UI 업데이트가 가능합니다.
선언형 패러다임 전환
기존 명령형 UI에서는 위젯이 객체로 관리되며, XML을 통해 UI를 초기화하고 getter/setter로 상태를 변경합니다.
반면, Jetpack Compose의 선언형 UI에서는 위젯이 객체가 아니라 데이터에 의해 생성되며, setter/getter 없이 동일한 @Composable 함수를 새로운 데이터와 함께 호출하여 UI를 업데이트합니다.
이 방식은 ViewModel 등의 아키텍처 패턴과 쉽게 연동되며, 사용자가 UI와 상호작용하면 이벤트를 앱 로직으로 전달해 상태를 변경하고, 새로운 상태로 UI를 다시 그리는 과정(재구성)이 자동으로 이루어집니다.
동적 콘텐츠
Jetpack Compose의 @Composable 함수는 XML이 아닌 Kotlin 코드로 작성되므로 동적 UI를 쉽게 구현할 수 있습니다.
예를 들어, Greeting() 함수는 이름 목록을 받아 반복문을 사용해 각 사용자에 대한 인사말을 생성합니다.
@Composable
fun Greeting(names: List<String>) {
for (name in names) {
Text("Hello $name")
}
}
이처럼 Compose에서는 if 문, 루프, 도우미 함수 등 Kotlin의 유연한 기능을 활용하여 정교한 UI를 동적으로 구성할 수 있습니다. 이는 Jetpack Compose의 큰 장점 중 하나입니다.
Recomposition(재구성)
기존 명령형 UI에서는 setter를 사용해 위젯의 상태를 직접 변경해야 했습니다. 반면, Compose에서는 새 데이터를 사용해 @Composable 함수를 다시 호출하여 UI를 업데이트합니다. 이 과정에서 변경된 부분만 자동으로 다시 그려지는 것을 재구성(Recomposition)이라고 합니다.
재구성의 원리
- 입력 값이 변경될 때만 해당 @Composable 함수가 다시 실행됨
- 값이 변경되지 않은 함수나 람다는 재구성되지 않음 → 성능 최적화
- 전체 UI를 다시 그리는 것이 아니라 필요한 부분만 선택적으로 갱신
Side Effects(부작용)
Jetpack Compose에서는 @Composable 함수가 재구성(Recomposition) 될 때 예측 가능한 동작을 유지해야 합니다. 하지만, 함수 내부에서 상태를 직접 변경하거나, 앱의 전역 상태에 영향을 주는 작업을 하면 예측할 수 없는 동작이 발생할 수 있습니다. 이런 동작을 부작용(Side Effects)이라고 하며, 다음 작업은 모두 위험한 부작용입니다.
- 공유 객체의 속성에 쓰기
- ViewModel에서 식별 가능한 요소 업데이트
- 공유 환경설정 업데이트
- 애니메이션처럼 자주 실행되는 함수는 가벼워야 하며, 무거운 작업은 백그라운드 코루틴을 사용해 처리
올바른 해결 방법으로, 상태 변경은 ViewModel을 통해 처리하고 공유 환경설정 등의 데이터는 백그라운드에서 읽고 UI에 전달하면 됩니다.
@Composable
fun SharedPrefsToggle(
text: String,
value: Boolean,
onValueChanged: (Boolean) -> Unit
) {
Row {
Text(text)
Checkbox(checked = value, onCheckedChange = onValueChanged)
}
}
재구성은 가능한 한 많이 건너뜀(재구성 최적화)
Compose는 UI의 변경된 부분만 선택적으로 재구성하여 성능을 최적화합니다.
즉, 필요한 부분만 다시 실행하고, 변경되지 않은 요소는 건너뜁니다.
아래 코드는 header가 변경되면 텍스트만 다시 렌더링되며, names가 변경되지 않으면 LazyColumn 내부는 건너뜁니다.
@Composable
fun NamePicker(
header: String,
names: List<String>,
onNameClicked: (String) -> Unit
) {
Column {
// header가 변경될 때만 재구성됨
Text(header, style = MaterialTheme.typography.bodyLarge)
HorizontalDivider()
// names가 변경되지 않으면 LazyColumn은 재구성되지 않음
LazyColumn {
items(names) { name ->
NamePickerItem(name, onNameClicked)
}
}
}
}
@Composable
private fun NamePickerItem(name: String, onClicked: (String) -> Unit) {
Text(name, Modifier.clickable(onClick = { onClicked(name) }))
}
낙관적인 재구성
프로그래밍에서 낙관적(Optimistic)이란, 일정한 가정을 기반으로 먼저 실행을 시도한 후, 만약 예상과 다르게 상황이 변하면 실행을 취소하거나 다시 수행하는 방식을 의미합니다.
재구성이 취소되면 Compose는 재구성에서 UI 트리를 삭제합니다. 표시되는 UI에 종속되는 부작용이 있다면 구성이 취소된 경우에도 부작용이 적용됩니다. 이로 인해 일관되지 않은 앱 상태가 발생할 수 있습니다.
낙관적 재구성을 처리할 수 있도록 모든 @Composable 함수 및 람다가 멱등원이고 부작용이 없는지 확인해야 합니다.
@Composable 함수는 매우 자주 실행될 수 있음
@Composable 함수는 UI 애니메이션의 모든 프레임마다 실행될 수 있습니다.
이때, 기기 저장소에서 데이터를 직접 읽거나 무거운 작업을 수행하면 UI가 버벅거릴 수 있습니다.
예를 들어 위젯이 기기 설정을 읽으려고 하면 잠재적으로 이 설정을 초당 수백 번 읽을 수 있으며 이는 앱 성능에 치명적인 영향을 줄 수 있습니다.
해결 방법
- @Composable 함수에서 직접 데이터 접근을 하지 말고, 필요한 데이터를 매개변수로 전달
- 기기 저장소 접근과 같은 무거운 작업은 별도의 스레드에서 실행
- Compose와의 연동을 위해 mutableStateOf 또는 LiveData를 사용해 UI를 갱신
@Composable 함수는 동시에 실행할 수 있음
Compose는 @Composable 함수를 백그라운드 스레드에서 실행할 수 있으며, 여러 스레드에서 동시에 호출될 수 있습니다.
@Composable 함수는 부작용(Side Effect) 없이 동작해야 하며, 부작용은 onClick과 같은 UI 콜백에서 처리해야 합니다.
@Composable 람다 내부에서 변수를 수정하면 스레드 안전성이 보장되지 않으므로 피해야 합니다.
아래 예제는 안전한 코드입니다.
@Composable
fun ListComposable(myList: List<String>) {
Row(horizontalArrangement = Arrangement.SpaceBetween) {
Column {
for (item in myList) {
Text("Item: $item")
}
}
Text("Count: ${myList.size}")
}
}
하지만 다음 코드는 부작용을 발생시키므로 잘못된 예제입니다.
@Composable
fun ListWithBug(myList: List<String>) {
var items = 0 // ❌ 상태 변경 금지!
Row(horizontalArrangement = Arrangement.SpaceBetween) {
Column {
for (item in myList) {
Card {
Text("Item: $item")
items++ // ❌ 스레드 안전하지 않음
}
}
}
Text("Count: $items")
}
}
@Composable 함수는 순서와 관계없이 실행할 수 있음
일반적으로 코드를 작성할 때 위에서 아래로 순서대로 실행된다고 생각하지만, Jetpack Compose에서는 반드시 그런 것이 아닙니다.
Compose는 UI를 최적화하기 위해 일부 구성 가능한 함수를 순서와 관계없이 실행할 수 있습니다.
예를 들어, 여러 개의 @Composable 함수를 포함하는 경우, Compose는 특정 UI 요소를 먼저 그릴 수 있습니다.
이는 성능 최적화를 위한 기능이며, UI 요소마다 우선순위가 다를 수 있기 때문입니다.
@Composable
fun ButtonRow() {
MyFancyNavigation {
StartScreen()
MiddleScreen()
EndScreen()
}
}
위 코드에서 StartScreen(), MiddleScreen(), EndScreen()은 작성된 순서대로 실행될 것처럼 보이지만,
Compose는 최적화 과정에서 순서를 다르게 실행할 수 있습니다.
즉, StartScreen()이 실행된 후 변경한 값을 MiddleScreen()에서 바로 활용할 수 있다고 가정하면 안 됩니다.
https://developer.android.com/develop/ui/compose/mental-model?hl=ko
'Android' 카테고리의 다른 글
Compose의 단계 (0) | 2025.02.09 |
---|---|
[Android] Android KTX 잘 알아보기 (0) | 2023.01.22 |
[Android] MVVM AAC ViewModel 잘 알아보기 (0) | 2023.01.08 |