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

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

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

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

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

앱 화면을 Column/Row로 쌓았는데, padding 하나 바꿨을 뿐인데도 특정 텍스트가 갑자기 줄바꿈되거나 버튼이 밀려나는 일이 생긴다. 더 답답한 건 상태 하나 바꾸면 화면 전체가 다시 그려지는 것처럼 느껴진다는 점이다. Column/Row는 단순한 컨테이너가 아니라 제약(Constraints)과 측정(Measure) 규칙을 가진 레이아웃 노드이며, 그 규칙이 Slot Table과 recomposition 범위에 그대로 반영된다. 이 글은 “왜 저 위치에 배치되는가”와 “왜 저 부분만 다시 호출되는가”를 같은 그림으로 연결한다.

핵심 개념

Column/Row를 ‘세로/가로로 나열’로만 이해하면 디버깅이 막힌다. 실제로는 제약 기반 레이아웃 시스템에서, 부모가 자식에게 Constraints(최소/최대 너비·높이)를 전달하고 자식이 측정 결과(Placeable)를 돌려주는 계약을 구현한 컴포저블이다. Column/Row는 그 계약을 묶어서 자식들을 여러 번 측정하고 배치(place)하는 MeasurePolicy를 제공한다.

View 시스템에서 LinearLayout을 쓸 때도 비슷한 계약이 있었다. measure()에서 MeasureSpec을 내려주고, child.measure()를 호출하고, layout()에서 위치를 정했다. Compose는 같은 문제를 더 작은 단위(노드)로 쪼갠다. ‘레이아웃’과 ‘그리기’가 분리되고, Modifier가 그 사이에 끼어들 수 있게 설계되었다.

용어를 사용 맥락으로 정의한다. Composition은 @Composable 호출을 실행해 UI 트리를 ‘만드는 단계’다. Layout은 만들어진 트리의 각 노드가 Constraints를 받아 크기를 정하고 위치를 잡는 단계다. Drawing은 최종적으로 Canvas에 그리는 단계다. Column/Row는 Composition 단계에서는 단순히 ‘노드 생성’에 가깝고, Layout 단계에서 진짜 일이 벌어진다.

Slot Table은 Composition 결과를 저장하는 런타임의 내부 테이블이다. Column { Text(); Text() } 같은 호출 순서가 Slot Table의 슬롯 순서가 된다. 이 순서가 안정적이면 recomposition에서 “이 슬롯은 이전에 Text였고 이번에도 Text다”를 빠르게 매칭한다. 반대로 조건문으로 자식 수가 흔들리면 슬롯 매칭이 깨지고 더 많은 invalidation이 퍼진다.

ColumnRowSkeleton.kt
1package com.example.columnrow
2
3import androidx.compose.foundation.background
4import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.padding
7import androidx.compose.material3.MaterialTheme
8import androidx.compose.material3.Surface
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
17fun ColumnRowSkeleton() {
18    Surface(color = MaterialTheme.colorScheme.background) {
19        Column(Modifier.padding(16.dp).background(Color(0xFFEFEFEF))) {
20            Text("Header")
21            Row(Modifier.padding(top = 8.dp).background(Color(0xFFDDFFDD))) {
22                Text("Left")
23                Text("Right", modifier = Modifier.padding(start = 12.dp))
24            }
25            Text("Footer", modifier = Modifier.padding(top = 8.dp))
26        }
27    }
28}
29
30@Preview
31@Composable
32private fun PreviewColumnRowSkeleton() {
33    ColumnRowSkeleton()
34}

이 코드를 실행하면 회색 박스 안에 Header, (초록 Row 안의 Left/Right), Footer가 위에서 아래로 쌓인다. 중요한 관찰 포인트는 배경색이 Modifier 체인 순서대로 적용된다는 점이다. Column의 padding→background 순서가 바뀌면 배경이 포함하는 영역이 달라진다. 이 차이는 Layout 단계에서 padding이 Constraints를 조정하는 방식으로 나타난다.

컴포넌트 해부

Column/Row의 파라미터는 ‘정렬 옵션’처럼 보이지만, 실제로는 측정과 배치 전략을 바꾸는 스위치다. 특히 verticalArrangement/horizontalArrangement는 남는 공간을 어떻게 분배할지 결정하고, Alignment는 child의 cross-axis 위치를 결정한다. content lambda는 단순한 콜백이 아니라 Slot Table에 저장되는 ‘자식 서브트리의 시작점’이다.

  • modifier: 레이아웃/그리기/입력/시맨틱을 노드 체인으로 합성하는 통로
  • verticalArrangement(Row에서는 horizontalArrangement): 남는 공간 분배 규칙(Top/SpaceBetween 등)
  • horizontalAlignment(Column)/verticalAlignment(Row): 교차축 정렬 규칙
  • content: 자식 컴포저블 슬롯(순서가 Slot Table의 슬롯 순서가 됨)
  • ColumnScope/RowScope: weight, align 같은 스코프 전용 Modifier 확장 제공
  • weight: 남는 공간을 분배하기 위한 측정 전략(자식 측정을 2패스로 만드는 원인)
  • fillMaxWidth/fillMaxHeight: 부모 Constraints의 최대값을 소비하도록 강제
  • wrapContentSize: 최소 크기 중심으로 배치하도록 강제
  • Arrangement.spacedBy: 간격을 고정값으로 삽입(측정 결과에 직접 반영)
  • Alignment.CenterVertically/CenterHorizontally: 교차축 위치 결정
  • clip/shape(대개 Surface와 함께): 그리기 단계에서 클리핑 비용과 오버드로우에 영향
  • background: Draw 단계 노드 추가(레이아웃 크기는 바꾸지 않음)
  • padding: Layout 단계에서 Constraints를 줄이고 placement offset을 추가
  • clickable: 입력/시맨틱 노드 추가, interactionSource와 ripple이 연결됨

