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

22. Compose Column/Row 레이아웃: Modifier 간격·정렬과 내부 동작

Compose의 Column/Row 배치와 Modifier 간격·정렬이 왜 그런 API인지, Slot Table·Recomposition·Measure/Place 흐름까지 연결해 이해한다. 실습 코드 포함. (초보용)​','primaryKeywords':['Jetpack Compose','Column Row','Jetap

22. Compose Column/Row 레이아웃: Modifier 간격·정렬과 내부 동작

Compose Column/Row 레이아웃: Modifier 간격·정렬과 내부 동작

처음 Compose로 화면을 만들면 Column 안에 Text를 넣었는데 간격이 제멋대로이거나, Row에서 버튼이 오른쪽으로 안 붙고, padding을 줬더니 클릭 영역까지 줄어든 것처럼 느껴지는 순간이 온다. 더 헷갈리는 지점은 상태를 바꾸면 어떤 부분만 다시 그려지는지 감이 없다는 점이다. Column/Row는 단순한 컨테이너가 아니라, 런타임이 슬롯을 기록하고 레이아웃 단계에서 측정·배치 정책을 실행하는 ‘규칙 묶음’이라서, 규칙을 이해하면 디버깅 속도가 달라진다.

핵심 개념

Column/Row는 ‘자식들을 세로/가로로 나열한다’가 전부가 아니다. View 시스템에서 LinearLayout을 쓸 때도 orientation만 바꿨지만, 실제로는 measure pass에서 자식들을 어떤 제약(constraints)으로 재측정할지, 남는 공간을 어떻게 분배할지, gravity를 언제 적용할지 같은 정책이 핵심이었다. Compose도 동일하고, 그 정책이 함수 호출 형태로 노출되어 있다. Modifier는 그 정책에 참여하는 노드들을 체인으로 쌓아, 측정·배치·그리기·입력 처리 순서에 영향을 준다.

초보가 가장 많이 막히는 지점은 ‘정렬은 Alignment인데 왜 Arrangement도 있나’ 같은 API 분리다. Alignment는 교차축(cross axis) 정렬이 많고, Arrangement는 주축(main axis)에서의 간격 분배다. Row에서 horizontalArrangement는 주축(가로)을, verticalAlignment는 교차축(세로)을 다룬다. Column은 반대로 verticalArrangement가 주축(세로), horizontalAlignment가 교차축(가로)이다. 이름이 축을 직접 말해주기 때문에, 코드를 읽는 사람이 즉시 방향을 떠올리게 만든 설계다.

Slot Table은 ‘컴포저가 호출 순서대로 Composable의 상태/키/그룹을 기록하는 표’에 가깝다. Column/Row는 레이아웃 노드를 만들고, 그 노드 아래에 자식들의 그룹이 연속으로 들어간다. 상태가 바뀌면 런타임은 어떤 State가 어디에서 읽혔는지 추적하고, 해당 그룹 범위만 재호출(recomposition)한다. Column/Row를 잘 쓴다는 건 배치만이 아니라, 재구성 범위를 예측 가능한 형태로 유지한다는 뜻이 된다.

Modifier 체이닝이 필요한 이유는 ‘옵션 파라미터 폭발’을 막기 위해서만이 아니다. padding, size, background, clickable, semantics 같은 것들은 서로 다른 단계(레이아웃/드로잉/입력/접근성)에 걸친다. 이를 하나의 파라미터 집합으로 만들면 적용 순서가 불명확해지고, 조합이 늘수록 조건 분기가 늘어난다. Modifier는 노드 리스트이며, 순서가 곧 의미다. padding().background()와 background().padding()이 다른 결과를 내는 이유가 여기서 나온다.

BasicColumnRowLayout.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.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 BasicColumnRowLayout() {
16    Column(
17        modifier = Modifier
18            .background(Color(0xFFEFEFEF))
19            .padding(16.dp),
20        verticalArrangement = Arrangement.spacedBy(12.dp)
21    ) {
22        Text("Title", style = MaterialTheme.typography.titleLarge)
23        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
24            Text("Left", modifier = Modifier.background(Color.White).padding(8.dp))
25            Text("Right", modifier = Modifier.background(Color.White).padding(8.dp))
26        }
27    }
28}
29
30@Preview(showBackground = true)
31@Composable
32private fun PreviewBasicColumnRowLayout() {
33    BasicColumnRowLayout()
34}

이 코드를 실행하면 Column 내부에서 첫 Text와 Row 사이가 12dp로 벌어진다. 이 간격은 ‘Spacer를 하나 더 그린다’가 아니라, Column의 measure policy가 자식들을 place할 때 누적 y 위치에 spacing을 더하는 방식으로 구현된다. Row 내부의 spacedBy도 동일한 방식이다. 그래서 간격은 ‘별도 노드’가 아니라 ‘배치 규칙’이며, Slot Table 관점에서도 자식 수가 늘지 않는다. 이 차이는 recomposition과 레이아웃 비용을 추적할 때 체감이 크다.

컴포넌트 해부

Column/Row는 표면적으로는 Composable 함수지만, 내부에서는 Layout을 통해 MeasurePolicy를 가진 레이아웃 노드를 만든다. 핵심은 자식 측정과 배치(place) 규칙이다. View의 onMeasure/onLayout이 합쳐져 있고, constraints 기반으로 동작한다. 그래서 파라미터가 ‘정렬’에만 있는 게 아니라, 자식의 가중치(weight) 같은 제약 참여까지 포함한다.

  • modifier: 레이아웃/드로잉/입력/semantics 노드 체인. 적용 순서가 결과를 바꾼다
  • verticalArrangement(Row에서는 horizontalArrangement): 주축에서 남는 공간 분배 규칙. spacedBy, SpaceBetween 등
  • horizontalAlignment(Row에서는 verticalAlignment): 교차축 정렬 규칙. Start/Center/End
  • content: 자식 슬롯. 호출 순서가 Slot Table 그룹 순서가 된다
  • RowScope/ColumnScope: weight/align 같은 스코프 확장 API를 제공하기 위한 설계
  • weight(자식 Modifier): 남는 공간을 비율로 분배. 측정 단계에서 두 번 측정이 발생할 수 있다
  • fillMaxWidth/fillMaxHeight: constraints를 확장해 자식 측정값에 영향을 준다
  • padding: 레이아웃 단계에서 constraints를 줄이고, place 오프셋을 만든다
  • size/width/height: constraints를 강제하거나 clamp한다
  • background: 드로잉 단계에서 drawBehind/drawWithContent 노드로 처리된다
  • clip/shape: 드로잉 단계에서 레이어/클리핑이 들어가며 오버드로우에 영향
  • clickable: 입력 처리 + semantics를 추가하며 interactionSource를 읽는다
  • semantics: 접근성 트리와 테스트 태그에 영향을 준다

