Jetpack Compose에서는 UI가 상태에 따라 자동으로 다시 그려지는 리컴포지션(Recomposition) 덕분에 선언적 UI 작성이 쉬워졌습니다.
하지만 모든 것을 @Composable로만 그리면, 불필요한 리컴포지션이 자주 발생해 성능이 떨어질 수 있습니다.
이번 글에서는 Compose의 그래픽 API와 Draw Modifiers를 활용해 효율적으로 커스텀 드로잉을 처리하고 리컴포지션을 줄이는 방법을 정리해보겠습니다.
Compose의 Draw Modifiers는 Composable의 시각적 출력을 커스터마이징할 수 있는 도구입니다. 대표적으로 다음과 같은 Modifier 함수들이 있습니다.
Modifier.drawBehind { ... }
→ Composable 뒤에 직접 그리기
Modifier.drawWithContent { ... }
→ Composable 기존 콘텐츠와 함께 그리기
Modifier.drawWithCache { ... }
→ 캐싱 기반 드로잉 — 리컴포지션을 줄이는 핵심!
두 가지 모두 Canvas에 직접 그릴 수 있는 API지만, 리컴포지션 빈도에 큰 차이가 있습니다.
| 항목 | drawBehind | drawWithCache |
|---|---|---|
| 호출 시점 | 매번 그릴 때마다 | 캐시된 DrawScope를 활용 |
| 리컴포지션 | 자주 발생 | 최소화됨 |
| 렌더링 성능 | 간단하지만 비효율적일 수 있음 | 비싼 연산을 캐시하여 효율적 |
| 사용 용도 | 간단한 배경, 작은 효과 | 복잡한 드로잉, 반복 계산 최소화 |
| 상태 의존성 | 상태 변경 시 즉시 다시 그려짐 | 상태가 바뀌지 않으면 재계산하지 않음 |
| 대표 예시 | 배경색, 단색 도형 | Gradient, Path, Brush 등 복잡한 효과 |
| 추천 시나리오 | 빠르게 간단히 그릴 때 | 성능이 중요한 커스텀 그래픽 구현 시 |
Box(
modifier = Modifier
.size(200.dp)
.drawBehind {
drawRect(Color.Gray)
drawCircle(Color.Blue, radius = 80f)
}
)
이 코드는 단순하지만, 상태가 바뀌어 Box가 리컴포즈될 때마다 매번 drawBehind가 다시 호출됩니다. 이런 경우는 간단한 드로잉엔 괜찮지만, 복잡한 그래픽이면 퍼포먼스 저하가 생깁니다.
drawWithCache는 이름 그대로, 드로잉 계산을 캐시하여 불필요한 다시 그리기를 막아줍니다.
Box(
modifier = Modifier
.size(200.dp)
.drawWithCache {
// 캐시에 저장되는 영역
val gradient = Brush.linearGradient(
colors = listOf(Color.Cyan, Color.Blue)
)
onDrawBehind {
// drawScope 내부에서 gradient 재사용
drawRect(brush = gradient)
}
}
)
onDrawBehind { ... }는 실제 그리기 단계에서만 호출되고,
그 외의 비용이 큰 계산(예: Brush 생성)은 drawWithCache의 초기 캐시 영역에서 한 번만 수행됩니다.
상태 변경이 일어나도 gradient가 바뀌지 않는다면, drawWithCache는 다시 계산하지 않습니다. 결과적으로 리컴포지션과 재그리기 모두 줄어듭니다.
drawWithCache 내부에서 상태를 사용할 때 주의할 점이 있습니다.
@Composable
fun CachedCircle(color: Color) {
Box(
modifier = Modifier
.size(150.dp)
.drawWithCache {
onDrawBehind {
drawCircle(color)
}
}
)
}
이 예시는 단순히 color만 사용하는데, 만약 color 값이 자주 바뀐다면?
➡️ drawWithCache는 내부 캐시를 새로 계산합니다. 따라서 진짜로 자주 바뀌지 않는 값만 캐싱하는 것이 핵심입니다.
예를 들어, 매 프레임마다 회전하는 애니메이션을 그려야 한다면?
@Composable
fun RotatingShape(angle: Float) {
Box(
modifier = Modifier
.size(200.dp)
.drawWithCache {
val brush = Brush.radialGradient(
listOf(Color.Magenta, Color.Red)
)
onDrawBehind {
rotate(angle) {
drawRect(brush = brush)
}
}
}
)
}
이때 angle 값이 바뀔 때마다 전체 리컴포지션이 발생하지는 않습니다.
drawWithCache 내부에서는 draw 단계만 갱신되므로, Canvas의 invalidate()만 호출되어 매우 효율적입니다.
| Modifier | 설명 |
|---|---|
drawBehind | 기존 콘텐츠 뒤에 그리기 |
drawWithContent | 기존 콘텐츠 위에 덧그리기 |
drawWithCache | 드로잉 계산 캐시 |
graphicsLayer | 하드웨어 가속 및 변환 (회전, 스케일 등) |
alpha, clip, shadow | 자주 쓰이는 그래픽 효과 |
예를 들어, drawWithContent는 기존 컴포넌트를 유지한 채 추가적인 효과를 넣을 때 유용합니다.
Modifier.drawWithContent {
drawContent()
drawRect(Color.Black.copy(alpha = 0.2f)) // 반투명 오버레이
}
| 전략 | 설명 |
|---|---|
✅ drawWithCache 사용 | 비싼 계산(Brush, Path 등)은 캐시 |
✅ remember와 함께 사용 | Compose 상태 기반 캐시 병행 |
✅ graphicsLayer 활용 | GPU 변환으로 효율적인 렌더링 |
| ⚠️ 상태를 과도하게 의존하지 말기 | 상태 변경이 많으면 캐시 무용지물 |
| ⚡ 가능한 한 “그리기 전용 함수”로 분리 | UI와 렌더 로직 분리 |
리컴포지션을 줄이는 핵심은 “무엇을 다시 계산할지 최소화하는 것”입니다.
Compose의 그래픽 시스템은 선언적이지만, drawWithCache와 같은 도구를 잘 활용하면 낮은 오버헤드로 고성능 UI를 구현할 수 있습니다.
단순한 효과 → drawBehind
복잡한 계산 → drawWithCache
실시간 애니메이션 → graphicsLayer
이렇게 나눠서 사용하는 것이 좋습니다.
💡 TIP: 복잡한 UI 효과를 그릴 때는 Composable을 여러 개 쪼개기보다,
하나의 Composable 내부에서 drawWithCache를 사용하는 편이 오히려 리컴포지션을 덜 발생시킵니다.