Composable 함수란 무엇일까요?
Composable 함수는 Jetpack Compose의 가장 기본이 되는 요소이며, Composable 트리 구조를 작성하는 데 사용됩니다. 단순히 문법에만 초점을 맞춘다면, 코틀린 함수에 @Composable 어노테이션을 붙이는 것만으로도 Composable 함수를 만들 수 있습니다. 가령, 아래와 같은 예시를 살펴볼 수 있습니다.
@Composable
fun NamePlate(name:String) {
// Our composable code
}
@Composable 어노테이션을 사용함으로써, 우리는 컴파일러에게 함수가 가진 데이터들을 하나의 노드로 변환하여 Composable 트리(tree)로서 사용하겠다는 의도를 전달합니다. 즉, 컴포저블의 입력값은 데이터가 되고,출력은 트리에 요소를 삽입하기 위한 일련의 동작(action)이라고 볼 수 있습니다. 우리는 이 동작을 함수 실행의 부수 효과(side effect)로 발생한다고 말할 수 있습니다.
이러한 동작은 일반적으로 Compose 관용어로 방출(emitting)이라고 알려져 있습니다.
Composition 처리 과정 중에 Composable 함수는 그 내부의 구현 정보를 방출합니다. 여기서 다시 정리하자면, Composable 함수는 트리의 인메모리 표현(in-memory representation)을 만들거나 업데이트하기 위해 실행됩니다. 그리고 Composable은 데이터를 이용해 내부 구현을 방출하여 트리를 위한 하나의 노드를 만듭니다.
또한 Composable은 트리로부터 상태(state)를 읽거나 쓸 수 있습니다.
함수에 @Composable 어노테이션을 붙이는 것은 여러 의미를 내포하고 있습니다. @Composable 어노테이션은 해당 어노테이션이 적용된 함수나 표현식의 타입을 효과적으로 변경하며, 다른 해당 타입에 일부 제약 사항이나 특성을 부여합니다. 이러한 특성들은 Jetpack Compose와 고도로 밀접한 연관이 있으며, Compose의 라이브러리들이 제 기능을 하도록 하는데 아주 중요한 역할을 합니다.
컴포저블 함수들의 속성은 어떻게 활성화될까요? Composable 함수의 대부분 속성들은 Compose Compiler에 의해 활성화됩니다. Kotlin 컴파일러 Kotlin 컴파일러 플러그인인 Compose Compiler는 일반적인 컴파일러 실행단계 중에 동작하며, Kotlin 컴파일러가 액세스할 수 있는 모든 정보에 액세스할 수 있습니다. 이로써 Composable 함수의 중간 표현인 IR을 가로채고 변환하여 원본 소스의 모든 Composable 함수에 추가적인 정보를 부여할 수 있습니다.
그리고 이 추가적인 정보 중 하나는 바로 Composer입니다. Runtime시 Composable 함수의 매개변수 목록 끝에 새롭게 주입되며, 모든 하위 Composable 호출로 전달되므로 트리의 모든 수준에서 접근할 수 있습니다.

