Jetpack Compose에서는 UI 요소를 쉽게 조정할 수 있다. Surface와 Modifier를 사용하여 간단한 UI를 꾸미는 방법을 알아보자.
Surface는 Material Design 시스템에서 제공하는 구성요소로, UI 요소에 배경을 쉽게 추가할 수 있다. 아래 코드는 Greeting 함수에서 Text 컴포저블을 Surface로 감싸고, 배경 색상을 테마의 primary 색상으로 설정한 예시다:
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Surface(color = MaterialTheme.colorScheme.primary) {
Text(
text = "Hello $name!",
modifier = modifier
)
}
}

Surface 내부에 중첩된 구성요소는 배경 색상 위에 그려진다. 이 코드에서는 Text 컴포저블이 배경 위에 나타나며, 기본적으로 MaterialTheme의 onPrimary 색상이 적용된다는 점을 알 수 있다. onPrimary 색상은 배경색인 primary와 대비되도록 설정된 색상이다.
Modifier는 UI 요소의 배치, 크기, 동작 등을 제어하는 데 사용된다.
예를 들어, padding을 사용해 UI 요소에 여백을 줄 수 있다.
아래 코드는 Greeting 함수에 패딩을 추가하는 예시다:
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.unit.dp
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Surface(color = MaterialTheme.colorScheme.primary) {
Text(
text = "Hello $name!",
modifier = modifier.padding(24.dp)
)
}
}

modifier.padding(24.dp)를 사용하면 Text 컴포저블 주변에 24dp의 여백이 추가된다. Modifier는 체이닝 방식으로 여러 속성을 동시에 적용할 수 있다.
이렇게 Surface와 Modifier를 사용해 UI 요소에 색상과 여백을 간단하게 적용할 수 있다. MaterialTheme과 같은 시스템을 활용하면 더욱 일관된 디자인을 구현할 수 있으며, Modifier를 통해 요소의 배치를 세밀하게 조정할 수 있다.
UI 구성 요소가 많아질수록 중첩 레벨이 깊어져 가독성이 떨어질 수 있다. 이를 해결하기 위해 재사용 가능한 작은 구성 요소를 만들어야 한다. 이렇게 하면 코드 중복을 줄이고, 유지보수 및 수정이 더 쉬워진다.
컴포저블 함수에는 기본적으로 빈 Modifier를 매개변수로 포함시키는 것이 좋다.
이 Modifier는 첫 번째 컴포저블에 전달되어 레이아웃이나 동작을 조정할 수 있게 한다. 이를 통해 함수 외부에서 유연하게 레이아웃을 수정할 수 있다.
다음은 인사말을 포함한 MyApp 컴포저블을 만드는 예시다. 이 컴포저블을 사용하면 중복 코드를 줄이고, onCreate 콜백과 미리보기를 간단하게 정리할 수 있다.
@Composable
fun MyApp(modifier: Modifier = Modifier) {
Surface(
modifier = modifier,
color = MaterialTheme.colorScheme.background
) {
Greeting("Android")
}
}
MyApp 컴포저블을 재사용하여 MainActivity와 미리보기 코드를 정리할 수 있다. 아래와 같이 MyApp을 사용해 코드를 단순화할 수 있다.
수정 전후를 비교해보자.
// 수정 전
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
BasicsCodelabTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding)
)
}
}
}
}
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Surface(color = MaterialTheme.colorScheme.primary) {
Text(
text = "Hello $name!",
modifier = modifier.padding(24.dp)
)
}
}
@Preview(showBackground = true, name = "Text preview")
@Composable
fun GreetingPreview() {
BasicsCodelabTheme {
Greeting("Android")
}
}
@Composable
fun MyApp(modifier: Modifier = Modifier) {
Surface(
modifier = modifier,
color = MaterialTheme.colorScheme.background
) {
Greeting("Android")
}
}
// 수정 후
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
BasicsCodelabTheme {
MyApp(modifier = Modifier.fillMaxSize())
}
}
}
}
@Composable
fun MyApp(modifier: Modifier = Modifier) {
Surface(
modifier = modifier,
color = MaterialTheme.colorScheme.background
) {
Greeting("Android")
}
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Surface(color = MaterialTheme.colorScheme.primary) {
Text(
text = "Hello $name!",
modifier = modifier.padding(24.dp)
)
}
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
BasicsCodelabTheme {
MyApp()
}
}

