MacroBenchmark & Baselin Profile을 사용한 성능 개선 여정-2편

SSY·2025년 5월 23일
1

Compose

목록 보기
14/14

시작하며

MacroBenchmark & Baselin Profile을 사용한 성능 개선 여정 - 1편에선 android 프로젝트의 컴파일 및 JIT/AOT컴파일 개념부터 시작하여 성능 측정 지표의 종류를 개념적으로 알아봤었고, BaselineProfile + Perfetto의 활용 및 지표를 객관적으로 측정하여 부분적으로 개선했다.

이번 글 주제는 '이미지 최적화'와 'Compose Frame Rendering 3단계에 기반한 최적화'에 관해 작성한다.

1. 이미지 최적화

이미지를 로딩할 땐, 아래와 같은 방식으로 로딩이 가능하다.

  • Rester이미지 : png, jpg 등, pixel기반의 이미지
  • Vector이미지 : .xml, .kt 등, pixel과 무관하게 점,선,면 등 수학 공식으로 만들어진 이미지

Rester이미지는 보통 painterResource사용 및 Main Thread를 점유하며 로딩이 이뤄져 frame rendering성능에 악영향을 미칠 수 있다. 따라서 고화질의 Rester이미지를 로딩하려거든 coil등 비동이 이미지 라이브러리 사용이 권장된다.

하지만 composable환경에서 Vector이미지는 Vector Image Tree를 구축하여 그려지는데, 이를 통해 Main Thread의 리소스를 더 아낄 수 있다.

아래는 위 원리에 입각하여 문제를 해결해가는 과정이다.

1. 문제 정의

최적화 전최적화 후

위는 UI는 12개의 증권사들을 LazyColumn으로 보여주는 단순한 UI이다. 하지만 '최적화 전'을 보면 알겠지만 해당 화면 진입 전, 버튼을 클릭 시, frame이 약 2초간 멈추는걸 확인할 수 있다. 진입 후엔 리스트가 뜨기는 하지만 스크롤할 때, UI가 매우 버벅이는것 또한 확인 가능하다.

증권사 로고들은 앱이 보유한 리소스로써 네트워크 로딩 뿐만 아니라 페이징 작업도 없다. 문제를 잡기 위해 아래 코드를 살펴보아도, unstable한 타입으로 인한 Recomposition문제도 찾을 수 없다.

LazyColumn {
  items(
    items = uiState.stockAppList,
    key = { it.code }
  ) { stockApp ->
    StockAppItem(
      uiState = uiState,
      stockApp = stockApp,
      onClickedStockApp = { onClickedStockApp(stockApp) }
    )
  }
}

@Composable
private fun StockAppItem(
  modifier: Modifier = Modifier,
  uiState: StockSettingUiState,
  stockApp: StockEnum,
  onClickedStockApp: () -> Unit = {}
  onClickedStockApp: () -> Unit = {}
) { ... }

따라서 Recomposition으로 인한 버벅거림이 아니라면, Frame Rendering문제가 아닐까란 가설을 세웠다. 그 후, 해당 UI에 Benchmark측정을 해보니, Frame Rendering의 문제임이 밝혀졌다.

Benchmark측정 결과, P99즉, 99%의 frame이 최대 900ms이내로 로딩되며 1%는 그 이상 걸릴 수 있다고 나와있다. 이는 거진 1초라고 봐도 무방한 수치이다.

UI가 제대로 렌더링되기 위해서 1초에 60 frame이 rendering돼야 한다. 이는 즉, 1 frame당 16ms정도가 나와야 UI가 버벅거리지 않음을 의미한다. 하지만 1frame의 rendering시간이 1000ms라는 것은 60배나 좋지 않은 성능임을 증명한다. 따라서 문제 해결 방향은 frame rendering개선이다.

정상이라면 1 frame의 rendering시간은 16ms이나 현재는 900ms이다.

