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

9. Compose Row의 weight·정렬로 반응형 레이아웃을 만드는 이유와 내부 동작

Jetpack Compose Row에서 weight, Arrangement, Alignment가 반응형 레이아웃을 만드는 원리와 내부 동작(측정/배치, Slot Table, Recomposition)을 코드로 추적한다. 초보가 자주 겪는 오해와 성능 함정까지 다룬다.

9. Compose Row의 weight·정렬로 반응형 레이아웃을 만드는 이유와 내부 동작

Compose Row의 weight·정렬로 반응형 레이아웃을 만드는 이유와 내부 동작

에뮬레이터에서만 예쁘던 Row가 실제 기기에서 깨지는 순간이 온다. 텍스트가 길어지면 버튼이 화면 밖으로 밀리고, 아이콘이 찌그러지고, 남는 공간이 한쪽에 몰린다. 초보가 가장 먼저 붙잡는 건 weight인데, 같은 weight를 줬는데도 어떤 날은 균등 분배가 되고 어떤 날은 줄바꿈이 생긴다. 이 차이는 Row가 “측정(Measure)” 단계에서 자식을 두 번에 나눠 재측정하고, 정렬 파라미터가 배치(Placement)에서만 작동하기 때문에 생긴다. 이 글은 그 과정을 코드 실행 결과로 확인시키는 데 초점을 둔다.

핵심 개념

Row에서 반응형을 만든다는 말은 “화면 폭이 바뀌어도 의미 있는 우선순위로 공간을 나눈다”는 뜻이다. View 시스템에서는 LinearLayout의 layout_weight, ConstraintLayout의 체인/가이드라인으로 이 문제를 풀었다. Compose는 Row + Modifier.weight + Arrangement/Alignment로 같은 문제를 풀지만, 설계 의도가 다르다. Row는 단순 컨테이너이고, 공간 분배는 Modifier가 들고 있는 ParentData로 전달된다. 이 분리 덕분에 같은 weight 개념이 Column, FlowRow, 커스텀 레이아웃에도 재사용된다.

용어를 사용 맥락으로 정의한다. Constraints는 부모가 자식에게 주는 “허용 크기 범위”이고, Measure는 그 범위 안에서 자식이 원하는 크기를 계산하는 단계다. Place는 계산된 Placeable을 실제 좌표에 놓는 단계다. weight는 Measure 단계에서만 의미가 있으며, 남는 폭을 가중치 비율로 재분배하기 위한 힌트다. Arrangement는 Place 단계에서 남는 공간을 어디에 둘지 결정한다. Alignment(정확히는 Row의 verticalAlignment)는 교차축(세로) 배치 기준이다.

왜 weight가 Modifier인가가 핵심이다. Row의 파라미터로 weight를 넣으면 Row의 API가 자식 수만큼 복잡해진다. Compose는 “부모가 해석할 수 있는 메타데이터”를 Modifier에 붙여 전달한다. Row는 자식의 Modifier 체인에서 RowScopeParentData(개념적 이름)를 읽고, 그 값이 있으면 weight child로 분류한다. 이 방식은 Slot API를 유지하면서도 레이아웃 규칙을 확장 가능하게 만든다.

처음에 나도 weight가 Arrangement와 같은 레벨에서 동작한다고 착각했다. 그래서 SpaceBetween을 주면 weight가 무시되는 줄 알았다. Layout Inspector로 보면 Row의 bounds는 멀쩡한데, 실제로는 텍스트가 잘리고 클릭 영역이 줄어드는 현상이 생겼다. 원인은 “측정은 weight로, 배치는 Arrangement로”라는 단계 분리가 머릿속에 없어서였다. 이 글은 그 분리를 강제로 체감시키는 실습을 포함한다.

BasicWeightRow.kt
1package com.example.rowweight
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.foundation.layout.width
9import androidx.compose.material3.MaterialTheme
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
18fun BasicWeightRow() {
19    Row(
20        modifier = Modifier.fillMaxWidth().padding(16.dp),
21        horizontalArrangement = Arrangement.spacedBy(8.dp)
22    ) {
23        Text("A", modifier = Modifier.background(Color(0xFFE3F2FD)).weight(1f))
24        Text("B", modifier = Modifier.background(Color(0xFFFFF3E0)).weight(2f))
25        Text("fixed", modifier = Modifier.background(Color(0xFFE8F5E9)).width(72.dp))
26    }
27}
28
29@Preview(showBackground = true, widthDp = 360)
30@Composable
31private fun BasicWeightRowPreview() {
32    MaterialTheme { BasicWeightRow() }
33}

이 코드를 실행하면 A 영역이 B의 절반 폭으로 늘어나고, fixed는 항상 72dp로 유지된다. 중요한 관찰 포인트는 spacedBy(8.dp)가 “남는 공간 분배”가 아니라 “자식 사이 간격을 먼저 예약”한다는 점이다. Row는 먼저 고정 폭(fixed, weight 없는 자식)을 측정하고, 그 다음 남은 폭에서 간격(spacing)을 빼고, 마지막으로 weight 자식들을 비율로 재측정한다. 그래서 spacing 값이 커지면 weight 자식이 받는 폭이 줄어든다.

컴포넌트 해부

Row는 표면(Surface) 컴포넌트가 아니라 레이아웃 컴포넌트다. 클릭/리플/그림자 같은 “표면” 기능이 필요하면 Surface/Card 같은 래퍼가 따로 필요하다. Row 자체는 Layout을 호출해 MeasurePolicy를 제공하고, 자식의 Modifier에서 ParentData를 읽어 배치 규칙을 만든다. 그래서 Row의 파라미터는 대부분 배치 규칙이며, 시각 효과는 Modifier나 별도 Surface가 담당한다.

  • modifier: Row 자체의 크기·패딩·배경·클립·입력·세만틱스가 모두 여기로 들어간다. Row는 modifier를 해석하지 않고 노드 체인에 위임한다.
  • horizontalArrangement: 주축(가로)에서 남는 공간을 어디에 둘지 결정한다. 측정이 끝난 뒤 place 단계에서만 적용된다.
  • verticalAlignment: 교차축(세로) 정렬 기준이다. Row 높이가 자식보다 클 때만 의미가 있다.
  • content: @Composable RowScope.() -> Unit 형태의 슬롯이다. RowScope가 weight 같은 확장 함수를 제공한다.
  • RowScope.weight(weight: Float, fill: Boolean): 자식 Modifier에 ParentData를 심는다. fill=false면 할당된 폭 안에서 자식이 자신의 고유 폭을 유지할 수 있다.
  • Arrangement.spacedBy(space): 간격을 “자식 사이에 고정 값으로 예약”한다. 남는 공간을 균등 분배하는 API가 아니다.
  • Arrangement.SpaceBetween/SpaceAround/SpaceEvenly: 남는 공간을 간격으로 변환한다. 자식 폭은 측정 결과 그대로이고, 간격만 달라진다.
  • Alignment.Top/CenterVertically/Bottom: verticalAlignment에 들어간다. 개별 자식 정렬은 Modifier.align로 override 가능하다.
  • Modifier.align(alignment): RowScope에서 제공되는 개별 자식 교차축 정렬 override다. ParentData로 전달된다.
  • Modifier.fillMaxHeight/fillMaxWidth: Row의 Constraints를 바꾸는 게 아니라 자식의 측정 결과를 제한하는 힌트가 된다.
  • clipToBounds: Row는 기본적으로 클립하지 않는다. 오버플로우가 보이면 clip을 명시해야 한다.
  • layoutDirection: RTL이면 start/end의 의미가 바뀐다. Arrangement.Start/End는 LayoutDirection에 의존한다.