Surface 계층과 Content 계층을 분리해서 생각하면 Column/Row가 왜 ‘레이아웃 뼈대’인지가 선명해진다. Surface는 색/그림자/shape/클립 같은 그리기 성질을 담당한다. Content는 측정과 배치를 담당한다. Column/Row 자체는 보통 Content 계층에 속하고, 배경/모서리/그림자는 Modifier 또는 Surface로 올린다.

Surface를 쓰지 않고 Column에 background만 붙이면 모서리 둥글기나 tonalElevation 같은 Material 규칙이 빠진다. 반대로 Surface를 과하게 겹치면 오버드로우가 늘고, 특히 스크롤 리스트에서 프레임 드랍이 눈에 띈다. 실제로 Layout Inspector에서 노드가 2~3겹 더 늘어난 걸 보고 원인을 찾은 적이 있다.

content 슬롯을 ‘그냥 자식 넣는 곳’으로 보면 조건부 UI에서 문제가 생긴다. 조건문으로 자식 수가 바뀌면 Slot Table의 그룹 구조가 달라지고, 런타임은 그룹 이동(movable group)이나 재생성 비용을 치른다. 키(key)로 그룹을 안정화시키는 이유가 여기서 나온다.

RowCard.kt
1package com.example.columnrow
2
3import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.RowScope
6import androidx.compose.foundation.layout.padding
7import androidx.compose.material3.MaterialTheme
8import androidx.compose.material3.Surface
9import androidx.compose.runtime.Composable
10import androidx.compose.ui.Modifier
11import androidx.compose.ui.unit.Dp
12import androidx.compose.ui.unit.dp
13
14@Composable
15fun RowCard(
16    modifier: Modifier = Modifier,
17    padding: Dp = 12.dp,
18    horizontal: Arrangement.Horizontal = Arrangement.spacedBy(8.dp),
19    content: @Composable RowScope.() -> Unit
20) {
21    Surface(
22        modifier = modifier,
23        color = MaterialTheme.colorScheme.surface,
24        tonalElevation = 2.dp,
25        shape = MaterialTheme.shapes.medium
26    ) {
27        Row(
28            modifier = Modifier.padding(padding),
29            horizontalArrangement = horizontal,
30            content = content
31        )
32    }
33}

이 재구성 코드는 실제 Material 컴포넌트의 내부를 복사한 게 아니라 구조를 설명하기 위한 스케치다. Surface가 ‘그리기 성질’을 담당하고, Row가 ‘측정/배치’를 담당한다. padding을 Surface에 붙이면 그림자/shape 영역까지 padding이 포함되고, Row에 붙이면 콘텐츠만 안쪽으로 들어간다. 실행 화면에서 그림자 외곽이 달라진다.

RowCardSample.kt
1package com.example.columnrow
2
3import androidx.compose.foundation.background
4import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.fillMaxWidth
6import androidx.compose.foundation.layout.height
7import androidx.compose.material3.Icon
8import androidx.compose.material3.MaterialTheme
9import androidx.compose.material3.Text
10import androidx.compose.runtime.Composable
11import androidx.compose.ui.Alignment
12import androidx.compose.ui.Modifier
13import androidx.compose.ui.graphics.Color
14import androidx.compose.ui.res.painterResource
15import androidx.compose.ui.tooling.preview.Preview
16import androidx.compose.ui.unit.dp
17
18@Composable
19fun RowCardSample() {
20    Column(Modifier.background(Color(0xFFF6F6F6))) {
21        RowCard(
22            modifier = Modifier.fillMaxWidth(),
23        ) {
24            Text("Wi‑Fi", style = MaterialTheme.typography.titleMedium)
25            Text("Connected", modifier = Modifier.align(Alignment.CenterVertically))
26        }
27        RowCard(
28            modifier = Modifier.fillMaxWidth().height(72.dp),
29        ) {
30            Icon(painterResource(android.R.drawable.ic_menu_info_details), contentDescription = "info")
31            Text("Fixed height card")
32        }
33    }
34}
35
36@Preview
37@Composable
38private fun PreviewRowCardSample() {
39    RowCardSample()
40}

두 번째 카드에 height(72.dp)를 걸면 Row의 Constraints가 ‘고정 높이’를 받는다. Icon과 Text는 그 높이 안에서 측정되고, align을 주지 않으면 기본 정렬 규칙에 따라 위쪽에 붙어 보일 수 있다. View 시절 LinearLayout에서 gravity를 안 줬을 때와 비슷한데, Compose에서는 align이 RowScope 확장으로 제공되는 이유가 ‘부모 레이아웃 타입에 종속된 배치 옵션’을 타입으로 제한하기 위해서다.

내부 동작 원리

Compose 실행 흐름은 Composition → Layout → Drawing 순서다. Column/Row 관점에서 Composition은 ‘Row 노드, Column 노드, Text 노드’를 Slot Table에 기록하는 단계다. Layout은 각 노드의 MeasurePolicy가 Constraints를 받아 size를 정하고 placeChildren을 수행하는 단계다. Drawing은 Modifier.drawBehind/background 같은 DrawModifier가 Canvas에 실제 픽셀을 찍는 단계다.

Compose Compiler는 @Composable 함수를 그대로 호출하지 않는다. 컴파일 결과는 대략 (composer, changedFlags) 파라미터가 추가된 함수로 변형되고, 함수 본문에는 startGroup/endGroup, skipToGroupEnd 같은 호출이 섞인다. 이 그룹 경계가 Slot Table의 그룹으로 저장된다. Column의 content 람다는 별도 그룹으로 들어가며, 자식 호출 순서가 슬롯 인덱스가 된다.

