
Jetpack Compose를 사용해 UI를 그릴 때 우린 당연한 것처럼 @Composable을 사용해 왔다. 하지만 @Composable 어노테이션이 정확히 어떤 원리로 화면을 그리며, 일반 함수와는 어떤 차이가 있는지 알지 못한다. 이 글에선 @Composable 어노테이션이 함수에 어떤 특성을 부여하는지, Compose Runtime이 이 특성을 어떻게 활용하는지를 하나씩 살펴볼 것이다.
Composable 함수의 의미
Composable 함수는 실행 시 자신의 정보를 메모리 속 트리에 노드로 기록하는데 이를 Compose 관용어로 "방출(emit)"이라고 한다.
@Composable
fun Greeting(name: String) {
Text(text = "Hello, $name!")
}
위 함수가 실행되면 "Hello, $name!" 정보를 담은 노드가 트리에 추가되고 Compose Runtime이 트리를 해석해 실제 UI를 만든다. Composable 함수를 표현식으로 나타내면 다음과 같다.
@Composable (Input) -> Unit
입력은 함수의 인자, 출력은 트리에 노드를 삽입하는 동작이며 이는 Composition 처리 과정 중에 발생한다.
Composable 함수는 상태가 변경될 때마다 다시 실행되어 메모리 구조를 항상 최신 상태로 유지한다. 따라서 트리를 최신 상태로 유지하기 위해 노드를 추가하거나 제거, 교체한다.
Composable 함수의 속성
Compose Runtime은 Composable 함수가 사전에 정의된 특성을 준수하도록 가정하기 때문에 병렬적인 Composition, 우선순위에 따른 임의의 Composition 정렬, 스마트 Recomposition , 또는 위치 기억법(positional memoization) 등과 같은 다양한 런타임 최적화 기법을 제공한다.
호출 컨텍스트
Composable 함수는 반드시 다른 Composable 함수 안에서만 호출할 수 있다. 일반 함수에서 Composable을 호출하면 컴파일 에러가 발생하는데 왜 이런 제약이 존재할까?
이를 이해하려면 Compose Compiler가 뒤에서 어떤 일을 하는지 알아야 한다. Compose Compiler는 컴파일 시점에 모든 Composable 함수의 매개변수 목록 끝에 Composer와 $changed라는 매개변수를 암묵적으로 추가한다.
@Composable
fun Greeting(name: String) {
var count by remember { mutableStateOf(0) }
Text(
text = "Hello $name!",
)
}
public static final void Greeting(
@NotNull String name,
@Nullable Composer $composer,
int $changed
)
Composer
Composable 함수와 Compose Runtime 사이의 중재자 역할을 한다. Composable 함수는 Composer를 통해 트리에 노드를 추가하거나 업데이트하며, Composer는 트리의 모든 Composable 호출로 하향 전달되기 때문에 트리 어느 깊이에서든 접근할 수 있다.
$changed
Recomposition 최적화를 위한 비트마스크로 각 비트가 매개변수 하나의 변경 여부를 나타낸다. Compose Runtime은 Recomposition 시점에 이 값을 보고 매개변수가 변경되지 않았다면 해당 Composable을 건너뛴다.
이 구조는 suspend 함수와 비슷한데, suspend 함수도 컴파일러가 Continuation이라는 매개변수를 함수의 마지막 인자로 암묵적으로 추가하며 Composable의 Composer와 같은 역할을 한다.
멱등성(Idempotent)
Composable 함수는 동일한 입력에 대해 항상 동일한 트리를 생성해야 하며 이를 멱등성이라고 한다.
Compose Runtime은 상태가 변경되면 Composable 함수를 다시 실행해 트리를 업데이트하는데, 이 과정을 Recomposition이라 한다. Recomposition은 트리를 순회하며 입력값이 변경된 노드만 다시 실행하고 나머지는 건너뛴다.
이때, 특정 노드를 건너뛸 수 있는 이유가 바로 멱등성 덕분이다. 입력값이 동일하다면 어차피 같은 결과가 나올 것이 보장되기 때문에 이미 메모리에 저장된 결과를 그대로 재사용한다. 앞서 살펴본 $changed가 바로 이 판단을 실제로 수행하는 메커니즘이다.
@Composable
fun Greeting(name: String) {
Text(text = "Hello, $name!")
}
name이 변경되지 않았다면 Compose Runtime은 Greeting을 다시 실행하지 않는다. 동일한 입력이면 동일한 트리가 나온다는 것이 보장되기 때문이다.
반대로 멱등성이 깨지면 어떻게 될까? Compose Runtime은 "입력이 같으면 결과도 같다"라고 가정하고 건너뛰었는데 실제로는 다른 결과가 나와버려 UI와 실제 상태와 달라지는 버그로 이어진다.
// 멱등성이 깨지는 예시
var count = 0
@Composable
fun Counter() {
count++ // 외부 상태를 직접 변경
Text(text = "Count: $count")
}
위 코드는 Counter가 몇 번 실행되느냐에 따라 결과가 달라진다. Recomposition이 발생할 때마다 count가 증가하기 때문에 동일한 입력임에도 다른 UI가 그려지기 때문에 Composable 함수 안에서 외부 상태를 직접 읽거나 쓰는 것을 피해야 한다.
통제되지 않은 사이드 이펙트 방지
사이드 이펙트란, 호출된 함수의 제어를 벗어나 발생할 수 있는 예상치 못한 모든 동작을 의미하며 네트워크 요청, 데이터베이스 트랜잭션, 전역 변수 변경 등이 해당한다.
Composable 함수는 여러 번, 임의의 순서로, 심지어 병렬로 실행될 수 있다. 이러한 특성 때문에 Composable 함수 안에서 직접 사이드 이펙트를 실행하면 통제할 수 없는 상황이 발생한다.
@Composable
fun EventsFeed(networkService: EventsNetworkService) {
val events = networkService.loadAllEvents() // 네트워크 요청
LazyColumn {
items(events) { event ->
Text(text = event.name)
}
}
}
위 코드는 Recomposition이 발생할 때마다 네트워크 요청이 실행된다. Composable 함수는 Compose Runtime에 의해 짧은 시간 내에 여러 번 다시 실행될 수 있으며, 개발자가 제어할 수 없기 때문에 네트워크 요청이 의도치 않게 반복 호출된다. 더 큰 문제는 실행 순서인데 아래 코드를 보자.
@Composable
fun MainScreen() {
Header()
ProfileDetail()
EventList()
}
Header, ProfileDetail, EventList는 Compose Compiler에 의해 어떤 순서로든, 심지어 병렬로 실행될 수 있기 때문에 Header에서 외부 변수를 업데이트하고 ProfileDetail에서 그 변수를 읽는 식의 로직은 절대 작성해선 안 된다.
때문에 우리는 모든 Composable 함수를 Stateless 하게 만들려고 노력해야 하며 이를 위해 Composable 함수는 모든 입력값을 매개변수로 전달받아 외부 상태에 의존하지 않도록 설계해야 한다.
그러나 모든 상황에서 Stateless 한 Composable 함수를 만드는 것은 불가능하다. 이를 해결하기 위해 Jetpack Compose는 LaunchedEffect, SideEffect, DisposableEffect 같은 사이드 이펙트 핸들러를 제공한다.
사이드 이펙트 핸들러는 사이드 이펙트가 Composable의 라이프사이클을 인식하도록 하여 안전하고 통제된 환경에서 실행될 수 있게 한다.
재시작 가능(Restartable)
일반 함수는 호출되면 콜 스택에서 한 번 실행되고 끝이다. 하지만 Composable 함수는 다르다. Compose Runtime은 Composable 함수가 관찰하는 상태(State)가 변경될 때마다 해당 함수를 다시 실행할 수 있도록 참조를 유지한다. 이를 재시작 가능(Restartable)하다고 한다.
Compose Compiler는 상태를 읽는 Composable 함수를 감지해 Compose Runtime에게 재시작하는 방법을 알려주는 코드를 자동으로 생성한다. 반대로 상태를 읽지 않는 Composable은 재시작할 필요가 없으므로 해당 코드가 생성되지 않는다.
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text(text = "Count: $count")
}
}
count가 변경되면 Compose Runtime은 Counter를 다시 실행한다. 이때 Recomposition은 변경된 상태를 읽는 Composable만 대상으로 하기 때문에 count를 읽지 않는 다른 Composable은 재실행되지 않는다.
public static final void Greeting(
@NotNull String name,
@Nullable Composer $composer,
int $changed
) {
$composer = $composer.startRestartGroup(1442732377);
...
Object it$iv$iv = $composer.rememberedValue();
...
ScopeUpdateScope var10000 = $composer.endRestartGroup();
}
위 코드를 디컴파일해보면 다음과 같이 변환되는데, startRestartGroup과 endRestartGroup이 바로 이 재시작 메커니즘을 구현하는 코드다. startRestartGroup은 Composable의 재시작 범위를 시작하고, endRestartGroup은 범위를 끝내며 상태가 변경됐을 때 이 Composable을 어떻게 재시작할지 Compose Runtime에 등록한다.
rememberedValue()는 remember { } 블록의 실제 구현이다. remember는 내부적으로 Composer가 관리하는 슬롯 테이블에서 이전에 저장된 값을 읽어온다.
즉, $composer.rememberedValue()는 이전 Composition에서 저장해 둔 count의 상태를 슬롯 테이블에서 꺼내오기 때문에 Recomposition이 발생해도 remember 블록 안의 값이 초기화되지 않고 유지되는 이유가 여기에 있다.
위치 기억법(Positional Memoization)
메모이제이션(Memoization)이란 함수가 동일한 입력에 대해 결과를 캐싱해 두고 같은 입력이 들어오면 다시 계산하지 않고 캐싱된 결과를 반환하는 기법이다. Compose는 이 개념을 Composable 함수에 적용하는데, 한 가지 요소가 추가된다.
Composable 함수는 소스 코드에서의 호출 위치가 식별자로 사용된다. 즉, 동일한 함수라도 다른 위치에서 호출되면 Compose Runtime은 이를 다른 노드로 취급한다.
@Composable
fun MyComposable() {
Text("Hello") // id 1
Text("Hello") // id 2
Text("Hello") // id 3
}
세 Text는 동일한 함수에 동일한 입력이지만 호출 위치가 다르기 때문에 트리에서 각각 고유한 노드로 저장된다. 이 메커니즘이 동작하는 핵심이 바로 앞서 살펴본 슬롯 테이블이다.
Compose Runtime은 Composable 함수의 정보를 슬롯 테이블에 저장하며, 호출 위치를 키로 사용해 이전에 저장된 값을 읽고 쓴다. remember도 이 슬롯 테이블을 활용해 값을 캐싱한다.

