면접 질문 목록으로 가기
면접 질문실전 질문꼬리 질문

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)
    }
}

리컴포지션과 스냅샷 상태 관찰

컴포저블이 컴포지션에 진입한 이후에는 0회 이상 리컴포지션이 발생할 수 있습니다. 리컴포지션의 트리거는 항상 해당 컴포저블이 읽는 State<T> 객체의 변경입니다. 상태 변경과 컴포저블 재실행을 연결하는 메커니즘이 바로 스냅샷 관찰(snapshot observation) 시스템입니다.

Compose 런타임은 코틀린의 Snapshot 시스템을 사용하여 상태 객체에 대한 읽기와 쓰기를 추적합니다. mutableStateOf()를 호출할 때마다 SnapshotMutableStateImpl이 생성되며, 이 객체는 값을 StateRecord 내부에 저장합니다. 컴포저블이 상태의 .value를 읽으면, 스냅샷 시스템이 현재 옵저버에게 이를 통지합니다.

internal open class SnapshotMutableStateImpl<T>(
    value: T,
    override val policy: SnapshotMutationPolicy<T>
) : StateObject, SnapshotMutableState<T> {

    override var value: T
        get() = next.readable(this).value
        set(value) = next.withCurrent {
            if (!policy.equivalent(it.value, value)) {
                next.overwritable(this, it) { this.value = value }
            }
        }
}

readable() 호출은 내부적으로 Snapshot.readObserver를 트리거합니다. Composition 컨텍스트에서 이 옵저버는 OwnerSnapshotObserver이며, 각 상태 읽기를 현재의 RecomposeScope에 매핑합니다. 이 RecomposeScope는 해당 읽기를 수행한 컴포저블 함수의 리스타트 그룹에 대응됩니다. 즉, 어떤 컴포저블이 어떤 상태를 읽었는지가 정확하게 기록되는 것입니다.

이후 상태 객체가 수정되면, 쓰기 동작이 Snapshot.writeObserver를 트리거하고, 영향받는 RecomposeScope 인스턴스가 무효화(invalid)로 표시됩니다. Recomposer는 이러한 무효화된 스코프를 수집하여 다음 프레임에 리컴포지션을 스케줄링합니다.

// Recomposer의 프레임 루프 내부
private suspend fun recompositionRunner(block: suspend CoroutineScope.() -> Unit) {
    withContext(broadcastFrameClock) {
        block()
    }
}

RecomposerMonotonicFrameClock이 프레임 신호를 보낼 때까지 대기한 후, 무효화된 스코프에 연결된 컴포저블 함수만 재실행합니다. 상태 읽기가 특정 컴포저블에 스코핑되기 때문에, 한 컴포저블의 상태 변경이 트리 내 관련 없는 다른 컴포저블의 리컴포지션을 트리거하지 않습니다. 이 정밀한 무효화 메커니즘이 Compose의 성능을 뒷받침하는 핵심 원리입니다.

스킵과 안정성(Stability)

리컴포지션 과정에서 Composer는 컴포저블을 통째로 건너뛸(skip) 수 있는지 확인합니다. 컴포저블의 모든 매개변수가 안정적(stable)이고 이전 값과 동일하면 스킵할 수 있습니다. Compose 컴파일러 플러그인은 각 리스타트 그룹에 비교 코드를 생성합니다.

// 컴파일러가 생성하는 스킵 로직 (간략화)
if (changed == 0 && composer.skipping) {
    composer.skipToGroupEnd()
} else {
    // 컴포저블 본문 실행
    Text(text)
}

타입이 안정적(stable)이라 판정되려면, Compose 컴파일러가 해당 타입의 equals() 메서드가 시간에 따른 아이덴티티와 일관적임을 증명할 수 있어야 합니다. 원시 타입, String, 그리고 @Stable 또는 @Immutable로 어노테이션된 타입이 안정적으로 분류됩니다. 모든 필드가 안정적인 data class도 마찬가지입니다. 불안정한(unstable) 타입을 매개변수로 받는 컴포저블은 항상 리컴포지션을 트리거하는데, 컴파일러가 equals() 결과의 의미 있는 비교를 보장할 수 없기 때문입니다.