첫 번째 코드는 MainActivity에서 Scaffold를 사용해 레이아웃을 구성한다. Scaffold는 상단바, 하단바, 플로팅 액션 버튼 등을 쉽게 배치할 수 있도록 도와주는 레이아웃 컨테이너다. 주요 내용은 다음과 같다:
Scaffold의 modifier로 fillMaxSize()를 설정해 화면 전체를 차지하게 하고, innerPadding 값을 받아 이를 Greeting 컴포저블의 modifier로 전달한다. innerPadding은 Scaffold 내부의 콘텐츠가 상단바나 하단바와 겹치지 않도록 여백을 지정해 준다.Greeting 함수는 Surface로 감싸져 있으며, Surface의 배경색은 MaterialTheme.colorScheme.primary로 설정된다. 그 안의 Text는 modifier.padding(24.dp)로 패딩을 추가해 여백을 확보한다.Scaffold를 사용해 전체 레이아웃을 구성하고, 콘텐츠의 여백을 제어한다.Surface를 사용해 배경색을 설정한다.Greeting 컴포저블은 패딩을 적용한 Text를 렌더링한다.두 번째 코드는 MainActivity에서 MyApp을 바로 호출해 레이아웃을 구성한다. Scaffold는 사용되지 않고, 대신 MyApp과 Surface를 사용해 화면을 덮는 구조로 작성되었다.
MyApp은 Surface로 감싸져 있으며, 이 Surface의 color로 MaterialTheme.colorScheme.background가 설정된다. 이 Surface가 화면 전체를 덮도록 modifier = modifier.fillMaxSize()가 적용된다.Greeting은 Surface로 배경을 설정한 상태에서 Text를 렌더링하는데, 이때 배경 색상은 MaterialTheme.colorScheme.primary가 적용된다.GreetingPreview에서는 BasicsCodelabTheme 안에 MyApp을 호출해 미리보기를 제공한다.Scaffold를 사용하지 않고, MyApp이라는 래퍼 컴포저블을 통해 레이아웃을 정의한다.Surface의 fillMaxSize()로 화면 전체에 배경색을 적용한다.MyApp 내부에서 Greeting이 호출되며, 이때도 Surface로 배경색이 설정된다.Scaffold를 사용해 레이아웃을 설정하고 여백을 관리하는 반면, 두 번째 코드는 Scaffold 없이 Surface로 화면 전체를 덮는 구조다.Scaffold 내부에서만 배경색이 설정되며, 그 배경은 Greeting 컴포저블에 국한된다. 두 번째 코드는 MyApp에서 Surface의 fillMaxSize()를 적용해 화면 전체에 배경색을 덮는다.두 번째 코드에서 MyApp의 Surface에 modifier.fillMaxSize()가 적용되었기 때문이다. 이 Surface는 MaterialTheme.colorScheme.background를 사용하여 배경색을 화면 전체에 적용한다. fillMaxSize()가 화면 크기만큼의 영역을 차지하도록 명시되었기 때문에, 배경색이 화면 전체를 덮게 된다.
첫 번째 코드에서 Scaffold(modifier = Modifier.fillMaxSize())를 사용했음에도 화면 전체를 덮지 않는 이유는 Scaffold의 기본 동작과 관련이 있다. Scaffold는 자체적으로 여러 슬롯(slots)을 제공하는 컨테이너로, 상단바, 하단바, 본문 영역 등의 UI 요소를 배치할 수 있다.
이 코드에서는 Scaffold 내부에 본문 콘텐츠를 배치하는 슬롯이 설정되어 있으며, 그 콘텐츠는 Greeting 컴포저블이다. Scaffold의 본문 영역에 패딩을 적용하기 위해 innerPadding을 Greeting의 modifier로 전달했는데, 이 innerPadding이 상단바나 하단바를 위해 남겨둔 여백 때문에 Greeting의 컴포넌트가 화면 전체를 덮지 않는다.
즉, Scaffold는 상단바나 하단바를 배치할 공간을 미리 확보하기 때문에 콘텐츠가 화면 전체를 덮지 않게 된다. 만약 Scaffold가 아니라 Surface에 fillMaxSize()를 적용했다면 화면 전체에 UI가 그려졌을 것이다.
만약 Scaffold에서 상단바나 하단바를 사용하지 않고 화면 전체를 덮고 싶다면, innerPadding을 제거하거나 Scaffold를 사용하지 않고 Surface를 직접 사용하면 된다.
Surface(modifier = Modifier.fillMaxSize()) {
Greeting("Android")
}

Compose의 세 가지 기본 표준 레이아웃 요소는 Column, Row, Box다.
// 수정 전
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Surface(color = MaterialTheme.colorScheme.primary) {
Text(
text = "Hello $name!",
modifier = modifier.padding(24.dp)
)
}
}
// 수정 후
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Surface(color = MaterialTheme.colorScheme.primary) {
Column(modifier = modifier.padding(24.dp)) {
Text(text = "Hello ")
Text(text = name)
}
}
}
기존 코드에 Column을 설정하면 어떻게 변할까?

기존에는 Hello Android 였던 화면이 Column을 설정함으로 인해 줄바꿈이 되었다.
구성 가능한 함수는 Kotlin의 일반 함수처럼 동작하므로, 우리가 코드를 작성할 때처럼 다양한 로직을 추가할 수 있다.
예를 들어, for 루프를 사용해서 Column 안에 여러 개의 요소를 반복적으로 추가할 수 있다.
즉, Kotlin의 for 루프를 사용해 UI를 그릴 수 있는 것이다.
@Composable
fun MyApp(
modifier: Modifier = Modifier,
names: List<String> = listOf("World", "Compose")
) {
Column(modifier) {
for (name in names) {
Greeting(name = name)
}
}
}

아직 크기를 설정하지 않았거나 크기를 제한하지 않아서, 각 요소는 가능한 최소한의 공간만 차지하고 있다. 미리보기에서도 같은 방식으로 요소들이 나타난다.
이를 해결하기 위해, 미리보기를 작은 화면(예: 소형 스마트폰)에서 어떻게 보이는지 확인할 수 있도록 크기를 설정할 수 있다.
소형 스마트폰의 일반적인 너비인 320dp로 설정하려면, @Preview 주석에 widthDp라는 매개변수를 추가하면 된다.

