안드로이드 Android

안드로이드 개발 (43) Compose Recomposition 최적화

SEOBI서비 2025. 4. 18. 22:48

잘 만들어 보자..

1. Compose 화면이 그려지는 그려지는 과정

@Composable
fun CounterDemo() {
    // 1. State 읽기
    var count by remember { mutableStateOf(0) }


    Counter(
        count = count,
    	// 2. 값 변경 Snapshot 이 변경 사실 기록
        onPlus = {                         
            count++                         
        }
    )
}

@Composable
private fun Counter(count: Int, onPlus: () -> Unit) {
    // 3. Recompose 될 때마다 여기 로그가 한 번씩 찍힌다.
    LaunchedEffect(count) {
        Log.d("RECOMPOSE", "Counter recomposed with count = $count")
    }

	// 4. Applier 가 View 에 패치
    Button(onClick = onPlus) {              
        Text("Count = $count")
    }
}


1) MutableState 읽기

Composable 함수가 count 같은 State 값을 읽으면, 런타임은 "애가 이 값에 의존하고 있구나" 하고 메모해 둡니다.

 

2) 사용자가 값 변경

버튼을 눌러 count ++ 가 실행 되면, 그 순간 스냅샷 시스템이 "관찰된 값이 바뀌었다" 는 신호를 Recomposer 에게 보냅니다.


3) Recompose 예약 → 실행

Recomposer 는 다음 프레임에서 해당 함수만 다시 호출합니다. 이때 이전 호출 때 만들어 둔 SlotTable(UI 노드 설계도)을 들고 와서 "바뀐 부분만" 비교 하고 적용합니다.

 

4) Applier가 실제 View 트리에 패치

마지막으로 androidx.compose.runtime:runtime.Applier 가 변화된 Slot을 View 계층(또는 Compose UI 레이아웃 노드)에 반영하고 끝납니다. "핵심은 읽은 값 감시한다" 라는 점입니다.

확인 해보자!

2. Recomposition 낭비 중인지 확인 법? 

1) 컴파일러 메트릭 켜보기

android.compose.compiler.report=true
android.compose.compiler.metricsDestination=./compose-metrics

gradle.properties 에 위를 적고 Release 빌드를 돌립니다.

(빨리 확인용으로는 debug 빌드를 가끔 쓰기도 하지만.. )

 

2) skip.txt 파일을 열어봅니다.


skip.txt 생성 디렉토리를 별도로 지정하지 않았다면 root 디렉토리 바로 밑에 compose-metrics/skip.txt  가 있을겁니다.

Counter.kt:25 restartable:3 skippable:10 restartSkips:2

 

restartable 은 재컴포지션 가능한 함수의 개수 입니다. skippable 은 그중 스킵 검사를 한 횟수 입니다. restartSkips 는 실제로 스킵이 일어난 횟수 입니다. 즉, skippable 숫자가 크고 restartSkips 가 작다면 낭비가 있는 겁니다. 

위 예제는 스킵된 비율이 (skippable)10/ (restartSkips) 2  = 20%  라면 검사 10번 중 8 번은 다시 그렸다는 말이므로 낭비가 일부 존재 합니다. 

 

3) 실시간 프로퍼일러

Android Studio → Layout Inspector → Compose 탭을 열고, Recompose 카운터가 계속 튀는 조각을 찾습니다.

 

고쳐 보자!

3. Recomposition 낭비 방지법

1) 람다(익명 함수) 새로 만들기

Button(onClick = { count++ })


위 같은 람다를 매 프레임 새로 만들면, Compose는 람다도 UI 트리 일부라고 생각해 다시 비교하게 됩니다.

 

→ 아래와 같이rememberUpdateState 로 람다 내용만 갱신하고 람다 객체는 재사용하면 해결 합니다. 

   // 1) 최신 count 값을 상태로 보관
    val currentCount by rememberUpdatedState(count)

    // 2) 람다 인스턴스는 remember 블록에서 단 한 번만 생성
    val plusOne = remember {
        {
            count = currentCount + 1
        }
    }

    Button(onClick = plusOne) {
        Text("Count = $count")
    }

 

 

