들어가기 전
이번 시리즈는 총 2편으로 구성되어 있습니다.
1편에서는 성능 측정을 위한 설정 과정과 분석 방법을 다룹니다.
2편에서는 분석 결과를 바탕으로 LazyColumn의 성능을 개선하기 위해 시도한 내용과 그 결과를 소개할 예정입니다.
목차
- Intro: 성능 개선을 시도하게 된 계기
- 성능 측정에 대하여
- MacroBenchmark 설정 방법
- benchmark 모듈 생성
- 🚨 만약 Firebase Analytics를 사용 중인 프로젝트라면?
- Benchmark 실행
- Benchmark 작성 전 알아야 할 기본 개념
- Davilk & ART
- CompilationMode
- StartupMode
- FrameTimingMetric
- Perfetto
- Perfetto란?
- Perfetto를 사용해야 하는 이유
- Perfetto 사용법
- Outro: 다음 편 예고
스타카토의 카테고리 목록 UI가 변경되면서, UI도 수정할 겸 Compose 학습도 할 겸 LazyColumn으로 마이그레이션 했다. 하지만… dev 앱과 production 앱 모두 약간의 스크롤 버벅거림이 있었다.
Layout Inspector를 활용해 Recomposition 횟수를 확인해 보았지만, Recomposition에는 문제가 없었다.
스크롤 전 -> Recomposition X | 스크롤 후 -> Recomposition X |
---|---|
![]() | ![]() |
또한, 이전의 카테고리 목록은 비교적 단순했기 때문에 LazyColumn만의 문제라고 단정할 수 없었다. RecyclerView로 구현했더라도 변경된 UI를 적용했다면 비슷한 문제가 발생했을 가능성이 있다. 다만, LazyColumn의 각 아이템이 포함하고 있는 요소가 많아 렌더링 시간이 길어진 것이 원인일 수도 있다고 생각했다.
스크롤 버벅거림이 심하게 거슬릴 정도는 아니라서, 정확한 수치를 확인하지 않으면 성능 개선이 어렵다고 판단했다. 그러던 중 드로이드 나이츠에서 발표하신 분의 블로그 글을 보고 Benchmark 도구를 사용해 보기로 결정했다.
non-debuggable
성능 측정을 하기 전 주의해야 할 점이 있다. 디버그할 수 있는(debuggable) 앱의 성능은 실제 사용자가 경험하는 앱의 성능과 크게 다르다. 디버그할 수 있는 앱은 개발 및 테스트를 위해 모든 코드가 런타임에서 해석되고 추가적인 오버헤드가 발생하기 때문에, 실제 사용자를 위한 최적화된 릴리스(Release) 버전의 앱과는 성능이 다를 수밖에 없다. 따라서 앱에 실제 성능 문제가 있는지 확인하려면 반드시 디버그 불가능한(non-debuggable) 앱으로 측정해야 한다.
실제 기기에서 성능을 측정
에뮬레이터가 아닌 실제 기기에서 성능을 측정하고 벤치마크를 실행해야 한다. 에뮬레이터는 호스팅 머신과 시스템 리소스를 공유해 성능을 정확하게 측정할 수 없다.
Recomposition
Recomposition이 자주 일어난다고 해서 앱 속도가 무조건 느려지는 것은 아니다. 하나의 컴포저블도 기본 스레드를 너무 오래 차단하면 눈에 보이는 버벅거림이 발생한다.
구글 공식 문서에 따르면 Jetpack Macrobenchmark를 사용하여 벤치마크를 설정하고 작성하는 것이 좋다고 한다. Macrobenchmark란 실제 사용자처럼 앱을 사용해 보며 성능을 측정하는 테스트이다. UI Test와 결이 비슷하다. 이는 테스트 코드가 앱 코드를 오염시키지 않기 때문에 신뢰할 수 있는 실제 성능 정보를 얻을 수 있다.
실제 사용자처럼 앱을 사용해 보며 성능을 측정하는 테스트
Macrobenchmark를 사용하려면 프로젝트에 새로운 모듈을 추가해야 한다. 모듈 생성은 프로젝트 우 클릭 > New > Module
에서 할 수 있다.
설정을 마친 후 완료 버튼을 누르면 프로젝트에 Macrobenchmark가 생성된다. 동시에 build.gradle.kts(:app)와 AndroidManifest.kt(:app)에 아래와 같은 변화가 생긴다.
build.gradle.kts(:app)
android {
buildTypes {
create("benchmark") {
// debuggable = false인 환경에서 실행해야 정확한 측정 가능
initWith(buildTypes.getByName("release"))
// 릴리즈 키로 서명하면 local에서 실행이 어려움
// macrobenchmark는 release 빌드 수준이지만 로컬 테스트를 위해 debug 서명 필요함
signingConfig = signingConfigs.getByName("debug")
// benchmark라는 빌드 타입에 대해 리소스/플래그/매니페스트가 없으면 release 빌드의 것을 대신 사용
matchingFallbacks += listOf("release")
// debuggable = false인 환경에서 실행해야 정확한 측정 가능
isDebuggable = false
}
}
}
Wizard가 생성한 내용을 살펴보면, isDebuggable 플래그가 비활성화되어 있고, 릴리스 빌드와 유사한 설정이 적용되어 있는 것을 확인할 수 있다. 릴리스 buildType과의 가장 큰 차이는 디버그 모드로 설정된 signingConfig이다. 이는 프로덕션 keystore 없이 로컬에서 앱을 빌드하기 위한 설정이다. 한편, macrobenchmark 모듈에서는 debuggable을 true로 설정해도 문제 되지 않는다.
Android Codelab에 따르면, 앱 모듈에서 릴리스 성능을 보장하려면 debuggable
을 false로 설정하거나 initWith(buildTypes.release)
를 사용해야 한다. Wizard는 이 두 설정을 모두 생성해 준다. 필자는 혹시 모를 상황에 대비해 일부러 지우지 않았다.
AndroidManifest.xml(:app)
<application>
...
<profileable
android:shell="true"
tools:targetApi="29" />
...
</application>
build.gradle.kts(:app)
에서 디버그 가능 플래그(isDebuggable)를 비활성화했기 때문에, wizard는 AndroidManifest.xml(:app)
파일에 <profileable>
태그를 추가하여 벤치마크가 앱을 릴리스 성능으로 프로파일링할 수 있도록 한다.
운이 좋다면 wizard가 설정해 준 그대로 benchmark를 손쉽게 실행할 수 있다. 하지만 기존에 진행 중인 프로젝트라면 이야기가 다르다. wizard가 생성한 build.gradle.kts(:app)
를 프로젝트 환경에 맞게 수정해줘야 한다. 필자는 프로젝트 환경에 맞춰 아래와 같이 수정했다.
build.gradle.kts(:app)
android {
buildTypes {
create("benchmark") {
initWith(buildTypes.getByName("release"))
signingConfig = signingConfigs.getByName("debug")
matchingFallbacks += listOf("release")
isDebuggable = false
// 추가
buildConfigField("String", "BASE_URL", "${localProperties["dev_base_url"]}")
manifestPlaceholders["appName"] = "@string/app_name_benchmark"
}
}
}
혼자 1일 활성 사용자 187명을 만들고 싶지 않다면… 여기를 주목해 주세요.. 그게 접니다…
Firebase Analytics를 사용 중이라면 추가 설정이 필요하다. 벤치마크 모듈은 릴리스 환경에 가까운 상태로 실행되므로, 별도 설정 없이 실행하면 매번 Firebase Analytics에 신규 사용자 및 활성 사용자로 기록된다.
실제로 필자는 혼자서 1일 활성 사용자 187명을 만들어냈다... 관련 자료가 생각보다 많지 않아 이 문제를 해결하는 데 꽤 애를 먹었다. 결국 무작정 Now in Android Repository에서 Firebase Analytics 관련 파일들을 뒤지고, 해당 파일의 커밋을 따라가며 Analytics를 도입한 커밋을 찾아냈다.(집념의 한국인🇰🇷) 그리고 그 커밋이 문제를 해결하는 결정적인 실마리가 되었다.
1. Firebase Console에서 기존 프로젝트에 벤치마크용 앱 추가
Firebase Console의 기존 프로젝트에 벤치마크용 앱을 추가한다. 앱을 추가하면 벤치마크용 설정이 포함된 새로운 google-services.json
파일이 생성된다. 이후 기존에 사용 중이던 파일을 새로 발급된 버전으로 교체해 준다.
2. /app/src/릴리스경로에
AndroidManifest.xml` 추가 및 Firebase Analytics의 수집을 활성화
그다음 /app/src
경로에 릴리스와 동일한 이름의 패키지를 생성한 뒤, 그 안에 AndroidManifest.xml
을 추가한다. 이 AndroidManifest.xml
은 아래처럼 구성하면 된다.
해당 설정은 릴리스 빌드에서 Firebase Analytics의 수집 비활성화 모드를 해제하겠다는 의미다. 즉, 릴리스 빌드에서는 Firebase Analytics를 활성화하겠다는 뜻이다.
/app/src/릴리스/AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
<!-- 릴리스 빌드에서 Firebase Analytics 수집 활성화-->
<meta-data
android:name="firebase_analytics_collection_deactivated"
android:value="false"
tools:replace="android:value" />
</application>
</manifest>
3. /app/src/main/AndroidManifest.xml
에 Firebase Analytics의 수집을 비활성화
Firebase는 초기화 코드가 없어도, google-services.json
파일과 관련 의존성이 설정되어 있다면 자동으로 인스턴스를 생성한다.
이 인스턴스가 앱 실행과 동시에 활성화되면, 벤치마크 실행 시 활성 사용자 데이터가 Firebase에 기록된다. 또한 벤치마크를 실행할 때마다 first_open
이벤트 등이 수집되어, 실제 사용자 행동을 분석하기 위한 데이터가 벤치마크용 데이터로 오염될 수 있다.
따라서 앱 실행 시점에 Firebase Analytics 수집을 차단하려면, /app/src/main/AndroidManifest.xml
에 명시적으로 수집 비활성화 설정을 추가해야 한다. 이렇게 해야 Firebase에 불필요한 로깅이 남는 것을 완전히 차단할 수 있다.
/app/src/main/AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
...
<!-- Firebase Analytics 수집 비활성화-->
<meta-data
android:name="firebase_analytics_collection_deactivated"
android:value="true" />
</application>
</manifest>
이제 모든 설정이 끝났다. 테스트를 실행하기 전 app 모듈과 benchmark 모듈의 Build Variant를 benchmark로 변경해준다. 그런 다음 Wizard가 생성한 ExampleStartupBenchmark
테스트를 실행하면, 벤치마크가 정상적으로 동작하는지 확인할 수 있다.
// 자동으로 생성되는 ExampleStartupBenchmark
@RunWith(AndroidJUnit4::class)
class ExampleStartupBenchmark {
@get:Rule
val benchmarkRule = MacrobenchmarkRule()
@Test
fun startup() = benchmarkRule.measureRepeated(
packageName = "com.on.staccato",
metrics = listOf(StartupTimingMetric()),
iterations = 5,
startupMode = StartupMode.COLD
) {
pressHome()
startActivityAndWait()
}
}
위 코드를 실행했을 때 테스트가 성공한다면 Benchmark 설정은 무사히 끝난 것이다!! 🫠🫠🫠
지금부터는 Benchmark 테스트를 작성하기 전에 알아야 할 기본 개념들을 다뤄보겠다.
Davilk
Davilk은 안드로이드에서 앱과 일부 시스템 서비스가 사용하는 관리형 런타임이다. JIT(Just In Time) 방식을 사용한다.
ART(Android Runtime)
ART는 Android 4.4에서 Dalvik의 대체 런타임 환경으로 처음 등장했으며, Android 5.0부터는 Dalvik을 완전히 대체해 기본 런타임 환경이 되었다. ART는 앱 성능을 향상하기 위해 AOT(Ahead Of Time) 컴파일을 도입했다. 또한 설치 시 Dalvik보다 더 엄격한 검증 과정을 수행한다.
Android 7.0부터는 ART를 순수 AOT에서 하이브리드 JIT/AOT 솔루션으로 전환되었다. JIT 컴파일러는 ART의 컴파일러를 보완하여 런타임 성능 향상 및 저장 공간 절약에 도움을 준다.
Android 런타임 환경은 ART 도입 이후 JIT와 AOT를 혼합해 사용하는 구조로 발전해 왔다.
따라서 벤치마크 테스트를 설계할 때도 어떤 컴파일 방식이 적용되는지, 즉 앱이 어떤 상태에서 실행되는지를 명확히 설정하는 것이 중요하다.
이를 위해 Android에서는 CompilationMode
를 제공하며, 이는 Macrobenchmark 실행 시 앱의 컴파일 상태를 지정하는 핵심 요소다. CompilationMode에는 None
, Partial
, Full
, Ignore
이 있다.
StartupMode은 MacrobenchmarkScope로 앱을 실행할 때 강제로 적용할 수 있는 선택적 모드이다. StartupMode에는 Cold
, Warm
, Hot
이 있다.
StartupTimingMetric
은 앱 시작 소요 시간과 관련된 지표들을 수집한다.
FrameTimingMetric
은 50번째, 90번째, 95번째, 99번째 백분위수에서 프레임 시간을 밀리초 단위(frameDurationCpuMs)로 출력한다. Android 12(API 수준 31) 이상에서는 프레임이 제한(frameOverrunMs)을 초과한 시간도 반환한다.
TraceSectionMetric
은 제공된 sectionName과 일치하는 추적 섹션이 발생하는 횟수와 해당 섹션이 소요되는 시간을 캡처한다. 시간에 대해서는 밀리초 단위의 최솟값, 중간값, 최댓값을 출력한다.
Perfetto는 추적 기능을 활용하여 복잡한 시스템의 동작을 이해하고, 클라이언트나 임베디드 시스템에서 발생하는 기능 및 성능 문제의 근본 원인을 파악할 수 있도록 도와주는 오픈소스 SDK, 데몬 및 도구 모음이다.
SQL 쿼리와 기타 기능을 통해 시스템에서 실행 중인 모든 프로세스에 대한 더 심층적인 분석 기능을 제공한다.
Android Studio 프로파일러는 앱 프로세스를 빠르게 확인하는 데는 좋지만, 시스템 전체를 분석하기에는 한계가 있다. 동일한 조건에서 벤치마크를 여러 번 실행했을 때 오차가 있었고, 정확한 성능 측정을 위해서는 Perfetto처럼 시스템 전체를 추적할 수 있는 도구가 필요했다.
데이터 수집 및 trace 파일 추출
우선 실제 기기에서 시스템 추적 기록을 활성화한 뒤, 측정하고자 하는 벤치마크 테스트를 실행한다. 테스트가 완료되면 시스템 추적 기록을 종료해 데이터를 수집한다.
수집된 trace는 터미널에서 adb 명령어를 사용해 추출할 수 있다. 먼저 adb devices
명령어로 연결된 디바이스 목록을 확인한 뒤, 디바이스 수에 따라 적절한 추출 명령어를 입력하면 된다.
trace 파일 Perfetto UI에서 분석하기(SQL)
추출된 trace 파일은 Perfetto UI에서 분석할 수 있으며, 내부 데이터를 SQL로 조회하는 기능도 제공된다. 이를 통해 특정 스레드의 실행 시간, 프레임 렌더링 시간 등 상세한 성능 데이터를 SQL 쿼리로 직접 확인하고 분석할 수 있다.
1편에서는 성능 측정을 위한 설정 과정과 분석 방법을 다뤘다.
2편에서는 분석 결과를 바탕으로 LazyColumn의 성능을 개선하기 위해 시도한 내용과 그 결과를 소개할 예정이다. ❗️2편도 많관부❗️
2편 목차
- 기존 Lazy Column 성능 이슈 파악
- 유의미했던 성능 개선 시도들
- Icon SVG를 XML이 아닌 Kotlin 파일로 변경
- ConstraintLayout을 Row/Column + Slot 패턴으로 변경
- 성능 개선 결과
- 성능 개선을 하면서 든 고민
- Baseline Profile 도입 여부
- Benchmark를 위한 CategoriesBenchmarkActivity의 위치
- 참여자들 LazyRow -> Row 변경 여부