Compose 기본2026년 03월 04일· 10 min read

20. Compose 기초: Composable 함수와 재구성(Recomposition)이 일어나는 이유

Composable 함수가 왜 순수 함수처럼 보이지만 상태를 가진 UI로 동작하는지, Slot Table과 Recomposition 비교 로직까지 내부 관점으로 설명한다. remember와 Stable 설계 이유 포함. (154자)​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​

20. Compose 기초: Composable 함수와 재구성(Recomposition)이 일어나는 이유

Compose 기초: Composable 함수와 재구성(Recomposition)이 일어나는 이유

처음 Compose를 붙이면 이런 장면이 자주 나온다. Button을 한 번 눌렀을 뿐인데 로그가 여러 번 찍히고, 내가 만든 Composable이 ‘전체가 다시 실행되는 것처럼’ 보인다. View 시스템에서 invalidate()/requestLayout()에 익숙하면 더 혼란스럽다. “대체 뭐가 다시 그려지는 거지?”라는 질문에 답하려면, Compose가 UI를 함수 호출 기록(슬롯)으로 저장하고 비교해서 필요한 곳만 다시 호출하는 구조를 먼저 잡아야 한다.

핵심 개념

Compose의 UI는 ‘화면에 그려진 결과물’이 아니라 ‘Composable 호출의 기록’으로 관리된다. 화면이 바뀌는 순간에 View 트리를 직접 조작하는 대신, Runtime이 Composable을 다시 호출해 새 트리를 계산하고 이전 기록과 비교한다. 이 비교가 가능한 이유가 Slot Table(슬롯 테이블)이다. Slot Table은 “이 위치에서 어떤 Composable이 어떤 순서로 호출됐고, 어떤 파라미터를 가졌고, remember가 만든 값이 무엇이었는지”를 저장하는 데이터 구조다.

용어를 기능 맥락으로 묶으면 이해가 빨라진다. Composition은 Composable 호출을 실행해 Slot Table에 기록을 만드는 단계다. Recomposition은 기존 Slot Table을 기준으로 ‘변한 입력’이 있는 구간만 다시 호출해 기록을 갱신하는 단계다. Skipping(스킵)은 “이 그룹은 입력이 같으니 호출을 생략한다”라는 최적화다. State 읽기 추적은 mutableStateOf 같은 State를 ‘어디서 읽었는지’를 Runtime이 기억해두고, 그 State가 바뀌면 그 읽기 지점 근처만 무효화(invalidate)하는 메커니즘이다.

왜 이런 구조가 필요했나. View 시스템은 객체 그래프(View 트리)가 곧 UI 상태였고, 변경은 명령형으로 흩어지기 쉬웠다. 텍스트 하나 바꾸려면 findViewById로 잡고 setText를 호출해야 했고, 조건부 UI는 visibility 조합으로 복잡해졌다. Compose는 “UI = state의 함수”로 만들고, 변경은 state만 바꾸게 강제한다. 대신 함수 호출이 반복되니, ‘반복 호출을 싸게 만드는’ Slot Table과 스킵 로직이 필수였다.

remember가 왜 필요한지도 여기서 나온다. Composable은 재호출되면 로컬 변수가 매번 새로 만들어진다. 그런데 UI는 이벤트 사이에 값을 유지해야 한다. 예를 들어 클릭 횟수, 애니메이션 진행, InteractionSource 같은 객체는 프레임 사이에 살아 있어야 한다. remember는 Slot Table의 특정 슬롯에 값을 저장해두고, 같은 호출 위치로 돌아오면 그 값을 재사용한다. 위치 기반이라는 점이 중요하다. 호출 순서가 바뀌면 다른 슬롯을 읽게 되고, 그때부터 ‘기억이 뒤섞이는’ 버그가 나온다.

CounterWithLogs.kt
1package com.example.composebasics
2
3import android.util.Log
4import androidx.compose.foundation.layout.Column
5import androidx.compose.material3.Button
6import androidx.compose.material3.Text
7import androidx.compose.runtime.Composable
8import androidx.compose.runtime.mutableIntStateOf
9import androidx.compose.runtime.remember
10import androidx.compose.runtime.getValue
11import androidx.compose.runtime.setValue
12
13@Composable
14fun CounterWithLogs() {
15    Log.d("Compose", "CounterWithLogs() called")
16
17    var count by remember { mutableIntStateOf(0) }
18
19    Column {
20        Text("count=$count")
21        Button(onClick = { count++ }) {
22            Text("+1")
23        }
24    }
25}

이 코드를 실행하고 버튼을 누르면 Logcat에 CounterWithLogs() called가 다시 찍힌다. ‘다시 그린다’가 아니라 ‘다시 호출한다’가 더 정확하다. count++가 mutableStateOf의 값을 바꾸면 Snapshot 시스템이 변경을 기록하고, 그 State를 읽었던 Composition 범위를 무효화한다. 다음 프레임에서 Runtime은 무효화된 그룹만 재호출해 Slot Table의 해당 구간을 업데이트한다. Text와 Button 전체가 다시 호출된 것처럼 보이지만, 실제로는 스킵 가능한 하위 그룹은 입력 비교 후 생략될 수 있다.

한 문단 요약: Compose는 UI를 View 객체로 유지하지 않고 Composable 호출 기록(Slot Table)으로 유지한다. State가 바뀌면 그 State를 ‘읽은’ 위치만 무효화되고, Runtime이 그 구간만 재호출(Recomposition)해 기록을 갱신한다. remember는 재호출에도 값을 유지하기 위해 Slot Table에 값을 고정시키는 장치다.

컴포넌트 해부