2) State 규칙 깨뜨리기

data class User(var name: String)

 

위 처럼 var 프로퍼티가 있는 데이터 클래스를 파라미터로 넘기면 불안정(Unstable) 타입으로 간주돼 모든 하위 UI가 재구성 됍니다.

   프로퍼티를 val로 하거나 @State 애노테이션으로 명시해야 합니다.

 

3) key 규칙 안 주는 LazyColumn

@Composable
fun ChatsScreen(chats: List<Chat>) {
    // 키 없이 사용 – 스크롤 시 매 행을 새 항목으로 오해
    LazyColumn {
        items(chats) { chat ->                   
            ChatRow(chat)                        
        }
    }
}

리스트 아이템에 고유 Key 를 안 주면 스크롤할 때 매 행이 새 데이터로 바뀌었다고 착각합니다. 그렇게 전부 재컴포지션이 일어납니다.

 

→  아래와 같이 꼭 key 를 써야 합니다. 

 items(chats, key = { chat -> chat.id }) { ... }



4) remeber 없이 새 객체를 계속 만들 때 

Button(
    onClick = { /* … */ },
    // recomposition마다 새 객체 생성
    interactionSource = MutableInteractionSource()
) {
    Text("Click")
}


위 예제 처럼 MutableInteractionSource() 로 새 인스턴스를 생성하면, Compose는 프로퍼티가 바뀌었다고 판단해 Button 자체 + 내부 UI를 전부 다시 그립니다.

→  아래와 같이 꼭 remember { … } 처럼 객체를 만들어서 사용해야합니다.

val source = remember { MutableInteractionSource() }

Button(
    onClick = { /* … */ },
    interactionSource = source           
) {
    Text("Click")
}

 


5) mutableListOf 직접 수정으로 Snapshot 불일치

@Composable
fun BadListScreen() {
    // Compose가 변경을 추적하지 못하는 일반 MutableList
    val items = remember { mutableListOf<String>() }


    fun add(item: String) {
        items.add(item)          
    }

    Column {
        // 스크롤·다시 빌드 시 전체 행 재컴포지션 가능
        LazyColumn {
            items(items) { Text(it) }
        }
    
        // add 함수가 Button 클릭에서 호출된다고 가정
        Button(onClick = { add("Item ${items.size}") }) {
            Text("Add")
        }
    }
}

 

위 예제 처럼 하면 문제가 생깁니다. 왜냐하면 mutableListOf 는 Compose Snapshot 시스템이 변경을 관찰하지 못합니다. 즉, Recomposer는 리스트 전체가 새로 바뀌었다고 오해하고 모든 행을 재컴포지션·재측정 해버립니다.

 

→  두가지 방법이 있습니다. 

방법 1. 상태형 리스트 사용

val items = remember { mutableStateListOf<Item>() }

fun add(item: Item) {
    items += item            
}

 

방법 2. 불변 리스트 교체

var items by remember { mutableStateOf(emptyList<Item>()) }

fun add(item: Item) {
    items = items + item    
}

(+ 연산자는 plus() 역할이라서 새로운 리스트를 copy 함)

두 방법 모두 Compose 가 변경을 추적해서 바뀐 행만 리컴포지션 합니다. 

 

정비 완료

5. 정리

  • Recomposition 은 “읽은 State 가 바뀌었을 때만” 발생한다—그러나 람다·키·안정성 규칙을 놓치면 불필요한 재컴포지션이 폭증한다.
  • 컴파일러 메트릭Layout Inspector 를 켜 두면 어느 함수가 낭비를 만드는지 금방 찾을 수 있다.
  • 람다 객체 재사용, Stable 데이터, Lazy List key 사용, remember 사용, Lazy List - mutableList 사용 주의 등으로