[Android-Manifest-Interview] Compose Fundamentals

Pardess·2025년 6월 10일

Jetpack Compose 구조

  1. Compose Compiler
  2. Compose Runtime
  3. Compose UI

Jetpack Compose의 구조는 관심사의 분리를 효과적으로 수행하도록 설계되어 있습니다. Compose Compiler는 UI 코드를 실행 가능한 구성 요소로 변환하고, Compose Runtime은 상태와 재구성을 관리하며, Compose UI 계층은 바로 사용할 수 있는 위젯과 UI 컴포넌트를 제공합니다. 이러한 계층적 아키텍처 덕분에 Android 애플리케이션 개발이 모듈화되고, 효율적이며, 유지보수가 용이해집니다.

Compose의 단계(Phase)

컴포지션(Composition), 레이아웃(Layout), 드로잉(Drawing).

Composition (컴포지션)


Composition 단계는 @Composable 함수들을 실행하여 컴포저블 함수들의 설명을 생성하고, UI 트리를 구축하는 역할을 합니다.

이 단계에서 Compose는 초기 UI 구조를 만들고, 컴포저블 간의 관계를 Slot Table이라는 데이터 구조에 기록합니다.

상태 변화가 발생하면, Composition 단계는 영향을 받는 UI 부분을 다시 계산하고 필요한 경우 Recomposition(재구성)을 트리거합니다.

Composition 단계의 주요 작업:

  • @Composable 함수 실행
  • UI 트리 생성 및 갱신
  • 변경 사항 추적 및 재구성을 위한 준비

Layout (레이아웃)


Layout 단계는 Composition 단계 이후에 실행됩니다. 이 단계에서는 주어진 제약 조건에 따라 각 UI 요소의 크기와 위치를 결정합니다.

각 컴포저블은 자신의 자식들을 측정하고, 자신의 너비와 높이를 결정하며, 부모 요소에 대해 자신의 위치를 정의합니다.

Layout 단계의 주요 작업:

  • UI 컴포넌트 측정
  • 너비, 높이, 위치 정의
  • 자식 요소들을 부모 컨테이너 내에서 배치

Drawing (드로잉)


Drawing 단계는 컴포지션과 레이아웃이 완료된 UI 요소들을 화면에 렌더링하는 단계입니다.

Compose는 이 과정을 위해 Skia 그래픽 엔진을 사용하여 부드럽고 하드웨어 가속된 렌더링을 제공합니다.

또한, Compose의 Canvas API를 통해 사용자 정의 드로잉 로직을 구현할 수도 있습니다.

Drawing 단계의 주요 작업:

  • 시각적 요소 렌더링
  • UI 컴포넌트를 화면에 그리기
  • 사용자 정의 드로잉 작업 적용

Jetpack Compose에서 선언형 UI의 핵심 특징


1. 상태 기반 UI (State-Driven UI):

선언형 UI 프레임워크에서는 상태 관리가 프레임워크 자체에 내장되어 있습니다. 시스템이 각 UI 컴포넌트의 상태를 추적하고, 상태가 변경되면 UI를 자동으로 업데이트합니다. 개발자는 특정 상태에서 UI가 어떻게 보여야 하는지만 정의하면 되며, 나머지 렌더링 업데이트는 프레임워크가 처리합니다. Jetpack Compose에서는 UI가 전적으로 상태에 의해 구동됩니다. 상태가 변경되면 프레임워크는 Recomposition을 트리거하여 변경된 부분만 다시 렌더링하고 최신 데이터를 반영합니다. 이로 인해 수동적인 뷰 관리가 필요 없습니다.

2. 함수 또는 클래스로 구성 요소 정의 (Defining Components as Functions or Classes):