초보가 가장 빨리 감을 잡는 컴포넌트는 Button이다. Button은 단일 위젯처럼 보이지만, 내부는 Surface(배경/테두리/클립/리플/톤) + Layout(Row/Box 등) + Content slot(사용자가 넣는 내용)로 분해된다. 이 분해가 API 설계의 이유다. 배경/상태/상호작용은 프레임워크가 책임지고, 내용은 호출자가 slot으로 주입한다.

  • modifier: 왜 체이닝인가 → 순서가 의미를 갖는 ‘노드 파이프라인’이기 때문(패딩 후 클릭영역 vs 클릭영역 후 패딩)
  • enabled: 왜 Boolean인가 → 입력 비교로 스킵 가능, Interaction/semantics도 함께 끄기 위함
  • onClick: 왜 람다인가 → 이벤트를 State 변경으로 연결시키는 단방향 데이터 흐름
  • shape: 왜 외부 주입인가 → clip/outline 계산이 레이아웃·드로잉 단계에 영향
  • colors: 왜 객체인가 → 상태(enabled/pressed/disabled)에 따른 색 결정 로직 캡슐화
  • elevation: 왜 분리인가 → 그림자 계산이 드로잉 단계에서 독립적으로 변함
  • border: 왜 nullable인가 → Slot Table 비교에서 ‘없음’이 명확한 상태
  • contentPadding: 왜 별도인가 → Layout 단계에서 측정/배치에 직접 영향
  • interactionSource: 왜 주입 가능한가 → 여러 컴포넌트가 같은 pressed/dragged 상태를 공유 가능
  • content: 왜 @Composable slot인가 → 호출자 UI를 Button 내부 Row/Box에 삽입하기 위함
  • semantic label(콘텐츠 설명): 왜 필요한가 → 접근성 트리에서 텍스트가 없을 때도 의미 전달
  • role: 왜 필요한가 → 스크린리더/테스트에서 버튼임을 명확히 하기 위함

Surface 계층은 ‘보이는 것’과 ‘눌리는 것’을 책임진다. 배경색, 모양, 테두리, elevation, 리플 같은 효과가 여기에 속한다. View 시스템이라면 background drawable, foreground ripple, outlineProvider, stateListAnimator 등 여러 속성이 흩어졌는데, Compose는 Surface가 이를 한 덩어리로 묶는다. 이 덩어리는 입력 비교가 쉬운 파라미터 집합이 되고, 스킵의 단위가 된다.

Content 계층은 ‘배치’와 ‘내용’을 책임진다. Button은 보통 Row로 아이콘+텍스트를 배치하고, 최소 터치 타겟과 패딩을 적용한다. 여기서 중요한 포인트는 content slot이 단순 콜백이 아니라 Composable 호출이라는 점이다. Button이 재구성될 때 content slot도 다시 호출될 수 있고, 반대로 Button의 외형만 바뀌면 content는 스킵될 수도 있다. 이 경계를 잘 만들면 재구성 비용이 눈에 띄게 줄어든다.

처음에 나도 content slot이 왜 이렇게 중요하다는 말을 이해 못 했다. Layout Inspector에서 Button을 누를 때마다 트리가 ‘깜빡’ 갱신되는 걸 보고 전체가 다시 그려진다고 단정했다. 3시간 정도 로그를 더 촘촘히 찍어보니, 실제로는 content 내부의 Text는 파라미터가 같아서 스킵되는 경우가 많았다. Inspector는 ‘노드가 갱신되는 것처럼’ 보여도, Runtime은 슬롯 그룹 단위로 호출을 생략할 수 있다.

EducationalButton.kt
1package com.example.composebasics
2
3import androidx.compose.foundation.interaction.MutableInteractionSource
4import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.RowScope
6import androidx.compose.foundation.layout.padding
7import androidx.compose.material3.Surface
8import androidx.compose.runtime.Composable
9import androidx.compose.runtime.remember
10import androidx.compose.ui.Modifier
11import androidx.compose.ui.unit.dp
12
13@Composable
14fun EducationalButton(
15    modifier: Modifier = Modifier,
16    enabled: Boolean = true,
17    onClick: () -> Unit,
18    interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
19    content: @Composable RowScope.() -> Unit
20) {
21    // 교육용 스케치: 실제 Button 구현과 다르며 구조만 설명한다.
22    Surface(
23        modifier = modifier
24            .padding(0.dp),
25        // 실제로는 clickable/semantics/ripple 등이 Modifier로 붙는다.
26        content = {
27            Row(modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp)) {
28                content()
29            }
30        }
31    )
32}

이 스케치 코드가 필요한 이유는 ‘slot’의 경계를 눈으로 보이게 만들기 위해서다. Surface(content=...) 안쪽 Row(content())가 content slot이다. 실행하면 아이콘/텍스트를 호출자가 넣을 수 있고, Button 자체는 배경/모양/상호작용을 감싼다. 실제 Material3 Button은 훨씬 많은 Modifier 노드(semantics, clickable, indication 등)를 붙이지만, 핵심은 “외형 계층과 내용 계층을 분리하면 재구성 범위를 분리할 수 있다”라는 점이다.

