요새 Jetpack 라이브러리들을 이것저것 사용해보면서 혼자서 데모 프로젝트로 연습을 하고 있는데, 공부를 하면 할수록 굳이(?) CSS가 아니어도 Android 기술로도 충분히 인터랙티브한 UI를 만들 수 있을 것 같았다.
시간 없어서 개괄설명으로 바로 들어가자면, 이 글에서는 Jetpack Compose의 Canvas API, BlendMode, CompositingStrategy를 공부하고 연습한 과정을 공유하겠습니다.

애니메이션에 대해서 잘 모르다보니 Marquee를 처음 들었을 때 익숙하지 않아서 어원을 찾아봤다.
과거 영화관의 화려한 캐노피(간판)을 marquee라고 불렀는데 번쩍거리고 시각적인 효과를 주는 요소로 점차 사용하게 되었다고 한다.
CNN 뉴스나 주식 시세판이나 공항 안내판에도 이런 효과를 심심치 않게 볼 수 있는데 따로 용어가 있다는 것이 새삼 신기했다.
Compose 1.2.0에서 기본적으로 제공하는 Modifier.basicMarquee가 있지만 커스텀할 수 있는 여지가 없어서 LazyRow, scrollBy 위주로 구현했다.
초기에는 문제가 많은 코드를 작성했었는데 무한 스크롤을 구현할 때 리스트 끝에서 시작으로 인덱스가 초기화될 때 화면에서 툭 끊기는 현상이 있었다.
animateScrollToItem(0)으로 억지로라도 자연스럽게 만들어보려고 했지만 스크롤에 경계가 있다는 것은 여전히 해결되지 않았었다.
LazyRow {
items(indices.size) { index ->
Card(indices[index])
}
}
LaunchedEffect(Unit) {
while (true) {
if (listState.firstVisibleItemIndex == indices.size - 1) {
listState.scrollToItem(0)
}
listState.animateScrollBy(1f)
delay(16)
}
}
진짜 무한 스크롤을 구현하기 위해 Int.MAX_VALUE를 활용한 트릭을 사용했다.
Int 32비트 signed integer의 최댓값이고 만약 사용자가 스크롤을 역방향으로 조작할 수 있다면 음수 인덱스로 가는 것을 방지하기 위해서 양방향으로 반반씩 버퍼를 확보하도록 했습니다.
그래서 1초 = 1000ms, 16ms 프레임이라고 한다면 스크롤 속도는 2.5 * 1000 / 16 = 156.25이고 아이템 하나가 200픽셀이라면 아이템 통과 속도는 아이템당 1.28초이다. 그래서 100년까지는 아니더라도 몇십년을 켜놓고 있어야 문제가 생기므로 트릭이자 일종의 꼼수를 만들어냈다.
그리고 16ms delay는 보통 게임에서 주사율 이야기할 때 60fps를 말하는 것이다.
scrollBy는 내부적으로 안드로이드 Choreographer에 애니메이션 프레임을 지속적으로 요청해서 스크롤 업데이트 작업을 수행하게 되는데, Choreographer 내부적으로도 VSYNC(Vertical Sync)라고 해서 디바이스 디스플레이가 한 프레임을 다 그려주고 다음 프레임을 시작할 준비가 되었다는 뜻으로 신호를 보내주는 작업이 있다.
그래서 delay가 없다면 Choreogrpaher가 처리할 수 없을 정도로 요청이 많이 들어와서 프레임 드랍 현상이 일어나고 배터리도 소모되는 것이다.
해당 16이라는 기준은 Recomposition 횟수를 가시적으로 보기 위해서 inspector를 켜봤다면 들어봤을 수도 있다.
@Composable
fun Banner(
indices: ImmutableList,
onNavigateToBanner: (() -> Unit)? = null
) {
if (indices.isEmpty()) return
val lazyListState = rememberLazyListState()
LaunchedEffect(indices) {
if (indices.isEmpty()) return@LaunchedEffect
// 핵심: Int.MAX_VALUE의 중간 지점에서 시작
val initialIndex = Int.MAX_VALUE / 2
// 데이터 크기로 나눈 나머지를 빼서 정확한 시작점 계산
lazyListState.scrollToItem(initialIndex - initialIndex % indices.size)
while (true) {
lazyListState.scrollBy(2.5f)
delay(16L)
}
}
LazyRow(
state = lazyListState,
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
userScrollEnabled = false
) {
items(Int.MAX_VALUE) { index ->
val actualIndex = index % indices.size
Card(index = indices[actualIndex])
}
}
}