Surface 계층과 Content 계층을 분리해서 생각하면 디버깅이 쉬워진다. Surface 계층은 배경/클리핑/그림자/입력 같은 ‘껍데기’를 담당하고, Content 계층은 실제 자식들을 배치하는 레이아웃 정책을 담당한다. Column/Row는 대체로 Content 계층에 가깝고, Surface는 Modifier 또는 Surface 컴포넌트가 맡는다.

Surface를 Modifier로 처리하면 체인 순서가 곧 레이어 순서가 된다. 예를 들어 background를 padding 앞에 두면 배경이 padding까지 포함해 그려지고, padding 뒤에 두면 배경이 내용 영역에만 그려진다. 이 차이는 실제로 화면에서 ‘배경이 꽉 찼나, 내용만 칠해졌나’로 확인된다. 클릭 영역도 비슷하게 modifier 순서에 따라 달라진다.

Content 계층에서 중요한 건 constraints 흐름이다. Column은 각 자식을 측정할 때, 너비는 부모 constraints의 maxWidth를 그대로 주는 경우가 많고(자식이 fillMaxWidth면 더 강제), 높이는 자식이 원하는 만큼을 더해 누적한다. Row는 반대로 가로 누적이다. weight가 들어가면 ‘먼저 weight 없는 애들을 측정하고 남은 공간을 계산한 뒤, weight 있는 애들을 다시 측정’ 같은 두 단계가 생긴다.

EducationalRowLikeLayout.kt
1import androidx.compose.runtime.Composable
2import androidx.compose.ui.Modifier
3import androidx.compose.ui.layout.Layout
4import androidx.compose.ui.unit.Constraints
5
6@Composable
7fun EducationalRowLikeLayout(
8    modifier: Modifier = Modifier,
9    spacingPx: Int = 0,
10    content: @Composable () -> Unit
11) {
12    Layout(modifier = modifier, content = content) { measurables, constraints: Constraints ->
13        val placeables = measurables.map { it.measure(constraints) }
14
15        var width = 0
16        var height = 0
17        for (p in placeables) {
18            width += p.width
19            height = maxOf(height, p.height)
20        }
21        width += maxOf(0, (placeables.size - 1) * spacingPx)
22
23        layout(width.coerceIn(constraints.minWidth, constraints.maxWidth),
24            height.coerceIn(constraints.minHeight, constraints.maxHeight)
25        ) {
26            var x = 0
27            for (p in placeables) {
28                p.placeRelative(x, 0)
29                x += p.width + spacingPx
30            }
31        }
32    }
33}

이 코드는 실제 Row 구현과 1:1이 아니다. 다만 ‘Row가 결국 measure → layout(place)’로 끝난다는 감각을 손에 잡히게 만든다. 실행하면 spacingPx가 배치 오프셋에만 영향을 주고, Slot Table에는 자식 수만큼의 그룹만 생긴다는 점이 핵심이다. 반대로 Spacer를 자식으로 끼우면 그룹이 하나 더 생기고, 레이아웃 노드도 하나 더 생긴다.

SurfaceAndContentSplitSample.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.padding
6import androidx.compose.foundation.layout.size
7import androidx.compose.material3.Surface
8import androidx.compose.material3.Text
9import androidx.compose.runtime.Composable
10import androidx.compose.ui.Alignment
11import androidx.compose.ui.Modifier
12import androidx.compose.ui.graphics.Color
13import androidx.compose.ui.tooling.preview.Preview
14import androidx.compose.ui.unit.dp
15
16@Composable
17fun SurfaceAndContentSplitSample() {
18    Surface(color = Color(0xFF111827)) {
19        Column(
20            modifier = Modifier.padding(16.dp),
21            verticalArrangement = Arrangement.spacedBy(10.dp),
22            horizontalAlignment = Alignment.Start
23        ) {
24            Text("Surface: dark background", color = Color.White)
25            Row(
26                modifier = Modifier.background(Color(0xFF1F2937)).padding(12.dp),
27                horizontalArrangement = Arrangement.spacedBy(8.dp),
28                verticalAlignment = Alignment.CenterVertically
29            ) {
30                Text("A", modifier = Modifier.size(32.dp).background(Color(0xFF60A5FA)).padding(6.dp))
31                Text("B", color = Color.White)
32            }
33        }
34    }
35}
36
37@Preview(showBackground = true)
38@Composable
39private fun PreviewSurfaceAndContentSplitSample() {
40    SurfaceAndContentSplitSample()
41}

실행하면 바깥 Surface가 전체 배경을 칠하고, Row의 배경은 padding 포함 영역까지 칠해진다. Row 내부 첫 Text는 size로 레이아웃 크기를 먼저 고정한 뒤 background가 그 크기에 맞춰 그려지고, padding은 내부 콘텐츠(Text의 글리프) 주변 여백을 만든다. 같은 Modifier라도 ‘측정에 참여하는 것(size/padding)’과 ‘그리기에 참여하는 것(background)’이 섞여 있고, 체인 순서가 결과를 바꾸는 이유가 여기서 드러난다.

내부 동작 원리

Compose 파이프라인은 Composition → Layout → Drawing 순서로 흘러간다. Column/Row를 호출하면 Composition 단계에서 ‘레이아웃 노드를 만든다’는 의도가 기록되고, content 람다를 실행해 자식 Composable 호출들이 이어진다. 이때 컴포저는 그룹을 열고 닫으며 Slot Table에 위치 정보를 남긴다. Layout 단계는 measure policy가 constraints를 받아 자식을 측정하고 place 좌표를 결정한다. Drawing 단계는 Modifier의 draw 노드와 자식들의 draw가 호출된다.