EducationalButtonSample.kt
1package com.example.composebasics
2
3import androidx.compose.foundation.layout.Spacer
4import androidx.compose.foundation.layout.width
5import androidx.compose.material3.Icon
6import androidx.compose.material3.MaterialTheme
7import androidx.compose.material3.Text
8import androidx.compose.runtime.Composable
9import androidx.compose.ui.Modifier
10import androidx.compose.ui.graphics.vector.ImageVector
11import androidx.compose.ui.unit.dp
12
13@Composable
14fun EducationalButtonSample(
15    icon: ImageVector,
16    label: String,
17    onClick: () -> Unit
18) {
19    EducationalButton(
20        modifier = Modifier,
21        enabled = true,
22        onClick = onClick
23    ) {
24        Icon(imageVector = icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
25        Spacer(Modifier.width(8.dp))
26        Text(label)
27    }
28}

이 샘플을 붙여서 실행하면 아이콘과 텍스트가 Row로 배치된 버튼이 나온다. label만 바뀌면 Text 파라미터 비교로 해당 그룹이 다시 호출되고, icon이 동일하면 Icon은 스킵될 수 있다. 반대로 enabled만 바뀌면 Surface/interaction/semantics 쪽이 바뀌고 content는 스킵될 수 있다. 이런 분리가 Compose API가 slot 중심으로 설계된 이유다.

내부 동작 원리

Composable 함수는 컴파일러 플러그인이 형태를 바꾼다. 소스 레벨에서는 일반 함수처럼 보이지만, Compose Compiler는 숨겨진 파라미터(Composer, changed 플래그, default 마스크)를 추가한 함수로 변환한다. 호출 지점마다 ‘그룹 시작/종료’가 삽입되고, remember는 Slot Table의 현재 슬롯 인덱스를 기준으로 값을 저장하거나 읽는다. 개발자가 Slot Table을 직접 만지지 않아도 되는 이유는 컴파일러가 이 프로토콜을 자동으로 생성하기 때문이다.

Runtime 실행 순서를 Button 맥락으로 쪼개면 3단계다. 1) Composition: Composable 호출을 실행하며 UI 트리(정확히는 노드 생성 지시 + 슬롯 기록)를 만든다. 2) Layout: 측정(measure)과 배치(place)가 일어나며, Modifier의 layout 관련 노드가 여기서 개입한다. 3) Drawing: 캔버스에 그리며, background/border/clip/elevation 같은 드로잉 노드가 여기서 동작한다. Recomposition은 1단계를 다시 수행하지만, 2·3단계는 ‘노드가 바뀌었는지’에 따라 부분적으로만 다시 수행된다.

State 변경이 Recomposition으로 이어지는 과정은 Snapshot에서 시작한다. mutableStateOf는 내부적으로 읽기(read)와 쓰기(write)를 추적한다. Composition 중에 state.value를 읽으면, Runtime은 “이 그룹이 이 State를 읽었다”라는 의존성을 등록한다. 이후 write가 발생하면 해당 그룹이 invalid로 표시되고, 다음 프레임에서 Recomposer가 그 그룹을 재구성 대상으로 스케줄링한다.

비교는 두 축에서 일어난다. 첫째는 ‘파라미터 비교’다. 컴파일러가 생성한 changed 플래그는 이전 값과 새 값을 비교해 동일하면 스킵 후보로 만든다. 둘째는 ‘안정성(stability)’이다. @Stable/@Immutable은 “이 타입은 내부 변경이 관찰 가능하게 전달된다” 혹은 “불변이라 참조가 같으면 내용도 같다”라는 힌트를 준다. 안정성이 낮으면 Runtime은 보수적으로 다시 호출하는 쪽을 택한다.

Modifier 체이닝이 왜 이런 형태인가에 대한 답도 내부 구조에 있다. Modifier는 리스트가 아니라 ‘연결된 노드 체인’에 가깝다. 각 Modifier 요소는 layout, draw, semantics, pointerInput 같은 역할별 노드로 분해되어 파이프라인을 구성한다. 체이닝 순서가 곧 파이프라인 순서라서, padding().clickable()과 clickable().padding()은 실제 터치 영역과 semantics 경계가 달라진다.

처음에 내가 겪은 대표적인 함정은 “왜 클릭 영역이 이상하게 줄었지?”였다. padding을 clickable 뒤에 붙였더니 화면상 크기는 커졌는데 터치가 안 먹는 구간이 생겼다. Layout Inspector에서 bounds를 확인해도 감이 안 와서 pointer input 로그를 찍었고, hit test가 clickable 노드의 레이아웃 크기 기준으로 끝난다는 걸 확인했다. Modifier 순서가 곧 노드 순서라는 사실을 체감한 순간이었다.

RecompositionScopeDemo.kt
1package com.example.composebasics
2
3import android.util.Log
4import androidx.compose.foundation.layout.Column
5import androidx.compose.material3.Button
6import androidx.compose.material3.Text
7import androidx.compose.runtime.Composable
8import androidx.compose.runtime.getValue
9import androidx.compose.runtime.mutableIntStateOf
10import androidx.compose.runtime.remember
11import androidx.compose.runtime.setValue
12
13@Composable
14fun RecompositionScopeDemo() {
15    var a by remember { mutableIntStateOf(0) }
16    var b by remember { mutableIntStateOf(0) }
17
18    Column {
19        Log.d("Compose", "Column recomposed")
20        TextA(a)
21        TextB(b)
22        Button(onClick = { a++ }) { Text("inc a") }
23        Button(onClick = { b++ }) { Text("inc b") }
24    }
25}
26
27@Composable
28private fun TextA(a: Int) {
29    Log.d("Compose", "TextA recomposed a=$a")
30    Text("A=$a")
31}
32
33@Composable
34private fun TextB(b: Int) {
35    Log.d("Compose", "TextB recomposed b=$b")
36    Text("B=$b")
37}

이 코드를 실행하고 inc a만 연타하면 Logcat에 TextA만 계속 찍히는 패턴이 보인다. Column 로그는 상황에 따라 같이 찍힐 수도 있고 아닐 수도 있다(컴파일러의 그룹 분할과 스킵 여부에 따라). 핵심은 a를 읽는 그룹(TextA)만 invalid가 되었다는 점이다. View 시스템이라면 부모 invalidate가 자식 전체를 흔드는 경우가 많지만, Compose는 ‘State 읽기 위치’가 재구성 범위를 결정한다.