Surface 계층을 분리해서 생각해야 디버깅이 쉬워진다. Row에 background를 주면 “Row의 영역”이 칠해진다. 클릭을 달면 “Row의 영역”이 터치 타깃이 된다. 반면 Row 안의 각 자식이 실제로 차지하는 폭은 weight와 측정 결과에 의해 결정된다. 초보가 흔히 겪는 문제는 Row에 padding을 줬는데 텍스트가 잘리는 경우다. padding이 줄인 건 Row가 자식에게 주는 Constraints이고, 자식 텍스트는 그 Constraints 안에서 줄바꿈/ellipsis를 선택한다.

Content 계층은 슬롯 테이블과 직결된다. Row의 content 람다는 컴포지션에서 그룹으로 기록되고, 각 자식은 순서대로 Slot Table에 자리 잡는다. RowScope.weight는 content 슬롯 안에서만 쓸 수 있다. 이 제약은 “부모가 해석할 ParentData를 자식이 선언적으로 제공”하게 만들기 위한 설계다. Row 바깥에서 weight를 호출할 수 있으면, 어떤 부모가 그 데이터를 해석해야 하는지 모호해진다.

파라미터를 안 쓰면 어떻게 되나를 기준으로 보면 구조가 선명해진다. horizontalArrangement를 생략하면 Start가 기본이라 남는 공간은 오른쪽(또는 RTL이면 왼쪽)에 몰린다. verticalAlignment를 생략하면 Top이라 높이가 큰 Row에서 텍스트가 위로 붙는다. weight를 안 쓰면 자식은 자신의 고유 폭으로만 측정되고, 남는 공간은 Arrangement가 처리한다. 그래서 “균등 분배”를 원하는데 weight 없이 SpaceEvenly만 쓰면, 텍스트 길이에 따라 폭이 들쭉날쭉해진다.

SketchRow.kt
1package com.example.rowweight
2
3import androidx.compose.runtime.Composable
4import androidx.compose.ui.Modifier
5
6// 교육용 스케치: 실제 Row 구현이 아니라, 구조를 이해시키기 위한 재구성 코드
7private data class RowChildData(
8    val weight: Float = 0f,
9    val fill: Boolean = true,
10    val alignOverride: Any? = null
11)
12
13private fun Modifier.rowChildData(data: RowChildData): Modifier = this.then(
14    object : Modifier.Element { override fun toString() = "RowChildData($data)" }
15)
16
17@Composable
18fun SketchRow(
19    modifier: Modifier = Modifier,
20    arrangement: String = "Start",
21    verticalAlignment: String = "Top",
22    content: @Composable () -> Unit
23) {
24    // 실제로는 Layout(modifier, content, measurePolicy)
25    // measurePolicy는 (measurables, constraints) -> layout(width, height) { placeChildren() }
26    // Row는 measurables에서 ParentData(=weight/align)를 읽고 2-pass 측정을 수행한다.
27    content()
28}

이 스케치 코드가 해결하는 문제는 “Row가 내부에서 무엇을 읽는가”를 감각적으로 만드는 것이다. 실제 구현에서 weight는 Modifier.Element가 아니라 ParentDataModifier 형태로 저장되고, 측정 시 measurables[i].parentData로 꺼낸다. content()가 호출되는 위치가 중요한데, 컴포지션 단계에서는 자식 트리가 만들어지고 Slot Table에 그룹이 쌓인다. 측정 단계에서야 비로소 parentData가 해석된다. 그래서 같은 content라도 측정 정책이 다른 부모(예: Column, Box) 아래에 있으면 weight가 무시된다.

SurfaceRowChip.kt
1package com.example.rowweight
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.foundation.shape.RoundedCornerShape
9import androidx.compose.material3.MaterialTheme
10import androidx.compose.material3.Surface
11import androidx.compose.material3.Text
12import androidx.compose.runtime.Composable
13import androidx.compose.ui.Alignment
14import androidx.compose.ui.Modifier
15import androidx.compose.ui.graphics.Color
16import androidx.compose.ui.tooling.preview.Preview
17import androidx.compose.ui.unit.dp
18
19@Composable
20fun SurfaceRowChip() {
21    Surface(
22        color = Color(0xFF111827),
23        contentColor = Color(0xFFF9FAFB),
24        shape = RoundedCornerShape(14.dp)
25    ) {
26        Row(
27            modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp),
28            horizontalArrangement = Arrangement.SpaceBetween,
29            verticalAlignment = Alignment.CenterVertically
30        ) {
31            Text("Network", modifier = Modifier.weight(1f))
32            Text("Wi‑Fi", modifier = Modifier.background(Color(0xFF374151)).padding(horizontal = 10.dp, vertical = 4.dp))
33        }
34    }
35}
36
37@Preview(showBackground = true, widthDp = 360)
38@Composable
39private fun SurfaceRowChipPreview() {
40    MaterialTheme { SurfaceRowChip() }
41}

실행하면 왼쪽 텍스트(Network)가 남는 공간을 먹고, 오른쪽 배지(Wi‑Fi)는 고유 폭을 유지한 채 끝으로 붙는다. SpaceBetween 때문에 “양 끝 정렬”처럼 보이지만, 실제로는 weight가 먼저 폭을 가져가서 오른쪽 배지가 밀려난 결과다. SpaceBetween은 남는 공간을 간격으로 바꾸는데, 남는 공간이 거의 0이면 간격도 0이 된다. 그래서 이 패턴은 ‘왼쪽은 늘어나고 오른쪽은 고정’ 같은 설정 화면에 자주 쓴다.