Compose Compiler는 Composable 함수를 그대로 호출하지 않는다. 각 Composable에 composer 파라미터와 changed 플래그를 추가한 형태로 변환하고, 그룹 시작/종료를 위한 호출을 삽입한다. 실제 생성물은 더 복잡하지만, 핵심은 ‘호출 위치가 동일하면 Slot Table의 동일한 슬롯을 재사용한다’는 점이다. 그래서 if/for로 Composable 호출 순서가 바뀌면 키 충돌이나 상태 이동이 생긴다.

Slot Table에는 remember로 만든 값, composition local, 노드 키, 그룹 경계가 들어간다. Column/Row 자체는 기억할 상태가 없으면 슬롯에 큰 데이터를 남기지 않지만, 자식 호출 순서를 고정시키는 ‘그룹 컨테이너’ 역할을 한다. 상태가 바뀌면 런타임은 Snapshot 시스템을 통해 읽기 추적을 보고, 그 State를 읽었던 그룹만 invalid로 표시한다.

Recomposition은 ‘다시 그리기’가 아니라 ‘Composable 재호출’이다. 재호출 결과가 이전과 같으면 Layout/Drawing까지 안 갈 수 있지만, Modifier나 측정 결과가 달라지면 layout이 다시 돈다. Column/Row에서 spacing이나 alignment가 바뀌면 배치 좌표가 달라지므로 Layout 단계가 반드시 다시 실행된다. 반대로 텍스트만 바뀌면 같은 크기라면 배치는 유지될 수 있다.

Modifier 체인은 내부적으로 노드들의 연결 리스트에 가깝다. padding은 LayoutModifierNode로 constraints를 줄이고, clickable은 PointerInput/Indication/semantics 노드를 추가한다. 중요한 건 순서다. padding().clickable()은 패딩 포함 영역이 클릭된다. clickable().padding()은 클릭 노드가 패딩 바깥쪽에 있어 클릭 영역이 내용 크기 기준으로 남는다. 실제로 터치했을 때 리플 범위가 달라진다.

@Stable/@Immutable은 런타임이 ‘이 객체를 파라미터로 받았을 때 변경 여부를 어떻게 판단할지’에 영향을 준다. Compose는 기본적으로 파라미터 비교를 통해 스킵 가능성을 판단한다. 안정적인 타입이면 내부 필드가 바뀌지 않는다고 가정하거나, 변경을 더 안전하게 추적한다. Column/Row에 전달하는 data class나 리스트가 매번 새로 생성되면, 자식 전체가 불필요하게 재호출되는 현상이 나온다.

RecompositionTraceSample.kt
1import android.util.Log
2import androidx.compose.foundation.layout.Arrangement
3import androidx.compose.foundation.layout.Column
4import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.padding
6import androidx.compose.material3.Button
7import androidx.compose.material3.Text
8import androidx.compose.runtime.Composable
9import androidx.compose.runtime.getValue
10import androidx.compose.runtime.mutableIntStateOf
11import androidx.compose.runtime.mutableStateOf
12import androidx.compose.runtime.remember
13import androidx.compose.runtime.setValue
14import androidx.compose.ui.Modifier
15import androidx.compose.ui.tooling.preview.Preview
16import androidx.compose.ui.unit.dp
17
18@Composable
19fun RecompositionTraceSample() {
20    var count by remember { mutableIntStateOf(0) }
21    var label by remember { mutableStateOf("static") }
22
23    Log.d("Recompose", "RecompositionTraceSample called")
24
25    Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(16.dp)) {
26        Header(label)
27        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
28            Button(onClick = { count++ }) { Text("count++") }
29            Button(onClick = { label = "static" }) { Text("set same label") }
30        }
31        Counter(count)
32    }
33}
34
35@Composable
36private fun Header(label: String) {
37    Log.d("Recompose", "Header called")
38    Text("Header: $label")
39}
40
41@Composable
42private fun Counter(count: Int) {
43    Log.d("Recompose", "Counter called")
44    Text("Count = $count")
45}
46
47@Preview(showBackground = true)
48@Composable
49private fun PreviewRecompositionTraceSample() {
50    RecompositionTraceSample()
51}

이 코드를 실행하고 버튼을 누르면 Logcat에 호출 로그가 찍힌다. count++을 누르면 Counter는 매번 호출되고, Header도 함께 호출될 수 있다(부모가 재호출되기 때문). 다만 Header 내부가 스킵될지 여부는 파라미터가 동일한지, 안정성 판단이 가능한지에 달려 있다. ‘set same label’을 눌러도 label 값이 동일하면 Snapshot이 실제 변경으로 기록하지 않거나, 변경이 없다고 판단되면 invalidation이 줄어든다. 여기서 Compose의 핵심은 “State를 읽은 위치만 추적한다”는 점이고, Column/Row는 그 위치를 그룹으로 묶는 역할을 한다.

ModifierOrderAndSemanticsSample.kt
1import androidx.compose.foundation.Interaction
2import androidx.compose.foundation.InteractionState
3import androidx.compose.foundation.background
4import androidx.compose.foundation.clickable
5import androidx.compose.foundation.interaction.MutableInteractionSource
6import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.padding
8import androidx.compose.material3.Text
9import androidx.compose.runtime.Composable
10import androidx.compose.runtime.remember
11import androidx.compose.ui.Modifier
12import androidx.compose.ui.graphics.Color
13import androidx.compose.ui.semantics.contentDescription
14import androidx.compose.ui.semantics.semantics
15import androidx.compose.ui.tooling.preview.Preview
16import androidx.compose.ui.unit.dp
17
18@Composable
19fun ModifierOrderAndSemanticsSample() {
20    val interactionSource = remember { MutableInteractionSource() }
21
22    Column(modifier = Modifier.padding(16.dp)) {
23        Text(
24            text = "padding -> clickable",
25            modifier = Modifier
26                .background(Color(0xFFE5E7EB))
27                .padding(16.dp)
28                .clickable(interactionSource = interactionSource, indication = null) {}
29                .semantics { contentDescription = "padded clickable" }
30        )
31
32        Text(
33            text = "clickable -> padding",
34            modifier = Modifier
35                .background(Color(0xFFE5E7EB))
36                .clickable(interactionSource = interactionSource, indication = null) {}
37                .padding(16.dp)
38                .semantics { contentDescription = "content-only clickable" }
39        )
40    }
41}
42
43@Preview(showBackground = true)
44@Composable
45private fun PreviewModifierOrderAndSemanticsSample() {
46    ModifierOrderAndSemanticsSample()
47}

