[Android][Kotlin] Benchmark 적용

D.O·2023년 10월 9일
1

이론에 대한 작성한 이전 글을 간단히 보고 오시는 것을 추천합니다.

이번 글에서는 이전에 수행했던 'MineMe 프로젝트'Jetpack Benchmark를 적용하는 과정과, 그 중에서 겪었던 버그 설명과 해결 방법을 공유하겠습니다.

해당 글은 아래 Android 공식 문서를 참조하였습니다.

전체 프로젝트 설정

정기적인 벤치마킹을 실행할 때, 다른 일반 테스트와의 충돌을 방지하기 위해 벤치마크를 별도의 모듈로 분리하는 것이 좋다고 합니다

따라서 모듈 템플릿을 사용하여 Benchmark 모듈을 생성해줍니다.

여기서 Module을 클릭해서 생성해줍니다.

이제 해당 생성된 모듈에 들어가서 Benchmark를 작성하면 됩니다.
벤치마크를 만들려면 라이브러리에서 제공되는 BenchmarkRule클래스를 사용하면 됩니다.
그리고 app모듈 Manifest에 가서
profileable android:shell="true"를 적어주셔야합니다.

이 태그는 앱의 성능 측정을 위한 앱의 프로파일링 권한을 부여합니다.


저는 앱의 시작 시간을 측정하기 위해 StartupBenchmark를 구현하였습니다.

앱을 시작하고 나서 첫 시작 화면인 Home 화면이 완전히 로드될 때까지의 시간을 측정했습니다. 이렇게 측정한 이유는 사용자가 앱을 시작하고 완전한 홈 화면을 보는 데 걸리는 시간이 사용자 경험에 큰 영향을 미친다고 생각하기 때문입니다.

즉, 앱이 처음으로 화면에 나타나기 시작하는 처음 표시하는 데 걸린 시간(TTID)이 아니라 화면의 모든 내용이 사용자에게 완전히 표시되는 완전히 표시하는 데 걸린 시간(TTFD)을 측정하고자 했습니다.

startActivityAndWait()는 앱의 Home 화면이 시작될 때까지 기다리는 기능을 합니다. 이 함수가 완료된 후에도 홈 화면에서 사용된 각 compose 컴포넌트(화면 구성 요소)가 완전히 로드되는 데 필요한 추가 시간을 측정하였습니다.

따라서 startActivityAndWait()가 완료된 후에도 홈 화면의 각 compose 컴포넌트가 완전히 표시되는 데 필요한 시간을 추가적으로 측정했습니다.


HomeScreen

HomeUiState에서 데이터를 성공적으로 불러왔을 때 상태는 Success로 전환됩니다. 그 전 상태에서는 사용자에게 Custom LoadingWheel이 보입니다. 이 LoadingWheel이 표시되는 시간을 측정하기 위해 해당 컴포넌트에 TestTag를 부착했습니다.


LoadingWheel

그럼에도 불구하고 Success 상태가 되어 LoadingWheel이 사라진 후에도 홈 화면이 완전히 표시되기까지 추가 시간이 소요될 수 있습니다.

이는 데이터 로딩 외의 다양한 요소들, 예를 들면 이미지 렌더링이나 애니메이션 등이 화면에 표시되는 데 걸리는 시간 때문일 수 있습니다.

따라서, Success 상태에서의 이미지를 렌더링해서 표시하는 컴포넌트에 렌더링이 완료되면 표시되는 testTag를 부착하여 이미지가 완전히 사용자에게 표시되는 상황까지의 로딩 시간을 정확하게 측정하였습니다.

최종적인 startUpBechmark 코드 입니다

@RunWith(AndroidJUnit4ClassRunner::class)
class StartupBenchmark {
    @get:Rule
    val benchmarkRule = MacrobenchmarkRule()

    @Test
    fun startupNoCompilation() = startup(CompilationMode.None())
    
    private fun startup(compilationMode: CompilationMode) = benchmarkRule.measureRepeated(
        packageName = PACKAGE_NAME,
        metrics = listOf(StartupTimingMetric()),
        compilationMode = compilationMode,
        iterations = 10,
        startupMode = StartupMode.COLD,
        setupBlock = {
            pressHome()
        },
    ) {
        startActivityAndWait()
        homeYouWaitForContent()
    }
}

