[Compose Internals] 7. Compose Runtime 고급 사용 사례

벼리·2026년 1월 25일

Compose

목록 보기
7/10

지금까지는 Compose를 안드로이드 UI 개발 도구로만 생각했을 수 있습니다. 하지만 Compose의 잠재력은 안드로이드나 UI 영역을 훨씬 뛰어넘습니다. 이번 장에서는 Compose의 더 넓은 활용 가능성을 이해하기 위해 핵심 개념을 살펴보겠습니다.

Compose UI와 Compose Runtime의 분리

Compose의 내부 구조를 이해하려면 먼저 Compose UI와 Compose Runtime을 명확히 구분해야 합니다.

Compose UI

우리가 흔히 쓰는 안드로이드 UI 툴킷입니다. 캔버스 위에 그림을 그리고 레이아웃을 배치하는 역할을 담당합니다.

Compose Runtime

Compose의 작동 원리 그 자체입니다. 상태(State)를 관리하고, 변경 사항을 감지하여 트리(Tree) 구조를 업데이트하는 엔진 역할을 합니다.

중요한 점은 런타임이 UI나 안드로이드에 대해 전혀 모른다는 것입니다. 런타임은 그저 트리 구조를 효율적으로 관리하는 도구일 뿐입니다. 따라서 코틀린이 실행되는 환경이라면 어디서든 이 런타임을 가져와 사용할 수 있습니다.

이는 React.js와 매우 비슷합니다. React가 웹(DOM)뿐만 아니라 React Native, 3D 렌더러 등 다양한 곳에 쓰이는 것처럼, Compose Runtime도 UI가 아닌 다른 트리 구조를 다루는 데 충분히 사용될 수 있습니다.

멀티플랫폼으로의 확장

Compose Runtime의 유연성 덕분에, JetBrains는 이를 활용하여 코틀린 멀티플랫폼(KMP) 지원을 확장하고 있습니다.

Compose for Desktop (JVM) 안드로이드 구현과 매우 유사합니다. 그래픽 엔진인 Skia를 사용하여 렌더링하므로, 안드로이드 Compose UI 코드를 거의 그대로 재사용할 수 있습니다. 마우스와 키보드 지원이 추가되었습니다.

Compose for iOS Desktop과 마찬가지로 Skia 엔진을 사용하여 화면을 그립니다. 코틀린/네이티브(Kotlin/Native) 기술을 통해 로직의 대부분을 공유합니다.

Compose for Web 웹은 두 가지 방식이 있습니다.

  • DOM 방식: 초기에 나온 방식으로, HTML/CSS 태그를 제어합니다. 컴파일러와 런타임만 재사용하고 UI 구성 요소는 웹 표준을 따릅니다.
  • Wasm(Canvas) 방식: Skia 엔진을 웹에서 돌리는 방식입니다. 이렇게 되면 안드로이드, 데스크탑, iOS, 웹이 모두 Skia라는 하나의 렌더링 엔진을 공유하게 되어 진정한 멀티플랫폼 UI 공유가 가능해집니다.

웹 어셈블리(Wasm)와 브라우저 호환성

최근 코틀린은 웹 어셈블리(Wasm) 지원을 통해 웹에서도 고성능 그래픽 처리를 시도하고 있습니다. 하지만 아직 주의할 점이 있습니다.

현재 모든 브라우저가 Wasm의 기능을 완벽하게 지원하지는 않습니다. 특히 iOS의 사파리(Safari)나 크롬 등에서 호환성 문제가 발생할 수 있습니다. 다행히 유럽 등의 규제 변화로 iOS에서도 자체 브라우저 엔진 사용이 허용되는 추세라 상황이 나아지고 있지만, Wasm을 실무에 도입하려면 브라우저 지원 현황을 꼼꼼히 체크해야 합니다.

요약하자면, Compose는 단순한 UI 라이브러리가 아니라 상태와 트리 구조를 관리하는 범용적인 엔진이며, 이를 바탕으로 다양한 플랫폼으로 영역을 넓혀가고 있습니다.

Composition 재소개 ((Re‑) Introducing composition)

