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

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

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

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

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

처음 Row를 쓰면 이런 장면이 자주 나온다. 분명히 horizontalArrangement = SpaceBetween을 줬는데 아이템이 한쪽에 뭉치거나, padding을 추가했더니 간격이 더 벌어지기는커녕 정렬이 틀어지고, weight를 섞는 순간 텍스트가 잘리거나 버튼이 화면 밖으로 밀린다. 원인은 대부분 “Row가 언제, 무엇을 기준으로 간격을 계산하는가”와 “Modifier 체인이 측정/배치에 끼어드는 순서”에 있다. 이 글은 화면 결과를 역으로 추적해서, 측정 제약(Constraints)→자식 측정→배치 좌표→그 다음에야 그려짐이라는 흐름으로 디버깅 루틴을 만든다.

핵심 개념

Row의 간격·정렬 문제는 UI 디자인 문제가 아니라 레이아웃 수학 문제인 경우가 많다. Row는 “자식들을 얼마나 크게 측정할지”를 Constraints로 결정하고, 측정 결과(각 자식의 width/height)를 모아서 “남는 공간을 어떻게 분배할지”를 Arrangement로 결정한다. 이때 Modifier는 단순 옵션이 아니라, 측정과 배치에 끼어드는 노드들의 체인이다. 같은 padding이라도 체인 위치에 따라 Row가 보는 크기 자체가 달라진다.

용어를 실제 디버깅 맥락으로 정의한다. Constraints는 부모가 자식에게 주는 min/max 크기 범위다. Measure는 그 범위 안에서 자식이 자기 크기를 결정하는 과정이다. Placement는 측정된 크기를 가진 자식들을 (x,y)에 놓는 과정이다. Arrangement/Alignment는 placement 좌표를 계산하는 정책이다. Modifier chain은 measure/placement를 가로채서 constraints를 바꾸거나(예: padding), 측정값을 바꾸거나(예: size), 배치 좌표를 이동시키는(예: offset) 연결 리스트다.

왜 Row에 horizontalArrangement/verticalAlignment가 따로 있을까. Row는 가로 축이 주축(main axis)이고 세로 축이 교차축(cross axis)이다. 간격(Arrangement)은 주축에서 “남는 공간”을 다루고, 정렬(Alignment)은 교차축에서 “기준선”을 맞춘다. 둘을 섞으면 개념이 무너진다. SpaceBetween은 주축 여백 분배고, CenterVertically는 교차축 정렬이다.

View 시스템에서 같은 문제는 LayoutParams와 measure/layout 오버라이드로 풀었다. padding은 View 자체의 내부 여백이었고, margin은 부모가 관리했다. Compose는 margin이 없고 padding/offset/size가 전부 Modifier로 표현된다. 그래서 “부모가 관리하던 것”과 “자식이 관리하던 것”이 한 줄 체인에 섞인다. 이 설계가 강력한 대신, 순서가 결과를 바꾸는 함정이 생긴다.

BasicRowSpacing.kt
1package com.example.rowdebug
2
3import androidx.compose.foundation.background
4import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.fillMaxWidth
7import androidx.compose.foundation.layout.padding
8import androidx.compose.material3.MaterialTheme
9import androidx.compose.material3.Text
10import androidx.compose.runtime.Composable
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
17private fun BasicRowSpacing() {
18    Row(
19        modifier = Modifier
20            .fillMaxWidth()
21            .background(Color(0xFFEFEFEF))
22            .padding(12.dp),
23        horizontalArrangement = Arrangement.SpaceBetween
24    ) {
25        Text("Left", color = MaterialTheme.colorScheme.onSurface)
26        Text("Right", color = MaterialTheme.colorScheme.onSurface)
27    }
28}
29
30@Preview(showBackground = true, widthDp = 360)
31@Composable
32private fun BasicRowSpacingPreview() {
33    BasicRowSpacing()
34}

이 코드를 실행하면 Left/Right가 양끝으로 벌어진다. 핵심은 fillMaxWidth가 Row 자체의 측정 결과를 부모 폭으로 확장시키고, SpaceBetween이 “Row 폭 - 자식들의 총 폭”을 간격으로 분배하기 때문이다. fillMaxWidth가 없으면 Row 폭이 자식 총 폭에 수렴해서 남는 공간이 0이 되고, SpaceBetween은 아무 일도 못 한다.

컴포넌트 해부

Row는 겉으로는 단순하지만, 내부적으로는 Layout 컴포저블 + MeasurePolicy로 구성된 레이아웃이다. 파라미터 대부분이 “측정 결과를 어떻게 합산하고, 배치 좌표를 어떻게 계산할지”에 매달려 있다. Row에서 간격·정렬이 꼬일 때는 파라미터를 한 번에 다 바꾸지 말고, ‘부모가 Row에 준 폭이 얼마인지’와 ‘자식이 측정된 폭이 얼마인지’부터 확인해야 한다.

  • modifier: Row 자체에 적용되는 Modifier 체인. constraints를 바꾸는 노드가 여기 붙는다.
  • horizontalArrangement: 주축(가로)에서 남는 공간을 분배하는 정책. SpaceBetween/SpaceAround/SpaceEvenly/Center/Start/End 등.
  • verticalAlignment: 교차축(세로)에서 자식들을 어떤 기준으로 정렬할지 결정. Top/CenterVertically/Bottom 등.
  • content: 자식 슬롯. 컴파일러가 스킵/재실행 단위를 만드는 지점이 된다.
  • fillMaxWidth/fillMaxHeight: Row가 차지하는 크기를 부모 제약에 맞춰 확장. 남는 공간이 생겨 Arrangement가 의미를 가진다.
  • padding: Row의 내부 여백. Row의 측정/배치 좌표계 자체가 바뀐다.
  • size/width/height: Row의 측정 결과를 강제. 부모 제약과 충돌하면 강제로 잘리거나(clip) 오버플로우가 생긴다.
  • weight(자식 Modifier): Row가 자식 측정에 관여하는 힌트. 남는 공간을 특정 자식에게 할당한다.
  • align(자식 Modifier): Row의 verticalAlignment를 자식 단위로 오버라이드.
  • offset(자식 Modifier): 배치 이후 좌표를 이동. 정렬이 깨진 것처럼 보이지만 실제 측정은 그대로다.
  • clip/background/border: 그리기 단계에 주로 영향. 하지만 clip은 오버플로우를 숨겨 디버깅을 방해한다.
  • semantics/testTag: 레이아웃엔 영향이 없지만, Inspector에서 노드를 찾는 키가 된다. 디버깅용으로 중요하다.