ModifierOrderAndSemanticsDemo.kt
1package com.example.composebasics
2
3import androidx.compose.foundation.clickable
4import androidx.compose.foundation.interaction.MutableInteractionSource
5import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.padding
7import androidx.compose.material3.Text
8import androidx.compose.runtime.Composable
9import androidx.compose.runtime.remember
10import androidx.compose.ui.Modifier
11import androidx.compose.ui.semantics.Role
12import androidx.compose.ui.semantics.contentDescription
13import androidx.compose.ui.semantics.role
14import androidx.compose.ui.semantics.semantics
15import androidx.compose.ui.unit.dp
16
17@Composable
18fun ModifierOrderAndSemanticsDemo() {
19    val interaction = remember { MutableInteractionSource() }
20
21    Box(
22        modifier = Modifier
23            .padding(24.dp)
24            .semantics {
25                role = Role.Button
26                contentDescription = "설정 열기"
27            }
28            .clickable(
29                interactionSource = interaction,
30                indication = null
31            ) { /* no-op */ }
32            .padding(24.dp)
33    ) {
34        Text("Tap area depends on modifier order")
35    }
36}

이 코드는 padding이 clickable 앞뒤로 섞이면 터치 영역과 semantics 경계가 달라질 수 있다는 점을 보여준다. TalkBack을 켜면 contentDescription이 버튼으로 읽히고, 터치 가능한 영역은 clickable이 붙은 시점의 레이아웃 크기에 의해 결정된다. interactionSource를 remember로 고정하지 않으면 pressed 상태가 프레임마다 새 객체로 끊겨 리플/프레스 상태가 불안정해질 수 있다. 이런 종류의 버그는 ‘왜 remember가 필요한가’의 실제 답이다.

실습하기

실습 목표는 세 가지다. 1) Composable이 ‘다시 호출되는 것’을 로그로 확인한다. 2) remember가 없을 때 값이 왜 유지되지 않는지 재현한다. 3) Modifier 순서가 터치 영역과 재구성 범위에 어떤 영향을 주는지 눈으로 확인한다. 에뮬레이터 한 대와 Logcat이면 충분하다.

build.gradle.kts
1plugins {
2    id("com.android.application")
3    id("org.jetbrains.kotlin.android")
4}
5
6android {
7    namespace = "com.example.composebasics"
8    compileSdk = 34
9
10    defaultConfig {
11        minSdk = 24
12        targetSdk = 34
13    }
14
15    buildFeatures {
16        compose = true
17    }
18}
19
20dependencies {
21    implementation(platform("androidx.compose:compose-bom:2024.12.01"))
22    implementation("androidx.activity:activity-compose:1.9.3")
23    implementation("androidx.compose.material3:material3")
24    debugImplementation("androidx.compose.ui:ui-tooling")
25}

Compose BOM만 맞추면 material3와 tooling이 따라온다. 여기서 중요한 건 tooling이 디버그에서만 들어가야 한다는 점이다. 실습 중 Layout Inspector와 Preview를 쓰려면 ui-tooling이 필요하다. 버전 충돌이 나면 BOM을 기준으로 맞추고, 개별 compose artifact 버전은 직접 박지 않는 편이 낫다.

1단계: 가장 기본 형태(재호출 로그 확인)

버튼을 누를 때마다 어떤 함수가 몇 번 호출되는지부터 확인한다. 화면에는 count 숫자와 버튼 하나만 보인다. 버튼을 연속으로 누르면 숫자는 증가하고, Logcat에는 같은 태그의 로그가 반복된다. 이 반복이 ‘재구성’이고, 실제 렌더링은 그 이후 단계에서 필요할 때만 일어난다.

MainActivity.kt
1package com.example.composebasics
2
3import android.os.Bundle
4import androidx.activity.ComponentActivity
5import androidx.activity.compose.setContent
6import androidx.compose.material3.MaterialTheme
7import androidx.compose.material3.Surface
8import androidx.compose.runtime.Composable
9import androidx.compose.ui.tooling.preview.Preview
10
11class MainActivity : ComponentActivity() {
12    override fun onCreate(savedInstanceState: Bundle?) {
13        super.onCreate(savedInstanceState)
14        setContent {
15            MaterialTheme {
16                Surface {
17                    CounterWithLogs()
18                }
19            }
20        }
21    }
22}
23
24@Preview(showBackground = true)
25@Composable
26private fun PreviewCounter() {
27    MaterialTheme { Surface { CounterWithLogs() } }
28}

실행하면 화면 중앙 근처에 count=0과 +1 버튼이 보인다. +1을 누를 때마다 텍스트가 바뀌고, Logcat의 CounterWithLogs() called가 반복된다. 여기서 “왜 전체 함수가 다시 호출되나”가 핵심 질문인데, 답은 Slot Table 갱신 때문이다. Compose는 변경된 값만 부분적으로 패치하지 않고, 해당 그룹을 다시 호출해 새 결과를 얻는다.

2단계: remember가 없을 때 무엇이 깨지는지 재현

remember를 빼면 count가 버튼 클릭 후에도 유지되지 않는다. 더 정확히는, 클릭 순간엔 람다가 count를 증가시키지만 다음 재호출에서 count 변수가 다시 0으로 초기화된다. 화면에는 항상 0 또는 1 근처에서 튀는 현상이 보인다. 이 현상은 “Composable은 재호출될 수 있다”라는 전제가 코드에 반영되지 않았을 때 발생한다.

CounterWithoutRemember.kt
1package com.example.composebasics
2
3import androidx.compose.foundation.layout.Column
4import androidx.compose.material3.Button
5import androidx.compose.material3.Text
6import androidx.compose.runtime.Composable
7
8@Composable
9fun CounterWithoutRemember() {
10    // 의도적으로 remember를 제거한 예시
11    var count = 0
12
13    Column {
14        Text("count=$count")
15        Button(onClick = { count++ }) {
16            Text("+1")
17        }
18    }
19}

이 코드를 MainActivity에서 CounterWithLogs 대신 호출하면, 버튼을 눌러도 숫자가 거의 변하지 않는다. 어떤 기기에서는 클릭 직후 잠깐 1로 보였다가 바로 0으로 돌아오기도 한다. 이유는 단순하다. count는 Composition 단계에서 매번 새로 만들어지는 로컬 변수이고, Recomposition은 그 로컬 변수를 유지해주지 않는다. remember가 Slot Table에 값을 저장해야만 프레임 사이에 값이 이어진다.

