Jetpack Compose의 Composition, Recomposition, 수명주기
Jetpack Compose에서 Composable 함수가 실행되면 앱의 UI를 트리 구조로 구성하고, 이 트리 구조를 Composition이라고 합니다.
Composable의 수명 주기는 초기 Composition, Recomposition, Composition 종료로 정의됩니다.
- 초기 Composition : 앱이 처음 실행되거나 화면이 처음 그려질 때, Compose가 UI를 그리기 위해 어떤 Composable 함수를 호출했는지 트리 구조로 기록하는 과정
- Recomposition : 앱의 상태(State<T>) 변경 시, 해당 상태를 읽는 Composable 함수만 다시 실행해서 Composition 트리의 필요한 부분만 업데이트하는 과정
- Composition 종료 : Composable이 트리에서 제거되는 과정
Compable 함수의 Composition 시점을 알 수 있는 간단한 예제입니다. 이 코드를 실행한 뒤 버튼을 여러 번 클릭하면 Recomposition이 발생할 때마다 로그가 출력되는 것을 확인할 수 있습니다.
@Composable
fun LifecycleDemo() {
// count 상태가 변경될 때 마다 Recomposition
println("🔄 Recomposition: LifecycleDemo가 실행됨")
// 초기 Composition
LaunchedEffect(Unit) {
println("🌱 Composition: 최초 컴포저블이 실행됨")
}
// Composition 종료
DisposableEffect(Unit) {
println("🗑️ Composition에 추가됨")
onDispose {
println("❌ Disposal: 컴포저블이 트리에서 제거됨")
}
}
var count by remember { mutableStateOf(0) }
Column {
Text("Count: $count")
Button(onClick = { count++ }) {
Text("증가")
}
}
}
이처럼 Composable의 수명주기는 Activity나 Fragment의 Lifecycle과는 별도로 동작하고, Composition 트리는 오직 Recomposition으로만 수정이 가능합니다.
호출 사이트 (Call Site)
Composition 내에서 Composable 함수 인스턴스는 호출 사이트(call site)로 구분됩니다. 호출 사이트는 소스 코드에서 컴포저블 함수가 실제로 호출된 위치를 의미하고, 각각의 호출 사이트마다 별도의 인스턴스와 상태를 가집니다.
다음은 호출 사이트의 개념을 이해할 수 있는 코드입니다.
@Composable
fun LoginScreen(showError: Boolean) {
if (showError) {
LoginError() // 호출 사이트 1
}
LoginInput() // 호출 사이트 2
}
@Composable
fun LoginInput() {
println("LoginInput 실행됨")
}
@Composable
fun LoginError() {
println("LoginError 실행됨")
}
LoginScreen에서 LoginError는 showError가 true일 경우에만 호출되고, LoginInput은 항상 호출됩니다. 각 Composable 함수는 소스 코드 위치에 따라 인스턴스가 구분되기 때문에 만약 showError가 true로 바뀌어서 Recomposition이 이뤄지면 새롭게 호출되는 LoginError가 Composition 트리에 추가되고, 항상 같은 위치에서 호출되는 LoginInput은 인스턴스가 유지됩니다.
Recomposition에 추가 정보 추가하기
만약 동일한 호출 사이트에서 여러 번 Composable을 호출하면 Compose는 각 인스턴스를 고유하게 구분할 정보가 없기 때문에 호출 인덱스와 호출 사이트를 조합해 각 인스턴스를 식별합니다.
다음은 반복문 안에서 MovieOverview Composable을 여러 번 호출하는 코드입니다.
@Composable
fun MoviesScreen(movies: List<Movie>) {
Column {
for (movie in movies) {
MovieOverview(movie)
}
}
}
만약 MoviesScreen에서 목록의 맨 뒤에 새로운 MovieOverview가 추가된다면 기존에 있던 MovieOverview 인스턴스들의 목록 내 위치는 변하지 않았으므로 Compose는 이미 생성된 인스턴스를 재사용할 수 있습니다.
하지만 목록의 맨 앞에 새로운 MovieOverview가 추가되어 기존 아이템들의 위치가 모두 변경된다면 현재 코드에서는 각 Composabl 인스턴스를 재사용할 수 없어 전체 Composable 함수가 모두 다시 실행됩니다.
이처럼 목록의 중간이나 앞쪽에 아이템이 추가/삭제/이동된다면 인덱스 기반으로 관리하던 Composable 인스턴스의 구분이 꼬여서 기존 인스턴스를 재사용하지 못하고, 해당 구간의 모든 Composable이 Recomposition 됩니다.
key composable
key Composable 은 런타임 트리의 특정 부분을 식별할 수 있는 값을 지정합니다.
@Composable
fun MoviesScreenWithKey(movies: List<Movie>) {
Column {
for (movie in movies) {
key(movie.id) { // Unique ID for this movie
MovieOverview(movie)
}
}
}
}
key에 고유한 값을 지정한면 목록이 재정렬되거나 중간에 아이템이 추가/삭제되어도 각 MovieOverview 인스턴스가 movie.id 값과 매칭되어 효율적으로 재사용됩니다.
LazyComlumn 처럼 일부 Composable 함수에는 key 파라미터가 내장되어 있습니다.
@Composable
fun MoviesScreenLazy(movies: List<Movie>) {
LazyColumn {
items(movies, key = { movie -> movie.id }) { movie ->
MovieOverview(movie)
}
}
}
Recompositon이 스킵되는 경우
Compose는 Recompositon 시, 불필요한 Composable 함수 호출을 스킵하는 최적화 방식을 제공합니다.
다음과 같은 경우, 함수의 입력값이 이전과 동일하다면 Recomposition 시 해당 Composable 함수의 실행이 스킵됩니다.
- 모든 Primitive 타입 : Boolean, Int, Long, Float, Char 등
- 모든 함수 타입 (람다)
- 문자열
- Compose의 MutableState 등 Stable 타입
// Primitive 타입
@Composable
fun Greeting(name: String) {
Text("Hello, $name")
}
// MutableState 타입
@Composable
fun Counter(count: State<Int>) {
Text("Count: ${count.value}")
}
// 여러 Stable 타입 조합
@Composable
fun UserProfile(name: String, age: Int, onClick: () -> Unit) {
println("UserProfile recomposed!")
Row {
Text("$name ($age)")
Button(onClick = onClick) { Text("Click me") }
}
}
반면, 다음 같은 경우는 입력값이 바뀌지 않아도 Recomposition 시 항상 Composable 함수가 실행됩니다.
- 함수 반환 유형이 Unit이 아닌 경우
- @NonRestartableComposable,@NonSkippableComposable 어노테이션 사용
- Unstable 타입 파라미터 (mutable 프로퍼티를 가진 데이터 클래스, 인터페이스 등)
data class UnstableMovie(var title: String)
// mutable 프로퍼티를 가진 데이터 클래스
@Composable
fun MovieOverview(movie: UnstableMovie) {
println("MovieOverview recomposed!")
Text(movie.title)
}
// 반환 타입이 String
@Composable
fun ReturnString(): String {
println("ReturnString recomposed!")
return "Hello"
}
@Stable
@Stable 어노테이션은 Compose에서 해당 타입을 Stable 타입으로 간주하도록 표시하는 역할을 합니다. 이 어노테이션이 붙은 타입의 인스턴스가 변경되지 않았다면 Compose는 Recomposition 시, 해당 Composable 함수의 실행을 스킵할 수 있습니다.
잘못 사용할 경우, 실제로 값이 변경되었음에도 Compose가 이를 감지하지 못해 UI가 갱신되지 않거나 상태가 꼬이는 버그와 성능 저하가 발생할 수 있습니다. 따라서 @Stable 어노테이션은 반드시 값의 변경이 Compose에서 안정적으로 감지되고, 그 변경을 직접 관리할 수 있을 경우에만 사용해야 합니다.
- 모든 public 프로퍼티가 불변(val) 일 경우
- 가변(var) 프로퍼티라면 Compose가 상태로 관리해 값이 바뀔 때마다 변경을 감지할 수 있을 경우
// 모든 프로퍼티가 불변일 경우
@Stable
interface UiState<T : Result<T>> {
val value: T?
val exception: Throwable?
val hasError: Boolean
get() = exception != null
}
// 프로퍼티의 변경을 Compose가 감지할 수 있을 경우
@Stable
class MyUiState(
val name: String,
age: Int
) {
// 내부적으로 Compose 상태로 관리
private var _age by mutableStateOf(age)
var age: Int
get() = _age
set(value) {
_age = value // setter에서 Compose 상태를 통해 값 변경
}
}
@Composable
fun UserProfile(uiState: MyUiState) {
Column {
Text("이름: ${uiState.name}")
Text("나이: ${uiState.age}")
Button(onClick = { uiState.age++ }) {
Text("나이 증가")
}
}
}