이 페이지에서는 컴포저블의 생명주기(lifecycle)와 Compose가 컴포저블의 recomposition 필요 여부를 결정하는 방식에 대해 설명합니다.
컴포지션
Composition은 여러 개의 @Composable 함수가 컴포즈 UI 트리를 형성하는 과정 및 구조를 의미합니다.
즉, Composable 함수들이 조합(Composition)되어 UI를 구성하는 것입니다.
@Composable
fun MyApp() {
Column {
Greeting(name = "Alice")
Greeting(name = "Bob")
}
}
이 코드에서는 MyApp()이 Composition을 형성하는 역할을 하며, Greeting(name: String)이라는 @Composable 함수들이 조합되어 하나의 UI 구조를 형성합니다.
수명 주기 개요
Jetpack Compose가 최초로 컴포저블을 실행할 때(초기 composition), UI를 구성하는 컴포저블을 추적하고 Composition을 생성합니다. 이후, 앱의 상태가 변경되면 Jetpack Compose는 recomposition을 예약합니다.
Recomposition이란, 상태 변화에 따라 변경될 가능성이 있는 컴포저블을 다시 실행하고, Composition을 업데이트하여 변경 사항을 반영하는 과정입니다. Composition은 최초 composition을 통해서만 생성되며, recomposition을 통해서만 업데이트됩니다.
즉, Composition을 수정하는 유일한 방법은 recomposition입니다.
핵심 포인트: 컴포저블의 수명 주기는 다음과 같이 구성됩니다.
1. 컴포지션 시작,
2. 0회 이상 recomposition
3. Composition에서 제거
Recomposition은 일반적으로 State<T> 객체의 변경에 의해 트리거됩니다.
Compose는 이를 추적하여, 해당 State<T>를 읽는 모든 컴포저블과, 컴포저블 중 건너뛸 수 없는(composable that cannot be skipped) 모든 컴포저블들을 실행합니다.
참고: 컴포저블의 생명주기는 뷰(View), 액티비티(Activity), 프래그먼트(Fragment)의 생명주기보다 단순합니다.
따라서, 더 복잡한 생명주기를 가진 외부 리소스를 관리하거나 상호작용해야 하는 경우, side effect를 사용해야 합니다.
컴포저블이 여러 번 호출되면, Composition에 여러 개의 인스턴스가 생성됩니다.
각 호출된 컴포저블은 Composition 내에서 독립적인 생명주기를 가집니다.
@Composable
fun MyComposable() {
Column {
Text("Hello")
Text("World")
}
}
컴포지션 내 컴포저블의 분석
Composition 내에서 컴포저블의 인스턴스는 호출 위치(call site)에 의해 식별됩니다.
Compose 컴파일러는 각 호출 위치를 서로 다른 것으로 간주합니다.
즉, 서로 다른 위치에서 동일한 컴포저블을 호출하면, Composition 내에서 별도의 인스턴스가 생성됩니다.
핵심 용어: 호출 위치(Call Site)
호출 위치란 컴포저블이 호출된 소스 코드의 위치를 의미합니다.호출 위치는 컴포저블이 Composition 내에서 어디에 배치되는지를 결정하며,결과적으로 UI 트리에도 영향을 줍니다.
만약 recomposition 중에 컴포저블이 이전 composition과 다른 컴포저블을 호출한다면,
Compose는 새롭게 호출되었거나 호출되지 않은 컴포저블을 식별합니다.
그리고 두 composition에서 모두 호출된 컴포저블의 경우, 입력 값이 변경되지 않았다면 recomposition을 건너뜁니다.
예시를 살펴보겠습니다.
@Composable
fun LoginScreen(showError: Boolean) {
if (showError) {
LoginError()
}
LoginInput() // This call site affects where LoginInput is placed in Composition
}
@Composable
fun LoginInput() { /* ... */ }
@Composable
fun LoginError() { /* ... */ }
위 코드에서 LoginScreen은 조건에 따라 LoginError를 호출할 수도 있고, LoginInput은 항상 호출됩니다.
각 컴포저블 호출은 고유한 호출 위치(call site)와 소스 코드의 위치를 가지며,
Compose 컴파일러는 이를 활용하여 각 컴포저블을 개별적으로 식별합니다.
비록 LoginInput이 처음 호출되던 위치에서 두 번째로 호출되는 위치로 변경되었더라도, recomposition 동안 LoginInput 인스턴스는 유지됩니다.
또한, LoginInput의 매개변수가 recomposition 동안 변경되지 않았기 때문에, Compose는 LoginInput 호출을 건너뜁니다.
스마트 recomposition에 도움이 되는 정보 추가
컴포저블을 여러 번 호출하면, Composition에도 여러 번 추가됩니다.
같은 호출 위치(call site)에서 컴포저블을 여러 번 호출하면, Compose는 각 호출을 고유하게 식별할 수 있는 정보가 부족하기 때문에 호출 위치 외에도 실행 순서(execution order)를 사용하여 각 인스턴스를 구분합니다.
이 동작은 대부분의 경우 적절하게 작동하지만, 특정 상황에서는 원하지 않는 동작이 발생할 수도 있습니다.
@Composable
fun MoviesScreen(movies: List<Movie>) {
Column {
for (movie in movies) {
// MovieOverview composables are placed in Composition given its
// index position in the for loop
MovieOverview(movie)
}
}
}
위 코드에서 Compose는 호출 위치뿐만 아니라 실행 순서도 활용하여 Composition에서 MovieOverview 인스턴스를 구별합니다.
예를 들어, 새로운 영화가 리스트의 맨 아래에 추가되면, 리스트의 기존 요소들은 위치가 변경되지 않으므로,
Compose는 이미 Composition에 있는 인스턴스를 재사용할 수 있습니다.
그러나 movies 리스트가 리스트의 맨 위나 중간에 요소를 추가하거나, 항목을 제거하거나, 순서를 변경하면,
리스트에서 위치가 변경된 모든 MovieOverview 호출이 recomposition됩니다.
이는 특히 MovieOverview가 부작용(side effect)을 통해 영화 이미지를 가져오는 경우 매우 중요한 문제가 됩니다.
예를 들어, recomposition이 진행되는 동안 이미지 로딩이 실행 중이라면, 해당 작업은 취소되고 다시 시작됩니다.
@Composable
fun MovieOverview(movie: Movie) {
Column {
// Side effect explained later in the docs. If MovieOverview
// recomposes, while fetching the image is in progress,
// it is cancelled and restarted.
val image = loadNetworkImage(movie.url)
MovieHeader(image)
/* ... */
}
}
이상적으로, MovieOverview 인스턴스의 정체성(identity)은 전달된 movie 객체의 정체성과 연결되어야 합니다.
만약 영화 리스트의 순서를 변경하면, 각 MovieOverview를 새로운 movie 인스턴스로 다시 recomposition하는 것이 아니라, Composition 트리 내에서 해당 인스턴스의 순서를 재배치하는 것이 더 바람직합니다.
Compose는 런타임에 특정 UI 트리의 요소를 식별할 값을 지정할 수 있는 방법을 제공합니다.
바로 key 컴포저블입니다.
key 컴포저블을 사용하여 코드 블록을 감싸고, 하나 이상의 값을 전달하면 그 값이 해당 인스턴스를 식별하는 역할을 합니다.
key에 사용되는 값은 전역적으로 유일할 필요는 없으며, 단순히 해당 호출 위치(call site)에서의 컴포저블 인스턴스들 사이에서만 유일하면 됩니다.
예를 들어, 각 movie 객체가 리스트 내에서만 고유한 키를 가지면 충분하며, 앱의 다른 곳에서 동일한 키를 사용하는 것은 문제가 되지 않습니다.
@Composable
fun MoviesScreenWithKey(movies: List<Movie>) {
Column {
for (movie in movies) {
key(movie.id) { // Unique ID for this movie
MovieOverview(movie)
}
}
}
}
위 코드에서는 movie.id를 key로 설정하여 각 영화 객체를 고유하게 식별합니다.
이제 리스트의 요소가 변경되더라도, Compose는 각 MovieOverview 호출을 개별적으로 인식하고 재사용할 수 있습니다.
핵심 포인트: key 컴포저블을 사용하면 Compose가 컴포지션에서 컴포저블 인스턴스를 식별할 수 있습니다. 이 기능은 여러 컴포저블이 동일한 호출 사이트에서 호출되고 부수 효과 또는 내부 상태가 포함되어 있을 때 더욱 중요합니다.
일부 컴포저블은 key 컴포저블을 기본적으로 지원합니다.
예를 들어, LazyColumn은 items DSL에서 사용자 정의 키를 지정할 수 있도록 지원합니다.
@Composable
fun MoviesScreenLazy(movies: List<Movie>) {
LazyColumn {
items(movies, key = { movie -> movie.id }) { movie ->
MovieOverview(movie)
}
}
}
입력이 변경되지 않은 경우 건너뛰기
Recomposition 중에, 일부 컴포저블 함수는 이전 Composition과 입력 값이 동일하다면 실행을 완전히 건너뛸 수 있습니다.
컴포저블 함수가 건너뛸 수 없는 경우는 다음과 같습니다:
- 함수의 반환 타입이 Unit이 아닌 경우
- 함수에 @NonRestartableComposable 또는 @NonSkippableComposable 애너테이션이 적용된 경우
- 필수 매개변수가 안정적이지 않은(non-stable) 타입인 경우
또한, Strong Skipping이라는 실험적인 컴파일러 모드가 존재하며, 이 모드를 활성화하면 안정적이지 않은 타입에 대한 제한이 완화됩니다.
타입이 안정적(stable)으로 간주되기 위한 조건:
- 같은 두 인스턴스에 대해 equals의 결과가 항상 동일해야 함
- 해당 타입의 public 프로퍼티가 변경되면, Composition이 이를 감지할 수 있어야 함
- 모든 public 프로퍼티의 타입도 안정적인 타입이어야 함
Compose 컴파일러는 일부 일반적인 타입을 @Stable 애너테이션 없이도 안정적인 타입으로 처리합니다.
다음과 같은 타입은 기본적으로 안정적인 타입으로 간주됩니다:
- 모든 기본 데이터 타입(Primitive Types):
Boolean, Int, Long, Float, Char 등 - String 타입
- 모든 함수 타입(람다 표현식 포함)
이러한 타입들은 불변(immutable)하기 때문에, Composition에 변경 사항을 알릴 필요가 없으며,
따라서 안정적인 타입의 조건을 자연스럽게 만족합니다.
참고: 모든 깊이 불변(Deeply Immutable)한 타입 은 안정적인 타입으로 안전하게 간주될 수 있습니다.
특징적인 안정적인 타입 중 하나는 Compose의 MutableState 타입입니다.
MutableState는 변경 가능한 타입이지만, .value 프로퍼티가 변경될 때 Compose가 이를 감지하고 Composition에 알릴 수 있기 때문에 안정적인 타입으로 간주됩니다.
컴포저블에 전달되는 모든 매개변수 타입이 안정적인 경우, Compose는 UI 트리 내에서 컴포저블의 위치를 기준으로 매개변수 값을 비교합니다.
이전 호출과 비교했을 때 모든 값이 변경되지 않았다면, recomposition은 건너뛰어집니다.
핵심 포인트: 모든 입력 값이 안정적(stable)이고 변경되지 않았다면, Compose는 해당 컴포저블의
recomposition을 건너뜁니다.
이때, 비교는 equals 메서드를 사용하여 수행됩니다.
Compose는 타입이 안정적(stable)임을 확신할 수 있는 경우에만 안정적인 타입으로 간주합니다.
예를 들어, 인터페이스(interface)는 일반적으로 안정적이지 않은 타입으로 처리되며,
public 프로퍼티가 변경 가능(mutable)한 타입도 내부 구현이 불변(immutable)일 수 있음에도 불구하고 안정적인 타입으로 간주되지 않습니다.
만약 Compose가 어떤 타입이 안정적인지 판별하지 못하지만, 이를 안정적인 타입으로 처리하도록 강제하고 싶다면,
@Stable 애너테이션을 사용하여 해당 타입을 안정적이라고 명시할 수 있습니다.
// Marking the type as stable to favor skipping and smart recompositions.
@Stable
interface UiState<T : Result<T>> {
val value: T?
val exception: Throwable?
val hasError: Boolean
get() = exception != null
}
위 코드에서 UiState는 인터페이스이므로 Compose가 기본적으로 안정적인 타입으로 간주하지 않습니다.
하지만 @Stable 애너테이션을 추가하면, Compose는 UiState를 안정적인 타입으로 처리하고, 스마트 recomposition을 더 적극적으로 수행할 수 있습니다.
또한, @Stable이 적용되면 이 인터페이스를 매개변수로 사용하는 모든 구현체도 안정적인 타입으로 간주됩니다.
핵심포인트: 만약 Compose가 어떤 타입이 안정적인지 판별할 수 없다면, @Stable 애너테이션을 사용하여 Compose가 스마트 recomposition을 더 효율적으로 수행할 수 있도록 도와줄 수 있습니다.
위에서 언급한 MutableState는 Mutable임에도 불구하고, 특이한점이 있는데, @Stable로 선언되어있습니다.
@Stable
interface MutableState<T> : State<T> {
override var value: T
operator fun component1(): T
operator fun component2(): (T) -> Unit
}
이를 통해 MutableState는 예외적으로 Stable하다고 할 수 있습니다.
스마트 Recomposition (Smart Recomposition)
클래스의 안정성이 결정되면, Compose 런타임은 스마트 recomposition이라고 알려진 내부 메커니즘을 통해 recomposition을 시작합니다. 스마트 recomposition은 제공된 안정성 정보를 활용하여 불필요한 recomposition을 선택적으로 건너뛰어 Compose의 전체 성능을 향상시킵니다.
스마트 recomposition이 작동하는 몇 가지 원칙은 아래와 같습니다.
- 안정성에 따른 결정: 매개변수가 안정적이고 그 값이 변경되지 않은 경우(equals()가 true를 반환), Compose는 관련 UI 컴포넌트의 recomposition을 건너뜁니다. 매개변수가 불안정하거나, 안정적이지만 그 값이 변경된 경우(equals()가 false를 반환), 런타임은 recomposition을 시작하여 UI 레이아웃을 무효화(invalidate)하고 다시 그립니다.
- 동등성 검사: 위에서 설명한 equals() 함수를 통한 동등성 비교는 해당 타입이 안정적으로 간주되는 경우에만 수행합니다. 새로운 입력값이 Composable 함수에 전달될 때마다, 항상 해당 타입의 equals() 메서드를 사용하여 이전 값과 비교합니다.
위의 시나리오에서 불필요한 recomposition을 피하면 Compose 성능을 향상시킬 수 있습니다. 전체 UI 트리를 recomposition하는 것은 상당한 비용을 필요로 하고, 적절히 처리하지 않으면 성능에 부정적인 영향을 미칠 수 있습니다.
Jetpack Compose는 본질적으로 스마트 recomposition을 지원하지만, 개발자들은 Composable 함수에서 사용되는 클래스들을 안정적으로 만들고 recomposition을 최대한 줄이는 방법을 철저히 이해하는 것이 중요합니다.
끝.
참고자료
컴포저블 수명 주기 | Jetpack Compose | Android Developers
이 페이지는 Cloud Translation API를 통해 번역되었습니다. 컴포저블 수명 주기 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 이 페이지에서는 컴포저블의 수명
developer.android.com
https://velog.io/@skydoves/compose-stability
Jetpack Compose 성능 최적화를 위한 Stability 이해하기
\_원문은 Optimize App Performance By Mastering Stability in Jetpack Compose(https://getstream.io/blog/jetpack-compose-stability/이 포스트의 내용을 심층적으로 다루는
velog.io
'Android > Compose' 카테고리의 다른 글
[Android] Compose 상태관리 (0) | 2025.03.07 |
---|---|
[Android] Compose의 UI 렌더링 동작 매커니즘 (0) | 2025.03.07 |
[Android] Compose에 대한 이해 (1) | 2025.03.06 |
[Android] Compose를 사용해야하는 이유 (0) | 2025.03.06 |
[Android] Jetpack Compose란 (1) | 2023.09.18 |