두 줄을 실제 기기에서 눌러 보면 체감이 난다. 첫 번째는 패딩까지 터치가 먹고, 두 번째는 글자 주변만 터치가 먹는다. 이유는 clickable이 입력 노드를 추가하는데, 그 노드가 체인에서 어느 위치에 있느냐에 따라 hit test 영역이 달라지기 때문이다. semantics는 접근성 트리에 별도 노드를 만들고, 테스트에서도 이 contentDescription이 검색 키가 된다. Column/Row 배치와 무관해 보이지만, Modifier가 ‘레이아웃+입력+접근성’을 한 줄로 결합하는 설계라서 레이아웃 디버깅에도 바로 연결된다.

실습하기

실습 목표는 세 가지다. (1) Column/Row로 화면 뼈대를 만들고 간격을 Spacer 없이 제어한다. (2) 상태 변경이 어느 범위를 재호출하는지 Logcat으로 확인한다. (3) weight와 정렬을 섞었을 때 constraints가 어떻게 흐르는지 화면에서 확인한다.

build.gradle
1plugins {
2    id 'com.android.application'
3    id 'org.jetbrains.kotlin.android'
4}
5
6android {
7    namespace 'com.example.layout'
8    compileSdk 34
9
10    defaultConfig {
11        minSdk 24
12        targetSdk 34
13    }
14
15    buildFeatures {
16        compose true
17    }
18
19    composeOptions {
20        kotlinCompilerExtensionVersion '1.5.14'
21    }
22}
23
24dependencies {
25    implementation platform('androidx.compose:compose-bom:2024.06.00')
26    implementation 'androidx.activity:activity-compose:1.9.1'
27    implementation 'androidx.compose.material3:material3'
28    implementation 'androidx.compose.ui:ui-tooling-preview'
29    debugImplementation 'androidx.compose.ui:ui-tooling'
30}

버전은 프로젝트 상황에 맞게 조정한다. 중요한 건 Compose BOM으로 UI 의존성 버전을 맞추는 점이다. 컴파일러 확장 버전이 런타임과 어긋나면 빌드 에러가 나거나, 미묘한 스킵 판단이 기대와 달라질 수 있다. 실제로 예전에 compiler extension을 올리고 BOM을 그대로 둔 상태에서 Preview가 검은 화면만 뜬 적이 있었고, 원인은 ui-tooling 쪽 바이너리 호환성 경고였다.

1단계: Column/Row로 뼈대 만들기

이 단계에서 확인할 건 두 가지다. 첫째, Arrangement.spacedBy로 간격을 만들면 자식 수가 늘지 않는다. 둘째, Alignment가 교차축에만 영향을 준다. 실행하면 상단 제목, 그 아래 두 개의 카드 같은 박스가 세로로 떨어져 보인다.

처음에 나도 spacedBy를 보고 ‘Spacer를 내부에서 자동으로 넣나’라고 착각했다. Layout Inspector에서 노드 수가 늘지 않는 걸 보고 감이 왔다. 간격은 노드가 아니라 배치 계산의 일부라서, recomposition 시에도 자식 트리 구조가 덜 흔들린다.

MainActivity.kt
1import android.os.Bundle
2import androidx.activity.ComponentActivity
3import androidx.activity.compose.setContent
4import androidx.compose.foundation.background
5import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Row
8import androidx.compose.foundation.layout.fillMaxWidth
9import androidx.compose.foundation.layout.padding
10import androidx.compose.material3.MaterialTheme
11import androidx.compose.material3.Surface
12import androidx.compose.material3.Text
13import androidx.compose.runtime.Composable
14import androidx.compose.ui.Alignment
15import androidx.compose.ui.Modifier
16import androidx.compose.ui.graphics.Color
17import androidx.compose.ui.tooling.preview.Preview
18import androidx.compose.ui.unit.dp
19
20class MainActivity : ComponentActivity() {
21    override fun onCreate(savedInstanceState: Bundle?) {
22        super.onCreate(savedInstanceState)
23        setContent { App() }
24    }
25}
26
27@Composable
28fun App() {
29    MaterialTheme {
30        Surface(color = Color(0xFFF9FAFB)) {
31            Column(
32                modifier = Modifier.padding(16.dp),
33                verticalArrangement = Arrangement.spacedBy(12.dp),
34                horizontalAlignment = Alignment.Start
35            ) {
36                Text("Layout", style = MaterialTheme.typography.titleLarge)
37                InfoRow("CPU", "0.4%")
38                InfoRow("MEM", "128MB")
39            }
40        }
41    }
42}
43
44@Composable
45private fun InfoRow(left: String, right: String) {
46    Row(
47        modifier = Modifier
48            .fillMaxWidth()
49            .background(Color.White)
50            .padding(12.dp),
51        horizontalArrangement = Arrangement.SpaceBetween,
52        verticalAlignment = Alignment.CenterVertically
53    ) {
54        Text(left)
55        Text(right)
56    }
57}
58
59@Preview(showBackground = true)
60@Composable
61private fun PreviewApp() {
62    App()
63}

SpaceBetween은 남는 가로 공간을 두 Text 사이에 몰아준다. Row의 주축이 가로이기 때문에 가능한 동작이다. Column에서 SpaceBetween을 쓰면 세로로 벌어진다. 실행 화면에서 오른쪽 값이 끝까지 붙지 않으면, 대개 Row에 fillMaxWidth가 없어서 constraints의 maxWidth가 자식 합으로 줄어든 상태다. 이때는 ‘정렬이 안 먹는다’가 아니라 ‘부모가 남는 공간을 갖고 있지 않다’가 원인이다.

2단계: 상태 연동으로 recomposition 범위 확인

버튼을 눌러 간격(spacing)을 바꾸면 레이아웃 단계가 다시 돈다. 반대로 단순 텍스트만 바꾸면 레이아웃이 유지될 수도 있다. 실행하면 spacing 값이 8dp/24dp로 토글되면서 카드 사이 간격이 눈에 띄게 바뀐다.

3시간 삽질 끝에 알아낸 건 ‘레이아웃이 느리다’가 아니라 ‘내가 spacing을 state로 만들고 매 프레임 바꾸고 있었다’는 사실이었다. 당시에는 애니메이션도 아닌데 스크롤이 버벅였고, Layout Inspector에서 measure 횟수가 비정상적으로 많았다. spacing state를 derivedStateOf로 안정화하고 변경 빈도를 줄이니 바로 사라졌다.

