|

안드로이드 커스텀 View 성능 최적화: onMeasure, onDraw, invalidate에서 자주 느려지는 지점

안드로이드 커스텀 View 성능 최적화를 설명하는 대표 이미지
onMeasure, onDraw, invalidate, requestLayout 경계를 구분하면 커스텀 View 병목을 더 빨리 찾을 수 있다

안드로이드 커스텀 View 성능 최적화는 결국 어디서 비용이 나는지 경계를 구분하는 일입니다. 커스텀 View가 버벅일 때 많은 경우 onDraw()만 의심하지만, 실제로는 onMeasure(), invalidate(), requestLayout() 호출 패턴까지 같이 봐야 병목이 보입니다.

이 글은 렌더링 이론을 길게 설명하는 글이 아닙니다. 대신 커스텀 View를 만들 때 실무에서 자주 하는 실수, 그리고 왜 그 실수가 프레임 드랍으로 이어지는지를 중심으로 정리하겠습니다.


안드로이드 커스텀 View 성능 최적화의 출발점: measure, layout, draw는 같은 비용이 아니다

커스텀 View를 디버깅할 때 가장 먼저 버려야 할 오해가 하나 있습니다. 화면 다시 그리기를 onDraw() 한 번 더 호출되는 정도로 생각하는 것입니다. 실제로는 안드로이드가 measure pass와 layout pass를 top-down으로 수행하고, 그 위에서 draw가 이어집니다.

  • 크기를 다시 계산하는 비용
  • 위치를 다시 잡는 비용
  • 실제 픽셀을 그리는 비용

예를 들어 색상만 바뀌었는데 requestLayout()까지 타면, 원래는 redraw만 해도 될 일을 측정부터 다시 시작하게 만듭니다. 반대로 크기는 자주 안 바뀌는데 onDraw()에서 매 프레임마다 좌표와 Path를 새로 만들면 draw 단계에서 프레임 예산을 다 써버릴 수 있습니다.

무엇이 바뀌었는지에 따라 다시 해야 할 작업 범위를 줄이는 것이 커스텀 View 최적화의 출발점입니다.


onMeasure

onMeasure()는 숫자 두 개를 정하는 메서드처럼 보이지만, 여기서 비용을 키우면 생각보다 타격이 큽니다. Android 공식 문서도 부모가 자식 측정을 한 번 이상 호출할 수 있다고 설명합니다. 즉, onMeasure()가 한 번만 돈다고 가정하면 안 됩니다.

  • onMeasure 안에서 텍스트 레이아웃을 매번 새로 계산
  • 비트맵 크기를 읽기 위해 decode 수행
  • 복잡한 컬렉션 순회로 최대 크기 계산
  • 크기와 무관한 비즈니스 로직까지 같이 실행
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    val labels = items.map { formatLabel(it) }
    val maxTextWidth = labels.maxOf { textPaint.measureText(it) }
    val desiredWidth = (paddingLeft + paddingRight + maxTextWidth).toInt()
    val desiredHeight = paddingTop + paddingBottom + barHeight * items.size

    setMeasuredDimension(
        resolveSize(desiredWidth, widthMeasureSpec),
        resolveSize(desiredHeight, heightMeasureSpec)
    )
}

이 코드가 항상 틀린 것은 아니지만, items가 자주 바뀌지 않는데도 측정 때마다 문자열 포맷과 텍스트 폭 계산을 다시 하면 스크롤이나 레이아웃 변경 중 불필요한 비용이 계속 쌓입니다. 보통은 데이터가 바뀌는 순간 필요한 계산을 미리 해두고, onMeasure()에서는 이미 계산된 값만 읽는 편이 좋습니다.


onMeasure 구현에서 놓치기 쉬운 포인트: MeasureSpec은 숫자보다 의도를 읽어야 한다

onMeasure()를 구현할 때 흔한 실수는 내가 원하는 크기만 생각하고 부모가 허용한 조건을 잊는 것입니다. 실무 감각으로 정리하면 보통 이렇게 이해하면 됩니다. EXACTLY는 이 크기로 맞춰라, AT_MOST는 이 크기보다 크면 안 된다, UNSPECIFIED는 원하는 크기를 말해봐라에 가깝습니다.

문제는 많은 커스텀 View가 이 구분 없이 desired size만 세게 주장한다는 점입니다. 그 결과 부모 제약을 무시해서 clipping이나 재측정이 생기거나, wrap_content에서만 예쁘고 실제 화면에서는 깨지기 쉽습니다. 그래서 onMeasure()에서는 내 View가 얼마를 원하느냐보다 부모가 지금 무엇을 허용했느냐를 먼저 읽는 습관이 중요합니다.


커스텀 View onDraw 최적화의 첫 단계: 좌표 계산을 몰아넣지 않는다

공식 custom drawing 문서는 크기와 관련된 positions, dimensions 같은 값은 onDraw()가 아니라 onSizeChanged()에서 계산하라고 권장합니다. 이유는 단순합니다. 크기가 안 바뀌었는데도 onDraw()는 매우 자주 호출될 수 있기 때문입니다.