바로 이 때문에 컴포저블 함수는 가능한 한 좁은 범위의 매개변수를 받아야 합니다. ViewModel 전체나 가변 데이터 홀더를 전달하는 대신, 해당 컴포저블이 실제로 필요로 하는 구체적인 원시 타입이나 안정적인 값을 전달하는 것이 바람직합니다. 이렇게 하면 스킵 체크를 통해 불필요한 재실행을 방지할 수 있습니다. 면접에서 "리컴포지션을 최소화하려면 어떻게 해야 하나요?"라는 질문이 나왔을 때, 이 원리를 설명하면 좋은 인상을 줄 수 있습니다.

// 권장: 좁은 범위의 안정적 매개변수
@Composable
fun UserName(name: String) {
    Text(text = name)
}

// 비권장: 불안정한 매개변수로 인해 매번 리컴포지션 발생
@Composable
fun UserName(viewModel: UserViewModel) {
    Text(text = viewModel.name)
}

컴포지션에서 벗어남

컴포저블은 부모가 더 이상 해당 컴포저블을 호출하지 않을 때 컴포지션에서 벗어납니다. 조건부 분기가 변경되거나, 리스트가 축소되거나, 부모 자체가 컴포지션에서 벗어나는 경우에 발생합니다. 런타임은 리컴포지션 중 Composer의 슬롯 테이블 순회 과정에서 대응하는 호출이 없는 그룹을 발견할 때 이를 감지합니다.

컴포저블이 컴포지션에서 벗어나면, 런타임은 정해진 순서에 따라 정리(cleanup) 작업을 수행합니다. 우선 해당 컴포저블 내부의 모든 DisposableEffect 블록에서 onDispose 콜백이 호출됩니다.

@Composable
fun SensorListener(sensorManager: SensorManager) {
    DisposableEffect(Unit) {
        val listener = object : SensorEventListener { /* ... */ }
        sensorManager.registerListener(listener, /* ... */)
        onDispose {
            // 이 컴포저블이 컴포지션에서 벗어날 때 호출됨
            sensorManager.unregisterListener(listener)
        }
    }
}

그 다음으로, LaunchedEffect의 코루틴이 취소됩니다. 런타임이 이펙트에 연결된 CoroutineScope를 취소하며, 이는 표준 구조화된 동시성(structured concurrency) 취소 방식을 따릅니다. 세 번째로, remember 블록에 저장된 값이 슬롯 테이블에서 제거됩니다. 기억된 값이 RememberObserver를 구현하고 있다면 onForgotten() 콜백이 호출됩니다. 참고로, onAbandoned()RememberObserver가 생성되었지만 컴포지션이 성공적으로 커밋되기 전에 폐기된 경우에만 호출되는 별도의 콜백으로, onForgotten()onAbandoned()는 상호 배타적입니다. 마지막으로, Applier가 해당하는 레이아웃 노드를 노드 트리에서 제거합니다.

이 정리 체인은 컴포저블의 생존 기간 동안 획득한 리소스가 컴포지션에서 벗어날 때 확실히 해제되도록 보장합니다. 순서가 중요한 이유는 이펙트가 기억된 값보다 먼저 해제되므로, 이펙트 해제 코드에서 기억된 값을 안전하게 참조할 수 있기 때문입니다. 실무에서 메모리 누수나 리소스 해제 문제를 디버깅할 때, 이 정리 순서를 알고 있으면 원인을 더 빠르게 파악할 수 있습니다.

위치 기반 키와 key() 컴포저블

기본적으로 Compose 런타임은 부모 내에서의 호출 지점 위치로 컴포저블 인스턴스를 식별합니다. 정적 레이아웃에서는 잘 동작하지만, 반복문에서 컴포저블이 생성되거나 리스트 항목이 재정렬되는 경우에는 문제가 발생합니다. key() 컴포저블을 사용하면 위치 기반 아이덴티티를 값 기반 아이덴티티(value-based identity)로 대체할 수 있습니다.