내부 동작 원리

Row를 이해하는 가장 빠른 길은 3단계를 분리하는 것이다. Composition 단계에서 Composable 호출이 Slot Table에 기록된다. Layout 단계에서 Row의 MeasurePolicy가 자식들을 측정하고 배치 좌표를 계산한다. Drawing 단계에서 각 노드가 실제로 그려진다. weight/Arrangement/Alignment는 Layout 단계의 산물이고, Slot Table에는 “어떤 파라미터로 Row를 호출했는지”가 저장된다.

Compose Compiler는 Row 같은 Composable 호출을 “그룹 시작/종료 + 파라미터 변경 체크 + 스킵 가능성” 형태로 변환한다. 개념적으로는 composer.startRestartGroup(key)로 그룹을 열고, 파라미터가 이전과 같으면 composer.skipToGroupEnd()로 content 계산을 생략할 수 있다. Row의 content 람다는 별도 그룹으로 들어가며, 자식 호출 순서가 Slot Table의 슬롯 순서를 결정한다. 자식 순서가 바뀌면 슬롯 재사용이 깨지고, 상태가 엉뚱한 곳으로 이동하는 현상이 생긴다.

Slot Table 관점에서 중요한 건 weight 자체가 Slot에 저장되는 방식이다. weight 값은 Row 호출 파라미터가 아니라 자식 Modifier에 붙은 ParentData다. 즉, Slot Table에는 Text("A") 호출과 Modifier 체인이 기록되고, 측정 시점에 Modifier 노드에서 ParentData를 추출한다. 그래서 같은 weight라도 recomposition에서 Modifier 체인이 새로 만들어지면 ParentData 객체도 새로 만들어질 수 있다. 이게 잦으면 프레임당 할당이 늘어난다.

Row의 측정은 2-pass로 이해하면 대부분 설명된다. 1) weight 없는 자식을 먼저 측정해 고정 폭을 확보한다. 2) 남은 폭을 weight 합으로 나누고, 각 weight 자식을 그 폭으로 다시 측정한다. fill=false인 weight 자식은 ‘할당 폭’을 받지만, 자식이 원하는 폭이 더 작으면 그만큼만 쓰고 남는 폭은 다시 남는다. 그 남는 폭은 Arrangement가 간격으로 바꾸거나 Start/End로 몰아넣는다.

Arrangement와 Alignment가 배치에서만 작동한다는 사실은 디버깅 포인트가 된다. 텍스트가 잘리는 문제는 대부분 측정 단계에서 이미 결정된다. 예를 들어 Text에 maxLines=1, overflow=Ellipsis를 주면, 측정 단계에서 주어진 Constraints 안에서 한 줄로 맞추고 넘치는 부분을 잘라낸다. 그 다음에 Arrangement.SpaceBetween을 바꿔도 텍스트가 다시 측정되지 않으면 잘림은 그대로다. ‘정렬을 바꿨는데 왜 줄바꿈이 안 바뀌지?’ 같은 질문의 답이 여기 있다.

Recomposition은 상태 읽기(read)와 연결된다. Row 자체는 내부적으로 상태를 읽지 않으면 다시 호출되지 않는다. 하지만 Row의 content 안에서 상태를 읽는 자식이 있으면 그 자식 그룹이 invalidation 된다. 이때 Slot Table은 이전 실행에서 기록된 그룹 경계를 기반으로 “어디부터 다시 실행할지”를 결정한다. 그래서 상태를 Row의 상위에서 읽으면 Row 전체가 다시 호출되고, 상태를 더 아래에서 읽으면 더 좁은 범위만 다시 호출된다.

RecomposeScopeDemo.kt
1package com.example.rowweight
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.foundation.layout.padding
8import androidx.compose.material3.Button
9import androidx.compose.material3.MaterialTheme
10import androidx.compose.material3.Text
11import androidx.compose.runtime.Composable
12import androidx.compose.runtime.getValue
13import androidx.compose.runtime.mutableIntStateOf
14import androidx.compose.runtime.remember
15import androidx.compose.runtime.setValue
16import androidx.compose.ui.Modifier
17import androidx.compose.ui.tooling.preview.Preview
18import androidx.compose.ui.unit.dp
19
20private const val TAG = "RecomposeTrace"
21
22@Composable
23fun RecomposeScopeDemo() {
24    var count by remember { mutableIntStateOf(0) }
25
26    Log.d(TAG, "Row recomposed. count=$count")
27
28    Row(
29        modifier = Modifier.fillMaxWidth().padding(16.dp),
30        horizontalArrangement = Arrangement.spacedBy(8.dp)
31    ) {
32        Text("count=$count", modifier = Modifier.weight(1f))
33        Button(onClick = { count++ }) {
34            Log.d(TAG, "Button content recomposed. count=$count")
35            Text("+1")
36        }
37    }
38}
39
40@Preview(showBackground = true)
41@Composable
42private fun RecomposeScopeDemoPreview() {
43    MaterialTheme { RecomposeScopeDemo() }
44}

이 코드를 실행하고 버튼을 연타하면 Logcat에 Row recomposed와 Button content recomposed가 함께 찍힌다. 이유는 count를 Row 스코프에서 읽기 때문이다. count는 Text와 Log에서 읽히며, 그 읽기 지점이 Row 그룹 안에 있다. Compose Runtime은 Snapshot state가 읽힌 위치를 추적하고 있다가 count가 바뀌면 해당 그룹을 invalidation 한다. 여기서 중요한 건 “레이아웃 다시 측정”이 자동으로 따라온다는 점이다. 텍스트 길이가 바뀌면 Row는 다음 프레임에서 다시 측정되고, weight 분배 결과도 바뀐다.

