
Compose recomposition은 상태가 바뀐 뒤 UI를 다시 계산하는 과정입니다. Compose를 쓰다 보면 화면이 너무 자주 다시 그려지는 것처럼 보여 걱정되지만, recomposition 자체가 항상 문제는 아닙니다.
중요한 것은 어떤 상태를 어디에서 읽고, 그 결과로 얼마나 넓은 UI가 다시 계산되는가입니다. 상태 읽기 위치가 넓으면 작은 변화도 큰 recomposition으로 번질 수 있습니다.

Compose recomposition은 왜 일어날까
Composable 함수는 상태를 읽고 UI를 표현합니다. 그 상태가 바뀌면 Compose는 영향을 받는 부분을 다시 실행해 최신 UI를 만듭니다. 이것이 recomposition입니다.
문제는 상태를 너무 위쪽에서 읽거나, 비싼 계산을 매번 같이 실행하거나, 리스트 item의 identity가 흔들릴 때 생깁니다.
상태 읽기 위치가 중요한 이유
상태를 부모 composable에서 읽으면 부모와 그 아래 일부가 함께 다시 계산될 수 있습니다. 반대로 상태를 실제로 필요한 작은 composable 안에서 읽으면 recomposition 범위를 좁힐 수 있습니다.
@Composable
fun ProfileScreen(viewModel: ProfileViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Header(uiState.name)
ProfileBody(uiState.profile)
}위 코드가 항상 나쁜 것은 아닙니다. 다만 uiState 전체가 자주 바뀌고 Header에는 name만 필요하다면, 필요한 값만 분리해서 읽는 구조를 검토할 수 있습니다.
remember는 언제 도움이 될까
remember는 recomposition 사이에서 계산 결과나 객체를 유지할 때 씁니다. 하지만 모든 값을 remember로 감싸는 것이 최적화는 아닙니다. 입력이 바뀌면 다시 계산되어야 하는 값인지 먼저 봐야 합니다.
val filtered = remember(items, query) {
items.filter { it.name.contains(query, ignoreCase = true) }
}derivedStateOf는 언제 필요할까
자주 바뀌는 상태에서 파생된 boolean이나 작은 결과만 UI에 필요할 때 derivedStateOf가 도움이 될 수 있습니다. 대표적으로 스크롤 위치가 계속 바뀌지만 버튼 노출 여부만 필요할 때입니다.
val showButton by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}LazyColumn에서 자주 보이는 문제
- item key가 없어서 리스트 변경 시 상태가 섞인다
- item 내부에서 비싼 계산을 매번 실행한다
- 전체 list state를 상위에서 읽고 모든 item에 넘긴다
- 안정적이지 않은 객체를 매번 새로 만들어 전달한다
나쁜 구조와 덜 흔들리는 구조 비교
recomposition을 줄이려면 먼저 상태를 어디에서 읽는지 봐야 합니다. 화면 전체에서 scroll state를 읽고 여러 child에 넘기면 작은 스크롤 변화도 넓은 영역에 영향을 줄 수 있습니다.
@Composable
fun FeedScreen(listState: LazyListState) {
val showTopButton = listState.firstVisibleItemIndex > 0
FeedHeader(showTopButton)
FeedList(listState)
FloatingButton(visible = showTopButton)
}반대로 버튼 노출 여부가 필요한 곳에서만 파생 상태를 읽으면 영향을 받는 범위를 줄이기 쉽습니다. 이것이 항상 성능을 보장한다는 뜻은 아니지만, 구조를 좁히는 첫 단계가 됩니다.
@Composable
fun ScrollToTopButton(listState: LazyListState) {
val visible by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
if (visible) {
Button(onClick = { /* scroll */ }) {
Text("Top")
}
}
}recomposition을 무조건 피하면 안 되는 이유
Compose는 recomposition을 전제로 설계된 UI 도구입니다. 그래서 recomposition이 일어났다는 사실만으로 문제라고 보면 안 됩니다. 실제로 봐야 하는 것은 recomposition 안에서 실행되는 작업의 비용, 화면 jank, 불필요한 객체 생성입니다.
- 단순 Text가 다시 계산되는 것은 대부분 큰 문제가 아니다
- 이미지 디코딩, 정렬, 필터링 같은 비싼 작업이 같이 돌면 문제가 된다
- 리스트 item identity가 바뀌면 상태 섞임과 스크롤 튐이 생길 수 있다
- 성능 의심 구간은 Layout Inspector나 tracing으로 확인한다
상태 hoisting과 recomposition 범위
state hoisting은 테스트와 재사용에는 좋지만 모든 상태를 최상위 화면에 모으라는 뜻은 아닙니다. UI 전체가 알아야 하는 상태와 작은 컴포넌트만 알아도 되는 상태를 나누는 것이 중요합니다.
예를 들어 검색어는 화면 전체 목록 필터링에 필요할 수 있지만, 특정 row의 expanded 여부는 row 내부 또는 item id 기반 상태로 관리하는 편이 더 자연스러울 수 있습니다.
실제 점검 순서
성능이 의심될 때는 코드를 먼저 바꾸기보다 증상을 좁혀야 합니다. 스크롤이 끊기는지, 입력이 늦는지, 특정 item만 느린지에 따라 원인이 다릅니다.
- 느린 화면을 하나 고른다
- 상태 변경이 자주 일어나는 지점을 찾는다
- 그 상태를 읽는 composable 범위를 본다
- 비싼 계산이나 객체 생성이 recomposition 안에 있는지 확인한다
- 작게 고친 뒤 체감과 측정 결과를 비교한다
자주 하는 과한 최적화
Compose 성능 글을 읽다 보면 remember를 많이 쓰고 싶은 유혹이 생깁니다. 하지만 remember는 상태 보존 도구이지 모든 문제의 답은 아닙니다. 잘못 쓰면 코드의 의도가 흐려지고, 입력 변경을 놓치는 버그가 생길 수 있습니다.
- 단순 문자열 조합까지 모두 remember로 감싼다
- 입력 key를 빠뜨린 remember를 쓴다
- derivedStateOf가 필요 없는 값에도 적용한다
- 측정 없이 stable class부터 만들려고 한다
코드 리뷰에서 볼 질문
Compose 성능 문제는 코드 리뷰에서도 어느 정도 걸러낼 수 있습니다. 상태를 읽는 위치, 리스트 key, 비싼 계산 위치, side effect key를 함께 보면 실제 성능 문제로 번지기 전에 구조를 잡을 수 있습니다.
- 이 상태는 정말 이 composable 전체가 알아야 하는가
- 리스트 item의 key는 안정적인가
- 정렬과 필터링이 매 recomposition마다 돌지 않는가
- side effect key가 너무 자주 바뀌지 않는가
실무 체크리스트
- 상태를 실제로 필요한 가장 작은 범위에서 읽는지 확인한다
- 비싼 계산은 입력 기준으로 remember를 검토한다
- 자주 변하는 값에서 작은 결과만 필요하면 derivedStateOf를 검토한다
- LazyColumn item에는 안정적인 key를 둔다
- 측정 없이 모든 recomposition을 문제로 단정하지 않는다
정리
Compose recomposition은 Compose가 UI를 최신 상태로 유지하는 자연스러운 과정입니다. 성능 문제는 recomposition의 존재가 아니라 범위와 비용에서 생깁니다.
기본 기준은 Android Developers Compose performance 문서를 확인하는 것이 좋습니다. side effect 흐름은 LaunchedEffect key 글과 함께 보면 연결됩니다.