필자가 회사에서 업무를 진행하다 보면, 룰렛이나 럭키 드로우 같은 회전 UI를 만들어야 하는 경우가 가끔 있다. 단순히 빙글빙글 돌기만 하면 좀 밋밋한데, 빠르게 회전할 때 살짝 흐릿하게 처리되는 모션 블러가 더해지면 한층 더 그럴듯한 느낌이 난다는 것을 게임이나 시뮬레이션 앱을 보면서 느낀 적이 있다.
막상 Compose에서 모션 블러를 어떻게 구현해야 하는지는 의외로 잘 정리된 자료를 찾기가 어려웠다. 최근 안드로이드 관련 기술 블로그를 확인하다가 ProAndroidDev의 "Motion Blur for a Spinning Wheel in Jetpack Compose" 글을 보게 되었고, 세 가지 다른 접근 방식을 비교하는 형태로 정리되어 있어서 매우 인상 깊었다.
이번 글에서는 Jetpack Compose에서 스피닝 휠(룰렛)을 만들고, 거기에 모션 블러를 적용하는 세 가지 기법(Ghost Frames, BlurMaskFilter, RenderEffect)을 각각 구현하고 장단점을 비교해 보고자 한다.
먼저 스피닝 휠부터 만들기
모션 블러를 다루기 전에, 회전하는 휠 자체를 먼저 만들어 보자. Canvas의 drawArc로 부채꼴(sector)들을 둥글게 배치하면 된다.
private val wheelColors = listOf(
Color(0xFFE53935), Color(0xFFFF9800),
Color(0xFFFFEB3B), Color(0xFF4CAF50),
Color(0xFF2196F3), Color(0xFF9C27B0),
Color(0xFF00BCD4), Color(0xFFFF5722),
)
Canvas(modifier = Modifier.size(240.dp)) {
val radius = size.minDimension / 2f
val cx = size.width / 2f
val cy = size.height / 2f
val sweepAngle = 360f / wheelColors.size
wheelColors.forEachIndexed { i, color ->
val startAngle = i * sweepAngle
drawArc(
color = color,
startAngle = startAngle,
sweepAngle = sweepAngle,
useCenter = true,
topLeft = Offset(cx - radius, cy - radius),
size = Size(radius * 2, radius * 2)
)
}
}
회전 애니메이션은 rememberInfiniteTransition으로 만들고, graphicsLayer의 rotationZ에 적용한다. LinearEasing을 쓰는 이유는 모션 블러 효과를 가장 잘 보여주기 위해서이다(가속/감속이 있으면 블러 강도가 균일하지 않음).
val infiniteTransition = rememberInfiniteTransition()
val rotation by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 2000, easing = LinearEasing)
)
)
Modifier.graphicsLayer { rotationZ = rotation }
이 상태에서 휠은 매끄럽게 회전한다. 다만 빠르게 돌수록 사람의 눈에는 "딱딱하게 움직인다"는 느낌이 든다. 진짜로 빙글빙글 돈다는 인상을 주려면 모션 블러가 필요하다는 점을 직접 돌려보면서 확인하게 되었다.
기법 1: Ghost Frames (유령 프레임)
가장 고전적이면서도 가장 직관적인 방법이다. "동일한 휠을 살짝 다른 각도로 여러 장 반투명하게 겹쳐서 그린다"가 핵심 아이디어이다. 카메라 셔터스피드가 느릴 때 빠르게 움직이는 물체가 잔상으로 남는 것과 같은 원리이다.
val ghostCount = 8
val ghostDelta = speed / 60f // 프레임당 이동 각도
val baseAlpha = 0.7f
repeat(ghostCount) { i ->
val ghostRotation = rotation - i * ghostDelta
val alpha = baseAlpha * (1f - i.toFloat() / ghostCount) // 점점 옅어짐
withTransform({
rotate(ghostRotation, pivot = Offset(cx, cy))
}) {
drawWheelSectors(alpha = alpha)
}
}
위 코드의 핵심은 alpha가 인덱스에 따라 점점 옅어진다는 점이다. 가장 최근 위치(i=0)는 거의 선명하게, 가장 오래 전 위치(i=7)는 거의 투명하게 그린다. 이렇게 하면 회전 방향으로 자연스럽게 흩어지는 잔상이 만들어진다.
장점
- API 레벨 제한 없음 (모든 Android 버전에서 동작)
- 블러 방향이 회전 방향과 정확히 일치 (이 부분이 다른 두 기법보다 우월함)
- ghostCount, ghostDelta, alpha 조정으로 블러 강도를 정밀하게 제어 가능
단점
- ghostCount가 많을수록 그리기 호출이 비례해서 늘어남 (8개면 휠을 8번 그림)
- 매 프레임마다 반복 그리기 → 성능 비용이 가장 큼
기법 2: BlurMaskFilter
안드로이드 네이티브 Paint API에 있는 BlurMaskFilter를 활용하는 방법이다. Compose의 Paint를 만들어서 asFrameworkPaint()로 네이티브 Paint에 접근한 뒤 maskFilter를 설정한다.
val blurPaint = Paint().apply {
asFrameworkPaint().apply {
isAntiAlias = true
maskFilter = BlurMaskFilter(
radius, // 블러 반지름 (px)
BlurMaskFilter.Blur.NORMAL // 블러 스타일
)
}
}
drawIntoCanvas { canvas ->
canvas.drawCircle(
center = Offset(cx, cy),
radius = radius,
paint = blurPaint
)
}
BlurMaskFilter.Blur는 네 가지 모드를 제공한다.
NORMAL: 내부+외부 모두 블러SOLID: 내부는 선명, 외부만 블러 (그림자 효과에 적합)INNER: 내부만 블러, 외부는 선명OUTER: 외부에만 블러 그림자 (내부는 투명)
장점
- 코드가 매우 짧고 간단함
- API 레벨 제한 없음
단점
- 블러가 모든 방향으로 균등하게 퍼지기 때문에 회전 방향과 무관한 "흐릿함"이 됨
- 하드웨어 가속 활성화 시 소프트웨어 레이어로 폴백될 수 있어 성능이 떨어짐
- 모션 블러보다는 "단순 흐림" 효과에 가까움
필자가 직접 시도해 본 결과, 모션 블러로서의 "방향성"은 거의 표현하지 못한다는 한계가 명확했다. 다만 회전이 아니라 단순히 부드러운 흐림 효과를 원할 때는 가장 간단한 선택지가 될 것 같다.
기법 3: RenderEffect (API 31+)
Android 12(API 31)부터 추가된 RenderEffect는 GPU 가속을 활용한 블러를 제공한다. Compose에서는 graphicsLayer의 renderEffect 프로퍼티로 사용한다.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
Modifier.graphicsLayer {
rotationZ = rotation
// x축 블러만 적용 → 반경 방향 블러 흉내
renderEffect = BlurEffect(
radiusX = blurRadius,
radiusY = 0f,
edgeTreatment = TileMode.Decal
)
}
}
여기서 한 가지 흥미로운 트릭은 radiusX만 적용하고 radiusY = 0f로 둔다는 것이다. 양쪽 모두 블러를 주면 단순 흐림이 되지만, 한 축만 블러를 주면 그 방향으로 흐려지는 효과가 만들어진다. 휠이 회전 중일 때는 이게 어느 정도 "회전 블러"처럼 보이는 착시 효과를 준다.
장점
- GPU 하드웨어 가속 → 성능이 가장 우수
- 네이티브 블러 품질로 매끄러운 결과
- 코드가 매우 간결
단점
- API 31+ 필요 (이전 버전 호환을 위해 분기 처리 필수)
- 축 방향 블러라서 진짜 회전 방향 블러는 아님 (Ghost Frames보다 정확도 낮음)
속도에 비례한 동적 블러 강도
실제 물리적 회전처럼 보이게 하려면, 회전 속도에 따라 블러 강도가 동적으로 변해야 한다. 천천히 돌 때는 거의 블러 없이, 빠르게 돌 때는 잔상이 강하게 남는 식이다.
val blurRadius = (abs(angularVelocity) / maxVelocity) * maxBlurRadius
val ghostCount = (abs(angularVelocity) / maxVelocity * maxGhosts).toInt()
이 방식을 적용하면 룰렛이 멈출 때 블러가 점진적으로 사라지는 효과까지 자연스럽게 표현된다. 단순히 "회전 = 블러 ON"이 아니라 속도와 연동되니까 훨씬 사실적인 느낌이 만들어진다.
세 가지 기법 비교 정리
| 기법 | API 레벨 | 성능 | 방향 정확도 | 구현 난이도 |
|---|---|---|---|---|
| Ghost Frames | 모든 버전 | 보통(그리기 N회) | 높음 | 보통 |
| BlurMaskFilter | 모든 버전 | 낮음(SW 폴백) | 낮음 | 쉬움 |
| RenderEffect | 31+ | 높음(GPU) | 보통 | 쉬움 |
필자가 세 가지를 모두 직접 만들어보고 비교해 본 결과는 다음과 같다.
- 빠른 회전 + 정확한 회전 방향 블러가 필요하다면 → Ghost Frames
- 회전이 아닌 단순 흐림 효과면 충분하다면 → BlurMaskFilter
- API 31+ 보장 가능 + 성능이 가장 중요하다면 → RenderEffect
그리고 한 가지 더 깨달은 점은, "어떤 기법이 가장 좋은가"보다 "내가 표현하고 싶은 게 무엇인가"에 따라 선택지가 달라진다는 것이다. 모션 블러 자체는 단순한 시각 효과지만, 자세히 들여다보면 방향성·강도·성능이라는 세 축의 트레이드오프가 있다는 것을 알 수 있었다.
구현하면서 신경 써야 했던 부분
1. Ghost Frames의 ghostDelta는 속도와 연동
고정값으로 두면 빠르게 돌 때는 너무 좁고 느리게 돌 때는 어색하게 넓다. 속도(angular velocity)에 비례시켜야 자연스럽다.
2. BlurMaskFilter는 하드웨어 가속과 충돌 가능성
특정 환경에서는 소프트웨어 레이어로 폴백되어 성능이 크게 떨어진다. Production에서는 실기기 테스트가 꼭 필요하다.
3. RenderEffect의 radiusY = 0 트릭
모든 방향 블러로 두면 그냥 흐릿하게만 보인다. 한 축만 블러를 주면 그 방향의 모션 블러 느낌이 살짝 살아난다.
4. API 31 분기 처리
RenderEffect는 API 31+ 전용이므로 Build.VERSION.SDK_INT 체크가 필수이다. 이전 버전에서는 Ghost Frames로 폴백하는 식의 패턴이 좋아 보인다.
마무리
이번 글에서는 Jetpack Compose에서 스피닝 휠에 모션 블러를 적용하는 세 가지 기법(Ghost Frames, BlurMaskFilter, RenderEffect)을 직접 구현하고 비교해 보았다.
처음에는 "모션 블러 = RenderEffect 한 줄"이라고 생각했었는데, 막상 만들어보니 진짜 회전 방향의 잔상을 표현하는 데에는 Ghost Frames가 여전히 가장 강력한 도구라는 점이 매우 인상 깊었다. 옛날 게임에서 캐릭터 잔상을 표현하던 방식이 지금도 유효하다는 게 흥미로운 부분인 것 같다.
다음에는 이 모션 블러를 응용해서 빠르게 움직이는 캐릭터나 파티클 효과에도 같은 잔상 기법을 적용해보는 것을 한번 정리해 볼 예정이다.
필자도 아직 Compose의 그래픽 API에 대해서는 공부 중이기 때문에 작성한 개념이나 설명에 오류가 있을 가능성이 있다는 점을 감안하고 읽어주었으면 좋겠다.
해당 게시글에 사용한 예제는 다음 Github에 올려두었다.
https://github.com/HeeGyeong/ComposeSample
GitHub - HeeGyeong/ComposeSample: This project provides various examples needed to actually use Jetpack Compose.
This project provides various examples needed to actually use Jetpack Compose. - HeeGyeong/ComposeSample
github.com
'Android > Jetpack Compose' 카테고리의 다른 글
| [Compose] iOS의 Large Content Viewer를 안드로이드에 - 접근성을 고려한 아이콘 프리뷰 구현 (0) | 2026.06.21 |
|---|---|
| [Compose] 스티커 캔버스를 만들어보자 - 복합 제스처, Spring 물리, 필오프(Peel-Off) 애니메이션 (0) | 2026.06.14 |
| [Compose] Canvas로 원형 다이얼(Dial) 컴포넌트를 직접 구현해보자 (1) | 2026.06.07 |
| [Compose] Shared Element Transitions로 화면 간 요소를 부드럽게 전환해보자 (0) | 2026.06.03 |
| [Compose] Material 3의 SwipeToDismissBox를 구현해보자 (0) | 2026.05.25 |