3단계: 커스터마이징과 재구성 범위 분리

UI를 조금만 복잡하게 만들면, ‘어디가 다시 호출되는지’가 더 중요해진다. 버튼 외형(색/패딩)은 자주 바뀌지 않는데, 텍스트만 자주 바뀌는 경우가 많다. content slot을 분리해두면 버튼 외형은 스킵되고 텍스트만 다시 호출되는 구조를 만들 수 있다.

StyledCounter.kt
1package com.example.composebasics
2
3import android.util.Log
4import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.padding
6import androidx.compose.material3.ButtonDefaults
7import androidx.compose.material3.MaterialTheme
8import androidx.compose.material3.Text
9import androidx.compose.material3.Button
10import androidx.compose.runtime.Composable
11import androidx.compose.runtime.getValue
12import androidx.compose.runtime.mutableIntStateOf
13import androidx.compose.runtime.remember
14import androidx.compose.runtime.setValue
15import androidx.compose.ui.Modifier
16import androidx.compose.ui.unit.dp
17
18@Composable
19fun StyledCounter() {
20    var count by remember { mutableIntStateOf(0) }
21
22    Column(modifier = Modifier.padding(16.dp)) {
23        Log.d("Compose", "StyledCounter Column")
24
25        Button(
26            onClick = { count++ },
27            colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary),
28            modifier = Modifier.padding(top = 12.dp)
29        ) {
30            Log.d("Compose", "Button content recomposed")
31            Text("count=$count")
32        }
33    }
34}

화면에는 파란색 계열 버튼이 보이고, 버튼 안 텍스트가 count=0에서 증가한다. Logcat에서 Column 로그와 Button content 로그가 어떻게 찍히는지 관찰한다. Material3 Button 내부는 더 세분화되어 있어, colors나 enabled가 바뀌지 않으면 외형 계층은 스킵되고 content만 다시 호출되는 경우가 생긴다. 이런 관찰이 ‘재구성은 전체 리렌더가 아니다’라는 감각을 만든다.

심화: Advanced 버전 만들기

실무 버튼은 클릭 한 번으로 끝나지 않는다. 네트워크 요청 중 로딩 표시가 필요하고, 연타 방지(debounce)가 필요하고, 아이콘+텍스트 조합이 흔하고, 롱프레스(보조 동작)도 들어간다. 접근성 라벨은 텍스트가 아이콘으로 대체될 때 특히 중요하다. 이 요구사항을 한 컴포넌트에 넣으면 재구성 범위와 상태 보관 위치를 잘못 잡기 쉽다.

사례 1: loading + debounce + semantics를 한 컴포넌트로 묶기

loading은 UI 상태이지만, debounce는 이벤트 정책이다. 둘을 섞으면 “로딩 중에는 클릭 무시”와 “연타는 무시”가 충돌한다. 해결은 정책을 명시적으로 분리하는 것이다. loading=true면 enabled=false로 내려서 semantics까지 함께 꺼지게 하고, debounce는 onClick 람다 내부에서 시간 기준으로 필터링한다. 시간 값은 remember로 유지되어야 한다.

AdvancedButton.kt
1package com.example.composebasics
2
3import android.os.SystemClock
4import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.Spacer
6import androidx.compose.foundation.layout.width
7import androidx.compose.material3.CircularProgressIndicator
8import androidx.compose.material3.Icon
9import androidx.compose.material3.MaterialTheme
10import androidx.compose.material3.Text
11import androidx.compose.material3.Button
12import androidx.compose.material3.ButtonDefaults
13import androidx.compose.runtime.Composable
14import androidx.compose.runtime.getValue
15import androidx.compose.runtime.mutableLongStateOf
16import androidx.compose.runtime.remember
17import androidx.compose.runtime.setValue
18import androidx.compose.ui.Modifier
19import androidx.compose.ui.graphics.vector.ImageVector
20import androidx.compose.ui.semantics.contentDescription
21import androidx.compose.ui.semantics.semantics
22import androidx.compose.ui.unit.dp
23
24@Composable
25fun AdvancedButton(
26    label: String,
27    icon: ImageVector? = null,
28    loading: Boolean,
29    debounceMs: Long = 600L,
30    a11yLabel: String = label,
31    onClick: () -> Unit,
32) {
33    var lastClickAt by remember { mutableLongStateOf(0L) }
34
35    val enabled = !loading
36
37    Button(
38        onClick = {
39            val now = SystemClock.elapsedRealtime()
40            if (now - lastClickAt < debounceMs) return@Button
41            lastClickAt = now
42            onClick()
43        },
44        enabled = enabled,
45        colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary),
46        modifier = Modifier.semantics { contentDescription = a11yLabel }
47    ) {
48        Row {
49            if (loading) {
50                CircularProgressIndicator(strokeWidth = 2.dp)
51                Spacer(Modifier.width(8.dp))
52            } else if (icon != null) {
53                Icon(imageVector = icon, contentDescription = null)
54                Spacer(Modifier.width(8.dp))
55            }
56            Text(label)
57        }
58    }
59}

이 코드를 실행하면 loading=true일 때 버튼이 비활성화되고(색이 바뀌며 클릭이 안 됨), 로딩 인디케이터가 텍스트 왼쪽에 나타난다. 연타하면 debounceMs 동안 onClick이 다시 호출되지 않는다. 여기서 중요한 관찰 포인트는 lastClickAt이 remember로 유지된다는 점이다. remember가 없으면 재호출마다 lastClickAt이 0으로 초기화되어 debounce가 무력화된다. 또한 enabled를 false로 내리면 semantics도 함께 조정되어 접근성 트리에서 ‘비활성 버튼’으로 읽힌다.

사례 2: long press 보조 동작과 상태 읽기 범위 줄이기