선언형 UI 프레임워크는 UI 요소를 함수 또는 클래스로 표현되는 모듈형 컴포넌트로 정의하도록 권장합니다. 이러한 컴포넌트는 UI의 레이아웃과 동작을 함께 기술함으로써 XML과 같은 마크업 언어와 Kotlin 또는 Java 같은 네이티브 언어 간의 간극을 줄여줍니다. Jetpack Compose에서는 @Composable 함수로 재사용 가능한 UI 컴포넌트를 정의하며, 이 함수는 현재 상태에 기반한 UI를 설명하고 다른 함수와 결합하여 모듈화되고 확장 가능한 구조를 생성할 수 있습니다.

3. 직접적인 데이터 바인딩 (Direct Data Binding):

선언형 UI 프레임워크는 모델 데이터를 UI 컴포넌트에 직접 바인딩할 수 있게 해주며, 이를 통해 수동 동기화의 필요성이 사라집니다. 이 방식은 더 깔끔하고 유지보수가 쉬운 코드를 가능하게 합니다. Jetpack Compose에서는 함수의 매개변수를 통해 데이터를 전달하여 중간 데이터 바인딩 계층이나 복잡한 어댑터 패턴 없이도 UI 개발을 단순화할 수 있습니다.

4. 컴포넌트의 멱등성 (Component Idempotence):

선언형 프레임워크의 핵심 특징 중 하나는 멱등성(idempotence)입니다. 이는 동일한 입력값에 대해 컴포넌트가 호출될 때마다 동일한 출력을 생성한다는 의미입니다. 이 속성 덕분에 컴포넌트는 일관된 동작과 재사용성을 보장합니다. Jetpack Compose의 모든 @Composable 함수는 본질적으로 멱등성을 가지며, 같은 입력이 주어졌을 때 항상 동일한 UI 결과를 생성하여 예측 가능하고 안정적인 UI 렌더링을 지원합니다.

3. Recomposition이란 무엇이며, 언제 발생하나요? 또한 앱 성능과는 어떤 관련이 있나요?


한 번 렌더링된 UI 레이아웃을 Jetpack Compose의 세 가지 주요 단계(Composition, Layout, Drawing)를 통해 업데이트하려면, 상태 변화가 발생했을 때 UI를 다시 그리는 메커니즘이 필요합니다. 이 과정을 Recomposition(재구성)이라고 합니다.

Recomposition이 발생하면 Compose는 다시 Composition 단계부터 시작합니다. 이때 Composable 노드들은 UI에 변화가 있음을 Compose 프레임워크에 알리고, 그에 따라 UI가 최신 상태를 반영하도록 업데이트됩니다.

이 과정은 전체 UI를 다시 그리는 것이 아니라, 변경된 상태와 관련된 Composable만 선택적으로 다시 그리기 때문에 성능을 최적화할 수 있습니다. 이는 앱의 응답성과 효율성을 높이는 데 중요한 역할을 합니다.

Recomposition을 유발하는 조건들


대부분의 모바일 애플리케이션은 앱의 데이터 모델을 메모리에 표현한 상태(state) 를 유지합니다. 이 상태 변화에 따라 UI가 항상 동기화되도록 하기 위해, Jetpack Compose는 두 가지 주요 메커니즘을 통해 Recomposition(재구성)을 트리거합니다:

  1. 입력 값 변경 (Input Changes):

    Composable 함수는 입력 파라미터가 변경될 때 재구성을 유발합니다. Compose 런타임은 새로운 인자와 이전 인자를 equals() 함수를 사용해 비교합니다. 비교 결과가 false이면 변경이 감지되고, 해당 UI 구성 요소만 다시 재구성하여 업데이트됩니다.

  2. 상태 변화 관찰 (Observing State Changes):

    Jetpack Compose는 State API를 활용해 상태 변화를 모니터링합니다. 일반적으로 remember 함수와 함께 사용되며, 이는 상태 객체를 메모리에 보존하고 Recomposition 시에도 해당 상태를 복원합니다. 이를 통해 개발자가 수동으로 UI를 갱신하지 않아도 항상 최신 상태를 반영하는 UI가 유지됩니다.

    Jetpack Compose에서 안정성이란 무엇이며, 성능과는 어떤 관련이 있나요?