이번 장에서는 컴포즈가 자동으로 처리해주던 컴포지션(Composition)을 우리가 직접 생성하고 관리하는 방법을 알아봅니다.

참고: 질문의 후반부에 포함된 일시 중단 이펙트(rememberCoroutineScope, LaunchedEffect 등) 내용은 이전 장에서 다룬 내용과 동일하여, 이번에는 새로운 내용인 컴포지션 재소개 부분만 정리했습니다.

컴포지션의 정체

컴포지션은 단순히 UI를 그리는 도구가 아닙니다. 컴포지션은 모든 Composable 함수가 실행될 수 있는 문맥(Context)을 제공하는 핵심 엔진입니다.

컴포지션의 3가지 핵심 요소

  • 슬롯 테이블(SlotTable): 데이터와 상태를 기억하는 캐시 저장소입니다.
  • 어플라이어(Applier): 트리 구조(UI 등)를 실제로 생성하고 수정하는 도구입니다.
  • 리컴포저(Recomposer): 상태 변경을 감지하고 컴포지션을 다시 실행(리컴포지션)하여 화면을 갱신하는 운전사 역할을 합니다.

보통은 안드로이드 프레임워크가 알아서 컴포지션을 만들어주지만, 우리가 원한다면 직접 만들 수도 있습니다.

컴포지션 직접 만들기

컴포지션을 수동으로 생성하려면 다음과 같은 팩토리 함수를 사용합니다.

fun Composition(
    applier: Applier<*>,
    parent: CompositionContext
): Composition

매개변수 설명

  • applier
    • 트리를 어떻게 만들고 연결할지 정의한 객체입니다.
    • 안드로이드 UI가 아닌 다른 트리(예: HTML DOM, 벡터 그래픽)를 만들고 싶다면 이 부분을 커스텀하면 됩니다.
  • parent
    • 부모의 문맥을 연결합니다.
    • 보통 rememberCompositionContext()를 통해 현재 환경의 문맥을 가져와서 전달합니다. 이렇게 하면 리컴포저와 생명주기가 연결됩니다

재미있는 활용법: UI 없는 컴포즈

만약 Applier에 아무런 기능도 넣지 않는다면(Applier) 어떻게 될까요?
노드(UI)를 생성하지 않고, 오직 컴포즈의 상태 관리 능력과 로직만 빌려 쓸 수 있습니다.

실제로 Cash App의 Molecule이라는 라이브러리가 이 방식을 사용합니다. 화면을 그리는 대신, 데이터 스트림을 관리하거나 복잡한 이벤트 로직을 처리하는 용도로 컴포즈 런타임만 사용하는 것이죠.

앞으로 다룰 내용

이전 장에서 우리는 컴포즈 런타임이 UI와 분리될 수 있다는 것을 배웠습니다. 이제부터는 안드로이드 UI(Compose UI) 없이 순수하게 컴포즈 런타임만 사용하는 예시들을 직접 만들어 볼 것입니다.

  • 벡터 그래픽 렌더링: 사용자 정의 트리를 이용해 그림 그리기
  • 브라우저 DOM 관리: 코틀린/JS 환경에서 컴포즈로 HTML 태그를 조작하는 미니 라이브러리 만들기

이제 본격적으로 컴포즈의 엔진을 분해하고 조립해 보겠습니다.

벡터 그래픽의 구성

Compose에서 벡터 그래픽을 렌더링하는 방식과 내부 구조에 대해 알아보겠습니다. 이 내용은 Compose가 UI뿐만 아니라 어떻게 다른 형태의 트리 구조(여기서는 벡터 이미지)를 관리하는지 보여주는 훌륭한 예시입

Compose는 안드로이드의 Drawable과 유사한 Painter라는 추상화 도구를 사용하여 벡터 이미지를 그립니다.

Image(
    painter = rememberVectorPainter { width, height ->
        Group(
            scaleX = 0.75f,
            scaleY = 0.75f
        ) {
            val pathData = PathData { /* ... */ }
            Path(pathData = pathData)
        }
    }
)