Surface 계층(컨테이너)은 “크기와 배경, 터치 영역”을 책임진다. Row 자체는 컨테이너이자 레이아웃이므로, background/padding/size가 Row에 붙으면 곧바로 측정과 배치에 영향을 준다. 특히 padding은 constraints를 축소시킨 뒤 자식을 측정한다. 그래서 padding을 추가했는데 간격이 ‘늘어나는’ 게 아니라 ‘줄어드는’ 상황이 생긴다. SpaceBetween이 분배할 남는 공간이 padding만큼 사라지기 때문이다.

Content 계층(자식들)은 “자기 크기 결정”을 책임진다. Text는 글자 수와 폰트 메트릭으로 폭이 정해지고, Button은 최소 터치 영역과 내부 padding으로 폭이 정해진다. Row는 자식들의 측정값을 모아서 배치한다. 이때 자식 쪽에 size/widthIn/weight가 붙으면 Row의 계산이 달라진다. 문제가 생기면 Row 파라미터보다 자식 Modifier부터 의심하는 경우가 많다.

처음에 나도 SpaceBetween이 ‘항상’ 양끝 정렬을 해준다고 착각했다. Layout Inspector에서 Row 폭이 자식 폭과 같게 잡힌 걸 보고서야 이해했다. Row가 부모 폭을 차지하지 않으면 남는 공간이 없고, 남는 공간이 없으면 Arrangement는 움직일 여지가 없다. 이때는 Arrangement를 탓해도 해결이 안 된다.

EducationalRowShell.kt
1package com.example.rowdebug
2
3import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.fillMaxWidth
6import androidx.compose.foundation.layout.padding
7import androidx.compose.runtime.Composable
8import androidx.compose.ui.Modifier
9import androidx.compose.ui.layout.Layout
10import androidx.compose.ui.unit.Constraints
11import androidx.compose.ui.unit.dp
12
13@Composable
14private fun EducationalRowShell(
15    modifier: Modifier = Modifier,
16    horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
17    content: @Composable () -> Unit
18) {
19    Layout(
20        modifier = modifier,
21        content = content
22    ) { measurables, incoming: Constraints ->
23        val placeables = measurables.map { it.measure(incoming) }
24        val width = placeables.sumOf { it.width }.coerceIn(incoming.minWidth, incoming.maxWidth)
25        val height = placeables.maxOfOrNull { it.height }?.coerceIn(incoming.minHeight, incoming.maxHeight) ?: 0
26
27        val sizes = IntArray(placeables.size) { placeables[it].width }
28        val positions = IntArray(placeables.size)
29        horizontalArrangement.arrange(width, sizes, layoutDirection, positions)
30
31        layout(width, height) {
32            placeables.forEachIndexed { index, p ->
33                p.placeRelative(x = positions[index], y = 0)
34            }
35        }
36    }
37}
38
39@Composable
40private fun EducationalRowUsage() {
41    EducationalRowShell(
42        modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
43        horizontalArrangement = Arrangement.SpaceBetween
44    ) {
45        androidx.compose.material3.Text("A")
46        androidx.compose.material3.Text("B")
47        androidx.compose.material3.Text("C")
48    }
49}

이 재구성 코드는 Row의 핵심을 드러낸다. arrange()는 “최종 Row 폭(width)”과 “자식들의 폭 배열(sizes)”만 보고 x 좌표를 만든다. 즉, Arrangement는 자식 측정 이전에 아무것도 못 한다. 그래서 디버깅 순서가 ‘Arrangement 확인’이 아니라 ‘Row 폭이 얼마나 나오나’가 된다.

RealWorldRowSample.kt
1package com.example.rowdebug
2
3import androidx.compose.foundation.background
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.foundation.layout.fillMaxWidth
9import androidx.compose.material3.MaterialTheme
10import androidx.compose.material3.Surface
11import androidx.compose.material3.Text
12import androidx.compose.runtime.Composable
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
19private fun RealWorldRowSample() {
20    Surface(color = MaterialTheme.colorScheme.background) {
21        Row(
22            modifier = Modifier
23                .fillMaxWidth()
24                .background(Color(0xFF222222))
25                .padding(12.dp),
26            horizontalArrangement = Arrangement.SpaceBetween
27        ) {
28            Text(
29                text = "Title",
30                color = Color.White,
31                modifier = Modifier
32                    .background(Color(0xFF444444))
33                    .padding(horizontal = 8.dp, vertical = 4.dp)
34            )
35            Text(
36                text = "Action",
37                color = Color.White,
38                modifier = Modifier
39                    .size(width = 72.dp, height = 28.dp)
40                    .background(Color(0xFF666666))
41                    .padding(horizontal = 8.dp, vertical = 4.dp)
42            )
43        }
44    }
45}
46
47@Preview(showBackground = true, widthDp = 360)
48@Composable
49private fun RealWorldRowSamplePreview() {
50    RealWorldRowSample()
51}

여기서 두 번째 Text는 size 뒤에 padding이 붙어 있다. 실행하면 텍스트가 잘리거나 패딩이 적용되지 않은 것처럼 보인다. 이유는 size가 측정 결과를 72x28로 고정하고, 그 안에서 padding이 내부 콘텐츠를 더 줄이기 때문이다. padding을 size 앞에 두면 Row가 보는 자식 폭이 ‘패딩 포함’으로 커지고, Arrangement 결과도 달라진다. 같은 Modifier라도 순서가 곧 수학식이다.

내부 동작 원리

Compose는 Composition → Layout → Drawing 순서로 프레임을 만든다. Composition은 어떤 UI 트리를 만들지 결정하고, Layout은 각 노드의 크기/좌표를 계산하며, Drawing은 좌표에 픽셀을 찍는다. Row 간격·정렬 버그는 대부분 Layout 단계에서 이미 결정된다. Drawing 단계에서 background/clip을 바꿔도 ‘좌표’는 바뀌지 않는다.