ModifierChainOrderDemo.kt
1package com.example.rowweight
2
3import androidx.compose.foundation.Indication
4import androidx.compose.foundation.background
5import androidx.compose.foundation.clickable
6import androidx.compose.foundation.interaction.MutableInteractionSource
7import androidx.compose.foundation.layout.Arrangement
8import androidx.compose.foundation.layout.Row
9import androidx.compose.foundation.layout.fillMaxWidth
10import androidx.compose.foundation.layout.padding
11import androidx.compose.material3.MaterialTheme
12import androidx.compose.material3.Text
13import androidx.compose.material3.ripple.rememberRipple
14import androidx.compose.runtime.Composable
15import androidx.compose.runtime.remember
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.tooling.preview.Preview
22import androidx.compose.ui.unit.dp
23
24@Composable
25fun ModifierChainOrderDemo() {
26    val interaction = remember { MutableInteractionSource() }
27    val indication: Indication = rememberRipple()
28
29    Row(
30        modifier = Modifier
31            .fillMaxWidth()
32            .background(Color(0xFF0B1220))
33            .semantics { contentDescription = "settings-row" }
34            .clickable(
35                interactionSource = interaction,
36                indication = indication,
37                onClick = { }
38            )
39            .padding(16.dp),
40        horizontalArrangement = Arrangement.SpaceBetween,
41        verticalAlignment = Alignment.CenterVertically
42    ) {
43        Text("Sound", color = Color.White, modifier = Modifier.weight(1f))
44        Text("On", color = Color(0xFF93C5FD))
45    }
46}
47
48@Preview(showBackground = true, widthDp = 360)
49@Composable
50private fun ModifierChainOrderDemoPreview() {
51    MaterialTheme { ModifierChainOrderDemo() }
52}

이 코드는 Modifier 체인 순서가 실제 동작을 바꾸는 걸 확인시키기 위한 것이다. background가 padding보다 앞에 있으니 배경은 패딩까지 포함한 전체 영역에 칠해진다. clickable이 padding보다 앞에 있으니 터치 타깃도 패딩을 포함한다. semantics도 마찬가지로 Row 전체에 붙는다. 반대로 padding을 먼저 넣고 clickable을 뒤에 두면, 클릭 영역이 줄어든다. Layout Inspector에서 semantics 노드를 보면 contentDescription이 Row에 붙어 있는지, 자식에 붙어 있는지 바로 확인된다.

실습하기

실습 목표는 세 가지다. 1) weight가 측정 단계에서 “남는 폭”을 나눈다는 걸 눈으로 확인한다. 2) Arrangement는 자식 폭을 바꾸지 않고 간격만 바꾼다는 걸 확인한다. 3) Alignment는 교차축에서만 의미가 있고, Row 높이가 커질 때만 차이가 난다는 걸 확인한다. 각 단계는 복사-붙여넣기 후 실행하면 화면 변화가 즉시 보이도록 구성한다.

build.gradle.kts (module)
1dependencies {
2    implementation(platform("androidx.compose:compose-bom:2024.12.01"))
3    implementation("androidx.compose.ui:ui")
4    implementation("androidx.compose.ui:ui-tooling-preview")
5    implementation("androidx.compose.material3:material3")
6    debugImplementation("androidx.compose.ui:ui-tooling")
7}

BOM을 쓰면 버전 충돌로 인한 미묘한 레이아웃 차이를 줄일 수 있다. 예전에 compose-ui와 material3 버전이 어긋나서 Preview만 깨지고 런타임은 정상인 케이스를 3시간 잡은 적이 있다. 에러 메시지는 'java.lang.NoSuchMethodError'였고, 원인은 transitive dependency로 들어온 ui-text 버전 불일치였다. 실습에서는 이런 변수를 최대한 제거하는 게 낫다.

1단계: weight의 2-pass 측정 체감

첫 화면은 고정 폭 + weight 조합이다. 실행하면 좌측 텍스트가 길어질수록 오른쪽 버튼이 밀리거나 줄어드는 게 보인다. 여기서 관찰 포인트는 ‘고정 폭이 먼저 먹고, 남는 폭을 weight가 나눠 가진다’는 점이다. 텍스트 길이를 바꾸면 Row가 다시 측정되고, weight 자식의 할당 폭이 달라진다.

측정 결과를 눈으로 확인하려고 각 영역에 배경색을 넣는다. 배경은 실제 측정된 bounds를 그대로 드러낸다. 텍스트 자체가 차지하는 glyph 영역이 아니라, Row가 자식에게 할당한 레이아웃 박스를 보는 용도다.

Step1WeightTwoPass.kt
1package com.example.rowweight
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.foundation.layout.width
9import androidx.compose.material3.Button
10import androidx.compose.material3.MaterialTheme
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
19fun Step1WeightTwoPass() {
20    Row(
21        modifier = Modifier.fillMaxWidth().padding(16.dp),
22        horizontalArrangement = Arrangement.spacedBy(10.dp)
23    ) {
24        Text(
25            text = "This title can be very long on a small phone",
26            modifier = Modifier.background(Color(0xFF1F2937)).padding(6.dp).weight(1f),
27            color = Color.White
28        )
29        Button(
30            onClick = { },
31            modifier = Modifier.width(110.dp)
32        ) { Text("Action") }
33    }
34}
35
36@Preview(showBackground = true, widthDp = 320)
37@Composable
38private fun Step1PreviewSmall() {
39    MaterialTheme { Step1WeightTwoPass() }
40}
41
42@Preview(showBackground = true, widthDp = 420)
43@Composable
44private fun Step1PreviewLarge() {
45    MaterialTheme { Step1WeightTwoPass() }
46}
47
48@Preview(showBackground = true, widthDp = 600)
49@Composable
50private fun Step1PreviewTablet() {
51    MaterialTheme { Step1WeightTwoPass() }
52}

320dp Preview에서는 제목 영역이 좁아지고 텍스트가 줄바꿈되거나 잘린다(텍스트 설정에 따라 다름). 420dp에서는 같은 코드인데 제목 영역이 확 넓어진다. Row가 받은 Constraints.maxWidth가 달라졌고, 고정 폭 버튼(110dp)과 spacing(10dp)을 먼저 빼고 남은 폭을 weight(1f)에게 전부 준 결과다.

2단계: 상태로 Arrangement/Alignment 차이 확인

Arrangement는 남는 공간을 간격으로 바꾸는 규칙이라, 상태로 토글하면 차이가 즉시 보인다. Alignment는 Row 높이가 자식보다 커야 차이가 보이므로, Row에 고정 높이를 주고 세로 정렬을 바꾼다. 이 단계는 ‘정렬을 바꿨는데 폭이 안 바뀌는’ 이유를 몸으로 기억시키는 용도다.

버튼을 누르면 arrangementIndex가 바뀌고 Row가 recomposition 된다. 이때 자식의 폭은 동일한데, 배치 좌표만 달라진다. Layout Inspector에서 각 자식의 width가 동일하게 유지되는지 확인하면 Arrangement가 측정에 관여하지 않는다는 게 선명해진다.