그리고 해당 Benchmark를 실행하게 되면 아래 처럼 결과가 나오게 됩니다.

이전 글인 이론편에서 설명하였듯이 Iteration은 다른 노이즈로 인한 성능 측정이 정확하게 안되는 경우가 발생하기 때문에 Iteration을 여러번으로 주는 것이 중요합니다.

구글 I/O에서는 가장 성능이 잘나온 최솟값에 주목하라고 합니다.

자세한 내용은 이전 글을 참고하세요.

파란색으로 표시되어있는 부분을 누르면 system trace를 할 수 있습니다

클릭하면 아래처럼 분석할 수 있는 profile 창이 뜹니다.

Janky frames는 가장 위에 있는 적색 부분인데 예상시간보다 작업이 오래걸린 부분을 나타냅니다.

이를 클릭하면 해당 프레임의 일부로 수행된 작업을 확인 할 수 있습니다.

이 정보는 어떻게 사용할 수 있을까요?

  • 성능 지연 원인 파악: Janky Frames 섹션을 클릭하면 해당 프레임에서의 작업을 분석할 수 있습니다. 예를 들어, 특정 프레임에서 CPU가 과도하게 사용되었다면, 해당 부분의 코드를 최적화할 필요가 있을 수 있습니다.
  • 리소스 사용 분석: 시스템 트레이스를 통해 앱이 어떤 리소스를 얼마나 사용하는지, 어떤 작업이 가장 많은 리소스를 사용하는지 등을 분석할 수 있습니다.
  • 최적화 전후 비교: 성능 개선 작업 후에도 벤치마킹을 다시 실행하여, 개선 전과 후의 성능을 비교할 수 있습니다. 이를 통해 얼마나 효과적으로 최적화되었는지 평가할 수 있습니다.

이러한 Janky frames들을 분석해서 어떤 부분이 문제인지 알아보고 성능 개선을 노려볼 수 있을 것 같다.

그리고 드래그나 클릭을 통해서 섹션을 설정할 수 있고 m을 누르면 해당 섹션만 확대되어 집중적으로 분석할 수 있다.

근데… 내가 생각했던거랑 좀 다르다

이걸 보고 어케 분석할지 아직 감이 안온다 이부분에 대해서 시간을 많이 두고 좀 더 공부하려 했으나 자료가 많이 없다.

천천히 날을 잡아 공부해서 글을 올려보겠습니다.

measureRepeated

measureRepeated는 특정 작업에 대한 성능 벤치마킹을 반복적으로 수행하기 위한 함수입니다.

아래는 해당 함수의 매개변수에 대한 설명입니다.

  1. packageName:
    • 설명: 측정하려는 애플리케이션의 패키지 이름입니다.
  2. metrics:
    • 설명: 벤치마크 중에 수집하려는 메트릭의 리스트입니다.
  3. compilationMode:
    • 설명: 앱의 컴파일 모드입니다. 애플리케이션 코드가 어떻게 컴파일되어야 하는지 결정합니다.
  4. iterations:
    • 설명: 벤치마크를 실행할 횟수입니다.
  5. startupMode:
    • 설명: 앱을 시작할 때의 모드입니다..
  6. setupBlock:
    • 설명: 각 벤치마크 실행 전에 실행되는 설정 블록입니다. 이 블록 내에서는 테스트 전에 필요한 준비 작업을 수행합니다.

중요하다고 생각되는 metrics와 compilationMode와 startupMode에 대해 설명하겠습니다.

metrics

measureRepeated 함수에 List로 전달되므로 측정된 여러 측정항목을 한 번에 지정할 수 있습니다. 벤치마크를 실행하려면 측정항목 유형이 하나 이상 필요하다고 합니다.

종류

  • StartupTimingMetric : 앱 시간을 측정
  • FrameTimingMetric : 앱에서 버벅거림 정도를 측정
  • TraceSectionMeric : 사용자가 정의한 추적 섹션 메트릭을 캡쳐
  • PowerMetric: 앱의 에너지 사용량을 모니터

자신이 측정하고 싶은 지표를 선택해서 list에 추가해주시면 됩니다.

CompilationMode

CompilationMode은 컴파일 유형입니다.

Android N+(API 24+)부터는 여러 컴파일 유형을 지원한다고 합니다.

종류