롱프레스는 pointerInput/combinedClickable로 처리하는 경우가 많다. 문제는 이 Modifier가 붙은 레벨이 너무 상위면, pressed 상태나 제스처 상태 변화가 불필요하게 큰 범위를 흔든다. 해결은 상호작용이 필요한 최소 노드에만 제스처 Modifier를 붙이는 것이다. 또한 label 계산이 비싸면 derivedStateOf로 캐시해 재구성 때 불필요한 계산을 줄인다.

LongPressCounter.kt
1package com.example.composebasics
2
3import androidx.compose.foundation.combinedClickable
4import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.padding
6import androidx.compose.material3.MaterialTheme
7import androidx.compose.material3.Text
8import androidx.compose.runtime.Composable
9import androidx.compose.runtime.derivedStateOf
10import androidx.compose.runtime.getValue
11import androidx.compose.runtime.mutableIntStateOf
12import androidx.compose.runtime.remember
13import androidx.compose.runtime.setValue
14import androidx.compose.ui.Modifier
15import androidx.compose.ui.unit.dp
16
17@Composable
18fun LongPressCounter(
19    onLongPress: () -> Unit
20) {
21    var count by remember { mutableIntStateOf(0) }
22
23    val label by remember(count) {
24        derivedStateOf {
25            // 비용이 큰 포맷팅/로컬라이징이 있다고 가정
26            "count=$count"
27        }
28    }
29
30    Box(
31        modifier = Modifier
32            .padding(24.dp)
33            .combinedClickable(
34                onClick = { count++ },
35                onLongClick = onLongPress
36            )
37    ) {
38        Text(label, color = MaterialTheme.colorScheme.onBackground)
39    }
40}

화면에는 텍스트 하나가 보이고, 탭하면 숫자가 증가한다. 길게 누르면 onLongPress가 호출된다. derivedStateOf는 count가 바뀔 때만 label 계산을 다시 한다. 여기서는 단순 문자열이지만, 실제로는 DecimalFormat/리소스 로딩/리스트 join 같은 비용이 들어가면 프레임 드랍 원인이 된다. 중요한 점은 derivedStateOf도 remember 슬롯에 저장되는 상태라는 점이고, 이 또한 Slot Table의 특정 위치에 고정된다.

내 흑역사 하나가 debounce 구현이었다. 예전에 lastClickAt을 remember 대신 그냥 var로 두고, “왜 연타 방지가 가끔만 되지?”라는 현상을 겪었다. 로그를 찍어보니 클릭 직후 재구성이 일어나면서 lastClickAt이 0으로 돌아가 있었다. 더 웃긴 건, 디버그에서만 덜 재현되고 릴리즈에서 더 잘 재현됐다. 릴리즈에서 스킵/인라이닝이 달라져 타이밍이 바뀌면서 증상이 더 자주 튀었다.

또 한 번은 접근성 라벨을 Text에만 의존했다가 아이콘-only 버튼에서 TalkBack이 ‘버튼’만 읽고 의미를 말하지 못했다. QA에서 “이 버튼이 뭔지 모르겠다”가 올라왔고, semantics contentDescription을 버튼 레벨에 붙여 해결했다. 이때도 Modifier 순서가 문제였다. semantics를 clickable 뒤에 붙이면 테스트에서 노드 탐색이 달라져 매칭이 실패하는 케이스가 나왔다. semantics는 보통 clickable보다 앞에서 의도를 고정하는 편이 디버깅이 쉬웠다.

자주 하는 실수

remember 없이 로컬 변수에 상태를 저장함

증상: 버튼을 눌러도 값이 유지되지 않거나, 잠깐 바뀌었다가 원복되는 것처럼 보인다. 로그를 찍으면 Composable이 재호출될 때마다 값이 초기화된다.

원인: Composable은 재구성 시 다시 호출되는 함수이고, 로컬 변수는 호출마다 새로 생성된다. 상태를 유지하려면 Slot Table에 저장되어야 한다.

해결: mutableStateOf를 remember로 감싼다. 화면 회전까지 유지하려면 rememberSaveable을 사용한다. debounce/interaction 같은 객체도 remember로 고정해 객체 재생성을 막는다.

조건문으로 Composable 호출 순서를 바꾸고 remember가 뒤섞임

증상: 특정 토글을 켠 뒤부터 텍스트가 엉뚱한 값을 보여주거나, 입력 필드 포커스가 다른 곳으로 튄다. 간헐적으로만 발생해 재현이 어렵다.

원인: remember는 호출 ‘위치’에 바인딩된다. if/for로 호출 순서가 바뀌면 Slot Table의 슬롯 인덱스가 달라져 다른 remember 값을 읽는다.

해결: 조건부로 바뀌는 구간은 key(...)로 그룹을 고정하거나, 상태를 상위로 끌어올려 안정적인 위치에 둔다. 리스트는 stable key를 가진 LazyColumn을 사용한다.

Modifier 순서를 무시하고 클릭 영역/레이아웃이 어긋남

증상: 화면상으로는 여백이 있는데 눌리지 않거나, 리플이 예상과 다른 위치에서 퍼진다. TalkBack 포커스 경계가 시각적 경계와 다르다.

원인: Modifier는 선언 순서대로 노드 체인이 만들어진다. padding, clip, clickable, semantics의 순서가 hit test와 접근성 트리에 직접 영향을 준다.

해결: ‘터치 영역을 키운 뒤 클릭 가능’이 목적이면 padding을 clickable 앞에 둔다. clip을 먼저 하면 리플이 잘릴 수 있다. semantics는 테스트/접근성 기준으로 의도를 고정할 위치에 둔다.

매 재구성마다 새 객체를 만들어 파라미터 비교를 깨뜨림

증상: 화면은 큰 변화가 없는데도 특정 Composable이 계속 재호출된다. 프로파일링하면 할당(alloc)이 꾸준히 발생한다.