SpacingStatePractice.kt
1import android.util.Log
2import androidx.compose.foundation.layout.Arrangement
3import androidx.compose.foundation.layout.Column
4import androidx.compose.foundation.layout.fillMaxWidth
5import androidx.compose.foundation.layout.padding
6import androidx.compose.material3.Button
7import androidx.compose.material3.Text
8import androidx.compose.runtime.Composable
9import androidx.compose.runtime.getValue
10import androidx.compose.runtime.mutableStateOf
11import androidx.compose.runtime.remember
12import androidx.compose.runtime.setValue
13import androidx.compose.ui.Modifier
14import androidx.compose.ui.tooling.preview.Preview
15import androidx.compose.ui.unit.Dp
16import androidx.compose.ui.unit.dp
17
18@Composable
19fun SpacingStatePractice() {
20    var wide by remember { mutableStateOf(false) }
21    val spacing: Dp = if (wide) 24.dp else 8.dp
22
23    Log.d("Recompose", "SpacingStatePractice called, wide=$wide")
24
25    Column(
26        modifier = Modifier.padding(16.dp),
27        verticalArrangement = Arrangement.spacedBy(spacing)
28    ) {
29        Button(onClick = { wide = !wide }) {
30            Text("Toggle spacing")
31        }
32        RepeatCard("Card 1")
33        RepeatCard("Card 2")
34        RepeatCard("Card 3")
35    }
36}
37
38@Composable
39private fun RepeatCard(title: String) {
40    Text(
41        text = title,
42        modifier = Modifier.fillMaxWidth().padding(12.dp)
43    )
44}
45
46@Preview(showBackground = true)
47@Composable
48private fun PreviewSpacingStatePractice() {
49    SpacingStatePractice()
50}

wide가 바뀌면 Column의 Arrangement가 바뀌고, place 좌표가 달라지므로 Layout 단계가 다시 실행된다. Logcat에서는 Column이 포함된 그룹이 invalid로 표시되어 재호출된다. 다만 RepeatCard가 스킵될지 여부는 파라미터(title) 안정성에 달려 있고, title이 상수면 대개 스킵된다. 화면에서는 텍스트 자체는 그대로인데 간격만 바뀌는 게 확인된다.

3단계: weight와 정렬을 섞어 constraints 흐름 확인

weight는 ‘남는 공간을 비율로 나눠 가진다’인데, 실제로는 측정 순서가 바뀐다. 먼저 고정 크기/weight 없는 자식을 측정해 남는 폭을 계산하고, 그 다음 weight 자식을 그 폭으로 재측정한다. 실행하면 왼쪽은 고정 라벨, 오른쪽은 남은 공간을 차지하는 입력 영역처럼 보인다.

이 단계에서 확인할 포인트는 fillMaxWidth와 weight의 관계다. Row 자체가 가로로 꽉 차지 않으면 남는 공간이 없어서 weight가 기대대로 동작하지 않는다. 또한 weight가 있는 자식에 padding을 어디에 두느냐에 따라 ‘남는 공간’ 계산이 달라져, 줄바꿈이나 ellipsis가 달라질 수 있다.

WeightAndAlignmentPractice.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.weight
7import androidx.compose.material3.MaterialTheme
8import androidx.compose.material3.Text
9import androidx.compose.runtime.Composable
10import androidx.compose.ui.Alignment
11import androidx.compose.ui.Modifier
12import androidx.compose.ui.graphics.Color
13import androidx.compose.ui.text.style.TextOverflow
14import androidx.compose.ui.tooling.preview.Preview
15import androidx.compose.ui.unit.dp
16
17@Composable
18fun WeightAndAlignmentPractice() {
19    Row(
20        modifier = Modifier
21            .fillMaxWidth()
22            .background(Color.White)
23            .padding(12.dp),
24        horizontalArrangement = Arrangement.spacedBy(8.dp),
25        verticalAlignment = Alignment.CenterVertically
26    ) {
27        Text("Label", style = MaterialTheme.typography.bodyMedium)
28        Text(
29            text = "This is a long value that should take remaining width and ellipsize.",
30            modifier = Modifier.weight(1f),
31            maxLines = 1,
32            overflow = TextOverflow.Ellipsis
33        )
34        Text("END")
35    }
36}
37
38@Preview(showBackground = true)
39@Composable
40private fun PreviewWeightAndAlignmentPractice() {
41    WeightAndAlignmentPractice()
42}

실행하면 가운데 텍스트가 남는 폭을 가져가고, 길면 말줄임 처리된다. 여기서 weight가 없으면 가운데 텍스트가 자기 폭만큼만 차지해서 END가 화면 밖으로 밀릴 수 있다. Row의 measure policy는 weight 자식을 ‘남는 폭’으로 다시 측정하기 때문에, 텍스트 레이아웃도 그 폭을 기준으로 다시 계산된다. 이 재측정이 자주 발생하면 비용이 늘어나므로, 애니메이션으로 weight를 매 프레임 바꾸는 건 피하는 편이 낫다.

심화: Advanced 버전 만들기

실무에서는 Column/Row 자체보다 ‘그 위에 얹는 규칙’을 컴포넌트로 캡슐화하는 일이 더 많다. 예를 들어 로딩 중에는 클릭을 막고, 빠른 연타를 디바운스하고, 아이콘+텍스트를 정렬하고, 롱프레스도 받고, 접근성 라벨까지 넣는 버튼이 필요하다. 이런 요구사항은 Modifier와 상태 관리가 엉키기 쉬운 구간이라, 내부 동작을 이해한 상태에서 구조를 잡아야 한다.

한 문단 요약: Column/Row의 간격·정렬은 ‘추가 뷰’가 아니라 MeasurePolicy의 배치 규칙이며, Modifier는 레이아웃/드로잉/입력/semantics 노드를 순서대로 쌓는다. 상태 변경은 State를 읽은 그룹만 invalid로 표시되고 Slot Table 위치가 같을 때 스킵이 가능하다.

사례 1: 로딩 + 디바운스 + 접근성 라벨을 가진 버튼

로딩 상태에서 버튼을 비활성화하는 가장 흔한 실수는 enabled=false만 주고 onClick에서 네트워크 요청을 중복으로 날리는 것이다. 클릭 자체는 막혀도, 상태 전환 타이밍이 늦으면 이전 프레임에서 이미 여러 번 호출된 onClick이 남아 있을 수 있다. 디바운스는 UI 레벨에서 마지막 클릭 시간을 기억하는 방식으로도 막을 수 있다.

