Compose 기본2026년 02월 17일· 11 min read

8. Jetpack Compose Row 기본 구조와 파라미터가 그렇게 생긴 이유

Compose Row의 핵심 파라미터(Modifier, horizontalArrangement, verticalAlignment, weight)가 왜 존재하는지와 Slot Table·Recomposition 관점의 내부 동작을 설명한다. 초보도 원리부터 잡는다.

8. Jetpack Compose Row 기본 구조와 파라미터가 그렇게 생긴 이유

Jetpack Compose Row 기본 구조와 파라미터가 그렇게 생긴 이유

Compose를 처음 쓰면 Row를 만들고도 결과가 예상과 다르게 나온다. 텍스트 두 개를 넣었는데 간격이 안 벌어지거나, weight를 줬는데 한쪽이 눌리고, 클릭 한 번에 화면이 통째로 다시 그려지는 것처럼 보이기도 한다. 원인은 대부분 Row가 “그리는 위젯”이 아니라 “측정과 배치 규칙”이라는 점을 놓치기 때문이다. Row의 파라미터는 취향이 아니라 Compose Runtime과 Layout 시스템이 요구하는 정보에 맞춰 설계되어 있다. 나도 처음 Compose로 전환할 때 Row의 weight가 왜 Modifier에 붙는지 이해가 안 됐다. 3시간 삽질 끝에 Layout Inspector에서 측정 제약(constraints)과 자식의 measuredWidth가 어떻게 바뀌는지 찍어보고 나서야 납득

핵심 개념

Row는 “가로로 나열한다”보다 더 큰 역할을 한다. Compose에서 레이아웃은 1) Composition(무엇을 만들지 결정) 2) Layout(얼마나 크게 만들지 결정) 3) Drawing(어떻게 그릴지 결정)으로 분리된다. Row는 이 중 Layout 단계에서 자식들을 측정(measure)하고 배치(place)하는 규칙을 제공한다. 그래서 Row의 파라미터는 ‘배치 규칙에 필요한 입력’으로 구성된다.

View 시스템의 LinearLayout과 비교하면 차이가 선명하다. LinearLayout은 View 객체가 상태를 들고 있고, measure/layout/draw가 객체 메서드로 호출된다. Compose Row는 객체가 아니라 함수 호출이며, 측정/배치 로직은 MeasurePolicy로 캡처된다. 이 설계 덕분에 UI 트리가 ‘데이터로부터 재생성’되어도 레이아웃 규칙을 안정적으로 재사용할 수 있다.

핵심 용어를 Row 맥락에서 정의한다. - Composition: Row 호출이 Slot Table에 “Row 그룹”으로 기록되는 단계다. 자식들도 순서대로 슬롯에 들어간다. - Slot Table: 이전 프레임의 호출 구조와 remember 값, 스키핑 가능 여부를 저장하는 테이블이다. Row 자체가 뭔가를 저장한다기보다, Row 호출 위치가 키가 된다. - Recomposition: 상태가 바뀌면 해당 상태를 읽은 그룹만 다시 호출된다. Row 안에서 상태를 읽으면 Row 그룹이 다시 실행될 수 있다. - Measure/Place: Layout 단계에서 Row가 자식들을 constraints로 측정하고 x/y 좌표에 배치한다. - Modifier: Row의 외부(부모와의 관계)와 내부(자식에게 전달되는 ParentData)를 동시에 다루는 체인이다. weight가 Modifier에 붙는 이유가 여기서 나온다.

Row가 필요한 이유는 ‘가로 배치’보다 ‘측정 전략의 캡슐화’에 있다. 예를 들어 weight는 남은 공간을 계산해야 하고, horizontalArrangement는 남은 공간을 분배해야 한다. 이런 계산은 자식 개수, 각 자식의 measuredWidth, 부모 constraints를 알아야 가능하다. Row는 이 값을 Layout 단계에서만 알 수 있으니, API도 그 단계에 맞춘 입력만 받는다.

RowBasics.kt
1import androidx.compose.foundation.background
2import androidx.compose.foundation.layout.Arrangement
3import androidx.compose.foundation.layout.Row
4import androidx.compose.foundation.layout.fillMaxWidth
5import androidx.compose.foundation.layout.padding
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.Color
11import androidx.compose.ui.tooling.preview.Preview
12import androidx.compose.ui.unit.dp
13
14@Composable
15fun RowBasics() {
16    Row(
17        modifier = Modifier
18            .fillMaxWidth()
19            .background(Color(0xFFEDE7F6))
20            .padding(12.dp),
21        horizontalArrangement = Arrangement.SpaceBetween
22    ) {
23        Text("Left", color = MaterialTheme.colorScheme.onBackground)
24        Text("Right", color = MaterialTheme.colorScheme.onBackground)
25    }
26}
27
28@Preview(showBackground = true, widthDp = 360)
29@Composable
30private fun RowBasicsPreview() {
31    RowBasics()
32}

이 코드를 실행하면 두 텍스트가 양 끝으로 벌어진다. 중요한 관찰 포인트는 ‘Text가 벌어지는 게 아니라 Row가 남는 폭을 계산해서 배치 좌표를 바꾼다’는 점이다. Text의 measuredWidth는 크게 변하지 않고, place되는 x 좌표가 달라진다. Layout Inspector에서 Row의 width가 화면 폭으로 잡히는지(fillMaxWidth), 그리고 자식의 positionX가 얼마나 벌어지는지 확인하면 감이 잡힌다.

컴포넌트 해부