위 코드에서 GroupPath는 우리가 아는 일반적인 UI 컴포저블과는 다릅니다. UI 컴포저블이 LayoutNode를 만든다면, 이들은 벡터 드로잉에 특화된 노드를 생성합니다.

별도의 컴포지션(Composition)

rememberVectorPainter 내부의 블록은 일반 UI와는 격리된 별도의 컴포지션 영역입니다. 따라서 여기에는 벡터 관련 요소만 올 수 있습니다.

  • 가능: Group, Path 등 벡터 전용 컴포저블
  • 불가능: Box, Image, Text 등 일반 UI 컴포저블

주의할 점은, 벡터 블록 안에 Box 같은 UI 컴포저블을 넣어도 컴파일 에러가 나지 않고 실행 시(런타임)에 무시되거나 문제가 생길 수 있다는 점입니다. 추후 업데이트를 통해 컴파일 시점에 이를 잡아낼 수 있도록 개선될 예정입니다.

하지만 상태(State)나 애니메이션 같은 Compose의 핵심 기능은 똑같이 동작합니다. 즉, 벡터의 크기나 색상을 애니메이션으로 부드럽게 변경하는 것이 가능합니다.

벡터 이미지 트리 구축 (Building vector image tree)

벡터 이미지는 LayoutNode보다 훨씬 단순한 구조인 VNode로 구성됩니다. 이는 기존 안드로이드의 XML 벡터 구조와 거의 동일합니다.

sealed class VNode {
    abstract fun DrawScope.draw()
}

// 루트 노드 또는 컨테이너 역할
internal class GroupComponent : VNode() {
    private val children = mutableListOf<VNode>()
    // ...
    override fun DrawScope.draw() {
        // 자식 노드들을 그리기 (변형 적용)
    }
}

// 실제로 선을 그리는 리프(Leaf) 노드
internal class PathComponent : VNode() {
    var pathData: List<PathNode>
    // ...
    override fun DrawScope.draw() {
        // 경로(Path) 그리기
    }
}

트리의 구성 요소

  • GroupComponent: 자식 노드들을 묶어주고 회전, 스케일 같은 변형(Transform)을 적용합니다.
  • PathComponent: 자식이 없는 말단 노드로, 실제 선이나 면을 그립니다.

ComposeNode와 Applier의 역할

우리가 Group이나 Path라는 컴포저블 함수를 호출하면, 내부적으로 ComposeNode가 호출되어 실제 VNode 객체를 생성하고 트리에 등록합니다.

@Composable
fun Group(
    scaleX: Float = DefaultScaleX,
    content: @Composable () -> Unit
) {
    ComposeNode<GroupComponent, VectorApplier>(
        factory = { GroupComponent() },
        update = {
            set(scaleX) { this.scaleX = it }
            // ... 값이 변경되면 속성 업데이트
        },
        content = content
    )
}

ComposeNode의 핵심 파라미터

  • factory: 노드를 처음 생성하는 방법을 정의합니다. (예: GroupComponent 생성)
  • update: 이미 생성된 노드의 속성을 효율적으로 변경합니다. 값이 바뀌었을 때만 실행되어 불필요한 작업을 줄입니다.
  • content: 자식 노드들을 배치하는 공간입니다.

VectorApplier (트리 관리자)

생성된 노드들을 실제 트리 구조로 조립하는 역할은 VectorApplier가 담당합니다.

class VectorApplier(root: VNode) : AbstractApplier<VNode>(root) {
    override fun insertTopDown(index: Int, instance: VNode) {
        // 트리의 위에서 아래로(Top-Down) 노드를 삽입합니다.
        current.asGroup().insertAt(index, instance)
    }
    
    // ... 삭제, 이동 등의 로직 구현
}

Applier는 트리를 조립할 때 두 가지 방식(Top-Down, Bottom-Up)을 지원하는데, 벡터 그래픽은 Top-Down 방식을 사용합니다.

  • Top-Down (하향식): 부모를 먼저 만들고 자식을 끼워 넣습니다.
  • Bottom-Up (상향식): 자식을 다 만든 뒤 부모에게 끼워 넣습니다.