@Composable
fun MessageList(messages: List<Message>) {
    Column {
        for (message in messages) {
            key(message.id) {
                MessageRow(message)
            }
        }
    }
}

key()가 없으면 리스트 맨 앞에 메시지를 삽입할 경우, 이후 모든 MessageRow의 위치 기반 아이덴티티가 밀려나게 됩니다. 런타임은 각 위치에 새로운 데이터가 들어온 것으로 취급하여, 모든 항목의 전체 리컴포지션을 트리거하고 내부 상태까지 초기화합니다. key(message.id)를 사용하면 런타임이 각 MessageRow를 ID로 매칭합니다. 리스트를 재정렬해도 슬롯 테이블 내에서 그룹이 이동할 뿐, 상태는 초기화되지 않습니다.

내부적으로 key() 컴포저블은 일반 startGroup() 대신 startMovableGroup() 호출을 생성합니다. 이동 가능한 그룹(movable group)은 위치가 아닌 키 값으로 추적됩니다. 리컴포지션 시 Composer는 이동 가능한 그룹의 룩업 테이블을 유지하고 키 기반으로 매칭하여, 삽입, 삭제, 이동을 전체 교체로 처리하지 않고 정확히 감지할 수 있습니다. 리스트 기반 UI를 구현할 때 key()를 적절히 활용하면 성능을 크게 높여 줄 수 있으므로, 실무에서도 반드시 숙지해 두시길 권장합니다.

요약

컴포저블의 생명주기는 Composer의 슬롯 테이블 관리에 의해 결정됩니다. 컴포저블은 슬롯 테이블에 새로운 그룹이 삽입될 때 컴포지션에 진입하고, 연관된 RecomposeScope가 스냅샷 상태 변경에 의해 무효화될 때 리컴포지션이 발생하며, 리컴포지션 중 해당 그룹에 대응하는 호출이 더 이상 존재하지 않음을 Composer가 감지할 때 컴포지션에서 벗어납니다. 스냅샷 관찰 시스템은 어떤 상태 객체가 어떤 스코프에서 읽히는지를 정밀하게 추적하여, 대상별 리컴포지션이 이루어질 수 있도록 지원합니다. 매개변수 안정성에 기반한 스킵 로직은 불필요한 재실행을 방지합니다. 컴포지션 종료 시 정리 작업은 이펙트 해제, 코루틴 취소, 기억된 값 제거, 노드 트리 삭제 순으로 수행됩니다.

정리하면, Compose의 생명주기 모델은 "진입 → 리컴포지션 → 종료"라는 단순한 개념 뒤에, 슬롯 테이블 관리, 스냅샷 상태 관찰, 안정성 기반 스킵이라는 세 가지 핵심 메커니즘이 긴밀하게 맞물려 동작합니다. 면접에서 이 세 가지를 연결 지어 설명할 수 있다면, 단순히 "컴포저블에는 세 가지 생명주기 이벤트가 있습니다"라고 답변하는 것보다 훨씬 깊이 있는 이해를 보여줄 수 있습니다.

실전 질문

Jetpack Compose에서 컴포저블의 생명주기를 정의하는 세 가지 주요 이벤트에 대해 설명해 주세요. 일반적으로 리컴포지션은 무엇에 의해 트리거되며, Compose는 어떤 컴포저블을 재실행해야 하는지 어떻게 판별하나요?

해답을 보기 전에 실제 면접에서 답변을 한다고 생각하고 충분히 생각해보세요. 전두엽을 활용한 깊은 사고 과정은 어려운 내용이 장기기억으로 전환되는 것을 돕습니다.

후속 질문: Compose 컴파일러 플러그인은 컴포저블 함수를 어떻게 변환하며, $changed 매개변수는 스킵 최적화에서 어떤 역할을 하나요?

해답을 보기 전에 실제 면접에서 답변을 한다고 생각하고 충분히 생각해보세요. 전두엽을 활용한 깊은 사고 과정은 어려운 내용이 장기기억으로 전환되는 것을 돕습니다.

면접 질문 목록으로 가기