Composition에서 Row 호출이 발생하면 Compose Compiler가 생성한 코드가 Composer에 group을 열고(키 기반), 파라미터 변경 여부를 추적하며, content 람다를 슬롯에 저장한다. Slot Table에는 ‘Row 그룹’과 ‘자식 그룹들’이 순서대로 쌓인다. Recomposition 때는 같은 호출 위치의 그룹을 찾아 파라미터가 바뀌었는지 비교하고, 바뀌지 않았으면 그 subtree를 스킵한다.

왜 Modifier가 체이닝 방식인가. Modifier는 immutable 연결 리스트에 가깝고, 각 노드는 LayoutModifierNode/DrawModifierNode/SemanticsModifierNode 같은 역할을 가진다. 체이닝은 “새 노드를 앞/뒤에 붙여 새로운 Modifier를 만든다”는 함수형 패턴이라, 스냅샷(State)과 궁합이 좋다. 대신 순서가 의미를 가진다. padding이 constraints를 줄인 뒤 size가 고정할지, size가 고정한 뒤 padding이 내부를 깎을지에 따라 결과가 달라진다.

Recomposition 범위를 이해하면 디버깅 속도가 빨라진다. Row 자체는 layout 노드지만, 간격이 꼬이는 원인이 state라면 recomposition이 어디까지 전파되는지 봐야 한다. 예를 들어 Text의 문자열이 바뀌면 Text 측정값이 바뀌고, Row는 다시 측정/배치한다. 반면 background 색만 바뀌면 측정은 그대로고 drawing만 바뀐다. Layout Inspector에서 ‘Recompose Counts’를 켜면 이 차이가 숫자로 보인다.

Slot Table 관점에서 Row의 content는 안정적인 호출 순서를 요구한다. if/for로 자식 개수가 바뀌면 그룹의 위치가 흔들리고, 기존 슬롯 재사용이 실패해 측정/배치가 요동칠 수 있다. Row 간격이 갑자기 튀는 버그를 ‘Arrangement가 불안정하다’고 오해하는 경우가 있는데, 실제로는 자식 구성의 키 안정성이 문제인 적이 있었다. LazyRow가 key를 요구하는 이유와 같은 맥락이다.

한 문단 요약이다. Row의 간격·정렬은 (1) Row가 실제로 차지한 폭, (2) 자식들이 측정된 폭, (3) Modifier 체인이 constraints/측정값을 어떻게 변형했는지, 이 3가지만 맞추면 재현·해결된다.

Row 디버깅은 Arrangement부터 보지 않는다. Row 폭(fillMaxWidth 등) → 자식 측정값(size/weight/padding 순서) → 그 다음에야 Arrangement/Alignment가 좌표를 만든다.

RecompositionTraceRow.kt
1package com.example.rowdebug
2
3import android.util.Log
4import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.fillMaxWidth
7import androidx.compose.material3.Button
8import androidx.compose.material3.Text
9import androidx.compose.runtime.Composable
10import androidx.compose.runtime.LaunchedEffect
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.tooling.preview.Preview
17
18@Composable
19private fun RecompositionTraceRow() {
20    var clicks by remember { mutableIntStateOf(0) }
21
22    Log.d("RowDebug", "RecompositionTraceRow composed, clicks=$clicks")
23
24    Row(
25        modifier = Modifier.fillMaxWidth(),
26        horizontalArrangement = Arrangement.SpaceBetween
27    ) {
28        Log.d("RowDebug", "Left Text composed")
29        Text("Clicks: $clicks")
30
31        Log.d("RowDebug", "Button composed")
32        Button(onClick = { clicks++ }) {
33            Text("+1")
34        }
35    }
36}
37
38@Preview(showBackground = true, widthDp = 360)
39@Composable
40private fun RecompositionTraceRowPreview() {
41    RecompositionTraceRow()
42}

이 코드를 실행하고 버튼을 누르면 Logcat에 같은 태그 로그가 반복해서 찍힌다. clicks가 바뀌면 Row 스코프에서 state를 읽고 있으므로 Row의 content 람다가 다시 실행된다. 다만 실제로는 ‘재호출’과 ‘재측정/재배치’가 항상 같은 빈도가 아니다. 텍스트 길이가 변하면 측정이 다시 필요하고, 길이가 같으면 배치만 바뀌거나 drawing만 바뀔 수 있다. Layout Inspector에서 각 노드의 size가 프레임마다 변하는지 확인하면 감이 잡힌다.

ModifierSemanticsInteractionRow.kt
1package com.example.rowdebug
2
3import androidx.compose.foundation.Interaction
4import androidx.compose.foundation.InteractionState
5import androidx.compose.foundation.background
6import androidx.compose.foundation.clickable
7import androidx.compose.foundation.interaction.MutableInteractionSource
8import androidx.compose.foundation.layout.Arrangement
9import androidx.compose.foundation.layout.Row
10import androidx.compose.foundation.layout.fillMaxWidth
11import androidx.compose.foundation.layout.padding
12import androidx.compose.material3.Text
13import androidx.compose.runtime.Composable
14import androidx.compose.runtime.remember
15import androidx.compose.ui.Modifier
16import androidx.compose.ui.graphics.Color
17import androidx.compose.ui.semantics.Role
18import androidx.compose.ui.semantics.contentDescription
19import androidx.compose.ui.semantics.semantics
20import androidx.compose.ui.tooling.preview.Preview
21import androidx.compose.ui.unit.dp
22
23@Composable
24private fun ModifierSemanticsInteractionRow() {
25    val interactionSource = remember { MutableInteractionSource() }
26
27    Row(
28        modifier = Modifier
29            .fillMaxWidth()
30            .semantics { contentDescription = "RowDebugContainer" }
31            .background(Color(0xFFEAEAEA))
32            .clickable(
33                interactionSource = interactionSource,
34                indication = null,
35                role = Role.Button,
36                onClick = {}
37            )
38            .padding(16.dp),
39        horizontalArrangement = Arrangement.SpaceBetween
40    ) {
41        Text("Tap area is the whole Row")
42        Text("No ripple")
43    }
44}
45
46@Preview(showBackground = true, widthDp = 360)
47@Composable
48private fun ModifierSemanticsInteractionRowPreview() {
49    ModifierSemanticsInteractionRow()
50}

