[Android] Lazy Column 성능 약 95% 개선기 1편 with 스타카토, Benchmark & Perfetto

hxeyexn·2025년 7월 20일
3
post-thumbnail

들어가기 전

이번 시리즈는 총 2편으로 구성되어 있습니다.

1편에서는 성능 측정을 위한 설정 과정과 분석 방법을 다룹니다.
2편에서는 분석 결과를 바탕으로 LazyColumn의 성능을 개선하기 위해 시도한 내용과 그 결과를 소개할 예정입니다.

목차

  • Intro: 성능 개선을 시도하게 된 계기
  • 성능 측정에 대하여
  • MacroBenchmark 설정 방법
    • benchmark 모듈 생성
    • 🚨 만약 Firebase Analytics를 사용 중인 프로젝트라면?
    • Benchmark 실행
  • Benchmark 작성 전 알아야 할 기본 개념
    • Davilk & ART
    • CompilationMode
    • StartupMode
    • FrameTimingMetric
  • Perfetto
    • Perfetto란?
    • Perfetto를 사용해야 하는 이유
    • Perfetto 사용법
  • Outro: 다음 편 예고

Intro

스타카토의 카테고리 목록 UI가 변경되면서, UI도 수정할 겸 Compose 학습도 할 겸 LazyColumn으로 마이그레이션 했다. 하지만… dev 앱과 production 앱 모두 약간의 스크롤 버벅거림이 있었다.

Layout Inspector를 활용해 Recomposition 횟수를 확인해 보았지만, Recomposition에는 문제가 없었다.

스크롤 전 -> Recomposition X스크롤 후 -> Recomposition X

또한, 이전의 카테고리 목록은 비교적 단순했기 때문에 LazyColumn만의 문제라고 단정할 수 없었다. RecyclerView로 구현했더라도 변경된 UI를 적용했다면 비슷한 문제가 발생했을 가능성이 있다. 다만, LazyColumn의 각 아이템이 포함하고 있는 요소가 많아 렌더링 시간이 길어진 것이 원인일 수도 있다고 생각했다.

스크롤 버벅거림이 심하게 거슬릴 정도는 아니라서, 정확한 수치를 확인하지 않으면 성능 개선이 어렵다고 판단했다. 그러던 중 드로이드 나이츠에서 발표하신 분의 블로그 글을 보고 Benchmark 도구를 사용해 보기로 결정했다.




성능 측정에 대하여

성능 측정 시 주의할 점

  1. non-debuggable

    성능 측정을 하기 전 주의해야 할 점이 있다. 디버그할 수 있는(debuggable) 앱의 성능은 실제 사용자가 경험하는 앱의 성능과 크게 다르다. 디버그할 수 있는 앱은 개발 및 테스트를 위해 모든 코드가 런타임에서 해석되고 추가적인 오버헤드가 발생하기 때문에, 실제 사용자를 위한 최적화된 릴리스(Release) 버전의 앱과는 성능이 다를 수밖에 없다. 따라서 앱에 실제 성능 문제가 있는지 확인하려면 반드시 디버그 불가능한(non-debuggable) 앱으로 측정해야 한다.

  2. 실제 기기에서 성능을 측정

    에뮬레이터가 아닌 실제 기기에서 성능을 측정하고 벤치마크를 실행해야 한다. 에뮬레이터는 호스팅 머신과 시스템 리소스를 공유해 성능을 정확하게 측정할 수 없다.

  3. Recomposition

    Recomposition이 자주 일어난다고 해서 앱 속도가 무조건 느려지는 것은 아니다. 하나의 컴포저블도 기본 스레드를 너무 오래 차단하면 눈에 보이는 버벅거림이 발생한다.


성능 측정 도구

구글 공식 문서에 따르면 Jetpack Macrobenchmark를 사용하여 벤치마크를 설정하고 작성하는 것이 좋다고 한다. Macrobenchmark란 실제 사용자처럼 앱을 사용해 보며 성능을 측정하는 테스트이다. UI Test와 결이 비슷하다. 이는 테스트 코드가 앱 코드를 오염시키지 않기 때문에 신뢰할 수 있는 실제 성능 정보를 얻을 수 있다.




MacroBenchmark 설정 방법

실제 사용자처럼 앱을 사용해 보며 성능을 측정하는 테스트

benchmark 모듈 생성

Macrobenchmark를 사용하려면 프로젝트에 새로운 모듈을 추가해야 한다. 모듈 생성은 프로젝트 우 클릭 > New > Module에서 할 수 있다.

Module 메뉴를 선택하면 아래처럼 새로운 모듈 생성할 수 있는 창이 뜬다. Templates의 좌측 하단을 보면 Benchmark가 있다. Benchmark를 클릭하고 모듈의 타입을 Macrobenchmark로 선택하면 된다. 나머지는 자신의 프로젝트에 맞게 설정하면 된다.