Step2ToggleArrangementAlignment.kt
1package com.example.rowweight
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.height
8import androidx.compose.foundation.layout.padding
9import androidx.compose.material3.Button
10import androidx.compose.material3.MaterialTheme
11import androidx.compose.material3.Text
12import androidx.compose.runtime.Composable
13import androidx.compose.runtime.getValue
14import androidx.compose.runtime.mutableIntStateOf
15import androidx.compose.runtime.remember
16import androidx.compose.runtime.setValue
17import androidx.compose.ui.Alignment
18import androidx.compose.ui.Modifier
19import androidx.compose.ui.graphics.Color
20import androidx.compose.ui.tooling.preview.Preview
21import androidx.compose.ui.unit.dp
22
23@Composable
24fun Step2ToggleArrangementAlignment() {
25    var index by remember { mutableIntStateOf(0) }
26    val arrangements = listOf(
27        Arrangement.Start,
28        Arrangement.Center,
29        Arrangement.SpaceBetween,
30        Arrangement.SpaceEvenly
31    )
32    val verticals = listOf(
33        Alignment.Top,
34        Alignment.CenterVertically,
35        Alignment.Bottom
36    )
37
38    Row(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
39        Button(onClick = { index = (index + 1) % arrangements.size }) {
40            Text("arr=${index}")
41        }
42    }
43
44    Row(
45        modifier = Modifier
46            .fillMaxWidth()
47            .height(72.dp)
48            .padding(horizontal = 16.dp)
49            .background(Color(0xFF0F172A)),
50        horizontalArrangement = arrangements[index],
51        verticalAlignment = verticals[index % verticals.size]
52    ) {
53        Text("A", color = Color.White, modifier = Modifier.background(Color(0xFF334155)).padding(8.dp))
54        Text("B", color = Color.White, modifier = Modifier.background(Color(0xFF475569)).padding(8.dp))
55        Text("C", color = Color.White, modifier = Modifier.background(Color(0xFF64748B)).padding(8.dp))
56    }
57}
58
59@Preview(showBackground = true, widthDp = 360)
60@Composable
61private fun Step2Preview() {
62    MaterialTheme { Step2ToggleArrangementAlignment() }
63}

토글할 때 A/B/C의 배경 박스 폭은 동일하게 유지되고, 위치만 바뀐다. SpaceBetween/Evenly에서 특히 잘 보인다. Row 높이를 72dp로 고정했기 때문에 Top/Center/Bottom 차이도 확실히 드러난다. 여기서 ‘정렬을 바꾸면 텍스트가 다시 측정될 것’이라는 기대가 틀렸다는 걸 확인한다. 측정은 자식의 intrinsic/constraints로 결정되고, 정렬은 place 좌표만 바꾼다.

3단계: 실전 패턴(타이틀 + 보조정보 + CTA) 만들기

설정 화면, 결제 화면, 리스트 아이템에서 가장 흔한 패턴은 ‘왼쪽은 유동, 오른쪽은 고정’이다. title은 길이에 따라 줄바꿈되거나 ellipsis가 필요하고, 오른쪽 CTA는 터치 타깃을 보장해야 한다. weight를 title에만 주고, CTA는 고정 폭 또는 wrapContent로 유지한다.

실행하면 작은 화면에서도 CTA가 눌릴 만큼 남고, title은 남는 폭에서만 줄바꿈된다. 여기서 중요한 건 CTA에 weight를 주지 않는 것이다. CTA에 weight를 주면 버튼이 불필요하게 늘어나고, ripple 영역이 과하게 커져서 UX가 이상해진다.

Step3ResponsiveRowItem.kt
1package com.example.rowweight
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.material3.Button
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.text.style.TextOverflow
14import androidx.compose.ui.tooling.preview.Preview
15import androidx.compose.ui.unit.dp
16
17@Composable
18fun Step3ResponsiveRowItem(
19    title: String,
20    subtitle: String,
21    onAction: () -> Unit
22) {
23    Row(
24        modifier = Modifier.fillMaxWidth().padding(16.dp),
25        horizontalArrangement = Arrangement.spacedBy(12.dp),
26        verticalAlignment = Alignment.CenterVertically
27    ) {
28        Row(
29            modifier = Modifier.weight(1f),
30            horizontalArrangement = Arrangement.spacedBy(8.dp),
31            verticalAlignment = Alignment.CenterVertically
32        ) {
33            Text(
34                text = title,
35                maxLines = 1,
36                overflow = TextOverflow.Ellipsis,
37                modifier = Modifier.weight(1f)
38            )
39            Text(text = subtitle, maxLines = 1)
40        }
41        Button(onClick = onAction) { Text("Buy") }
42    }
43}
44
45@Preview(showBackground = true, widthDp = 320)
46@Composable
47private fun Step3PreviewSmall() {
48    MaterialTheme {
49        Step3ResponsiveRowItem(
50            title = "Super long product name that should not push the button away",
51            subtitle = "$9.99",
52            onAction = { }
53        )
54    }
55}

이 구조는 Row 안에 Row를 넣은 형태라 Slot Table의 그룹도 중첩된다. 바깥 Row는 CTA 버튼을 고정하고, 안쪽 Row가 title/subtitle의 공간 싸움을 처리한다. title에만 weight를 주고 subtitle은 wrapContent로 두면, 가격 같은 짧은 정보가 우선 보인다. 실무에서 ‘가격이 잘리는’ 버그는 대부분 title과 subtitle에 둘 다 weight를 줘서 발생한다.

심화: Advanced 버전 만들기

Row의 weight/정렬을 실무에서 제대로 쓰려면 “상태 변화가 잦은 영역”과 “레이아웃이 무거운 영역”을 분리해야 한다. 예를 들어 로딩 스피너 토글이 16ms마다 바뀌는 컴포넌트가 Row 전체를 다시 측정하게 만들면, 리스트에서 프레임 드랍이 난다. 고급 패턴은 상태 읽기 위치를 아래로 내리고, derivedStateOf로 계산을 캐시하고, 안정성(Stable/Immutable)을 맞춰서 불필요한 invalidation을 줄이는 쪽으로 간다.

사례 1: weight 분배를 유지하면서 로딩/디바운스/아이콘을 넣는 CTA

요구사항을 한 번에 넣으면 레이아웃이 흔들린다. loading이 true일 때 텍스트가 사라지면 버튼 폭이 줄어들어 Row의 weight 분배가 달라지고, 좌측 텍스트가 매 프레임 흔들린다. 버튼 폭을 안정화하려면 최소 폭을 고정하거나, 로딩 상태에서도 동일한 레이아웃 박스를 유지해야 한다.