여기서 clickable이 padding 앞에 붙어 있다. 실행하면 탭 영역이 padding 포함 전체 Row로 잡힌다. padding 뒤에 clickable을 두면 탭 영역이 안쪽 콘텐츠 영역으로 줄어든다. semantics도 같은 원리로 붙는 위치에 따라 접근성 트리에서 경계가 달라진다. ‘정렬이 꼬였다’고 느끼는 사례 중 일부는 사실 터치 영역/semantics 경계가 기대와 달라서 생긴 착시다.

실습하기

실습 목표는 두 가지다. 첫째, Row 폭과 자식 폭이 실제로 어떻게 잡히는지 눈으로 확인한다. 둘째, Modifier 순서를 바꿨을 때 ‘측정값’이 바뀌는지 ‘그리기만’ 바뀌는지 분리해서 본다. 화면에 경계 박스를 칠하고, 각 단계에서 Layout Inspector로 size/position을 확인하면 된다.

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

Compose BOM을 쓰면 라이브러리 버전 불일치를 줄일 수 있다. 실습 중 Layout Inspector를 쓸 거라 debugImplementation의 ui-tooling이 필요하다. compiler extension 버전이 프로젝트의 Kotlin/AGP 조합과 맞지 않으면 빌드가 깨지는데, 이 글의 코드는 Row 자체를 건드리는 게 아니라 레이아웃 디버깅이 목적이라 최신 안정 조합을 쓰는 편이 시간을 아낀다.

1단계: Row 폭이 없으면 SpaceBetween이 멈춘다

첫 단계는 SpaceBetween이 ‘작동하지 않는’ 상태를 의도적으로 만든다. 실행하면 텍스트 두 개가 서로 붙어 보인다. 이때 Arrangement를 바꿔도 화면이 거의 안 변한다. Row 폭이 자식 폭 합과 같아서 남는 공간이 0이기 때문이다.

Layout Inspector에서 Row 노드를 선택하고 width를 확인한다. fillMaxWidth를 추가한 뒤 다시 확인하면 width가 부모 폭으로 늘어나고, 그 순간 SpaceBetween이 바로 눈에 띄게 작동한다. 디버깅할 때 가장 먼저 확인할 포인트다.

MainActivityStep1.kt
1package com.example.rowdebug
2
3import android.os.Bundle
4import androidx.activity.ComponentActivity
5import androidx.activity.compose.setContent
6import androidx.compose.foundation.background
7import androidx.compose.foundation.layout.Arrangement
8import androidx.compose.foundation.layout.Column
9import androidx.compose.foundation.layout.Row
10import androidx.compose.foundation.layout.padding
11import androidx.compose.material3.MaterialTheme
12import androidx.compose.material3.Surface
13import androidx.compose.material3.Text
14import androidx.compose.runtime.Composable
15import androidx.compose.ui.Modifier
16import androidx.compose.ui.graphics.Color
17import androidx.compose.ui.unit.dp
18
19class MainActivity : ComponentActivity() {
20    override fun onCreate(savedInstanceState: Bundle?) {
21        super.onCreate(savedInstanceState)
22        setContent { MaterialTheme { DemoStep1() } }
23    }
24}
25
26@Composable
27private fun DemoStep1() {
28    Surface {
29        Column(modifier = Modifier.padding(16.dp)) {
30            Text("No fillMaxWidth")
31            Row(
32                modifier = Modifier
33                    .background(Color(0xFFEFEFEF))
34                    .padding(12.dp),
35                horizontalArrangement = Arrangement.SpaceBetween
36            ) {
37                Text("Left")
38                Text("Right")
39            }
40        }
41    }
42}

이 화면에서 Row는 콘텐츠 크기만큼만 차지한다. SpaceBetween이 분배할 남는 공간이 0이라서 좌표 배열이 사실상 Start와 동일하게 나온다. 같은 코드를 두고 “왜 SpaceBetween이 버그냐”는 질문이 나오기 쉬운데, Row의 최종 폭이 문제다.

2단계: 상태 변화가 측정값을 바꾸면 배치가 튄다

두 번째 단계는 텍스트 길이가 바뀌면서 자식 측정값이 변하는 상황을 만든다. 버튼을 누르면 왼쪽 텍스트가 길어지고, 오른쪽 텍스트가 밀리거나 잘리는 현상이 나온다. 이때는 Arrangement가 아니라 ‘자식 폭 합’이 Row 폭을 압박하는 게 원인이다.

실행 중에 Logcat을 같이 보면, 클릭마다 composable이 재호출되는 것과 동시에 레이아웃이 다시 잡히는 걸 체감한다. Layout Inspector에서 Text의 measured width가 클릭마다 커지는지 확인하면 “정렬이 꼬임”이 사실은 재측정의 결과라는 걸 확인하게 된다.

DemoStep2.kt
1package com.example.rowdebug
2
3import androidx.compose.foundation.background
4import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.fillMaxWidth
7import androidx.compose.foundation.layout.padding
8import androidx.compose.material3.Button
9import androidx.compose.material3.Text
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.graphics.Color
17import androidx.compose.ui.tooling.preview.Preview
18import androidx.compose.ui.unit.dp
19
20@Composable
21private fun DemoStep2() {
22    var n by remember { mutableIntStateOf(0) }
23
24    Row(
25        modifier = Modifier
26            .fillMaxWidth()
27            .background(Color(0xFFEFEFEF))
28            .padding(12.dp),
29        horizontalArrangement = Arrangement.SpaceBetween
30    ) {
31        Text("Left: " + "X".repeat(n))
32        Button(onClick = { n = (n + 3).coerceAtMost(30) }) {
33            Text("Grow")
34        }
35    }
36}
37
38@Preview(showBackground = true, widthDp = 360)
39@Composable
40private fun DemoStep2Preview() {
41    DemoStep2()
42}

여기서 recomposition은 n을 읽는 Row content 전체에 걸린다. Slot Table에는 Row 그룹과 그 자식(Text, Button, Button의 Text)이 고정 순서로 저장되어 있어서, n 변경 시 같은 그룹을 찾아 다시 실행한다. 텍스트 길이가 바뀌면 Text의 intrinsic 측정 결과가 달라지고, Row는 그 결과를 바탕으로 다시 arrange()를 돌린다. ‘정렬이 튄다’는 느낌은 좌표가 다시 계산된 결과다.

