아티클 목록으로 가기
새로운 LinkBuffer SlotTable: 무엇이 달라졌고, 왜 중요한가

새로운 LinkBuffer SlotTable: 무엇이 달라졌고, 왜 중요한가

skydovesJaewoong Eum (skydoves)||9분 소요

새로운 LinkBuffer SlotTable: 무엇이 달라졌고, 왜 중요한가

SlotTable은 Compose의 전체 컴포지션(Composition) 계층 구조를 저장하는 내부 데이터 구조입니다. 모든 그룹, remember로 저장된 값, 각 컴포저블의 고유 식별 정보가 이 자료구조에 담겨 있습니다. Compose가 처음 출시된 이래로, SlotTable은 갭 버퍼(gap buffer)를 사용하여 데이터를 관리해 왔습니다. 텍스트 에디터에서 차용한 이 갭 버퍼는 순차적 삽입에는 뛰어나지만, 컴포지션 규모가 커질수록 삭제, 이동, 순서 변경 비용이 점점 높아지는 문제가 있었습니다. 새로운 실험적 LinkBuffer SlotTable은 이 갭 버퍼를 연결 리스트(linked list) 구조로 교체하여, 구조적 연산에서 배열 복사를 완전히 제거합니다. 그 결과, 리스트 순서 변경은 2배 이상 빨라졌고, 대부분의 다른 연산도 약 10% 정도 성능이 향상되었습니다.

이 글에서는 갭 버퍼가 동적 UI에서 왜 비용이 높아지는지, LinkBuffer의 연결 리스트 아키텍처가 어떻게 이러한 비용을 회피하는지, 성능 차이가 가장 두드러지는 실제 시나리오는 무엇인지, 앱에서 새로운 구현을 활성화하는 방법, 그리고 고려해야 할 트레이드오프까지 살펴보겠습니다. 갭 버퍼의 내부 데이터 모델(그룹, 슬롯, 앵커, 리더/라이터 패턴)에 대한 자세한 설명은 Jetpack Compose SlotTable 내부 구조 분석을 참고하시기 바랍니다. 갭 버퍼와 링크 버퍼 소스 코드의 라인별 비교가 궁금하시다면 갭 버퍼에서 연결 리스트로: Compose가 더 빠른 리컴포지션을 위해 SlotTable을 재작성한 방법을 읽어보시는 것을 적극 권장합니다.

근본적인 문제: 컴포지션 크기에 비례하여 증가하는 배열 복사

앱의 모든 컴포저블 함수는 SlotTable 내의 하나의 그룹(group)에 대응됩니다. Compose가 리컴포지션(Recomposition)을 수행할 때, 변경 사항을 추적하기 위해 그룹을 읽고 쓰게 됩니다. 이 그룹을 관리하는 데이터 구조가 리컴포지션의 속도에 직접적인 영향을 미칩니다.

갭 버퍼는 그룹을 하나의 평탄한 IntArray에 저장하며, 다음 쓰기가 발생할 위치로 이동하는 빈 영역(갭)을 함께 관리합니다. 갭 위치에서의 순차적 삽입은 효율적이지만, 다른 위치에서 그룹을 삭제하거나 순서를 변경하면 갭이 해당 위치까지 먼저 이동해야 합니다. 이때 현재 갭 위치와 목표 위치 사이에 있는 모든 요소가 복사됩니다.

예를 들어, 1,000개의 그룹으로 구성된 컴포지션에서 500번째 그룹을 삭제하려면, 갭이 500개의 위치를 이동하면서 요소를 하나씩 복사해야 합니다. 100개 항목이 있는 리스트에서 하나의 항목 순서를 변경하려면 두 번의 갭 이동(하나는 추출, 하나는 재삽입)이 필요하며, 각각 배열의 일부를 복사하게 됩니다. 컴포지션이 커질수록 이러한 복사 작업이 리컴포지션 중 구조적 변경의 주된 비용으로 자리 잡습니다. 즉, UI 트리가 복잡해질수록 성능 저하가 점점 더 심해지는 구조적 한계를 가지고 있는 셈입니다.

gap-buffer-slottable

LinkBuffer: 위치 기반 대신 포인터 기반으로

LinkBuffer는 평탄한 배열 레이아웃을 연결 리스트 구조로 교체합니다. 메모리 효율성을 위해 그룹은 여전히 IntArray에 저장되지만, 각 그룹은 이제 형제(sibling), 부모(parent), 자식(child)에 대한 명시적 포인터 필드를 통해 연결된 6개 필드 레코드로 구성됩니다.

internal const val SLOT_TABLE_GROUP_SIZE = 6