AdvancedCtaButton.kt
1package com.example.rowweight
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.Box
8import androidx.compose.foundation.layout.Row
9import androidx.compose.foundation.layout.defaultMinSize
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.Immutable
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.semantics.contentDescription
26import androidx.compose.ui.semantics.semantics
27import androidx.compose.ui.unit.dp
28
29@Immutable
30data class AdvancedCtaStyle(
31    val label: String,
32    val a11yLabel: String
33)
34
35@Composable
36fun AdvancedCtaButton(
37    style: AdvancedCtaStyle,
38    loading: Boolean,
39    enabled: Boolean,
40    icon: @Composable (() -> Unit)? = null,
41    debounceMs: Long = 500L,
42    onLongPress: (() -> Unit)? = null,
43    onClick: () -> Unit
44) {
45    var lastClickAt by remember { mutableLongStateOf(0L) }
46    val interaction = remember { MutableInteractionSource() }
47
48    Surface(tonalElevation = 2.dp, shape = MaterialTheme.shapes.medium) {
49        Row(
50            modifier = Modifier
51                .semantics { contentDescription = style.a11yLabel }
52                .combinedClickable(
53                    interactionSource = interaction,
54                    indication = null,
55                    enabled = enabled && !loading,
56                    onLongClick = onLongPress,
57                    onClick = {
58                        val now = SystemClock.elapsedRealtime()
59                        if (now - lastClickAt >= debounceMs) {
60                            lastClickAt = now
61                            onClick()
62                        }
63                    }
64                )
65                .defaultMinSize(minWidth = 96.dp, minHeight = 40.dp)
66                .padding(horizontal = 12.dp, vertical = 8.dp),
67            horizontalArrangement = Arrangement.spacedBy(8.dp),
68            verticalAlignment = Alignment.CenterVertically
69        ) {
70            if (loading) {
71                CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
72                Box(modifier = Modifier.size(0.dp))
73            } else {
74                if (icon != null) icon() else Box(modifier = Modifier.size(0.dp))
75                Text(style.label)
76            }
77        }
78    }
79}

이 코드를 실행해 두 번 빠르게 탭하면 두 번째 클릭이 무시된다. Log를 찍어보면 onClick 호출이 1회만 발생한다. loading 상태에서도 defaultMinSize로 버튼 폭이 유지되므로 Row 전체가 흔들리지 않는다. a11yLabel은 TalkBack에서 읽히는 문구로, 화면에 보이는 label과 분리해 두면 “아이콘만 있는 버튼”에서도 접근성 텍스트를 강제할 수 있다.

사례 2: Row 아이템에서 상태 읽기 위치를 내려 recomposition 범위 줄이기

리스트에서 각 아이템이 타이머/다운로드 진행률 같은 빠른 상태를 읽으면, Row 전체가 계속 recomposition 되고 측정도 자주 발생한다. 이때 weight가 들어간 Row는 측정 비용이 더 커진다. 해결책은 ‘빠르게 변하는 값’을 가장 작은 자식에서만 읽게 만드는 것이다. derivedStateOf로 문자열 포맷을 캐시하면 할당도 줄어든다.

RowItemWithProgress.kt
1package com.example.rowweight
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.foundation.layout.padding
8import androidx.compose.material3.MaterialTheme
9import androidx.compose.material3.Text
10import androidx.compose.runtime.Composable
11import androidx.compose.runtime.derivedStateOf
12import androidx.compose.runtime.getValue
13import androidx.compose.runtime.mutableFloatStateOf
14import androidx.compose.runtime.remember
15import androidx.compose.runtime.setValue
16import androidx.compose.ui.Modifier
17import androidx.compose.ui.tooling.preview.Preview
18import androidx.compose.ui.unit.dp
19
20private const val ITEM_TAG = "RowItem"
21
22@Composable
23fun RowItemWithProgress(title: String) {
24    var progress by remember { mutableFloatStateOf(0.42f) }
25
26    Log.d(ITEM_TAG, "RowItem recomposed")
27
28    Row(
29        modifier = Modifier.fillMaxWidth().padding(16.dp),
30        horizontalArrangement = Arrangement.SpaceBetween
31    ) {
32        Text(title, modifier = Modifier.weight(1f))
33        ProgressText(progress = progress)
34    }
35}
36
37@Composable
38private fun ProgressText(progress: Float) {
39    val text by remember(progress) {
40        derivedStateOf { "${(progress * 100).toInt()}%" }
41    }
42    Log.d(ITEM_TAG, "ProgressText recomposed")
43    Text(text)
44}
45
46@Preview(showBackground = true, widthDp = 360)
47@Composable
48private fun RowItemWithProgressPreview() {
49    MaterialTheme { RowItemWithProgress(title = "Download") }
50}

Logcat을 보면 ProgressText recomposed가 더 자주 찍히도록 구조를 만들 수 있다. 예전 삽질에서는 progress를 RowItemWithProgress 상단에서 포맷 문자열로 만들고 그 문자열을 Row에 넘겼다. 그 결과 progress가 60fps로 변할 때 Row 전체가 매 프레임 recomposition 되고, LazyColumn에서 스크롤이 뚝뚝 끊겼다. Systrace에서 measure/layout 구간이 길게 늘어났고, 원인은 ‘빠른 상태 읽기 위치가 Row 그룹 상단’이었다.

또 다른 흑역사는 weight를 가진 Text에 Modifier.animateContentSize()를 붙인 케이스다. 증상은 텍스트 길이가 바뀔 때마다 Row가 계속 흔들리며, 특정 기기에서 'LayoutNode should not be placed twice' 같은 내부 assert 로그가 튀었다. 원인은 애니메이션이 중간 프레임마다 크기를 바꾸면서 재측정과 배치가 반복되는데, 그 대상이 weight 분배의 기준이 되는 자식이어서 부모 측정이 연쇄적으로 흔들린 것이다. 교정은 애니메이션을 weight 자식이 아니라 내부 컨텐츠(Box)로 옮기고, 부모 Row의 폭 분배는 고정시키는 방식으로 했다.

자주 하는 실수

1) weight를 주면 wrapContent처럼 동작할 거라 기대함

증상: Text에 weight(1f)를 줬는데도 텍스트가 두 줄로 늘어나거나, 반대로 한 줄로 강제되며 ellipsis가 생긴다. 버튼이 작아지거나 클릭 영역이 예상보다 작아진다.