예시를 살펴봅시다. 아래와 Composable 함수가 있습니다.
@Composable
fun NamePlate(name: String, lastname: String) {
Column(modifier = Modifier.padding(16.dp)) {
Text(text = name)
Text(text = lastname, style = MaterialTheme.typography.subtitle1)
}
}
Compose Compiler는 위의 코드를 아래와 같이 변환합니다.
@Composable
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
)
}
}
이렇듯, Composer는 트리 내에서 모든 Composable 호출로 전달됩니다. Composable 함수가 호출 컨텍스트(calling context)를 이루고 트리를 따라 Composer이 하향으로 전달되기 위해 Composable 함수는 오로지 다른 Composable 함수에서만 호출될 수 있습니다.
Composer는 개발자가 작성하는 Composable 코드와 Compose Runtime 간의 중재자 역할을 합니다. Composable 함수는 트리에 대한 변경 사항을 전달하고, 런타임 시에 트리의 형태를 빌드하거나 업데이트 하기 위해 Composer를 사용합니다.
Composable 함수는 생성하는 노드 트리에 대해 멱등성을 가져야 합니다. 동일한 입력 매개변수를 사용하여 Composable 함수를 여러 번 다시 실행하더라도 동일한 트리가 생성되어야 합니다. Jetpack Compose Runtime은 이러한 멱등성 가정하에 작업이 진행됩니다.
Jetpack Compose에서 recomposition이란, 입력되는 값이 변경될 때마다 Composable 함수를 다시 실행하여 업데이트된 정보를 방출시키고 트리를 업데이트하는 작업입니다.
Recomposition 과정은 다음의 과정을 수행합니다.
특히 2의 과정은 Composable 함수가 멱등성의 성질을 가질 때만 가능합니다. 왜냐하면 입력값이 동일할 경우 동일한 결과를 생성한다고 가정할 수 있기 때문입니다. 동일한 입력값에 대한 결과는 이미 메모리에 적재되어 있으므로 Compose는 다시 실행할 필요가 없고, 결과적으로 생략할 수 있습니다.
사이드 이펙트(Side effect)는 호출된 함수의 제어를 벗어나서 발생할 수 있는 예상치 못한 모든 동작을 의미합니다. 로컬 캐시에서 데이터 읽기, 네트워크 요청 작업, 전역 변수 설정과 같은 것들이 사이트 이펙트로 간주됩니다. 이들은 호출하는 함수가 그 동작에 영향을 미칠 수 있는 외부 요인에 의존하게 만듭니다.
이러한 사이드 이펙트는 모호함의 근원이라고 볼 수 있습니다. Compose Runtime은 Composable함수가 예측 가능하도록 기대하기 때문에 사이트 이펙트가 포함된 Composable 함수는 예측이 어려워지고, 여러 번 실행될 수 있습니다. Composable 함수가 사이드 이펙트를 실행한다면 매 함수 호출마다 새로운 프로그램 상태를 생성할 수 있으므로, Compose Runtime은 멱등성을 따르지 않게 됩니다.
그렇기 때문에 우리는 모든 Composable 함수를 stateless(무상태, 상태를 보존하지 않음)하게 만들려고 노력해야 합니다. 그래서 Composable 함수는 모든 입력값은 매개변수로서 받고, 결과를 생성하기 위해 주어진 입력값만을 사용합니다. 이것은 Composable을 더 어리석어 보일 정도로 단순하고, 높은 재사용성을 갖게 합니다.
그러나 stateful(상태유지, 상태를 보유하는) 프로그램을 개발하기 위해서는 특정 단계에서 사이드 이펙트를 실행해야만 합니다. (주로 Composable 트리의 루트에서 빈번하게 발생합니다.) 프로그램은 네트워크 요청을 실행하고, 데이터베이스에 정보를 저장하고, 메모리 캐시를 사용하는 등의 작업이 필요합니다.
이러한 이유로, Jetpack Compose는 Composable 함수에서 안전하고 통제된 환경 내에서 이펙트를 호출할 수 있는 이펙트 핸들러(effect handlers)와 같은 메커니즘을 제고압니다.
이펙트 핸들러는 사이드 이펙트가 Composable의 라이프사이클을 인식하도록 하며, 해당 라이프사이클에 의해 제한되거나 실행될 수 있게 합니다. 또한, Composable 노드가 트리를 떠날 때 자동으로 이펙트를 해제하거나 취소할 수 있게 하고, 이펙트에 주어진 입력값이 변경되면 재실행시키거나, 심지어 동일한 이펙트를 recomposition 과정에서 유지시키고 한 번만 호출되게 할 수 있습니다.
이펙트 핸들러는 Composable 함수 내에서 아무런 제어를 받지 못하고 직접적으로 이펙트가 호출되는 것을 방지하는 역할을 합니다.
Composable 함수는 일반적인 콜 스택 일부로 단 한번 호출되는 표준 함수들과 다르게 작동합니다. Composable 함수는 recomposition으로 여러 번 다시 시작될 수 있습니다. 그래서 런타임은 함수가 재실행될 수 있도록 해당 함수들에 대한 참조를 유지합니다.