설정을 마친 후 완료 버튼을 누르면 프로젝트에 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"
		    }
		}
}

🚨 만약 Firebase Analytics를 사용 중인 프로젝트라면?

혼자 1일 활성 사용자 187명을 만들고 싶지 않다면… 여기를 주목해 주세요.. 그게 접니다…

Firebase Analytics를 사용 중이라면 추가 설정이 필요하다. 벤치마크 모듈은 릴리스 환경에 가까운 상태로 실행되므로, 별도 설정 없이 실행하면 매번 Firebase Analytics에 신규 사용자 및 활성 사용자로 기록된다.

실제로 필자는 혼자서 1일 활성 사용자 187명을 만들어냈다... 관련 자료가 생각보다 많지 않아 이 문제를 해결하는 데 꽤 애를 먹었다. 결국 무작정 Now in Android Repository에서 Firebase Analytics 관련 파일들을 뒤지고, 해당 파일의 커밋을 따라가며 Analytics를 도입한 커밋을 찾아냈다.(집념의 한국인🇰🇷) 그리고 그 커밋이 문제를 해결하는 결정적인 실마리가 되었다.


어떻게 Benchmark에서 Firebase를 비활성화할 수 있을까?

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>

Benchmark 실행

이제 모든 설정이 끝났다. 테스트를 실행하기 전 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 테스트를 작성하기 전에 알아야 할 기본 개념들을 다뤄보겠다.




Benchmark 작성 전 알아야 할 기본 개념

Davilk & ART

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의 컴파일러를 보완하여 런타임 성능 향상 및 저장 공간 절약에 도움을 준다.

  • JIT(Just In Time) : 앱 실행 중에 코드를 즉시 컴파일해 실행 속도를 높이는 방식
  • AOT(Ahead Of Time) : 앱 설치 시 코드를 미리 컴파일해 실행 시간을 단축하는 방식

CompilationMode

Android 런타임 환경은 ART 도입 이후 JIT와 AOT를 혼합해 사용하는 구조로 발전해 왔다.

따라서 벤치마크 테스트를 설계할 때도 어떤 컴파일 방식이 적용되는지, 즉 앱이 어떤 상태에서 실행되는지를 명확히 설정하는 것이 중요하다.

이를 위해 Android에서는 CompilationMode를 제공하며, 이는 Macrobenchmark 실행 시 앱의 컴파일 상태를 지정하는 핵심 요소다. CompilationMode에는 None, Partial, Full, Ignore이 있다.

  • None: 컴파일 상태를 초기화하고 모든 것을 JIT 모드로 실행
  • Partial: Baseline Profiles 및/또는 워밍업 반복을 사용하여 앱을 사전 컴파일하고 JIT 모드에서 실행
    • 성능 개선 후 사용자가 앱을 설치할 때 성능을 확인하려면 Baseline Profiles을 사용하는 Partial() 모드를 사용
  • Full: 전체 앱 코드를 사전 컴파일하므로 JIT 모드에서 실행되는 코드가 없음
    • 코드에서 변경한 내용에만 집중하고, 앱의 컴파일 상태는 신경 쓰지 않을 때
    • JIT 모드에서 실행되는 코드로 발생할 수 있는 변동을 줄일 수 있음
    • 앱 시작에 부정적인 영향을 미칠 수 있으므로 앱 시작을 측정하는 벤치마크에는 사용 X
    • 런타임 성능 개선을 측정하는 벤치마크에만 사용
  • Ignore: 컴파일 상태를 무시
    • 개발자가 앱의 컴파일 상태를 사용자 정의한 후 Macrobenchmark에 해당 상태를 변경하지 않도록 지시

StartupMode

StartupMode은 MacrobenchmarkScope로 앱을 실행할 때 강제로 적용할 수 있는 선택적 모드이다. StartupMode에는 Cold, Warm, Hot이 있다.

  • Cold
    • 앱이 처음부터 다시 시작되는 것을 의미
    • 시작 전까지 시스템 프로세스가 앱 프로세스를 생성한다는 것을 의미
    • 기기가 부팅된 후 앱이 처음 실행되거나 시스템이 앱을 종료한 후 앱이 다시 실행되는 경우 등에 발생
  • Warm
    • 프로세스가 남아 있거나 상태 복원이 가능한 상황에서 액티비티를 다시 시작해야 하는 경우
    • cold start보다 가볍고 hot start보다는 무거운 시작 방식
  • Hot
    • 앱과 액티비티가 메모리에 남아 있어 초기화 없이 바로 화면을 다시 띄우는 시작 방식
    • 하지만, 일부 객체가 정리됐을 경우에는 다시 생성해야 함