import androidx.compose.foundation.layout.fillMaxWidth
@Composable
fun MyApp(
modifier: Modifier = Modifier,
names: List<String> = listOf("World", "Compose")
) {
Column(modifier = modifier.padding(vertical = 4.dp)) {
for (name in names) {
Greeting(name = name)
}
}
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Surface(
color = MaterialTheme.colorScheme.primary,
modifier = modifier.padding(vertical = 4.dp, horizontal = 8.dp)
) {
Column(modifier = Modifier.fillMaxWidth().padding(24.dp)) {
Text(text = "Hello ")
Text(text = name)
}
}
}
MyApp 함수names: List<String>: 기본적으로 "World"와 "Compose"라는 두 개의 이름을 포함하는 리스트이다. names는 for 루프에서 반복되어 Greeting 컴포저블에 각 이름이 전달된다.Column: 세로로 UI 요소들을 쌓는 레이아웃이다. 여기에 각 이름에 대해 Greeting 컴포저블을 추가해 나열하는 방식으로 UI가 구성된다.modifier.padding(vertical = 4.dp): Column의 위아래에 4dp의 패딩을 적용하여 공간을 확보한다.for (name in names): names 리스트에 있는 각 이름을 순회하면서 Greeting 컴포저블을 호출하여 이름을 전달한다.Greeting 함수name: String: MyApp에서 전달된 이름이 Greeting 함수로 들어간다.Surface: 배경과 스타일을 적용하는 컨테이너 역할을 한다. MaterialTheme.colorScheme.primary 색상을 배경으로 사용한다.modifier.padding(vertical = 4.dp, horizontal = 8.dp): Surface 주위에 4dp의 위아래 패딩과 8dp의 좌우 패딩을 설정해 여백을 추가한다.Column: 텍스트를 세로로 쌓기 위해 사용된다. fillMaxWidth()로 가로 공간을 꽉 채우고, 내부에는 24dp의 패딩을 적용한다.Text(text = "Hello "): 첫 번째 텍스트로 "Hello"라는 문자열을 출력한다.Text(text = name): name 값을 받아서 이름을 출력한다. 예를 들어, "Hello World" 또는 "Hello Compose" 같은 텍스트가 표시된다.MyApp에서 리스트에 있는 이름을 Greeting 컴포저블에 하나씩 전달하여 화면에 표시한다.Surface로 감싸져 배경 색상과 여백이 설정되며, "Hello"와 이름이 함께 출력된다.마지막으로 컴포저블에 버튼을 추가해보자.

Greeting 함수에 버튼을 추가하려고 한다. 버튼은 Jetpack Compose에서 Button, ElevatedButton, OutlinedButton 등 여러 종류가 있다. 여기서는 ElevatedButton을 사용할 것이다.
ElevatedButton(
onClick = { /* 클릭 시 동작을 넣을 수 있음 */ }
) {
Text("Show more")
}
이 버튼을 누르면 어떤 일이 발생하게 만들 수 있는 클릭 동작(onClick)을 설정할 수 있다. 버튼 안에 Text 컴포저블을 넣어서 "Show more"이라는 글자를 버튼에 표시한다.
텍스트와 버튼을 나란히 배치하기 위해 Row를 사용한다. Row는 여러 컴포저블을 가로로 정렬하는 레이아웃이다. 텍스트는 세로로 쌓아야 하므로, Column을 사용한다.
Row(modifier = Modifier.padding(24.dp)) {
Column(modifier = Modifier.weight(1f)) {
Text(text = "Hello ")
Text(text = name)
}
ElevatedButton(
onClick = { /* 클릭 동작 */ }
) {
Text("Show more")
}
}
weight 사용텍스트를 왼쪽에, 버튼을 오른쪽에 배치하기 위해 Column에 weight(1f)을 적용한다. weight는 레이아웃에서 남은 공간을 어떻게 분배할지를 결정한다. weight(1f)를 적용한 요소는 가능한 한 많은 공간을 차지하게 되고, 나머지 공간에 버튼이 들어간다.

만약 weight(1f)를 적용하지 않았다면 텍스트와 버튼 사이에 공간이 없기 때문에 위와 같이 UI가 구성된다.
weight의 기본 개념:
다양한 weight 값:
weight 값의 의미:
예시:
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<View
android:layout_width="0dp"
android:layout_height="50dp"
android:layout_weight="1"/>
<View
android:layout_width="0dp"
android:layout_height="50dp"
android:layout_weight="2"/>
<View
android:layout_width="0dp"
android:layout_height="50dp"
android:layout_weight="3"/>
</LinearLayout>
이 경우, 세 View는 각각 전체 너비의 1/6, 2/6, 3/6을 차지하게 된다.
Row: 텍스트와 버튼을 가로로 나란히 배치.Column: 텍스트 두 줄을 세로로 쌓음.weight(1f): 텍스트가 버튼을 밀어내고 왼쪽을 차지하도록 함.이 섹션에서는 화면에 약간의 상호작용을 추가한다. 지금까지는 정적 레이아웃을 만들었지만, 이제는 사용자 변경사항에 반응하여 화면과 상호작용할 수 있게 한다.