Row의 시그니처는 단순하지만 파라미터가 ‘측정과 배치’에 딱 맞춰져 있다. 자주 쓰는 파라미터를 목록으로 풀면 설계 의도가 드러난다.

  • modifier: 부모-자식 관계(크기, 패딩, 클릭, 배경, semantics)를 체인으로 누적한다
  • horizontalArrangement: 남는 가로 공간을 자식들 사이/주변에 어떻게 분배할지 결정한다
  • verticalAlignment: 각 자식을 Row의 높이 안에서 위/중앙/아래/기준선으로 맞춘다
  • content: Row의 슬롯. Composition 단계에서 자식 호출 순서가 Slot Table 키가 된다
  • RowScope: content 람다 리시버. weight/align 같은 ParentData를 자식에 부착한다
  • Modifier.weight: 남은 폭을 비율로 배분하기 위한 ParentData. 측정 순서(비가중→가중)를 바꾼다
  • Modifier.align: 특정 자식만 verticalAlignment를 덮어쓴다(ParentData로 전달)
  • Arrangement.spacedBy: 간격을 ‘고정 dp’로 지정한다. 남는 폭을 분배하는 SpaceBetween과 성격이 다르다
  • Arrangement.SpaceBetween/SpaceAround/SpaceEvenly: 남는 폭을 분배하는 규칙. 자식 폭이 바뀌면 간격도 바뀐다
  • Alignment.Top/CenterVertically/Bottom: 세로 정렬 규칙. Row 높이가 커질 때 차이가 보인다
  • Alignment.CenterVertically vs alignByBaseline: 텍스트 기준선을 맞추는 요구가 있는 UI에서 핵심이다
  • clip/background/border: Row 자체가 그려지는 계층을 만든다. Row는 원래 그리는 요소가 아니라 Layout이므로 Modifier가 사실상 ‘그림’을 담당한다

Surface 계층과 Content 계층을 분리해서 보면 Row가 더 명확해진다. Surface는 배경/클립/테두리/클릭/리플/semantics처럼 ‘Row 자체를 하나의 박스처럼 다루는 요구’를 처리한다. Content는 자식 배치 규칙을 처리한다. Compose에서는 이 둘이 Row 본체에 섞이지 않고 Modifier 체인과 Layout 노드로 분리된다.

Surface 계층에서 중요한 사실 하나가 있다. Row에 background를 붙이면 Row가 배경을 그리는 게 아니라, Modifier.background가 DrawModifierNode를 추가해서 그린다. 그래서 background가 붙어도 Row의 측정 로직은 거의 변하지 않는다. 반대로 padding은 LayoutModifierNode로 측정 제약을 바꾼다. 같은 Modifier라도 어떤 노드를 추가하느냐에 따라 Layout 단계에 영향을 주기도 하고, Drawing 단계에만 영향을 주기도 한다.

Content 계층은 Row의 MeasurePolicy가 담당한다. horizontalArrangement는 place 단계에서 x 좌표 계산에만 관여하는 것처럼 보이지만, weight가 섞이면 측정 단계까지 영향이 번진다. 가중치 자식은 ‘남은 폭’을 알아야 측정할 수 있으니, Row는 비가중 자식을 먼저 측정하고 남은 폭을 계산한 뒤 가중 자식을 측정하는 2패스 전략을 쓴다. 이 전략 때문에 weight는 단순한 정렬 옵션이 아니라 측정 순서를 바꾸는 입력이다.

SketchRow.kt
1import androidx.compose.runtime.Composable
2import androidx.compose.ui.Modifier
3
4// 교육용 스케치: 실제 Row 구현을 복사한 코드가 아니라 구조를 설명하기 위한 재구성이다.
5@Composable
6private fun SketchRow(
7    modifier: Modifier = Modifier,
8    horizontalArrangement: String = "Start",
9    verticalAlignment: String = "Top",
10    content: @Composable () -> Unit
11) {
12    // 1) Composition: 이 함수 호출 자체가 Slot Table에 그룹으로 기록된다.
13    // 2) Layout: modifier가 레이아웃 노드를 추가하고, 내부 MeasurePolicy가 측정/배치를 수행한다.
14    // 3) Drawing: background/border 같은 modifier가 draw 노드를 추가한다.
15
16    // 실제로는 Layout(...) + MeasurePolicy가 들어간다.
17    content()
18
19    // horizontalArrangement/verticalAlignment는 배치 좌표 계산에 사용된다.
20    // weight 같은 ParentData는 자식별 측정 파라미터로 흘러간다.
21}

이 스케치에서 핵심은 content가 단순히 호출된다는 점이다. Compose는 ‘자식 목록’을 미리 만들지 않는다. 호출 순서가 곧 트리 구조이며, 그 순서가 Slot Table의 인덱스가 된다. 그래서 Row의 content 람다 안에서 if로 자식을 조건부로 추가하면, 그 조건이 바뀌는 순간 슬롯 위치가 밀려 remember가 엉뚱한 컴포저블에 매칭될 수 있다. Row 자체가 위험한 게 아니라, “호출 순서가 키”라는 규칙이 위험을 만든다.

RowWithSurfaceAndContent.kt
1import androidx.compose.foundation.background
2import androidx.compose.foundation.layout.Arrangement
3import androidx.compose.foundation.layout.Row
4import androidx.compose.foundation.layout.fillMaxWidth
5import androidx.compose.foundation.layout.padding
6import androidx.compose.foundation.layout.width
7import androidx.compose.material3.Text
8import androidx.compose.runtime.Composable
9import androidx.compose.ui.Modifier
10import androidx.compose.ui.graphics.Color
11import androidx.compose.ui.tooling.preview.Preview
12import androidx.compose.ui.unit.dp
13
14@Composable
15fun RowWithSurfaceAndContent() {
16    Row(
17        modifier = Modifier
18            .fillMaxWidth()
19            .background(Color(0xFF0F172A))
20            .padding(horizontal = 12.dp, vertical = 10.dp),
21        horizontalArrangement = Arrangement.spacedBy(8.dp)
22    ) {
23        Text("A", modifier = Modifier.width(40.dp), color = Color.White)
24        Text("B", modifier = Modifier.width(80.dp), color = Color.White)
25        Text("C", modifier = Modifier.width(60.dp), color = Color.White)
26    }
27}
28
29@Preview(showBackground = true, widthDp = 360)
30@Composable
31private fun RowWithSurfaceAndContentPreview() {
32    RowWithSurfaceAndContent()
33}

실행하면 짙은 배경 위에 A/B/C가 고정 간격 8dp로 배치된다. 여기서 확인할 건 두 가지다. 첫째, background는 Row의 크기(측정)보다 ‘그려지는 영역’에만 관여한다. 둘째, spacedBy는 남는 폭을 분배하지 않고 고정 간격을 삽입한다. 화면을 늘려도 간격이 그대로라면 spacedBy가 place 단계에서 단순 더하기를 한다는 감각이 생긴다.