AdvancedButton.kt
1import android.os.SystemClock
2import androidx.compose.foundation.background
3import androidx.compose.foundation.combinedClickable
4import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.padding
7import androidx.compose.foundation.layout.size
8import androidx.compose.material3.CircularProgressIndicator
9import androidx.compose.material3.MaterialTheme
10import androidx.compose.material3.Text
11import androidx.compose.runtime.Composable
12import androidx.compose.runtime.getValue
13import androidx.compose.runtime.mutableLongStateOf
14import androidx.compose.runtime.remember
15import androidx.compose.runtime.setValue
16import androidx.compose.ui.Alignment
17import androidx.compose.ui.Modifier
18import androidx.compose.ui.graphics.Color
19import androidx.compose.ui.semantics.contentDescription
20import androidx.compose.ui.semantics.semantics
21import androidx.compose.ui.text.font.FontWeight
22import androidx.compose.ui.unit.dp
23
24@Composable
25fun AdvancedButton(
26    text: String,
27    modifier: Modifier = Modifier,
28    loading: Boolean,
29    debounceMs: Long = 600L,
30    icon: (@Composable () -> Unit)? = null,
31    a11yLabel: String = text,
32    onClick: () -> Unit,
33    onLongClick: (() -> Unit)? = null
34) {
35    var lastClickUptime by remember { mutableLongStateOf(0L) }
36
37    val clickableModifier = Modifier.combinedClickable(
38        enabled = !loading,
39        onClick = {
40            val now = SystemClock.uptimeMillis()
41            if (now - lastClickUptime >= debounceMs) {
42                lastClickUptime = now
43                onClick()
44            }
45        },
46        onLongClick = if (loading) null else onLongClick
47    )
48
49    Row(
50        modifier = modifier
51            .semantics { contentDescription = a11yLabel }
52            .background(if (loading) Color(0xFF9CA3AF) else Color(0xFF2563EB))
53            .then(clickableModifier)
54            .padding(horizontal = 14.dp, vertical = 10.dp),
55        horizontalArrangement = Arrangement.spacedBy(8.dp),
56        verticalAlignment = Alignment.CenterVertically
57    ) {
58        if (loading) {
59            CircularProgressIndicator(
60                modifier = Modifier.size(16.dp),
61                strokeWidth = 2.dp,
62                color = Color.White
63            )
64        } else if (icon != null) {
65            icon()
66        }
67
68        Text(
69            text = text,
70            color = Color.White,
71            style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold)
72        )
73    }
74}

이 코드에서 핵심은 lastClickUptime이 remember로 Slot Table에 저장된다는 점이다. remember가 없으면 recomposition 때마다 0으로 초기화되어 디바운스가 무력화된다. combinedClickable은 입력 노드와 semantics를 함께 구성하고, enabled가 false면 hit test 단계에서 이벤트가 소비되지 않는다. loading이 true일 때 onLongClick을 null로 바꾼 이유는 롱프레스가 지연된 콜백이라서, 로딩 전환 타이밍에 따라 의도치 않게 실행되는 케이스를 줄이기 위해서다.

사례 2: Column/Row 기반으로 ‘폼 행’ 컴포넌트 만들기

폼 UI는 대부분 Row로 라벨/값을 나누고, Column으로 행을 쌓는다. 여기서 자주 깨지는 건 정렬 기준선이다. 라벨 길이가 제각각이면 값 영역이 들쭉날쭉해 보인다. 해결은 라벨 영역에 고정 폭 또는 weight를 주고, 값 영역을 weight로 밀어주는 방식이다. 그리고 spacing은 Spacer 대신 Arrangement로 통일하면 노드 수가 덜 증가한다.

FormRowsSample.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.foundation.layout.width
8import androidx.compose.foundation.layout.weight
9import androidx.compose.material3.MaterialTheme
10import androidx.compose.material3.Text
11import androidx.compose.runtime.Composable
12import androidx.compose.ui.Alignment
13import androidx.compose.ui.Modifier
14import androidx.compose.ui.graphics.Color
15import androidx.compose.ui.tooling.preview.Preview
16import androidx.compose.ui.unit.dp
17
18@Composable
19fun FormRowsSample() {
20    Column(
21        modifier = Modifier
22            .background(Color.White)
23            .padding(16.dp),
24        verticalArrangement = Arrangement.spacedBy(10.dp)
25    ) {
26        FormRow(label = "Name", value = "Kim")
27        FormRow(label = "Email", value = "kim@example.com")
28        FormRow(label = "Address", value = "Seoul, Republic of Korea")
29    }
30}
31
32@Composable
33private fun FormRow(label: String, value: String) {
34    Row(
35        modifier = Modifier.fillMaxWidth(),
36        verticalAlignment = Alignment.CenterVertically,
37        horizontalArrangement = Arrangement.spacedBy(12.dp)
38    ) {
39        Text(
40            text = label,
41            modifier = Modifier.width(80.dp),
42            style = MaterialTheme.typography.bodyMedium,
43            color = Color(0xFF374151)
44        )
45        Text(
46            text = value,
47            modifier = Modifier.weight(1f),
48            style = MaterialTheme.typography.bodyMedium,
49            color = Color(0xFF111827)
50        )
51    }
52}
53
54@Preview(showBackground = true)
55@Composable
56private fun PreviewFormRowsSample() {
57    FormRowsSample()
58}

내 흑역사 하나가 여기서 나왔다. 라벨 폭을 하드코딩하지 않고 라벨 Text에 weight를 줬더니, 긴 라벨이 값 영역을 밀어내면서 값이 한 글자만 보였다. Layout Inspector로 constraints를 찍어보니 라벨이 남는 폭을 다 가져가고 있었다. 해결은 라벨을 고정 폭 또는 maxLines=1+ellipsis로 제한하고, 값에 weight를 주는 쪽으로 역할을 고정하는 것이었다.

또 하나는 clickable을 Row에 걸면서 padding 순서를 반대로 둔 실수다. QA에서 ‘텍스트 주변만 눌린다’는 리포트를 받았고, 실제로는 clickable().padding() 순서였다. 터치 영역은 clickable이 위치한 노드 크기 기준으로 결정된다. padding을 클릭 전에 적용하면 해결되지만, 배경/리플 범위까지 함께 바뀌므로 디자인 요구사항과 같이 맞춰야 한다.