Compose는 메모리 내 표현을 항상 최신 상태로 유지하기 위해 트리의 어떤 노드를 재시작할지 선택적으로 판단합니다. Composable 함수는 관찰하는 상태의 변화에 기반하여 반작용적으로 재실행되도록 설계되었습니다.
Compose Compiler는 일부 상태를 읽는 모든 Composable 함수를 찾아 Compose Runtime에게 재시작하는 방법을 알려줍니다.
우리는 Composable 함수와 트리를 빠르고, 선언적이며, 가벼운 방법으로 프로그램에 대한 설명을 만드는 것으로 생각해볼 수 있습니다. 이 설명은 메모리에 보존되어 나중에 Compose Runtime에 의해 해석되거나 구체화됩니다.
Composable 함수들은 UI를 구축하거나 반환하지 않습니다. Composable은 단순히 인메모리 구조를 구축 및 업데이트하기 위한 데이터를 방출할 뿐입니다. 이 매커니즘은 Composable을 더욱 빠르게 만들고, 런타임시에 함수를 아무 문제없이 여러번 실행할 수 있도록 합니다.
따라서 개발자들은 함수가 여러 번 호출될 수 있다는 가정하에 코드를 작성해야 합니다. 비용이 큰 계산 작업들은 코루틴으로 처리해야하며, 해당 작업들은 추후에 다룰 예정인 라이프사이클에 대응할 수 있는 이펙트 핸들러 내에서 처리되어야 합니다.
위치 기억법은 함수 메모이제이션의 한 형태입니다. 함수 메모이제이션이란? 함수가 이력값에 기반하여 결과를 캐싱하는 기법을 일컫습니다. 그래서 동일한 입력값에 대해 함수가 호출될 때마다 다시 계산할 필요가 없습니다.
함수 메모이제이션은 순수한 함수에 대해서만 가능합니다. 왜냐하면 순수한 함수들은 동일한 입력값에 대해 항상 동일한 결과를 반환할 것이라는 확실성이 있기 때문에, 우리는 그 함수의 결과를 캐싱하고 재사용할 수 있습니다.
함수 메모제이션에서, 함수 호출은 그 이름, 타입 및 매개변수 값의 조합을 통해 식별될 수 있습니다. 이러한 요소들을 사용해 고유한 키를 생성하고, 나중에 호출될 때 캐싱된 결과를 저장, 검색, 읽기 등을 위해 사용할 수 있습니다. Compose의 경우 추가적인 요소가 고려됩니다. Composable 함수는 소스 코드 내 호출 위치에 대한 불변 정보를 가지고 있습니다. 이 위치 정보를 이용해, 같은 함수를 동일한 매개변수로 호출하면 Composable 부모 트리 내에서 고유한 다른 ID를 생성합니다.
@Composable
fun MyComposable() {
Text(”Hello”) // id 1
Text(”Hello”) // id 2
Text(”Hello”) // id 3
}
위의 예시에서는 인메모리 트리는 3개의 다른 인스턴스를 저장하고, 각각의 고유한 정체성을 가지게 됩니다.
Composable의 정체성은 recomposition을 거쳐도 유지되므로, Compose Runtime이 이 구조를 활용하여 Composable이 이전에 호출되었는지 여부를 파악하고 가능한 경우 생략할 수 있습니다.
그러나 종종 Compose Runtime에서 Composable 함수에 고유한 정체성을 할당하는 것이 어려운 경우가 있습니다. 예를 들어 반복문에서 생성된 Composable 리스트 형태입니다.
@Composable
fun TalksScreen(talks: List<Talk>) {
Column {
for (talk in talks) {
Talk(talk)
}
}
}
이 경우, Talk 컴포저블은 매번 같은 위치에서 호출되지만, 각각의 talk 요소는 리스트 내에서 다른 항목으로 치부되고, 결과적으로 트리에서는 서로 다른 노드로 구성됩니다. 즉 호출 순서에 의존합니다.
이런 경우, 리스트의 상단이나 중간에 다른 요소를 추가하면 불필요한 recomposition이 발생합니다. 왜냐하면 Composable의 위치가 변경되었기 때문에, 입력 값이 변경되지 않더라도 고유 ID가 변경되기 때문입니다. 이러한 방식은 매우 긴 리스트에서 매우 비효율적입니다.
@Composable
fun TalksScreen(talks: List<Talk>) {
Column {
for (talk in talks) {
key(talk.id) {
Talk(talk)
}
}
}
}
위의 예제에서는 각 Talk Composable에 대한 키 값으로 고유한 talk ID를 사용하고 있습니다. 이렇게 위치 기반이 아닌 고유한 key를 사용하면 Compose Runtime이 Composable 함수의 위치에 상관없이 리스트에 속한 모든 항목의 정체성을 유지하도록 합니다.
위치 기반 기억법은 Compose Runtime이 설계에 따라 Composable 함수를 기억할 수 있게 합니다. 즉, 인메모리에 자동적으로 저장되고, recompositon을 생략할 수 있어야 합니다.
개발자들은 때로 Composable 함수의 범위보다 더 세밀하게 인메모리에 의존해야할 필요가 있습니다. 예를 들어서 Composable 함수 내에서 발생하는 비용이 큰 계산 결과를 캐싱하고 싶다고 가정해 보겠습니다.
이와 관련하여 Compose Runtime은 remember 함수를 제공합니다.
@Composable
fun FilteredImage(path: String) {
val filters = remember { computeFilters(path) }
ImageWithFiltersApplied(filters)
}
@Composable
fun ImageWithFiltersApplied(filters: List<Filter>) {
TODO()
}
여기서 우리는 이미지 필터라는 연산 결과를 사전에 계산하고 캐싱하기 위해 remember를 사용합니다.
캐싱된 값을 검색하기 위한 키 값은 소스 코드의 호출 위치와 함수의 입력값을 기반으로 합니다. remember는 트리의 상태를 유지하는 인메모리 구조에서 값을 메모리에 읽고 쓰는 역할을 수행하는 Composable 함수입니다.
그렇다면 인메모리에 저장된 값은 언제까기 유지될까요? 이 값은 메모리에 기억될 때, 메모이제이션을 호출하는 Composable 컨텍스트 생명주기 내에서만 실행됩니다. 실제로 Compose는 인메모리에서 값을 탐색할 때, Composable의 정보가 저장된 슬롯의 scope 내에서 값을 탐색합니다. 이러한 탐색은 Compose 컨텍스트 내에서 마치 싱글톤처럼 동작합니다. Composable이 다른 부모로부터 호출되는 경우는 새로운 인스턴스의 값이 반환됩니다.
Kotlin의 suspend 함수는 호출 컨텍스트를 필요로 하기 때문에, 다른 suspend 함수에서만 호출될 수 잇습니다. 이는 suspend 함수끼리만 묶일 수 있도록 보장하며, Kotlin 컴파일러가 모든 계산 과정에 걸쳐 런타임 환경을 주입하고 전달할 기회를 제공합니다. 런타임 환경에서 suspend 매개변수 목록의 끝에 Continuation라는 매개변수가 추가됩니다. 이러한 로직들은 Compose의 호출 컨텍스트와 유사합니다.
suspend fun publishTweet(tweet: Tweet): Post =
위의 코드는 Kotlin 컴파일러에 의해 아래와 같이 변경됩니다.
fun publishTweet(tweet: Tweet, callback: Continuation<Post>): Unit
Continuation은 일종의 콜백과 같습니다. 프로그램에 어떻게 실행을 계속 이어서 할지 알려주는 역할을 합니다. 즉, 런타임에서 다양한 중단점에서 실행을 중단하고 재개하는데 필요한 모든 정보를 담고 있습니다.
Compose의 호출 컨텍스트는 이와 유사하게 실행 트리 전반에 걸쳐 암시적인 방법으로 추가 정보를 전달하고 있습니다. Composable 함수는 표준 Kotlin 함수를 재시작 가능하고, 상태(State) 등으로부터 반응할 수 있도록 만듭니다.
Composable 함수는 표준 함수와는 다른 제약과 기능을 가지고 있습니다. 표준 함수와 Composable 함수는 서로 어떻게든 별개의 기능 범주를 나타낸다는 점에서 함수 컬러링이라는 차이점을 가지고 있습니다.
함수 컬러링이란? Google의 블로그 포스트에서 소개된 개념입니다. 해당 포스트에서는 동기 함수에서 비동기 함수를 호출할 수 없기 때문에, async와 sync 함수가 잘 결합되지 않는다고 설명합니다. 이를 해결하기 위해 일부 라이브러리와 언어에서는 Promise와 async/await를 도입했습니다. 이는 서로 다른 함수를 결합하려는 시도였으며, 해당 블로그에서는 이 두 가지 함수 범주를 다른 함수 색상(function coloring)으로 묘사했습니다.
Jetpack Compose에서 Composable 함수의 경우도 비슷합니다. Composable 함수는 프로그램 로직을 작성하기 위해 설계된 것이 아니라, 노드 트리의 변경사항을 기술하기 위한 함수입니다. 본질적인 색깔이 서로 다르기 때문에 우리는 표준 함수에서 Composable 함수를 호출할 수는 없습니다. 그렇게 하려면 setContent와 같은 통합점이 필요합니다.
이 글을 읽고 약간 혼란스러울 수 있습니다. 왜냐하면 우리는 Composable 함수 안에서 표준 함수를 자연스럽게 사용하고 있기 때문입니다.
아래의 예시를 살펴보겠습니다.
@Composable
fun SpeakerList(speakers: List<Speaker>) {
Column {
speakers.forEach {
Speaker(it)
}
}
}
Speaker Composable 함수는 forEach 람다에서 호출되는데, 컴파일에는 전혀 문제가 없습니다. 어떻게 서로 다른 색상의 함수를 혼용할 수 있을까요?
그 이유는 바로 인라인(inline) 때문입니다. 컬렉션 연산자는 인라인으로 선언되어 있기 때문에 호출한 부분에 람다식을 inline 시켜 마치 간접 호출이 없는 것처럼 효과를 만듭니다.
코드 내부를 보면 다음과 같습니다.
public inline fun <T> Iterable<T>.forEach(action: (T) -> Unit) { ... }
forEach는 inline으로 선언되어 있기 때문에, 컴파일 하면 다음과 같이 동작합니다.
@Composable
fun SpeakerList(speakers: List<Speaker>) {
Column {
for (speaker in speakers) {
Speaker(speaker)
}
}
}
inline을 활용하여 우리는 함수 컬러링 문제를 우회하고 Composable 로직을 작성할 수 있게 됩니다. 이로서, 트리에는 오직 Composable 함수로 구성되게 됩니다.
@Composable 어노테이션은 컴파일 시점에 함수의 타입을 효과적으로 변경합니다. 함수의 구문(syntax) 관점에서, Composable 함수의 타입은 @Composable (T) → A 입니다. 여기서 A는 Unit일 수 있고, 함수가 값을 반환하는 경우(예를 들어 remember) 다른 타입일 수 있습니다.
개발자들은 kotlin에서 일반적인 람다를 선언하는 것처럼 Composable 람다를 선언할 수 있습니다.
// 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)
}
}
또한 Composable 함수는 @Composable Scope.() → A와 같은 형태의 타입을 가질 수 있는데, 이는 특정 Composable로만 정보 범위를 지정하는 데 자주 사용됩니다.
언어적 관점에서 볼 때, 타입은 컴파일러에 정보를 제공하여 빠른 정적 검증을 수행하고, 때로는 편리한 코드를 생성하며, 런타임에 의해 활용되는 데이터 사용 방식을 제한 및 정제하기 위해 존재합니다.
@Composable 어노테이션은 런 타임 시 Composable 함수의 유효성을 검사하고 사용하는 방법을 변경하는데, 이것이 바로 Composable 함수가 Kotlin의 표준 함수와 다른 타입으로 간주되는 이유입니다.