3단계: Modifier 순서를 바꿔 측정 단계가 달라지는 걸 확인한다

세 번째 단계는 같은 UI를 두 줄로 놓고, Modifier 순서만 바꿔 비교한다. 실행하면 위/아래 Row의 간격과 텍스트 잘림이 다르게 나온다. 특히 size와 padding의 위치가 바뀌면 자식의 측정값이 달라지고, SpaceBetween이 계산하는 남는 공간도 달라진다.

이 단계에서 중요한 관찰 포인트는 ‘Row의 폭은 동일한데 자식 폭이 달라진다’는 점이다. Arrangement는 단지 좌표를 계산할 뿐이고, 실제 문제는 Modifier가 자식 측정값을 어떻게 만들었는지에 있다. Layout Inspector에서 각 Text의 width를 비교하면 원인이 숫자로 보인다.

DemoStep3.kt
1package com.example.rowdebug
2
3import androidx.compose.foundation.background
4import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.fillMaxWidth
8import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.size
10import androidx.compose.material3.Text
11import androidx.compose.runtime.Composable
12import androidx.compose.ui.Modifier
13import androidx.compose.ui.graphics.Color
14import androidx.compose.ui.tooling.preview.Preview
15import androidx.compose.ui.unit.dp
16
17@Composable
18private fun DemoStep3() {
19    Column(modifier = Modifier.padding(16.dp)) {
20        Text("size -> padding")
21        Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
22            Text(
23                "Chip",
24                modifier = Modifier
25                    .size(width = 72.dp, height = 28.dp)
26                    .background(Color(0xFFB3E5FC))
27                    .padding(horizontal = 12.dp, vertical = 6.dp)
28            )
29            Text("Right")
30        }
31
32        Text("padding -> size")
33        Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
34            Text(
35                "Chip",
36                modifier = Modifier
37                    .padding(horizontal = 12.dp, vertical = 6.dp)
38                    .background(Color(0xFFB2DFDB))
39                    .size(width = 72.dp, height = 28.dp)
40            )
41            Text("Right")
42        }
43    }
44}
45
46@Preview(showBackground = true, widthDp = 360)
47@Composable
48private fun DemoStep3Preview() {
49    DemoStep3()
50}

첫 줄은 size가 먼저 자식의 외곽 크기를 고정하고, padding이 내부 콘텐츠를 더 깎는다. 두 번째 줄은 padding이 먼저 측정에 개입해 외곽을 키운 뒤, size가 다시 고정한다. 둘 다 72x28로 보일 수 있지만, 텍스트 베이스라인과 클리핑, 그리고 Row가 인식하는 ‘자식 폭’이 달라져 SpaceBetween의 결과가 변한다.

심화: Advanced 버전 만들기

실무에서 Row는 단순 배치 이상으로 쓰인다. 툴바, 리스트 아이템, 버튼 바 같은 곳에서 ‘아이콘+텍스트+로딩+접근성’ 요구가 한꺼번에 온다. 이때 정렬이 깨지는 이유는 대개 기능 요구 때문에 Modifier와 state가 늘어나면서, 측정/배치에 영향을 주는 노드가 무심코 섞이기 때문이다.

사례 1: weight와 SpaceBetween을 같이 쓰면 간격이 사라진다

Row에서 SpaceBetween이 안 먹는다고 느낄 때, 자식에 weight가 들어가 있는 경우가 많다. weight는 남는 공간을 자식 폭으로 ‘흡수’한다. 남는 공간이 자식 폭으로 변환되면, Arrangement가 분배할 남는 공간이 줄거나 0이 된다. SpaceBetween이 멈춘 게 아니라, 남는 공간이 이미 소비된 상태다.

처음에 이 조합으로 3시간 삽질한 적이 있다. 디자이너 시안은 “좌측 타이틀, 우측 배지, 그 사이 여백 자동”이었고, 나는 타이틀에 weight(1f)를 줬다. 결과는 배지가 오른쪽 끝으로 붙지 않고 타이틀 옆에 달라붙었다. Layout Inspector에서 타이틀 width가 Row 폭 대부분을 먹고 있는 걸 보고서야 ‘SpaceBetween이 쓸 여백이 없어졌다’는 걸 인정했다.

사례 2: clickable/semantics 위치가 터치 영역과 접근성 읽기 순서를 바꾼다

툴바 Row에 clickable을 붙일 때 padding 앞/뒤는 UX를 바꾼다. padding 뒤에 clickable을 붙이면 터치 영역이 작아져 ‘정렬이 이상하다’는 피드백이 온다. 실제로는 정렬이 아니라 터치 히트박스가 기대보다 작아서 사용자가 다른 곳을 누르게 된다.

접근성도 비슷하다. contentDescription을 Row에 줄지, 아이콘에 줄지에 따라 TalkBack이 읽는 범위가 달라진다. semantics가 레이아웃을 바꾸진 않지만, 디버깅할 때 노드를 찾는 기준이 되므로 testTag/semantics를 의도적으로 배치하는 습관이 필요하다.

