
Jetpack Compose Internals를 읽고 내용을 정리하는 시리즈입니다.
이번 포스팅에서는 1장 Composable 함수들(Composable functions)을 다룹니다.
Composable 함수 == UI 트리에서 하나의 노드
Composable 함수의 실행 == 특정 노드를 만들거나 업데이트 하는 것
UI 트리란?
Compose에서 UI를 그리기 위해 메모리에서 관리하는 데이터 구조.
Composable 함수의 입출력
@Composable (Input) ‑> Unit

Input : 입력 데이터Unit : 입력을 소비하고 트리를 구성하는 Composable 함수의 부수 효과Composable 함수의 특징
Compose Compiler은 Kotlin 컴파일러 플러그인의 일종이다.
덕분에 중간 표현(IR / Intermediate Representation) 단계에 개입해 기존 코드를 수정할 수 있다.
Compose Compiler는 Composable 함수의 마지막 인자에 Composer를 추가한다.
Composer의 인스턴스는 런타임에 주입되며, 아래처럼 트리를 따라 모든 하위 Composable로 전달된다.

Kotlin 컴파일러 플러그인이란?
Kotlin 컴파일 과정 중간에 개입해 코드를 직접 분석하거나 변형할 수 있는 도구.
Composer는 어떻게 추가되고 전달될까?
아래 예시 코드처럼, 인자로 주입된 Composer는 하위 트리의 모든 Composable 함수의 호출부로 전달된다.
// 전
@Composable
fun NamePlate(name: String, lastname: String) {
Column(modifier = Modifier.padding(16.dp)) {
Text(text = name)
Text(text = lastname, style = MaterialTheme.typography.subtitle1)
}
}
// 후
fun NamePlate(name: String, lastname: String, ***$composer: Composer<*>***) {
...
Column(modifier = Modifier.padding(16.dp), ***$composer***) {
Text(
text = name,
**$composer**
)
Text(
text = lastname,
style = MaterialTheme.typography.subtitle1,
***$composer***
)
}
...
}
이러한 구조는 Composable 함수가 다른 Composable 함수 내에서만 호출되도록 강제하며,
Composer가 트리를 따라 아래로 전달될 수 있도록 한다.
Composer는 어떤 역할을 할까?
Composer는 UI 트리의 변경 사항을 전달하고, 런타임에서 트리의 형태를 빌드하거나 업데이트할 때 사용된다.
즉, 개발자가 직접 작성한 Composable 함수 코드와 Compose Runtime 간의 중재자 역할을 한다.
Composable 함수는 멱등성을 가져야 한다.
이는 동일한 입력으로 Composable 함수를 실행하면 항상 동일한 결과(UI 트리)를 만들어 내야함을 의미하며, 이러한 멱등성이 필요한 이유는 recomposition 때문이다.
Recomposition이란?
recomposition은 입력값이 바뀔 때마다 Composable 함수를 다시 실행해 UI 트리를 최신으로 업데이트하는 작업이다. 멱등성이 요구되는 이유는 Recomposition 과정을 살펴보면 알 수 있다.
모든 Composable 함수가 멱등성을 만족한다면 입력값이 바뀌지 않은 노드를 업데이트할 필요가 없다.
이미 그 결과가 인메모리 UI 트리에 적재되어있기 때문이다.
사이드 이펙트란?
호출된 함수의 제어를 벗어나 발생할 수 있는 모든 동작 (e.g. 네트워크 요청, 로컬 캐시 읽기, 전역 변수 설정 등)
사이드 이펙트는 특정 함수가 ‘입력값’을 제외한 ‘외부 요인’에도 의존하도록 만들어 함수의 멱등성을 깨뜨린다.
따라서 Composable 함수 내부에서 사이드 이펙트를 실행할 때는 주의가 필요하다.
Composable 함수는 컴포즈 런타임에 의해 짧은 시간 안에 여러 번 리컴포지션될 수 있으며
이는 외부 요인을 제어하기 어렵게 만들기 때문이다.
사이드 이펙트 유의사항
이상적인 Composable 함수는 Stateless 하다.
하지만 우리가 만드는 프로그램은 Stateful 하므로, 어딘가에선 사이드 이펙트를 수행해야만 한다.
(Composable 트리의 루트에서 실행하는 것이 일반적이다.)
Compose에서 사이드 이펙트를 안전하게 실행하는 방법
Effect Handler를 사용하면 Composable 함수 안에서 사이드 이펙트를 안전하게 실행할 수 있다.
이를 적절히 활용하면 Composable 함수 안에서 사이드 이펙트가 제약 없이 직접 호출되는 것을 막을 수 있다.
일반적인 함수와 달리 컴포저블 함수는 재시작 가능하며, 여기서 재시작은 Recomposition을 의미한다.
일반적인 함수 : 콜 스택 구조 하에 단 한 번만 호출된다.