내부 동작 원리

Compose Runtime이 Row를 만났을 때의 흐름은 대략 이렇다. 컴파일러가 Row 호출을 ‘그룹 시작/종료’와 파라미터 변경 체크로 변환한다. 런타임은 Slot Table에서 이전 프레임의 그룹을 찾아 파라미터가 동일하면 스킵을 시도한다. 스킵이 가능하면 Row의 content 람다 호출 자체가 생략되고, 이전에 만들어진 노드 트리(레이아웃 노드/모디파이어 노드)가 재사용된다.

Row가 레이아웃 노드로 내려가면 Layout 단계에서 constraints를 받는다. constraints는 min/max width/height 범위다. Row는 자식들을 측정할 때, 각 자식에게 전달할 constraints를 조절한다. padding이 있으면 내부 constraints가 줄어든다. weight가 있으면 남은 폭을 계산한 뒤 가중치 비율로 각 자식에게 “이 정도 폭을 목표로 측정하라”는 constraints를 만든다.

Drawing 단계는 Row 본체가 아니라 Modifier가 주도한다. background/border/clip는 draw 노드를 추가하고, 이 노드는 draw() 호출에서 캔버스에 직접 그린다. 그래서 Row는 ‘그림’에 관여하지 않는 편이 자연스럽다. Row가 Layout에 집중하고, 그림은 Modifier로 분리한 이유가 여기 있다.

Recomposition 트리거는 상태 읽기(read)다. Row 자체는 상태를 읽지 않으면 스킵될 수 있다. 하지만 Row의 content 안에서 상태를 읽으면 그 읽기 위치가 그룹에 기록되고, 상태가 바뀌면 그 그룹이 invalidation 된다. 이때 Row 전체가 다시 호출될지, Row 내부의 특정 자식만 다시 호출될지는 ‘상태를 어디서 읽었는지’와 ‘컴파일러가 만든 그룹 경계’에 달려 있다.

@Stable/@Immutable이 Row와 직접 관련 없어 보이지만 실제로는 파라미터 비교에 영향을 준다. 예를 들어 horizontalArrangement에 커스텀 객체를 넣고 그 객체가 안정적(stable)이지 않으면, 런타임이 매 프레임 “변경됨”으로 판단해 Row를 스킵하지 못할 수 있다. 초보 단계에서 가장 흔한 실수는 ‘람다/객체를 매번 새로 만들고도 왜 리컴포지션이 늘어나는지 모르는 것’이다.

Modifier 체인 순서는 레이아웃과 드로잉 결과를 바꾼다. padding 다음 background와 background 다음 padding은 결과가 다르다. 전자는 ‘패딩 포함 영역’을 칠하고, 후자는 ‘패딩 제외 영역’을 칠한다. 이 차이는 Modifier가 노드 체인으로 쌓이고, 체인의 바깥쪽 노드부터 constraints를 변형하거나 draw를 감싸기 때문이다.

RowRecompositionTrace.kt
1import android.util.Log
2import androidx.compose.foundation.layout.Arrangement
3import androidx.compose.foundation.layout.Row
4import androidx.compose.foundation.layout.padding
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
12import androidx.compose.ui.Modifier
13import androidx.compose.ui.tooling.preview.Preview
14import androidx.compose.ui.unit.dp
15
16@Composable
17fun RowRecompositionTrace() {
18    var count by remember { mutableIntStateOf(0) }
19
20    Log.d("Trace", "RowRecompositionTrace recomposed count=$count")
21
22    Row(
23        modifier = Modifier.padding(12.dp),
24        horizontalArrangement = Arrangement.spacedBy(12.dp)
25    ) {
26        Log.d("Trace", "Left child recomposed")
27        Text("Count: $count")
28
29        Log.d("Trace", "Right child recomposed")
30        Button(onClick = { count++ }) {
31            Text("+1")
32        }
33    }
34}
35
36@Preview(showBackground = true)
37@Composable
38private fun RowRecompositionTracePreview() {
39    RowRecompositionTrace()
40}

이 코드를 실행하고 버튼을 누르면 Logcat에 recomposed 로그가 반복해서 찍힌다. 초보가 흔히 착각하는 지점은 “버튼만 바뀌는데 왜 Row가 다시 호출되나”이다. count를 Row의 첫 Text에서 읽었기 때문에 Row 그룹이 invalidation 된다. 컴파일러는 Row content 내부를 여러 그룹으로 쪼갤 수 있지만, 상태 읽기 위치가 상위 그룹이면 상위가 다시 실행된다. 상태 읽기를 더 아래로 내리면 영향 범위가 줄어든다.

RowModifierOrderAndSemantics.kt
1import androidx.compose.foundation.Indication
2import androidx.compose.foundation.clickable
3import androidx.compose.foundation.interaction.MutableInteractionSource
4import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Row
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.contentDescription
12import androidx.compose.ui.semantics.semantics
13import androidx.compose.ui.tooling.preview.Preview
14import androidx.compose.ui.unit.dp
15
16@Composable
17fun RowModifierOrderAndSemantics() {
18    val interaction = remember { MutableInteractionSource() }
19
20    Row(
21        modifier = Modifier
22            .semantics { contentDescription = "settings-row" }
23            .clickable(
24                interactionSource = interaction,
25                indication = null as Indication?,
26                onClick = {}
27            )
28            .padding(12.dp),
29        horizontalArrangement = Arrangement.spacedBy(8.dp)
30    ) {
31        Text("Settings")
32        Text("On")
33    }
34}
35
36@Preview(showBackground = true, widthDp = 360)
37@Composable
38private fun RowModifierOrderAndSemanticsPreview() {
39    RowModifierOrderAndSemantics()
40}

실행하면 텍스트 두 개가 있는 단순한 Row가 보이지만, 접근성 트리에는 contentDescription이 들어간다. clickable이 semantics를 추가하는 것과 별개로, semantics를 명시하면 테스트 태그/스크린리더 라벨이 안정적으로 잡힌다. interactionSource를 remember로 고정하지 않으면 터치 상태가 리컴포지션마다 초기화될 수 있고, ripple/pressed 상태가 튀는 현상을 만들 수 있다. 예전에 이걸 놓쳐서 “눌렀다 떼면 pressed가 사라졌다가 다시 생기는” 이상한 깜빡임을 Layout Inspector가 아니라 Interaction 디버그 로그로 추적한 적이 있다.