그럼 UI의 어느 부분이 문제인 걸까? 문제지점을 알아내기 위해, LazyColumn내, item의 내부 코드 중, Image를 단순 Box로 변경 후, 성능을 측정했다.

[before]

Image(
  modifier = Modifier
    .align(Alignment.CenterVertically)
    .size(dimensionResource(id = R.dimen.dimen_40dp))
    .border(
      shape = CircleShape,
      border = BorderStroke(
        width = dimensionResource(id = R.dimen.dimen_01dp),
        color = colorResource(id = R.color.gray_F2F3F5)
      )
    ),
  painter = painterResource(id = imgRes),
  contentDescription = null
)

[after]

Box(
  modifier = Modifier
    .size(40.dp)
    .background(Color.Magenta)
)
변경된 UI성능 측정 결과

그 결과, P99가 908.6ms에서 13.8ms로 개선된걸 확인할 수 있다. 이를 통해 2가지를 알 수 있다.

  1. 병목 구간은 Image composable함수이다.
  2. 이미지 로딩 방식은 LazyColumn의 스크롤 성능에 영향을 준다.

2. 문제 해결

1단계 : ImageBitmap을 coil로 로딩

Image컴포저블은 painterResource()를 활용하여 ImageBitmap을 Main Thread에서 로드하고 있었다. 따라서 coil을 사용하여 이미지를 비동기로 로드했으며, 그 결과, 이미지 렌더링 성능이 908.6ms -> 26.0ms로 극적으로 개선됐다.

ImageBitmap + 동기적 로딩ImageBitmap + 비동기적 로딩

위 벤치마크에 대한 perfetto는 어떻게 나왔을까? perfetto를 통해 성능을 유의미하게 측정하기 위해선 trace()메서드의 key값 지정이 필요하다. 따라서 현재 측정하고자 하는 UI에 StockAppItem::StockImage라는 key값을 지정했다.

trace("StockAppItem::StockImage") {
  Image(
    modifier = Modifier
      .align(Alignment.CenterVertically)
      .border(
        shape = CircleShape,
        border = BorderStroke(
          width = dimensionResource(id = R.dimen.dimen_01dp),
          color = colorResource(id = R.color.gray_F2F3F5)
        )
      ),
    painter = rememberAsyncImagePainter(
      model = imageRequest
    ),
    contentDescription = null
  )
}

key값 지정 완료 후, perfetto가 출력됐다. 이제 이를 통해 SQL문 실행을 통해 위, StockAppItem::StockImage의 로딩 시간을 측정했으며, 그 결과는 약 590μs로 나왔다.

⭐⭐⭐교훈
용량이 큰 ImageBitmappainterResource()를 활용해 로드할 경우, frame 버벅임이 일어난다. 따라서 coil 등과 같은 비동기 이미지 로딩을 고려하자.

하지만 더 최적화할 여지는 없을까?

2단계 : VectorImage(.xml)를 coil로 로딩


참고 : 안드로이드 공식 홈페이지

Android공식 홈페이지에 따르면 ImageBitmap보다 ImageVector사용을 권장하고 있다. 따라서 이미지를 비동기로 로드하는 방식은 유지하며, 로드하는 이미지를 ImageVector로 변경하면 좀 더 로드 성능이 빠를거란 생각이 들었다.

ImageBitmap + 비동기적 로딩ImageVector(.xml) + 비동기적 로딩
|

Benchmark측정 결과, 차이가 극명하게 나진 않았다. 다만, perfetto를 통해 렌더링 속도를 확인했을 땐로드를 확인했을 땐 402μs초가 나왔다. 즉, 기존보다 200μs단축된 것이다. (해당 방식으로, 벤치마크를 3번 측정 결과, frameOverrunMs가 16.4ms, 26.8ms, 23.0ms로 기록하며 변동 심해 결과 확인이 어려웠으나, perfetto는 400μs로 일정하게 나왔다. 따라서 제대로 된 성능 측정을 위해선 perfetto로 확인이 필요하다.)