AdvancedRowActionButton.kt
1package com.example.rowdebug
2
3import android.os.SystemClock
4import androidx.compose.foundation.combinedClickable
5import androidx.compose.foundation.interaction.MutableInteractionSource
6import androidx.compose.foundation.layout.Arrangement
7import androidx.compose.foundation.layout.Row
8import androidx.compose.foundation.layout.Spacer
9import androidx.compose.foundation.layout.fillMaxWidth
10import androidx.compose.foundation.layout.padding
11import androidx.compose.foundation.layout.size
12import androidx.compose.material3.CircularProgressIndicator
13import androidx.compose.material3.Icon
14import androidx.compose.material3.MaterialTheme
15import androidx.compose.material3.Surface
16import androidx.compose.material3.Text
17import androidx.compose.runtime.Composable
18import androidx.compose.runtime.MutableLongState
19import androidx.compose.runtime.getValue
20import androidx.compose.runtime.mutableLongStateOf
21import androidx.compose.runtime.remember
22import androidx.compose.runtime.setValue
23import androidx.compose.ui.Alignment
24import androidx.compose.ui.Modifier
25import androidx.compose.ui.graphics.vector.ImageVector
26import androidx.compose.ui.semantics.Role
27import androidx.compose.ui.semantics.contentDescription
28import androidx.compose.ui.semantics.semantics
29import androidx.compose.ui.unit.dp
30
31@Composable
32fun AdvancedRowActionButton(
33    text: String,
34    icon: ImageVector?,
35    loading: Boolean,
36    enabled: Boolean,
37    debounceMs: Long = 600L,
38    a11yLabel: String = text,
39    onClick: () -> Unit,
40    onLongClick: (() -> Unit)? = null
41) {
42    var lastClickAt by remember { mutableLongStateOf(0L) }
43    val interactionSource = remember { MutableInteractionSource() }
44
45    val clickableModifier = Modifier.combinedClickable(
46        enabled = enabled && !loading,
47        role = Role.Button,
48        interactionSource = interactionSource,
49        indication = null,
50        onLongClick = onLongClick,
51        onClick = {
52            val now = SystemClock.elapsedRealtime()
53            if (now - lastClickAt >= debounceMs) {
54                lastClickAt = now
55                onClick()
56            }
57        }
58    )
59
60    Surface(
61        shape = MaterialTheme.shapes.medium,
62        color = MaterialTheme.colorScheme.primary,
63        contentColor = MaterialTheme.colorScheme.onPrimary,
64        modifier = Modifier
65            .semantics { contentDescription = a11yLabel }
66            .then(clickableModifier)
67    ) {
68        Row(
69            modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
70            verticalAlignment = Alignment.CenterVertically,
71            horizontalArrangement = Arrangement.spacedBy(8.dp)
72        ) {
73            if (loading) {
74                CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
75            } else if (icon != null) {
76                Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(18.dp))
77            } else {
78                Spacer(modifier = Modifier.size(18.dp))
79            }
80            Text(text)
81        }
82    }
83}

이 컴포넌트는 Row의 정렬 문제를 ‘기능 요구’ 속에서 통제하는 예다. spacedBy는 SpaceBetween보다 예측 가능하다. SpaceBetween은 남는 공간의 함수라서 텍스트 길이가 변하면 간격이 같이 흔들린다. 반면 spacedBy는 간격이 고정이라서 측정값 변화가 있어도 레이아웃이 덜 요동친다. debounce는 state(lastClickAt)를 가지므로 recomposition을 유발할 수 있는데, 클릭 시점에만 바뀌고 UI 측정값은 변하지 않으니 레이아웃 비용은 제한적이다.

AdvancedButtonUsage.kt
1package com.example.rowdebug
2
3import androidx.compose.foundation.layout.Column
4import androidx.compose.foundation.layout.padding
5import androidx.compose.material.icons.Icons
6import androidx.compose.material.icons.filled.Favorite
7import androidx.compose.material3.MaterialTheme
8import androidx.compose.material3.Surface
9import androidx.compose.material3.Text
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.tooling.preview.Preview
17import androidx.compose.ui.unit.dp
18
19@Composable
20private fun AdvancedButtonUsage() {
21    var count by remember { mutableIntStateOf(0) }
22
23    Surface(color = MaterialTheme.colorScheme.background) {
24        Column(modifier = Modifier.padding(16.dp)) {
25            Text("Clicked: $count")
26            AdvancedRowActionButton(
27                text = "Favorite",
28                icon = Icons.Filled.Favorite,
29                loading = false,
30                enabled = true,
31                a11yLabel = "Favorite button",
32                onClick = { count++ },
33                onLongClick = { count += 10 }
34            )
35        }
36    }
37}
38
39@Preview(showBackground = true, widthDp = 360)
40@Composable
41private fun AdvancedButtonUsagePreview() {
42    AdvancedButtonUsage()
43}

실행하면 버튼 내부 아이콘과 텍스트가 8dp 간격으로 고정되고, 길게 누르면 10씩 증가한다. 여기서 내가 한 번 크게 데인 포인트가 있다. 예전에는 clickable을 Row의 padding 뒤에 붙여서 히트박스가 콘텐츠만큼만 잡혔다. QA에서 “버튼이 잘 안 눌린다”가 올라왔고, 실제 로그에는 아무것도 안 찍혔다. 원인은 정렬이 아니라 터치 영역이었다. combinedClickable을 Surface에 붙이고, 그 안에서 padding으로 시각적 여백을 만든 뒤, semantics까지 Surface에 붙여 히트박스/접근성 경계를 일치시켰다.

자주 하는 실수

1) SpaceBetween을 줬는데 아이템이 붙어 있다

증상: horizontalArrangement = SpaceBetween인데도 자식들이 서로 붙거나 거의 차이가 없다. Start/Center로 바꿔도 비슷해 보여서 ‘Arrangement가 안 먹는다’고 느낀다.

원인: Row의 최종 폭이 자식 폭 합과 비슷하다. fillMaxWidth가 없거나, 부모가 Row에 타이트한 constraints를 주는 상황(예: Row가 Column의 wrapContent 폭 안에 들어감)이다. 남는 공간이 0이면 SpaceBetween도 0을 분배한다.

해결: Layout Inspector에서 Row width를 확인하고, 필요하면 Row에 fillMaxWidth 또는 부모 쪽에 width 제약을 준다. 디버깅 중에는 background를 Row에 붙여 폭을 눈으로 확인한다.

2) size와 padding 순서 때문에 텍스트가 잘린다

증상: Chip처럼 보이게 만들려고 size를 줬는데 텍스트가 잘리거나, padding이 적용되지 않은 것처럼 보인다. 특히 작은 height에서 자주 터진다.

원인: size가 측정 결과를 고정한 뒤 padding이 내부 콘텐츠 영역을 더 줄인다. Text는 남은 영역에 맞춰 줄바꿈/클리핑을 하면서 시각적으로 깨진다. 반대로 padding 뒤에 size를 두면 padding 포함 외곽을 size가 다시 잘라버릴 수 있다.

해결: “외곽 크기 고정”이 목적이면 size를 마지막에 두고, “콘텐츠 주변 여백 확보”가 목적이면 padding을 마지막에 둔다. 둘을 동시에 만족시키려면 heightIn/minHeight, widthIn 같은 범위 제약을 쓰고 clip 여부를 명시한다.

