본문 바로가기
Android

[Android] 프레임과 주사율(Hz), 그리고 Compose 캘린더 화면의 느린 렌더링 개선하기

by 태크민 2026. 5. 30.

최근 회사 프로젝트에서 Firebase Performance 지표를 살펴보다가, 날짜를 선택하는 캘린더 화면의 느린 렌더링(Slow rendering) 비율이 앱 평균보다 2배 가까이 높다는 걸 발견했습니다.

그런데 막상 원인을 찾으려고 보니, 프레임이 정확히 뭔지, 120Hz라는 게 무슨 의미인지, 그리고 "끊긴다"는 게 어떤 상태를 말하는 건지 스스로 명확하게 설명하지 못하겠더라고요.

그래서 이번 포스팅에서는 먼저 주사율(Hz)과 프레임 개념을 정리하고, 실제로 캘린더 화면의 느린 렌더링을 어떻게 개선했는지까지 살펴보겠습니다.

 


주사율(Hz)이란?

주사율은 화면이 1초에 몇 번 새로 고쳐지는지를 의미합니다. 120Hz라면 화면이 1초에 120번 새로 고쳐지는 것이죠.

이걸 시간 간격으로 바꿔보면 이해가 더 쉽습니다. 1초는 1000ms이기 때문에, 1초에 120번 새로 고쳐지려면 1000 ÷ 120 = 약 8.3ms마다 한 번씩 화면이 갱신되어야 합니다.

  • 60Hz: 약 16.7ms마다 한 번
  • 90Hz: 약 11.1ms마다 한 번
  • 120Hz: 약 8.3ms마다 한 번

여기서 중요한 점은, 이 갱신 주기가 앱과 상관없이 일정하게 계속 돌아간다는 것입니다. 앱이 새 화면을 그렸든 안 그렸든, 디스플레이는 정해진 박자에 맞춰 화면을 다시 표시합니다. 안드로이드에서는 이 박자 신호를 VSync라고 부릅니다.

 

프레임이란?

프레임은 앱이 그린 화면 한 장입니다. 처음에 저는 주사율과 프레임을 같은 개념으로 생각했는데, 둘은 엄연히 다릅니다.

주사율은 디스플레이가 화면을 갱신하는 박자이고, 프레임은 앱이 그 박자에 맞춰 넘겨주는 그림입니다. 앱은 박자 한 번에 최대 한 장의 프레임만 넘길 수 있기 때문에, 120Hz 기기라면 앱이 아무리 빨라도 1초에 최대 120프레임까지만 보여줄 수 있습니다.

저는 이걸 버스에 비유해서 이해했습니다. 화면 갱신은 8.3ms마다 정확히 도착하는 버스이고, 프레임은 그 버스에 타는 승객입니다. 앱이 그림을 다 그리면 다음에 오는 버스에 태워 보내는 거죠. 만약 그림이 늦게 완성되면, 그동안 지나간 버스는 빈 차로 출발하게 됩니다.

또 한 가지 알아두면 좋은 점은, 화면에 변화가 없으면 앱은 새 프레임을 그리지 않는다는 것입니다. 가만히 멈춰 있는 화면에서는 프레임이 거의 만들어지지 않습니다. 이 부분은 뒤에서 캘린더 문제를 이해할 때 중요하게 쓰입니다.

 

프레임이 박자를 놓치면 어떻게 될까?

결론부터 말하자면, 프레임은 나눠서 그려지지 않습니다. 완성된 한 장이 통째로 넘어가야 하고, 다 그려지기 전에는 일부만 먼저 보여줄 수 없습니다.

예를 들어 120Hz 기기에서 1번 프레임을 그리는 데 10ms, 2번 프레임을 그리는 데 8ms가 걸린다고 가정해보겠습니다.

박자:      0ms       8.3ms      16.6ms     24.9ms
           |----------|----------|----------|
1번 프레임: [====10ms====]
                      ↑ 아직 미완성 → 이전 화면이 한 번 더 보임
                                 ↑ 1번 프레임 표시
2번 프레임:                      [===8ms===]
                                            ↑ 2번 프레임 표시

1번 프레임은 8.3ms 안에 완성되지 못했기 때문에, 첫 번째 박자에는 이전 화면이 그대로 한 번 더 표시됩니다. 그리고 1번 프레임은 다음 박자인 16.6ms에 표시되죠. 즉, 1번 프레임 하나가 박자 2개를 차지하게 되고, 원래 두 번째 박자에 나왔어야 할 2번 프레임은 세 번째 박자로 밀리게 됩니다.