원인: weight는 “할당 폭”을 의미하고, 그 폭 안에서 자식이 어떻게 그릴지는 자식의 측정 정책(Text의 maxLines/softWrap/overflow)에 달려 있다. wrapContent는 Constraints가 넓을 때만 의미가 있고, weight는 남는 폭을 강제로 배정한다.

해결: 텍스트 정책을 먼저 결정한다. 한 줄이 필요하면 maxLines=1 + overflow=Ellipsis를 명시한다. 두 줄까지 허용이면 maxLines=2로 제한하고, 버튼 같은 고정 요소는 weight를 주지 않는다.

2) SpaceBetween을 쓰면 자식 폭도 균등해질 거라 착각함

증상: SpaceBetween을 넣었는데 텍스트가 길면 왼쪽이 커지고 오른쪽이 밀린다. 균등 분배 UI를 기대했는데 텍스트 길이에 따라 폭이 달라진다.

원인: Arrangement는 측정이 끝난 뒤 남는 공간을 간격으로 바꾸는 규칙이다. 자식 폭은 측정 결과 그대로라서, 텍스트가 길면 그만큼 폭을 요구하고(또는 줄바꿈/ellipsis로 타협) 간격만 조정된다.

해결: 균등 폭이 목표면 weight를 사용한다. 예: 각 자식에 weight(1f)를 주고, 간격은 spacedBy로 고정한다. SpaceBetween은 ‘양 끝 정렬’이나 ‘남는 공간을 간격으로 흡수’하는 UI에 적합하다.

3) Row에 padding을 줬더니 클릭 영역이 줄어듦

증상: Row에 clickable을 붙였는데 가장자리 터치가 안 된다. 배경은 넓은데 클릭은 좁게 느껴진다.

원인: Modifier 체인 순서 때문이다. padding 뒤에 clickable을 붙이면 clickable은 패딩이 적용된 “안쪽 영역”에만 걸린다. 반대로 clickable 뒤에 padding을 붙이면 클릭은 바깥까지 포함된다.

해결: 터치 타깃을 먼저 정의한다. 보통 clickable을 padding 앞에 두고, 시각적 패딩은 clickable 뒤에 둔다. Layout Inspector에서 hit test 영역을 확인한다.

4) weight 자식에 width/size를 같이 줘서 충돌시킴

증상: weight(1f)와 width(100.dp)를 같이 줬는데 기대와 다르게 동작한다. 어떤 기기에서는 width가 먹고, 어떤 상황에서는 weight가 먹는 것처럼 보인다.

원인: width는 자식의 측정 제약을 바꾸고, weight는 부모가 할당 폭을 정한다. 둘은 같은 단계(측정)에 관여하지만 방향이 다르다. 특히 fill=true인 weight는 할당 폭에 맞추려 하고, width는 그 폭을 고정하려 한다.

해결: 의도를 하나로 고정한다. 비율 분배가 목표면 width를 제거하고 weight만 쓴다. 최소 폭 보장이 목표면 width 대신 defaultMinSize를 쓴다. fill=false를 조합해 ‘할당은 받되 내용은 고유 폭 유지’도 선택지다.

5) LazyColumn 아이템에서 상태를 상단에서 읽어 Row 전체가 계속 리컴포즈됨

증상: 스크롤이 끊기고, 프로파일러에서 measure/layout 시간이 증가한다. Logcat에 같은 아이템의 recomposition 로그가 계속 찍힌다.

원인: 빠르게 변하는 state를 Row 아이템의 상단에서 읽으면 아이템 전체 그룹이 invalidation 된다. weight가 있는 Row는 2-pass 측정이라 재측정 비용이 더 커진다.

해결: state 읽기 위치를 최소 컴포저블로 내린다. 문자열 포맷은 derivedStateOf로 캐시한다. 안정적인 모델(@Immutable)로 파라미터 변경 비교 비용을 줄인다.

MistakeSketch.kt
1package com.example.rowweight
2
3import androidx.compose.foundation.layout.Row
4import androidx.compose.foundation.layout.fillMaxWidth
5import androidx.compose.material3.MaterialTheme
6import androidx.compose.material3.Text
7import androidx.compose.runtime.Composable
8import androidx.compose.ui.Modifier
9import androidx.compose.ui.tooling.preview.Preview
10
11@Composable
12fun MistakeWeightWithFixedWidth() {
13    Row(modifier = Modifier.fillMaxWidth()) {
14        Text("Left", modifier = Modifier.weight(1f))
15        // 충돌 예시: 고정 폭을 강제하면서도 weight로 늘리려는 의도
16        Text("Right", modifier = Modifier.weight(1f).then(androidx.compose.ui.Modifier))
17    }
18}
19
20@Preview(showBackground = true)
21@Composable
22private fun MistakePreview() {
23    MaterialTheme { MistakeWeightWithFixedWidth() }
24}

이 코드는 ‘충돌을 만들면 디버깅이 어려워진다’는 메시지를 주기 위한 스케치다. 실제 충돌은 width/requiredWidth/sizeIn 같은 제약 Modifier와 weight를 섞을 때 자주 생긴다. 의도가 최소 폭인지 비율 분배인지 먼저 정하고 Modifier를 정렬해야 한다.

성능 최적화 체크리스트

  • Row의 목표가 ‘비율 분배’인지 ‘간격 분배’인지 먼저 문장으로 적는다(비율이면 weight, 간격이면 Arrangement).
  • weight는 남는 폭에만 적용된다는 점을 전제로 고정 폭(버튼/아이콘)을 먼저 결정한다.
  • spacedBy는 간격을 먼저 예약하므로, spacing이 커질수록 weight 자식에 남는 폭이 줄어드는지 Preview(320/360/420dp)로 확인한다.
  • SpaceBetween/Evenly를 쓸 때 자식 폭이 변하지 않는지 Layout Inspector에서 각 자식 width를 비교한다.
  • 텍스트가 weight를 받을 때 maxLines/overflow를 명시해 측정 단계에서의 타협 규칙을 고정한다.
  • Modifier 체인에서 clickable과 padding 순서를 고정하고, 터치 타깃이 48dp 이상인지 확인한다.
  • weight와 width/requiredWidth를 섞지 않는다. 최소 폭 보장은 defaultMinSize/sizeIn으로 대체한다.
  • fill=false가 필요한지 검토한다(할당 폭은 받되 내용은 고유 폭 유지). 남는 폭이 Arrangement로 흘러가는지 확인한다.
  • LazyColumn 아이템에서 빠르게 변하는 state는 가장 작은 자식에서만 읽게 하고, 상단 Row는 가능한 한 stateless로 유지한다.
  • 문자열 포맷/계산은 derivedStateOf로 캐시해 recomposition마다 할당이 생기지 않게 한다.
  • 모델 파라미터는 @Immutable/@Stable을 만족시키는 형태로 만들고, 불필요한 equals 비교/불안정 파라미터로 인한 스킵 실패를 줄인다.
  • Debug에서 recomposition 카운트를 찍어본다(Logcat 또는 Layout Inspector의 recomposition highlight). 변화가 Row 전체인지 특정 자식인지 확인한다.

