컴포즈 런타임(The Compose Runtime)

Compose Runtime 내부 동작 정리: SlotTable → ChangeList → Applier → setContent

Compose를 쓰다 보면 “상태 바뀌면 알아서 UI가 갱신된다” 정도로 이해하고 넘어가기 쉬운데, 내부에서는 꽤 체계적인 파이프라인이 돌아간다. 핵심은 한 문장으로 요약하면 이렇다.

SlotTable에 컴포지션 상태를 저장하고, 변경은 ChangeList에 모아뒀다가, Applier가 실제 노드 트리에 반영한다.

이 글은 그 흐름을 SlotTable/ChangeList/Applier/setContent 관점에서 정리한 내용이다.


1) SlotTable: “현재 컴포지션 상태 저장소”

SlotTable은 Compose Runtime이 Composition의 현재 상태를 들고 있기 위해 사용하는 인메모리 구조다.

  • 초기 composition 때 테이블을 채우고

  • 이후 recomposition 때마다 갱신한다

  • remember 값, 호출 위치 기반 그룹 정보, 매개변수 관련 정보 등이 여기 쌓인다

즉, 런타임이 “지금 UI가 어떤 구조/상태인지”를 판단하는 기준이 SlotTable이다.


2) 내부 구조: 갭 버퍼 기반 + (groups, slots) 2개 배열

SlotTable은 “빠르게 읽고/수정”하기 위해 갭 버퍼(gap buffer) 아이디어를 활용한다. 구현 관점에서 크게 두 덩어리로 나뉜다.

  • groups: 그룹(Composable 단위) 메타데이터를 저장하는 영역

  • slots: 그 그룹에 속하는 실제 데이터(예: remember 값)를 저장하는 영역

그리고 갭(gap) 이라는 “비어 있는 연속 구간”을 두고, 삽입/삭제/교체가 일어나면 갭을 이동시키면서 덮어쓰는 방식으로 비용을 줄인다.

예를 들어 조건 분기 UI가 있다면, 조건이 바뀔 때마다 트리를 다 갈아엎는 대신 기존 위치로 갭이 이동한 뒤 필요한 부분만 덮어쓰기에 가까운 방식이 가능해진다.


3) NonRestartableComposable과 “그룹 타입” 감각 잡기

예를 들어 아래처럼 단순한 조건 분기가 있다고 하자(개념 예시):

@NonRestartableComposable
fun ConditionalText() {
  if (a) Text("a") else Text("b")
}

여기서 포인트는 @NonRestartableComposable 같은 힌트 때문에 런타임이 재시작 가능한 그룹(restartable) 대신, 상황에 따라 교체 가능한 그룹(replaceable) 형태로 잡아버릴 수 있다는 것.

정확한 내부 동작은 케이스마다 다르지만, 스터디 관점에서는 이렇게 이해하면 편하다.

  • “재시작 가능”은 부분 recomposition의 범위를 만들 여지가 큼

  • “교체 가능”은 그 위치를 통째로 갈아끼우는 모델에 더 가깝다


4) SlotReader / SlotWriter: 읽기는 다중, 쓰기는 단일

SlotTable을 접근하는 역할도 분리된다.

  • SlotReader: 여러 개가 동시에 존재 가능(읽기 전용 탐색)

  • SlotWriter: 단 하나만 존재 가능(테이블 수정)

Writer가 단일인 이유는 간단하다. 동시에 여러 Writer가 들어오면, 갭 이동/삽입/삭제 같은 작업에서 데이터 정합성이 깨질 위험이 커지기 때문.

그래서 코드 레벨에서도 “Writer 열기 전에 Reader가 모두 닫혀 있어야 한다” 같은 제약이 강하게 걸려 있다. (SlotTable 내부 주석에 이런 내용이 꽤 직접적으로 명시됨)


5) ChangeList: “지연된 변경사항 큐(명령 리스트)”

SlotTable이 “상태 저장소”라면, **ChangeList는 ‘할 일 목록’**에 가깝다.

Composable이 실행되면서 “노드를 만들자/옮기자/삭제하자/속성을 바꾸자” 같은 작업이 발생하는데, 이걸 즉시 트리에 반영하지 않고 명령(operations) 형태로 차곡차곡 쌓아둔다.

이게 ChangeList의 본질이다.

  • Compose 변경사항을 순차 저장

  • 컴포지션이 끝나는 타이밍에 일괄 실행(flush)

그래서 ChangeList는 “명령 큐(Command Queue)” 같은 표현이 오히려 직관적이다.