Slot Table에는 ‘이 위치에 어떤 컴포저블이 있었는지’와 remember 값 같은 저장소가 함께 들어간다. recomposition이 발생하면 런타임은 그룹 키와 슬롯 위치를 기준으로 이전 값을 재사용한다. 조건문으로 자식이 사라지면 슬롯이 당겨지고, 다른 remember가 엉뚱한 자리에 붙는 현상(상태 뒤바뀜)이 나타난다. key가 필요한 순간이 여기다.

Recomposition 트리거는 State 읽기(read)다. mutableStateOf 값을 읽은 컴포저블 범위가 관찰 대상이 된다. 값이 바뀌면 그 범위가 invalid로 마킹되고, 다음 프레임에서 해당 그룹만 다시 호출된다. ‘화면 전체가 다시 그려지는 느낌’은 대개 로그를 잘못 찍어서 생긴 착시다. Layout Inspector의 recomposition count와 실제 invalidation 범위를 같이 봐야 한다.

Modifier 체인은 내부적으로 ‘노드 체인’으로 변환된다. padding은 LayoutModifierNode로서 Constraints를 줄이고 placement를 오프셋한다. background는 DrawModifierNode로서 draw 단계에만 관여한다. clickable은 PointerInput/semantics 노드를 추가한다. 체이닝 API가 선택된 이유는 노드 합성을 값 객체처럼 만들기 위해서다. 매번 새로운 Modifier 인스턴스가 만들어지지만, 런타임은 구조 공유를 통해 연결 리스트처럼 다룬다.

@Stable/@Immutable은 런타임이 파라미터 변경 여부를 더 공격적으로 스킵하기 위한 힌트다. Row/Column 자체는 파라미터가 자주 바뀌지 않지만, content에 전달되는 모델 객체가 불안정하면(매 recomposition마다 새 인스턴스) changedFlags가 매번 ‘변경됨’으로 평가되어 자식까지 연쇄 호출된다. 실무에서 리스트 아이템 모델을 data class로 두고도 내부에 MutableList를 넣어 무효화가 폭발한 적이 있다.

RecomposeTraceSample.kt
1package com.example.columnrow
2
3import android.util.Log
4import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.Row
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.remember
12import androidx.compose.runtime.setValue
13import androidx.compose.ui.tooling.preview.Preview
14
15@Composable
16fun RecomposeTraceSample() {
17    var count by remember { mutableIntStateOf(0) }
18
19    Log.d("Recompose", "RecomposeTraceSample called count=$count")
20
21    Column {
22        Text("count=$count")
23        Row {
24            Button(onClick = { count++ }) { Text("+") }
25            Button(onClick = { count-- }) { Text("-") }
26        }
27        StaticFooter()
28    }
29}
30
31@Composable
32private fun StaticFooter() {
33    Log.d("Recompose", "StaticFooter called")
34    Text("footer")
35}
36
37@Preview
38@Composable
39private fun PreviewRecomposeTraceSample() {
40    RecomposeTraceSample()
41}

실행하고 버튼을 누르면 Logcat에 RecomposeTraceSample 호출 로그가 계속 찍힌다. StaticFooter 로그도 같이 찍히면 “전체가 다시 호출되네?”라는 오해가 생긴다. 실제로는 컴포저블 함수 호출이 다시 일어나는 것과, Layout/Drawing이 다시 수행되는 범위는 다를 수 있다. footer가 다시 호출되는 건 같은 Column 그룹 안에 있기 때문이고, footer의 파라미터가 안정적이면 런타임은 내부적으로 skip 경로를 타서 하위 노드 업데이트를 줄인다.

ModifierChainOrderSample.kt
1package com.example.columnrow
2
3import androidx.compose.foundation.interaction.MutableInteractionSource
4import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.padding
6import androidx.compose.foundation.selection.toggleable
7import androidx.compose.material3.Text
8import androidx.compose.runtime.Composable
9import androidx.compose.runtime.remember
10import androidx.compose.ui.Modifier
11import androidx.compose.ui.semantics.Role
12import androidx.compose.ui.semantics.contentDescription
13import androidx.compose.ui.semantics.semantics
14import androidx.compose.ui.tooling.preview.Preview
15import androidx.compose.ui.unit.dp
16
17@Composable
18fun ModifierChainOrderSample(checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
19    val interaction = remember { MutableInteractionSource() }
20
21    Column(
22        Modifier
23            .semantics { contentDescription = "wifi-toggle" }
24            .toggleable(
25                value = checked,
26                onValueChange = onCheckedChange,
27                role = Role.Switch,
28                interactionSource = interaction,
29                indication = null
30            )
31            .padding(16.dp)
32    ) {
33        Text(if (checked) "ON" else "OFF")
34        Text("Tap anywhere in this column")
35    }
36}
37
38@Preview
39@Composable
40private fun PreviewModifierChainOrderSample() {
41    ModifierChainOrderSample(checked = true, onCheckedChange = {})
42}

이 코드를 실행하면 Column 전체가 토글 영역이 된다. semantics를 toggleable 앞에 두면 접근성 노드가 토글 노드와 합쳐지는 방식이 달라질 수 있다. padding을 toggleable 뒤에 두면 터치 영역이 padding을 포함한다. View에서 setPadding과 setOnClickListener 순서가 무관했던 것과 달리, Compose는 Modifier가 노드 체인이라 순서가 의미를 가진다.

실습하기

실습 목표는 세 가지다. (1) Column/Row의 배치 규칙을 눈으로 확인한다. (2) 상태 변경이 어떤 범위에 invalidation을 걸고, 어떤 컴포저블이 다시 호출되는지 로그로 확인한다. (3) Modifier 순서가 레이아웃과 입력 영역을 어떻게 바꾸는지 확인한다.