자주 묻는 질문

Row에서 weight가 정확히 언제 적용되나? Arrangement보다 먼저인가?

weight는 Layout 단계의 측정(Measure)에서 적용되고, Arrangement/Alignment는 배치(Place)에서 적용된다. Row는 먼저 weight가 없는 자식을 측정해 고정 폭을 확정하고, 남는 폭을 weight 합으로 나눠 weight 자식을 다시 측정한다(2-pass). 그 다음에 남는 공간이 있으면 Arrangement가 간격으로 변환하거나 Start/End로 몰아넣는다. 그래서 SpaceBetween을 바꿔도 자식 폭이 그대로인 경우가 많다. 학습 키워드는 Constraints, MeasurePolicy, Placeable, 2-pass measure이다.

weight(1f)를 줬는데도 텍스트가 잘리거나 줄바꿈되는 이유는 뭔가?

Row가 할당한 폭은 ‘텍스트가 그 폭 안에서 어떻게 타협할지’를 강제하지 않는다. Text는 주어진 Constraints 안에서 maxLines, softWrap, overflow 규칙으로 결과를 만든다. 예를 들어 maxLines=1 + Ellipsis면 측정 단계에서 한 줄로 맞추고 넘치는 글자를 잘라낸다. 반대로 softWrap=true면 같은 폭에서도 줄바꿈을 선택한다. 즉, weight는 폭을 배정하고, Text 정책은 그 폭을 사용하는 방법을 결정한다. 처방은 Text에 maxLines/overflow를 명시하고, 버튼 같은 고정 요소에는 weight를 주지 않는 것이다.

Arrangement.SpaceBetween과 spacedBy의 차이가 체감이 안 된다. 어떤 상황에서 결정적으로 갈리나?

spacedBy는 ‘자식 사이에 고정 간격을 먼저 예약’한다. 화면이 좁아져도 간격은 유지되며, 그만큼 자식에 남는 폭이 줄어든다. SpaceBetween은 ‘남는 공간 전체를 간격으로 변환’한다. 화면이 좁아져 남는 공간이 0이면 간격도 0이 된다. 그래서 좁은 화면에서도 간격을 유지하고 싶으면 spacedBy가 맞고, 넓은 화면에서 자연스럽게 퍼지게 만들고 싶으면 SpaceBetween/Evenly가 맞다. 확인 방법은 Preview 폭을 320dp와 420dp로 바꿔 간격이 고정인지 가변인지 보는 것이다.

RowScope.weight의 fill 파라미터는 왜 존재하나? 언제 false를 써야 하나?

fill=true는 ‘할당된 폭을 꽉 채우도록’ 자식을 재측정하는 쪽에 가깝다. fill=false는 ‘할당 폭을 받되, 자식이 원하면 더 작게 측정될 수 있게’ 허용한다. 실전에서는 “오른쪽에 짧은 배지/아이콘이 있고, 왼쪽 텍스트가 남는 폭을 먹되 배지는 고유 폭 유지” 같은 상황에서 fill=false가 도움이 된다. 남는 폭이 다시 생기면 Arrangement가 처리하므로, SpaceBetween 같은 배치 규칙과 조합할 때 결과가 달라진다. 키워드는 fill, remaining space, arrangement interaction이다.

Recomposition이 일어나면 Row는 항상 다시 측정/배치되나? 성능에 어떤 영향이 있나?

Recomposition은 ‘Composable 함수 재실행’이고, 측정/배치는 ‘레이아웃 무효화’가 동반될 때 실행된다. 상태 변경으로 Row content가 바뀌면 보통 레이아웃도 무효화되어 다음 프레임에 재측정이 발생한다. 특히 weight가 있는 Row는 고정 자식 측정 + weight 자식 재측정의 2-pass가 들어가므로, 아이템 수가 많고 상태 변경이 잦으면 누적 비용이 커진다. 처방은 상태 읽기를 더 아래로 내려 Row 자체가 자주 invalidation 되지 않게 하고, derivedStateOf로 포맷/계산을 캐시하며, 불필요한 Modifier 재생성을 줄이는 것이다. 측정은 Android Studio Profiler의 Compose/Frame Timeline에서 Layout 시간을 확인한다.

Slot Table에는 Row의 weight 정보가 저장되나? 상태가 섞이는 문제와 관련이 있나?

weight 값 자체는 Row 호출 파라미터가 아니라 자식 Modifier에 붙은 ParentData로 전달된다. Slot Table에는 Row 그룹과 자식 호출 순서, 그리고 Modifier 체인 구성이 기록된다. 상태가 섞이는 문제는 대개 ‘자식 호출 순서가 바뀌었는데 key를 주지 않은’ 경우에 발생한다. 예를 들어 조건문으로 자식의 위치가 바뀌면 Slot Table의 슬롯 재사용이 엉키면서 remember 상태가 다른 컴포넌트로 이동한다. weight는 그 현상의 직접 원인이라기보다, 레이아웃이 흔들리면서 문제를 더 눈에 띄게 만들 수 있다. 키워드는 startRestartGroup, key(), slot reuse이다.

Row에서 반응형을 만들 때 가장 안전한 설계 규칙 한 가지를 고르라면?

‘고정 요소(CTA/아이콘)와 유동 요소(타이틀/본문)를 분리하고, 유동 요소에만 weight를 준다’가 가장 사고를 줄인다. 고정 요소에 weight를 주면 폭이 불필요하게 늘어나고, 클릭 영역/리플/정렬이 예상과 달라진다. 유동 요소는 Text의 maxLines/overflow로 타협 규칙을 고정하고, 남는 공간 처리는 Arrangement로만 조절한다. 리스트에서는 상태 읽기를 유동 요소 내부로 내리고, 모델은 @Immutable 형태로 유지해 스킵이 잘 일어나게 만든다. 학습 키워드는 stateless row, stable params, text measurement policy이다.

관련 글

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

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

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

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

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

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

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

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

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