Jetpack Compose에서 안정성(Stability)은 특정 클래스나 타입이 동일한 입력에 대해 시간이 지나도 일관된 결과를 생성하는 성질을 의미합니다. 안정적인 클래스나 함수는 재구성이 여러 번 일어나더라도 그 동작이 예기치 않게 바뀌지 않음을 보장합니다. 이러한 특성은 Compose가 UI 업데이트를 효율적으로 처리하고, 불필요한 재구성을 방지하는 데 매우 중요합니다.

재구성(Recomposition)은 이미 렌더링된 UI를 업데이트하기 위해 다양한 메커니즘에 의해 트리거됩니다. 이때, Composable 함수의 파라미터 안정성은 핵심적인 역할을 하며, Compose의 런타임 및 컴파일러가 언제 재구성이 필요한지를 판단하는 기준이 됩니다.

Compose 컴파일러는 Composable 함수의 파라미터를 분석하여 이를 안정적(stable)인지 불안정적(unstable)인지 분류합니다. 이 안정성 분류는 Compose 런타임이 입력값의 변화에 따라 해당 Composable을 다시 렌더링할지 여부를 결정하는 데 핵심적으로 활용됩니다. 이로써 불필요한 작업을 줄이고 앱의 성능을 최적화할 수 있게 됩니다.

안정한(Stable) 타입 vs. 불안정한(Unstable) 타입 이해하기


Composable 함수의 파라미터가 어떻게 안정하거나 불안정한 것으로 분류되는지 궁금할 수 있습니다. 이 분류는 Compose 컴파일러에 의해 처리되며, 해당 파라미터의 타입을 평가하여 다음 기준에 따라 안정성을 판단합니다:

안정한(Stable) 타입

  • 기본 타입(Primitive types): String을 포함한 기본 타입들은 예기치 않게 변경되지 않으므로 본질적으로 안정합니다.
  • 함수 타입(Function types): (Int) -> String 같은 람다 표현식이 외부 값을 캡처하지 않는다면, 예측 가능한 동작을 하므로 안정적으로 간주됩니다.
  • 클래스: 불변(immutable)이고, 안정적인 public 프로퍼티로 구성된 data class는 안정한 타입으로 간주됩니다. 또한 @Stable 또는 @Immutable과 같은 안정성 관련 어노테이션이 명시된 클래스도 안정하다고 판단됩니다. (이 어노테이션의 구체적인 영향은 뒤에서 설명됩니다.)

예를 들어, 다음과 같은 data class를 생각해보세요:

data class User(
    val id: Int,
    val name: String,
)

이 User 클래스는 불변의 기본 타입 프로퍼티만 포함하고 있으므로, Compose 컴파일러는 이를 안정한 타입으로 간주합니다.

불안정한(Unstable) 타입

Compose 컴파일러는 다음 기준에 따라 불안정한 타입도 식별합니다:

  • 인터페이스 및 추상 클래스: List, Map, Any 같은 타입들은 구체 구현을 컴파일 타임에 보장할 수 없기 때문에 불안정하다고 간주됩니다. (이러한 분류의 이유는 뒤에서 더 자세히 설명됩니다.)
  • 가변 프로퍼티를 가진 클래스: 하나 이상의 가변 프로퍼티(var) 또는 본질적으로 불안정한 프로퍼티를 가진 data class는 불안정한 타입으로 분류됩니다.

예를 들어 다음 클래스를 보세요:

data class MutableUser(
    val id: Int,
    var name: String // 이 가변 프로퍼티 때문에 이 클래스는 불안정
)

이 MutableUser 클래스는 name이라는 가변 프로퍼티를 포함하고 있기 때문에 불안정한 타입으로 간주됩니다. 비록 나머지 프로퍼티들이 기본 타입이더라도, 하나의 가변 프로퍼티만으로도 전체 클래스가 불안정하다고 판단됩니다.

