FastFive 출입 카드 UI 따라 만들기 — 버벅임과의 싸움

woo·2026년 6월 9일

만들게 된 계기

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


"이거 어떻게 만들지?"
그 카드 UI가 신기해서 한 번 따라 만들어보고 싶었다.
(위 사진에서 얼굴 표정 이미지들이 핸드폰 기울기에 따라 움직임!)

그래서 기기를 기울이면 카드 위에 공이 굴러다니는 물리 시뮬레이션을 Jetpack Compose로 구현해봤는데 — 만들고 보니 애니메이션이 심하게 버벅거렸다. 뭐가 문제인지도 몰랐고, 어떻게 고쳐야 할지도 몰랐다.

이 글은 그 버벅임을 잡아가는 과정을 기록한 글이다.

기술 스택

분류기술
LanguageKotlin
UIJetpack Compose
ArchitectureMVVM
ConcurrencyKotlin Coroutines (LaunchedEffect, withFrameNanos)
SensorAndroid SensorManager (TYPE_ACCELEROMETER)

초기 구조의 문제점

센서 이벤트 → 상태 업데이트 → 리컴포지션 → 물리 계산 → 렌더링

처음엔 뭐가 문제인지 잘 몰랐다.
성능 저하 원인을 파악하기 위해 Claude Code를 활용해 병목 구간 후보를 분석했고,
제안된 개선안을 직접 검증하면서 Compose 렌더링 구조와 물리 계산 흐름을 이해했다.

분석 결과 문제는 크게 세 가지였다.

문제설명
타이밍 불규칙센서 데이터가 불규칙하게 들어와 프레임 간격이 일정하지 않음
UI 스레드 점유렌더링 중 물리 계산까지 수행하여 메인 스레드 과부하
Compose 원칙 위반Composable 내에서 직접 상태 변경 시도

한 문장으로 요약하면: "화면 그리는 곳에서 계산까지 하면 안 된다."

개선된 구조

핵심 변경: 역할 분리

[물리 계산 로직] — ViewModel에 위치
[프레임 동기화 루프] — Compose의 withFrameNanos를 통해 화면 갱신 주기에 맞춰 updatePhysics 호출
[센서] — 별도 스레드에서 기울기 값만 수집
[Composable] — 공 위치만 렌더링

개선 1 — 게임 루프를 화면 주기에 맞추기

기존의 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()
        }
    }
}

개선 2 — 센서 값을 안전하게 넘기기

센서 콜백은 별도 스레드에서 실행된다.
@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)
}

개선 3 — Composable은 렌더링만 담당

@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(...)
        }
    }
}

개선 4 — 물리감 튜닝

상수이전이후이유
SENSITIVITY45f60f기울임 반응성 향상
DAMPING0.995f0.992f공이 빠르게 멈추도록
SUB_STEPS68충돌 계산 정밀도 향상
WALL_BOUNCE-0.85f-0.72f벽 충돌 시 튀어오름 감소

결과 측정

개선 전 vs 후

동영상 첨부가 안되어 gif로 변환하여 파일을 첨부하려 했는데 막상 변환하니 둘 차이가 눈에 띄게 크지 않아 노션 링크로 대체합니당..!

노션으로 이동하기

adb shell dumpsys gfxinfo 명령어로 30초씩 측정했다.

프레임 시간 분포

구간이전이후개선
평균 수준 (50th)32ms16ms50% 감소
상위 10% (90th)150ms17ms89% 감소
상위 5% (95th)400ms17ms96% 감소
상위 1% (99th)900ms32ms96% 감소

품질 지표

지표이전이후
총 프레임 (30초)500개1,213개
끊기는 프레임 비율82.60%2.97%
메인 스레드 지연 횟수284회34회

결론 및 회고

"화면 그리는 곳에서 계산까지 하면 안 된다."

당연한 말 같지만, 직접 버벅임을 만들고 측정하고 고쳐보기 전까지는 체감이 안 됐다.

이번 경험을 통해 "AI가 제안한 내용을 그대로 적용하는 것"과 "제안의 근거를 이해하고 검증하는 것"은 다르다는 걸 배웠다.
처음에는 병목 원인을 전혀 알지 못했지만, 측정 결과를 기반으로 개선안을 하나씩 검증하면서 Compose의 렌더링 구조와 상태 관리 방식에 대해 이전보다 깊게 이해할 수 있었다.

0개의 댓글