원인: 파라미터로 List/람다/색상 객체 등을 매번 새로 만들면 참조 동일성 비교가 실패한다. 안정성이 낮은 타입이면 Runtime이 보수적으로 재호출한다.

해결: remember로 객체를 캐시하거나, 불변 컬렉션/데이터 클래스로 모델을 설계한다. 람다는 rememberUpdatedState로 최신 참조만 갱신하고, 전달되는 함수 객체는 고정한다.

State를 읽는 위치가 너무 상위라 재구성 범위가 커짐

증상: 리스트 아이템 하나 바뀌었는데 상단 앱바까지 로그가 찍히며 재호출된다. 스크롤 중 잔떨림이 생긴다.

원인: State를 상위 Column/Scaffold에서 읽으면 그 그룹이 invalid가 되어 하위 전체가 재호출 후보가 된다. 스킵이 되더라도 호출 경계가 커지면 비용이 늘 수 있다.

해결: State 읽기를 필요한 가장 하위로 내린다. 파생 값은 derivedStateOf로 계산 범위를 제한한다. 리스트는 item 단위로 state를 분리하고 key를 부여한다.

RememberOrderFixDemo.kt
1package com.example.composebasics
2
3import androidx.compose.runtime.Composable
4import androidx.compose.runtime.key
5import androidx.compose.runtime.mutableIntStateOf
6import androidx.compose.runtime.remember
7import androidx.compose.runtime.getValue
8import androidx.compose.runtime.setValue
9
10@Composable
11fun RememberOrderFixDemo(showExtra: Boolean) {
12    // 잘못된 패턴: showExtra에 따라 remember 슬롯 순서가 바뀔 수 있다.
13    // 해결 스케치: key로 그룹을 고정하거나 상태를 상위로 올린다.
14
15    key("main") {
16        var a by remember { mutableIntStateOf(0) }
17        if (showExtra) {
18            key("extra") {
19                var b by remember { mutableIntStateOf(100) }
20                b += 0
21            }
22        }
23        a += 0
24    }
25}

성능 최적화 체크리스트

  • State를 읽는 위치가 실제로 그 값이 필요한 최소 Composable인지 점검한다(상위 Scaffold/Column에서 읽지 않기).
  • remember가 필요한 값(InteractionSource, debounce 타임스탬프, formatter, coroutine scope)을 로컬 var로 두지 않았는지 확인한다.
  • 조건문/반복문으로 Composable 호출 순서가 바뀌는 구간에 key를 부여했는지 확인한다(특히 remember가 여러 개일 때).
  • 람다 파라미터를 매 재구성마다 새로 생성해 전달하지 않는지 확인한다(필요 시 rememberUpdatedState 사용).
  • List/Map을 매번 새 인스턴스로 만들어 전달하지 않는지 확인한다(불변 모델, remember 캐시, stable collection).
  • @Stable/@Immutable을 남용하지 않았는지 확인한다(내부 가변 필드가 있으면 오히려 버그 유발).
  • Modifier 순서가 터치 영역과 semantics에 맞는지 확인한다(padding/clip/clickable/semantics).
  • derivedStateOf로 파생 계산을 캐시할 지점이 있는지 확인한다(포맷팅/필터링/정렬).
  • 불필요한 SideEffect/LaunchedEffect 키 변경으로 이펙트가 반복 실행되지 않는지 확인한다.
  • Layout Inspector에서 ‘리컴포지션 카운트’만 믿지 말고, Logcat/trace로 실제 invalidation 범위를 확인한다.
  • androidx.tracing을 켜고 recomposition 구간이 프레임(16ms) 안에 들어오는지 확인한다(느린 기기 기준).
  • Preview에서만 보이는 동작과 실제 런타임 동작을 분리해 검증한다(Preview는 재호출 타이밍이 다를 수 있음).

자주 묻는 질문

Composable 함수가 다시 호출되면 화면을 ‘다시 그리는’ 건가?

Composable 재호출은 ‘화면을 다시 칠한다’와 동일하지 않다. 재호출은 Composition 단계에서 Slot Table의 특정 구간을 새 입력으로 다시 계산하는 작업이다. 그 결과가 이전과 동일하면 Runtime은 스킵을 통해 하위 호출을 생략할 수 있고, 노드가 바뀌지 않으면 Layout/Draw 단계도 최소화된다. 확인 방법은 Logcat에 함수 호출 로그를 찍고, Layout Inspector에서 Recomposition Count와 함께 실제 레이아웃 패스(Measure/Layout)가 다시 도는지 관찰하는 것이다. 학습 키워드는 Slot Table, group, skipping, changed 플래그다.

Slot Table에는 정확히 무엇이 저장되나? View 트리 같은 건가?

Slot Table은 View 트리처럼 ‘위젯 객체’를 저장하는 구조가 아니라, Composable 호출의 순서와 그룹 경계, remember 값, CompositionLocal 값, 노드 생성에 필요한 메타데이터를 저장하는 테이블이다. UI 노드 자체는 Compose UI 쪽의 노드 트리로 존재하지만, “이 노드를 어떤 호출이 만들었고 다음 재구성 때 어디를 다시 호출해야 하는가”를 찾는 기준점이 Slot Table이다. 그래서 호출 순서가 바뀌면 remember가 뒤섞이는 문제가 생긴다. 학습 키워드는 Composer, slot, group start/end, key, remember 위치 바인딩이다.

왜 remember가 없으면 값이 유지되지 않나? Kotlin 변수는 살아 있지 않나?

Composable은 일반 함수 호출과 동일하게 스택 프레임에서 로컬 변수를 만든다. 재구성이 일어나면 같은 Composable이 다시 호출되고, 이전 호출의 스택 프레임은 이미 종료되어 로컬 변수는 사라진다. remember는 이 값을 스택이 아니라 Slot Table에 저장한다. Slot Table의 슬롯은 “이 호출 위치”에 매핑되므로, 같은 위치에서 재호출되면 값을 다시 꺼내 쓴다. 화면 회전/프로세스 재시작까지 유지하려면 rememberSaveable을 쓰고, 저장 가능한 타입(primitive/Bundle 가능 타입)인지도 점검해야 한다. 학습 키워드는 remember, rememberSaveable, slot index, key다.

