이론에 대한 작성한 이전 글을 간단히 보고 오시는 것을 추천합니다.
이번 글에서는 이전에 수행했던 '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들을 분석해서 어떤 부분이 문제인지 알아보고 성능 개선을 노려볼 수 있을 것 같다.
그리고 드래그나 클릭을 통해서 섹션을 설정할 수 있고 m을 누르면 해당 섹션만 확대되어 집중적으로 분석할 수 있다.
근데… 내가 생각했던거랑 좀 다르다
이걸 보고 어케 분석할지 아직 감이 안온다 이부분에 대해서 시간을 많이 두고 좀 더 공부하려 했으나 자료가 많이 없다.
천천히 날을 잡아 공부해서 글을 올려보겠습니다.
measureRepeated
는 특정 작업에 대한 성능 벤치마킹을 반복적으로 수행하기 위한 함수입니다.
아래는 해당 함수의 매개변수에 대한 설명입니다.
중요하다고 생각되는 metrics와 compilationMode와 startupMode에 대해 설명하겠습니다.
measureRepeated 함수에 List
로 전달되므로 측정된 여러 측정항목을 한 번에 지정할 수 있습니다. 벤치마크를 실행하려면 측정항목 유형이 하나 이상 필요하다고 합니다.
종류
자신이 측정하고 싶은 지표를 선택해서 list에 추가해주시면 됩니다.
CompilationMode은 컴파일 유형입니다.
Android N+(API 24+)부터는 여러 컴파일 유형을 지원한다고 합니다.
종류
Partial:
Full:
None:
Ignore:
앱은 콜드 스타트, 웜 스타트, 핫 스타트라는 세 가지 상태 중 하나에서 시작
각 상태는 앱이 사용자에게 표시되는 데 걸리는 시간에 영향을 미칩니다.
콜드 스타트에서는 앱이 처음부터 시작됩니다. 다른 두 상태에서는 시스템이 실행 중인 앱을 백그라운드에서 포그라운드로 가져와야 합니다.
공식 문서에 따르면 앱의 시작을 측정할 때는 항상 콜드 스타트를 가정하여 최적화하는 것이 좋다고한다 이렇게 하면 웜 스타트와 핫 스타트의 성능도 개선될 수 있다고 한다.
종류
CompilationMode에서 언급되었던 AOT와 JIT에 대해서는 다음 글인 BaseLine_Profile에서 설명하겠습니다.
또 다른 벤치마크 코드, ScrollDetailStoryListBenchmark
코드입니다.
이 코드는 Story의 DetailStory 화면에서의 스크롤 동작 시의 프레임 성능을 측정하기 위해 작성 하였습니다.
이번에는 스크롤의 성능을 체크하는 것이므로 startupMode를 Warm으로 주었습니다.
그리고 성능 지표로는 스크롤 동작 중의 프레임 타이밍을 측정하는 FrameTimingMetric
를 사용하였습니다.
측정 결과 입니다.
제가 Benchmark를 진행하며 겪었던 어려움과 해결과정을 설명하겠습니다.
제가 만든 앱에서는 Protobuf를 사용하여 UserData를 저장합니다. 이에는 jwt, darktheme 여부 등의 정보가 포함됩니다.
Benchmark를 진행할 때는 실제 라이브 애플리케이션과 최대한 유사한 환경에서 수행하는 것이 좋다고 해서 Benchmark 빌드에서 애플리케이션의 코드를 난독화하는 과정이 포함했습니다. 그런데, 난독화 과정에서 Protobuf의 필드 이름이 변경될 경우, 해당 필드를 런타임에서 찾을 수 없게 되어 버그가 발생할 수 있습니다.
이러한 문제 때문에 저는 처음에 UserData를 찾을 수 없다는 오류가 떠 어려움을 겪었습니다.
이러한 문제를 해결하기 위해서는 ProGuard 설정을 통해 Protobuf 메시지에 사용되는 필드들을 보존해야 합니다. 아래와 같은 설정을 사용하면 해당 문제를 해결할 수 있습니다
Protobuf는 내부적으로 리플렉션을 사용하여 메시지의 필드에 액세스합니다. 만약 난독화 도구가 이러한 필드의 이름을 변경한다면, Protobuf 라이브러리는 런타임에서 해당 필드에 액세스할 수 없게 되어 오류가 발생하게 됩니다. 따라서 위와 같은 ProGuard 설정을 통해 필드 이름이 변경되지 않도록 보호해야 합니다.
제 앱은 처음 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")
이 방법의 장점으로는
단점으로는 만약 패키지명이 변경될 경우, 이 로직 역시 수정이 필요하다는 단점이 있다고 판단됩니다.
패키지명을 자주 바꾸지 않을 것이라 예상되기 때문에, 이 방법으로 문제를 해결하기로 결정했습니다.
이것은 오류는 아니지만 구글 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()
}
이 코드는 먼저 세 개의 지역(경남, 서울, 부산)을 순차적으로 선택하는 방식으로 테스트를 진행합니다. 각 지역을 선택할 때는 드롭다운 메뉴를 클릭하여 메뉴 아이템 목록을 표시하고, 지정된 지역명에 해당하는 아이템을 찾아 클릭하는 방식으로 동작합니다. 이를 통해 테스트의 일관성을 보장하면서도 필요한 성능 테스트를 진행할 수 있습니다.
초기에는 "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를 활용하여 앱의 성능 최적화 과정에 대해 작성해보겠습니다.!!
긴 글 읽어주셔서 감사합니다.