Compose 아이덴티티 메커니즘: key()가 Movable Group으로 변환되는 과정
Compose 아이덴티티 메커니즘: key()가 Movable Group으로 변환되는 과정
Jetpack Compose는 정교한 아이덴티티(identity) 시스템을 통해 UI 상태를 관리하며, 이를 바탕으로 컴포저블을 재사용할지 아니면 새로 생성할지를 결정합니다. key(userId) { UserCard(user) }와 같이 콘텐츠를 감싸면, 리컴포지션(Recomposition)이나 목록 재정렬, 구조적 변경이 발생해도 살아남는 아이덴티티 정보를 Compose에 제공하게 됩니다. 대부분의 개발자는 key()가 리스트 아이템 이동 시 상태를 보존하는 데 도움이 된다는 정도는 알고 있지만, 더 깊이 들여다보면 의문이 생깁니다. Compose는 실제로 아이덴티티를 어떻게 추적하며, key()를 사용할 때 컴파일러와 런타임 수준에서 정확히 어떤 일이 벌어질까요?
이 글에서는 Compose의 아이덴티티 메커니즘을 심층적으로 살펴봅니다. 컴파일러가 key() 호출을 movable group 명령어로 변환하는 과정, 런타임이 replaceable, movable, restart 그룹을 구분하는 방식, 소스 위치 키(source location key)와 객체 키(object key)를 결합하는 2단계 아이덴티티 시스템, JoinedKey가 여러 키를 결합할 때 enum을 특별히 처리하는 방법, 그리고 리컴포지션 과정에서 슬롯 테이블(slot table)이 아이덴티티 정보를 저장하고 조회하는 원리까지 하나씩 다루겠습니다. 이 글은 key()의 사용법을 안내하는 글이 아닙니다. 안정적인 아이덴티티를 가능하게 하는 컴파일러와 런타임 내부 메커니즘을 탐구하는 글입니다.
근본 문제: 구조적 변경 시 위치 기반 아이덴티티가 깨지는 현상
다음과 같이 재정렬이 가능한 간단한 리스트를 생각해 보겠습니다.
@Composable
fun UserList(users: List<User>) {
Column {
for (user in users) {
UserCard(user)
}
}
}
@Composable
fun UserCard(user: User) {
var expanded by remember { mutableStateOf(false) }
// ...
}
users가 [Alice, Bob, Charlie]이고 Alice의 카드가 펼쳐진 상태라면, Compose는 이 펼쳐진 상태를 기억합니다. 그런데 리스트가 [Bob, Alice, Charlie]로 바뀌면 어떻게 될까요? 명시적 아이덴티티가 없는 경우, Compose는 위치 기반 메모이제이션(positional memoization)을 사용합니다. 첫 번째 UserCard 호출은 위치 0에 매핑되고, 두 번째는 위치 1에 매핑되는 식입니다. 리스트가 재정렬되면 위치 0에는 이제 Bob이 들어가지만, 위치 0에 저장되어 있던 expanded = true 상태가 여전히 남아 있습니다. 결과적으로 Bob의 카드가 잘못 펼쳐진 상태로 표시됩니다.
가장 단순한 해결 방법은 구조가 변경될 때마다 모든 상태를 다시 생성하는 것이지만, 이렇게 하면 사용자 경험이 크게 나빠집니다. 스크롤 위치가 초기화되고, 애니메이션이 처음부터 다시 시작되며, 텍스트 필드에 입력했던 내용도 사라져 버립니다. Compose에는 위치가 바뀌어도 아이덴티티를 추적할 수 있는 메커니즘이 필요합니다.
key() 컴포저블은 명시적 아이덴티티를 제공하여 이 문제를 해결합니다.
for (user in users) {
key(user.id) {
UserCard(user)
}
}
이제 Compose는 각 UserCard를 위치가 아닌 user.id로 추적합니다. 리스트가 재정렬되어도 Alice의 펼쳐진 상태는 Alice를 따라갑니다. 그렇다면 내부적으로 이 메커니즘은 어떻게 동작할까요? 그 답은 컴파일러 변환(compiler transformation)과 그룹 시스템(group system)에 있습니다.
그룹 아키텍처: 세 가지 유형의 컴포지션 단위
Compose는 컴포지션 트리를 그룹(group) 단위로 구성하며, 각 그룹은 서로 다른 역할을 수행합니다. Composer 인터페이스는 코드 구조에 따라 컴파일러가 생성하는 세 가지 그룹 유형을 정의합니다.
Replaceable 그룹
Replaceable 그룹은 콘텐츠가 나타나거나 사라지지만 위치가 이동하지는 않는 조건부 로직을 처리합니다.
@ComposeCompilerApi
override fun startReplaceableGroup(key: Int) = start(key, null, GroupKind.Group, null)
override fun endReplaceableGroup() = endGroup()
컴파일러는 if 표현식, when 분기, 조기 반환(early return), null 병합 연산자 주위에 replaceable 그룹을 삽입합니다. 이 그룹은 형제 노드 사이에서 이동할 수 없으며, 전체가 삽입되거나 제거될 수만 있습니다.
// 작성하는 코드:
if (showHeader) {
Header()
}
Content()
// 컴파일러가 생성하는 코드 (개념적):
composer.startReplaceableGroup(123) // 123은 소스 위치 키
if (showHeader) {
Header()
}
composer.endReplaceableGroup()
Content()
정수 키(이 예시에서는 123)는 소스 위치(source location)에서 생성됩니다. 형제 그룹들 사이에서 해당 그룹을 고유하게 식별하는 역할을 합니다.
Movable 그룹
Movable 그룹은 명시적 key() 호출에 의해 생성됩니다. Replaceable 그룹과 달리 내부 상태를 유지하면서 형제 노드 사이에서 재정렬될 수 있다는 점이 핵심적인 차이입니다.
@ComposeCompilerApi
override fun startMovableGroup(key: Int, dataKey: Any?) =
start(key, dataKey, GroupKind.Group, null)
@ComposeCompilerApi
override fun endMovableGroup() = endGroup()
여기서 가장 중요한 차이는 dataKey 매개변수입니다. key는 여전히 소스 위치 식별자이지만, dataKey는 key() 호출에서 사용자가 제공한 아이덴티티를 전달합니다.
소스 코드 문서에서 발췌한 설명은 다음과 같습니다.
/**
* 슬롯 테이블의 현재 실행 위치에 "Movable Group" 시작 마커를 삽입합니다.
* Movable Group은 슬롯 테이블 상태를 유지하면서 형제 노드 사이에서
* 이동하거나 재정렬할 수 있는 그룹이며, 물론 삽입이나 삭제도 가능합니다.
* Movable Group은 다른 그룹보다 비용이 더 높습니다. 슬롯 테이블에서
* 키가 일치하지 않는 그룹을 만났을 때, 해당 그룹이 부모 그룹 내
* 더 뒤쪽 위치로 이동했을 가능성이 있으므로, 부모 그룹의 실행이
* 완전히 끝날 때까지 임시로 보관해야 하기 때문입니다.
*/