
Android Compose에서 성능 최적화는 단순히 "빠르게 만들기"를 넘어서, 사용자 경험을 좌우하는 핵심 요소예요. 특히 리컴포지션(Recomposition)을 얼마나 효율적으로 관리하느냐에 따라 앱의 반응성과 배터리 효율성이 크게 달라져요.
오늘은 상태 읽기 지연(Defer State Reads) 패턴을 활용해 리컴포지션을 효과적으로 줄인 사례를 통해 Compose 성능 최적화를 알아볼게요.
📊 리컴포지션의 3단계와 성능 비용
Compose의 리컴포지션은 다음 3단계로 진행돼요
- 컴포지션(Composition) - UI 구조를 다시 계산
- 레이아웃(Layout) - 각 요소의 위치와 크기를 결정
- 드로우(Draw) - 실제 화면에 그리기
각 단계는 모두 연산 비용이 발생하며, 특히 컴포지션 단계에서는 모든 계산을 처음부터 다시 수행하기 때문에 가장 많은 리소스를 소모해요.
따라서 불필요한 리컴포지션을 줄이거나 특정 단계를 건너뛸 수 있다면 성능상 큰 이득을 얻을 수 있어요.
저는 프로젝트에서 개발을 진행할 때 리컴포지션을 줄이기 위해, 상태를 잘 관리하기 위해서 State Hoisting을 활용해서 작업했었어요. 그런데 최근에 공식문서를 읽으면서 제가 오해하고 잘못 사용하고 있다는 걸 깨달았어요. 먼저 예시를 볼까요?
일반적인 케이스 - 불필요한 부모의 리컴포지션
아래 코드를 살펴볼게요
class MyScreenViewModel : ViewModel() {
var inputText by mutableStateOf("")
private set
fun onInputTextChanged(newText: String) {
inputText = newText
}
}
@Composable
fun MyScreen(
modifier: Modifier = Modifier,
viewModel: MyScreenViewModel = viewModel()
) {
Column(modifier = modifier.fillMaxSize()) {
MyTopAppBar(
value = viewModel.inputText, // String 값 자체를 전달
onValueChange = { newText -> viewModel.onInputTextChanged(newText) }
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MyTopAppBar(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
TopAppBar(
title = {
TextField(
value = value,
onValueChange = onValueChange,
label = { Text("텍스트를 입력하세요") },
modifier = Modifier.fillMaxWidth()
)
},
modifier = modifier
)
}
위 코드에서 TextField에 텍스트를 입력하면 어떤 일이 일어날까요?
inputText 상태가 변경될 때마다 이 상태를 관찰하는 모든 컴포저블이 리컴포지션돼요
- MyScreen ← 부모 컴포저블도 리컴포지션이에요!
- MyTopAppBar ← 실제 상태를 사용하는 컴포저블이에요
- TextField ← 실제로 변경이 필요한 컴포저블이에요
우리가 원하는 건 TextField만 리컴포지션되는 것인데, 부모 컴포저블까지 불필요하게 리컴포지션되고 있어요.
참고로 리컴포지션의 횟수는 레이아웃 인스펙터(layout Inspector)를 사용해 측정했어요.
왜 하던 대로 개발했는데 여태까지 몰랐지?
영상을 보셨나요? 꽤나 많은 코드가 상위에서 그대로 상태를 직접 넘겨주는데요(저도 그랬습니다..) Compose에서 상태가 변경되면, 해당 상태를 직접 읽는 모든 컴포저블이 리컴포지션 대상이 돼요. MyScreen에서 viewModel.inputText를 읽어서 MyTopAppBar에 전달하기 때문에, MyScreen도 상태 변경을 감지하게 되는 거예요. 그럼 어떻게 해결할까요?
상태 읽기 지연(Defer State Reads)을 사용해 보자!
해결책은 상태값 자체를 전달하는 대신, 상태값을 반환하는 람다 함수를 전달하는 거예요.
@Composable
fun MyScreen(
modifier: Modifier = Modifier,
viewModel: MyScreenViewModel = viewModel()
) {
Column(modifier = modifier.fillMaxSize()) {
MyTopAppBar(
// String을 반환하는 람다를 전달
textProvider = { viewModel.inputText },
onValueChange = { newText -> viewModel.onInputTextChanged(newText) }
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MyTopAppBar(
textProvider: () -> String, // 람다 함수로 상태 지연 읽기
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
TopAppBar(
title = {
TextField(
value = textProvider(), // 여기서 실제 상태 읽기
onValueChange = onValueChange,
label = { Text("텍스트를 입력하세요") },
modifier = Modifier.fillMaxWidth()
)
},
modifier = modifier
)
}
측정 결과를 한번 볼까요?
이게 왜 효과적일까?
람다 함수 자체의 인스턴스(identity)는 변하지 않아요. 람다 함수가 캡처하는 상태의 값만 내부적으로 변경되기 때문에, 상위 컴포저블은 "람다 함수가 바뀌었다"라고 인식하지 않아서 리컴포지션을 피할 수 있어요. 코드상으로도 람다 내부가 바뀔 뿐이지 람다 객체 자체가 변하는 건 아니니까요!
상태 읽기가 실제로 필요한 지점(textProvider() 호출 시점)까지 지연되어, 불필요한 상위 컴포저블의 리컴포지션을 방지해요.
실제로 개선한 케이스 - PullToRefresh 최적화
실제 프로젝트에서 적용한 사례를 살펴볼게요. Spoony에서 당겨서 새로고침 기능을 개발하기 위해서 컴포넌트를 만들었어요. 레이어에 문제가 약간 있어서 알파값을 조정하도록 코드를 작성했었는데요. Pull-to-Refresh 기능에서 알파값이 변경될 때마다 리컴포지션이 매우 많이 발생했었지만 그냥 넘어갔었어요..ㅋㅋ
Before: 문제가 있던 코드
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun FollowScreen() {
val refreshState = rememberPullToRefreshState()
val alpha by remember {
derivedStateOf {
if (refreshState.isRefreshing) {
1f
} else {
val progress = refreshState.progress
progress * progress * progress
}
}
}
Column {
Box(
modifier = Modifier
.fillMaxSize()
.nestedScroll(refreshState.nestedScrollConnection)
) {
PullToRefreshContainer(
modifier = Modifier
.align(Alignment.TopCenter)
.zIndex(1f),
state = refreshState,
// 알파값을 직접 전달 → 문제 발생!
containerColor = SpoonyAndroidTheme.colors.main500.copy(alpha = alpha),
contentColor = SpoonyAndroidTheme.colors.white.copy(alpha = alpha)
)
}
}
}
문제점: alpha 값이 변경될 때마다 FollowScreen 전체가 리컴포지션되어 심각한 성능 저하가 발생했어요. 그리고 PTR 컴포넌트 자체도 리컴포지션이 굉장히 많이 발생했어요..
때문에 저는 상위에서 관찰하고 있는 상태값에 이번 솔루션을 적용해 보자 했고 그건 바로 알파 값이었어요!
After: 최적화된 코드
@Composable
private fun FollowScreen() {
val refreshState = rememberPullToRefreshState()
val alpha by remember {
derivedStateOf {
if (refreshState.isRefreshing) {
1f
} else {
val progress = refreshState.progress
progress * progress * progress
}
}
}
Column {
Box(
modifier = Modifier
.fillMaxSize()
.nestedScroll(refreshState.nestedScrollConnection)
) {
PullToRefreshContainer(
modifier = Modifier
.align(Alignment.TopCenter)
.zIndex(1f),
state = refreshState,
containerColor = SpoonyAndroidTheme.colors.main500,
contentColor = SpoonyAndroidTheme.colors.white,
alpha = { alpha } // 상태를 람다로 전달!
)
}
}
}
@Composable
@ExperimentalMaterial3Api
fun PullToRefreshContainer(
state: PullToRefreshState,
modifier: Modifier = Modifier,
indicator: @Composable (PullToRefreshState) -> Unit = { pullRefreshState ->
Indicator(state = pullRefreshState)
},
shape: Shape = PullToRefreshDefaults.shape,
containerColor: Color = PullToRefreshDefaults.containerColor,
contentColor: Color = PullToRefreshDefaults.contentColor,
spinnerContainerSize: Dp = 40.dp,
alpha: () -> Float = { 1f } // Float 반환 람다로 상태를 전달받음
) {
CompositionLocalProvider(LocalContentColor provides contentColor) {
Box(
modifier = modifier
.size(spinnerContainerSize)
.graphicsLayer {
translationY = state.verticalOffset - size.height
this.alpha = alpha() // 여기서 실제 상태 읽기
}
.background(color = containerColor, shape = shape)
) {
indicator(state)
}
}
}
현재 스크린에서 발생하는 리컴포지션은 서버 통신으로 인한 로직이에요. PTR에 의해서 생기는 리컴포지션 자체는 Indicator에서만 일어나게 최적화가 잘 진행됐어요.
성능 개선 결과
최적화 전: 1회 조작당 약 33번 리컴포지션 최적화 후: 1회 조작당 2번 리컴포지션
→ 리컴포지션 횟수 94% 감소 (33번 → 2번)
이는 단순히 숫자상의 개선이 아니라, 사용자가 체감할 수 있는 스크롤 부드러움과 배터리 효율성의 대폭적인 향상을 의미해요.
실제로 꽤나 버벅거렸던 문제가 바로 사라졌어요 :)
저는 이 경험을 토대로, 그렇다면 Modifier에 상태를 전달할 때도 적용할 수 있겠다고 생각이 들었어요. 그리고 공식문서도 권장하는 방식 중하나예요.
(추가 팁) Modifier에서 람다를 사용하는 이유
Modifier는 불변(Immutable) 객체예요. 따라서 Modifier의 값이 변경되면 새로운 객체를 생성해야 해요.
하지만 람다 함수를 사용하면
- 람다 함수 자체는 변하지 않아요 (동일한 인스턴스)
- 람다 내부에서 상태를 읽을 때만 실제 값을 가져와요
- Modifier 객체 재생성 없이 동적 값 적용이 가능해요
따라서 Modifier에 동적 값을 전달할 때는 람다를 적극 활용하는 것이 성능상 유리해요.
마무리
상태 읽기 지연은 Compose 성능 최적화의 핵심 패턴 중 하나예요. 특히 다음과 같은 상황에서 큰 효과를 발휘해요
- 자주 변경되는 상태를 다루는 경우
- 복잡한 UI 계층 구조를 가진 경우
- 애니메이션이나 스크롤과 같은 연속적인 상태 변경이 있는 경우
작은 패턴 변경만으로도 성능 개선을 얻을 수 있다는 점에서, 모든 Compose 개발자가 반드시 알아야 할 필수 기법이라고 할 수 있어요. 너무 어렵다면 아래 두 가지만 기억하면 좋아요.
1. 상태 읽기 지연 적용하기
// ❌ 상태값 직접 전달
MyComponent(value = viewModel.state)
// ✅ 람다로 상태 지연 읽기
MyComponent(valueProvider = { viewModel.state }
2. Modifier에서 람다 활용하기
// ❌ 동적 값 직접 사용
Modifier.alpha(animatedAlpha)
// ✅ 람다로 동적 값 지연 적용
Modifier.graphicsLayer { alpha = animatedAlpha }
성능 최적화는 단순히 "빠르게 만들기"가 아니라, 사용자에게 더 나은 경험을 제공하기 위한 개발자의 책임이라고 생각해요. 오늘 배운 패턴들을 여러분의 프로젝트에도 적용해 보시길~
📚 참고 자료
'Android > Compose' 카테고리의 다른 글
| [Android Compose] flowWithLifecycle은 언제 쓰면 좋을까? (0) | 2025.08.08 |
|---|---|
| [Android Compose] Effect Handlers 딥다이브 (0) | 2025.06.20 |
| [Android Compose] Figma의 타원형 Radial Gradient를 구현해보자 (0) | 2025.06.06 |
| [Android Compose] Figma 그림자를 쉽게 만들어 보자 (0) | 2025.05.14 |
| [Android Compose] TopBar 배경색 전환 애니메이션 구현 방법 (0) | 2025.02.05 |