실습하기

실습은 Row가 ‘측정/배치 규칙’이라는 사실을 눈으로 확인하는 흐름으로 구성한다. 각 단계는 복사-붙여넣기 실행이 가능해야 하고, 실행 결과가 구체적으로 보이도록 Preview 폭을 고정한다.

build.gradle
1android {
2  buildFeatures { compose true }
3  composeOptions {
4    kotlinCompilerExtensionVersion = "1.5.14"
5  }
6}
7
8dependencies {
9  implementation(platform("androidx.compose:compose-bom:2024.10.00"))
10  implementation("androidx.compose.ui:ui")
11  implementation("androidx.compose.ui:ui-tooling-preview")
12  implementation("androidx.compose.material3:material3")
13  debugImplementation("androidx.compose.ui:ui-tooling")
14}

버전은 프로젝트에 맞게 조정하면 된다. 중요한 건 BOM으로 UI/Material3 버전을 묶어서 Row/Modifier 동작 차이를 버전 불일치로 착각하지 않는 것이다. Preview가 안 뜨면 ui-tooling 의존성과 composeOptions 설정부터 확인한다.

1단계: 가장 기본 형태(배치 좌표가 바뀌는지 확인)

Row에 fillMaxWidth를 주지 않으면 Row의 폭은 자식들의 합으로 결정되는 경우가 많다. 이때 SpaceBetween을 줘도 ‘남는 폭’이 없어서 간격이 벌어지지 않는다. 초보가 가장 많이 겪는 “SpaceBetween이 안 먹는다” 문제의 정체다.

실행하면 첫 번째 Row는 텍스트가 붙어 있고, 두 번째 Row는 양 끝으로 벌어진다. 두 Row의 차이는 오직 fillMaxWidth 유무다. Layout Inspector에서 Row width를 비교하면 원인이 숫자로 보인다.

RowStep1.kt
1import androidx.compose.foundation.background
2import androidx.compose.foundation.layout.Arrangement
3import androidx.compose.foundation.layout.Column
4import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.fillMaxWidth
6import androidx.compose.foundation.layout.padding
7import androidx.compose.material3.Text
8import androidx.compose.runtime.Composable
9import androidx.compose.ui.Modifier
10import androidx.compose.ui.graphics.Color
11import androidx.compose.ui.tooling.preview.Preview
12import androidx.compose.ui.unit.dp
13
14@Composable
15fun RowStep1() {
16    Column(Modifier.padding(12.dp)) {
17        Row(
18            modifier = Modifier
19                .background(Color(0xFFFFF3E0))
20                .padding(8.dp),
21            horizontalArrangement = Arrangement.SpaceBetween
22        ) {
23            Text("No fill")
24            Text("No space")
25        }
26
27        Row(
28            modifier = Modifier
29                .fillMaxWidth()
30                .background(Color(0xFFE3F2FD))
31                .padding(8.dp),
32            horizontalArrangement = Arrangement.SpaceBetween
33        ) {
34            Text("fillMaxWidth")
35            Text("SpaceBetween")
36        }
37    }
38}
39
40@Preview(showBackground = true, widthDp = 360)
41@Composable
42private fun RowStep1Preview() {
43    RowStep1()
44}

2단계: 상태 연동(리컴포지션 범위 체감)

상태가 Row 내부 어디에서 읽히는지에 따라 리컴포지션 범위가 달라진다. 버튼 클릭으로 count가 바뀔 때, count를 읽는 텍스트만 다시 호출되게 만들면 로그가 줄어드는 걸 확인할 수 있다.

실행하면 count 텍스트만 바뀌고 나머지 텍스트는 그대로다. Logcat에서 “Static recomposed” 로그가 덜 찍히는 형태로 관찰할 수 있다. 완전히 0이 되지는 않을 수 있는데, 그 차이는 그룹 경계와 파라미터 안정성에 달려 있다.

RowStep2.kt
1import android.util.Log
2import androidx.compose.foundation.layout.Arrangement
3import androidx.compose.foundation.layout.Row
4import androidx.compose.foundation.layout.padding
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
12import androidx.compose.ui.Modifier
13import androidx.compose.ui.tooling.preview.Preview
14import androidx.compose.ui.unit.dp
15
16@Composable
17private fun CountText(count: Int) {
18    Log.d("Trace", "CountText recomposed")
19    Text("Count: $count")
20}
21
22@Composable
23fun RowStep2() {
24    var count by remember { mutableIntStateOf(0) }
25
26    Row(
27        modifier = Modifier.padding(12.dp),
28        horizontalArrangement = Arrangement.spacedBy(12.dp)
29    ) {
30        Log.d("Trace", "Static label recomposed")
31        Text("Counter")
32
33        CountText(count)
34
35        Button(onClick = { count++ }) {
36            Text("+1")
37        }
38    }
39}
40
41@Preview(showBackground = true, widthDp = 360)
42@Composable
43private fun RowStep2Preview() {
44    RowStep2()
45}

3단계: 커스터마이징(weight, alignment, modifier 순서)

weight는 RowScope의 ParentData로 전달돼 측정 전략을 바꾼다. 그래서 weight는 Row의 파라미터가 아니라 자식 Modifier에 붙는다. 이 단계에서 weight를 주고, align으로 특정 자식만 세로 정렬을 바꾸면 Row의 ‘규칙 + 예외’ 구조가 보인다.

실행하면 왼쪽 텍스트가 남는 공간을 먹고, 오른쪽 배지는 고정 폭으로 붙는다. padding과 background 순서를 바꾸면 배경이 어디까지 칠해지는지도 즉시 차이가 난다.