override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)

    val contentLeft = paddingLeft.toFloat()
    val contentTop = paddingTop.toFloat()
    val contentRight = width - paddingRight.toFloat()
    val contentBottom = height - paddingBottom.toFloat()

    chartRect = RectF(contentLeft, contentTop, contentRight, contentBottom)
    centerX = chartRect.centerX()
    centerY = chartRect.centerY()

    canvas.drawArc(chartRect, startAngle, sweepAngle, true, fillPaint)
}

이 코드는 작은 예시라서 바로 문제처럼 안 보일 수 있습니다. 하지만 애니메이션이 돌거나 invalidate()가 자주 발생하면 매 프레임마다 RectF를 새로 만들고 중심 좌표를 다시 계산합니다.

private val chartRect = RectF()
private var centerX = 0f
private var centerY = 0f

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    super.onSizeChanged(w, h, oldw, oldh)

    val contentLeft = paddingLeft.toFloat()
    val contentTop = paddingTop.toFloat()
    val contentRight = w - paddingRight.toFloat()
    val contentBottom = h - paddingBottom.toFloat()

    chartRect.set(contentLeft, contentTop, contentRight, contentBottom)
    centerX = chartRect.centerX()
    centerY = chartRect.centerY()
}

override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)
    canvas.drawArc(chartRect, startAngle, sweepAngle, true, fillPaint)
}

이 차이는 사소해 보여도 커스텀 View가 복잡해질수록 누적 효과가 큽니다. onDraw는 그리기만 하는 곳에 가깝게 만들수록 좋습니다.


onDraw allocation 함정: Paint, RectF, String.format이 프레임을 끊는 순간

Android 공식 문서는 onDraw()에서 allocation을 없애고, 애니메이션 중에는 절대 allocation하지 말라고 권장합니다. allocation 자체도 비용이지만, 더 무서운 것은 그 뒤에 따라오는 GC입니다. 프레임 예산이 빡빡한 구간에서 GC가 끼면 사용자는 스크롤이 한 번 툭 걸린다고 느끼게 됩니다.

  • Paint()를 onDraw에서 생성
  • RectF(), Path()를 onDraw에서 생성
  • 숫자 표시를 위해 매번 String.format() 호출
  • 그리기 중간에 임시 List 생성
  • bitmap, shader, gradient를 상태 변화마다 즉석 생성
override fun onDraw(canvas: Canvas) {
    val barPaint = Paint(Paint.ANTI_ALIAS_FLAG)
    val rect = RectF()

    items.forEachIndexed { index, item ->
        rect.set(0f, index * rowHeight, item.value, (index + 1) * rowHeight)
        canvas.drawRoundRect(rect, 12f, 12f, barPaint)
    }
}

수정 방향은 거의 정해져 있습니다. Paint, RectF, Path는 필드로 재사용하고, 텍스트는 데이터 변경 시점에 미리 만들고, 색상이나 stroke width가 자주 안 바뀌면 setter에서만 갱신하는 편이 좋습니다. 핵심은 객체 재사용이라는 기계적 규칙보다 프레임 안에서 새 메모리를 만들지 않는 쪽으로 설계하는 감각입니다.

private val barPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
    style = Paint.Style.FILL
}
private val rect = RectF()

override fun onDraw(canvas: Canvas) {
    items.forEachIndexed { index, item ->
        rect.set(0f, index * rowHeight, item.value, (index + 1) * rowHeight)
        canvas.drawRoundRect(rect, 12f, 12f, barPaint)
    }
}

invalidate

많은 커스텀 View는 상태가 바뀔 때 setter에서 바로 invalidate()를 부릅니다. 이 자체는 정상입니다. 문제는 값이 실제로 안 바뀌었는데도 습관적으로 부르는 경우입니다.

fun setProgress(progress: Float) {
    this.progress = progress
    invalidate()
}

동일한 값이 계속 들어오거나, 소수점 오차 수준의 미세한 변화까지 모두 redraw할 필요가 없다면 불필요한 onDraw()를 계속 만들게 됩니다. 보통은 실제 값이 바뀌었을 때만 invalidate() 하도록 한 번 걸러주는 편이 낫습니다.

fun setProgress(progress: Float) {
    if (this.progress == progress) return
    this.progress = progress
    invalidate()
}

즉, invalidate()는 싸 보이지만 공짜는 아닙니다. 특히 애니메이션이나 빠른 데이터 스트림과 결합되면 의미 있는 변화만 다시 그리는가를 꼭 따져봐야 합니다.


invalidate와 requestLayout 차이: 픽셀만 바뀌는지, 필요한 크기까지 바뀌는지

커스텀 View 초반에 가장 자주 헷갈리는 경계가 여기입니다. 둘 다 화면 갱신과 관련 있어 보여서 상태 바뀔 때 둘 다 호출해버리는 경우가 많습니다. 하지만 의미는 꽤 다릅니다. invalidate는 다시 그려라, requestLayout은 크기와 위치도 다시 계산해야 할 수 있다에 가깝습니다.