private const val SLOT_TABLE_GROUP_KEY_OFFSET = 0      // 컴포저블 식별자
private const val SLOT_TABLE_GROUP_NEXT_OFFSET = 1     // 다음 형제 그룹
private const val SLOT_TABLE_GROUP_PARENT_OFFSET = 2   // 부모 그룹
private const val SLOT_TABLE_GROUP_CHILD_OFFSET = 3    // 첫 번째 자식 그룹
private const val SLOT_TABLE_GROUP_FLAGS_OFFSET = 4    // 그룹 속성 플래그
private const val SLOT_TABLE_GROUP_SLOTS_OFFSET = 5    // 슬롯 데이터 범위

각 그룹은 다음 형제, 부모, 첫 번째 자식을 가리키는 포인터를 보유합니다. 따라서 컴포지션 트리를 순회할 때 갭을 기준으로 배열 오프셋을 계산하는 대신, 포인터를 따라가는 방식으로 탐색할 수 있습니다.

핵심 장점은, 구조적 연산이 배열 복사가 아닌 포인터 업데이트로 처리된다는 것입니다.

그룹 삭제 시에는 형제 체인에서 해당 노드의 연결을 해제합니다. 구체적으로, 선행 노드의 next 포인터가 삭제된 노드를 건너뛰도록 변경하고, 해제된 슬롯은 재사용을 위해 프리 리스트(free list)에 추가됩니다. 메모리 상에서 어떤 요소도 이동하지 않습니다.

그룹 이동(순서 변경) 시에는 기존 위치에서 연결을 해제한 뒤 새 위치에 연결하면 됩니다. 두 위치 사이의 거리나 컴포지션의 크기에 관계없이, 단 두 번의 포인터 업데이트만으로 완료됩니다.

그룹 삽입 시에는 프리 리스트에서 슬롯을 할당받거나(프리 리스트가 비어 있으면 배열을 확장), 새 노드를 체인에 연결합니다.

linkbuffer-slottable

슬롯 저장: 범프 할당과 압축

갭 버퍼는 슬롯 데이터(각 그룹에 저장되는 실제 값, 예를 들어 remember 결과 등)도 두 번째 갭 버퍼를 사용하여 관리했습니다. LinkBuffer는 이를 범프 할당(bump allocation)으로 대체합니다. 새로운 슬롯은 슬롯 배열의 끝에 순차적으로 추가됩니다. 각 그룹은 크기에 4비트, 시작 주소에 28비트를 사용하는 단일 정수로 인코딩된 SlotRange를 저장합니다. 대부분의 컴포저블 그룹이 15개 미만의 슬롯을 가지므로, 별도의 보조 조회 없이도 인라인 인코딩으로 일반적인 경우를 충분히 처리할 수 있습니다.

슬롯 배열이 가득 차면 할당자가 압축(compaction) 과정을 실행합니다. 살아있는 모든 그룹을 스캔하여 슬롯 데이터를 배열 앞쪽에 연속적으로 복사하고, 슬롯 범위를 갱신합니다. 이 과정이 슬롯 데이터를 복사하는 유일한 시점이며, 배열이 가득 찬 경우에만 발생합니다. 압축 사이의 할당은 포인터를 하나 증가시키는 것만으로 완료됩니다. 또한 할당자는 압축 중 이동된 각 범위 뒤에 작은 여유 버퍼(8개의 추가 슬롯)를 예약하여, 그룹이 커지더라도 즉시 또 다른 압축이 발생하지 않도록 합니다.

성능 차이가 가장 크게 나타나는 시나리오

모든 컴포지션이 LinkBuffer로부터 동일한 수준의 혜택을 받는 것은 아닙니다. 성능 향상이 가장 두드러지는 경우는 빈번한 구조적 변경이 발생하는 시나리오입니다.

리스트 순서 변경

Column이나 Row 안의 항목 순서가 변경될 때(예: 드래그 앤 드롭이나 정렬 변경), 갭 버퍼는 그룹을 새 위치로 이동시키면서 이동할 때마다 요소를 복사해야 합니다. N개 항목의 순서를 변경하면 N번의 갭 이동이 필요하고, 각 이동 비용은 이동 거리에 비례합니다. 반면 LinkBuffer는 포인터를 재연결하는 방식으로 순서를 변경하므로 각 이동이 O(1)입니다. 수백 개 항목이 있는 리스트에서 "2배 이상 빠르다"는 벤치마크 결과가 바로 이 시나리오에서 나온 것입니다.

조건부 콘텐츠

if/when 블록으로 컴포저블 콘텐츠를 추가하거나 제거하면 SlotTable에 삽입과 삭제가 발생합니다. 갭 버퍼에서 현재 갭으로부터 먼 위치에 삽입하려면 전체 갭 이동이 필요합니다. 반면 LinkBuffer는 트리의 어느 위치에서 변경이 발생하든 상수 시간의 연결/해제 연산으로 처리합니다.

MovableContent

