

로딩 화면에서 흔히 사용하는 스켈레톤 UI를 rememberInfiniteTransition으로 구현해 두고 사용했습니다. 화면은 멀쩡하게 나왔지만 Layout Inspector의 recomposition 카운트를 확인하니 문제가 드러났습니다. 화면에는 정적인 회색 박스 몇 개뿐인데, 스켈레톤이 떠 있는 내내 recomposition 카운트가 매 프레임 계속 증가하고 있었습니다. 초당 60회씩 같은 트리를 다시 구성하고 있던 셈입니다.
리팩토링 전 코드는 아래와 같았습니다.
LoadingShimmerEffect는 반짝이는 그라데이션(Brush)을 만들어 넘겨주는 래퍼이고, TownListItemSkeleton은 그 brush로 각 회색 자리를 칠해 실제 항목 모양을 그립니다.
// (1) 반짝이는 그라데이션 brush를 만들어 content 람다로 넘겨주는 래퍼
@Composable
fun LoadingShimmerEffect(content: @Composable (Brush) -> Unit) {
// 반짝임에 쓸 회색 세 단계 (밝게 → 어둡게 → 밝게)
val shimmerColors = listOf(
Color.LightGray.copy(alpha = 0.6f),
Color.LightGray.copy(alpha = 0.2f),
Color.LightGray.copy(alpha = 0.6f),
)
// 0 → 1000 → 0 을 끝없이 왕복하는 무한 애니메이션 값
val transition = rememberInfiniteTransition(label = "")
val translateAnim = transition.animateFloat(
initialValue = 0f,
targetValue = 1000f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1000, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
), label = ""
)
// 위 애니메이션 값으로 그라데이션의 끝점을 옮겨, 빛이 스쳐 지나가는 효과를 만든다
val brush = Brush.linearGradient(
colors = shimmerColors,
start = Offset.Zero,
end = Offset(x = translateAnim.value, y = translateAnim.value)
)
content(brush) // 이 brush를 받아 각 회색 자리가 background(brush)로 칠한다
}
// (2) 실제 스켈레톤 항목 한 개의 모양
@Composable
private fun TownListItemSkeleton() {
LoadingShimmerEffect { brush ->
Box(
modifier = Modifier
.padding(horizontal = 20.dp, vertical = 8.dp)
.fillMaxWidth()
// gray50 / gray200 은 앱의 색상 팔레트
.background(color = DiaryColorsPalette.current.gray50, shape = RoundedCornerShape(16.dp))
.border(width = 1.dp, color = DiaryColorsPalette.current.gray200, shape = RoundedCornerShape(16.dp))
.clip(shape = RoundedCornerShape(16.dp))
.padding(top = 16.dp, start = 20.dp, end = 20.dp, bottom = 20.dp)
) {
Column(modifier = Modifier.fillMaxWidth()) {
// 윗줄: 원형 아바타 + 텍스트 막대 2개
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
// 각 자리를 brush로 칠하면, brush가 움직이며 반짝이는 것처럼 보인다
Box(Modifier.size(24.dp).clip(CircleShape).background(brush)) // 아바타
Box(Modifier.width(80.dp).height(16.dp).clip(RoundedCornerShape(4.dp)).background(brush)) // 텍스트 1
Box(Modifier.width(60.dp).height(14.dp).clip(RoundedCornerShape(4.dp)).background(brush)) // 텍스트 2
}
Spacer(modifier = Modifier.height(12.dp))
// 아랫줄: 정사각형 이미지 자리 2개
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Box(Modifier.weight(1f).aspectRatio(1f).clip(RoundedCornerShape(8.dp)).background(brush))
Box(Modifier.weight(1f).aspectRatio(1f).clip(RoundedCornerShape(8.dp)).background(brush))
}
}
}
}
}
문제는 LoadingShimmerEffect 안의 이 부분입니다.
val translateAnim = transition.animateFloat(...) // 매 프레임 값이 바뀌는 State
val brush = Brush.linearGradient(
start = Offset.Zero,
end = Offset(x = translateAnim.value, ...) // @Composable 본문에서 읽는다
)
content(brush) // 그 brush를 자식에게 넘긴다
animateFloat은 매 프레임 값이 바뀌는 무한 애니메이션입니다. 그 값을 @Composable 함수 본문에서 읽으면, 값이 바뀔 때마다 해당 Composable이 recompose됩니다. 무한 애니메이션이므로 매 프레임 recompose가 멈추지 않고, brush를 content(brush)로 자식에 넘기는 구조 탓에 그 brush로 칠하던 자식 Box들까지 함께 다시 구성됐습니다. 게다가 호출하는 쪽에서 LoadingShimmerEffect { TownListItemSkeleton() }처럼 한 번 더 감싸 애니메이션이 중첩되어 있었습니다.
색상 리스트와 Shape를 remember로 감싸고 이 불필요한 중첩을 정리해도 카운트는 그대로였습니다. 매 프레임 바뀌는 값을 본문에서 읽는다는 구조 자체가 바뀌지 않았기 때문입니다. 객체 재할당을 줄이는 것만으로는 recomposition 자체를 없앨 수 없었습니다.
해결책을 찾던 중 Modifier.drawWithCache라는 확장 함수를 보게 됐습니다. 동작을 정리하면 이렇습니다.
drawWithCache { ... }의 람다는 Composition이 아니라 Draw 단계에서 실행된다.즉, 문제는 "애니메이션 값을 읽는다"가 아니라 "어느 단계에서 읽느냐"였습니다.
Compose는 화면을 세 단계로 그립니다.
Composition(무엇을 그릴지) → Layout(어디에) → Draw(픽셀로)
그리고 상태를 읽은 단계에 따라, 그 값이 바뀔 때 다시 실행되는 범위가 달라집니다.
그래서 애니메이션 값을 읽는 위치를 @Composable 본문 밖으로 꺼내 drawWithCache 안에서 읽도록 바꿨습니다. 기존 @Composable 함수는 Modifier.shimmer() 확장 함수로 다시 작성했습니다.
@Composable
fun Modifier.shimmer(): Modifier {
val shimmerColors = remember { listOf(...) }
val transition = rememberInfiniteTransition(label = "shimmer")
val translateAnim by transition.animateFloat(...)
return this.drawWithCache { // 여기부터 Draw 단계
val brush = Brush.linearGradient(
colors = shimmerColors,
start = Offset.Zero,
end = Offset(x = translateAnim, y = translateAnim) // 값을 여기서 읽는다
)
onDrawBehind {
drawRect(brush = brush, size = size)
}
}
}
함께 쓰인 onDrawBehind는 콘텐츠 "뒤(behind)"에 그리는 블록입니다. size는 레이아웃이 확정한 실제 픽셀 크기이므로 그 영역을 brush로 채웁니다. 바깥에 .clip(shape)을 두면 그 모양대로 잘립니다. brush를 자식에게 넘기던 구조도 사라져서, 이제 어떤 컴포저블이든 .clip(shape).shimmer() 한 줄로 적용할 수 있습니다.
함수 이름이 drawWithCache라서 "brush를 캐싱해 한 번만 만들어 빨라진다"고 오해하기 쉽습니다. 하지만 애니메이션 값이 매 프레임 바뀌므로 brush는 사실 매 프레임 새로 만들어집니다. 이 경우 캐싱으로 얻는 이득은 없습니다.
실제 이득은 다른 데 있습니다. 그 작업 전체가 Draw 단계 안에 머무르기 때문에 recomposition이 한 번도 일어나지 않는다는 점입니다. 여기서 drawWithCache는 캐시 도구가 아니라 상태 읽기를 Draw 단계로 격리하는 도구로 쓰였습니다.

수정 후 Layout Inspector를 다시 확인하니, 스켈레톤이 떠 있는 내내 recomposition 카운트는 증가하지 않았습니다. shimmer 애니메이션은 그대로 동작하며, 매 프레임 갱신은 Draw 단계에서만 이뤄집니다.
정리하면, 자주 바뀌는 값일수록 읽는 위치를 가능한 한 뒤(Composition → Layout → Draw)로 미루는 것이 좋습니다. recomposition을 없애는 것은 remember나 캐시가 아니라, 그 값을 어느 단계에서 읽느냐에 달려 있습니다.