이는 Compose가 클래스의 모든 프로퍼티의 안정성을 종합적으로 고려하기 때문입니다.

Composable 함수의 추론 (Inferring Composable Functions)


이제 또 하나의 중요한 개념인 컴파일러가 Composable 함수의 타입을 어떻게 추론하고 최적화하는지에 대해 살펴볼 필요가 있습니다.

Compose 컴파일러는 Kotlin 컴파일러 플러그인으로, 개발자가 작성한 소스 코드를 컴파일 시점에 분석합니다. 이 분석을 넘어, Composable 함수의 효율적인 실행을 위해 원본 소스 코드를 수정하기도 합니다.

성능 최적화를 위해, 컴파일러는 Composable 함수를 다음과 같은 타입으로 분류합니다:

Restartable, Skippable, Moveable, Replaceable

이 중에서 RestartableSkippable은 재구성과 밀접한 관련이 있으며, 아래에서 이 두 타입의 역할을 자세히 설명합니다.

  • Restartable (재시작 가능) Restartable 함수는 Compose 컴파일러에 의해 식별되며, 재구성(recomposition) 과정의 기반이 됩니다. 입력 값이나 상태가 변경되면, Compose 런타임은 이 함수들을 다시 호출하여 UI를 업데이트합니다. 대부분의 Composable 함수는 기본적으로 Restartable로 간주되며, 이 덕분에 런타임은 필요할 때 언제든지 재구성을 트리거할 수 있습니다.
  • Skippable (건너뛰기 가능) Skippable 함수는 특정 조건이 충족될 경우, 재구성을 건너뛸 수 있는 함수입니다. 이러한 스마트 재구성(Smart Recomposition) 기능은 성능을 크게 향상시키는 데 중요합니다. 특히, 함수 계층 구조의 상단에 있는 루트 Composable 함수의 재구성을 건너뛰면, 하위 함수들의 호출도 피할 수 있어 전체 렌더링 비용을 줄일 수 있습니다. 특히 주목할 점은, 하나의 함수가 Restartable이면서 동시에 Skippable일 수 있다는 점입니다. 즉, 필요할 때는 재구성을 수행하고, 조건이 맞으면 재구성을 건너뛸 수 있는 유연성을 가집니다.

Summary


Jetpack Compose에서의 안정성(Stability)은 성능과 신뢰성에 직접적인 영향을 미치는 핵심 개념입니다.

Composable 함수에서 안정한 타입을 사용하고, 부작용(side effect)을 피하는 방식으로 설계하면, 더욱 매끄럽고 효율적인 재구성이 가능하며 UI 경험이 최적화됩니다.

안정성을 적극적으로 활용함으로써, Compose 런타임의 효율성을 최대한 활용할 수 있으며, 향후 변화에도 유연하게 대응할 수 있는 앱을 만들 수 있습니다.

Practical Questions


Q) Compose 컴파일러는 파라미터가 안정한지 불안정한지를 어떻게 판단하며, 이 판단은 왜 재구성에 중요할까요?

Q) @Stable 및 @Immutable 어노테이션은 Jetpack Compose에서 어떤 역할을 하며, 언제 사용하는 것이 적절할까요?

마스터를 위한 전문가 팁: 스마트 재구성(Smart Recomposition)이란?


앞에서 안정성의 원칙과 Compose 컴파일러가 안정한 타입과 불안정한 타입을 어떻게 구분하는지를 살펴봤다면, 이제 이러한 분류가 재구성(Recomposition)에 어떤 영향을 미치는지를 이해하는 것이 중요합니다.

Compose 컴파일러는 Composable 함수에 전달되는 파라미터의 안정성 여부를 평가합니다.

이 안정성 정보는 Compose 런타임에서 재구성을 효율적으로 관리하는 데 사용됩니다.

클래스의 안정성이 판단되면, Compose 런타임은 해당 정보를 활용하여 스마트 재구성(Smart Recomposition)이라는 메커니즘을 실행합니다.