Recomposition은 어떤 기준으로 ‘범위’를 정하나? 왜 어떤 때는 부모까지 다시 호출되나?

기준은 State 읽기(read)다. Composition 중에 mutableStateOf 값을 읽은 그룹이 그 State에 의존하게 되고, 값이 바뀌면 그 그룹이 invalid가 된다. 다만 그룹 경계는 컴파일러가 생성한 코드 구조(조건문, 람다 경계, inline 여부)에 영향을 받는다. 그래서 같은 코드라도 약간의 구조 차이로 부모 로그가 함께 찍히는 경우가 생긴다. 해결 방향은 State 읽기를 필요한 하위로 내리고, 파생 값은 derivedStateOf로 고립시키며, 조건부 구간은 key로 그룹을 안정화하는 것이다. 학습 키워드는 snapshot read tracking, invalidation, recomposition scope, derivedStateOf다.

@Stable, @Immutable은 왜 존재하나? 안 붙이면 느린가?

안정성 애노테이션은 ‘비교 전략’을 돕기 위한 힌트다. Compose는 파라미터가 바뀌었는지 판단해 스킵 여부를 결정한다. 불변(Immutable) 타입이면 참조가 같을 때 내용도 같다고 가정할 수 있고, Stable 타입이면 내부 변경이 State 등을 통해 관찰 가능하게 전달된다고 가정한다. 이 힌트가 없으면 Runtime은 더 보수적으로 재호출할 수 있다. 다만 무턱대고 붙이면 위험하다. 내부에 var가 있고 그 변경이 State로 노출되지 않으면, UI가 업데이트되지 않는 ‘조용한 버그’가 생긴다. 학습 키워드는 stability inference, referential equality, snapshot state, immutable data model이다.

Modifier는 왜 체이닝이고, 왜 순서가 그렇게 중요하나?

Modifier는 단순 옵션 묶음이 아니라, 레이아웃/드로잉/입력/semantics 노드들의 연결 체인이다. 체이닝 순서가 곧 노드가 적용되는 순서라서 padding을 어디에 두느냐에 따라 측정 크기, 클릭 hit test 영역, 리플 클립, 접근성 경계가 달라진다. 예를 들어 clickable 뒤에 padding을 두면 시각적 영역은 커져도 클릭 가능한 영역은 커지지 않을 수 있다. 디버깅은 Layout Inspector의 bounds와, 필요하면 pointerInput 로그로 hit test 경계를 확인하는 방식이 효과적이다. 학습 키워드는 modifier node, hit test, semantics tree, layout/draw phases다.

Layout Inspector의 Recomposition Count가 높으면 무조건 최적화 대상인가?

Recomposition Count는 ‘재호출 시도’의 흔적이지, 프레임 비용을 직접 의미하지 않는다. 재호출이 있어도 대부분 스킵되고, 노드 변경이 없으면 measure/layout/draw가 거의 돌지 않을 수 있다. 반대로 Recomposition Count가 낮아도, 한 번의 재호출에서 비싼 계산(정렬/포맷팅/이미지 디코딩)을 하면 프레임이 깨진다. 판단은 trace와 프로파일링이 필요하다. androidx.tracing으로 구간을 표시하고, Android Studio CPU/Memory 프로파일러에서 할당과 실행 시간을 같이 본다. 학습 키워드는 skipping, recomposition vs relayout, tracing, allocation profiling이다.

불필요한 Recomposition을 줄이려면 무엇부터 고치나?

우선 ‘불필요한 State 읽기’를 줄이는 쪽이 가장 효과가 크다. 상위에서 State를 읽고 하위로 내려보내면 상위 그룹이 invalid가 되어 호출 경계가 커진다. 다음은 ‘불안정한 파라미터’를 줄이는 것이다. 매번 새 List/람다/객체를 만들어 전달하면 changed 비교가 실패한다. 그 다음이 파생 계산 최적화다. derivedStateOf로 비싼 계산을 캐시하고, remember로 formatter 같은 객체를 재사용한다. 마지막으로 안정성 애노테이션은 모델 설계가 갖춰졌을 때만 적용한다. 학습 키워드는 state hoisting, stable parameters, derivedStateOf, rememberUpdatedState다.

관련 글

20. Compose 기초: Composable 함수와 재구성(Recomposition)이 일어나는 이유
Compose 기본2026.03.04

20. Compose 기초: Composable 함수와 재구성(Recomposition)이 일어나는 이유

Composable 함수가 왜 순수 함수처럼 보이지만 상태를 가진 UI로 동작하는지, Slot Table과 Recomposition 비교 로직까지 내부 관점으로 설명한다. remember와 Stable 설계 이유 포함. (154자)​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​

11. Compose @Composable 함수: 선언형 UI가 동작하는 진짜 이유
Compose 기본2026.03.01

11. Compose @Composable 함수: 선언형 UI가 동작하는 진짜 이유

Compose @Composable의 선언형 UI가 왜 가능한지, 컴파일러가 생성하는 코드와 Runtime의 Slot Table·Recomposition 비교까지 내부 동작으로 설명한다. 초보도 원리를 잡는다. ㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇ

28. Compose Button 클릭 이벤트와 상태 업데이트가 동작하는 이유
Compose 기본2026.03.06

28. Compose Button 클릭 이벤트와 상태 업데이트가 동작하는 이유

Jetpack Compose Button 클릭 처리와 상태 업데이트가 왜 이렇게 설계됐는지, remember/State/Slot Table/Recomposition 관점에서 내부 동작까지 연결해 설명한다.','primaryKeywords':['Jetpack Compose Button','Compose 상태 관리','Rek