기존 안드로이드 View 시스템과 달리, 벡터 그래픽은 자식을 추가할 때 복잡한 레이아웃 재계산 비용이 들지 않으므로 직관적인 Top-Down 방식이 효율적입니다.

Compose UI에 벡터 composition 통합하기

이번 장에서는 우리가 만든 벡터 그래픽 시스템(Applier)을 실제 Compose UI와 연결하는 방법을 배웁니다.

💡 책에서 소개하는 RenderVector라는 함수는 최신 Compose 버전에서 성능 최적화를 위해 제거되거나 변경되었습니다. 하지만 이 코드는 서로 다른 두 개의 컴포지션(UI와 벡터)을 어떻게 연결하고 관리하는지 보여주는 중요한 설계 패턴을 담고 있습니다. 코드 자체보다는 작동 원리에 집중해 주세요.

UI와 벡터의 연결 고리 만들기

벡터 이미지를 그리는 Painter를 만들기 위해서는 가장 먼저 Compose UI의 컴포지션과 우리가 만든 벡터 이미지의 컴포지션을 연결해야 합니다.

이 연결을 담당하는 핵심 로직은 다음과 같습니다.

  1. 부모 문맥 가져오기
    • rememberCompositionContext를 사용해 현재 UI의 상태와 설정(CompositionLocal 등)을 가져옵니다.
    • 이를 통해 벡터 컴포지션도 부모(UI)의 상태를 공유할 수 있습니다.
  2. 생명주기 관리
    • UI 화면에서 이 벡터 이미지가 사라지면, DisposableEffect를 통해 벡터 컴포지션도 메모리에서 해제(Dispose)해 줍니다.
class VectorPainter internal constructor() : Painter() {
    // ...

    // 1. UI 컴포지션 문맥에서 호출되는 함수
    @Composable
    internal fun RenderVector(
        content: @Composable () -> Unit
    ) {
        // 2. 부모(UI)의 문맥(Context)을 캡처합니다.
        // 이를 통해 테마나 밀도(Density) 같은 설정이 벡터 컴포지션으로 전달됩니다.
        val composition = composeVector(
            rememberCompositionContext(),
            content
        )

        // 3. UI에서 이 Painter가 더 이상 쓰이지 않으면
        // 벡터 컴포지션도 함께 정리(Dispose)합니다.
        DisposableEffect(composition) {
            onDispose {
                composition.dispose()
            }
        }
    }

    private fun composeVector(
        parent: CompositionContext,
        composable: @Composable () -> Unit
    ): Composition {
        // 구현 내용은 아래에서 계속됩니다.
        return TODO()
    }
}

벡터 트리 구축 및 그리기

이제 실제로 벡터 노드 트리를 만들고 내용을 채워 넣는 composeVector 함수와, 화면에 그림을 그리는 onDraw 함수를 구현해 봅시다.

여기서 중요한 점은 VectorApplier를 사용해 별도의 컴포지션을 생성한다는 것입니다. 일반적인 UI Applier는 벡터 노드를 이해하지 못하기 때문입니다.

class VectorPainter : Painter() {
    // 벡터 트리의 최상위 루트 노드
    private val vector = VectorComponent()
    
    // 벡터 요소들을 관리할 별도의 컴포지션
    private var composition: Composition? = null

    @Composable
    internal fun RenderVector(
        content: @Composable () -> Unit
    ) {
        // 위에서 본 연결 로직이 들어갑니다.
    }

    private fun composeVector(
        parent: CompositionContext,
        composable: @Composable () -> Unit
    ): Composition {
        // 1. 컴포지션이 없거나 이미 해제되었다면 새로 만듭니다.
        val composition =
            if (this.composition == null || this.composition!!.isDisposed) {
                Composition(
                    // 우리가 만든 VectorApplier를 사용합니다.
                    applier = VectorApplier(vector.root),
                    parent = parent
                )
            } else {
                this.composition!!
            }
        this.composition = composition

        // 2. setContent를 통해 벡터 트리의 내용을 갱신합니다.
        // RenderVector의 content가 바뀔 때마다 이 부분도 실행되어
        // 벡터 구조를 업데이트합니다.
        composition.setContent {
            // 이 블록 안에서는 벡터 전용 컴포저블만 쓸 수 있습니다.
            composable() 
        }

        return composition
    }