자주 하는 실수

1) 정렬이 안 먹는 것처럼 보임 (fillMaxWidth/Height 누락)

증상: Row에서 SpaceBetween을 줬는데 오른쪽 텍스트가 끝까지 안 붙는다. Column에서 Alignment.CenterHorizontally를 줬는데 중앙으로 안 모인다.

원인: 부모가 남는 공간을 갖고 있지 않다. Row/Column이 wrap content 크기로 측정되면 Arrangement가 분배할 공간이 없다. View의 match_parent가 빠진 LinearLayout과 비슷한 상황이다.

해결: Row/Column에 fillMaxWidth/fillMaxHeight 또는 상위에서 크기를 주는 Modifier를 추가한다. Layout Inspector에서 해당 노드의 width/height가 기대값인지 확인한다.

2) padding을 줬더니 클릭 영역이 이상해짐 (Modifier 순서)

증상: 텍스트 주변만 눌리거나, 리플이 내용만 감싸고 패딩 영역은 반응이 없다. 같은 화면인데 어떤 항목은 잘 눌리고 어떤 항목은 잘 안 눌린다.

원인: clickable이 padding 앞에 있으면 hit test 영역이 padding 적용 전 크기를 기준으로 잡힌다. Modifier는 선언 순서대로 노드가 쌓이고, 입력 처리 노드는 자신이 가진 레이아웃 크기만 안다.

해결: 패딩까지 클릭되게 하려면 padding().clickable() 순서로 둔다. 반대로 내용만 클릭되게 하려면 clickable().padding()을 유지하되 배경/리플 요구사항을 다시 확인한다.

3) weight를 줬는데 기대한 대로 안 늘어남 (부모 constraints/측정 순서)

증상: Modifier.weight(1f)를 줬는데 텍스트가 여전히 짧게 보이거나, 다른 요소가 화면 밖으로 밀린다. 또는 weight가 있는 자식이 두 개일 때 비율이 이상하다.

원인: Row 자체가 가로로 확장되지 않았거나(fillMaxWidth 누락), weight 없는 자식들이 먼저 공간을 다 써버렸다. weight는 ‘남는 공간’이 있어야 의미가 있고, Row는 일반적으로 weight 없는 자식을 먼저 측정한다.

해결: Row에 fillMaxWidth를 주고, 공간을 많이 쓰는 자식(긴 텍스트)은 maxLines/overflow로 제한한다. 복잡하면 Layout Inspector에서 각 자식의 measured width를 확인한다.

4) 리스트/반복문에서 상태가 다른 아이템으로 이동함 (키/호출 순서)

증상: Column 안에서 for로 아이템을 그리는데, 아이템을 추가/삭제하면 체크 상태가 다른 줄로 이동한다. 텍스트 입력이 갑자기 다른 행으로 옮겨간다.

원인: Slot Table은 호출 순서를 기반으로 remember 슬롯을 재사용한다. 중간에 아이템이 삽입되면 이후 아이템들의 슬롯 인덱스가 밀리며 상태가 ‘옆으로’ 이동한다. RecyclerView에서 stableId 없이 notifyItemInserted를 잘못 쓰는 느낌과 비슷하다.

해결: LazyColumn에서는 key를 제공하고, 단순 Column 반복에서도 key()를 사용해 그룹 키를 고정한다. 상태는 아이템 id 기반으로 외부로 끌어올리는 방식도 함께 쓴다.

5) 불필요한 recomposition이 계속 발생함 (불안정 파라미터/람다 생성)

증상: 스크롤하지도 않는데 Logcat에 recomposition 로그가 계속 찍힌다. Column/Row 하위 전체가 자주 재호출되고, 프레임 드랍이 난다.

원인: 매 recomposition마다 새 리스트/새 data class/새 람다를 만들어 파라미터가 매번 달라진다. Compose는 파라미터 비교로 스킵을 시도하는데, 불안정 타입이거나 참조가 계속 바뀌면 스킵이 어렵다.

해결: remember로 컬렉션/람다를 고정하거나, derivedStateOf로 계산 값을 캐시한다. 타입에 @Immutable을 붙일 수 있는 구조인지 검토하고, 불필요한 객체 할당을 Android Studio Profiler에서 확인한다.

KeyFixSample.kt
1import androidx.compose.runtime.Composable
2import androidx.compose.runtime.key
3
4@Composable
5fun KeyFixSample(ids: List<Long>, row: @Composable (Long) -> Unit) {
6    for (id in ids) {
7        key(id) {
8            row(id)
9        }
10    }
11}

이 코드를 적용하면 ids의 중간 삽입/삭제가 있어도 각 id에 해당하는 그룹이 같은 키로 매칭된다. Slot Table에서 ‘호출 위치’ 대신 ‘키’를 우선 단서로 삼기 때문에, remember 슬롯이 다른 아이템으로 이동하는 현상이 줄어든다. 다만 키가 중복되면 더 큰 혼란이 생기므로, 실제로 유니크한 id를 써야 한다.

성능 최적화 체크리스트

  • Row/Column 정렬이 안 먹을 때, 먼저 부모가 남는 공간을 갖는지(fillMaxWidth/Height, size, constraints) 확인한다
  • 간격은 Spacer 남발 대신 Arrangement.spacedBy를 우선 적용해 노드 수 증가를 막는다
  • Modifier 순서가 클릭 영역과 배경 범위를 바꾸는지, padding과 clickable의 위치를 테스트로 검증한다
  • weight 사용 시 Row/Column의 주축 방향과 부모 constraints(maxWidth/maxHeight)를 Layout Inspector로 확인한다
  • 긴 텍스트가 weight 영역을 잠식하지 않도록 maxLines/overflow를 명시하고 측정 폭 변화를 관찰한다
  • 반복 렌더링에서 상태가 이동하면 key를 제공하고, remember를 아이템 id 기반 상태로 바깥으로 끌어올린다
  • recomposition 로그를 찍을 때는 부모/자식에 각각 로그를 두고 스킵 여부(호출 횟수 변화)를 비교한다
  • 불안정 파라미터(매번 새 리스트/새 람다)를 줄이기 위해 remember/derivedStateOf로 할당을 고정한다
  • Modifier.background/clip/shadow 조합은 레이어 생성 여부를 확인하고, 과도한 clip은 스크롤 성능에 악영향이 없는지 점검한다
  • Layout Inspector에서 노드 수가 예상보다 많으면 Spacer/Box 중첩을 줄이고 Arrangement/Alignment로 대체한다
  • Accessibility를 위해 semantics(contentDescription)와 클릭 타겟(최소 48dp)을 함께 점검한다