Composable 함수 : recomposition으로 여러 번 다시 시작될 수 있다.

위 사진에서 입력 상태가 바뀌어 Composable 4와 5가 재시작하는 모습을 살펴보자.
컴포즈 컴파일러는 여러 번 재시작될 수 있는 컴포저블 함수를 판단하고, 컴포즈 런타임에게 재시작 방법을 알려주는 코드(해당 함수의 참조 등)를 생성한다.
Composable 함수가 가볍고 빠른 이유는?
컴포저블 함수는 UI를 그리거나 반환하지 않고 단지 인메모리 구조를 생성/업데이트할 뿐이다.
덕분에 런타임에서 아무 문제 없이 해당 함수를 여러번 실행할 수 있으며,
실제로 컴포저블 함수의 실행은 애니메이션 프레임 단위 만큼 빈번하게 발생하기도 한다.
이는 컴포저블 함수 안에서 무겁거나 외부 의존성이 있는 작업을 지양해야하는 이유이기도 하다.
위치 기억법은 함수 메모이제이션의 한 형태이다.
함수 메모이제이션이란?
함수형 프로그래밍 패러다임에서 널리 알려진 기술로, 이 패러다임에서 프로그램은 순수 함수로만 구성된다.
함수 메모이제이션은 함수의 입력값에 기반해 결과를 캐싱하고 재사용하는 기법이다.
특정 함수의 호출은 함수명, 함수 타입, 매개변수 값 조합으로 생성된 고유한 키로 식별되며 결과를 캐싱한다.
컴포저블 함수는 위 요소와 더불어 추가로 소스 코드 내 호출 위치를 고려한다.
컴포즈 런타임은 한 컴포저블 함수가 동일한 매개변수 값으로 다른 위치에서 호출될 때,
컴포저블 트리 안에서 서로 다른 고유한 ID(key)를 생성해 각각 다른 인스턴스로 저장한다.
@Composable
fun MyComposable() { // id 0
Text(”Hello”) // id 1
Text(”Hello”) // id 2
Text(”Hello”) // id 3
}
그렇다면 반복문으로 하나의 함수를 동일한 위치에서 여러번 호출하는 경우 어떻게 처리될까?
호출 순서에 따라 컴포저블 함수를 구별할 수 있다.
@Composable
fun TalksScreen(talks: List<Talk>) {
Column {
for (talk in talks) {
Talk(talk)
}
}
}
하지만 이렇게 호출 순서에 의존하는 방법은 불필요한 리컴포지션을 일으킬 수 있다는 문제가 있다.
컴포저블 함수는 입력값이 바뀌지 않은 경우 리컴포지션을 생략해야 한다.
하지만 순서가 변경된 컴포저블 함수는 입력값 변경 여부에 관계 없이 무조건 리컴포지션되므로 비효율적이다.
@Composable
fun TalksScreen(talks: List<Talk>) {
Column {
for (talk in talks) {
key(talk.id) { // Unique key
Talk(talk)
}
}
}
}
이를 해결하기 위해선 명시적인 id(key)를 설정해주면 된다.
이는 함수의 위치나 호출 순서에 상관 없이 id를 기준으로 아이템의 고유성을 유지하게 한다.
때로는 컴포저블 함수의 범위보다 더 세밀한 조작이 필요하다.
예를 들면 컴포저블 함수 내에서 비용이 큰 계산의 결과를 직접 캐싱하는 경우가 있다.
컴포즈에서는 이를 위해 remember 함수를 제공한다.
remember는 개발자가 인메모리 트리에 값을 읽고 쓸 수 있도록 하는 Composable 함수이다.
@Composable
fun FilteredImage(path: String) {
val filters = remember { computeFilters(path) } // 비용이 큰 계산
ImageWithFiltersApplied(filters)
}
@Composable
fun ImageWithFiltersApplied(filters: List<Filter>) {
TODO()
}
위 예시 코드에서는 computeFilters의 연산 결과를 계산하고 캐싱하기 위해 remember를 사용한다.
캐싱된 값을 검색할 때 사용하는 id(key) 또한 호출 위치와 함수의 입력값(예시 코드의 path)을 기반으로 한다.
Compose에서 메모이제이션은 애플리케이션 전체 수명 동안 유지되지 않는다.
무언가가 메모리에 기록될 때, 메모이제이션을 호출하는 Composable의 컨텍스트 안에서만 수행된다.
(예시 코드에서는 FilteredImage가 컨텍스트에 해당)
이렇게 메모리의 캐싱값을 탐색하는 과정들은 해당 Composable의 슬롯 범위 내에서만 싱글톤처럼 동작한다.
따라서 동일한 컴포저블 함수가 다른 곳에서 호출되면 메모이제이션에 따라 새로운 인스턴스로 취급된다.
Kotlin의 suspend 함수는 다른 suspend 함수에서만 호출될 수 있다. (호출 컨텍스트가 필요하다.)
이는 suspend 함수들끼리 묶이도록 하며, Kotlin 컴파일러가 런타임에서 Continuation을 주입할 수 있게 한다.
Continuation은 suspend 함수의 마지막 매개변수로 추가되며, 개발자에게는 노출되지 않는 암시적인 객체다.
일종의 콜백 역할을 하며 코루틴의 중단/재개를 가능하게 한다.
// 컴파일 전
suspend fun publishTweet(tweet: Tweet): Post = ...
// 컴파일 후
fun publishTweet(tweet: Tweet, callback: Continuation<Post>): Unit
이와 마찬가지로 @Composable도 언어적 기능으로 이해할 수 있다.
Composable 함수는 Kotlin 함수를 재시작 가능하고, 상태 변화에 반응할 수 있도록 만든다.
함수 컬러링이란, 함수들이 서로 다른 별개의 기능을 수행한다는 차이점을 이해하기 위한 개념이다.
구글 Dart 팀 Bob Nystrom의 “What color is your function?”이라는 아티클에서 처음 소개되었다.
Bob은 동기(sync) 함수에서 비동기(async) 함수를 호출할 수 없어 두 함수가 잘 결합되지 않는다고 설명한다.
따라서 이 두 가지 함수가 서로 다른 함수 색상(function coloring)을 갖는다고 묘사한다.
Kotlin에서는 suspend 키워드로 이러한 결합 문제를 해결하려 하지만, suspend 함수도 colored된 함수이다.
suspend 함수 또한 suspend 함수 안에서만 호출될 수 있기 때문이다.
그래서 일반 함수와 suspend 함수를 혼합하기 위한 통합 매커니즘(코루틴 시작점 등)이 필요하다.
한 번 통합점을 거치면 그 지점 이후에는 새로운 색의 함수를 사용할 수 있다.
이렇게 다른 개념과 목적을 나타내는 함수들을 조합하는 것은, 마치 두 언어를 함께 사용하는 것과 같다.
Composable 함수도 마찬가지다.
일반 함수에서 Composable 함수를 호출하려면 Composition.setContent 등의 통합점이 필요하다.
@Composable
fun SpeakerList(speakers: List<Speaker>) {
Column {
speakers.forEach {
Speaker(it) // 일반 함수(forEach 람다) 안에서 Composable 함수를 호출한다!
}
}
}
Speaker Composable 함수는 어떻게 forEach 람다 안에서 호출될까?
이는 컬렉션 함수들이 inline으로 선언되어, Speaker가 SpeakerList 본문 안에 인라인되기 때문이다.
형태 1 : 값 반환 @Composable (T) ‑> A
일반적인 컴포저블 함수의 경우 A는 Unit이며, remember 함수처럼 특정 타입을 반환할 수도 있다.
// This can be reused from any Composable tree
val textComposable: @Composable (String) ‑> Unit = {
Text(
text = it,
style = MaterialTheme.typography.subtitle1
)
}
@Composable
fun NamePlate(name: String, lastname: String) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = name,
style = MaterialTheme.typography.h6
)
textComposable(lastname)
}
}
형태 2 : 람다 리시버 지정 @Composable Scope.() ‑> A
특정 Composable에서 Scope가 가진 함수나 변수에 접근할 수 있도록 정보의 범위를 지정할 때 유용하다.
inline fun Box(
... ,
content: @Composable BoxScope.() ‑> Unit
) {
// ...
Layout(
content = { BoxScopeInstance.content() }, // BoxScopeInstance의 구현체
measurePolicy = measurePolicy,
modifier = modifier
)
}