Partial:

  • 기본 프로필이 앱에 포함된 경우 앱을 부분적으로 사전 컴파일합니다. 이는 실제 장치에서의 새로운 설치 경험을 가장 잘 반영합니다. 벤치마크 내용을 기반으로 사전 컴파일을 안내하거나 JIT 발생 후 성능을 모방할 수도 있습니다.

Full:

  • 앱이 완전히 사전 컴파일됩니다. Android N에서 이와 같이 완전히 사전 컴파일되는 경우는 드물어 일반적인 사용자 경험을 잘 반영하지 않을 수 있습니다. 그러나 이 모드는 더 안정적인 성능을 보여줄 수 있습니다.

None:

  • 앱이 사전 컴파일되지 않습니다. 앱 내 기본 프로필의 성능 영향을 평가하는데 유용합니다.

Ignore:

  • 앱의 컴파일 상태는 그대로 둔 채 무시됩니다. 이는 개발자가 앱의 컴파일 상태를 사용자 정의한 후에 그 상태를 그대로 유지하고 싶을 때 사용됩니다.

startup modes

앱은 콜드 스타트, 웜 스타트, 핫 스타트라는 세 가지 상태 중 하나에서 시작

각 상태는 앱이 사용자에게 표시되는 데 걸리는 시간에 영향을 미칩니다.

콜드 스타트에서는 앱이 처음부터 시작됩니다. 다른 두 상태에서는 시스템이 실행 중인 앱을 백그라운드에서 포그라운드로 가져와야 합니다.

공식 문서에 따르면 앱의 시작을 측정할 때는 항상 콜드 스타트를 가정하여 최적화하는 것이 좋다고한다 이렇게 하면 웜 스타트와 핫 스타트의 성능도 개선될 수 있다고 한다.

종류

  • Cold Start (콜드 스타트):
    • 앱이 처음 시작될 때 발생합니다.
    • 앱 프로세스가 메모리에 없는 상태에서, 앱 아이콘을 탭하여 시작할 때의 상황을 의미합니다.
  • Warm Start (웜 스타트):
    • 앱이 이미 한 번 실행된 상태에서 홈 화면이나 다른 앱으로 전환한 후 다시 해당 앱으로 돌아올 때 발생합니다.
  • Hot Start (핫 스타트):
    • 앱이 이미 실행 중이며, 사용자가 앱 내부에서 화면이나 기능을 전환할 때 발생합니다.

CompilationMode에서 언급되었던 AOT와 JIT에 대해서는 다음 글인 BaseLine_Profile에서 설명하겠습니다.

또 다른 벤치마크 코드, ScrollDetailStoryListBenchmark코드입니다.

이 코드는 Story의 DetailStory 화면에서의 스크롤 동작 시의 프레임 성능을 측정하기 위해 작성 하였습니다.

이번에는 스크롤의 성능을 체크하는 것이므로 startupMode를 Warm으로 주었습니다.

그리고 성능 지표로는 스크롤 동작 중의 프레임 타이밍을 측정하는 FrameTimingMetric를 사용하였습니다.

측정 결과 입니다.

Bug

1. Protobuf 난독화 문제

제가 Benchmark를 진행하며 겪었던 어려움과 해결과정을 설명하겠습니다.

제가 만든 앱에서는 Protobuf를 사용하여 UserData를 저장합니다. 이에는 jwt, darktheme 여부 등의 정보가 포함됩니다.

Benchmark를 진행할 때는 실제 라이브 애플리케이션과 최대한 유사한 환경에서 수행하는 것이 좋다고 해서 Benchmark 빌드에서 애플리케이션의 코드를 난독화하는 과정이 포함했습니다. 그런데, 난독화 과정에서 Protobuf의 필드 이름이 변경될 경우, 해당 필드를 런타임에서 찾을 수 없게 되어 버그가 발생할 수 있습니다.

이러한 문제 때문에 저는 처음에 UserData를 찾을 수 없다는 오류가 떠 어려움을 겪었습니다.

해결방법

이러한 문제를 해결하기 위해서는 ProGuard 설정을 통해 Protobuf 메시지에 사용되는 필드들을 보존해야 합니다. 아래와 같은 설정을 사용하면 해당 문제를 해결할 수 있습니다