3) weight와 SpaceBetween을 같이 써서 간격이 사라진다

증상: 타이틀에 weight를 줬더니 오른쪽 액션이 끝으로 안 가고, SpaceBetween이 무력화된 것처럼 보인다. 또는 아이템 간격이 화면 크기에 따라 요동친다.

원인: weight는 남는 공간을 자식 폭으로 흡수한다. Row 폭에서 고정 폭 자식들을 빼고 남은 공간이 weight 자식의 폭으로 들어가면, Arrangement가 분배할 남는 공간이 줄거나 0이 된다.

해결: SpaceBetween 대신 Arrangement.spacedBy로 고정 간격을 쓰거나, weight를 주되 오른쪽 아이템을 align/Box로 분리한다. “남는 공간을 분배”와 “남는 공간을 소비”를 동시에 걸면 결과가 기대와 달라진다.

4) clickable/semantics 위치가 달라서 UX가 깨진다

증상: 버튼처럼 보이는데 잘 안 눌린다. TalkBack이 읽는 범위가 어색하거나, 테스트에서 노드를 못 찾는다. 시각적 정렬 문제처럼 보고되기도 한다.

원인: clickable을 padding 뒤에 붙여 히트박스가 작아졌거나, semantics를 자식에만 달아 컨테이너 경계와 접근성 경계가 불일치한다. Layout은 같아도 입력/접근성 트리가 달라진다.

해결: 터치 영역을 컨테이너(Surface/Box/Row)에 붙이고, 그 안에서 padding으로 시각적 여백을 만든다. semantics/testTag는 Inspector와 테스트에서 찾는 기준이므로 컨테이너에 두는 편이 디버깅이 쉽다.

5) 조건부 자식 추가로 Slot 재사용이 흔들려 레이아웃이 튄다

증상: 로딩 스피너를 if로 넣었다 뺐다 하면 정렬이 순간적으로 튀거나, 애니메이션이 끊긴다. 같은 Row인데도 상태 전환 시 폭이 갑자기 바뀐다.

원인: 자식 구성 순서가 바뀌면 Slot Table에서 그룹 매칭이 달라지고, remember된 값이나 측정 캐시가 기대와 다르게 재사용될 수 있다. 특히 아이콘/스피너를 교체할 때 폭이 달라지면 더 크게 튄다.

해결: 자리 고정을 위해 Spacer로 동일한 폭을 유지하거나, 항상 같은 위치에 노드를 두고 내부만 바꾼다(예: Box 안에서 로딩/아이콘 교체). 리스트라면 key를 안정적으로 준다.

StableSlotLeading.kt
1package com.example.rowdebug
2
3import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.Spacer
6import androidx.compose.foundation.layout.size
7import androidx.compose.material3.CircularProgressIndicator
8import androidx.compose.material3.Icon
9import androidx.compose.material3.Text
10import androidx.compose.runtime.Composable
11import androidx.compose.ui.Modifier
12import androidx.compose.ui.graphics.vector.ImageVector
13import androidx.compose.ui.unit.dp
14
15@Composable
16fun StableSlotLeading(
17    loading: Boolean,
18    icon: ImageVector?,
19    text: String
20) {
21    Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
22        if (loading) {
23            CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
24        } else if (icon != null) {
25            Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(18.dp))
26        } else {
27            Spacer(modifier = Modifier.size(18.dp))
28        }
29        Text(text)
30    }
31}

이 패턴은 리컴포지션 자체를 줄이기 위한 게 아니라, 레이아웃 튐을 줄이기 위한 자리 고정이다. 아이콘/로딩/없음 상태가 바뀌어도 leading 영역의 측정값이 18dp로 유지되면, Row의 자식 폭 합이 급변하지 않아서 배치 좌표가 덜 흔들린다.

성능 최적화 체크리스트

  • Row에서 SpaceBetween이 기대대로 동작하지 않으면 먼저 Row의 width를 확인한다(Inspector에서 measured width).
  • Row가 부모 폭을 차지해야 하는 요구라면 modifier에 fillMaxWidth를 명시하고, background로 경계를 시각화한다.
  • Arrangement는 ‘남는 공간’을 분배한다. weight가 남는 공간을 소비하는지부터 점검한다.
  • Modifier 순서 점검: size/requiredSize가 padding 앞에 있으면 내부 콘텐츠가 잘릴 가능성이 커진다.
  • Modifier 순서 점검: clickable/semantics가 padding 앞에 있으면 히트박스/접근성 경계가 커지고, 뒤에 있으면 작아진다.
  • 자식의 widthIn/heightIn, minHeight가 터치 최소 크기와 충돌하는지 확인한다(특히 버튼/칩).
  • Row 안에서 조건부로 자식을 추가/삭제하면 폭이 튈 수 있다. 동일 폭 Spacer로 자리 고정을 검토한다.
  • Text 길이 변화가 잦으면 SpaceBetween 대신 spacedBy + weight 조합으로 ‘간격 고정/확장 영역 분리’를 고려한다.
  • clip을 임시로 제거하고 오버플로우를 노출시킨 뒤 원인을 찾는다(clip이 디버깅을 가린다).
  • Layout Inspector에서 각 자식의 measured width/height와 position을 캡처해 전후 비교한다(말로 추측하지 않는다).
  • Recomposition Counts를 켜고, state 변경이 Row 전체를 재호출하는지 특정 자식만 재호출하는지 확인한다.
  • 의도치 않은 객체 할당을 줄이려면 Arrangement.spacedBy(8.dp) 같은 값은 매 프레임 생성되지 않는지 프로파일링한다(대부분은 상수지만 람다 캡처가 섞이면 달라진다).

자주 묻는 질문

Q1. SpaceBetween을 줬는데도 양끝 정렬이 안 된다. 진짜 버그가 아닌가?

대부분 버그가 아니라 Row 폭이 기대만큼 크지 않은 상태다. SpaceBetween은 “Row의 최종 폭 - 자식들의 총 폭”을 남는 공간으로 보고 그걸 간격으로 나눈다. Row가 wrapContent처럼 동작하면 남는 공간이 0이라 간격도 0이다. Layout Inspector에서 Row의 measured width를 확인하고, 부모가 주는 constraints가 타이트한지(예: Row가 또 다른 Row/Box의 wrapContent 안에 있는지) 확인한다. 해결은 Row에 fillMaxWidth를 주거나, 부모 레이아웃에서 Row가 확장될 수 있게 width 제약을 바꾸는 쪽이다. 키워드는 Constraints, measure, fillMaxWidth다.