참고로 1번 프레임이 10ms에 완성된 뒤 16.6ms까지 남는 시간은 그냥 대기 시간입니다. 사용자 입장에서는 그림을 그린 시간과 기다린 시간을 구분할 수 없고, 단지 "한 박자 늦게 나왔다"는 결과만 느끼게 됩니다.

같은 원리로, 100ms가 걸리는 프레임은 120Hz 기준으로 박자 약 12개를 차지하게 됩니다.

 

버벅임과 늦게 뜨는 것은 다르다

오래 걸리는 프레임이 있다고 해서 사용자가 항상 끊김을 느끼는 건 아닙니다. 그 순간 화면이 움직이고 있었는지에 따라 체감이 완전히 달라집니다.

스크롤 중일 때

스크롤할 때 앱은 프레임마다 현재 손가락 위치를 확인하고, 그 위치에 맞춰 화면을 그립니다. 손가락이 1ms에 1px씩 움직이고, 16ms 시점의 프레임이 50ms 걸린다고 해볼게요.

시간:    0ms  8ms  16ms  24ms  32ms  40ms  48ms  56ms  64ms  72ms
손가락:   0    8    16    24    32    40    48    56    64    72
화면:     0    8    16    16    16    16    16    16    16    72

무거운 프레임을 그리는 동안에도 손가락은 계속 움직이지만, 화면은 이전 위치(16px)에 멈춰 있습니다. 그리고 그 프레임이 끝나고 나면 다음 프레임은 그 시점의 손가락 위치(72px)에 맞춰 그려지기 때문에, 화면이 한 번에 툭 점프하게 됩니다. 이게 우리가 흔히 말하는 버벅임입니다.

이때 손가락 입력이 사라지는 건 아닙니다. 입력은 쌓여 있다가 프레임이 끝난 뒤에 반영되기 때문에, 정확히는 "스크롤이 안 된다"가 아니라 "반영이 늦어진다"에 가깝습니다. 스크롤할 때 멈췄다 움직였다를 반복하는 현상은, 새 항목이 화면에 들어올 때마다 이런 무거운 프레임이 반복해서 생기는 경우라고 보시면 됩니다.

화면에 처음 들어갈 때

반면 화면에 처음 진입할 때는 이전부터 이어지던 움직임이 없습니다. 그래서 첫 프레임이 오래 걸리더라도 끊겨 보이지는 않고, 그만큼 화면이 늦게 뜨는 것으로 느껴집니다. 첫 프레임이 9ms든 12ms든 사용자는 전혀 알아채지 못하고, 100ms 정도가 되어야 "살짝 늦게 열리네" 정도로 느끼는 수준입니다.

 

Firebase는 느린 렌더링을 어떻게 판단할까?

Firebase Performance Monitoring은 화면(Activity)에 한 번 방문할 때마다 그 동안 그려진 프레임을 기록합니다. 이 한 번의 방문을 화면 인스턴스라고 부르는데, Firebase는 프레임 하나하나가 아니라 이 화면 인스턴스 단위로 느렸는지를 판단합니다.

먼저 프레임 하나를 두고 보는 기준은 두 가지입니다.

  • 느린 프레임: 그리는 데 16ms를 초과한 프레임
  • 정지 프레임: 그리는 데 700ms를 초과한 프레임

그리고 한 번의 방문에서 이 프레임들이 차지하는 비율을 보고, 그 방문이 기준에 걸렸는지를 판정합니다.

  • 느린 렌더링: 느린 프레임이 전체 프레임의 50%를 초과한 방문
  • 정지 프레임: 정지 프레임이 전체 프레임의 0.1%를 초과한 방문

예를 들어 화면에 한 번 들어가서 프레임이 1,000개 그려졌다고 해보겠습니다. 이 중 501개가 16ms를 넘었다면 50%를 초과했으니 이 방문은 느린 렌더링으로 판정됩니다. 정확히 500개였다면 50%와 같기 때문에 해당하지 않죠. 정지 프레임도 마찬가지로, 700ms를 넘은 프레임이 2개(0.2%)라면 정지 프레임으로 판정되고 1개(0.1%)라면 해당하지 않습니다.

여기서 0.1%라는 기준은 사실상 '한 번이라도 멈췄는가'에 가깝습니다. 보통의 방문은 프레임이 1,000개도 안 되기 때문에, 700ms를 넘는 프레임이 하나만 있어도 0.1%를 넘게 되거든요. 정리하면 느린 렌더링은 방문 내내 프레임이 전반적으로 느렸는지를 보는 지표이고, 정지 프레임은 화면이 눈에 띄게 멈춘 순간이 있었는지를 보는 지표입니다.