Protobuf는 내부적으로 리플렉션을 사용하여 메시지의 필드에 액세스합니다. 만약 난독화 도구가 이러한 필드의 이름을 변경한다면, Protobuf 라이브러리는 런타임에서 해당 필드에 액세스할 수 없게 되어 오류가 발생하게 됩니다. 따라서 위와 같은 ProGuard 설정을 통해 필드 이름이 변경되지 않도록 보호해야 합니다.

2. jwt확인 버그

제 앱은 처음 MainActivity에서 로컬에 jwt 저장 유무에 따라 없다면 SignUp, 있다면 DoApp으로 분기되게 구현하였습니다.

startup benchmark 시에는 DoApp의 하위 컴포넌트가 완전히 뜨는 상황에서 까지의 성능을 체크하기 위해서 benchmark 시 test_jwt가 기본적으로 저장되게 구현해야 했습니다.

하지만 이 부분을 해결하기까지 많은 오류가 발생했는데요.

그 과정과 해결과정까지 설명드리겠습니다.

제가 첫 번째로 시도해본 방법은 startActivityAndWait에 intent를 넣어주는 것이었습니다.

startupBenchmark.class

val intent = Intent()
intent.putExtra("BENCHMARKING", true)
startActivityAndWait(intent)

mainActivity.kt

val isBenchmarking = intent.getBooleanExtra("BENCHMARKING", false)
if (isBenchmarking) {
     viewModel.updateJWT("test_jwt")
}

이 방법은 intent를 받아서 제 app을 정확하게 실행시키지 못했습니다.

제 앱이 안뜨고 다른 앱을 추천하는 화면이 뜨는 오류가 발생했습니다.
그 이유는 애플리케이션의 다른 인스턴스나 인텐트 필터와의 충돌로 인한 것일 수 있습니다.

그래서 액티비티를 정확하게 지정해야겠다고 생각하고 이렇게 수정했습니다.

startupBenchmark.class

val intent = Intent()
intent.component = ComponentName(PACKAGE_NAME, "$PACKAGE_NAME.MainActivity")
intent.putExtra("BENCHMARKING", true)
startActivityAndWait(intent)

하지만 이 방법 또한 경로 이상으로 제대로 작동하지 않았습니다.
그 이유는 난독화 과정에서 패키지나 액티비티의 이름이 변경되어서 정확한 경로를 찾지 못했을 수 있습니다.

이 외에도 sharedPreference를 이용하는 방법, command로 진행하는 방법 등등 다양한 방법을 시도했는데 난독화 문제나 인텐트 필터, 혹은 애플리케이션의 상태 관리와 관련된 문제로 작동을 하지 않았습니다. (사실 에러 코드가 정확히 파악되지 않아 이유는 자세히 모르겠습니다.)

해결방법

이전에 시도했던 방법에 비해서 매우 간단하게 이 문제를 해결했는데요

MainActivity가 실행될 때 현재 벤치마킹 중인지만 감지하게 했습니다.

아래 줄만 MainActivity에 추가하면 되는데요

MainActivity.kt

//detect benchmarking
val isBenchmarking = applicationContext.packageName.endsWith(".benchmark")
if (isBenchmarking) viewModel.updateJWT("test_jwt")

이 방법의 장점으로는

  1. 추가적인 리소스나 복잡한 로직 없이 간단한 코드로 벤치마킹을 감지할 수 있다.
  2. 난독화나 빌드 환경의 영향을 최소화하여 안정성을 높인다.
  3. 앱의 실행 시간에 별다른 영향을 주지 않아 성능에 부담이 없다.

단점으로는 만약 패키지명이 변경될 경우, 이 로직 역시 수정이 필요하다는 단점이 있다고 판단됩니다.

패키지명을 자주 바꾸지 않을 것이라 예상되기 때문에, 이 방법으로 문제를 해결하기로 결정했습니다.

벤치마크 일관성 문제

이것은 오류는 아니지만 구글 I/O에서도 언급했듯이 Benchmark는 동일한 조건에서 진행되는게 좋다고 했습니다.

특히 recomposition에 기반한 성능 테스트에서는 작은 변화도 큰 차이를 만들 수 있기 때문에 반복 가능하고 예측 가능한 테스트 환경을 구축하는 것이 중요하다고 생각했습니다.

아래는 제 지역선택에 따른 스토리 필터 부분을 밴치마킹한 부분입니다.