이 스마트 재구성은 컴파일러가 제공한 안정성 데이터를 바탕으로 불필요한 UI 업데이트를 선택적으로 건너뛰며 결과적으로 UI 성능과 반응성을 최적화합니다.

스마트 재구성(Smart Recomposition)은 어떻게 작동하는가


Composable 함수에 새로운 입력값이 전달될 때마다, Compose는 해당 값이 이전 값과 다른지를 확인하기 위해 클래스의 equals() 메서드를 사용해 비교합니다.

  • 안정한 파라미터, 값의 변화 없음: 파라미터가 안정(stable)하고, 값이 이전과 동일하다면 (equals() == true), Compose는 해당 UI 구성 요소의 재구성을 건너뜁니다.
  • 안정한 파라미터, 값의 변화 있음: 파라미터가 안정하지만 값이 변경되었다면 (equals() == false), Compose 런타임은 재구성을 트리거하여 변경된 상태를 UI에 반영합니다.
  • 불안정한 파라미터: 파라미터가 불안정(unstable)한 경우에는, 값이 변하지 않았더라도 Compose는 항상 재구성을 수행합니다.

불필요한 재구성을 피해야 하는 이유


불필요한 재구성을 건너뛰면, 함수를 다시 실행하거나 UI 요소를 다시 그리는 데 필요한 계산 비용을 줄일 수 있어 UI 성능이 향상됩니다.

특히, 여러 상태에 의존하는 복잡한 UI 계층 구조에서는 재구성이 많을수록 성능 저하가 발생할 수 있기 때문에 불필요한 재구성을 줄이는 것은 매우 중요합니다.

Summary


Jetpack Compose는 기본적으로 스마트 재구성(Smart Recomposition)을 지원하지만, 개발자 역시 안정적인 클래스 설계재구성 최소화에 대한 이해가 필요합니다.

안정성 원칙을 올바르게 이해하고 적용함으로써, 더욱 효율적이고 확장성 있는 Compose UI를 구축할 수 있습니다.

마스터를 위한 전문가 팁: 어떤 안정성 어노테이션이 있으며, 그 차이는 무엇인가요?


안정성 어노테이션(Stability Annotations)은 개발자가 클래스의 안정성을 명시적으로 표시할 수 있도록 도와줍니다. 이 어노테이션은 Compose 컴파일러가 재구성을 최적화하는 데 활용됩니다.

Compose Runtime 라이브러리에서는 대표적으로 두 가지 어노테이션을 제공합니다:

  • @Immutable
  • @Stable

이 두 어노테이션은 용도가 서로 다르며, 클래스의 특성에 따라 적절히 적용해야 합니다.

@Immutable


@Immutable 어노테이션은 클래스를 완전한 불변(immutable)으로 표시하며, 단순히 val이나 읽기 전용 제약을 사용하는 것보다 더 강력한 불변성 보장을 Compose 컴파일러에 제공합니다.

이 어노테이션이 붙은 클래스는 모든 프로퍼티가 불변이라고 컴파일러가 간주하게 됩니다.

@Immutable이 적용된 클래스는 해당 값을 절대 변경하지 않는다고 간주되므로,

이 클래스를 사용하는 Composable 함수에서 불필요한 재구성을 안전하게 생략할 수 있어 성능이 향상됩니다.

주요 특징:

  • 클래스 내의 모든 프로퍼티가 불변(immutable)으로 간주됩니다.
  • 일반적으로 가변 프로퍼티가 전혀 없는 data class나 모델 클래스에 사용됩니다.
  • 클래스 내부 상태가 변경되지 않음을 보장함으로써 최적화를 단순화할 수 있습니다.

@Immutable vs. @Stable의 차이점