첫 번째 측정 결과
두 번째 측정 결과
세 번째 측정 결과

3단계 : ImageVector(.xml)을 ImageVector(.kt)로 변경

Vector이미지는 .xml.kt타입으로 로드할 수 있다. 만약 두 이미지가 동일한 .svg확장자로부터 생성됐다면, .kt타입으로 이미지를 로드하는 것이 유리하다. 왜 그럴까?

그 이유는 Compose환경에서 vector이미지를 로딩하려면 Vector Image Tree를 그려야 하는데, (Composable함수 구성을 위해 Layout Node Tree를 그리는 것과 유사) .xml로 이미지 로딩을 위해선 Vector Image Tree로 표현할 수 있는 객체인 ImageVector로 변환하는 작업이 필요하기 때문이다. 바로 이 과정에서 Main Thread를 Blocking하는 연산 비용이 들어가게 된다. 아래는 .xml이미지의 로딩 과정이다.

  1. loadVectorResource() 호출 및 ImageVector 생성
  2. rememberVectorPainter() 호출 및 VectorPainter생성
  3. VectorPainter를 사용하여 Image Composable함수 호출 및 이미지 로딩

하지만 .kt타입을 로드할 땐? 위의 1번 과정 없이 ImageVector를 바로 주입받는걸 확인할 수 있으며, 이를 통해 Vector Image Tree를 바로 구성할 수 있게 되었다. 즉, .kt타입의 이미지 렌더링 속도가 더 빠른 이유는 .xml타입의 이미지처럼 ImageVector를 생성하는 작업이 없기 때문이며, 이로 인해 ImageVector타입을 바로 사용하고 Vector Image Tree를 바로 구성할 수 있기 때문이다. 따라서 아래의 순서를 따른다.

  1. rememberVectorPainter() 호출 및 VectorPainter생성
  2. VectorPainter를 사용하여 Image Composable함수 호출 및 이미지 로딩

이러한 이유로 기존 .xml타입의 이미지를 .kt타입의 이미지로 교체하였다. 측정 및 perfetto확인 결과, 약 300µs에서 80µs로 성능 개선을 확인할 수 있다.

Vector이미지(.xml)
Vector이미지(.kt)

4단계 : RadioButton제거 및 custom하게 변경

그 외, 우린 UI를 구축할 때, compose의 기본 API로 UI를 구축하곤 한다. 대표적인 예로, RadioButton등이 있다.

RadioButton의 파라미터 중, colors라는 녀석이 있다. 이녀석은 기본 값으로 Material Design즉, 디자인 시스템에 따라 UI색깔을 정해줄 수 있는 장점이 있다.

하지만, 디자인 시스템이 갖추어져있지 않은 경우, 해당 API은 rendering 성능 자체에선 불리할 수 있다는 점을 알아야 한다. 아래 사진은 하나의 item에서 RadioButton이 차지하는 렌더링 시간으로, 하나의 item의 rendering시간에서 약 1/4를 차지하고 있으며 평균 300µs로 측정된다. 해당 UI는 크게 복잡한 부분이 없기에, 필자는 rendering 시간이 너무 길다고 느껴졌다.

trace("stockAppRadioButton") {
  RadioButton(
    selected = uiState.selectedStockApp == stockApp,
    colors = RadioButtonDefaults.colors().copy(
      selectedColor = colorResource(id = R.color.gray_091E42),
      unselectedColor = colorResource(id = R.color.gray_B3BAC5)
    ),
    onClick = { onClickedStockApp() }
  )
}

이 또한 더 줄일 수 없을까? 가능하다. 더더욱 기초적인 UI요소인 Box를 활용하면 frame rendering시간 단축이 더 가능하다.

