아티클 목록으로 가기

Compose의 7가지 그룹 타입

skydovesJaewoong Eum (skydoves)||14분 소요

Compose의 7가지 그룹 타입

여러분이 작성하는 모든 @Composable 함수는 눈에 보이지 않는 스캐폴딩(scaffolding)을 생성합니다. Compose 컴파일러는 각 Kotlin 구조를 "그룹(group)"으로 감싸고, 이 그룹이 리컴포지션(Recomposition) 과정에서 런타임에 어떤 동작을 수행할 수 있는지 알려주는 역할을 합니다. 조건 분기에는 하나의 그룹 타입이 배정되고, 함수 본문에는 또 다른 그룹 타입이 배정됩니다. key() 호출에는 또 다른 타입이 사용됩니다. 이러한 결정은 모두 컴파일 타임에 이루어지며, 런타임이 UI의 각 부분을 건너뛸지, 교체할지, 이동할지, 재활용할지를 결정짓습니다.

이 글에서는 Compose 런타임에 존재하는 7가지 그룹 타입을 심층적으로 살펴봅니다. replace group이 조건 분기를 어떻게 처리하는지, restart group이 대상 지정 리컴포지션을 어떻게 가능하게 하는지, movable group이 재정렬 시 상태(state)를 어떻게 보존하는지, node group이 슬롯 테이블(slot table)과 UI 트리를 어떻게 연결하는지, reusable group이 컴포지션 구조를 어떻게 재활용하는지, defaults group이 기본 매개변수 계산을 어떻게 격리하는지, 그리고 이 7가지가 단 3개의 GroupKind 값을 가진 하나의 핵심 함수로 어떻게 수렴하는지를 단계별로 살펴보겠습니다.

근본적인 문제: 왜 여러 그룹 타입이 필요한가?

여러 Kotlin 구조가 혼합된 컴포저블(composable)을 생각해 보겠습니다.

@Composable
fun UserCard(user: User, showBio: Boolean) {
    key(user.id) {
        Text(user.name)
        if (showBio) {
            val bio = remember { loadBio(user.id) }
            Text(bio)
        }
    }
}

위 코드에서 각 구조는 런타임으로부터 서로 다른 처리를 필요로 합니다. if 분기는 showBiofalse가 되면 완전히 사라질 수 있으므로, 런타임은 내부의 모든 것을 삭제해야 합니다. key(user.id) 블록은 리스트 내 다른 위치로 이동할 수 있으므로, 런타임은 해당 블록을 찾아서 파괴하지 않고 재배치해야 합니다. UserCard 함수 자체는 매개변수가 변경될 때 독립적으로 재시작할 수 있어야 합니다. Text 호출은 실제 노드를 UI 트리에 방출해야 합니다.

하나의 그룹 타입으로는 이 모든 경우를 효율적으로 처리할 수 없습니다. 이동된 자식을 탐색하는 그룹은 절대 이동하지 않는 단순한 if/else 분기에서 불필요한 시간을 낭비하게 됩니다. 불일치가 발생하면 즉시 삭제하는 그룹은 재정렬을 통해 보존할 수 있었던 상태까지 파괴하게 됩니다. 따라서 컴파일러는 각 구조를 런타임에 정확히 필요한 능력만 부여하는 그룹 타입으로 분류하며, 그 이상의 것은 부여하지 않습니다. 이것이 바로 7가지 그룹 타입이 존재하는 근본적인 이유입니다.

퍼널 구조: 7개의 진입점, 3개의 GroupKind 값

모든 그룹 타입은 하나의 핵심 메커니즘으로 수렴합니다. GroupKind value class는 단 3가지 표현만 정의합니다.

@JvmInline
internal value class GroupKind private constructor(val value: Int) {
    inline val isNode get() = value != Group.value
    inline val isReusable get() = value != Node.value

    companion object {
        val Group = GroupKind(0)
        val Node = GroupKind(1)
        val ReusableNode = GroupKind(2)
    }
}

3개의 값이지만, 그룹 타입은 7가지입니다. 이 중 5가지가 GroupKind.Group을 사용합니다. 각 타입의 동작 차이는 슬롯 테이블의 저장 방식에 있는 것이 아니라, 핵심 start() 함수를 호출하기 전후에 각 start 메서드가 실행하는 로직에 있습니다. 라우팅 관계는 다음과 같습니다.

Start 메서드GroupKindobjectKeydata
startReplaceableGroup(key)Groupnullnull
startReplaceGroup(key)Group (fast path)nullnull
startMovableGroup(key, dataKey)GroupdataKeynull
startRestartGroup(key)Group (replace 경유)nullnull
startDefaults()Groupnullnull
startNode()Nodenullnull
startReusableNode()ReusableNodenullnull

핵심 start() 함수는 이 모든 변형을 하나의 시그니처로 수용합니다.

private fun start(
    key: Int,
    objectKey: Any?,
    kind: GroupKind,
    data: Any?
)

이 함수는 삽입, 키 매칭, 그룹 이동, 강제 교체를 처리합니다. 7가지 start 메서드 각각은 start()를 직접 호출하거나, 해당 그룹 타입에 불필요한 start() 로직 일부를 건너뛰는 특화된 fast path를 구현합니다.

Replace group: 조건 분기 래핑

컴파일러는 if/else 분기, when 표현식, 조기 반환, null 병합 패턴 주위에 replace group을 생성합니다. 서브트리가 나타나거나 사라질 수 있는 모든 곳이 replace group으로 감싸집니다.

간단한 조건문에 대해 컴파일러가 어떤 코드를 생성하는지 살펴보겠습니다.

@Composable
fun Greeting(name: String?) {
    if (name != null) {
        Text("Hello, $name")
    }
}

컴파일러는 if 본문을 소스 위치에서 파생된 키와 함께 startReplaceGroupendReplaceGroup 호출로 감쌉니다. 런타임이 startReplaceGroup을 만나면, 전체 start() 메커니즘을 거치지 않는 fast path를 실행합니다. 이 fast path는 현재 슬롯 위치의 키가 예상 키와 일치하는지 확인합니다. 키가 일치하고 object key가 없으면, 리더는 단순히 기존 그룹 안으로 진입합니다.

val slotKey = reader.groupKey
if (slotKey == key && !reader.hasObjectKey) {
    // 키가 일치하고 object key가 없으면 기존 그룹을 그대로 사용
    reader.startGroup()
    enterGroup(false, null)
    return
}

키가 일치하지 않으면, 런타임은 교체 경로를 선택합니다. 슬롯 테이블에서 기존 그룹을 삭제하고 삽입 모드로 전환합니다.

if (!reader.isGroupEnd) {
    val removeIndex = nodeIndex
    val startSlot = reader.currentGroup
    recordDelete()
    val nodesToRemove = reader.skipGroup()
    changeListWriter.removeNode(removeIndex, nodesToRemove)
    invalidations.removeRange(startSlot, reader.currentGroup)
}
// 기존 그룹 삭제 후 삽입 모드로 전환
reader.beginEmpty()
inserting = true
ensureWriter()
writer.beginInsert()
writer.startGroup(key, Composer.Empty)
insertAnchor = writer.anchor(writer.currentGroup)
enterGroup(false, null)

이 아티클은 구독자 전용입니다

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

구독하기
아티클 목록으로 가기