1편을 마치며 이런 질문을 남겼습니다.
"상태가 바뀔 때마다 UI를 다시 그린다는데, 그럼 느리지 않나요?"
다음 예시코드를 보겠습니다.
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) { Text("증가") }
Column {
Text("Count: $count")
Text("Count: $count")
Text("Count: $count")
}
버튼을 누르면 count가 0에서 1로 바뀝니다. 이 예시에서는 Text가 3개뿐이지만, 만약 수백 개의 Composable이 있다면 어떨까요?
화면 전체를 매번 다시 그려야 한다면 버벅거리지 않을까요?
하지만 실제로는 부드럽게 작동합니다. 왜일까요?
Compose는 전체를 다시 그리지 않고, 꼭 필요한 부분만 다시 그립니다.
이번 편에서는 Recomposition이 어떻게 작동하는지, State를 어떻게 관리해야 효율적인지 알아보겠습니다.
1. Recomposition - State를 읽는 부분만 다시 그린다
1-1. Recomposition이란?
먼저 용어부터 정리하겠습니다.
Recomposition은 State가 변경되면 해당 State를 읽는 Composable 함수를 다시 호출하는 과정입니다.
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
println("Counter 호출됨")
Button(onClick = { count++ }) {
Text("Count: $count")
}
}
버튼을 클릭하면:
// 첫 실행
Counter 호출됨
// 버튼 클릭 (count = 1)
Counter 호출됨
// 버튼 클릭 (count = 2)
Counter 호출됨
count가 변경될 때마다 Counter 함수가 다시 호출되는 게 보이시나요?
이것이 Recomposition입니다.
1-2. 전체를 다시 그리지 않습니다.
그럼 전체를 다시 그리는건가요? 라는 의문이 생깁니다.
"함수를 다시 호출한다"라고 해서 화면 전체를 다시 그리는 건 아닙니다.
다음 예시코드와 함께 확인해보겠습니다.
@Composable
fun Screen() {
var count by remember { mutableStateOf(0) }
println("Screen 호출됨")
Column {
Button(onClick = { count++ }) {
Text("증가")
}
CounterDisplay(count)
StaticText()
}
}
@Composable
fun CounterDisplay(count: Int) {
println("CounterDisplay 호출됨: $count")
Text("Count: $count")
}
@Composable
fun StaticText() {
println("StaticText 호출됨")
Text("나는 변하지 않습니다")
}
버튼을 클릭하면 어떻게 될까요?
// 첫 실행
Screen 호출됨
CounterDisplay 호출됨: 0
StaticText 호출됨
// 버튼 클릭 (count = 1)
Screen 호출됨
CounterDisplay 호출됨: 1
// StaticText는 호출 안 됨!
주목할 점이 있습니다.
StaticText는 count가 바뀌어도 다시 호출되지 않았습니다. 왜 그럴까요?
핵심은 State를 실제로 "읽는" Composable만 다시 호출됩니다.
CounterDisplay는 count를 읽고 있으니 다시 호출되지만, StaticText는 count를 읽지 않으니 호출되지 않는 겁니다.
StaticText는 count를 읽지 않으니 Recomposition에서 제외됩니다.
1-3. State 별로 독립적으로 작동합니다.
이번엔 State가 두 개인 경우의 예시를 보겠습니다.
@Composable
fun MultiStateExample() {
var count1 by remember { mutableStateOf(0) }
var count2 by remember { mutableStateOf(0) }
Column {
Button(onClick = { count1++ }) {
Text("Count1 증가")
}
Button(onClick = { count2++ }) {
Text("Count2 증가")
}
Counter1Display(count1)
Counter2Display(count2)
}
}
@Composable
fun Counter1Display(count: Int) {
println("Counter1Display 호출됨")
Text("Count1: $count")
}
@Composable
fun Counter2Display(count: Int) {
println("Counter2Display 호출됨")
Text("Count2: $count")
}
```
Count1 버튼을 누르면:
```
Counter1Display 호출됨
// Counter2Display는 호출 안 됨!
```
Count2 버튼을 누르면:
```
Counter2Display 호출됨
// Counter1Display는 호출 안 됨!
각 Composable은 자신이 읽는 State가 바뀔 때만 다시 호출됩니다.
그럼 여기서 질문이 생깁니다. 🤔
"화면에 수백 개의 Composable이 있어도 괜찮을까요?"
확인해봅시다!
2. Smart Recomposition - 어떻게 부분만 다시 그리나
2-1. Composition 트리
Compose는 내부적으로 Composition 트리를 만듭니다.
MultiStateExample
├─ Button (count1++)
├─ Button (count2++)
├─ Counter1Display ← count1 읽음
├─ Counter2Display ← count2 읽음
└─ StaticText ← 아무것도 안 읽음
State가 변경되면:
- 해당 State를 읽는 노드를 찾음
- 그 노드만 다시 실행
- 나머지는 그대로 유지
2-2. 그런데 부모는 왜 호출될까?
@Composable
fun Parent() {
var count by remember { mutableStateOf(0) } // 여기서 State 생성
println("Parent 호출됨")
Column {
Button(onClick = { count++ }) { Text("증가") }
Child(count)
}
}
@Composable
fun Child(count: Int) {
println("Child 호출됨")
Text("Count: $count")
}
```
버튼 클릭:
```
Parent 호출됨 // 왜?
Child 호출됨
이유: Parent가 count를 생성하고 있기 때문입니다.
State를 읽지는 않지만, State가 선언된 곳이므로 Recomposition에 참여합니다.
2-3. 부모가 호출 안 되는 경우
State가 자식에만 있으면:
@Composable
fun Parent() {
println("Parent 호출됨")
Column {
Child()
}
}
@Composable
fun Child() {
var count by remember { mutableStateOf(0) } // Child에서 State 생성
println("Child 호출됨")
Button(onClick = { count++ }) {
Text("Count: $count")
}
}
```
버튼 클릭:
```
Child 호출됨
// Parent 호출 안 됨!
Parent는 State와 무관하므로 Recomposition에서 제외됩니다.
2-4. 실전에서의 의미
@Composable
fun TodoScreen() {
var todos by remember { mutableStateOf(listOf<Todo>()) }
Column {
AddTodoButton(onAdd = { /* todos 추가 */ })
TodoList(todos) // todos 변경 시 여기만 Recompose
BottomNavigation() // todos와 무관, Recompose 안 됨
}
}
todos가 변경되면:
- TodoList만 다시 그림
- AddTodoButton, BottomNavigation은 그대로
이것이 Compose가 빠른 이유입니다.
3. 언제 최적화를 신경 써야 하나
3-1. 기본은 자동 최적화
대부분의 경우 신경 쓸 필요 없습니다.
Compose가 알아서:
- State 읽는 부분만 찾아서
- 그 부분만 다시 실행하고
- 나머지는 건너뜁니다
3-2. 신경 써야 하는 경우
1. LazyColumn의 key
@Composable
fun TodoList(todos: List<Todo>) {
LazyColumn {
items(todos) { todo -> // ❌ key 없음
TodoItem(todo)
}
}
}
문제 :
- 리스트 중간에 아이템 추가/삭제
- Compose가 "어떤 아이템이 어떤 아이템인지" 모름
- 전체를 다시 그림
해결 :
LazyColumn {
items(
items = todos,
key = { it.id } // 각 아이템을 구분할 고유 값
) { todo ->
TodoItem(todo)
}
}
이제 Compose가:
- 각 아이템을 id로 추적
- 변경된 아이템만 업데이트
- 위치가 바뀌어도 올바르게 처리
3-3. 신경 써야 하는 경우 2: 객체 재생성
@Composable
fun UserProfile() {
var updateTrigger by remember { mutableStateOf(0) }
val user = User("이든", 25) // 매번 새로 생성
Column {
Button(onClick = { updateTrigger++ }) {
Text("새로고침")
}
UserDisplay(user)
}
}
@Composable
fun UserDisplay(user: User) {
println("UserDisplay 호출됨")
Text("${user.name}, ${user.age}세")
}
```
버튼 클릭:
```
UserDisplay 호출됨 // user가 바뀌지 않았는데?
UserDisplay 호출됨
UserDisplay 호출됨
문제: val user = User("이든", 25)가 Recomposition마다 새로 생성됩니다.
// Recomposition 1
val user = User("이든", 25) // 객체 A
// Recomposition 2
val user = User("이든", 25) // 객체 B (내용은 같지만 다른 객체)
해결:
val user = remember { User("이든", 25) } // 한 번만 생성, 재사용
```
이제:
```
UserDisplay 호출됨 // 첫 실행
// 버튼 눌러도 더 이상 호출 안 됨
3-4. 언제 remember를 쓸까?
기본 원칙:
- State는 항상 remember + mutableStateOf
- 객체 생성 비용이 크면 remember
- 매번 같은 값이면 remember
// Primitive 타입은 괜찮음
val text = "고정된 텍스트" // 매번 생성해도 비용 낮음
// 객체는 remember
val user = remember { User("이든", 25) }
val list = remember { listOf(1, 2, 3) }
// State는 필수
var count by remember { mutableStateOf(0) }
3-5. 실전 예시: 필터링
@Composable
fun FilteredList(items: List<String>) {
var searchQuery by remember { mutableStateOf("") }
// ❌ 나쁜 예: 매번 필터링
val filteredItems = items.filter {
it.contains(searchQuery, ignoreCase = true)
}
Column {
TextField(
value = searchQuery,
onValueChange = { searchQuery = it }
)
LazyColumn {
items(filteredItems) { item ->
Text(item)
}
}
}
}
searchQuery가 바뀔 때마다 filter가 실행됩니다. 리스트가 크면 느려집니다.
하지만 이 정도는 괜찮습니다. 실제로 느려지기 전까진 최적화하지 마세요.
만약 정말 느리다면:
val filteredItems = remember(searchQuery, items) {
items.filter {
it.contains(searchQuery, ignoreCase = true)
}
}
remember에 key를 주면, key가 바뀔 때만 재계산합니다.
4. State 위치와 Recomposition
4-1. State 위치가 중요한 이유
State를 어디에 두느냐에 따라 Recomposition 범위가 달라집니다.
// 예시 1: State가 너무 위에
@Composable
fun Screen() {
var searchQuery by remember { mutableStateOf("") }
Column {
SearchBar(searchQuery, { searchQuery = it })
FilteredList(searchQuery)
BottomBar() // searchQuery와 무관한데 Screen이 Recompose되면 영향받음
}
}
searchQuery가 바뀌면 Screen 전체가 Recompose 대상이 됩니다.
// 예시 2: State를 필요한 곳에만
@Composable
fun Screen() {
Column {
SearchSection() // State를 내부에
BottomBar() // 완전히 독립적
}
}
@Composable
fun SearchSection() {
var searchQuery by remember { mutableStateOf("") }
Column {
SearchBar(searchQuery, { searchQuery = it })
FilteredList(searchQuery)
}
}
이제 searchQuery가 바뀌어도 SearchSection만 Recompose 됩니다.
4-2. State Hoisting 다시 보기
1편에서 배운 State Hoisting, Recomposition 관점에서 다시 봅시다.
// Bad: State가 너무 아래
@Composable
fun Screen() {
Column {
ChildA() // 내부에 state
ChildB() // 내부에 state - 공유 불가
}
}
문제:
- ChildA와 ChildB가 같은 데이터 공유 불가
- 각자 독립적으로 State 관리
// Good: 필요한 만큼만 올리기
@Composable
fun Screen() {
var sharedState by remember { mutableStateOf("") }
Column {
ChildA(sharedState, { sharedState = it })
ChildB(sharedState, { sharedState = it })
ChildC() // sharedState와 무관
}
}
sharedState가 바뀌면:
- Screen Recompose (State 생성 위치)
- ChildA Recompose (State 읽음)
- ChildB Recompose (State 읽음)
- ChildC는 Recompose 안 됨 (State 안 읽음)
4-3. State 위치 선택 기준
원칙:
- 가능한 낮게 (Recomposition 범위 최소화)
- 필요한 만큼만 올리기 (공유 필요하면 공통 부모로)
// 한 Composable에서만 쓰면
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) } // 여기에
Button(onClick = { count++ }) { Text("$count") }
}
// 여러 Composable이 공유하면
@Composable
fun Parent() {
var count by remember { mutableStateOf(0) } // 공통 부모에
Column {
ChildA(count)
ChildB(count)
}
}
4-4. 실전 예시
@Composable
fun TodoScreen() {
// 전체 화면이 공유하는 State → 여기에
var todos by remember { mutableStateOf(listOf<Todo>()) }
var filter by remember { mutableStateOf(Filter.ALL) }
Column {
// 여기서만 쓰는 State → 내부에
AddTodoSection(onAdd = { todos = todos + it })
// 공유 State 전달
FilterButtons(filter, { filter = it })
TodoList(
todos = todos.filter { /* filter 적용 */ }
)
}
}
@Composable
fun AddTodoSection(onAdd: (Todo) -> Unit) {
// 여기서만 쓰는 State
var inputText by remember { mutableStateOf("") }
Row {
TextField(inputText, { inputText = it })
Button(onClick = {
onAdd(Todo(inputText))
inputText = ""
}) {
Text("추가")
}
}
}
효과:
- inputText 바뀔 때 → AddTodoSection만 Recompose
- todos 바뀔 때 → TodoScreen + TodoList만 Recompose
- filter 바뀔 때 → TodoScreen + FilterButtons + TodoList만 Recompose
결론
1편에서 던진 질문, "상태 바뀔 때마다 UI를 다시 그리는데 느리지 않나요?"
답은 이렇습니다.
느리지 않습니다. 왜냐하면:
- 전체를 다시 그리지 않음
- State를 읽는 Composable만 Recomposition
- 나머지는 그대로 유지
- State 위치가 중요함
- State를 낮게 두면 Recomposition 범위 감소
- 필요한 만큼만 올려서 공유
- 기본적으로 최적화됨
- 대부분은 신경 안 써도 됨
- LazyColumn에서 key 쓰기
- remember로 객체 재사용
핵심 원칙:
- State는 필요한 곳에 가깝게
- 공유 필요하면 공통 부모로
- 최적화는 문제 생기면 그때
Compose를 쓰면서 "이거 너무 느린데?"라고 느껴진다면:
- State 읽는 곳이 너무 많은지 확인
- LazyColumn에 key 썼는지 확인
- 불필요한 객체 재생성 확인
참고 자료
이 글은 다음 공식 문서를 기반으로 작성되었습니다:
Jetpack Compose 공식 문서:
'Compose' 카테고리의 다른 글
| Jetpack Compose 입문 : 선언형 UI와 State 관리 (0) | 2026.01.25 |
|---|