그래서 Firebase 콘솔에 표시되는 비율도 이렇게 판정된 방문의 비율입니다. 어떤 화면의 느린 렌더링이 8%라면 전체 프레임의 8%가 느렸다는 뜻이 아니라, 그 화면에 방문한 전체 횟수 중 8%가 느린 렌더링 기준에 걸렸다는 뜻이죠.

여기서 주의할 점은 16ms가 주사율과 상관없이 고정된 기준이라는 것입니다. 16ms는 60Hz 화면의 한 박자를 기준으로 정해진 값이라, 120Hz 기기에서 12ms 걸린 프레임은 실제로는 박자를 놓쳤어도 Firebase에서는 느린 프레임으로 잡히지 않습니다.

반대로 앞에서 말한 "화면 진입 시 100ms짜리 첫 프레임"은 사용자가 거의 느끼지 못하는데도 느린 프레임으로 집계됩니다. 결국 Firebase가 말하는 느린 렌더링과 사용자가 실제로 느끼는 버벅임은 꼭 일치하지 않는다는 걸 알 수 있었습니다.

 


캘린더 화면은 왜 느린 렌더링으로 잡혔을까?

이제 실제 문제로 돌아와 보겠습니다. 기기에서 Perfetto로 시스템 트레이스를 떠서 캘린더 화면 진입 과정을 확인해보니, 화면에 들어가는 동안 그려진 프레임이 단 3개뿐이었습니다. 캘린더는 열리고 나면 가만히 멈춰 있는 화면이라, 그 이후로는 새 프레임이 거의 만들어지지 않았던 것이죠.

3개의 프레임은 다음과 같았습니다. (debug 빌드 기준)

  • 1번째 프레임 (125~174ms): 달력 전체를 처음 구성하고 측정하고 그리는 프레임
  • 2번째 프레임 (38~45ms): 날짜 칸들을 다시 측정하고 그리는 프레임
  • 3번째 프레임 (3~7ms): 가벼운 프레임

3개 중 2개가 16ms를 넘었으니 느린 프레임 비율은 67%입니다. 50%를 넘기 때문에, 사용자가 이 화면에 들어올 때마다 거의 매번 느린 렌더링으로 집계되고 있었습니다.

앞에서 본 1,000개짜리 예시였다면 무거운 프레임 2개는 고작 0.2%에 불과했을 겁니다. 그런데 프레임이 3개뿐인 방문에서는 같은 2개가 67%가 되어버리는 거죠. 판정이 방문 안의 비율로 이루어지다 보니, 프레임 수가 적은 화면일수록 앞쪽 프레임 몇 개에 결과가 크게 휘둘리게 됩니다.

물론 사용자가 오래 머물면서 스크롤을 하면 가벼운 프레임이 계속 쌓이면서 비율이 내려갑니다. 하지만 잠깐 보고 나가는 경우에는 앞쪽의 무거운 프레임 2개가 비율을 그대로 결정해버리죠. 실제로 지표를 머문 시간별로 나눠보니, 짧게 머문 방문일수록 느린 렌더링 비율이 확연히 높았습니다.

즉, 이 화면은 계속 버벅이는 화면이 아니라, 진입할 때 프레임 수 자체가 적은데 그중 앞쪽 프레임이 무거워서 비율상 느린 화면으로 잡히는 구조였습니다.

 

2번째 프레임은 왜 생겼을까?

그렇다면 날짜 칸을 다시 측정하는 2번째 프레임은 왜 생긴 걸까요?

캘린더의 날짜 칸은 텍스트 크기에 맞춰 정사각형 모양을 만드는 SquareTextView라는 컴포넌트를 사용하고 있었고, 기존 코드는 다음과 같았습니다.

@Composable
fun SquareTextView(...) {
    val density = LocalDensity.current

    var width by remember { mutableStateOf(Dp.Unspecified) }
    var height by remember { mutableStateOf(Dp.Unspecified) }
    val maxViewSize by remember(width, height) {
        mutableStateOf(max(width, height))
    }

    Box(
        modifier = modifier
            .size(maxViewSize),
    ) {
        Text(
            modifier = Modifier
                .align(Alignment.Center)
                .onSizeChanged {
                    width = with(density) { it.width.toDp() }
                    height = with(density) { it.height.toDp() }
                },
            ...
        )
    }
}

이 코드의 흐름을 순서대로 보면 이렇습니다.

  1. 첫 프레임에서는 아직 정사각형 크기를 모르는 상태로 화면을 그립니다.
  2. 텍스트 측정이 끝난 뒤에야 onSizeChanged가 호출되고, 텍스트의 너비와 높이를 state에 저장합니다.
  3. state가 바뀌었으니 Compose는 리컴포지션이 필요하다고 판단합니다.
  4. 다음 프레임에서 저장된 크기로 Box를 다시 측정하고 다시 그립니다.