의존성은 Material3 기준이다. Compose BOM을 쓰면 버전 충돌이 줄어든다. 프로젝트가 이미 Compose 템플릿이면 대부분 들어가 있지만, 직접 추가할 때는 BOM + material3 조합이 가장 덜 삽질한다.

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

BOM 버전은 프로젝트 환경에 따라 다를 수 있다. sync에서 에러가 나면 메시지에 찍히는 ‘could not find’ 좌표를 그대로 따라가면 된다. 처음 Compose 세팅할 때 ‘ui-tooling’ 누락으로 Preview가 하얗게만 떠서 30분을 날린 적이 있다. 에러는 없는데 Preview만 비는 케이스라 더 짜증났다.

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

배치 규칙을 확인하려면 색을 칠하는 게 가장 빠르다. background는 레이아웃 크기를 바꾸지 않으니, padding과 조합해서 ‘어디까지가 콘텐츠 영역인지’를 눈으로 분리할 수 있다.

실행하면 화면 상단에 파란 Header, 가운데 노란 Row, 하단에 분홍 Footer가 세로로 쌓인다. Row 안에서 텍스트 간격이 어떻게 생기는지, padding을 어디에 붙였는지에 따라 색 영역이 어떻게 달라지는지 확인한다.

MainActivity.kt
1package com.example.columnrow
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.fillMaxSize
11import androidx.compose.foundation.layout.padding
12import androidx.compose.material3.MaterialTheme
13import androidx.compose.material3.Surface
14import androidx.compose.material3.Text
15import androidx.compose.runtime.Composable
16import androidx.compose.ui.Modifier
17import androidx.compose.ui.graphics.Color
18import androidx.compose.ui.tooling.preview.Preview
19import androidx.compose.ui.unit.dp
20
21class MainActivity : ComponentActivity() {
22    override fun onCreate(savedInstanceState: Bundle?) {
23        super.onCreate(savedInstanceState)
24        setContent { App() }
25    }
26}
27
28@Composable
29fun App() {
30    MaterialTheme {
31        Surface(Modifier.fillMaxSize()) {
32            Column(Modifier.padding(16.dp).background(Color(0xFFECECEC))) {
33                Text("Header", Modifier.background(Color(0xFFB3D9FF)).padding(8.dp))
34                Row(
35                    Modifier.padding(top = 12.dp).background(Color(0xFFFFF2B3)).padding(8.dp),
36                    horizontalArrangement = Arrangement.spacedBy(12.dp)
37                ) {
38                    Text("Left")
39                    Text("Right")
40                }
41                Text("Footer", Modifier.padding(top = 12.dp).background(Color(0xFFFFC2D1)).padding(8.dp))
42            }
43        }
44    }
45}
46
47@Preview
48@Composable
49private fun PreviewApp() {
50    App()
51}

Row의 background 앞뒤로 padding 위치를 바꾸면 노란색 영역이 줄어들거나 늘어난다. 이 변화는 Layout 단계에서 padding Modifier가 Constraints를 줄이는 방식으로 발생한다. View의 padding은 view 자체의 속성이었지만, Compose는 padding이 노드 체인 중 하나라서 ‘어떤 노드에 적용하느냐’가 곧 레이아웃 트리 구조가 된다.

2단계: 상태를 붙여 recomposition 범위 확인하기

초보가 가장 많이 착각하는 지점이 “state 변경 = 전체 다시 그리기”다. 실제로는 state를 읽은 그룹만 invalid로 마킹된다. 로그를 그룹 단위로 찍으면 범위가 보인다.

실행 후 Toggle 버튼을 누르면 Header 쪽 숫자만 바뀐다. Logcat에서는 HeaderArea가 계속 호출되지만 FooterArea는 거의 스킵되는 패턴을 기대한다. FooterArea가 계속 호출되면 파라미터 안정성이나 호출 위치(같은 그룹)에 문제가 있는지 의심할 수 있다.

StateDrivenLayout.kt
1package com.example.columnrow
2
3import android.util.Log
4import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.padding
7import androidx.compose.material3.Button
8import androidx.compose.material3.Text
9import androidx.compose.runtime.Composable
10import androidx.compose.runtime.getValue
11import androidx.compose.runtime.mutableIntStateOf
12import androidx.compose.runtime.remember
13import androidx.compose.runtime.setValue
14import androidx.compose.ui.Modifier
15import androidx.compose.ui.tooling.preview.Preview
16import androidx.compose.ui.unit.dp
17
18@Composable
19fun StateDrivenLayout() {
20    var clicks by remember { mutableIntStateOf(0) }
21
22    Column(Modifier.padding(16.dp)) {
23        HeaderArea(clicks)
24        Row(Modifier.padding(top = 12.dp)) {
25            Button(onClick = { clicks++ }) { Text("Toggle/Inc") }
26        }
27        FooterArea()
28    }
29}
30
31@Composable
32private fun HeaderArea(clicks: Int) {
33    Log.d("Recompose", "HeaderArea clicks=$clicks")
34    Text("clicks=$clicks")
35}
36
37@Composable
38private fun FooterArea() {
39    Log.d("Recompose", "FooterArea")
40    Text("footer is static")
41}
42
43@Preview
44@Composable
45private fun PreviewStateDrivenLayout() {
46    StateDrivenLayout()
47}

여기서 remember가 빠지면 clicks가 recomposition 때마다 0으로 초기화된다. 초기화가 일어나는 이유는 Slot Table에 저장될 값이 없기 때문이다. remember는 ‘이 그룹 위치의 슬롯에 값을 저장하고, 다음 recomposition에서 같은 슬롯을 재사용하라’는 계약이다. Column/Row 레이아웃과 직접 관련 없어 보이지만, 레이아웃 뼈대에 상태가 붙는 순간 UI가 흔들리는 원인의 70%는 remember의 부재나 위치 변경이다.