RowStep3.kt
1import androidx.compose.foundation.background
2import androidx.compose.foundation.layout.Arrangement
3import androidx.compose.foundation.layout.Row
4import androidx.compose.foundation.layout.fillMaxWidth
5import androidx.compose.foundation.layout.padding
6import androidx.compose.foundation.layout.width
7import androidx.compose.material3.Text
8import androidx.compose.runtime.Composable
9import androidx.compose.ui.Alignment
10import androidx.compose.ui.Modifier
11import androidx.compose.ui.graphics.Color
12import androidx.compose.ui.tooling.preview.Preview
13import androidx.compose.ui.unit.dp
14
15@Composable
16fun RowStep3() {
17    Row(
18        modifier = Modifier
19            .fillMaxWidth()
20            .padding(12.dp)
21            .background(Color(0xFFE8F5E9))
22            .padding(12.dp),
23        horizontalArrangement = Arrangement.spacedBy(8.dp),
24        verticalAlignment = Alignment.CenterVertically
25    ) {
26        Text(
27            text = "Title takes remaining space",
28            modifier = Modifier.weight(1f)
29        )
30        Text(
31            text = "NEW",
32            modifier = Modifier
33                .align(Alignment.Top)
34                .background(Color(0xFF2E7D32))
35                .padding(horizontal = 8.dp, vertical = 4.dp),
36            color = Color.White
37        )
38        Text(
39            text = ">",
40            modifier = Modifier.width(16.dp)
41        )
42    }
43}
44
45@Preview(showBackground = true, widthDp = 360)
46@Composable
47private fun RowStep3Preview() {
48    RowStep3()
49}

심화: Advanced 버전 만들기

Row를 실무에서 쓰면 ‘그냥 가로 배치’로 끝나지 않는다. 클릭 디바운스, 로딩 중 비활성화, 아이콘+텍스트 정렬, 롱프레스, 접근성 라벨 같은 요구가 한 컴포넌트로 모인다. 이때 Row의 설계 포인트는 두 가지다. 1) 상태를 어디서 읽을지(리컴포지션 범위) 2) Modifier/semantics를 어떤 순서로 쌓을지(측정과 접근성).

한 문단 요약: Row는 자식의 폭을 ‘측정’하고 좌표를 ‘배치’하는 규칙이다. weight는 남은 폭 계산을 위해 자식 ParentData로 전달되고, horizontalArrangement는 남는 공간 분배 규칙이라 fillMaxWidth 같은 외부 제약이 없으면 효과가 약하다. 상태 읽기 위치가 리컴포지션 범위를 결정한다.

사례 1: 클릭 디바운스 + 로딩 상태가 있는 Row 버튼

예전에 설정 화면에서 Row 전체를 clickable로 감싸고, 내부에 스위치도 넣었다. 연타하면 네트워크 요청이 2~3번씩 중복으로 날아갔고, 서버에서 409가 떨어졌다. Logcat에는 okhttp가 같은 endpoint를 연속 호출하는 흔적이 남았고, UI 쪽에서는 onClick이 프레임마다 여러 번 들어오는 게 아니라 ‘사용자 입력이 빠르게 반복된 것’이 원인이었다.

교정은 두 단계였다. 첫째, onClick을 디바운스해서 일정 시간 내 재호출을 막는다. 둘째, 로딩 중에는 semantics까지 포함해 disabled 상태를 명확히 만든다. 단순 enabled=false가 아니라, 클릭이 막히는 이유가 접근성에도 전달돼야 한다.

DebouncedRowButton.kt
1import android.os.SystemClock
2import androidx.compose.foundation.background
3import androidx.compose.foundation.clickable
4import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.Spacer
7import androidx.compose.foundation.layout.fillMaxWidth
8import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.size
10import androidx.compose.material3.CircularProgressIndicator
11import androidx.compose.material3.MaterialTheme
12import androidx.compose.material3.Text
13import androidx.compose.runtime.Composable
14import androidx.compose.runtime.mutableLongStateOf
15import androidx.compose.runtime.remember
16import androidx.compose.runtime.getValue
17import androidx.compose.runtime.setValue
18import androidx.compose.ui.Alignment
19import androidx.compose.ui.Modifier
20import androidx.compose.ui.graphics.Color
21import androidx.compose.ui.semantics.disabled
22import androidx.compose.ui.semantics.semantics
23import androidx.compose.ui.tooling.preview.Preview
24import androidx.compose.ui.unit.dp
25
26@Composable
27fun DebouncedRowButton(
28    title: String,
29    loading: Boolean,
30    debounceMs: Long = 600L,
31    a11yLabel: String = title,
32    onClick: () -> Unit
33) {
34    var lastClickAt by remember { mutableLongStateOf(0L) }
35
36    val enabled = !loading
37
38    Row(
39        modifier = Modifier
40            .fillMaxWidth()
41            .background(Color(0xFF111827))
42            .semantics {
43                if (!enabled) disabled()
44            }
45            .clickable(enabled = enabled) {
46                val now = SystemClock.elapsedRealtime()
47                if (now - lastClickAt >= debounceMs) {
48                    lastClickAt = now
49                    onClick()
50                }
51            }
52            .padding(horizontal = 14.dp, vertical = 12.dp),
53        horizontalArrangement = Arrangement.spacedBy(10.dp),
54        verticalAlignment = Alignment.CenterVertically
55    ) {
56        Text(
57            text = title,
58            modifier = Modifier.weight(1f),
59            color = Color.White,
60            style = MaterialTheme.typography.titleMedium
61        )
62
63        if (loading) {
64            CircularProgressIndicator(
65                modifier = Modifier.size(18.dp),
66                strokeWidth = 2.dp,
67                color = Color.White
68            )
69        } else {
70            Spacer(Modifier.size(18.dp))
71        }
72
73        Text(">", color = Color.White)
74    }
75}
76
77@Preview(showBackground = true, widthDp = 360)
78@Composable
79private fun DebouncedRowButtonPreview() {
80    DebouncedRowButton(title = "Sync", loading = true, onClick = {})
81}

실행하면 로딩 중에는 프로그레스가 돌고 클릭이 막힌다. 연타해도 onClick은 debounceMs 간격으로만 들어간다. 여기서 Row의 역할은 ‘타이틀이 남는 공간을 먹고(Modifier.weight), 오른쪽 영역은 고정 폭으로 유지’하는 레이아웃 규칙이다. 디바운스 상태(lastClickAt)는 remember로 고정돼야 한다. remember가 없으면 리컴포지션 때마다 0으로 초기화돼 디바운스가 무력화된다.