카드의 세로가 가로보다 짧기 때문에 원형 그래디언트를 사용하면 시각적으로 어색했다.
처음에는 Brush.radialGradient로 원형 그래디언트를 만들었는데 카드 크기가 동적으로 변할 때 그래디언트가 잘리고, 타원형 효과를 낼 수 없었고, gradient의 중심도 정확히 제어하기 어려웠다.
Box(
modifier = Modifier
.background(
brush = Brush.radialGradient(
colors = listOf(
Color.Red.copy(alpha = 0.3f),
Color.Transparent
),
radius = 100f
)
)
)
DrawScope의 extension function으로 커스텀 타원형 그래디언트를 만들었다.
size.width * 0.6f처럼 비율로 계산하면 어떤 카드 크기에도 유연하게 대응할 수 있고, 4단계 그래디언트로 중간 색상을 2개 추가해서 부드러운 전환을 만들었다. 그리고 color.alpha * 0.7f로 원본 알파값을 유지하면서 점진적으로 감소시켰다.
private fun DrawScope.drawEllipticalGradient(
color: Color,
size: Size
) {
val center = Offset(size.width / 2f, size.height / 2f)
val radiusX = size.width * 0.6f // 가로 60%
val radiusY = size.height * 0.8f // 세로 80%
val gradient = Brush.radialGradient(
colors = listOf(
color,
color.copy(alpha = color.alpha * 0.7f),
color.copy(alpha = color.alpha * 0.4f),
Color.Transparent
),
center = center,
radius = radiusX
)
drawRect(brush = gradient, size = size)
}
사용할 때는 drawWithContent modifier 안에서 먼저 그래디언트를 그리고 그 위에 텍스트 콘텐츠를 그리면 된다.
@Composable
fun Card(
index: Index,
modifier: Modifier = Modifier
) {
val gradientColor = if (index.isPositive) {
Color(0xFFFF5C5C).copy(alpha = 0.2f)
} else {
Color(0xFF5C7CFA).copy(alpha = 0.2f)
}
Column(
modifier = modifier
.wrapContentSize()
.drawWithContent {
drawEllipticalGradient(color = gradientColor, size = size)
drawContent()
}
.padding(12.dp)
) {
Text(text = index.name)
Text(text = index.value)
Text(text = index.changePercent)
}
}
처음에는 단순히 투명한 gradient를 오버레이하는 방식으로 구현했었다. 그런데 텍스트까지 가려져서 읽기 어려워졌고, 배경의 fade만 적용하고 싶은데 전체가 어두워지는 문제가 있었다.
Box {
Canvas(modifier = Modifier.fillMaxSize()) {
drawEllipticalGradient(...)
}
Text(...)
Box(
modifier = Modifier
.fillMaxSize()
.background(
brush = Brush.verticalGradient(
colors = listOf(Color.Transparent, Color.Black)
)
)
)
}
BlendMode.DstIn은 Porter-Duff 블렌딩 모드 중 하나다. 간단하게 말하면 이미 그려진 내용(Destination)을 유지하되, 새로 그리는 내용(Source)의 알파값으로 마스킹하는 것이다.
우리 케이스에서는 Dst가 그래디언트 배경이고 Src가 fade gradient인데, DstIn을 사용하면 배경만 선택적으로 fade 처리할 수 있다.
Column(
modifier = modifier
.wrapContentSize()
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithContent {
// 1단계: 그래디언트 배경
drawEllipticalGradient(color = gradientColor, size = size)
// 2단계: Fade 효과 (배경에만!)
val fadeHeight = size.height * 0.3f
val fadeWidth = size.width * 0.2f
// 상단 페이드
drawRect(
brush = Brush.verticalGradient(
colors = listOf(Color.Transparent, Color.Black),
startY = 0f,
endY = fadeHeight
),
size = Size(size.width, fadeHeight),
blendMode = BlendMode.DstIn
)
// 하단 페이드
drawRect(
brush = Brush.verticalGradient(
colors = listOf(Color.Black, Color.Transparent),
startY = size.height - fadeHeight,
endY = size.height
),
topLeft = Offset(0f, size.height - fadeHeight),
size = Size(size.width, fadeHeight),
blendMode = BlendMode.DstIn
)
// 좌측 페이드
drawRect(
brush = Brush.horizontalGradient(
colors = listOf(Color.Transparent, Color.Black),
startX = 0f,
endX = fadeWidth
),
size = Size(fadeWidth, size.height),
blendMode = BlendMode.DstIn
)
// 우측 페이드
drawRect(
brush = Brush.horizontalGradient(
colors = listOf(Color.Black, Color.Transparent),
startX = size.width - fadeWidth,
endX = size.width
),
topLeft = Offset(size.width - fadeWidth, 0f),
size = Size(fadeWidth, size.height),
blendMode = BlendMode.DstIn
)
// 3단계: 텍스트 콘텐츠 (fade 영향 안 받음!)
drawContent()
}
.padding(12.dp)
) {
Text(text = index.name)
// ...
}
처음에는 상하만 페이드를 적용했는데 좌우도 추가했다. 무한 스크롤에서 카드가 화면 밖으로 나갈 때 부드럽게 사라지는 효과가 필요했기 때문이다.
초기 구현을 실제 디바이스에서 테스트하니 성능 문제가 있었다.
Android Studio의 Profiler로 분석해보니 GPU Rendering의 Execute가 12ms나 걸리고 있었다.
원인은 BlendMode를 사용할 때 Compose가 기본적으로 각 draw 명령을 즉시 GPU에 전송하는데, 우리는 총 6번의 GPU 명령(그래디언트 + 4방향 fade + 콘텐츠)을 순차적으로 실행해서 오버헤드가 발생한 것이다.
CompositingStrategy.Offscreen을 사용하면 GPU 메모리에 임시 오프스크린 버퍼를 생성하고, 모든 draw 명령을 버퍼에 먼저 수행한 다음 완성된 결과를 한 번에 메인 버퍼로 복사한다.
Column(
modifier = modifier
.wrapContentSize()
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithContent {
// 동일한 draw 로직
}
)
이렇게 변경하니 GPU Rendering의 Draw가 8ms에서 5ms로, Execute가 12ms에서 6ms로 줄었다.
물론 trade-off가 있다. 추가 버퍼로 인해 메모리 사용이 약간 증가하지만 우리 케이스는 카드 하나가 작아서(약 200x80 dp) 메모리 오버헤드가 미미했다. 그래서 성능 개선이 더 중요했다.
CompositingStrategy에는 세 가지 옵션이 있다:
Auto: Compose가 자동으로 결정 (기본값)ModulateAlpha: 알파 변조만 필요할 때Offscreen: BlendMode 사용 시 권장build.gradle.kts에 Compose 메트릭 설정을 추가해서 성능을 분석했다.
kotlin {
compilerOptions {
freeCompilerArgs.addAll(
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=${project.layout.buildDirectory.get()}/compose_metrics",
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=${project.layout.buildDirectory.get()}/compose_metrics"
)
}
}
빌드 후 생성되는 메트릭 파일을 보면 isSkippable: true와 isStable: true를 확인할 수 있다.
이건 ImmutableList를 사용했기 때문인데, kotlinx.collections.immutable 라이브러리를 활용하면 Compose가 변경 추적을 효율적으로 수행한다.
// 매번 recomposition
fun Banner(indices: List)
// 내용이 같으면 skip
fun Banner(indices: ImmutableList)
그리고 remember로 불필요한 재계산도 방지했다.
val indices = remember(uiState.indices) {
uiState.indices.map { it.toIndex() }.toImmutableList()
}
빠르게 스크롤할 때 카드가 잠깐 사라졌다가 나타나고 Logcat에 "Skipped XX frames!" 경고가 떴다.
원인은 LazyRow의 아이템이 재사용될 때마다 drawWithContent 내부에서 새로운 Brush 객체를 생성해서 GC 압박이 생긴 것이었다.
해결은 간단했다. remember로 gradientColor를 캐싱했다.
@Composable
fun Card(index: Index) {
val gradientColor = remember(index.isPositive) {
if (index.isPositive) {
Color(0xFFFF5C5C).copy(alpha = 0.2f)
} else {
Color(0xFF5C7CFA).copy(alpha = 0.2f)
}
}
// ...
}
라이트 모드에서는 자연스러운 페이드가 나왔는데 다크모드에서는 페이드 영역이 회색으로 보였다.
원인은 BlendMode.DstIn에서 사용하는 Color.Black과 Color.Transparent가 다크모드 배경(#000000)과 섞이면서 의도하지 않은 회색이 나타난 것이었다.
해결은 alpha만 조절하는 것이었다.
// 문제 코드
drawRect(
brush = Brush.verticalGradient(
colors = listOf(Color.Transparent, Color.Black)
),
blendMode = BlendMode.DstIn
)
// 해결
drawRect(
brush = Brush.verticalGradient(
colors = listOf(
Color(0xFF000000).copy(alpha = 0f),
Color(0xFF000000).copy(alpha = 1f)
)
),
blendMode = BlendMode.DstIn
)
더 나은 방법은 Color.White를 쓰는 것이다. BlendMode.DstIn은 알파 채널만 사용하므로 어떤 색을 써도 무방하다.
drawRect(
brush = Brush.verticalGradient(
colors = listOf(Color.Transparent, Color.White),
startY = 0f,
endY = fadeHeight
),
blendMode = BlendMode.DstIn
)
배경에서 앱이 실행될 때도 CPU 사용률이 높았다.
원인은 LaunchedEffect의 while (true) 루프가 백그라운드에서도 계속 실행되고 있었기 때문이다.
해결은 Lifecycle을 관찰해서 화면이 보일 때만 애니메이션을 돌리는 것이었다.
@Composable
fun Banner(
indices: ImmutableList,
onNavigateToBanner: (() -> Unit)? = null
) {
val lifecycleOwner = LocalLifecycleOwner.current
val isActive = remember { mutableStateOf(true) }
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
isActive.value = event == Lifecycle.Event.ON_RESUME
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
LaunchedEffect(indices, isActive.value) {
if (!isActive.value) return@LaunchedEffect
val initialIndex = Int.MAX_VALUE / 2
lazyListState.scrollToItem(initialIndex - initialIndex % indices.size)
while (isActive.value) {
lazyListState.scrollBy(2.5f)
delay(16L)
}
}
// ...
}
5년 전에 대학교에 입학할 때 인터랙티브 웹개발자라는 유튜브를 본 적이 있었다. 워낙 실력이 뛰어나신 분이라 구글에 스카웃 제의가 들어와서 미국으로 건너가셨지만... 그걸 보면서 앱이 아닌 웹으로 저런 것이 가능하구나 하면서 나도 만들어보고 싶다는 생각을 하며 동경했었다.
이번에 canvas로 이러쿵 저러쿵해보니 옛날 생각이 많이 났다. 결론은 재밌다.