버튼을 클릭할 수 있게 만드는 방법과 항목의 크기를 조절하는 방법을 알아보기 전에 각 항목이 펼쳐진 상태인지를 가리키는 값을 어딘가에 저장해야 한다.
이 값을 항목의 상태라고 한다.
인사말마다 이러한 값 중 하나가 필요하므로 이 값의 논리적 위치는 Greeting 컴포저블에 있다.
// Don't copy over
@Composable
fun Greeting(name: String) {
var expanded = false // Don't do this!
Surface(
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp)
) {
Row(modifier = Modifier.padding(24.dp)) {
Column(modifier = Modifier.weight(1f)) {
Text(text = "Hello, ")
Text(text = name)
}
ElevatedButton(
onClick = { expanded = !expanded }
) {
Text(if (expanded) "Show less" else "Show more")
}
}
}
}
onClick 작업과 동적 버튼 텍스트도 추가했다.
하지만, 이 작업은 예상대로 작동하지 않는다.
expanded 변수에 다른 값을 설정해도 Compose에서 이 값을 상태 변경으로 감지하지 않으므로 아무 일도 일어나지 않는다.
Compose 앱에서는 구성 가능한 함수가 데이터를 받아서 화면(UI)을 만드는 역할을 한다.
예를 들어, 이름이나 숫자 같은 데이터가 변경되면, Compose가 해당 데이터를 사용해 화면을 업데이트한다. 이 과정을 리컴포지션이라고 한다.
Compose는 데이터가 변경된 부분만 다시 그리기 때문에 전체 화면을 다시 만들 필요가 없다. 화면을 빠르게 업데이트할 수 있는 이유가 여기에 있다.
하지만 중요한 점은, 구성 가능한 함수는 상황에 따라 자주 실행되거나, 실행 순서가 달라질 수 있다는 것이다. 그래서 함수가 실행되는 순서나 얼마나 자주 실행되는지에 의존하는 코드를 작성하면 문제가 생길 수 있다. 따라서, 함수가 어떤 순서로 실행되더라도 잘 동작하도록 코드를 작성하는 것이 중요하다.
쉽게 말해, 함수의 실행 순서나 횟수에 신경 쓰지 말고 데이터가 바뀌면 그에 맞춰 화면을 업데이트하도록 만들라는 뜻이다.
Compose에서 UI를 업데이트할 때는 상태(State)를 사용하는 것이 중요하다. 상태는 UI가 어떻게 보여야 하는지를 결정하는 데이터다. 상태가 변경되면 UI도 자동으로 업데이트된다. 이를 리컴포지션이라고 한다.
mutableStateOf: 이 함수는 Compose에서 상태를 만들 때 사용한다. 상태가 변경되면 UI가 자동으로 업데이트된다. 예를 들어, 버튼 클릭 시 상태가 바뀌고, 그에 따라 UI가 다시 그려진다. val expanded = mutableStateOf(false) // 상태를 정의
remember: 상태를 컴포저블이 다시 그려질 때 유지하려면 remember를 사용해야 한다. remember는 컴포저블이 재구성될 때도 상태를 기억하여 유지시킨다. 즉, 컴포저블이 다시 그려질 때 상태가 초기화되지 않고 계속 유지된다. val expanded = remember { mutableStateOf(false) } // 상태를 기억
이러한 특성 때문에 대부분의 Jetpack Compose 앱에서는 mutableStateOf와 remember를 함께 사용하는 경우가 많다.
상태가 변경되면 Compose는 상태를 읽는 컴포저블을 자동으로 다시 그린다. 그래서 상태가 바뀔 때마다 UI가 자동으로 업데이트된다.
상태가 컴포저블 내에서 정의되지 않으면, 상태가 바뀔 때마다 상태가 초기화될 수 있다. 이는 상태를 기억하지 않기 때문이다.
@Composable
fun Greeting() {
val expanded = remember { mutableStateOf(false) } // 상태를 기억
// 상태를 사용하는 UI
}
상태는 컴포저블이 호출될 때마다 유지된다. 만약 여러 번 호출된 컴포저블이 있다면, 각 호출마다 독립적인 상태를 가질 수 있다.
mutableStateOf: 상태를 정의하는 함수.remember: 상태를 재구성 간에 유지하게 해주는 함수.
우선 버튼을 클릭하면 "텍스트만 변경"되도록 해보자.
버튼의 크기가 작아지거나 커지는 것이 아니라, 텍스트의 크기에 따라 버튼의 크기도 자동으로 조정되는 것이다.
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
val expanded = remember { mutableStateOf(false) }
Surface(
color = MaterialTheme.colorScheme.primary,
modifier = modifier.padding(vertical = 4.dp, horizontal = 8.dp)
) {
Row(modifier = Modifier.padding(24.dp)) {
Column(modifier = Modifier.weight(1f)) {
Text(text = "Hello ")
Text(text = name)
}
ElevatedButton(onClick = { expanded.value = !expanded.value }) {
Text(if (expanded.value) "Show less" else "Show more")
}
}
}
}
expanded.value가 변경되고, 이에 따라 버튼의 텍스트가 "Show less" 또는 "Show more"로 변경된다.expanded.value는 버튼의 클릭 상태를 관리하며, 컴포저블이 다시 그려질 때 이 상태에 따라 UI가 업데이트된다.이제 실제로 요청을 받은 경우 항목을 펼쳐 보겠다. 상태에 따라 달라지는 추가 변수를 추가한다.

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
val expanded = remember { mutableStateOf(false) }
val extraPadding = if (expanded.value) 48.dp else 0.dp
// ...
extraPadding은 간단한 계산을 실행하므로 리컴포지션에 대비하여 이 값을 기억할 필요가 없다.
따라서, 이제 Column에 새로운 패딩 수정자를 적용할 수 있다.
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
val expanded = remember { mutableStateOf(false) }
val extraPadding = if (expanded.value) 48.dp else 0.dp
Surface(
color = MaterialTheme.colorScheme.primary,
modifier = modifier.padding(vertical = 4.dp, horizontal = 8.dp)
) {
Row(modifier = Modifier.padding(24.dp)) {
Column(
modifier = Modifier
.weight(1f)
.padding(bottom = extraPadding)
) {
Text(text = "Hello ")
Text(text = name)
}
ElevatedButton(
onClick = { expanded.value = !expanded.value }
) {
Text(if (expanded.value) "Show less" else "Show more")
}
}
}
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
val expanded = remember { mutableStateOf(false) }
val extraPadding = if (expanded.value) 48.dp else 0.dp
expanded는 UI의 확장 상태를 관리하는 MutableState이다. remember를 사용해 리컴포지션 간에 상태를 유지한다.extraPadding은 expanded 상태에 따라 동적으로 계산되는 패딩 값이다.Surface(
color = MaterialTheme.colorScheme.primary,
modifier = modifier.padding(vertical = 4.dp, horizontal = 8.dp)
) {
Surface는 배경색과 패딩을 가진 컨테이너를 생성한다.MaterialTheme.colorScheme.primary로 배경색을 설정한다.modifier에 추가 패딩을 적용한다. Row(modifier = Modifier.padding(24.dp)) {
Row는 자식 요소들을 가로로 배치한다.Column(
modifier = Modifier
.weight(1f)
.padding(bottom = extraPadding)
) {
Text(text = "Hello ")
Text(text = name)
}
Column은 자식 요소들을 세로로 배치한다.weight(1f)로 사용 가능한 공간을 모두 차지하도록 한다.extraPadding을 하단 패딩으로 적용한다.Text 컴포넌트로 "Hello"와 name을 표시한다.ElevatedButton(
onClick = { expanded.value = !expanded.value }
) {
Text(if (expanded.value) "Show less" else "Show more")
}
ElevatedButton은 사용자와 상호작용할 수 있는 버튼을 생성한다.onClick 람다에서 expanded 상태를 토글한다.expanded 상태에 따라 "Show less" 또는 "Show more"로 변경된다.val extraPadding = if (expanded.value) 48.dp else 0.dp가
remember { mutableStateOf }를 사용하지 않는 이유는 다음과 같다.
파생된 상태 (Derived State): extraPadding은 expanded.value에 기반한 파생된 값이다. 이미 expanded가 상태로 관리되고 있으므로, extraPadding은 별도의 상태로 관리할 필요가 없다.
즉시 계산 (On-the-fly Calculation): 이 값은 expanded.value가 변경될 때마다 자동으로 재계산된다. Compose는 이러한 변경을 감지하고 UI를 적절히 업데이트한다.
상태 호이스팅은 Jetpack Compose에서 여러 컴포저블이 동일한 상태를 필요로 할 때, 그 상태를 상위 컴포저블로 끌어올리는 패턴이다. 이는 중복된 상태 관리를 피하고, 컴포저블의 재사용성을 높이며, 상태를 한 곳에서 관리할 수 있게 도와준다.
상태를 호이스팅할 수 있게 만들면 상태가 중복되지 않고 버그가 발생하는 것을 방지할 수 있으며 컴포저블을 재사용할 수 있고 훨씬 쉽게 테스트할 수 있다.
대표적인 예시:
상태 호이스팅을 적용한 간단한 예제로 카운터를 만들어 보자.
@Composable
fun CounterApp() {
var count by remember { mutableStateOf(0) }
CounterDisplay(count = count, onIncrement = { count++ })
}
@Composable
fun CounterDisplay(count: Int, onIncrement: () -> Unit) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = "Count: $count")
Button(onClick = onIncrement) {
Text("Increment")
}
}
}
@Preview
@Composable
fun CounterAppPreview() {
CounterApp()
}
이 코드에서 count는 상위 컴포저블 CounterApp에서 관리되고, CounterDisplay는 상태를 표시하고 버튼 클릭 시 onIncrement 콜백을 호출하여 상태를 변경한다.
@Preview가 달린 CounterAppPreview 컴포저블은 CounterApp 컴포저블을 호출하고, CounterApp은 다시 CounterDisplay 컴포저블을 호출하는 구조다.
@Preview 어노테이션은 이 함수가 미리보기 용도임을 컴파일러에 알린다.CounterAppPreview()는 CounterApp()을 호출하여 그 안의 UI를 표시한다.이렇게 상태를 상위 컴포저블에서 관리하고 하위 컴포저블에 전달하는 방식이 상태 호이스팅이다.

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
BasicsCodelabTheme {
MyApp(modifier = Modifier.fillMaxSize())
}
}
}
}
@Composable
fun MyApp(modifier: Modifier = Modifier) {
var shouldShowOnboarding by remember { mutableStateOf(true) }
Surface(modifier) {
if (shouldShowOnboarding) {
OnboardingScreen(onContinueClicked = { shouldShowOnboarding = false })
} else {
Greetings()
}
}
}
shouldShowOnboarding 상태는 MyApp에서 관리된다.OnboardingScreen 컴포저블은 onContinueClicked라는 콜백을 통해 상태를 변경한다. shouldShowOnboarding이 false로 변경되어, Greetings 컴포저블이 표시된다.OnboardingScreen과 Greetings는 각각 독립적인 컴포저블이지만, 상태는 MyApp에서만 관리되므로, 재사용성과 상태 관리를 명확하게 할 수 있다.
@Composable
fun OnboardingScreen(onContinueClicked: () -> Unit, modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Welcome to the Basics Codelab!")
Button(
modifier = Modifier.padding(vertical = 24.dp),
onClick = onContinueClicked
) {
Text("Continue")
}
}
}
MyApp에서 직접 관리되지 않고, 각각의 컴포저블에서 상태가 관리된다. 즉, shouldShowOnboarding 같은 상태가 없었다면 각 컴포저블 내부에서 상태를 변경했을 것이며, 상태 공유가 불가능하다.MyApp에서 관리한다.shouldShowOnboarding을 통해 어떤 화면을 보여줄지 제어할 수 있고, OnboardingScreen은 상태를 직접 관리하지 않고, onContinueClicked 콜백을 통해 상태 변경 요청만 한다. OnboardingScreen과 Greetings는 더욱 재사용 가능해졌으며, 상태 변경 로직이 MyApp에서 일괄 관리되므로 유지보수가 용이하다.MainActivityclass MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
BasicsCodelabTheme {
MyApp(modifier = Modifier.fillMaxSize())
}
}
}
}
MainActivity는 앱의 진입점이다. onCreate 함수에서 setContent를 통해 컴포저블로 UI를 설정한다. 여기서는 BasicsCodelabTheme이라는 테마를 적용하고, MyApp 컴포저블을 호출해 앱의 메인 UI를 정의한다.Modifier.fillMaxSize()는 MyApp의 레이아웃을 화면 전체 크기로 채운다. MyApp 컴포저블@Composable
fun MyApp(modifier: Modifier = Modifier) {
var shouldShowOnboarding by remember { mutableStateOf(true) }
Surface(modifier) {
if (shouldShowOnboarding) {
OnboardingScreen(onContinueClicked = { shouldShowOnboarding = false })
} else {
Greetings()
}
}
}
MyApp는 앱의 주요 UI를 담당한다.shouldShowOnboarding는 상태 관리 변수로, 이 변수를 통해 온보딩 화면을 보여줄지 여부를 결정한다.remember와 mutableStateOf를 사용하여 상태가 유지되며, UI 변경 시 다시 렌더링된다.Surface는 앱의 기본 배경을 설정하는 컴포넌트이다.if 문을 통해 shouldShowOnboarding이 true이면 OnboardingScreen을 보여주고, false이면 Greetings 컴포저블을 보여준다.OnboardingScreen의 onContinueClicked는 사용자가 버튼을 클릭하면 shouldShowOnboarding을 false로 설정하여 온보딩을 종료하고 Greetings 화면으로 넘어가도록 하는 콜백이다. OnboardingScreen 컴포저블@Composable
fun OnboardingScreen(
onContinueClicked: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Welcome to the Basics Codelab!")
Button(
modifier = Modifier.padding(vertical = 24.dp),
onClick = onContinueClicked
) {
Text("Continue")
}
}
}
OnboardingScreen은 온보딩 화면을 구성하는 컴포저블이다.onContinueClicked는 버튼 클릭 시 호출되는 함수로, 온보딩이 끝나고 메인 화면으로 이동하도록 한다.Column을 사용하여 화면 중앙에 텍스트와 버튼을 배치하고, 버튼 클릭 시 onContinueClicked를 호출한다.Greetings 컴포저블@Composable
private fun Greetings(
modifier: Modifier = Modifier,
names: List<String> = listOf("World", "Compose")
) {
Column(modifier = modifier.padding(vertical = 4.dp)) {
for (name in names) {
Greeting(name = name)
}
}
}
Greetings는 이름 목록을 받아서 각 이름에 대해 Greeting 컴포저블을 호출한다. names 리스트에 있는 각 항목에 대해 Greeting을 호출하여 여러 개의 환영 메시지를 보여준다. Greeting 컴포저블@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
var expanded by remember { mutableStateOf(false) }
val extraPadding = if (expanded) 48.dp else 0.dp
Surface(
color = MaterialTheme.colorScheme.primary,
modifier = modifier.padding(vertical = 4.dp, horizontal = 8.dp)
) {
Row(modifier = Modifier.padding(24.dp)) {
Column(
modifier = Modifier
.weight(1f)
.padding(bottom = extraPadding)
) {
Text(text = "Hello, ")
Text(text = name)
}
ElevatedButton(
onClick = { expanded = !expanded }
) {
Text(if (expanded) "Show less" else "Show more")
}
}
}
}
Greeting은 단일 환영 메시지를 출력하는 컴포저블이다.expanded라는 상태를 통해 메시지가 확장되거나 축소될 수 있다. Show more 버튼을 클릭하면 상태가 변경되고 추가 패딩이 적용된다.Surface와 Row를 사용하여 이름과 버튼을 나란히 배치하고, 버튼을 통해 확장 및 축소 기능을 제공한다. @Preview 함수들OnboardingPreview, GreetingPreview, MyAppPreview는 각각의 UI를 미리 볼 수 있도록 해주는 @Preview 어노테이션이 붙은 함수들이다. Android Studio에서 실시간으로 미리보기를 제공하여 앱을 실행하지 않고도 UI를 확인할 수 있다.MainActivity에서 시작되고, MyApp이 호출된다.MyApp에서 상태에 따라 온보딩 화면(OnboardingScreen)이 먼저 보여지거나, 환영 메시지(Greetings) 화면이 보여진다.Greetings 화면이 나타난다.Greeting 컴포저블은 이름에 맞는 환영 메시지를 출력하며, 메시지를 확장/축소할 수 있는 버튼이 제공된다.상태를 호이스팅한다는 것은 상태를 상위 컴포저블 함수로 올리는 것을 의미한다. 이는 함수 이름이 아니라, 컴포저블 함수 내부에서 관리되는 변수나 값을 말한다.
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Column {
Text(text = "Count: $count")
Button(onClick = { count++ }) {
Text("Increment")
}
}
}
이 예에서 count는 Counter 컴포저블 내부에서 관리된다.
@Composable
fun Counter(
count: Int,
onIncrement: () -> Unit
) {
Column {
Text(text = "Count: $count")
Button(onClick = onIncrement) {
Text("Increment")
}
}
}
@Composable
fun CounterScreen() {
var count by remember { mutableStateOf(0) }
Counter(
count = count,
onIncrement = { count++ }
)
}
호이스팅 후에는:
count 상태가 CounterScreen으로 이동했다.Counter 컴포저블은 이제 count와 onIncrement 콜백을 파라미터로 받는다다.이렇게 함으로써 얻는 이점:
1. Counter 컴포저블은 이제 재사용이 더 쉬워진다. 다른 곳에서도 다양한 초기값이나 증가 로직으로 사용할 수 있다.
2. 테스트가 더 쉬워진다. Counter에 특정 count 값을 주입하고 onIncrement 동작을 확인할 수 있다.
3. 상태 관리가 중앙화되어 더 복잡한 로직을 구현하기 쉬워진다.
요약하면, 상태 호이스팅은 상태 자체(이 경우 count)와 그 상태를 변경하는 함수(onIncrement)를 상위 컴포저블로 옮기는 과정이다. 이는 함수 이름이 아니라 컴포저블 함수의 파라미터와 내부 로직에 관한 것이다.
온보딩 화면 (Onboarding Screen): 사용자가 처음 앱을 실행할 때 앱의 주요 기능이나 사용 방법을 소개하는 화면
로그인/회원가입 화면 (Login/Signup Screen): 사용자가 계정에 로그인하거나 새 계정을 생성하는 화면
홈 화면 (Home Screen): 앱의 주요 기능이나 콘텐츠를 제공하는 기본 화면으로, 사용자에게 앱의 핵심 서비스를 제공.
세부 사항 화면 (Detail Screen): 홈 화면에서 선택한 항목의 세부 정보를 보여주는 화면. 예를 들어, 뉴스 앱에서 기사의 내용을 보여주는 화면이다.
설정 화면 (Settings Screen): 사용자가 앱의 설정을 변경할 수 있는 화면
도움말/피드백 화면 (Help/Feedback Screen): 사용자가 도움을 요청하거나 피드백을 제공할 수 있는 화면
이제 실제로 성능이 중요한 경우를 생각해보자.
이전에는 Column을 사용해 두 개의 간단한 인사말을 화면에 표시했다.
하지만, 수천 개의 인사말을 처리해야 한다면 어떻게 될까?
이 문제를 시뮬레이션하기 위해, 1,000개의 인사말을 가진 리스트를 생성해보자.
이를 위해 Greetings 함수의 names 매개변수를 변경하여 1,000개의 항목이 포함된 리스트를 생성할 수 있다. 다음 코드를 사용하면 인덱스를 문자열로 변환한 값으로 채워진 리스트가 생성된다.
names: List<String> = List(1000) { "$it" }

