Compose

Jetpack Compose Recomposition - 왜 느리지 않을까?

devfeijoa 2026. 2. 5. 20:15

 

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가 변경되면:

  1. 해당 State를 읽는 노드를 찾음
  2. 그 노드만 다시 실행
  3. 나머지는 그대로 유지

 

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 위치 선택 기준

원칙:

  1. 가능한 낮게 (Recomposition 범위 최소화)
  2. 필요한 만큼만 올리기 (공유 필요하면 공통 부모로)
 
// 한 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를 다시 그리는데 느리지 않나요?"

답은 이렇습니다.

느리지 않습니다. 왜냐하면:

  1. 전체를 다시 그리지 않음
    • State를 읽는 Composable만 Recomposition
    • 나머지는 그대로 유지
  2. State 위치가 중요함
    • State를 낮게 두면 Recomposition 범위 감소
    • 필요한 만큼만 올려서 공유
  3. 기본적으로 최적화됨
    • 대부분은 신경 안 써도 됨
    • LazyColumn에서 key 쓰기
    • remember로 객체 재사용

핵심 원칙:

  • State는 필요한 곳에 가깝게
  • 공유 필요하면 공통 부모로
  • 최적화는 문제 생기면 그때

Compose를 쓰면서 "이거 너무 느린데?"라고 느껴진다면:

  1. State 읽는 곳이 너무 많은지 확인
  2. LazyColumn에 key 썼는지 확인
  3. 불필요한 객체 재생성 확인

 

 

 

참고 자료

이 글은 다음 공식 문서를 기반으로 작성되었습니다:

Jetpack Compose 공식 문서:

'Compose' 카테고리의 다른 글

Jetpack Compose 입문 : 선언형 UI와 State 관리  (0) 2026.01.25