Compose
Jetpack Compose는 Android용 최신 선언형 UI 툴킷입니다. Compose는 선언형 API를 제공하여 앱의 UI를 명령형 방식으로 직접 변경하는 것이 아니라, 데이터 상태에 따라 UI를 렌더링하도록 만들어 줍니다. 이러한 개념과 용어는 추가 설명이 필요하지만, 앱 설계에 중요한 영향을 미칩니다.
선언형 프로그래밍 패러다임
기존의 안드로이드 UI 계층 구조는 UI 위젯들의 트리(Tree) 형태로 표현될 수 있었습니다. 사용자의 상호작용 등으로 인해 앱의 상태가 변경되면, UI 계층도 이를 반영하여 업데이트해야 합니다.
가장 일반적인 UI 업데이트 방식은 findViewById() 같은 함수로 트리를 탐색하고, button.setText(String), container.addChild(View), img.setImageBitmap(Bitmap)과 같은 메서드를 호출하여 UI 요소의 상태를 직접 변경하는 것입니다.
그러나 이러한 방식으로 뷰를 직접 조작하면 오류가 발생할 가능성이 높아집니다. 예를 들어, 동일한 데이터를 여러 곳에서 렌더링할 경우, 일부 UI 요소를 업데이트하는 것을 잊어버릴 수 있습니다. 또한, UI 업데이트 간의 충돌로 인해 잘못된 상태가 발생할 수 있습니다. 예를 들어, UI에서 제거된 요소의 값을 설정하려고 시도하는 상황이 발생할 수 있습니다. 일반적으로 업데이트해야 하는 뷰가 많아질수록 유지보수의 복잡성이 증가합니다.
최근 몇 년 동안, 개발 업계 전반에서 UI를 선언형 모델로 전환하는 추세가 이어지고 있습니다. 선언형 UI는 UI를 만들고 업데이트하는 과정을 훨씬 단순하게 만들어 줍니다. 이 기술은 기본적으로 화면을 처음부터 다시 생성한 후, 필요한 변경 사항만 적용하는 방식으로 동작합니다. 이 접근 방식은 상태를 가진 UI 계층을 수동으로 업데이트해야 하는 복잡성을 제거합니다. Compose는 이러한 선언형 UI 프레임워크입니다.
물론, 화면 전체를 다시 생성하는 것은 성능 면에서 비용이 많이 들 수 있습니다. 실행 속도, 연산 능력, 배터리 사용량 등의 측면에서 부담이 될 수 있기 때문입니다. 이를 해결하기 위해, Compose는 특정 시점에서 어떤 UI 요소가 다시 그려질 필요가 있는지를 지능적으로 판단하여 최적화된 방식으로 UI를 갱신합니다. 이에 따라, UI 컴포넌트를 설계할 때 리컴포지션(Recomposition)이 어떻게 이루어지는지를 고려해야 합니다.
간단한 Composable 함수
Compose를 사용하면 데이터를 받아 UI 요소를 생성하는 일련의 Composable 함수를 정의하여 사용자 인터페이스를 구축할 수 있습니다.
간단한 예로, 문자열을 받아 화면에 인사말을 표시하는 Greeting 위젯을 만들 수 있습니다.
이 함수에서 주목할 만한 몇 가지 사항
- 이 함수는 @Composable 애너테이션이 적용되어 있습니다.
모든 Composable 함수에는 이 애너테이션이 있어야 합니다.
이는 Compose 컴파일러에게 해당 함수가 데이터를 UI로 변환하는 역할을 한다는 것을 알리는 역할을 합니다. - 이 함수는 데이터를 전달받습니다.
Composable 함수는 매개변수를 받을 수 있으며, 이를 통해 앱 로직이 UI를 설명할 수 있습니다.
예제에서는 String 값을 받아 사용자에게 인사말을 표시합니다. - 이 함수는 UI에 텍스트를 표시합니다.
Text() Composable 함수를 호출하여 실제 텍스트 UI 요소를 생성합니다.
Composable 함수는 다른 Composable 함수를 호출하여 UI 계층을 구성합니다. - 이 함수는 값을 반환하지 않습니다.
UI를 생성하는 Compose 함수는 특정 값을 반환할 필요가 없습니다.
대신, 화면의 원하는 상태를 기술하는 방식으로 동작합니다. - 이 함수는 빠르고, 멱등성이 있으며, 부작용이 없습니다.
- 같은 인자를 여러 번 전달해 호출해도 동일한 동작을 수행합니다.
- 전역 변수나 random() 호출과 같은 외부 값을 사용하지 않습니다.
- UI를 설명하는 역할만 하며, 전역 변수 변경 등의 부작용을 발생시키지 않습니다.
일반적으로 모든 Composable 함수는 위와 같은 특성을 가져야 합니다.
이는 Recomposition(재구성)과 관련된 이유로 중요하게 작용합니다.
선언형 패러다임 전환
많은 명령형 객체 지향 UI 툴킷에서는 위젯 트리를 인스턴스화하여 UI를 초기화합니다. 일반적으로 XML 레이아웃 파일을 인플레이트하여 이를 수행합니다. 각 위젯은 자체 내부 상태를 유지하며, 앱 로직이 위젯과 상호 작용할 수 있도록 getter 및 setter 메서드를 노출합니다.
Compose의 선언형 접근 방식에서는 위젯이 상대적으로 상태를 가지지 않으며(Stateless), setter나 getter 함수도 제공하지 않습니다. 실제로 위젯은 객체로 노출되지 않습니다. UI를 업데이트하려면 동일한 컴포저블 함수를 다른 인수와 함께 다시 호출하면 됩니다. 이렇게하면 앱 아키텍처 가이드에서 설명하는 ViewModel과 같은 아키텍처 패턴에 상태를 쉽게 제공할 수 있습니다. 그런 다음 컴포저블 함수는 관찰 가능한 데이터가 업데이트될 때마다 현재 애플리케이션 상태를 UI로 변환하는 역할을 합니다.
사용자가 UI와 상호작용하면 onClick과 같은 이벤트가 발생합니다. 이러한 이벤트는 앱 로직에 전달되어 상태를 변경할 수 있습니다. 상태가 변경되면 컴포저블 함수가 새로운 데이터로 다시 호출되며, 이로 인해 UI 요소가 다시 그려집니다. 이 과정을 Rrecomposition이라고 합니다.
동적 콘텐츠
컴포저블 함수는 XML이 아닌 Kotlin으로 작성되므로, 다른 Kotlin 코드와 마찬가지로 동적일 수 있습니다. 예를 들어, 사용자 목록에 인사말을 표시하는 UI를 만들고 싶다고 가정해 보겠습니다.
@Composable
fun Greeting(names: List<String>) {
for (name in names) {
Text("Hello $name")
}
}
이 함수는 이름 목록을 받아 각 사용자에게 인사말을 생성합니다.
컴포저블 함수는 매우 정교하게 작성될 수 있으며, 특정 UI 요소를 표시할지 여부를 결정하는 if 문을 사용할 수도 있습니다. 또한, 반복문을 사용할 수 있으며, 보조 함수를 호출할 수도 있습니다. 즉, Kotlin 언어의 모든 기능을 활용할 수 있습니다.
이러한 강력한 기능과 유연성은 Jetpack Compose의 핵심 장점 중 하나입니다.
"동적(dynamic)이다"의 의미
변화하는 데이터나 조건에 따라 UI를 유연하게 생성하고 변경할 수 있다
리컴포지션(Recomposition)
명령형 UI 모델에서는 위젯을 변경하기 위해 해당 위젯의 setter 메서드를 호출하여 내부 상태를 변경합니다.
하지만 Compose에서는 컴포저블 함수를 새로운 데이터와 함께 다시 호출하여 UI를 업데이트합니다.
이렇게 하면 해당 함수가 재구성(recomposition)되며, 필요할 경우 UI 요소가 새로운 데이터로 다시 그려집니다.
Compose 프레임워크는 변경된 부분만을 지능적으 재구성할 수 있습니다.
예를들어, 다음과 같이 버튼을 표시하는 Composable 함수를 생각해보겠습니다.
@Composable
fun ClickCounter(clicks: Int, onClick: () -> Unit) {
Button(onClick = onClick) {
Text("I've been clicked $clicks times")
}
}
이 함수는 clicks 값을 받아 버튼이 눌린 횟수를 표시합니다.
사용자가 버튼을 클릭할 때마다 호출자가 clicks 값을 업데이트하면,
Compose는 Text 함수를 다시 호출하여 새로운 값을 화면에 표시합니다.
이 과정을 재구성(Recomposition)이라고 합니다.
하지만, 중요한 점은변경된 값과 관련이 없는 다른 함수들은 재구성되지 않는다는 것입니다.
앞서 논의했듯이 전체 UI 트리를 재구성하는 작업은 컴퓨팅 성능 및 배터리 수명을 사용한다는 측면에서 컴퓨팅 비용이 많이 들 수 있습니다.
Compose는 이 문제를 해결하기 위해 지능적인 재구성(Intelligent Recomposition)을 수행합니다.
재구성이란 입력 값이 변경될 때 컴포저블 함수를 다시 호출하는 과정입니다. Compose는 새로운 입력 값을 기반으로 변경 가능성이 있는 함수나 람다만 다시 호출하고, 나머지는 건너뜁니다. 이렇게 변경되지 않은 함수나 람다는 다시 호출하지 않음으로써, Compose는 UI를 효율적으로 재구성할 수 있습니다.
컴포저블 함수의 recomposition이 생략될 수 있으므로, 컴포저블 함수 실행의 부작용(Side-Effect)에 의존하지 않아야 합니다. 만약 부작용에 의존하면, 사용자에게 예상치 못한 이상한 동작이 발생할 수 있습니다.
부작용이란 애플리케이션의 다른 부분에서 볼 수 있는 모든 변화를 의미합니다. 예를 들어, 다음과 같은 작업들은 위험한 부작용에 해당합니다.
- 공유 객체의 속성을 변경하는 것
- ViewModel의 옵저버블 상태를 업데이트하는 것
- SharedPreferences를 업데이트하는 것
컴포저블 함수는 애니메이션이 렌더링될 때처럼 매 프레임마다 다시 실행될 수도 있습니다. 따라서 컴포저블 함수는 빠르게 실행되어야 하며, 그렇지 않으면 애니메이션이 끊기거나 지연될 수 있습니다.
만약 비용이 많이 드는 작업(예: SharedPreferences 읽기 등)이 필요하다면, 백그라운드 코루틴에서 실행하고, 그 결과 값을 컴포저블 함수의 매개변수로 전달해야 합니다.
예를 들어, 아래 코드는 SharedPreferences에서 값을 업데이트하는 컴포저블을 생성하는 코드입니다.
컴포저블 내부에서 직접 SharedPreferences를 읽거나 쓰는 것이 아니라, 백그라운드 코루틴에서 ViewModel을 통해 읽고 쓸 수 있도록 변경한 것입니다.
이렇게 하면 앱 로직이 현재 값을 전달하고, 콜백을 통해 업데이트를 트리거할 수 있습니다.
@Composable
fun SharedPrefsToggle(
text: String,
value: Boolean,
onValueChanged: (Boolean) -> Unit
) {
Row {
Text(text)
Checkbox(checked = value, onCheckedChange = onValueChanged)
}
}
이 문서는 Compose를 사용할 때 알아두어야 할 몇 가지 사항을 다룹니다.
- Recomposition은 가능한 한 많은 컴포저블 함수와 람다의 재실행을 건너뜁니다.
- Recomposition은 낙관적으로 수행되며, 취소될 수도 있습니다.
- 컴포저블 함수는 애니메이션의 매 프레임마다 실행될 수 있을 정도로 자주 호출될 수 있습니다.
- 컴포저블 함수는 병렬로 실행될 수 있습니다.
- 컴포저블 함수는 순서와 관계없이 실행될 수 있습니다.
모든 경우에서 최선의 방법은 컴포저블 함수를 빠르게 실행되도록 하고, 멱등성(idempotent)을 유지하며, 부작용(side-effect)을 발생시키지 않도록 하는 것입니다.
Recomposition은 가능한 한 많이 건너뛴다
UI의 일부가 잘못된 경우 Compose는 업데이트가 필요한 부분만 재구성(recomposition)하려고 합니다.
즉, UI 트리에서 특정 Button 컴포저블만 다시 실행하고, 그 위 또는 아래에 있는 다른 컴포저블들은 다시 실행하지 않고 건너 뛸 수 있습니다.
모든 컴포저블 함수와 람다는 독립적으로 recomposition될 수 있습니다.
아래 예제는 리스트를 렌더링할 때 일부 요소만 재구성될 수 있음을 보여줍니다.
/**
* Display a list of names the user can click with a header
*/
@Composable
fun NamePicker(
header: String,
names: List<String>,
onNameClicked: (String) -> Unit
) {
Column {
// this will recompose when [header] changes, but not when [names] changes
Text(header, style = MaterialTheme.typography.bodyLarge)
HorizontalDivider()
// LazyColumn is the Compose version of a RecyclerView.
// The lambda passed to items() is similar to a RecyclerView.ViewHolder.
LazyColumn {
items(names) { name ->
// When an item's [name] updates, the adapter for that item
// will recompose. This will not recompose when [header] changes
NamePickerItem(name, onNameClicked)
}
}
}
}
/**
* Display a single name the user can click.
*/
@Composable
private fun NamePickerItem(name: String, onClicked: (String) -> Unit) {
Text(name, Modifier.clickable(onClick = { onClicked(name) }))
}
각 스코프는 recomposition 시에 유일하게 실행되는 부분이 될 수도 있습니다.
예를 들어, header가 변경되면 Compose는 Column만 다시 실행하고, 그 부모 컴포저블은 건너뛸 수 있습니다.
또한 Column이 실행될 때 LazyColumn의 items는 names 값이 변하지 않았다면 건너뛸 수도 있습니다.
따라서 모든 컴포저블 함수와 람다는 부작용(side-effect)을 가지면 안 됩니다.
부작용이 필요한 경우에는 반드시 콜백을 통해 실행해야 합니다.
Recomposition은 낙관적으로 수행된다
Compose는 컴포저블의 매개변수가 변경되었을 가능성이 있다고 판단할 때마다 recomposition을 시작합니다.
Recomposition은 낙관적으로(optimistic) 수행되며, Compose가 recomposition을 완료하기 전에 매개변수가 다시 변경되지 않을 것이라고 예상합니다.
하지만, recomposition이 완료되기 전에 매개변수가 다시 변경되면, Compose는 현재 진행 중인 recomposition을 취소하고 새로운 매개변수로 다시 시작할 수 있습니다.
Recomposition이 취소되면 해당 recomposition에서 생성된 UI 트리는 폐기됩니다.
만약 UI가 표시되는 것과 관련된 부작용(side-effect)이 있다면, recomposition이 취소되더라도 부작용은 그대로 적용될 수 있습니다.
이로 인해 일관성 없는 앱 상태가 발생할 가능성이 있습니다.
따라서 모든 컴포저블 함수와 람다는 멱등성(idempotent)을 유지하고, 부작용이 없어야 합니다.
컴포저블 함수는 매우 자주 실행될 수 있다
일부 경우에서 컴포저블 함수는 UI 애니메이션의 매 프레임마다 실행될 수 있습니다.
만약 해당 함수가 비용이 많이 드는 작업(예: 기기 저장소에서 데이터 읽기 등)을 수행한다면, UI 지연(jank)이 발생할 수 있습니다.
예를 들어, 위젯이 기기 설정을 읽는 코드가 포함된 경우, 이 작업이 초당 수백 번 실행될 수도 있으며, 이는 앱 성능에 심각한 영향을 줄 수 있습니다.
컴포저블 함수가 데이터를 필요로 한다면, 직접 데이터를 가져오지 않고, 데이터 매개변수를 정의해야 합니다.
그 후 비용이 많이 드는 작업을 다른 스레드에서 수행하고, 그 결과를 mutableStateOf 또는 LiveData를 사용하여 Compose에 전달해야 합니다.
컴포저블 함수는 병렬로 실행될 수도 있다
참고: 현재 Compose에서는 컴포저블 함수가 병렬로 실행되지 않지만, 향후 멀티스레드 환경을 지원할 가능성이 있으므로 멀티스레드 방식으로 코드를 작성하는 것이 좋습니다.
Compose는 recomposition을 최적화하기 위해 컴포저블 함수를 병렬로 실행할 가능성이 있습니다. 이렇게 하면 멀티코어의 성능을 활용할 수 있으며, 화면에 표시되지 않는 컴포저블을 낮은 우선순위로 실행할 수도 있습니다.
이런 최적화가 적용되면, 컴포저블 함수가 백그라운드 스레드 풀에서 실행될 가능성이 생깁니다. 즉, ViewModel의 함수를 호출하는 경우, 여러 개의 스레드에서 동시에 해당 함수를 호출할 수도 있습니다.
애플리케이션이 올바르게 동작하도록 하려면 모든 컴포저블 함수는 부작용(side-effect)이 없어야 합니다. 부작용이 필요한 경우에는, onClick과 같은 UI 이벤트 콜백에서 트리거해야 합니다. 이러한 콜백은 항상 UI 스레드에서 실행되므로 안전합니다.
컴포저블 함수가 호출될 때, 기존 호출자와 다른 스레드에서 호출이 발될 수도 있습니다. 즉, 컴포저블 람다 내부에서 변수를 수정하는 코드는 피해야 합니다. 이러한 코드는 스레드 안전성이 없고, Compose가 허용하지 않는 부작용을 유발하기 때문입니다.
아래는 리스트와 개수를 표시하는 부작용이 없는 코드 예제입니다.
@Composable
fun ListComposable(myList: List<String>) {
Row(horizontalArrangement = Arrangement.SpaceBetween) {
Column {
for (item in myList) {
Text("Item: $item")
}
}
Text("Count: ${myList.size}")
}
}
이 코드는 입력 리스트를 UI로 변환하는 역할만 하므로 올바른 코드입니다. 그러나 로컬 변수를 수정하는 경우, 멀티스레드 환경에서 안전이 보장되지 않습니다.
아래 코드처럼 로컬 변수를 수정하는 것은 잘못된 방식입니다.
@Composable
fun ListWithBug(myList: List<String>) {
var items = 0
Row(horizontalArrangement = Arrangement.SpaceBetween) {
Column {
for (item in myList) {
Card {
Text("Item: $item")
items++ // Avoid! Side-effect of the column recomposing.
}
}
}
Text("Count: $items")
}
}
이 코드의 문제점은 items 변수를 수정하는 부분이 recomposition이 발생할 때마다 실행되며, 애니메이션 프레임마다 실행될 수도 있어 UI가 잘못된 카운트를 표시할 가능성이 높다는 점입니다. Compose는 이런 변수를 허용하지 않으며, 이는 멀티스레드 환경에서 안전한 코드 실행을 보장하기 위해서입니다.
결론적으로, Compose에서는 컴포저블 함수 내부에서 상태를 직접 변경하지 않고, 항상 UI 상태를 매개변수로 전달해야 합니다. 이렇게 하면 Compose가 자유롭게 스레드를 변경하면서 컴포저블 람다를 실행할 수 있습니다.
컴포저블 함수는 순서와 관계없이 실행될 수 있다.
참고: Compose는 현재 메인 스레드에서 실행되지만, 처음부터 멀티스레딩을 고려하여 설계되었으며, 단일 스레드 모델이 유지될 것이라는 보장은 없습니다. 따라서, 컴포저블 함수를 항상 멀티스레드 환경에서도 동작할 수 있도록 작성해야 합니다.
컴포저블 함수의 코드를 보면 작성된 순서대로 실행될 것이라고 가정할 수도 있지만, 이는 보장되지 않습니다.
컴포저블 함수 내부에서 다른 컴포저블 함수를 호출하는 경우, 이 함수들은 어떤 순서로든 실행될 수 있습니다.
Compose는 일부 UI 요소가 다른 요소보다 더 높은 우선순위를 가지는 것을 인식하고, 이를 먼저 렌더링할 수도 있습니다.
예를 들어, 아래와 같이 탭 레이아웃에서 세 개의 화면을 그리는 코드가 있다고 가정해 보겠습니다.
@Composable
fun ButtonRow() {
MyFancyNavigation {
StartScreen()
MiddleScreen()
EndScreen()
}
}
이 코드에서 StartScreen(), MiddleScreen(), EndScreen()의 호출 순서는 보장되지 않습니다.
즉, StartScreen()이 어떤 전역 변수(global variable)를 설정하고, MiddleScreen()이 해당 변수를 활용하도록 구현하면 안 됩니다.
각 함수는 독립적으로 동작해야 합니다.
끝.
참고자료
https://developer.android.com/develop/ui/compose/mental-model?hl=ko
Compose 이해 | Jetpack Compose | Android Developers
이 페이지는 Cloud Translation API를 통해 번역되었습니다. Compose 이해 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. Jetpack Compose는 Android를 위한 현대적인 선언
developer.android.com
'Android > Compose' 카테고리의 다른 글
[Android] Compose 상태관리 (0) | 2025.03.07 |
---|---|
[Android] Compose의 UI 렌더링 동작 매커니즘 (0) | 2025.03.07 |
[Android] Composable의 Lifecycle (0) | 2025.03.06 |
[Android] Compose를 사용해야하는 이유 (0) | 2025.03.06 |
[Android] Jetpack Compose란 (1) | 2023.09.18 |