가장 일반적이고 중요한 재구성 트리거는 상태 변경이다.
Compose에서는 다음과 같은 상태 객체들이 변경될 때 재구성이 발생한다.
// 기본 상태 객체
val count = mutableStateOf(0)
// remember를 사용한 상태 관리
val count = remember { mutableStateOf(0) }
// by 위임을 통한 간결한 사용
var count by remember { mutableStateOf(0) }
상태가 변경되면 Compose는 해당 상태를 읽는 모든 컴포저블을 재구성한다.
컴포저블 함수에 전달되는 매개변수가 변경될 때 재구성이 발생한다.
@Composable
fun UserProfile(user: User) { // user 객체가 변경되면 재구성 발생
// 프로필 UI
}
// 사용 예시
UserProfile(viewModel.currentUser) // currentUser가 변경될 때마다 재구성
버튼 클릭, 텍스트 입력, 스와이프 등의 사용자 인터랙션으로 인한 상태 변화도 리컴포지션을 트리거한다.
Button(onClick = { count++ }) { // 클릭하면 count 증가로 인한 재구성
Text("클릭 수: $count")
}
ViewModel이나 다른 데이터 소스에서 관찰 가능한 데이터가 변경될 때에도 리컴포지션이 발생한다.
val viewModel: MainViewModel = viewModel()
val uiState by viewModel.uiState.collectAsState() // 상태 변화 관찰
// uiState가 변경되면 이를 사용하는 컴포넌트 재구성
Text("현재 상태: ${uiState.status}")
기본적으로 부모 컴포넌트가 재구성되면 모든 자식 컴포넌트도 재구성 대상이 된다.
@Composable
fun Parent(count: Int) { // count 변경 시 Parent 재구성
Child() // Parent가 재구성되면 Child도 재구성 대상
}
애니메이션 실행 중에는 각 프레임마다 재구성이 발생한다!
val alpha by animateFloatAsState(
targetValue = if (isVisible) 1f else 0f
)
// alpha 값이 변할 때마다 재구성
Box(modifier = Modifier.alpha(alpha))
LaunchedEffect API를 사용할 때 상태 변경이 발생하면 재구성된다.
LaunchedEffect(key1 = viewModel.dataLoaded) {
if (viewModel.dataLoaded) {
// 데이터 로드 완료 시 상태 변경으로 재구성 발생
snackbarState.showSnackbar("데이터 로드 완료")
}
}
화면 회전, 다크 모드 전환, 언어 변경 등의 구성 변경
// 현재 구성에 따라 다른 UI 표시
val isDarkTheme = isSystemInDarkTheme() // 테마 변경 시 재구성
앱 창 크기 조정이나 멀티윈도우 변경 시
val windowSizeClass = calculateWindowSizeClass()
// 창 크기에 따른 레이아웃 조정
when (windowSizeClass) {
WindowSizeClass.COMPACT -> CompactLayout()
WindowSizeClass.MEDIUM -> MediumLayout()
WindowSizeClass.EXPANDED -> ExpandedLayout()
}
Compose에서 재구성은 필연적이지만, 성능 최적화를 위해 몇 가지 전략을 사용할 수 있다.
👉 "상태를 관리하는 곳과 UI를 그리는 곳을 분리한다."
🔹 왜 필요할까?
예제
@Composable
fun ParentScreen() {
var text by remember { mutableStateOf("") } // 상태를 상위에서 관리
ChildInput(text = text, onTextChange = { text = it }) // 상태를 전달
}
@Composable
fun ChildInput(text: String, onTextChange: (String) -> Unit) {
TextField(value = text, onValueChange = onTextChange) // UI만 담당
}
이렇게 하면 ChildInput
이 직접 상태를 관리하지 않고, ParentScreen
이 상태를 변경할 때만 필요한 부분이 재구성된다.
👉 "recomposition이 되어도 값이 초기화되지 않게 기억시킨다."
왜 필요할까?
remember
를 사용하지 않으면 화면이 다시 그려질 때마다 변수가 초기화될 수 있다. remember
를 사용하면 변수의 값을 유지할 수 있다. 🔹 예제
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) } // 화면이 다시 그려져도 값 유지
Button(onClick = { count++ }) {
Text("Count: $count")
}
}
이렇게 하면 버튼을 클릭해도 count
값이 유지되고, 불필요한 재구성이 방지된다.
👉 "값이 바뀔 때만 계산이 실행되도록 한다!"
왜 필요할까?
리스트 개수를 계산하거나 필터링할 때, 데이터가 바뀌지 않아도 매번 계산하면 성능이 저하될 수 있다.
derivedStateOf
를 사용하면 필요할 때만 계산이 실행되어 최적화할 수 있다.
예제
@Composable
fun ExpensiveCalculation(items: List<String>) {
val itemCount by remember { derivedStateOf { items.size } } // items가 바뀔 때만 계산
Text("아이템 개수: $itemCount")
}
이렇게 하면 items
가 바뀌지 않는 한 size
를 계속 계산하지 않아 성능이 향상된다.
👉 "안정적인 UI와 변경되는 UI를 분리하면 불필요한 재구성을 줄일 수 있다!"
왜 필요할까?
예제
@Composable
fun MainScreen() {
Column {
StaticHeader() // 변하지 않는 부분
DynamicContent() // 변경될 가능성이 있는 부분
}
}
@Composable
fun StaticHeader() {
Text("안녕하세요! 👋", fontSize = 24.sp)
}
@Composable
fun DynamicContent() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text("Count: $count")
}
}
이렇게 하면 버튼을 눌러도 StaticHeader
는 다시 그려지지 않고, DynamicContent
만 재구성된다.
👉 "리스트에서 특정 항목이 불필요하게 재구성되지 않도록 한다!"
왜 필요할까?
LazyColumn
같은 리스트를 사용할 때, 항목이 추가/삭제될 때 기존 항목이 재구성될 수 있음 key
를 사용하면 항목의 변경이 최소화되어 성능이 최적화됨 예제
LazyColumn {
items(items, key = { it.id }) { item ->
Text("아이템: ${item.name}")
}
}
이렇게 하면 id
가 같은 항목은 재사용되고, **새로운 항목만 추가/삭제된다.
Jetpack Compose의 리스트(LazyColumn, LazyRow 등)는 항목을 효율적으로 그리기 위해 자동으로 재사용(Recycling)한다.
하지만 항목을 구별할 기준이 없으면 기존 항목을 새 항목으로 잘못 인식하여 불필요한 재구성이 발생할 수 있다.
예를 들어 1학년 5반의 학생 목록 30명이 LazyColumn으로 저장되어있다고 가정하자.
이때 김철수가 삭제되면, key를 지정하지 않았다면 리컴포지션이 발생한다.
만약 key를 사용하여 각 항목의 고유한 값을 지정하면,
변경된 부분만 재구성되고, 나머지는 유지할 수 있어 성능이 향상된다.
@Composable
fun StudentList(students: List<Student>) {
LazyColumn {
items(students, key = { it.id }) { student ->
Text(text = student.name)
}
}
}
동적으로 변하는 리스트라면 key를 꼭 써야 최적화됨을 기억하자.