처음에는 이렇게 구현하였습니다.

fun MacrobenchmarkScope.selectRandomRegionFromDropdown() {
 
    val regionDropdown = device.findObject(By.res("RegionDropDown"))
    regionDropdown.click() // 드롭다운 메뉴를 연다

    device.waitForIdle()
    val menuItems = device.findObjects(By.clazz(android.widget.TextView::class.java)) // TextView 클래스를 가진 모든 객체를 가져온다.
    
    // menuItems 중에서 무작위로 하나를 선택한다.
    val randomItem = menuItems.random()
    randomItem.click()
    device.waitForIdle()
}

이에 따라 지역 선택을 무작위로 진행할 경우, 각 테스트 실행마다 다른 결과를 가져올 수 있습니다. 이렇게 되면 테스트 결과가 이 랜덤한 결과에 영향을 받을 거라 생각했습니다.

해결방법

이러한 문제를 해결하기 위해 항상 동일한 지역을 선택하여 필터링하는 방식으로 benchmark 코드를 리팩토링 하였습니다. 변경한 코드는 아래와 같습니다:

// 테스트 대상 지역 리스트
internal val dropdownRegion = listOf("경남", "서울", "부산")

// 세 번 반복하여 각 지역을 순차적으로 선택합니다.
repeat(3) {
    selectRegionFromDropdown(dropdownRegion[it])
}

// 지정된 지역을 드롭다운 메뉴에서 선택하는 함수
fun MacrobenchmarkScope.selectRegionFromDropdown(regionName: String) {
    // 지역 선택 드롭다운 메뉴 버튼을 찾아 클릭합니다.
    val regionDropdown = device.wait(Until.findObject(By.res("RegionDropDown")), 3_000)
    regionDropdown.click()
    device.waitForIdle()

    // 지정된 지역명에 해당하는 메뉴 아이템을 찾아 클릭합니다.
    val menuItem = device.wait(Until.findObject(By.text(regionName)), 3_000)
    menuItem.click()
    device.waitForIdle()
}

이 코드는 먼저 세 개의 지역(경남, 서울, 부산)을 순차적으로 선택하는 방식으로 테스트를 진행합니다. 각 지역을 선택할 때는 드롭다운 메뉴를 클릭하여 메뉴 아이템 목록을 표시하고, 지정된 지역명에 해당하는 아이템을 찾아 클릭하는 방식으로 동작합니다. 이를 통해 테스트의 일관성을 보장하면서도 필요한 성능 테스트를 진행할 수 있습니다.

findObject 대기 시간

초기에는 "device.findObject(By.text(regionName))을 사용하여 특정 UI 요소를 검색하려고 했으나, 해당 요소가 아직 화면에 존재하지 않아 null 값을 반환하면서 오류가 발생했습니다.

해결방법

이 문제를 해결하기 위해 device.wait 메소드를 활용하여 해당 UI 요소가 화면에 나타날 때까지 최대 3초 동안 대기하도록 수정하였습니다. 코드는 다음과 같습니다

화면에 아직 표시되지 않아 null 값을 반환하여 오류가 발생한다면 대기시간을 주는 방법을 고려해보시는게 좋습니다.

val menuItem = device.wait(Until.findObject(By.text(regionName)), 3_000)

마무리

Jetpack Benchmark를 실제 프로젝트에 도입하면서, 그 과정과 함께 여러 버그 및 이에 대한 해결 방안들을 함께 살펴보았습니다.

Jetpack Benchmark의 출시 이전에 별도로 성능 측정을 구현해본 경험이 없었습니다. 그럼에도 불구하고, Benchmark를 처음 적용해보면서 어려움을 크게 느끼지는 않았습니다. (물론, 일부 버그와 문제에 좀 고생했지만요… ㅎㅎ)

그래서 도입을 하면서 “Jetpack Benchmark가 되게 잘 만들었구나” 라고 생각을 했습니다.

특히, 제가 직접 앱을 조작하지 않아도 설정한 시나리오대로 자동으로 성능 테스트가 진행되는 것이 꽤나 흥미로웠습니다.

다음 글에서는 BaselineProfile과 함께 이 Benchmark를 활용하여 앱의 성능 최적화 과정에 대해 작성해보겠습니다.!!

긴 글 읽어주셔서 감사합니다.

profile
Android Developer

0개의 댓글