Metrics

StartupTimingMetric

StartupTimingMetric은 앱 시작 소요 시간과 관련된 지표들을 수집한다.

  • timeToInitialDisplayMs
    • 시스템이 launch intent를 수신한 시점부터 대상 액티피비의 첫 번째 프레임을 렌더링하는 시점까지의 시간
  • timeToFullDisplayMs
    - 시스템이 launch intent를 수신한 시점부터 앱이 reportFullyDrawn() 메서드를 사용하여 완전히 렌더링 되었다고 보고하는 시점까지의 시간
    - 이 측정치는 reportFullyDrawn() 호출 후 또는 해당 호출을 포함하는 첫 번째 프레임의 렌더링이 완료될 때 중단

FrameTimingMetric

FrameTimingMetric은 50번째, 90번째, 95번째, 99번째 백분위수에서 프레임 시간을 밀리초 단위(frameDurationCpuMs)로 출력한다. Android 12(API 수준 31) 이상에서는 프레임이 제한(frameOverrunMs)을 초과한 시간도 반환한다.

  • frameDurationCpuMs
    • 프레임을 렌더링하는 데 드는 시간을 알려줌
    • 짧을수록 좋음
  • frameOverrunMs
    - GPU 작업을 비롯해 프레임 제한을 초과한 시간을 알려줌
    - 이 값은 양수일 수도, 음수일 수도 있음
    - 음수가 좋음 → 프레임을 생성하는 데 시간이 남았다는 뜻이기 때문

TraceSectionMetric

TraceSectionMetric은 제공된 sectionName과 일치하는 추적 섹션이 발생하는 횟수와 해당 섹션이 소요되는 시간을 캡처한다. 시간에 대해서는 밀리초 단위의 최솟값, 중간값, 최댓값을 출력한다.




Perfetto

Perfetto란?

Perfetto는 추적 기능을 활용하여 복잡한 시스템의 동작을 이해하고, 클라이언트나 임베디드 시스템에서 발생하는 기능 및 성능 문제의 근본 원인을 파악할 수 있도록 도와주는 오픈소스 SDK, 데몬 및 도구 모음이다.

SQL 쿼리와 기타 기능을 통해 시스템에서 실행 중인 모든 프로세스에 대한 더 심층적인 분석 기능을 제공한다.


Perfetto를 사용해야 하는 이유

Android Studio 프로파일러는 앱 프로세스를 빠르게 확인하는 데는 좋지만, 시스템 전체를 분석하기에는 한계가 있다. 동일한 조건에서 벤치마크를 여러 번 실행했을 때 오차가 있었고, 정확한 성능 측정을 위해서는 Perfetto처럼 시스템 전체를 추적할 수 있는 도구가 필요했다.


Perfetto 사용법

데이터 수집 및 trace 파일 추출

우선 실제 기기에서 시스템 추적 기록을 활성화한 뒤, 측정하고자 하는 벤치마크 테스트를 실행한다. 테스트가 완료되면 시스템 추적 기록을 종료해 데이터를 수집한다.

수집된 trace는 터미널에서 adb 명령어를 사용해 추출할 수 있다. 먼저 adb devices 명령어로 연결된 디바이스 목록을 확인한 뒤, 디바이스 수에 따라 적절한 추출 명령어를 입력하면 된다.

  • 한 대: adb pull /data/local/traces/ .
  • 여러 대: adb -s 기기명 pull /data/local/traces/ .

trace 파일 Perfetto UI에서 분석하기(SQL)

추출된 trace 파일은 Perfetto UI에서 분석할 수 있으며, 내부 데이터를 SQL로 조회하는 기능도 제공된다. 이를 통해 특정 스레드의 실행 시간, 프레임 렌더링 시간 등 상세한 성능 데이터를 SQL 쿼리로 직접 확인하고 분석할 수 있다.




Outro

다음 편 예고

1편에서는 성능 측정을 위한 설정 과정과 분석 방법을 다뤘다.
2편에서는 분석 결과를 바탕으로 LazyColumn의 성능을 개선하기 위해 시도한 내용과 그 결과를 소개할 예정이다. ❗️2편도 많관부❗️

2편 목차

  • 기존 Lazy Column 성능 이슈 파악
  • 유의미했던 성능 개선 시도들
    • Icon SVG를 XML이 아닌 Kotlin 파일로 변경
    • ConstraintLayout을 Row/Column + Slot 패턴으로 변경
  • 성능 개선 결과
  • 성능 개선을 하면서 든 고민
    • Baseline Profile 도입 여부
    • Benchmark를 위한 CategoriesBenchmarkActivity의 위치
    • 참여자들 LazyRow -> Row 변경 여부



참고 자료

profile
Android Developer

0개의 댓글