면접 질문실전 질문꼬리 질문

Jetpack Compose의 컴포저블 생명주기와 리컴포지션

skydovesJaewoong Eum (skydoves)||11분 소요

Jetpack Compose의 컴포저블 생명주기와 리컴포지션

Jetpack Compose에서 모든 컴포저블(composable) 함수는 세 가지 이벤트로 정의되는 생명주기를 따릅니다. 컴포지션(Composition)에 진입하고, 0회 이상 리컴포지션(Recomposition)을 수행하며, 최종적으로 컴포지션에서 벗어납니다. 전통적인 안드로이드 View 생명주기가 수많은 콜백 메서드로 구성되어 있는 것과 달리, Compose의 생명주기 모델은 개념적으로는 단순하지만 런타임 구현 측면에서는 훨씬 정교합니다. Compose 런타임은 슬롯 테이블(slot table)을 통해 각 컴포저블 인스턴스를 추적하고, 위치 기반 키(positional key)로 아이덴티티를 관리하며, 스냅샷 상태 관찰(snapshot state observation) 시스템을 활용하여 정확히 어떤 컴포저블이 재실행되어야 하는지 판별합니다. 이러한 내부 동작 원리를 이해하는 것은 성능 최적화된 Compose 코드를 작성하고, 예상치 못한 리컴포지션 동작을 진단하는 데 필수적입니다. 면접에서도 컴포저블 생명주기에 대한 깊이 있는 질문이 자주 출제되므로, 이번 기회에 내부 구현까지 확실히 이해해 두시면 큰 도움이 될 것입니다.

이번 면접 질문을 통하여 아래 내용들을 학습하실 수 있습니다.

  • Compose 런타임이 초기 컴포지션과 리컴포지션 과정에서 슬롯 테이블을 통해 컴포저블 인스턴스를 어떻게 추적하는지 설명할 수 있습니다.
  • 위치 기반 메모이제이션(positional memoization)과 호출 지점 아이덴티티(call site identity)가 컴포저블 생명주기 경계를 어떻게 결정하는지 이해할 수 있습니다.
  • 스냅샷 상태 관찰 시스템이 대상별 리컴포지션 스코프(recomposition scope)를 어떻게 트리거하는지 파악할 수 있습니다.
  • Recomposer가 무효화(invalidation), 스케줄링, 컴포저블 함수 재실행을 어떻게 조율하는지 추적할 수 있습니다.
  • 안정성(stability), key(), 스코프 경계 등을 활용하여 리컴포지션 세분화(granularity)를 제어하는 전략을 적용할 수 있습니다.

슬롯 테이블과 컴포저블 아이덴티티

Composition은 모든 컴포저블 인스턴스의 상태를 슬롯 테이블(slot table)이라는 데이터 구조에 저장합니다. 초기 컴포지션 과정에서 Composer는 실행 순서에 따라 이 테이블에 엔트리를 기록합니다. 각 컴포저블 호출 지점은 호출 계층 내 위치를 기반으로 고유한 아이덴티티를 부여받는데, 이를 위치 기반 메모이제이션(positional memoization)이라고 합니다.

Composer 클래스는 슬롯 테이블에 대한 커서를 관리합니다. 컴포저블 함수가 실행될 때, Composer는 테이블 내에 그룹을 생성하거나 업데이트합니다.

// Composer가 그룹을 추적하는 방식의 간략화된 표현
@ComposeCompilerApi
fun startRestartGroup(key: Int): Composer {
    startGroup(key)
    // ...
    return this
}

Compose 컴파일러는 모든 컴포저블 함수를 변환하여 startRestartGroup()endRestartGroup() 호출을 삽입합니다. 이 두 호출이 함수 본문을 감싸며 리스타트 스코프(restart scope)를 정의하는데, 이것이 바로 리컴포지션의 단위가 됩니다. key 매개변수는 컴포저블 호출의 소스 코드 위치에서 파생된 합성 정수(synthetic integer)입니다. 따라서 동일한 컴포저블 함수를 서로 다른 호출 지점에서 두 번 호출하면, 각각 다른 키를 받아 별개의 인스턴스로 추적됩니다. 이러한 설계 덕분에 Compose는 동일 함수의 여러 호출을 서로 구별할 수 있습니다.

컴포저블이 최초로 컴포지션에 진입하면 Composer는 삽입 모드(insert mode)로 동작합니다. 새로운 슬롯을 할당하고, 매개변수 값과 remember 블록을 저장하며, 그룹을 등록합니다. 이후 리컴포지션에서는 업데이트 모드(update mode)로 전환됩니다. 기존 슬롯 테이블 엔트리를 순회하면서 각 위치에서 저장된 키와 예상 키를 비교합니다. 키가 일치하면 해당 컴포저블은 이전과 동일한 인스턴스이므로 업데이트되거나 건너뛸(skip) 수 있습니다. 키가 일치하지 않으면 런타임이 구조적 변경을 감지하여 그룹을 삽입하거나 제거합니다.

컴포지션 진입

컴포저블은 주어진 위치에서 최초로 호출될 때 컴포지션에 진입합니다. 전체 트리의 초기 컴포지션 과정에서 발생할 수도 있고, 부모 컴포저블이 새로운 자식을 도입할 때 발생할 수도 있습니다. 가령 조건부 분기가 처음으로 true로 평가되어 새 컴포저블이 추가되는 경우가 대표적입니다.

@Composable
fun ProfileScreen(showBadge: Boolean) {
    Column {
        ProfileHeader()
        if (showBadge) {
            // showBadge가 true가 될 때 Badge가 컴포지션에 진입
            AchievementBadge()
        }
    }
}

AchievementBadge가 컴포지션에 진입하면, Composer는 슬롯 테이블에 새로운 그룹을 삽입합니다. AchievementBadge 내부의 remember 블록이 실행되어 초기값이 저장되고, LaunchedEffectDisposableEffect 블록이 이펙트를 등록합니다. 이어서 Applier가 해당하는 레이아웃 노드를 생성한 뒤 노드 트리의 올바른 위치에 배치하게 됩니다.

Applier는 Composition과 UI 트리 사이의 다리 역할을 합니다. 안드로이드에서 표준 ApplierUiApplier이며, LayoutNode 인스턴스를 대상으로 동작합니다. 컴포저블이 컴포지션에 진입하면, ApplierinsertTopDown() 또는 insertBottomUp()을 호출하여 새 노드를 배치하게 됩니다.

internal class UiApplier(root: LayoutNode) : AbstractApplier<LayoutNode>(root) {
    override fun insertTopDown(index: Int, instance: LayoutNode) {
        current.insertAt(index, instance)
    }

    override fun insertBottomUp(index: Int, instance: LayoutNode) {
        // 의도적으로 비어 있음: UiApplier는 탑다운 방식의 Applier
    }

    override fun remove(index: Int, count: Int) {
        current.removeAt(index, count)
    }
}

이 면접 질문은 구독자 전용입니다

Dove Letter를 구독하시면 안드로이드, 코틀린 개발 관련 독점 면접 질문의 전체 내용을 볼 수 있습니다.

구독하기