사례 2: 아이콘+텍스트+롱프레스 + 접근성 라벨

롱프레스는 흔히 pointerInput으로 처리하지만, 초보 단계에서 바로 들어가면 이벤트 충돌이 난다. clickable과 pointerInput의 순서가 바뀌면 제스처 소비(consumption) 방식이 달라져 클릭이 씹히기도 한다. 예전에 이걸 잘못 쌓아서 ‘길게 누르면 클릭이 두 번 발생’하는 버그를 만들었고, 로그에는 onClick과 onLongPress가 같은 프레임에 연속으로 찍혔다.

교정은 제스처를 한 곳에서 처리하고 semantics를 함께 정의하는 방식이었다. 아이콘+텍스트 정렬은 Row의 verticalAlignment로 잡고, 특정 요소만 align으로 예외를 준다. 접근성 라벨은 contentDescription 하나로 끝내지 않고, 상태(loading/disabled)까지 반영한다.

IconTextRowAction.kt
1import androidx.compose.foundation.background
2import androidx.compose.foundation.combinedClickable
3import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.padding
6import androidx.compose.foundation.layout.size
7import androidx.compose.material3.Icon
8import androidx.compose.material3.MaterialTheme
9import androidx.compose.material3.Text
10import androidx.compose.runtime.Composable
11import androidx.compose.ui.Alignment
12import androidx.compose.ui.Modifier
13import androidx.compose.ui.graphics.Color
14import androidx.compose.ui.graphics.vector.ImageVector
15import androidx.compose.ui.semantics.contentDescription
16import androidx.compose.ui.semantics.semantics
17import androidx.compose.ui.tooling.preview.Preview
18import androidx.compose.ui.unit.dp
19
20@Composable
21fun IconTextRowAction(
22    icon: ImageVector,
23    title: String,
24    a11yLabel: String,
25    onClick: () -> Unit,
26    onLongPress: () -> Unit
27) {
28    Row(
29        modifier = Modifier
30            .background(Color(0xFFF1F5F9))
31            .semantics { contentDescription = a11yLabel }
32            .combinedClickable(
33                onClick = onClick,
34                onLongClick = onLongPress
35            )
36            .padding(horizontal = 12.dp, vertical = 10.dp),
37        horizontalArrangement = Arrangement.spacedBy(10.dp),
38        verticalAlignment = Alignment.CenterVertically
39    ) {
40        Icon(
41            imageVector = icon,
42            contentDescription = null,
43            modifier = Modifier.size(20.dp),
44            tint = Color(0xFF0F172A)
45        )
46        Text(
47            text = title,
48            style = MaterialTheme.typography.bodyLarge,
49            color = Color(0xFF0F172A)
50        )
51    }
52}
53
54@Preview(showBackground = true, widthDp = 360)
55@Composable
56private fun IconTextRowActionPreview() {
57    // 실제 프로젝트에서는 Icons.Default.* 사용
58}

Preview에서는 아이콘 벡터가 없으면 바로 확인이 어렵다. 실제 프로젝트에서는 material-icons-extended를 추가하거나 Icons.Default를 사용한다. combinedClickable을 선택한 이유는 클릭과 롱프레스를 한 노드에서 처리해 이벤트 충돌을 줄이기 위해서다. Row의 배치 규칙은 변하지 않지만, semantics와 입력 처리가 Modifier 체인에 얹히면서 ‘Row가 버튼처럼 동작’하게 된다.

자주 하는 실수

SpaceBetween이 동작하지 않는다고 착각함

증상: horizontalArrangement = SpaceBetween을 줬는데 자식들이 붙어 있다. 화면 폭을 늘려도 간격이 거의 안 벌어진다.

원인: Row의 width가 자식 합으로만 결정돼 남는 폭이 없다. fillMaxWidth, width, 혹은 부모 constraints가 Row에 충분한 폭을 주지 않았다.

해결: Row가 남는 폭을 가져야 SpaceBetween이 의미가 생긴다. fillMaxWidth를 주거나, 부모가 Row를 확장하도록 구성한다. Layout Inspector에서 Row width가 자식 합인지 화면 폭인지 숫자로 확인한다.

weight를 주고도 텍스트가 잘리거나 측정이 이상함

증상: Modifier.weight(1f)를 준 텍스트가 기대보다 좁게 나오거나, 옆의 고정 폭 뷰가 밀려난다. ellipsis가 과하게 발생한다.

원인: 가중치 자식과 비가중 자식의 측정 순서 때문이다. 비가중 자식이 먼저 폭을 많이 먹으면, 남은 폭이 줄어 가중치 자식이 더 타이트한 constraints로 측정된다.

해결: 고정 폭 요소는 width로 명확히 고정하고, 가중치 자식은 필요한 최소 폭을 확보하도록 maxLines/overflow를 조정한다. 필요하면 비가중 자식의 폭을 제한하거나, 가중치 비율을 재설계한다.

Row content에서 조건문으로 자식을 넣었다가 remember가 꼬임

증상: 토글을 켰다 끄면 입력값이 다른 컴포넌트로 이동한 것처럼 보인다. 예를 들어 TextField의 내용이 옆 아이템으로 순간이동한다.

원인: Slot Table은 호출 순서를 키로 사용한다. Row의 content에서 if로 중간 자식이 생겼다 사라지면 뒤쪽 슬롯 인덱스가 밀리고, remember 값이 다른 호출에 매칭된다.

해결: 조건부 자식이 있는 리스트/반복에서는 key를 사용하거나, 구조가 바뀌지 않게 placeholder를 둔다. Row 단독이라도 중간에 자식이 끼었다 빠지는 구조는 피한다.

Modifier 순서로 배경/클릭 영역이 예상과 다름

증상: padding을 줬는데 클릭 영역이 더 크거나 더 작다. 배경이 텍스트 뒤에만 칠해지거나, 패딩까지 칠해지지 않는다.

원인: Modifier는 노드 체인이고, 바깥쪽 노드가 안쪽을 감싼다. clickable이 padding 앞에 있으면 패딩까지 클릭 영역에 포함될 수 있고, background 위치에 따라 칠해지는 영역이 달라진다.

