회사를 옮기고 나서 사무실을 FASTFIVE로 사용하고 있는데, 건물에 들어가려면 앱을 켜고 등록된 사용자에 한해 카드를 꾹 누르면 FCM 기능으로 출입문이 열리는 구조다.

"이거 어떻게 만들지?"
그 카드 UI가 신기해서 한 번 따라 만들어보고 싶었다.
(위 사진에서 얼굴 표정 이미지들이 핸드폰 기울기에 따라 움직임!)
그래서 기기를 기울이면 카드 위에 공이 굴러다니는 물리 시뮬레이션을 Jetpack Compose로 구현해봤는데 — 만들고 보니 애니메이션이 심하게 버벅거렸다. 뭐가 문제인지도 몰랐고, 어떻게 고쳐야 할지도 몰랐다.
이 글은 그 버벅임을 잡아가는 과정을 기록한 글이다.
| 분류 | 기술 |
|---|---|
| Language | Kotlin |
| UI | Jetpack Compose |
| Architecture | MVVM |
| Concurrency | Kotlin Coroutines (LaunchedEffect, withFrameNanos) |
| Sensor | Android SensorManager (TYPE_ACCELEROMETER) |
센서 이벤트 → 상태 업데이트 → 리컴포지션 → 물리 계산 → 렌더링
처음엔 뭐가 문제인지 잘 몰랐다.
성능 저하 원인을 파악하기 위해 Claude Code를 활용해 병목 구간 후보를 분석했고,
제안된 개선안을 직접 검증하면서 Compose 렌더링 구조와 물리 계산 흐름을 이해했다.
분석 결과 문제는 크게 세 가지였다.
| 문제 | 설명 |
|---|---|
| 타이밍 불규칙 | 센서 데이터가 불규칙하게 들어와 프레임 간격이 일정하지 않음 |
| UI 스레드 점유 | 렌더링 중 물리 계산까지 수행하여 메인 스레드 과부하 |
| Compose 원칙 위반 | Composable 내에서 직접 상태 변경 시도 |
한 문장으로 요약하면: "화면 그리는 곳에서 계산까지 하면 안 된다."
[물리 계산 로직] — ViewModel에 위치
[프레임 동기화 루프] — Compose의 withFrameNanos를 통해 화면 갱신 주기에 맞춰 updatePhysics 호출
[센서] — 별도 스레드에서 기울기 값만 수집
[Composable] — 공 위치만 렌더링
기존의 delay(16L) 방식은 화면 갱신 타이밍과 어긋날 수 있다.
withFrameNanos는 Compose가 다음 프레임을 그리기 직전에 정확히 맞춰 콜백을 실행해주기 때문에 이것으로 교체했다.
물리 계산 로직 자체는 ViewModel에 있고, 루프 트리거는 Composable 측 LaunchedEffect에서 담당한다.
// 이전 — 타이머 기준 (화면 갱신 주기와 어긋남)
viewModelScope.launch(Dispatchers.Main) {
while (isActive) {
updatePhysics()
delay(16L)
}
}
// 개선 — 화면 갱신 직전에 정확히 실행
LaunchedEffect(Unit) {
while (true) {
withFrameNanos {
if (viewModel.containerWidth > 0f) viewModel.updatePhysics()
}
}
}
센서 콜백은 별도 스레드에서 실행된다.
@Volatile을 붙여 메인 스레드에서 읽을 때 최신 값이 보장되도록 했다.
@Volatile private var tiltX = 0f
@Volatile private var tiltY = 0f
override fun onSensorChanged(event: SensorEvent) {
// 저주파 필터로 센서 노이즈 제거
tiltX = tiltX + SENSOR_ALPHA * (event.values[0] - tiltX)
tiltY = tiltY + SENSOR_ALPHA * (event.values[1] - tiltY)
}
@Composable
fun TiltBallScreen(viewModel: PhysicsViewModel) {
Canvas(
modifier = Modifier
.size(width = 300.dp, height = 400.dp)
.onSizeChanged { size ->
viewModel.setBounds(size.width.toFloat(), size.height.toFloat())
}
) {
viewModel.balls.forEach { ball ->
drawImage(...)
}
}
}
| 상수 | 이전 | 이후 | 이유 |
|---|---|---|---|
SENSITIVITY | 45f | 60f | 기울임 반응성 향상 |
DAMPING | 0.995f | 0.992f | 공이 빠르게 멈추도록 |
SUB_STEPS | 6 | 8 | 충돌 계산 정밀도 향상 |
WALL_BOUNCE | -0.85f | -0.72f | 벽 충돌 시 튀어오름 감소 |
동영상 첨부가 안되어 gif로 변환하여 파일을 첨부하려 했는데 막상 변환하니 둘 차이가 눈에 띄게 크지 않아 노션 링크로 대체합니당..!
adb shell dumpsys gfxinfo 명령어로 30초씩 측정했다.
| 구간 | 이전 | 이후 | 개선 |
|---|---|---|---|
| 평균 수준 (50th) | 32ms | 16ms | 50% 감소 |
| 상위 10% (90th) | 150ms | 17ms | 89% 감소 |
| 상위 5% (95th) | 400ms | 17ms | 96% 감소 |
| 상위 1% (99th) | 900ms | 32ms | 96% 감소 |
| 지표 | 이전 | 이후 |
|---|---|---|
| 총 프레임 (30초) | 500개 | 1,213개 |
| 끊기는 프레임 비율 | 82.60% | 2.97% |
| 메인 스레드 지연 횟수 | 284회 | 34회 |
"화면 그리는 곳에서 계산까지 하면 안 된다."
당연한 말 같지만, 직접 버벅임을 만들고 측정하고 고쳐보기 전까지는 체감이 안 됐다.
이번 경험을 통해 "AI가 제안한 내용을 그대로 적용하는 것"과 "제안의 근거를 이해하고 검증하는 것"은 다르다는 걸 배웠다.
처음에는 병목 원인을 전혀 알지 못했지만, 측정 결과를 기반으로 개선안을 하나씩 검증하면서 Compose의 렌더링 구조와 상태 관리 방식에 대해 이전보다 깊게 이해할 수 있었다.