3단계: weight와 정렬로 뼈대 확장하기

실무 레이아웃은 ‘왼쪽은 고정, 오른쪽은 남는 공간’ 패턴이 많다. View에서는 layout_weight가 측정 비용을 키우는 주범이었다. Compose의 weight도 비슷하게 자식 측정이 2패스로 바뀌기 때문에, 무심코 남발하면 측정 횟수가 늘어난다.

실행하면 왼쪽 라벨은 필요한 만큼만 차지하고, 오른쪽 텍스트가 남는 공간을 먹는다. 오른쪽 텍스트가 길어지면 한 줄로 줄어들거나 줄바꿈된다. Layout Inspector에서 Row의 자식 측정이 어떻게 일어나는지 확인하면 weight가 왜 비용을 만드는지 감이 온다.

WeightLayoutSample.kt
1package com.example.columnrow
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.width
10import androidx.compose.material3.MaterialTheme
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 WeightLayoutSample() {
21    Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
22        Row(Modifier.fillMaxWidth().background(Color(0xFFEFEFEF)).padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
23            Text("Label", modifier = Modifier.width(72.dp))
24            Text(
25                "This side takes the remaining width and can wrap if it is too long.",
26                modifier = Modifier
27                    .padding(start = 12.dp)
28                    .weight(1f)
29            )
30        }
31        Row(Modifier.fillMaxWidth().background(Color(0xFFEFEFEF)).padding(12.dp)) {
32            Text("No weight")
33            Text(" -> both measure at intrinsic needs", modifier = Modifier.padding(start = 12.dp))
34        }
35    }
36}
37
38@Preview
39@Composable
40private fun PreviewWeightLayoutSample() {
41    MaterialTheme { WeightLayoutSample() }
42}

weight가 붙은 Text는 RowScope의 확장으로만 제공된다. 이 제약은 “부모가 Row일 때만 의미 있는 측정 전략”을 타입으로 고정한다. 또한 weight는 남는 공간 계산을 위해 다른 자식들의 측정 결과를 먼저 알아야 하므로, Row는 ‘고정 자식 측정 → 남는 공간 계산 → weight 자식 재측정’ 형태가 된다. 이 구조가 측정 비용의 근원이다.

심화: Advanced 버전 만들기

Column/Row 레이아웃을 ‘뼈대’로 쓰는 실무 패턴은 결국 재사용 가능한 컴포넌트로 수렴한다. 버튼 하나를 예로 들면, 로딩 상태에서 텍스트가 사라지고 스피너가 들어오며, 연타 방지(debounce)가 필요하고, 아이콘+텍스트 조합과 롱프레스, 접근성 라벨까지 요구된다. 이 요구를 Column/Row로 조립하면 내부 동작까지 같이 드러난다.

한 문단 요약: Column/Row는 MeasurePolicy로 측정·배치를 수행하는 레이아웃 노드이며, content 슬롯 순서가 Slot Table에 저장된다. 상태를 읽은 그룹만 invalid가 되고, Modifier 순서는 노드 체인 순서라서 레이아웃·입력·시맨틱 결과가 바뀐다.

사례 1: 로딩/아이콘/텍스트를 한 Row로 고정하면서 폭 흔들림 막기

로딩 스피너를 넣을 때 가장 흔한 문제는 버튼 폭이 흔들리는 현상이다. 텍스트가 사라졌다가 나타나면 Row의 측정 결과가 달라지고, 부모 레이아웃이 다시 재배치된다. 폭을 고정하거나, 텍스트 영역을 유지한 채 alpha만 바꾸면 흔들림이 줄어든다.

여기서는 Row 내부에 아이콘 슬롯과 텍스트 슬롯을 두고, 로딩 중에는 텍스트를 유지하되 표시만 바꾼다. recomposition은 isLoading을 읽는 그룹에서 발생하지만, Row의 자식 수가 바뀌지 않으니 Slot Table 구조는 안정적이다.

AdvancedButton.kt
1package com.example.columnrow
2
3import android.os.SystemClock
4import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.RowScope
7import androidx.compose.foundation.layout.padding
8import androidx.compose.material3.CircularProgressIndicator
9import androidx.compose.material3.Icon
10import androidx.compose.material3.MaterialTheme
11import androidx.compose.material3.Text
12import androidx.compose.material3.TextButton
13import androidx.compose.runtime.Composable
14import androidx.compose.runtime.Immutable
15import androidx.compose.runtime.getValue
16import androidx.compose.runtime.mutableLongStateOf
17import androidx.compose.runtime.remember
18import androidx.compose.runtime.setValue
19import androidx.compose.ui.Modifier
20import androidx.compose.ui.semantics.contentDescription
21import androidx.compose.ui.semantics.semantics
22import androidx.compose.ui.unit.dp
23
24@Immutable
25data class AdvancedButtonStyle(
26    val contentPadding: Int = 12,
27)
28
29@Composable
30fun AdvancedButton(
31    text: String,
32    modifier: Modifier = Modifier,
33    isLoading: Boolean = false,
34    debounceMs: Long = 500L,
35    a11yLabel: String = text,
36    icon: (@Composable RowScope.() -> Unit)? = null,
37    onLongPress: (() -> Unit)? = null,
38    onClick: () -> Unit,
39) {
40    var lastClickUptime by remember { mutableLongStateOf(0L) }
41
42    TextButton(
43        modifier = modifier.semantics { contentDescription = a11yLabel },
44        enabled = !isLoading,
45        onClick = {
46            val now = SystemClock.uptimeMillis()
47            if (now - lastClickUptime < debounceMs) return@TextButton
48            lastClickUptime = now
49            onClick()
50        }
51    ) {
52        Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(12.dp)) {
53            if (icon != null) icon()
54            if (isLoading) {
55                CircularProgressIndicator(strokeWidth = 2.dp)
56                Text(text, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.35f))
57            } else {
58                Text(text)
59            }
60        }
61    }
62}