movableContentOf는 컴포저블 콘텐츠를 트리의 한 위치에서 다른 위치로 이동하면서 상태를 유지하는 기능입니다. 갭 버퍼에서는 그룹을 한 위치에서 추출하고(갭 이동 + 복사) 다른 위치에 삽입하는(두 번째 갭 이동 + 복사) 과정이 필요합니다. LinkBuffer는 하위 트리의 연결을 해제한 뒤 새 위치에 재연결하기만 하면 되므로, 데이터 복사가 전혀 발생하지 않습니다.

LazyColumn과 SubcomposeLayout

LazyColumnSubcomposeLayout을 사용하여 레이아웃 단계에서 필요에 따라 항목을 컴포즈합니다. 항목이 스크롤되면서 화면에 나타나고 사라질 때, SlotTable에서 그룹이 추가되고 제거됩니다. 갭 버퍼는 갭 위치에서 발생하는 추가 작업을 효율적으로 처리하며(SubcomposeLayout은 일반적으로 이를 잘 배치합니다), 따라서 안정 상태의 스크롤에서는 2배가 아닌 일반적인 10% 수준의 성능 향상에 가깝습니다. 다만, 먼 위치로 점프하는 scrollToItem 같은 연산은 갭에서 벗어난 위치에서 구조적 변경을 발생시키므로, 더 큰 성능 이점을 얻을 수 있습니다.

메모리 트레이드오프

연결 리스트 구조는 그룹당 갭 버퍼의 5개 필드 대신 6개 필드를 사용하므로, 그룹당 메모리가 20% 증가합니다. 프리 리스트도 해제된 그룹을 추적하기 위한 소량의 오버헤드를 추가합니다. 하지만 실제로는 슬롯용 두 번째 갭 버퍼가 제거되고, 구조적 변경 시 필요한 임시 메모리(중간 복사본)가 감소하면서 이를 상쇄합니다. 대부분의 앱에서 메모리 차이는 성능 향상에 비하면 무시할 수 있는 수준입니다.

릴리스 노트 주요 내용

Compose Runtime 1.10+ 릴리스 노트에서는 새로운 SlotTable을 다음과 같이 설명하고 있습니다. 이번 재작성은 갭 버퍼보다 메모리 복사 연산을 대폭 줄여 리컴포지션 성능을 높이는 데 초점을 맞추고 있습니다. 긴 항목 리스트의 순서 변경처럼 컴포지션 계층 구조에 변화를 주는 연산은 2배 이상 빠르게 리컴포즈할 수 있습니다. 대부분의 다른 연산은 약 10% 수준의 성능 향상을 보여 줍니다. SlotTable 구현은 모든 컴포저블 메서드에 영향을 미치며, 재컴파일이 필요하지 않습니다. 새로운 SlotTable 사용은 의존성을 포함하여 앱 전체에 적용되는 변경입니다.

이 구현은 아직 실험적이며 기본적으로 비활성화되어 있습니다. ComposeRuntimeFlags.isLinkBufferComposerEnabledtrue로 설정하면 활성화할 수 있습니다. 난독화가 적용된 릴리스 빌드에서는, 런타임에 포함된 기본 proguard-rules.pro 파일에서 이 값을 false로 가정합니다. 프로덕션에서 새로운 SlotTable을 활성화하려면, 앱의 ProGuard 규칙 파일에서 이 가정값을 true로 변경해야 합니다.

릴리스 노트에서 특별히 강조할 만한 두 가지 사항이 있습니다. 첫째, "재컴파일이 필요하지 않다"는 것은 이것이 순수한 런타임 변경이라는 뜻입니다. Compose 컴파일러 플러그인을 업데이트하거나 컴포저블을 다시 빌드할 필요가 없습니다. 동일한 컴파일된 바이트코드가 어떤 SlotTable 구현에서든 동작합니다. 둘째, "의존성을 포함하여 앱 전체에 적용되는 변경"이라는 것은, 의존성 트리에 있는 모든 라이브러리의 컴포저블도 새로운 SlotTable 위에서 실행된다는 의미입니다. 자체 코드에만 활성화하고 라이브러리에는 비활성화하거나, 그 반대로 하는 것은 불가능합니다. 이는 의도된 설계로, SlotTable은 전체 컴포지션을 위한 단일 공유 데이터 구조이기 때문입니다.

LinkBuffer 활성화 방법

LinkBuffer는 실험적 기능이며 기본적으로 비활성화되어 있습니다. 활성화하려면 첫 번째 setContent() 호출 전에 기능 플래그를 설정해야 합니다.

ComposeRuntimeFlags.isLinkBufferComposerEnabled = true

이 설정은 의존성의 컴포저블을 포함하여 앱 전체에 적용됩니다. 런타임은 두 구현을 동시에 혼합하여 사용하는 것을 지원하지 않습니다.