@Composable
fun BaseRadioButton(
  selectedState: Boolean,
  onClickedStockApp: () -> Unit
) {
  val borderState by rememberUpdatedState(
    when (selectedState) {
      true -> BorderStroke(
        width = 8.dp,
        color = colorResource(R.color.gray_091E42)
      )
      false -> BorderStroke(
        width = 2.dp,
        color = colorResource(id = R.color.gray_B3BAC5)
      )
    }
  )
  Box(
    modifier = Modifier
      .size(24.dp)
      .border(
        shape = CircleShape,
        border = borderState
      )
      .noRippledClickable(
        onClick = onClickedStockApp
      )
  )
}

custom하게 BaseRadioButton구축 후, 측정을 통해 렌더링 시간이 평균 300µs에서 100µs로 대폭 개선되었다. 이를 통한 교훈은 compose에서 제공해주는 API는 상황에따라 무거울 수 있으니, custom하게 구현하는게 좋다는 것이다.

2. Frame Rendering 최적화

[composable함수의 frame rendering 3단계]

  • Composition
  • Layout(1. Measure 2. Place)
  • Drawing

Layout/Drawing단계때 잘못된 state값의 읽기/변경 작업은 frame rendering과정을 불필요하게 1번 더 반복시킬 수 있다. 그 이유는 frame rendering과정은 단방향으로만 진행되며 역행할 수 없기 때문이다. 예를 들어, Modifier.layout { ... }함수에서 imageHeightPx값을 변경했고 이를 composable함수에 주입한다고 가정하자. 이때, composable함수의 파라미터는 Composition단계 때 상태값을 읽어들이므로 Composition단계를 한번 더 거쳐야만 한다. 하지만 Layout단계에선 Composition단계로 역행할 수 없다. 따라서 frame rendering과정이 1번 더 반복된다.

아래의 UI는 스크롤 움직임에 따라 TradingDetailTopBar의 글씨크기가 달라진다. 즉, 스크롤에 따라 0.0f ~ 1.0f사이의 state값을 수신받고, 그에 따라 fontSize크기를 변경해주면서 글자 크기를 변경해주고 있다.

하지만 아래 코드는 문제가 존재한다. 바로, fontSizeComposition단계 때 상태값을 읽어들인다는 점이다. 따라서 0.0f ~ 1.0f사이의 상태값이 변경될때마다 무수히 많은 Recomposition이 발생한다. 아래는 LayoutInspector를 통해 RecompositionTradingDetailTopBar에서부터 발생하는걸 보여주고 있다.

Text(
  modifier = Modifier.align(Alignment.CenterHorizontally),
  text = tradingDetailProfileVo.stock,
  style = TypoSubhead3.copy(
    fontSize = 12.sp * (1 - scrollProgressState)
  )
)

문제를 어떻게 해결해야할까? 우선 요구사항 먼저 생각해보자. TopAppBar쪽 글자 크기는 scrollState의 값 즉, 0.0f ~ 1.0f값에 따라 글자 크기를 점진적으로 변경해주는 것이다. 따라서 이럴 땐, Composition단계 때 상태값을 읽는 fontSize를 활용하는 게 아니라, Layout단계 때 상태값을 읽는 Modifier.layout { ... }를 활용하는것도 문제 해결의 1가지 방법일 수 있다. 따라서 이를 활용해 코드 작성 결과, 최 하위 composable함수인 Text에서 Recomposition이 skip되는걸 확인할 수 있다.

val heightToPx by rememberUpdatedState(
  ((1 - scrollProgressState) * with (density) { 18.dp.roundToPx() }).toInt()
)
Text(
  modifier = Modifier
    .layout { measurable, constraints ->
      val placeable = measurable.measure(
        Constraints.fixedHeight(height = heightToPx)
	  )
      layout(placeable.width, placeable.height) {
        placeable.placeRelative(0, 0)
      }
    }
    .align(Alignment.CenterHorizontally),
  text = tradingDetailProfileVo.stock,
  style = TypoSubhead3.copy(
    color = colorResource(id = R.color.gray_091E42),
    fontSize = 12.sp
  )
)