해결: ‘클릭 영역을 어디까지로 할지’ 먼저 결정하고 Modifier 순서를 고정한다. 보통 background → clickable → padding 또는 clickable → padding → background 같은 패턴을 팀 내에서 통일한다.

interactionSource를 매번 새로 만들어 pressed 상태가 튐

증상: 길게 누르거나 빠르게 탭하면 pressed/ripple 상태가 끊기는 것처럼 보인다. 특정 기기에서만 더 심하게 재현된다.

원인: MutableInteractionSource를 remember 없이 생성하면 리컴포지션 때마다 새 인스턴스가 만들어져 입력 상태가 초기화된다. pressed 이벤트 스트림이 끊기면서 시각적 상태가 튄다.

해결: interactionSource는 remember로 고정한다. clickable/combinedClickable에 전달하는 람다도 가능하면 안정적으로 유지한다(필요 시 rememberUpdatedState 사용).

InteractionSourceFixedRow.kt
1import androidx.compose.foundation.clickable
2import androidx.compose.foundation.interaction.MutableInteractionSource
3import androidx.compose.foundation.layout.Row
4import androidx.compose.foundation.layout.padding
5import androidx.compose.material3.Text
6import androidx.compose.runtime.Composable
7import androidx.compose.runtime.remember
8import androidx.compose.ui.Modifier
9import androidx.compose.ui.tooling.preview.Preview
10import androidx.compose.ui.unit.dp
11
12@Composable
13fun InteractionSourceFixedRow() {
14    val interaction = remember { MutableInteractionSource() }
15
16    Row(
17        modifier = Modifier
18            .clickable(interactionSource = interaction, indication = null, onClick = {})
19            .padding(12.dp)
20    ) {
21        Text("Press me")
22    }
23}
24
25@Preview(showBackground = true)
26@Composable
27private fun InteractionSourceFixedRowPreview() {
28    InteractionSourceFixedRow()
29}

이 코드는 pressed 상태가 리컴포지션과 무관하게 유지되는지 확인하기 위한 최소 재현이다. indication을 null로 둔 이유는 리플이 없어도 interaction 스트림이 유지되는지를 분리해서 보기 위해서다. 리플까지 포함한 확인은 MaterialTheme + 기본 indication으로 바꾸면 된다.

성능 최적화 체크리스트

  • Row의 폭이 필요한 배치 규칙(예: SpaceBetween)에 충분한 constraints를 받는지 Layout Inspector로 width를 확인한다
  • SpaceBetween/SpaceEvenly 같은 분배형 Arrangement를 쓸 때 fillMaxWidth 또는 부모의 확장 제약이 있는지 점검한다
  • weight 사용 시 비가중 자식이 과도한 폭을 먹지 않도록 width/maxLines/overflow로 상한을 둔다
  • 가중치 자식이 여러 개면 비율 합(예: 1f+2f)이 의도대로인지, 남은 폭 계산이 맞는지 화면 폭을 바꿔 테스트한다
  • Row content에서 조건부로 자식이 삽입/삭제되는 위치가 remember와 충돌하지 않는지(슬롯 이동) 확인한다
  • 리컴포지션 범위를 줄이기 위해 상태 읽기 위치를 Row 상단이 아니라 필요한 자식 컴포저블로 내린다
  • 람다/객체를 매 리컴포지션마다 새로 만들지 않는지 확인한다(필요 시 remember, rememberUpdatedState)
  • MutableInteractionSource, FocusRequester 같은 상태성 객체는 remember로 고정한다
  • Modifier 순서(background/padding/clickable/semantics)가 클릭 영역과 그려지는 영역을 의도대로 만드는지 터치 디버그로 확인한다
  • Row에 과도한 draw modifier를 누적하지 않는지 확인한다(배경/그라디언트/블러는 비용이 큼)
  • Baseline 정렬이 필요한 텍스트 조합이면 CenterVertically 대신 alignByBaseline 계열을 검토한다
  • Layout Inspector에서 Row의 measured size와 자식 positionX/positionY를 확인해 ‘간격 문제’가 측정인지 배치인지 구분한다

자주 묻는 질문

Row는 내부적으로 어떤 구조로 동작하나? 실제로 뭔가 객체가 생성되나?

Row 호출은 컴파일 단계에서 Compose Compiler에 의해 그룹 시작/종료와 파라미터 변경 체크 코드로 변환된다. 런타임은 Slot Table에 이전 호출의 그룹 구조를 저장하고, 동일한 호출 위치에서 파라미터가 변하지 않으면 스킵을 시도한다. UI 트리 쪽에서는 Row가 Layout 노드를 만들고(정확히는 Layout 계열 컴포저블이 노드를 만든다), Modifier 체인이 노드에 붙는다. 즉, Row 자체가 ‘View 객체’처럼 상주하는 게 아니라, 호출이 노드를 기술하고 런타임이 노드를 재사용하거나 업데이트한다. 학습 키워드는 Composer, SlotTable, group, skippable, LayoutNode, Modifier.Node이다.

weight가 왜 Row의 파라미터가 아니라 자식 Modifier에 붙나?

weight는 Row 전체 설정이 아니라 ‘특정 자식이 남은 폭을 얼마나 가져갈지’라는 자식별 메타데이터다. Compose는 이런 자식별 레이아웃 정보를 ParentData로 전달한다. RowScope.weight는 Modifier에 ParentDataModifier(또는 그에 준하는 노드)를 추가하고, Row의 MeasurePolicy가 측정 단계에서 그 ParentData를 읽어 가중치 자식을 2패스로 측정한다(비가중 먼저 측정 → 남은 폭 계산 → 가중 측정). Row 파라미터로 weight를 받으면 자식별로 값을 매핑할 방법이 애매해지고, Modifier 체인이 이미 제공하는 ‘자식에 메타데이터를 부착하는 통로’를 중복하게 된다. 키워드는 ParentData, RowScope, MeasurePolicy, constraints, two-pass measure이다.

Row에서 상태가 바뀌면 어디까지 다시 그려지나? Row 전체가 매번 다시 실행되나?