    // Painter 인터페이스 구현: 시스템이 화면을 그릴 때 호출합니다.
    override fun DrawScope.onDraw() {
        // 루트 노드에게 "너 자신을 그려라"라고 명령합니다.
        with(vector) {
            draw()
        }
    }
}

이로써 통합이 완료되었습니다. VectorPainter는 이제 다음 과정을 거쳐 화면에 그림을 그립니다.

  1. UI 컴포지션 안에서 RenderVector가 호출됩니다.
  2. VectorApplier를 사용하는 별도의 하위 컴포지션이 생성됩니다.
  3. 부모(UI)의 상태가 변경되면 이 하위 컴포지션도 함께 업데이트됩니다.
  4. 화면을 그려야 할 때 onDraw가 호출되어 벡터 노드 트리를 순회하며 캔버스에 그립니다.

이 예제를 통해 여러분은 이미 존재하는 컴포지션(UI) 안에, 나만의 커스텀 트리(벡터)를 관리하는 새로운 컴포지션을 통합하는 방법을 배웠습니다. 다음 장에서는 이 원리를 활용해 안드로이드가 아닌 웹(Kotlin/JS) 환경에서 동작하는 독립적인 Compose 시스템을 만들어 보겠습니다.

Compose를 이용한 DOM 관리

이번 장에서는 Compose를 안드로이드가 아닌 웹 브라우저 환경에서 사용하는 방법을 다룹니다. Compose의 핵심인 런타임과 컴파일러는 안드로이드에 종속되지 않기 때문에, 이를 활용해 웹의 HTML 요소(DOM)를 직접 제어하는 시스템을 만들어 볼 수 있습니다.

💡 현재 JetBrains는 웹 어셈블리(Wasm) 기반의 Compose Multiplatform을 주력으로 밀고 있습니다. 아래 내용은 Compose가 웹의 기본 요소(DOM)와 어떻게 상호작용하는지 원리를 이해하기 위한 학습용 예제(Toy Library)로 봐주시면 좋습니다.

웹의 구조와 Compose의 역할

브라우저는 HTML 태그들을 트리 구조로 관리하는데, 이를 DOM(Document Object Model)이라고 합니다.

예를 들어 아래와 같은 HTML 목록이 있다고 가정해 봅시다.

<div>
  <ul>
    <li>Item 1</li>
    <li>Item 2</li>
    <li>Item 3</li>
  </ul>
</div>

Compose의 관점에서 이 DOM 트리는 우리가 앞서 다룬 벡터 이미지의 트리 구조와 매우 비슷합니다.

  • HTML 태그(div, ul 등): 자식을 가질 수 있는 컨테이너 노드 (HTMLElement)
  • 텍스트(Item 1 등): 자식이 없는 말단 노드 (Text)

우리는 이 DOM 요소들을 Compose의 노드(Node)로 취급하여 관리할 것입니다.

DOM 요소를 위한 Composable 만들기

이제 실제 HTML 태그를 생성하는 Composable 함수를 만들어 봅시다.

Tag 함수 (HTMLElement) HTML 태그는 div나 button처럼 종류가 바뀌면 아예 새로 만들어야 합니다. 따라서 ComposeNode를 사용하여 노드를 생성합니다.

Text 함수 (Text Node) 텍스트는 내용만 바뀔 뿐 구조는 동일하므로 ReusableComposeNode를 사용하여 재사용성을 높입니다.

@Composable
fun Tag(tag: String, content: @Composable () -> Unit) {
    ComposeNode<HTMLElement, DomApplier>(
        factory = { document.createElement(tag) as HTMLElement },
        update = {},
        content = content
    )
}

@Composable
fun Text(value: String) {
    ReusableComposeNode<Text, DomApplier>(
        factory = { document.createTextNode("") },
        update = {
            set(value) { this.data = it }
        }
    )
}
💡