하지만 개발자라면 위의 Modifier.layout { ... }함수를 추출하고싶은 욕망이 든다. 따라서 아래와 같이 추출을 시도한다. 테스트를 해보면 어떤 결과가 나올까?

fun Modifier.heightWithNoComposition(height: Int): Modifier =
  this.layout { measurable, _ ->
    val constraints = Constraints.fixedHeight(
      height = height
    )

    val placeable = measurable.measure(constraints)
    layout(placeable.width, placeable.height) {
      placeable.placeRelative(0, 0)
    }
  }
val heightToPx by rememberUpdatedState(
  ((1 - scrollProgressState) * with (density) { 18.dp.roundToPx() }).toInt()
)
Text(
  modifier = Modifier
    .heightWithNoComposition(heightToPx)
    .align(Alignment.CenterHorizontally),
  text = tradingDetailProfileVo.stock,
  style = TypoSubhead3.copy(
    fontSize = 12.sp
  )
)

Recomposition을 건너뛰던 코드가 단순 함수 추출로 인해 Recomposition을 다시 유발하는 신기한 상황이 벌어졌다. 왜 그런걸까?

첫 번째 이유는, scrollState상태 변수가 최 상위 TradingDetailTopBar에 주입되어, Recomposition을 유발됐기 때문이다. (compose runtime은 composable함수 파라미터에 상태값이 변경되는 순간 Recomposition을 예약함)

두 번째 이유는, 최 상위 TradingDetailTopBarRecomposition으로 하위 composable함수들이 skippable한지 검사를 한다. 이때, 주입받은 상태변수 scrollStateComposition을 유발하고 이 녀석이 주입 된 Modifier를 통해 Text함수가 재구성되는 것이다.

그렇다면 이를 어떻게 해결할까?

위의 원인에 나와있는걸 고쳐주면 된다. 첫 번째 이유에선 최 상위 composable함수인 TradingDetailTopBarFloat타입의 scrollState가 주입되고 있다. 이를 Lambda타입으로 바꿔줌으로써 상태값 변경에 따른 Recomposition유발을 피할 수 있다.

@Composable
fun TradingDetailTopBar(
//  scrollProgressState: Float,
  scrollProgressStateProvider: () -> Float,
)

두 번째 이유에선 scrollState를 주입받은 Modifier.heightWithNoComposition의 파라미터도 모두 Lambda타입으로 바꿔준다.

fun Modifier.heightWithNoComposition(
//  height: Float,
  heightProvider: () -> Float
): Modifier 

위와같이 변경 후, scrollState를 주입받는 Text composable함수는 아래와 같이 호출된다.

val heightToPx by rememberUpdatedState(
  { ((1 - scrollProgressState) * with (density) { 18.dp.roundToPx() }).toInt() }
)
Text(
  modifier = Modifier
    .heightWithNoComposition(heightToPx)
    .align(Alignment.CenterHorizontally),
  text = tradingDetailProfileVo.stock,
  style = TypoSubhead3.copy(
    fontSize = 12.sp
  )
)

즉, composable함수의 파라미터 타입을 Lambda로 바꾸고, 이를 Layout단계 때, 읽음으로써 Recomposition을 skip하게된다.

참고

profile
불가능보다 가능함에 몰입할 수 있는 개발자가 되기 위해 노력합니다.

6개의 댓글

comment-user-thumbnail
2025년 5월 28일

글 잘 봤습니다!
현재 perfetto로 성능 개선 중에 있는데 큰 도움이 되고 있습니다:)

1개의 답글
comment-user-thumbnail
2025년 6월 17일

좋은 글 감사합니다~

1개의 답글
comment-user-thumbnail
6일 전

어제 발표 잘 들었습니다 :)

1개의 답글