공식 optimizing-view 문서도 requestLayout()은 expensive operation이라고 설명합니다. 호출되면 시스템이 전체 view hierarchy를 다시 traverse해서 각 View의 크기를 다시 찾고, 측정 충돌이 있으면 여러 번 돌 수도 있습니다.

fun setIndicatorColor(color: Int) {
    indicatorColor = color
    requestLayout()
    invalidate()
}

색상만 바뀌었으면 크기는 안 바뀝니다. 이 경우 requestLayout()은 거의 불필요합니다. 반대로 텍스트 크기 자체가 바뀌었거나, 표시할 항목 수가 바뀌어 desired size가 달라졌거나, stroke width와 content padding 변화로 실제 bounds가 바뀐다면 requestLayout 후보가 됩니다.

이번 변경이 픽셀만 바꾸는가, 아니면 필요한 크기까지 바꾸는가 이 질문 하나가 invalidate와 requestLayout을 가르는 기준이 됩니다.


데이터 변경과 렌더링 준비를 분리하면 커스텀 View가 훨씬 가벼워진다

실무에서 커스텀 View가 느려지는 코드를 보면 setter 하나에 너무 많은 일이 들어 있는 경우가 많습니다. 외부 데이터 받기, 정렬하기, 포맷 문자열 만들기, 최대 길이 재계산, Path 재생성, requestLayout, invalidate까지 한 번에 처리해버리는 식입니다.

처음에는 편하지만, 나중에는 어떤 변경이 layout까지 필요한지, 어떤 변경은 draw만 다시 하면 되는지 구분이 흐려집니다. 그래서 보통은 단계 분리가 좋습니다. 데이터 변경 시에는 모델 정리와 텍스트 precompute, 크기 변경 시에는 bounds 계산, 그리기 시에는 이미 준비된 값 소비 식으로 나누면 읽기도 쉽고 성능도 안정적입니다.


profiling mindset

커스텀 View 최적화에서 흔한 함정은 일단 onDraw() 코드부터 줄여보자는 접근입니다. 물론 자주 맞지만 항상 맞지는 않습니다. 어떤 화면은 draw보다 layout이 더 크고, 어떤 화면은 measure 재호출이 병목이며, 또 어떤 화면은 draw 자체보다 잦은 invalidation이 문제입니다.

  1. 버벅임이 스크롤 중인지, 애니메이션 중인지, 데이터 갱신 직후인지 먼저 본다
  2. 그 시점에 measure, layout, draw 중 무엇이 반복되는지 본다
  3. redraw만 필요한데 requestLayout이 섞였는지 확인한다
  4. onDraw 안 allocation 흔적이 있는지 본다
  5. 크기 계산을 onSizeChanged나 setter로 옮길 수 있는지 본다

공식 문서 관점으로 연결하면 frame budget 감각은 Android vitals의 slow rendering 기준으로 보고, 개별 slow frame은 Perfetto FrameTimeline으로 추적하고, 현장에서는 Profile GPU Rendering도 빠른 1차 확인 도구가 됩니다. 중요한 것은 도구 이름보다 질문 방식입니다. 어떤 프레임에서 무엇을 다시 하느라 어디서 시간이 커졌는지를 보는 습관이 중요합니다.


바로 적용할 수 있는 커스텀 View 성능 체크리스트

  1. onDraw 안에서 새 객체를 만들고 있지 않은가
  2. 좌표, bounds, Path 계산을 매 프레임 반복하고 있지 않은가
  3. 값이 안 바뀌었는데도 invalidate를 부르고 있지 않은가
  4. 크기 변화가 없는데 requestLayout까지 같이 부르고 있지 않은가
  5. onMeasure가 포맷, 정렬, decode, 무거운 계산까지 같이 하고 있지 않은가
  6. setter 하나가 데이터 처리와 렌더링 갱신을 전부 떠안고 있지 않은가
  7. 느린 원인을 draw라고 단정하기 전에 measure/layout 재호출 여부를 확인했는가

마무리

안드로이드 커스텀 View 성능 최적화에서 가장 중요한 것은 트릭이 아니라 경계를 잘 나누는 것입니다. 크기 계산은 onMeasure()onSizeChanged() 쪽으로, 실제 픽셀 그리기는 onDraw() 쪽으로, redraw만 필요하면 invalidate(), 크기까지 달라지면 requestLayout()으로 구분하는 원칙만 흔들리지 않아도 커스텀 View는 훨씬 덜 버벅이고 디버깅도 쉬워집니다.

함께 읽으면 좋은 글로는 RecyclerView와 ListAdapter 차이: 왜 submitList를 쓰면 갱신이 더 쉬워질까, ViewBinding과 DataBinding 차이: 안드로이드에서 무엇을 먼저 쓰는 게 좋을까, SavedStateHandle은 언제 써야 할까가 있습니다. 공식 기준은 How Android draws views, Implement a custom drawing, Optimize a custom view, Slow rendering and jank 문서를 같이 보면 더 명확합니다.

함께보면 좋은 글