렌더링 최적화를 위한 Jetpack Compose Recomposition 최소화
Jetpack Compose는 선언형 UI를 구성하는 현대적인 Android 툴킷으로, 기존의 명령형 방식과 달리 개발자가 UI를 ‘어떻게 그릴지’가 아닌 ‘무엇을 그릴지’에 집중할 수 있도록 도와줍니다. 명령형 UI에서는 상태 변화에 따라 UI를 직접 갱신해야 하지만, 선언형 UI는 상태 변화에 따라 UI를 자동으로 갱신하므로 개발자는 상태 관리에만 집중할 수 있어 보다 효율적입니다.
그런데 Jetpack Compose는 상태 변화로 인한 UI 갱신을 Recomposition이라는 과정을 통해 처리하는데 이 과정이 비효율적으로 처리되면 성능 저하를 일으킬 수 있습니다. 코빗은 지난 2023년부터 Jetpack Compose를 적용해 왔는데 이제는 시간이 좀 지나긴 했지만, 코빗 기술 블로그 개설을 맞아 제가 Jetpack Compose에서 Recomposition을 효율적으로 관리하고 최적화했던 방법을 실제 개발 당시의 경험과 함께 소개하려고 합니다.
Recomposition 발생 원리와 최적화 방법
Jetpack Compose의 선언형 UI 방식에서는 UI 구성 단위인 Composable 함수의 상태가 변경될 때 호출되는 Recomposition을 잘 관리하는 것이 성능 관리에 중요합니다. 여기서 Recomposition은 UI 구성 단위인 Composable 함수의 상태가 변경될 때 해당 함수가 다시 호출되며 UI가 업데이트되는 과정을 말합니다. 상태가 변경되면 해당 상태를 직접 읽는 모든 Composable이 Recomposition 대상이 되기 때문에, 만일 부모 Composable에서 상태를 읽고 자식에게 전달한다면 상태 변경 시 부모도 함께 Recomposition되는 비효율이 발생합니다. 따라서 특정 부분만 다시 그려지도록 최적화하는 것이 성능 관리에 중요합니다.
문제: Recomposition의 비효율 발생
예를 들어, 저희가 기존에 개발했던 직장/학교 정보를 변경하는 화면에서는 아래와 같이 상세 주소를 입력하는 UI의 상태가 실시간으로 변경되면서 Recomposition이 발생하는 문제가 있었습니다. 즉, 상세 주소 입력 UI Composable과 동일한 부모 Composable 계층에 속한 다른 직업구분, 직업코드 등의 입력 UI Composable까지 함께 Recomposition되기 때문에 비효율적인 렌더링이 발생하는 문제가 있었습니다.
문제를 어떻게 해결할까
그럼 위의 문제를 해결하는 방법에 대해 말씀드리겠습니다. 기존에는 아래 코드처럼 직장, 학교 정보 변경 화면의 UI 상태를 담는 EditJobUiState 데이터 클래스에 상세 주소의 상태와 관련된 필드가 같이 있었습니다. 그래서 상세 주소를 업데이트하면 EditJobUiState가 갱신되면서 화면 전체 UI에서 Recomposition이 발생했습니다.
data class EditJobUiState(
...
val jobTypeCode: TextFieldState,
val jobTypeCodeSelector: Selector.JobTypeCode,
...
// 직장 상세 주소 정보가 같은 상태 클래스 내에 있음
val jobDetailAddress: TextFieldState,
...
)
@Composable
fun EditJobScreen(
viewModel: EditJobViewModel,
...
) {
val uiState by viewModel.uiState.collectAsState()
...
/* uiState를 EditJobScreenLayout에서 읽기 때문에 상세 주소 입력 UI이 업데이트될 때마다
부모 Composable인 EditJobScreen 전체가 Recomposition되는 비효율이 발생합니다. */
EditJobScreenLayout(
uiState = uiState,
...
)
}
@Composable
private fun EditJobScreenLayout(
uiState: EditJobUiState,
...
) {
...
BaseTextFieldLayout(
...
// 이미 부모 Composable 에서 읽은 상태를 넘겨받음
text = uiState.jobDetailAddress.text,
state = uiState.jobDetailAddress.state,
...
)
...
}
렌더링 최적화 방법
안드로이드 공식 문서에 따르면, Jetpack Compose의 렌더링 성능을 개선할 수 있는 다양한 모범 사례가 있고, 저희는 자식 Composable인 상세 주소만 Recomposition될 수 있도록 하는 것이 목적이므로 ‘Defer reads as long as possible(최대한 상태 읽기를 미루기)’ 라는 방법을 사용했습니다.
해당 방법을 적용하려면 아래 순서대로 수정이 필요합니다.
- 상세 주소 상태를 EditJobUiState에서 분리하기
- 상태를 읽는 시점을 상세 주소 입력 UI를 그릴 때까지 미루기
- 상세 주소 Composable의 부모 역할을 해줄 Wrapper라는 Intermediate Composable로 감싸주기
이 세 단계를 거치면 상세 주소 필드만 변경될 때 다른 UiState 필드를 상태로 가지는 Composable이 Recomposition 되지 않도록 할 수 있습니다.
렌더링 최적화 적용
- 상세 주소 상태를 EditJobUiState에서 분리하기
EditJobUiState와 상세 주소 관련 상태 모델을 분리시킨 코드는 아래와 같습니다.
// 위 화면의 UiState 모델
data class EditJobUiState(
...
val jobTypeCode: TextFieldState,
val jobTypeCodeSelector: Selector.JobTypeCode,
...
)
// 상세 주소 상태 모델 - 상세 주소 관련 상태를 분리
data class JobDetailAddressTextFieldState(
val textFieldState: TextFieldState,
val isJobDetailAddressValid: Boolean,
)
- 상태를 읽는 시점을 상세 주소 입력 UI를 그릴 때까지 미루기
직장/학교 정보 변경 화면의 Composable 에서 상세 주소의 상태를 관찰하고 람다를 사용해 해당 상태를 넘기면 상세 주소 입력 UI를 그릴 때까지 상태를 읽는 시점을 미룰 수 있게됩니다.
@Composable
fun EditJobScreen(
viewModel: EditJobViewModel,
...
) {
val uiState by viewModel.uiState.collectAsState()
val detailAddressTextFieldState by viewModel.jobDetailAddressTextFieldState.collectAsState()
...
EditJobScreenLayout(
uiState = uiState,
// 람다로 상태를 넘겨주면서 읽기를 최대한 지연시킵니다.
detailAddressTextFieldState = { detailAddressTextFieldState },
...
)
}
- 상세 주소 Composable의 부모 역할을 해줄 Wrapper라는 Intermediate composable로 감싸주기
그 후 아래 코드와 같이 Wrapper 라는 Intermediate Composable로 감싸게 되면 Wrapper를 부모 Composable로 인식하기 때문에 상세 주소 Composable의 상태가 업데이트되더라도 Wrapper 안의 상세 주소 Composable만 Recomposition 하게 됩니다.
@Composable
fun Wrapper(content: @Composable () -> Unit) {
content()
}
...
@Composable
private fun EditJobScreenLayout(
uiState: EditJobUiState,
detailAddressTextFieldState: () -> EditJobUiState.JobDetailAddressTextFieldState,
...
) {
...
Wrapper {
BaseTextFieldLayout(
...
text = detailAddressTextFieldState().textFieldState.text, // 여기서 상태 Read
state = detailAddressTextFieldState().textFieldState.state, // 여기서 상태 Read
...
)
}
...
}
Wrapper Composable이 새로운 Recomposition 경계를 생성하여, 람다 내부의 상태 변경이 Wrapper 내부에서만 처리되도록 합니다. 위의 단계를 최종적으로 도식화하면 아래와 같이 개선된 모습을 볼 수 있습니다.
최적화가 잘 되었는지 검증하기
그럼 우리가 개선한 방식이 실제로도 개선이 되었을까요? 이론적으로 생각해볼 때 최적화를 적용 후 Recomposition 횟수는 아래와 같이 감소할 것으로 예상됩니다.
(A - 1) * B
A : 상세 주소 입력 UI가 속한 부모 Composable 내의 Composable 개수
B : 상세 주소 입력 UI에 키보드로 입력한 횟수
안드로이드 스튜디오에서 Layout Inspector라는 기능으로 특정 화면 내 Composable의 Recomposition 횟수를 확인할 수 있습니다. 해당 기능을 통해 실제로 Recomposition이 감소했는지 확인해보도록 하겠습니다.
상세 주소 입력 란에 텍스트를 입력해 16번의 Recomposition이 발생하게 해 보겠습니다. 최적화 전에는 상세 주소 Composable이 속한 부모 Composable 내의 모든 Composable이 16번씩 Recomposition된 것을 확인할 수 있습니다.
그런데 최적화 후에는 상세 Wrapper 안의 상세 주소 Composable만 16번 Recomposition된 것을 확인할 수 있습니다.
결론
Jetpack Compose는 직관적인 선언형 UI 구조로 개발자에게 강력한 생산성을 제공하지만, 효율적인 Recomposition 관리를 통해 렌더링 최적화를 이루는 것이 불필요한 렌더링을 줄이고 성능을 높이기 위해 필수적입니다. 이처럼 상태를 적절히 분리하고, Wrapper와 람다를 활용해 상태 읽기를 지연하는 방법을 적용함으로써 Jetpack Compose의 장점을 극대화할 수 있습니다.
저는 앞으로 가능하다면 Jetpack Compose의 성능 개선에 관한 모범 사례에 소개된 ‘Use remember to minimize expensive calculations’, ‘Use derivedStateOf to limit recompositions’와 같은 방법들을 이용해 렌더링을 최적화하는 것에 대해서도 다뤄보고자 합니다. 또, 저희 팀은 생성형 AI 코딩 에이전트를 사용할 때에도 Jetpack Compose 모범 사례를 고려한 규칙 같은 것들을 적용하고 있는데, 이런 경험에 대해서도 앞으로 코빗 기술 블로그를 통해 공유할 수 있으면 좋겠습니다.
마지막으로, 앞으로도 UI 상태 관리와 렌더링 최적화에 집중해 코빗 이용자에게 보다 부드럽고 빠른 화면 전환을 제공하는 안드로이드 앱을 개발하도록 하겠습니다.
감사합니다.