위 코드에서 DomApplier는 Compose의 명령(추가, 삭제, 이동)을 실제 DOM 조작 명령(appendChild 등)으로 변환해 주는 역할을 합니다. 구현이 길어 생략했지만, 원리는 앞서 본 VectorApplier와 같습니다.

웹에서 Compose 실행하기 (독립 구성)

안드로이드에서는 setContent만 호출하면 되었지만, 웹이나 다른 환경에서는 Compose 시스템(컴포지션)을 처음부터 직접 조립해야 합니다.

이를 위해 필요한 핵심 요소는 다음과 같습니다.

  • 스냅샷 매니저: 상태(State) 변경을 감지하는 시스템
  • 리컴포저(Recomposer): 변경 감지 시 리컴포지션을 수행하는 스케줄러
  • 컴포지션(Composition): 실제 트리 구조를 관리하는 객체

이 과정을 묶어서 renderComposable이라는 함수로 정의해 보겠습니다.

fun renderComposable(root: HTMLElement, content: @Composable () -> Unit) {
    // 1. 상태 변경 감지 시스템 시작
    GlobalSnapshotManager.ensureStarted()

    // 2. 리컴포저 설정 (메인 스레드 사용)
    val recomposerContext = DefaultMonotonicFrameClock + Dispatchers.Main
    val recomposer = Recomposer(recomposerContext)

    // 3. 컴포지션 생성 (DOM Applier 연결)
    val composition = ControlledComposition(
        applier = DomApplier(root),
        parent = recomposer
    )

    // 4. 초기 컨텐츠 설정
    composition.setContent(content)

    // 5. 리컴포지션 루프 시작
    CoroutineScope(recomposerContext).launch(start = CoroutineStart.UNDISPATCHED) {
        recomposer.runRecomposeAndApplyChanges()
    }
}

이제 메인 함수에서 이를 호출하여 HTML을 그릴 수 있습니다.

fun main() {
    renderComposable(document.body!!) {
        Tag("button") {
            Text("Click me!")
        }
    }
}

상호작용 추가하기 (이벤트 처리)

정적인 HTML만 보여준다면 Compose를 쓰는 이유가 없습니다. 버튼 클릭 같은 이벤트를 처리하고 상태를 업데이트할 수 있어야 합니다.

Tag 함수에 onClick 파라미터를 추가하여 실제 DOM의 onclick 이벤트와 연결해 봅시다.

@Composable
fun Tag(
    tag: String,
    onClick: () -> Unit = {},
    content: @Composable () -> Unit
) {
    ComposeNode<HTMLElement, DomApplier>(
        factory = { document.createElement(tag) as HTMLElement },
        update = {
            // 리스너가 바뀌면 DOM의 onclick 속성을 업데이트합니다.
            set(onClick) {
                this.onclick = { onClick() }
            }
        },
        content = content
    )
}

이제 안드로이드와 똑같이 상태(State)를 사용하여 동적인 웹 앱을 만들 수 있습니다.

fun main() {
    renderComposable(document.body!!) {
        // 클릭 시 업데이트될 카운터 상태
        var counterState by remember { mutableStateOf(0) }

        Tag("h1") {
            Text("Counter value: $counterState")
        }

        Tag("button", onClick = { counterState++ }) {
            Text("Increment!")
        }
    }
}

결론: Compose의 무한한 가능성

이 장에서는 Compose UI 없이, 순수 Compose 런타임만을 사용하여 웹페이지를 만드는 방법을 살펴봤습니다.

  • 사용자 정의 컴포지션: 우리가 만든 Tag나 Text 같은 커스텀 노드로 트리를 구성할 수 있습니다.
  • 확장성: 이 원리를 이용하면 웹(DOM) 뿐만 아니라, CLI 툴(예: Mosaic 라이브러리)이나 게임 렌더링 등 트리 구조를 가진 어떤 시스템이든 Compose로 제어할 수 있습니다.

Compose는 단순한 UI 라이브러리가 아니라, 상태를 관리하고 트리를 갱신하는 강력한 범용 엔진입니다. 여러분도 이 원리를 이해한다면 Compose를 활용해 창의적인 도구를 직접 만들 수 있습니다.

profile
코딩일기

0개의 댓글