6) Layout이 노드를 “즉시 생성”하는 게 아닌 이유

Compose UI의 기본 빌딩블록 중 하나인 Layout을 보면 내부에서 ReusableComposeNode 같은 걸 호출한다.

이걸 보고 “아 Layout이 LayoutNode를 바로 만들고 붙이는구나” 라고 오해하기 쉬운데, 실제로는 이렇게 보는 편이 정확하다.

Layout은 Composer에게 ‘이 위치에서 노드를 생성/사용하고 업데이트하라’는 규칙을 알려주는 역할이고,
실제로 트리 반영은 ChangeList → Applier 단계에서 확정된다.

ReusableComposeNode 내부 흐름도 그 감각을 잘 보여준다.

  • 삽입 중이면 create

  • 아니면 use(재사용)

  • update 블록으로 속성 반영

  • 자식 content는 replaceable group으로 감싼 뒤 호출

  • 마지막에 endNode로 닫기

즉, “지금 당장 트리에 붙이는 코드”라기보다, 런타임이 트리를 만들 수 있도록 절차를 예약/기록하는 쪽에 가깝다.


7) Applier: 실제 노드 트리에 반영하는 담당자

ChangeList에 쌓인 작업은 결국 누가 실행할까? → Applier다.

Applier는 인터페이스 자체가 매우 명확하다.

  • down / up 으로 트리 탐색

  • insert / remove / move 같은 구조 변경

  • apply로 current 노드에 속성 적용

Compose UI(Android)에서는 보통 UiApplier가 쓰이고, 노드 타입은 LayoutNode다.

그리고 Android 쪽 구현에서는 bottom-up 삽입 전략을 선호한다.
이유는 요약하면 다음처럼 이해할 수 있다.

  • top-down은 “부모에 넣는 순간 부모/상위가 계속 알림받아야” 해서 비용이 커질 수 있음

  • bottom-up은 “자식 조립 후 부모에 한번에 붙이기”가 가능해, 중복 알림을 줄이기 좋음

그래서 UiApplier는 insertTopDown은 사실상 무시하고, insertBottomUp에서 current.insertAt(...) 같은 식으로 위임하는 구조를 취한다.


8) setContent: “컴포지션 생성”의 진입점

마지막으로 Android에서 컴포지션이 어떻게 시작되냐는 질문은 결국 setContent로 귀결된다.

  • AndroidComposeView를 준비(없으면 생성)

  • Composition(UiApplier(root), parentContext) 를 만들어 래핑

  • 이후 WrappedComposition이 각종 안드로이드 환경(LocalContext, LocalConfiguration 등)을 CompositionLocal로 공급할 수 있게 세팅

그래서 우리가 아무렇지 않게 쓰는 LocalContext, LocalConfiguration, LocalLifecycleOwner 같은 값이 “그냥 magically 생기는 것”이 아니라, 초기 Composition을 만들 때 플랫폼 쪽에서 Provider를 깔아주는 구조라는 걸 이해할 수 있다.


9) Snapshot과 MutableState 내부 감각

초기 컴포지션/재구성에서 빠지지 않고 나오는 게 Snapshot 시스템인데, 스터디 관점에서는 아래 정도 감각만 잡아도 도움이 된다.

  • State 값의 읽기/쓰기는 “현재 스냅샷”을 기준으로 동작

  • 변경은 스냅샷 컨텍스트 안에서 안전하게 일어나고

  • 마지막에 apply로 원자적으로 반영되는 모델

mutableStateOf 내부를 보면 StateRecord를 통해 “스냅샷별 값”을 유지하는 식으로 설계되어 있고, 레코드가 next로 연결되면서 “현재 스냅샷에서 읽어야 할 record”를 찾아가는 형태가 보인다.

(여기까지 들어가면 runtime 스터디가 갑자기 snapshot 자료구조 스터디로 확장되니, 일단은 “스냅샷 단위로 상태를 안전하게 관리한다” 정도만 가져가도 충분하다고 봄)


최종 흐름 한 줄 정리

  1. Composable 실행 → SlotTable 기반으로 현재 상태를 읽고

  2. 변경은 ChangeList에 “명령” 형태로 누적하고

  3. 컴포지션이 끝나면 Applier(UiApplier)가 그 명령을 실행해서

  4. 실제 LayoutNode 트리가 갱신된다

  5. Android에선 setContent가 이 전체 시스템을 시작시키는 관문이다

댓글

이 블로그의 인기 게시물