@Composable
fun FilteredImage(path: String) {
// 계산 비용이 왕 큰 함수
val filters = remember { computeFilters(path) } // 슬롯 테이블에 캐싱
ImageWithFiltersApplied(filters)
}
Composable 함수 내에서 발생하는 비용이 큰 계산 결과를 캐싱하고 싶다고 가정해 보자. 이와 관련해 Compose Runtime은 remember 함수를 제공한다.
예시 코드에선 이미지 필터라는 연산 결과를 사전 계산하고 캐싱하기 위해 remember를 사용했다. 캐싱된 값을 검색하기 위한 키 값은 소스 코드의 호출 위치와 함수의 입력값을(위의 예시 코드에서는 path라는 파일 경로가 사용) 기반으로 한다.
리스트처럼 반복문에서 Composable이 생성되는 경우엔 어떨까?
@Composable
fun TalksScreen(talks: List<Talk>) {
Column {
for (talk in talks) {
Talk(talk)
}
}
}
예시 코드의 경우 Talk는 매번 같은 위치에서 호출되기 때문에 Compose Runtime은 호출 순서를 기준으로 각 노드를 구별한다. 이 경우 리스트 끝에 항목을 추가하는 경우엔 문제가 없지만 중간이나 앞에 항목을 추가하면 Compose Runtime이 요소 삽입이 발생하는 지점 아래의 모든 Talk Composable함수에 대해서 Recomposition을 발생시킨다.
이는 Composable 함수들의 위치가 변경되었기 때문인데, 이는 해당 함수들의 입력값이 변경되지 않았더라도 해당한다. 마치 Recyclerview의 notifyDataSetChanged()를 사용할 때와 유사한데, 이를 해결하기 위해 Compose는 key를 제공한다.
@Composable
fun TalksScreen(talks: List<Talk>) {
Column {
for (talk in talks) {
key(talk.id) {
Talk(talk)
}
}
}
}
key를 사용하면 Compose Runtime이 호출 위치가 아닌 명시적으로 지정한 키 값으로 노드를 식별하기 때문에 리스트 중간에 항목이 추가되더라도 기존 노드의 정체성이 유지되어 불필요한 Recomposition이 발생하지 않는다.
함수 컬러링 (Function Coloring)
Composable 함수는 일반 함수에서 호출할 수 없다. 반드시 setContent { } 같은 통합점을 거쳐야 한다. 이처럼 서로 다른 두 범주의 함수가 섞이지 못하는 현상을 함수 컬러링(Function Coloring)이라고 한다.
함수 컬러링은 Bob Nystrom이 2015년 작성한 "What color is your function?"이라는 글에서 소개된 개념으로 동기 함수에서 비동기 함수를 호출할 수 없는 문제를 두 함수가 서로 다른 색깔을 가진다고 표현한다.
Kotlin의 suspend 함수도 마찬가지로 다른 suspend 함수에서만 호출할 수 있기 때문에 채색된 함수로 간주된다. @Composable도 동일한 맥락이다. 일반 함수에서 Composable을 호출하려면 통합점이 필요하고 그 이후로는 완전히 Composable로만 이루어진 콜 스택을 갖게 된다. 그런데 아래 코드는 어떨까?
@Composable
fun SpeakerList(speakers: List<Speaker>) {
Column {
speakers.forEach {
Speaker(it) // 일반 함수(forEach 람다) 안에서 Composable 호출
}
}
}
forEach는 일반 함수인데 그 안에서 Composable인 Speaker를 호출하고 있다. 컴파일 에러가 날 것 같지만 문제없이 동작한다.
이유는 forEach가 inline 함수이기 때문으로, 컴파일 시점에 람다 코드가 호출부에 그대로 인라이닝 되기 때문에 실제로는 SpeakerList 본문 안에서 Speaker가 호출되는 것과 같다.
Composable 함수 타입 (Composable Function Types)
@Composable 어노테이션은 컴파일 타임에 함수의 타입을 변경한다. Composable 함수의 타입은 다음과 같으며 일반 람다처럼 변수에 저장하거나 다른 함수에 전달할 수 있다.
@Composable (Input) -> Unit
또한 Composable 함수는 @Composable Scope.() -> Unit 형태의 타입을 가질 수 있는데, 특정 Composable 안에서만 사용할 수 있는 범위를 지정할 때 활용된다. Box의 content 파라미터가 대표적인 예다.
inline fun Box(
content: @Composable BoxScope.() -> Unit
) { ... }
BoxScope가 수신 객체로 지정되어 있기 때문에 content 람다 안에서만 BoxScope의 함수를 사용할 수 있다. Compose에서 Modifier 관련 함수들이 특정 스코프 안에서만 동작하는 이유가 바로 이 타입 시스템 덕분이다.
마무리
지금까지 @Composable 어노테이션이 함수에 어떤 특성을 부여하는지, 그리고 Compose Runtime이 이를 어떻게 활용하는지 살펴봤다. 정리하면 다음과 같다.
- 호출 컨텍스트 : Compose Compiler가 Composer와 $changed를 암묵적으로 추가하고 Composable은 반드시 다른 Composable 안에서만 호출할 수 있다.
- 멱등성 : 동일한 입력에 대해 항상 동일한 트리를 생성해야 하며, 이를 통해 Recomposition 시 불필요한 재실행을 건너뛸 수 있다.
- 사이드 이펙트 방지 : Composable은 여러 번, 임의의 순서로 실행될 수 있기 때문에 직접적인 사이드 이펙트는 이펙트 핸들러를 통해 안전하게 처리해야 한다.
- 재시작 가능 : 관찰하는 상태가 변경되면 Compose Runtime이 해당 Composable을 다시 실행한다.
- 위치 기억법 : 호출 위치를 식별자로 사용해 Composable의 정체성을 유지하고 슬롯 테이블을 통해 값을 캐싱한다.
- 함수 컬러링 : Composable은 채색된 함수로 통합점 이후 완전히 Composable로만 이루어진 콜 스택을 갖는다.
'Android' 카테고리의 다른 글
| Compose SlotTable Internals (1) | 2026.04.26 |
|---|---|
| ART(Android Runtime)와 Baseline Profile 기초 (2) | 2026.03.16 |
| Android Bitmap과 메모리 최적화 (0) | 2026.02.22 |
| Android ExifInterface를 활용해 촬영한 사진이 회전하는 문제 해결하기 (0) | 2026.02.14 |
| Compose 디자인 시스템 설계하기 (0) | 2026.01.22 |
