[Android] Jetpack Compose Skeleton Loading 최적화하기

ByWindow·2026년 6월 25일

Android

목록 보기
15/15
post-thumbnail

문제: 스켈레톤이 매 프레임 recompose 된다?!

매 프레임마다 recomposition이 발생하는 스켈레톤
로딩 화면에서 흔히 사용하는 스켈레톤 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))
                }
            }
        }
    }
}

원인: 애니메이션 값을 Composition 단계에서 읽고 있었다

문제는 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 자체를 없앨 수 없었습니다.

해결

drawWithCache: 그리기 단계에서 상태를 읽는 도구

해결책을 찾던 중 Modifier.drawWithCache라는 확장 함수를 보게 됐습니다. 동작을 정리하면 이렇습니다.

  • drawWithCache { ... }의 람다는 Composition이 아니라 Draw 단계에서 실행된다.
  • 따라서 그 안에서 읽은 state가 바뀌면 recompose가 아니라 다시 그리기(redraw)만 예약된다.

즉, 문제는 "애니메이션 값을 읽는다"가 아니라 "어느 단계에서 읽느냐"였습니다.

Compose는 화면을 세 단계로 그립니다.

Composition(무엇을 그릴지) → Layout(어디에) → Draw(픽셀로)

그리고 상태를 읽은 단계에 따라, 그 값이 바뀔 때 다시 실행되는 범위가 달라집니다.

  • Composition에서 읽으면 → recompose (세 단계 전부 + 자식 트리)
  • Draw에서 읽으면 → 다시 그리기만 (Composition·Layout은 건너뜀)

그래서 애니메이션 값을 읽는 위치를 @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나 캐시가 아니라, 그 값을 어느 단계에서 읽느냐에 달려 있습니다.

profile
step by step...my devlog

0개의 댓글