위 코드는 1,000개의 인사말을 생성하므로, 화면에 모두 표시되지는 않겠지만 리스트가 굉장히 커진다. 이 경우 성능이 크게 저하될 수 있다. 실제 에뮬레이터에서 이 코드를 실행하면 성능이 떨어지는 것을 확인할 수 있는데, 심할 경우 에뮬레이터가 멈출 수도 있다.
이 문제를 해결하기 위해 스크롤이 가능한 열을 사용해야 한다.
Jetpack Compose에서는 LazyColumn을 사용해 스크롤 가능한 리스트를 구현할 수 있다.
LazyColumn은 이름에서 알 수 있듯이 지연 렌더링(lazy rendering) 방식으로 동작한다.
즉, 화면에 보이는 항목들만 렌더링하여 성능을 최적화한다.
이렇게 하면 항목 수가 많아도 성능 저하를 줄일 수 있다.

LazyColumn은 RecyclerView와 비슷한 역할을 한다. Android의 RecyclerView는 화면에 보이지 않는 항목을 재활용(recycling)하는 방식으로 성능을 최적화한다.
하지만, Compose의 LazyColumn은 항목을 재활용하지 않는다.
대신, 항목이 필요할 때마다 새로운 컴포저블을 렌더링하는 방식을 사용한다.
이는 Android의 기존 View 시스템보다 훨씬 가벼운 작업이므로 성능 문제를 야기하지 않는다.
기존 Android 개발 방식은 RecyclerView를 사용하는 것이였다. 하지만 Jetpack Compose 등장 이후에는 LazyColumn 사용이 권장된다.
즉, RecyclerView -> LazyColumn으로 발전한 것이다.
// 수정 전
//@Composable
//private fun Greetings(
// modifier: Modifier = Modifier,
// names: List<String> = listOf("World", "Compose")
//) {
// Column(modifier = modifier.padding(vertical = 4.dp)) {
// for (name in names) {
// Greeting(name = name)
// }
// }
//}
// 수정 후
@Composable
private fun Greetings(
modifier: Modifier = Modifier,
names: List<String> = List(1000) { "$it" }
) {
LazyColumn(modifier = modifier.padding(vertical = 4.dp)) {
items(items = names) { name ->
Greeting(name = name)
}
}
}
LazyColumn은 리스트를 렌더링할 때 성능을 고려한 컴포저블이다. 화면에 보이는 항목들만 렌더링하므로, 수천 개의 항목이 있는 리스트에서도 성능이 저하되지 않는다.items 블록은 리스트의 각 항목을 순회하며 컴포저블을 생성한다.Greeting 컴포저블을 사용해 각 항목에 대해 인사말을 렌더링한다.items 함수를 선택할 수 있다. 따라서 LazyColumn에서 사용할 items 함수를 명확히 가져오려면 androidx.compose.foundation.lazy.items 패키지를 명시적으로 임포트해야하므로, 주의하도록 하자.LazyColumn은 RecyclerView처럼 뷰를 재활용하지 않는다. 하지만 컴포저블을 렌더링하는 비용이 기존 Android View를 인스턴스화하는 것보다 훨씬 적기 때문에, 성능 문제 없이 스크롤할 수 있다.앱에서 두 가지 문제가 발생할 수 있다.
첫 번째는 온보딩 화면의 상태가 유지되지 않는 문제이고, 두 번째는 리스트 항목의 확장 상태가 유지되지 않는 문제다.
이런 문제들은 앱이 회전하거나 테마가 변경되는 등의 구성 변경이 일어날 때 상태가 초기화되기 때문에 발생한다. 이제 이 문제들을 해결하는 방법을 알아보자.
앱에서 사용자가 온보딩 화면을 넘기고 나면, 앱이 회전하거나 어두운 모드로 전환되면 온보딩 화면이 다시 나타나는 현상을 경험할 수 있다.
이 문제는 Jetpack Compose에서 remember 함수가 컴포지션이 유지되는 동안만 상태를 기억하기 때문이다.
기기가 회전하면 활동(Activity)이 다시 생성되면서, remember 함수로 관리되던 상태가 손실된다.
이 문제를 해결하기 위해서는 rememberSaveable을 사용해야 한다. rememberSaveable은 구성 변경(예: 회전)이나 프로세스 중단이 일어나도 상태를 저장한다.
b) 수명 주기 관리:
c) 기존 안드로이드 컴포넌트와의 호환성:
d) 점진적 마이그레이션:
Jetpack Compose에서의 일반적인 구조:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyAppTheme {
// 여기서 최상위 컴포저블 함수 호출
MyApp()
}
}
}
}
@Composable
fun MyApp() {
// 앱의 UI 구성
}
이 구조에서 액티비티는 최소한의 역할만 수행하고, 실제 UI 로직은 컴포저블 함수에서 처리한다.
결론적으로, 액티비티는 여전히 안드로이드 앱의 중요한 구성 요소이며, Jetpack Compose와 함께 사용되어 시스템 레벨의 기능과 UI 구성을 연결하는 역할을 한다.
다만, UI 구성에 있어서의 역할은 크게 줄어들었다.
remember를 rememberSaveable로 변경온보딩 화면의 상태를 유지하려면, remember 대신 rememberSaveable을 사용한다. 이렇게 하면 기기가 회전하거나 앱이 중단되었다가 다시 시작되어도 상태가 유지된다.
수정 전:
var shouldShowOnboarding by remember { mutableStateOf(true) }
수정 후:
import androidx.compose.runtime.saveable.rememberSaveable
// ...
var shouldShowOnboarding by rememberSaveable { mutableStateOf(true) }
이제 앱을 실행한 후 기기를 회전하거나, 어두운 모드로 변경하거나, 프로세스를 종료해도 온보딩 화면이 다시 나타나지 않는다. 상태가 이전처럼 손실되지 않고 유지된다.

또 다른 문제는 리스트의 항목을 펼쳤을 때 발생한다. 예를 들어, 사용자가 목록의 항목을 확장한 후 목록을 스크롤하거나 기기를 회전하면, 확장한 상태가 사라지고 항목이 다시 닫힌 상태로 돌아간다. 이 문제 역시 상태가 초기화되면서 발생한다.
리스트 항목의 확장 상태를 유지하기 위해서도 rememberSaveable을 사용해야 한다. 이렇게 하면 각 항목의 확장 상태가 구성 변경 이후에도 유지된다.
rememberSaveable 사용리스트 항목의 확장 상태를 유지하려면, remember 대신 rememberSaveable을 사용하여 상태를 저장한다.
수정 전:
var expanded by remember { mutableStateOf(false) }
수정 후:
var expanded by rememberSaveable { mutableStateOf(false) }
이제 리스트 항목을 확장한 후 스크롤하거나 기기를 회전해도 항목의 확장 상태가 유지된다. 즉, 사용자가 확장한 항목은 여전히 펼쳐진 상태로 유지된다.