여기서 debounce는 remember로 lastClickUptime을 Slot Table에 저장해서 구현한다. remember가 없으면 매 recomposition마다 0으로 초기화되어 연타 방지가 무력화된다. onLongPress는 TextButton만으로는 처리하기 어려워서 pointerInput이나 combinedClickable로 확장하는 게 일반적이다. 이 글에서는 구조를 단순화했지만, 확장 지점이 어디인지가 더 중요하다.

사례 2: long press + semantics를 Modifier로 분리해 레이아웃과 입력을 분리하기

입력 처리를 content 안에 섞으면 Column/Row의 역할(측정/배치)과 입력 역할이 뒤엉킨다. Modifier로 입력과 시맨틱을 분리하면 노드 체인이 명확해지고, Layout Inspector에서도 원인 추적이 쉬워진다.

실행하면 탭은 onClick, 롱프레스는 onLongPress가 호출된다. 접근성 서비스에서는 a11yLabel이 읽힌다. Modifier 순서를 바꿔서 padding 앞뒤에 clickable을 두면 터치 영역이 달라지는 것도 확인 가능하다.

AdvancedButtonContent.kt
1package com.example.columnrow
2
3import androidx.compose.foundation.combinedClickable
4import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.padding
6import androidx.compose.material3.Icon
7import androidx.compose.material3.MaterialTheme
8import androidx.compose.material3.Text
9import androidx.compose.runtime.Composable
10import androidx.compose.ui.Modifier
11import androidx.compose.ui.semantics.contentDescription
12import androidx.compose.ui.semantics.semantics
13import androidx.compose.ui.tooling.preview.Preview
14import androidx.compose.ui.unit.dp
15
16@Composable
17fun AdvancedButtonContent(
18    text: String,
19    modifier: Modifier = Modifier,
20    a11yLabel: String = text,
21    onClick: () -> Unit,
22    onLongPress: () -> Unit,
23) {
24    Row(
25        modifier = modifier
26            .semantics { contentDescription = a11yLabel }
27            .combinedClickable(onClick = onClick, onLongClick = onLongPress)
28            .padding(12.dp)
29    ) {
30        Icon(
31            painter = androidx.compose.ui.res.painterResource(android.R.drawable.ic_menu_send),
32            contentDescription = null
33        )
34        Text(text, modifier = Modifier.padding(start = 8.dp), style = MaterialTheme.typography.labelLarge)
35    }
36}
37
38@Preview
39@Composable
40private fun PreviewAdvancedButtonContent() {
41    MaterialTheme { AdvancedButtonContent("Send", onClick = {}, onLongPress = {}) }
42}

흑역사 1: 버튼 연타 방지를 remember 없이 구현했다가 QA에서 재현이 안 되는 버그가 나왔다. 디버깅 당시 Logcat에는 클릭 간격 체크가 정상처럼 보였는데, recomposition이 한 번만 일어나면 lastClick 값이 유지되는 것처럼 착각했다. 화면 회전이나 다크모드 토글로 recomposition 경로가 달라지면 값이 리셋되어 연타가 뚫렸다. 원인은 Slot Table에 저장되지 않는 지역 변수였다.

흑역사 2: Row 안에 if (isLoading) { Spinner } else { Icon } 구조를 넣고, 텍스트까지 조건으로 빼버렸다. 버튼 폭이 프레임마다 흔들렸고, Layout Inspector에서 measure 횟수가 튀었다. 3시간 삽질 끝에 알아낸 건 자식 수와 측정 결과가 계속 변하면서 부모 배치까지 흔들린다는 점이었다. 해결은 ‘자식 수를 고정’하고 alpha/visibility 성격을 draw 단계로 밀어넣는 방향이었다.

자주 하는 실수

1) Column에 padding을 주고 background를 기대한 영역과 다르게 칠해짐

증상: padding을 줬는데 배경색이 padding 영역을 포함하지 않거나, 반대로 포함되어야 할 영역이 비어 보인다. 특히 카드처럼 보여야 하는데 회색이 이상한 크기로 칠해진다.

원인: Modifier는 순서가 의미를 가진 노드 체인이다. padding이 먼저면 레이아웃 크기 계산에 반영되고, background는 그 결과 크기에 그린다. background가 먼저면 원래 크기에 그려지고 padding은 바깥 여백처럼 동작한다.

해결: ‘배경이 포함해야 하는 영역’을 먼저 정한다. 콘텐츠만 칠하려면 padding 후 background, 바깥까지 칠하려면 background 후 padding이 아니라, 보통은 Surface로 그리기 계층을 올리고 Row/Column은 콘텐츠 레이아웃만 담당하게 둔다.

2) 조건문으로 Row 자식 수를 바꿔 상태가 엉킴(remember 값이 뒤바뀜)

증상: 체크박스 리스트에서 특정 아이템을 토글하면 다른 아이템이 토글되거나, 텍스트 입력이 다른 줄로 이동한다. 로그에는 값이 정상인데 UI만 바뀐다.

원인: Slot Table은 호출 순서 기반이다. Row content에서 if로 자식이 생겼다 사라지면 슬롯 인덱스가 당겨지고, remember 저장소가 다른 컴포저블에 매칭된다. ViewHolder 재사용 버그와 비슷한데 원인이 다르다.