정사각형 하나를 만들기 위해 프레임 2개를 쓰고 있었던 거죠. 캘린더의 날짜 칸 하나하나가 전부 이 컴포넌트였기 때문에, 2번째 프레임에서 수십 개의 날짜 칸을 통째로 다시 측정하게 됐고, 실제로 트레이스에서도 텍스트 120여 개를 다시 측정하는 구간이 확인됐습니다.

 

layout Modifier로 한 번에 측정하기

해결 방법은 생각보다 간단했습니다. Compose의 layout Modifier를 사용하면 자식을 측정하고, 그 결과를 가지고 내 크기를 정하는 작업을 같은 측정 단계 안에서 끝낼 수 있습니다.

@Composable
fun SquareTextView(...) {
    Text(
        modifier = modifier
            .layout { measurable, constraints ->
                // 텍스트 크기를 잰 뒤 긴 변 기준 정사각형으로 같은 측정 패스 안에서 배치
                val placeable = measurable.measure(constraints.copy(minWidth = 0, minHeight = 0))
                val side = maxOf(placeable.width, placeable.height)
                val width = constraints.constrainWidth(side)
                val height = constraints.constrainHeight(side)
                layout(width, height) {
                    placeable.place(
                        x = (width - placeable.width) / 2,
                        y = (height - placeable.height) / 2,
                    )
                }
            },
        ...
    )
}

텍스트를 측정하는 그 순간 바로 정사각형 크기를 결정하기 때문에, 더 이상 state에 크기를 저장할 필요가 없습니다. state가 바뀌지 않으니 리컴포지션도 일어나지 않고, 결과적으로 2번째 프레임이 사라지게 됩니다.

추가로 날짜 칸에 걸려 있던 IntrinsicSize.Min도 제거했습니다. IntrinsicSize는 실제 측정 전에 자식의 크기를 한 번 더 물어보는 과정이 들어가기 때문에, 날짜 칸처럼 개수가 많은 곳에서는 비용이 쌓이게 됩니다. 선택 범위 배경을 그리기 위해 IntrinsicSize가 필요했던 부분은 drawBehind로 칸의 왼쪽/오른쪽 절반을 직접 칠하는 방식으로 바꿨습니다.

 

개선 결과

처음에는 debug 빌드로만 비교했는데, 성능 비교는 실제 사용자 환경과 같은 release 빌드로 해야 정확하기 때문에 같은 기기, 같은 시나리오로 release 빌드에서 다시 측정했습니다.

  • 진입 시 프레임 수: 3개 → 2개
  • 2번째 프레임: 16~21ms → 사라짐
  • 진입 시 느린 프레임 비율: 67% → 50%
  • 스크롤 중 가장 긴 프레임: 35.5ms → 10.3ms

release 빌드에서 2번째 프레임은 16~21ms로, Firebase 기준인 16ms에 아슬아슬하게 걸쳐 있었습니다. 실제 사용자 기기는 대부분 테스트 기기보다 성능이 낮기 때문에, 거의 항상 느린 프레임으로 잡혔을 가능성이 높습니다. 이 프레임이 사라지면서 비율이 50%가 됐고, Firebase 기준은 50% "초과"이기 때문에 더 이상 느린 렌더링으로 집계되지 않습니다.

한 가지 의외였던 점은 1번째 프레임이었습니다. debug 빌드에서는 1번째 프레임도 30% 정도 줄어들었는데, release 빌드에서는 수정 전후 차이가 거의 없었습니다. debug 빌드는 R8 최적화가 꺼져 있고 Compose도 디버그 모드로 동작하기 때문에 수치가 과장되어 나올 수 있다는 걸 이번에 직접 확인할 수 있었습니다.

 

결론

이번 작업을 하면서 가장 크게 느낀 점은, 지표의 숫자만 보고 "이 화면이 버벅인다"고 판단하면 안 된다는 것이었습니다. Firebase의 느린 렌더링은 16ms라는 고정 기준과 방문 내 비율로 계산되기 때문에, 이번 캘린더처럼 프레임 수가 적은 정적인 화면은 앞쪽 프레임 몇 개만 무거워도 느린 화면으로 집계될 수 있습니다.

그리고 Compose에서 onSizeChanged로 크기를 받아 state에 저장하고 다시 그리는 패턴은, 생각보다 쉽게 프레임을 하나 더 만들어냅니다. 측정 결과를 바탕으로 크기를 정해야 하는 상황이라면 layout Modifier로 같은 측정 단계 안에서 처리하는 방법을 먼저 고려해보시면 좋을 것 같습니다.

 

참고자료

'Android' 카테고리의 다른 글

[Android] MQTT 통신  (1) 2023.09.11