@Immutable과 @Stable 어노테이션의 차이는 처음에는 혼란스러울 수 있지만, 실제로는 비교적 명확합니다.

  • @Immutable은 클래스의 모든 public 프로퍼티가 완전히 불변(immutable)임을 보장합니다. 즉, 객체가 한 번 생성되면 그 상태가 절대 변하지 않는 것을 의미합니다.
  • 반면, @Stable은 가변 프로퍼티(mutable properties)가 존재하더라도, 동일한 입력에 대해 일관되고 예측 가능한 결과를 제공하는 경우에 사용할 수 있습니다. 이 차이를 통해, 개발자는 클래스의 안정성 및 가변성 특성에 따라 적절한 어노테이션을 선택할 수 있습니다.

@Immutable

사용 예:

@Immutable 어노테이션은 일반적으로 I/O 연산(예: 네트워크 응답, 데이터베이스 엔터티)에서 유래된 도메인 모델에 적용됩니다.

이러한 모델은 일반적으로 변경되지 않으며, 특히 Kotlin의 data class로 작성되는 경우가 많습니다.

하지만, 이들 모델이 interface나 불안정한 타입을 포함할 경우, Compose는 기본적으로 해당 클래스를 불안정하게 간주합니다.

이때 @Immutable을 명시하면, Compose 컴파일러는 해당 클래스를 불변한 안정 객체로 인식하여 재구성을 최적화할 수 있습니다.

@Immutable
public data class User(
    public val id: String,
    public val nickname: String,
    public val profileImages: List<String>,
)

@Stable

사용 예:

반대로, @Stable 어노테이션은 다양한 구현체가 존재할 수 있고, 내부에 가변 상태를 포함할 수 있는 인터페이스나 클래스에 자주 사용됩니다.

@Stable
interface UiState<T : Result<T>> {
    val value: T?
    val exception: Throwable?

    val hasSuccess: Boolean
        get() = exception == null
}

위 예시에서 @Stable은 UiState 인터페이스를 안정한 타입으로 간주하도록 하여 Jetpack Compose가 재구성 최적화 및 불필요한 재구성 건너뛰기(smart skipping)를 활용할 수 있게 해줍니다.

이로써 UI 업데이트의 효율성이 향상됩니다.

Summary


적절한 안정성 어노테이션을 적용함으로써 Jetpack Compose 애플리케이션의 성능을 최적화할 수 있습니다.

  • *@Immutable완전히 불변해야 하는 클래스에 사용하여 불필요한 재구성을 최소화**할 수 있습니다.
  • *@Stable제어된 가변성을 가지면서도 예측 가능한 동작을 보장하는 클래스**에 사용합니다.

이러한 어노테이션을 정확히 이해하고 효과적으로 활용하면, 앱의 성능 향상은 물론 유지보수성 또한 크게 개선할 수 있습니다.

마스터를 위한 전문가 팁: 특정 클래스에 @Immutable 대신 @Stable을 잘못 사용하면 어떻게 될까?


@Stable과 @Immutable은 Jetpack Compose에서 각기 다른 목적을 가진 어노테이션이지만, 현재로서는 Compose 컴파일러가 이 둘을 처리하는 방식에 실질적인 차이는 없습니다.

따라서 어떤 클래스에 @Immutable 대신 @Stable을 잘못 사용하더라도, 즉각적인 문제는 발생하지 않습니다.

하지만 그렇다면 왜 이 둘을 구분해 놓았을까요?

그 이유 중 하나는, Compose 팀이 향후 최적화나 동작 방식을 변경할 수 있도록 의도적으로 이 둘을 분리해 놓았기 때문일 수 있습니다.

즉, 현재는 동작 방식이 같더라도, Compose 메커니즘이 진화함에 따라 이 두 어노테이션이 내부적으로 다르게 처리될 가능성이 있습니다.

따라서 지금부터라도 각 어노테이션의 의도에 맞게 올바르게 사용하는 것이 앞으로 컴파일러의 동작이 바뀌더라도 코드를 미래 지향적으로 유지하고 마이그레이션 이슈를 줄이는 데 도움이 됩니다.

0개의 댓글