R8을 사용하는 릴리스 빌드에서는 ProGuard 규칙을 통해 플래그를 구성해야 합니다. Compose 런타임은 플래그가 false라고 가정하는 기본 규칙을 함께 배포하므로, R8이 LinkBuffer 코드를 완전히 제거합니다. LinkBuffer를 유지하려면 앱의 ProGuard 규칙에 다음을 추가하세요.

-assumevalues public class androidx.compose.runtime.ComposeRuntimeFlags {
    static boolean isLinkBufferComposerEnabled return true;
}

이 규칙 없이 릴리스 빌드에 두 구현을 모두 남겨 두면, R8이 Compose 런타임 내의 호출을 역가상화(devirtualize)할 수 없게 됩니다. 그 결과, JIT가 가상 메서드 호출을 인라인할 수 없어 측정 가능한 수준의 성능 저하가 발생합니다. R8이 사용하지 않는 구현을 제거할 수 있도록, ProGuard 규칙의 값을 반드시 플래그 값과 일치시켜야 합니다.

갭 버퍼 vs LinkBuffer: 연산 복잡도 비교

연산갭 버퍼LinkBuffer
순차적 삽입분할 상환 O(1)O(1)
임의 위치 삽입O(N) 갭 이동O(1) 포인터 연결
삭제O(N) 갭 이동O(1) 연결 해제 + 해제
이동 / 순서 변경O(N) x 2 갭 이동O(1) x 2 포인터 업데이트
읽기 / 순회그룹당 O(1)그룹당 O(1)
N개 항목 순서 변경O(N x M), M = 평균 이동 거리O(N)

gap-vs-linkbuffer-comparison

위 표는 컴포지션 크기가 커질수록 성능 격차가 벌어지는 이유를 보여 줍니다. 작고 정적인 UI에서는 두 구현 모두 비슷한 성능을 보입니다. 반면, 빈번한 구조적 변경이 발생하는 크고 동적인 UI에서는 LinkBuffer의 상수 시간 연산이 누적되어 상당한 성능 절감 효과를 가져옵니다.

코드 구조

아키텍처 변경은 코드 구조에도 그대로 반영되어 있습니다. 갭 버퍼 구현은 4,243줄짜리 단일 모놀리식 파일이었습니다. LinkBuffer는 동일한 기능을 5개의 집중된 파일로 분해합니다.

  • SlotTable (1,268줄): 공개 API 및 테이블 생명주기 관리
  • SlotTableAddressSpace: 그룹 및 슬롯 저장, 범프 할당, 프리 리스트 관리
  • SlotTableEditor: 구조적 편집 연산 (삽입, 삭제, 이동)
  • SlotTableBuilder: movableContentOf를 위한 서브테이블 생성
  • SlotTableReader: 리컴포지션 중 읽기 전용 순회

각 파일의 자세한 설명과 구현된 알고리즘에 대해서는 갭 버퍼에서 연결 리스트로를 참고하시기 바랍니다.

결론

이 글에서는 새로운 LinkBuffer SlotTable과 Compose의 내부 컴포지션 추적 방식이 어떻게 변경되었는지 살펴보았습니다. 컴포지션 크기에 비례하여 선형적으로 증가하던 갭 버퍼의 배열 복사 비용이, 삭제, 이동, 순서 변경에 대해 상수 시간 포인터 연산으로 대체되었습니다. 슬롯 저장 방식도 두 번째 갭 버퍼에서 주기적 압축이 포함된 범프 할당으로 전환되었습니다. 그 결과, 리스트 순서 변경 시나리오에서 리컴포지션이 2배 이상 빨라졌으며, 일반적인 연산에서도 약 10%의 성능 향상을 달성했습니다.

실제 체감 효과는 앱의 UI 패턴에 따라 달라집니다. 정적인 레이아웃을 가진 앱은 일반적인 10% 수준의 향상을 경험하게 됩니다. 반면, 드래그 앤 드롭 리스트, 애니메이션이 적용된 순서 변경, 조건부 콘텐츠 변경, movableContentOf 활용이 많은 앱에서는 가장 큰 성능 향상을 경험할 수 있습니다. 이러한 패턴이 바로 LinkBuffer가 탁월한 성능을 발휘하는 구조적 연산을 유발하기 때문입니다.

LinkBuffer 활성화는 단일 플래그 변경과 이에 대응하는 ProGuard 규칙 설정만으로 가능하며, 재컴파일이 필요하지 않습니다. 동적 UI에서 리컴포지션 병목을 겪고 있는 앱이라면, 코드를 단 한 줄도 수정하지 않으면서 앱의 모든 컴포저블 속도를 높여 주는, 현시점에서 가장 효과적인 런타임 최적화 수단 중 하나라고 할 수 있습니다.

아티클 목록으로 가기