해결: key로 그룹을 고정하거나, 조건부 UI를 ‘같은 슬롯 수’로 표현한다. 예를 들어 아이콘을 없애는 대신 투명 처리하거나 Spacer로 자리만 유지한다. LazyColumn에서는 items(key=...)를 반드시 고려한다.

3) weight 남발로 측정 횟수 증가 + 스크롤 성능 저하

증상: 리스트 스크롤이 미세하게 끊기고, 특히 텍스트가 긴 아이템에서 더 심해진다. Layout Inspector에서 measure/layout 시간이 아이템마다 커진다.

원인: weight는 남는 공간을 계산해야 하므로 Row/Column이 2패스 측정을 수행한다. 고정 크기 자식들을 먼저 측정한 뒤, 남은 공간을 weight 자식에 재분배한다. 아이템 수가 많으면 이 비용이 누적된다.

해결: 정말 남는 공간 분배가 필요한 곳에만 weight를 둔다. 고정 폭이 가능한 영역은 width/requiredWidth로 고정한다. 텍스트는 maxLines/overflow를 지정해 측정 복잡도를 줄인다.

4) remember 위치가 바뀌어 상태가 리셋됨

증상: 버튼을 눌러 카운트를 올렸는데, 특정 조건을 토글하면 카운트가 0으로 돌아간다. 또는 화면 일부만 바뀌었는데 입력값이 초기화된다.

원인: remember는 ‘그룹 위치’에 저장된다. Column/Row의 content에서 조건문으로 remember가 실행되는 위치가 바뀌면, 런타임은 다른 슬롯으로 인식하고 초기화한다. 컴파일러가 생성한 그룹 경계가 달라지는 것이다.

해결: remember는 가능하면 조건문 바깥의 안정적인 위치에 둔다. 조건이 필요하면 remember(key1=...)로 키를 명시하거나, 상태를 상위로 끌어올린다(state hoisting).

5) clickable을 padding 앞에 둬서 터치 영역이 생각보다 작음

증상: Row 전체가 눌릴 줄 알았는데 텍스트 부분만 눌린다. QA가 “터치가 잘 안 된다”라고 리포트한다.

원인: clickable이 pointer input 노드를 만들고, 그 노드가 차지하는 영역은 그 시점의 레이아웃 크기다. padding을 clickable 뒤에 두면 시각적 영역은 커졌는데 입력 영역은 그대로일 수 있다.

해결: 터치 타겟을 키우려면 padding을 clickable 앞에 둔다. 또는 minimumTouchTargetSize 같은 Modifier를 추가한다. Layout Inspector의 ‘Hit test’를 켜서 실제 터치 영역을 확인한다.

ClickAreaPitfall.kt
1package com.example.columnrow
2
3import androidx.compose.foundation.background
4import androidx.compose.foundation.clickable
5import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.padding
7import androidx.compose.material3.Text
8import androidx.compose.runtime.Composable
9import androidx.compose.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 ClickAreaPitfall(onClick: () -> Unit) {
16    Row(
17        Modifier
18            .background(Color(0xFFFFF2B3))
19            .clickable(onClick = onClick)
20            .padding(24.dp)
21    ) {
22        Text("Tap area includes padding")
23    }
24}
25
26@Preview
27@Composable
28private fun PreviewClickAreaPitfall() {
29    ClickAreaPitfall(onClick = {})
30}

성능 최적화 체크리스트

  • Layout Inspector에서 Column/Row 노드의 size와 position을 확인하고, padding/background 순서 변경 전후 스냅샷을 남긴다
  • weight가 들어간 Row/Column은 측정 2패스 가능성을 의심하고, 리스트 아이템에서는 최소화한다
  • 조건문으로 자식 수가 바뀌는 content 슬롯은 key 적용 여부를 점검한다(특히 LazyColumn items)
  • remember는 조건문 바깥의 안정적인 그룹에 두고, 불가피하면 remember(key1=...)를 사용한다
  • 모델 객체가 매 recomposition마다 새로 생성되는지 확인한다(예: list.toList(), map {})
  • @Immutable/@Stable을 남발하지 않고, 실제로 내부가 불변인지(특히 MutableList/MutableState 포함) 검증한다
  • Modifier 체인에서 입력(clickable/toggleable)과 padding의 순서를 점검하고, 터치 타겟 48dp를 만족하는지 확인한다
  • Text가 많은 Row에서는 maxLines/overflow를 지정해 측정 비용과 레이아웃 흔들림을 줄인다
  • recomposition 카운트는 ‘호출 횟수’와 ‘레이아웃/그리기 비용’을 분리해서 해석한다(호출이 곧 비용은 아님)
  • debugImplementation의 ui-tooling을 넣고, Preview/Inspector가 비는 문제를 의존성부터 의심한다
  • 스크롤 화면에서 Surface/background 중첩을 줄이고 오버드로우(Developer options)로 확인한다
  • click 로그는 onClick에만 찍지 말고, state read 지점(컴포저블 시작)에 찍어 invalidation 범위를 확인한다

자주 묻는 질문

Column/Row는 내부적으로 LinearLayout처럼 measure를 여러 번 하나?

그렇다. Column/Row는 Layout 단계에서 MeasurePolicy를 통해 자식을 측정한다. weight가 없으면 대체로 자식당 1회 측정으로 끝나지만, weight가 들어가면 남는 공간 계산 때문에 2패스가 된다. View의 LinearLayout이 weight 때문에 child를 재측정하던 것과 같은 구조다. Layout Inspector에서 해당 Row를 선택하면 measure/layout 시간이 튀는 지점을 확인할 수 있다. 학습 키워드는 Constraints, Placeable, MeasurePolicy, weight 2-pass 측정이다.

왜 content가 @Composable 람다 슬롯 형태인가? 그냥 children 리스트를 받으면 안 되나?