Q2. padding을 추가했더니 간격이 더 벌어질 줄 알았는데 오히려 좁아졌다.

padding은 ‘바깥 여백’이 아니라 Row 내부의 공간을 깎는 동작이다. Row에 padding을 붙이면 Row는 먼저 incoming constraints를 padding만큼 줄인 뒤 자식들을 측정하고, 배치할 때 다시 padding만큼 원점을 이동시킨다. 결과적으로 자식들이 사용할 수 있는 폭이 줄어들고, SpaceBetween이 분배할 남는 공간도 줄어든다. 바깥 여백처럼 쓰고 싶다면 Row를 감싸는 Box/Surface에 padding을 주거나, Row에 background를 주고 그 바깥에 padding을 두는 구조로 분리한다. 키워드는 padding이 constraints를 줄인다는 점과 컨테이너 분리다.

Q3. size와 padding 순서가 왜 그렇게 중요하나? 둘 다 결국 크기 조절 아닌가?

둘은 크기 조절이지만 적용 시점이 다르다. size는 측정 결과를 특정 값으로 고정하려는 성격이 강하고, padding은 측정 전에 constraints를 줄여서 ‘자식에게 주는 공간’을 바꾼다. size → padding이면 외곽이 먼저 고정되고 내부 콘텐츠 공간이 더 줄어 클리핑/줄바꿈이 발생하기 쉽다. padding → size면 padding을 포함한 외곽을 다시 잘라버릴 수 있다. Row의 Arrangement는 자식의 측정된 width 배열을 입력으로 쓰므로, 이 순서 차이가 곧 간격 계산의 입력을 바꾼다. 키워드는 measure 단계와 Modifier 체인의 순서다.

Q4. weight를 줬더니 SpaceBetween이 죽었다. 둘을 같이 쓰면 안 되나?

같이 쓸 수는 있지만 의도를 분리해야 한다. SpaceBetween은 남는 공간을 ‘간격’으로 분배하고, weight는 남는 공간을 ‘자식 폭’으로 흡수한다. 같은 남는 공간을 두 정책이 경쟁하면 SpaceBetween이 쓸 여백이 사라진다. 실무에서는 (1) 간격은 fixed로 두고(spacedBy), (2) 늘어날 영역은 weight로 지정하는 조합이 예측 가능하다. 예: 왼쪽 타이틀 Text에 weight(1f)를 주고, 오른쪽 아이콘은 고정 폭, 간격은 spacedBy(8.dp). 키워드는 남는 공간의 소비 vs 분배다.

Q5. Row 정렬 문제를 Slot Table이나 Recomposition 관점에서 왜 봐야 하나?

겉으로는 레이아웃 문제지만, 원인이 state/구성 변화인 경우가 많기 때문이다. 예를 들어 로딩 상태에서 아이콘을 제거했다가 다시 넣으면 자식 트리 구조가 바뀌고, Composer는 Slot Table에서 그룹을 매칭해 재사용한다. 호출 순서가 흔들리면 remember 값이 다른 자식에 붙는 식의 ‘위치 기반’ 문제가 생길 수 있고, 그 결과 측정값이 순간적으로 달라져 정렬이 튄다. 해결은 자식 순서를 안정화(항상 leading 자리를 유지, Spacer로 폭 고정)하거나, 리스트라면 key를 안정적으로 주는 방식이다. 키워드는 group, slot, 안정적인 호출 순서다.

Q6. Layout Inspector에서 보이는 값과 실제 동작이 다르게 느껴진다. 무엇을 믿어야 하나?

Inspector는 ‘측정/배치 결과’를 보여주고, 사용자가 체감하는 건 ‘그림 + 입력 + 접근성’까지 합친 결과다. 예를 들어 offset은 배치 이후 좌표를 이동시키므로, Inspector에선 노드 크기가 정상인데 화면에선 겹쳐 보일 수 있다. clickable/semantics 위치는 레이아웃 크기를 바꾸지 않지만 히트박스와 TalkBack 범위를 바꿔서 “정렬이 이상하다”는 피드백을 만든다. 디버깅할 때는 (1) measured size/position, (2) clip 여부, (3) 터치 영역(개발자 옵션의 포인터 위치 표시), (4) TalkBack 포커스 순서까지 같이 본다. 키워드는 layout vs input/semantics 분리다.

Q7. Modifier 체인이 성능에 어떤 영향을 주나? Row 정렬 디버깅이 성능과도 연결되나?

연결된다. Modifier 노드가 늘면 측정/배치 단계에서 호출 체인이 길어지고, 특히 레이아웃을 바꾸는 Modifier(padding, size, offset, weight 관련)가 많을수록 measure pass 비용이 커진다. 또 state 변경으로 Text 길이가 자주 바뀌면 매 프레임 재측정이 발생해 jank가 생길 수 있다. 측정은 그리기보다 비싼 편이라, 간격을 SpaceBetween(남는 공간 함수)로 두면 콘텐츠 폭 변화에 따라 좌표가 계속 바뀐다. 고정 간격(spacedBy) + 확장 영역(weight)로 바꾸면 재측정은 여전히 필요해도 배치 변동 폭을 줄일 수 있다. 측정은 Android Studio Profiler의 System Trace(Choreographer 프레임)와 Compose Layout Inspector, 그리고 필요하면 androidx.tracing으로 구간을 찍어 확인한다. 키워드는 measure cost, recomposition vs remeasure, tracing이다.

관련 글

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

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

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

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

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

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

21. Compose Column/Row 레이아웃 기초: 왜 이렇게 배치되고 다시 그려지나
Compose 기본2026.03.04

21. Compose Column/Row 레이아웃 기초: 왜 이렇게 배치되고 다시 그려지나

Jetpack Compose Column/Row 배치 규칙을 내부 동작(컴파일러, Slot Table, recomposition) 관점에서 설명하고, 상태/Modifier/성능 함정까지 실습 코드로 연결한다. 140~160자 맞춤 설명 문장이다!?!?