자주 묻는 질문

Column/Row는 내부적으로 ViewGroup처럼 자식 View를 들고 있나?

그 방식이 아니다. Column/Row는 Composition 단계에서 ‘레이아웃 노드 + 자식 그룹’ 구조를 만든다. 자식은 View 인스턴스가 아니라 Composable 호출의 결과로 만들어진 UI 트리 노드다. 런타임은 Slot Table에 호출 순서와 remember 슬롯을 기록하고, Layout 단계에서 MeasurePolicy가 measurables를 받아 측정한다. 그래서 자식이 실제로 어떤 순서로 호출되는지(조건문/반복문/키)가 상태 재사용에 직접 영향을 준다. 학습 키워드는 Layout, MeasurePolicy, SlotTable, key()이다.

Arrangement.spacedBy는 Spacer를 자동으로 넣는 기능인가?

노드를 추가하지 않는다. spacedBy는 Row/Column의 배치 계산에서 누적 좌표에 간격을 더하는 규칙이다. Spacer를 자식으로 넣으면 Composition 트리에 노드가 하나 더 생기고, 측정/배치도 그만큼 더 수행된다. spacedBy는 노드 수 증가 없이 place 단계에서 x/y를 이동시키는 방식이라, 구조가 단순해지고 Slot Table도 덜 흔들린다. 다만 간격이 ‘아이템 사이’에만 들어가므로, 시작/끝 여백은 padding으로 분리하는 게 읽기 좋다.

왜 Modifier는 파라미터가 아니라 체이닝 방식인가?

레이아웃/드로잉/입력/접근성은 서로 다른 단계에 걸친다. 이를 Column/Row 파라미터로 다 넣으면 적용 순서가 모호해지고 조합 수가 폭발한다. Modifier는 노드 체인이라서 ‘어떤 노드가 먼저 constraints를 바꾸고, 어떤 노드가 그 위에 그려지고, 어떤 노드가 hit test를 받는지’를 순서로 표현한다. padding().clickable()과 clickable().padding()이 다른 결과를 내는 건 설계 의도에 가깝다. 키워드는 ModifierNode, LayoutModifier, PointerInputModifier, SemanticsModifier다.

remember가 없으면 뭐가 깨지나? Column/Row와도 관련이 있나?

remember가 없으면 recomposition 때마다 값이 초기화된다. Column/Row는 상태를 직접 갖지 않지만, 그 내부에서 상태를 사용하면 그룹 경계 안에 remember 슬롯이 저장된다. 예를 들어 디바운스용 lastClickTime, 토글 상태, 애니메이션 진행 값이 remember 없이 지역 변수로만 있으면 버튼을 누를 때마다 0으로 돌아가거나, 스크롤/상태 변경으로 재호출될 때 값이 튄다. Slot Table이 같은 위치의 remember 슬롯을 재사용하기 때문에 안정적으로 유지되는 구조다. 키워드는 remember, SlotTable, recomposition, invalidation이다.

Row에서 verticalAlignment와 horizontalArrangement를 헷갈린다. 외우는 요령이 있나?

축을 먼저 고정하면 헷갈림이 줄어든다. Row의 주축은 가로이므로 ‘가로 방향 간격/분배’는 horizontalArrangement가 담당한다. 교차축은 세로이므로 ‘세로 방향 정렬’은 verticalAlignment가 담당한다. Column은 반대로 주축이 세로라서 verticalArrangement, 교차축이 가로라서 horizontalAlignment가 들어간다. 이 분리는 API가 길어 보이지만, 코드 리뷰에서 “이 정렬이 어느 축을 건드리는지”를 즉시 드러내는 효과가 있다. 키워드는 main axis/cross axis다.

Recomposition이 일어나면 Column/Row 전체가 다시 측정되고 그려지나?

항상 그렇지 않다. recomposition은 Composable 재호출이고, 그 결과가 이전과 동일하면 스킵이 가능하다. 다만 Column/Row의 파라미터(Arrangement/Alignment/Modifier)가 바뀌면 레이아웃 노드의 속성이 바뀌고, 측정/배치가 다시 실행될 가능성이 높다. 반대로 자식 텍스트만 바뀌고 크기가 동일하게 유지되면 배치가 유지될 수 있다. 실제 확인은 Layout Inspector의 recomposition counts, 그리고 Logcat으로 부모/자식 호출 로그를 함께 찍어보는 방식이 빠르다. 키워드는 skipping, stability, measure/layout invalidation이다.

LazyColumn이 없는데도 key()를 써야 하나?

단순 Column+for에서도 상태 이동 문제가 생긴다. Slot Table은 호출 순서 기반으로 remember 슬롯을 재사용하므로, 중간 삽입/삭제가 있으면 이후 아이템들의 슬롯이 밀린다. key(id)를 사용하면 그룹이 id로 식별되어 같은 id의 상태가 유지될 가능성이 커진다. 다만 key는 ‘상태 이동 방지’의 한 축일 뿐이고, 입력값처럼 중요한 상태는 item id 기반으로 상태 맵을 두고 hoist하는 방식이 더 안전하다. 키워드는 key(), state hoisting, stable ids다.

관련 글

10. Row 간격·정렬이 꼬이는 이유: Modifier 순서와 Arrangement/Alignment 디버깅
Compose 기본2026.02.17

10. Row 간격·정렬이 꼬이는 이유: Modifier 순서와 Arrangement/Alignment 디버깅

Jetpack Compose Row에서 spacing·alignment가 예상과 다를 때, Modifier 순서와 Arrangement/Alignment가 측정·배치 단계에서 어떻게 적용되는지 내부 동작으로 추적한다. 디버깅 루틴 포함. 154자 내외

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

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

Jetpack Compose에서 derivedStateOf가 왜 필요한지, Slot Table·스냅샷·재구성 비교까지 따라가며 불필요한 recomposition을 줄이는 패턴을 구현한다. 초보도 동작 이유를 이해한다. ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ

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

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

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