슬롯 람다는 호출 순서 자체가 Slot Table의 구조가 되기 때문이다. children 리스트를 만들면 리스트 생성 비용(할당)과 순서 안정성 문제가 생긴다. 슬롯은 컴파일러가 그룹 경계를 만들기 쉬워서 changedFlags와 skip 경로를 설계하기 좋다. 또한 RowScope/ColumnScope 같은 리시버 스코프를 통해 weight/align 같은 기능을 ‘부모 타입에 종속된 API’로 제한할 수 있다. 학습 키워드는 Slot Table group, composable lambda, scope receiver다.

상태 하나 바꾸면 Column 안의 모든 컴포저블이 다시 호출되는 것처럼 보인다. 정상인가?

정상처럼 보일 수 있다. recomposition은 ‘함수 호출’ 레벨에서 다시 실행되지만, 런타임은 changedFlags와 안정성 정보를 기반으로 하위 그룹을 skip할 수 있다. 그래서 로그만 보면 전부 다시 호출되는 것 같아도, 실제 노드 업데이트나 레이아웃 재계산이 줄어들 수 있다. 확인 방법은 (1) Logcat을 컴포저블 시작점과 하위 컴포저블에 분리해서 찍고, (2) Layout Inspector의 recomposition count와 measure/layout 시간을 같이 보는 것이다. 학습 키워드는 invalidation, skipping, stable parameters다.

remember가 없으면 왜 값이 초기화되나? 그냥 지역 변수면 안 되나?

@Composable 함수는 UI를 그리는 ‘선언’이 아니라, recomposition 때마다 다시 실행되는 코드다. 지역 변수는 함수 호출 스택에만 존재하므로 호출이 끝나면 사라진다. remember는 Slot Table의 특정 슬롯에 값을 저장하고, 동일한 그룹 위치로 돌아왔을 때 그 값을 꺼내 쓰게 만든다. 즉 remember는 런타임 저장소와 소스 코드 위치를 연결하는 장치다. remember 위치가 조건문 때문에 바뀌면 다른 슬롯으로 인식되어 초기화가 발생한다. 학습 키워드는 remember slot, group position, key, state hoisting이다.

Modifier는 왜 체이닝 API인가? 객체를 계속 만들면 느리지 않나?

Modifier는 개념적으로 ‘노드들의 합성’이다. 체이닝은 노드 체인을 왼쪽에서 오른쪽으로 쌓는 표현이며, 런타임은 이를 연결 구조로 다룬다. 매번 인스턴스가 생기더라도 대부분은 작은 객체이고, 구조 공유가 일어나며, 더 중요한 건 ‘순서가 의미를 가진다’는 점을 API가 그대로 드러낸다는 점이다. padding→background와 background→padding이 다른 결과를 내는 건 순서 기반 노드 체인이기 때문이다. 성능은 프로파일링으로 확인하고, 과도한 동적 Modifier 생성(매 프레임 람다 캡처 등)을 피하는 쪽이 더 큰 이득이다. 학습 키워드는 Modifier node, order, allocation hotspot이다.

@Stable/@Immutable을 붙이면 recomposition이 줄어드나?

조건부다. 이 애노테이션은 런타임이 ‘이 타입의 equals/프로퍼티 변경 규칙을 신뢰할 수 있다’고 가정하게 만든다. 실제로 내부가 불변이 아니라면 오히려 버그를 숨긴다. 예를 들어 @Immutable data class 안에 MutableList가 있으면 참조는 같아도 내부가 바뀌어 UI가 갱신되지 않을 수 있다. recomposition을 줄이려면 애노테이션보다 ‘파라미터로 전달되는 객체가 매번 새로 만들어지는지’와 ‘state read 범위를 좁혔는지’를 먼저 점검해야 한다. 학습 키워드는 stability inference, skippable, immutable contract다.

Column/Row 배치가 기대와 다를 때 어디부터 디버깅해야 하나?

첫 번째는 Constraints 흐름이다. 부모가 어떤 최대/최소 크기를 내려주는지에 따라 자식 배치가 달라진다. 두 번째는 Modifier 순서다. padding은 Constraints를 줄이고, fillMaxWidth는 최대 폭을 소비하며, size/requiredSize는 그 위에 강제한다. 세 번째는 Arrangement/Alignment다. 남는 공간이 있을 때만 Arrangement가 의미를 가진다. 디버깅 루틴은 (1) 배경색으로 영역을 칠해 레이아웃 크기를 눈으로 확인, (2) Layout Inspector로 실제 size/position 확인, (3) 문제 노드의 Modifier를 한 개씩 제거하며 원인을 좁히는 방식이 가장 빠르다. 학습 키워드는 Constraints, fill/wrap, inspector size overlay다.

관련 글

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

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

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

12. Compose State로 UI가 바뀌는 이유: mutableStateOf와 remember의 내부
Compose 기본2026.03.01

12. Compose State로 UI가 바뀌는 이유: mutableStateOf와 remember의 내부

mutableStateOf와 remember로 UI가 갱신되는 이유를 Compose Runtime 관점에서 설명한다. Slot Table, recomposition 추적, 안정성(@Stable)까지 연결한다. 초보도 내부 흐름을 잡는다.','primaryKeywords':['Jetpack Compose State','m

13. Compose State·MutableState 동작 원리: 값 변경이 UI에 반영되는 이유
Compose 기본2026.03.01

13. Compose State·MutableState 동작 원리: 값 변경이 UI에 반영되는 이유

Jetpack Compose에서 State/MutableState가 왜 필요한지, remember·Slot Table·recomposition 추적이 어떻게 연결되는지 내부 동작 관점에서 설명한다. 실습 코드 포함. 154자 내외로 맞춘 설명 문장이다.