[Android] 안드로이드 컴포즈란?
Jetpack Compose를 알아보기 전에 Android Jetpack에 대해서 간단하게 살펴보자.
Jetpack
Jetpack은 Components, Tools, Guidance로 이루어져 있다. 이를 통해, 기존의 라이브러리와 아키텍쳐 컴포넌트를 제공해준다. 아래의 도표는 JetPack이 제공하는 라이브러리를 4가지 종류로 분류한 것이다.

위와 같이 Android Jetpack에서는 다양한 라이브러리를 지원해주며 이를 통해 얻을 수 있는 장점은 두 가지로 용이성과 호환성이 있다.
Jetpack Compose
Jetpack의 많은 UI 관련 라이브러리 중 하나이며 구글에서 개발한 모던 안드로이드 개발을 위한 선언형 UI Toolkit이다. 2021년 8월에 1.0.0 stable버전이 정식 출시되었으며, 공식적으로 Production에 적용 가능하게 되었다.
유래
Android 툴 킷팀은 2014년도부터 ViewPager, RecyclerView등을 OS에서 분리하면서 얻은 이점을 바탕으로 이를 극대화하고자 많은 UI 위젯을 OS에서 분리하는 작업을 시도했다.
- UI가 OS에 강하게 결합되어있을 때의 단점
- 업데이트 제한 : 위젯의 업데이트를 반영하기 위해서는 Android OS의 버전 업데이트가 필요했다. 따라서 사용자가 최신 OS로 업데이트 하지 않는다면 새로운 UI를 사용할 수 없었다.
(업데이트를 그냥 한다고 생각하면 되지만 , 업데이트의 안정성(기기 성능 저하, 기능 상실)에 대한 우려나 업데이트 파일의 큰 용량으로 사용자들은 OS 업데이트하기를 꺼렸을 것이다.) - 느린 배포 : OS 업데이트는 기기 제조사와 통신사에 의해서 이루어지므로, OS 업데이트를 즉각적으로 할 수 없었다.
- 유지보수의 어려움 : 특정 UI 위젯의 버그 해결, 기능 업데이트시 OS 자체를 업데이트해야했기 때문에 유지보수가 어려웠다.
- 업데이트 제한 : 위젯의 업데이트를 반영하기 위해서는 Android OS의 버전 업데이트가 필요했다. 따라서 사용자가 최신 OS로 업데이트 하지 않는다면 새로운 UI를 사용할 수 없었다.
또한, 기존 플랫폼 UI 툴킷을 사용하여 선언적 Android UI를 작성하는 솔루션을 시도하고 있었다. React에서는 이미 선언적 프로그래밍 패러다임을 사용하고 있었고 Android Toolkit 팀은 React에서 영향을 받아 선언형 프로그래밍을 사용한 Jetpack Compose를 만들었다. 대신, React는 JIT(Just In Time) 컴파일러를 사용하며 자바스크립트로 작성되며, 브라우저에서 실행 시점에 해석된다.
하지만 Jetpack Compose는 Svelte 컴파일러가 사용하는 AOT(Ahead Of Time)의 개념을 반영했다. Svelte는 컴파일 시점에 많은 작업을 처리하여 나은 성능을 제공하는 UI 프레임워크인데, Compose도 UI를 컴파일러 수준에서 최적화를 하여 런타임 오버헤드를 줄였다.
위의 글을 두 가지 유래로 정리하자면 다음과 같다.
- 기존 플랫폼 UI(명령형 UI) 툴킷을 선언형 UI로 변경:
- React의 선언적 컴포넌트와 같은 개념을 차용하여 기존 Android 플랫폼의 View시스템 기반 UI 툴킷을 선언적 방식으로 사용하는 솔루션을 개발하는 것이었다. 추가적으로, React의 선언적 방식에다가 Svelte의 Ahead-of-Time (AOT) 컴파일러의 개념을 도입하여 Kotlin으로 작성된 선언적 UI 코드를 Android UI로 변환하는 시도를 했다. 기존의 명령형 방식에서 선언형 프로그래밍으로 전환하고자하는 노력을 했다.
- OS에서 UI 위젯을 분리/개선:
- 기존 Android 플랫폼의 UI 위젯들을 OS에서 분리하려는 시도였다. 기존의 View 기반 UI 위젯(예: ViewPager, RecyclerView 등)을 OS 업데이트와 독립적으로 제공하여 더 빠르게 새로운 기능을 제공하고, API의 문제점들을 개선하려는 시도였다. 이로 인해 UI 위젯들이 OS에 종속되지 않고, 개발자가 필요할 때 독립적으로 최신 버전을 사용할 수 있게 되었다.
선언형 UI와 명령형 UI의 특징과 차이점을 알고 싶다면, 링크로 연결된 포스트를 참고하면 좋다.
여기까지, 간단하게 유래를 살펴보았고 이제 Compose에 대해 이해를 하는 시간을 가져보자.
사실 Compose는 3가지로 구성되어 있다.
- Compose 컴파일러
- Compose 런타임
- Compose UI
Compose UI는 구글에서 안드로이드용으로 설계한 UI 툴킷 이외에도 JetBrains에서 만든 iOS, 웹 전용 라이브러리도 존재한다.
1. Compose 컴파일러
Compose 컴파일러는 플러그인의 진입점 역할을 하는 ComponentRegistrar 인터페이스를 통해 코틀린 컴파일러와 통합된다. Compose 컴파일러는 컴파일 타임에 코틀린 컴파일러 종속성을 확인하며 컴포저블 함수를 컴파일 시간에 분석한다. 만약 @composable 어노테이션을 식별하면, Compose 컴파일러는 런타임 동작이 활성화될 수 있도록 해당 함수의 코드를 약간 수정한다. 이 과정은 람다와 인라인 함수와 같은 고수준의 코드를 저수준의 코드(IR)로 변경하는데, 이 과정을 Lowering이라고 한다. Compose 컴파일러는 이 단계에서 IR 트리의 요소를 수정하여 런타임과 호환되도록 한다.
Compose 컴파일러는 메타데이터를 추가하고 변경하여 IR 단계에서 소스 코드를 수정하는데, 이는 궁극적으로 Compose 런타임의 기능을 지원하도록 한다. 이후 IR은 JVM, JS, LLVM(IOS 용) 또는 WASM용 네이티브 바이너리로 컴파일되어 Compose가 다중 플랫폼이 될 수 있다.
Compose 컴파일러가 수행한 IR 생성은 Compose 런타임의 작업을 쉽게 하는데 몇 가지 중요한 사항은 다음과 같다.
- 클래스 안정성 추론
클래스의 Stability을 추론하여 변경되지 않은 객체는 런타임이 건너뛸 수 있도록 돕는다. 원시 타입은 컴포즈 안에서 stable하며, data 클래스와 같은 커스텀 타입은 @Stable이나 @Immutable 어노테이션을 작성해야 stable하다고 다룰 수 있다. - Live Literals 활성화
Live Literals는 디버그 기능으로서, 컴포저블 함수 내의 상수를 MutableState로 변경하여 재컴파일 없이도 변경 사항을 즉각적으로 UI에 반영해주는 기능이다. 이 기능을 활성화하면 성능이 크게 저하되기 때문에 디버그 빌드에서만 활성화되며, 릴리즈 빌드에서는 비활성화되어 성능을 최적화한다. - Lamda 최적화
컴포저블 함수에 전달되는 람다를 효율적으로 처리하여 불필요한 리컴포지션을 줄이고 성능을 최적화하는 방식이다. 람다의 종류에 따라 최적화 방법이 두 가지가 존재한다.
- 일반적인 Lambda 최적화
- 값을 캡처하지 않는 람다 : 코틀린 컴파일러는 이미 최적화를 수행해 값을 캡쳐하지 않는 람다를 싱글톤으로 모델링한다. 이는 재사용이 가능한 객체로 관리되어 불필요한 인스턴스 생성을 방지한다.
- 값을 캡처하는 람다 : 값(전역 변수)을 캡처하는 경우, 코틀린 컴파일러는 이 Lambda를 remember로 감싸 최적화한다. remember은 람다를 상태로 관리하여 값이 변경되지 않은 경우 재구성을 건너뛸 수 있게 한다.
- 컴포저블 Lambda 최적화
- 값을 캡처하지 않는 람다 : 코틀린 기본 최적화가 그대로 적용된다.
- 값을 캡처하는 람다 : Compose 컴파일러는 컴포저블 람다를 composableLambda 팩토리 함수로 변환하여, 해당 람다가 변경될 때만 특정 부분을 리컴포지션하도록 최적화한다. 이 과정에서 람다는 State로 감싸지고, 람다가 참조하는 값이 변경될 때만 도넛홀 건너뛰기(donut-hole skipping) 기법을 통해 리컴포지션이 필요한 UI의 특정 부분이 갱신된다.
- 일반적인 Lambda 최적화
도넛홀 건너뛰기(donut-hole skipping)
이 최적화 방식은 트리의 상위 부분에서 변화를 감지하더라도, 실제로 영향을 미치는 하위 특정 부분만 재구성하는 방식이다. 이를 통해 불필요한 리컴포지션을 최소화하고, 성능을 향상시킨다.
- Composer 주입
Compose 컴파일러는 $composer 파라미터를 주입해 Compose 런타임이 UI를 구성하는 것을 돕는다. 컴포저블 함수를 $composer.start(key)와 $composer.end()로 감싸며 $composer는 하위 컴포저블 함수로 전달된다. Compose 컴파일러는 최적화와 Lowering을 통해 코드를 수정하거나 메타데이터를 추가하는데, 이는 Compose 런타임에 도움을 준다.
2. Compose 런타임
UI를 업데이트하기 위해, 리컴포지션을 핸들링하고 변화를 관리한다. Compose 컴파일러에의해 주입된 $composer로 인해서, 컴포저블 함수를 Compose 런타임에 연결한다.
Compose 런타임의 핵심은 슬롯 테이블과 State Change 리스트다. 컴포저블이 생성되거나 리컴포지션될 때, 함수 내부에 존재하는 State를 슬롯 테이블에 저장한다. 슬롯 테이블은 호출된 함수 리스트, 사용된 파라미터 등 생성 중에 발생한 값을 저장한다. Compose 런타임은 슬롯 테이블에 저장되어있는 값을 기반으로 change 리스트를 생성한다. 이 목록은 트리에 실제 change 리스트를 만드는 것이다.
changes 리스트는 Applier이라는 인터페이스를 통해 자신의 changes를 노드 트리에 적용시킨다. Applier은 플랫폼에 독립적이어서 모든 UI 툴킷이 Compose 런타임과 함께 동작할 수 있다.
Applier가 트리를 빌드하면 Compose UI 툴킷이 노드를 화면에 표시한다. Compose 런타임은 리컴포지션(리컴포지션 invalidations)을 추적한다.
3. Compose UI
Compose 런타임 전용 클라이언트 라이브러리다. Google에서 만든 안드로이드용 Compose UI 뿐만 아니라, JetBrains에서 만든 웹, iOS와 같은 타 플랫폼 클라이언트 라이브러리도 존재한다. Compose UI를 통해 실제 화면에 노출되는 동작은 4단계로 구분할 수 있다.
1. 노드 구성
Compose UI를 통해서 setContent를 호출하면 Compose 런타임은 Node 트리를 빌드하기 시작한다. 버튼이나 텍스트 필드는 자체 노드를 가지며 이러한 모든 노드가 함께 트리를 구성한다.
각 노드는 LayoutNode에 의해 표현되는데, 이는 Compose UI에서 개별 UI 컴포넌트를 표현하는 기본 단위다. 이런 LayoutNode들이 모여 UI 트리를 구성하고, 이를 통해 Compose 런타임은 화면에 UI 요소들을 배치하고, 사용자 입력에 반응하도록 한다.
LayoutNode는 Compose UI에서 개별 UI 컴포넌트를 표현하는 기본 단위로, UI 컴포넌트의 배치, 크기, 위치, 반응 방식을 정의하는 역할을 한다. Android Compose에서 LayoutNode는 각 컴포저블 UI 요소(예: 버튼, 텍스트 등)의 틀처럼 동작한다.
1. 크기 정보: 해당 UI 요소가 화면에서 차지할 너비와 높이
2. 위치 정보: 화면 내에서 이 요소가 배치될 위치
3.동작 반응: 사용자가 버튼을 누르거나 스크롤하는 등 상호작용할 때 어떻게 반응할지에 대한 정보
이 단계에는 노드 트리를 만드는 단계다. Compose 런타임은 새 노드를 만들거나 변경되지 않은 경우 기존 노드를 재사용한다. 이러한 노드는 슬롯 테이블에 저장되며 기본적으로 앱 UI의 중추 역할을 한다.
2. 노드 측정
모든 노드가 구성되고 나서 각 노드가 얼마나 커야하는지 측정하는 단계다. 각 노드에 존재하는 LayoutNode에는 MeasurePolicy와 같은 규칙이 존재한다. 이를 통해 너비와 높이를 측정하며 최상위에서부터 자식 노드까지 이루어진다. 자식 노드의 크기를 부모 모드에게 전달하고, 부모 노드는 자식 노드를 기준으로 자신의 크기를 계산한다.
3. 노드 배치
LayoutNode에는 하는 위치를 핸들링하는 Placeable이 존재한다. 이를 통해, 정렬, 패딩 및 (X, Y)좌표를 파악한다. 배치는 탑다운 방식으로 이루어지고 각 부모 노드는 크기와 부모가 사용할 수 있는 공간에 따라 자식을 배치한다.
4. 노드 그리기
측정되고 배치한 대로 LayoutNode를 그린다. LayoutNode는 DrawNode생성하는데 각 노드에 맞는 다양한 요소로 렌더링한다.
안드로이드의 경우 Canva로 드로잉을 핸들링한다. DrawNode는 드로잉 커맨드를 생성하고 Canvas는 그들을 스크린에 렌더링한다. 이 과정은 리프 노드에서부터 루트 노드까지 반복된다.
위의 4단계를 거쳐 실질적으로 화면에 렌더링된 UI를 볼 수 있다.
위 글은 Understanding JetPack Compose 포스팅 글을 참고하여 작성하였습니다.
Reference
Jetpack Compose 컴파일러가 부리는 마법 완전히 파헤치기
Understanding Jetpack Compose — part 1 of 2
A Jetpack Compose by any other name
Jetpack Compose 성능 최적화를 위한 Stability 이해하기
Use Android Jetpack to Accelerate Your App Development
Compose internal ch.2 - Compose 컴파일러 (The Compose compiler)