상태 변경은 ‘그 상태를 읽은 위치’를 기준으로 invalidation을 만든다. count를 Row 상단에서 읽으면 Row 그룹이 invalidation 되고 Row content 전체가 다시 호출될 가능성이 커진다. 반대로 count를 특정 자식 컴포저블 내부에서만 읽으면, 컴파일러가 만든 그룹 경계에 따라 그 자식만 다시 호출될 수 있다. 다시 호출과 다시 그리기는 구분해야 한다. Recomposition은 함수 재호출이고, Layout은 측정/배치가 다시 필요할 때만 발생한다. 텍스트 내용이 바뀌면 측정이 다시 필요할 수 있지만, 색만 바뀌면 draw만 바뀌는 경우도 있다. 확인 방법은 Logcat으로 recomposition 로그를 찍고, Layout Inspector에서 layout pass 여부를 함께 보는 것이다. 키워드는 read tracking, invalidation, recomposition scope, measure/layout invalidation이다.

Modifier 순서가 왜 그렇게 중요하나? padding과 background 순서가 달라지면 뭐가 바뀌나?

Modifier는 단순 옵션 리스트가 아니라 ‘노드 체인’이며, 바깥쪽 노드가 안쪽 노드를 감싼다. padding은 레이아웃 노드로 constraints를 변형하고 자식의 배치 위치를 바꾼다. background는 드로우 노드로 draw 단계에서 캔버스를 칠한다. background 뒤에 padding을 두면 패딩이 적용된 이후 영역을 칠하는 형태가 되고, padding 뒤에 background를 두면 패딩을 제외한 콘텐츠 영역만 칠하는 형태가 된다. clickable도 마찬가지로 입력 영역(hit test)과 semantics를 어디까지 포함할지 순서에 따라 달라진다. 팀에서 Modifier 순서 규칙을 정하지 않으면 “왜 클릭이 안 되지?” 같은 QA 이슈가 반복된다. 키워드는 Modifier.Node, layout modifier, draw modifier, pointer input, hit test, semantics이다.

Row의 horizontalArrangement는 측정에 영향이 없나? 배치에만 영향이 있나?

대부분의 경우 horizontalArrangement는 place 단계에서 x 좌표를 계산하는 규칙이라 측정 결과(각 자식의 measuredWidth)에는 직접 영향이 적다. 하지만 weight가 섞이면 이야기가 달라진다. Row는 남은 폭을 계산해야 하고, 남은 폭은 ‘부모로부터 받은 maxWidth’와 ‘비가중 자식들의 measuredWidth 합’에 의해 결정된다. 이때 Arrangement 자체는 남는 폭 분배 방식이지만, 남는 폭이 존재하려면 Row가 확장 폭을 가져야 한다(fillMaxWidth 등). 그래서 초보가 Arrangement를 바꿔도 변화가 없는 경우가 많고, 원인은 측정 단계(폭이 남지 않음)에 있다. 디버깅은 Row width를 먼저 확인하고, 그 다음 자식 measuredWidth와 positionX를 확인하는 순서가 빠르다. 키워드는 constraints, remaining space, placement, fillMaxWidth, inspector이다.

@Stable, @Immutable이 Row 성능과 무슨 관계가 있나?

Row 자체가 @Stable을 요구하는 건 아니지만, Row에 전달되는 파라미터(특히 객체/람다)가 안정적이지 않으면 런타임의 변경 감지가 매번 ‘변경됨’으로 떨어질 수 있다. Compose는 파라미터가 동일하면 그룹을 스킵하려고 한다. 안정적인 타입은 내부 필드 변경 가능성이 제한되거나, 변경이 Compose에 의해 추적 가능하다는 힌트를 준다. 예를 들어 data class를 불변으로 유지하고 @Immutable로 표시하면, 동일 인스턴스 재사용 또는 값 비교에서 불필요한 invalidation을 줄일 여지가 생긴다. 반대로 매 리컴포지션마다 새 객체를 만들어 전달하면 스킵이 깨지고 Row content가 자주 재호출된다. 측정은 매번 일어나지 않더라도, 컴포지션 비용과 할당이 늘어난다. 키워드는 stability inference, skippable, parameter change, allocation profiling이다.

Layout Inspector에서 보이는 트리와 실제 리컴포지션 범위가 다른 것처럼 느껴진다. 왜 그런가?

Inspector는 레이아웃 노드/컴포저블 트리를 시각화하지만, 리컴포지션 단위는 ‘컴파일러가 만든 그룹 경계’와 ‘상태 읽기 위치’에 의해 결정된다. 화면에 Row-Text-Button이 보인다고 해서 Text만 바뀔 때 Text만 재호출된다는 보장은 없다. 또한 리컴포지션이 일어나도 Layout 단계가 항상 다시 도는 건 아니다. 텍스트 문자열이 바뀌면 측정이 다시 필요할 수 있지만, 색/alpha 변경은 draw invalidation만 발생할 수 있다. 그래서 “트리는 그대로인데 왜 느리지?” 같은 질문이 나온다. 처방은 세 가지다. 1) Logcat으로 recomposition 로그를 찍어 호출 빈도를 본다 2) Android Studio의 Compose Recomposition Counts/Highlight를 켠다 3) 프로파일러에서 할당(Allocations)과 measure/layout 패스를 함께 본다. 키워드는 group boundary, recomposition highlight, layout pass, tracing, allocation profiler이다.

관련 글

8. Jetpack Compose Row 기본 구조와 파라미터가 그렇게 생긴 이유
Compose 기본2026.02.17

8. Jetpack Compose Row 기본 구조와 파라미터가 그렇게 생긴 이유

Compose Row의 핵심 파라미터(Modifier, horizontalArrangement, verticalAlignment, weight)가 왜 존재하는지와 Slot Table·Recomposition 관점의 내부 동작을 설명한다. 초보도 원리부터 잡는다.

30. derivedStateOf로 불필요한 Recomposition 줄이는 실전 패턴
Compose 기본2026.03.07

30. derivedStateOf로 불필요한 Recomposition 줄이는 실전 패턴

Jetpack Compose에서 derivedStateOf가 왜 필요한지, 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