Release 빌드와 Debug 빌드에서의 Compose 성능 차이 Deep Dive

송규빈·2025년 2월 8일
1
post-thumbnail

Release 빌드와 Debug 빌드에서의 Compose 성능 차이

Compose 개발을 하다보면 Release와 Debug에서의 성능 차이를 꽤나 심각하게 느낄 수 있다.

여러 커뮤니티에서 R8 때문이다라는 내용을 많이 봤는데 구체적으로 어떻게, 왜 Release 빌드에서 성능이 좋아지는가가 답답해서 알아봤다.

이 주제에 대해 다룬 블로그 글과 공식 문서를 기반으로 내 방식대로 좀 더 뜯어보았다.

우선, Compose에서의 성능 최적화를 하려면?

먼저 공식 문서를 기반으로 정리해본다.

주요 개념

단계

컴포지션, 레이아웃, 그리기 단계를 이해하는 것은 Compose가 UI를 업데이트 하는 방식을 최적화하는 데 중요하다.

→ Compose의 동작 방식을 제대로 알고 사용하라는 말 같다.

Baseline Profiles

필수 코드를 사전 컴파일하여 앱을 더 빠르게 실행하고 더 원활한 상호작용을 지원한다.

→ 포함된 코드 경로 해석과 JIT(Just-in-time) 컴파일 단계를 피하여 최초 실행 후 코드 실행 속도가 약 30% 향상된다.

Stability

불필요한 리컴포지션을 더 효율적으로 skip하여 성능을 개선한다.

→ 상태를 Stable하게 만들어야 한다.

권장사항

비용이 많이 드는 계산 피하기

remember를 사용하여 비용이 많이 드는 계산 결과를 캐싱한다.

Help Lazy Layout

LazyXXX(지연 레이아웃)에 안정적인 키를 제공하여 불필요한 리컴포지션을 최소화한다.

불필요한 리컴포지션 제한

derivedStateOf를 사용하여 상태를 빠르게 변경할 때 리컴포지션을 제한한다.

람다 Modifier를 사용하여 상태 변경

자주 변경되는 상태 변수에는 Modifier.offset{ ... }과 같은 람다 기반 Modifier를 사용한다.

backwards write 피하기

컴포저블에서 이미 읽은 상태에 write하지 않는다.

@Composable
fun BadComposable() {
    var count by remember { mutableStateOf(0) }

    // Causes recomposition on click
    Button(onClick = { count++ }, Modifier.wrapContentSize()) {
        Text("Recompose")
    }

    Text("$count")
    count++ // Backwards write, writing to state after it has been read</b>
}

이제 본격적으로 주제에 대한 내용으로 들어가보자

공식 문서에서는 R8로 릴리즈 모드에서 빌드하고, R8 컴파일러로 최적화 및 축소를 활성화하여 성능 좋고 효율적인 릴리즈 빌드를 보장하라고 한다.

Compose로 작성된 앱을 디버그 모드에서 빌드했을 때 View 기반 코드보다 더 느리다는 것을 체감할 수 있다.

위 그래프를 보면 알 수 있듯 첫 번째 프레임을 시작하고 렌더링하는 데 걸리는 시간(cold start)이 디버그에서 확연하게 차이난다는 것을 볼 수 있다.

비단, Compose 뿐만은 아니라 View 시스템도 마찬가지이기는 하다.

여기서 봐야할 것은 모든 디버그용 코드는 Release 빌드보다 느리게 실행된다는 것이고, Compose에서는 그 차이가 훨씬 더 크다는 것이다.

왜 그럴까?

Compose는 Unbundled이다.

여기서 bundle은 우리가 배포할 때 사용되는 AAB(Android App Bundle)을 의미한다.

Compose는 번들로 제공되지 않는다고 앱에서 라이브러리를 정적으로 연결한다.

즉, Compose를 라이브러리로 제공하고, 시스템 프레임워크에서 Unbundled된다.

그러나 뷰 시스템은 Android OS의 일부로 시스템 프레임워크와 번들로 제공된다.

반면 뷰 시스템은 Android의 일부분으로 볼 수 있고, 이미 사용자 기기에 있고 사전에 기계 코드로 컴파일되어 로드되고 실행된다.

즉 Compose는 OS에 기본 탑재되어 있지 않기 때문에, 사용자가 앱을 설치하면 그 앱 내부에 Compose가 함께 들어있는 것이고

뷰 시스템은 Android OS에 이미 포함되어 있어서 별도로 앱에 넣을 필요가 없다.

성능 측면에서는 위의 이유 때문에 Compose가 비효율적이기는 하지만,

Compose는 각 앱에 독립적으로 포함되어 제공되기 때문에 개발자들은 Compose의 업데이트나 변경을 앱 단위로 관리할 수 있다.

반면 뷰 시스템은 OS 업데이트와 함께 변경되므로 앱 개발자가 직접 관리하기 어렵다.

(안드로이드 스튜디오 lint 등을 통해 OS 별로 버전 분기 처리를 많이 해봤을 것이다.)

라이브러리로 Compose를 배포하는 데는 몇가지 단점이 있다.

위에서도 계속 말했듯 성능 문제이다.

앱을 debuggable로 빌드하는 경우 앱의 모든 코드를 해석해야 하며 Compose의 모든 라이브러리 코드도 포함된다.

이는 기계어 코드를 바로 실행하는 대신, ART(Android RunTime)이 dex 코드를 읽어 해석하며 실행한다는 것을 의미한다다.

이러한 과정을 중간 단계라고 생각할 수 있는데, 해석된 코드는 컴파일된 코드에 비해 느히다.
왜냐하면 Android 런타임이 추가 작업을 수행해야 하기 때문이다.

더불어, ART는 자주 실행되는 코드를 바로 기계어로 변환하여 매번 해석되지 않도록 하는 Just In Time 컴파일(JIT)을 수행한다.

이 과정 역시 시간이 걸리고 CPU 부하를 증가시키며, 시스템 트레이스를 통해 이러한 추가 작업을 확인할 수 있다.

그러나 뷰 시스템의 대부분의 코드는 이미 릴리즈에서 빌드되고 컴파일되어 Android OS와 함께 제공되었기 때문에 해석 모드에서 실행되지 않는다.

debuggable로 빌드된 뷰 시스템 기반 앱 코드는 항상 릴리즈로 빌드되고 최적화된 시스템 코드를 참조한다.

위 내용에 대해 앱 빌드 과정을 잘 알지 못하면 체감이 안 올 수 있기 때문에 정리해본다.

앱 빌드 및 실행 과정 설명

1. 코드 컴파일과 Dex 생성

Android Studio에서 앱을 빌드하면, Gradle이 Kotlin/Java 소스 코드를 컴파일하여 바이트코드(일반적으로 .class)로 만든 후,

이 바이트코드를 Android에서 실행 가능한 dex(Dalvik Executable) 파일로 변환한다.

(Compose와 같은 라이브러리도 앱에 포함되므로, 해당 라이브러리의 코드 역시 dex 파일에 포함된다.

2. 앱 패키징

생성된 dex 파일과 리소스, 매니페스트 파일 등이 APK(or AAB) 파일로 패키징된다.

이 파일에는 Compose를 비롯한 모든 라이브러리와 앱 코드가 포함되어 있다.

3. 디버그 빌드의 실행: Interpretation

디버그 모드로 빌드된 앱은 디버깅과 빠른 개발 주기를 위해 일부 최적화 단계를 생략한다.

이 경우, 앱의 모든 코드(Compose 라이브러리 코드 포함)는 ART(Android RunTime)에 의해 해석(interpretation)되어 실행된다.

해석 단계: ART가 dex 파일의 바이트코드를 한 줄씩 읽어 실행하는 과정으로, 바로 기계어로 변환되어 실행되는 컴파일된 코드보다 실행 속도가 느리다.

4. 실행 중 최적화: Just In Time(JIT) 컴파일

ART는 앱이 실행되는 동안, 자주 사용되는 Hot 코드를 감지하는데, 이러한 코드는 JIT 컴파일을 통해 실시간으로 기계어 코드로 변환되어, 다음부터는 해석 대신 네이티브 코드로 빠르게 실행된다.

이 최적화 과정은 실행 중에 추가적인 CPU 부하와 시간이 소요된다.

5. 성능 관점의 차이(디버그 vs 릴리즈)

  • 디버그 빌드: 모든 코드가 해석되며, JIT 컴파일로 일부 코드가 최적화되더라도 해석 오버헤드와 JIT 과정 때문에 성능이 낮아질 수 있다.
  • 릴리즈 빌드: 앱 배포 시에는 AOT(사전 컴파일)나 Baseline Profiles 최적화 등을 통해 실행 전에 미리 기계어 코드로 변환함으로써,
    해석 오버헤드를 크게 줄이고 최적의 성능을 낼 수 있다.

하지만, RecyclerView도 라이브러리로 제공되는데 왜 같은 영향을 받지 않는건가?

RecyclerView와 다른 AndroidX View 와 같은 뷰 라이브러리의 경우, 디버그 모드에서 해석되어 실행되는 코드는 극히 입루에 불과하기 때문이다.

RecyclerView와 관련된 뷰의 재사용 및 재활용을 관리하는 특정 코드만 디버그 모드로 실행되고 해석되지만, 그 아래에서 작동하는 뷰 스택 전체는 여전히 시스템 프레임워크의 코드이다.

그래서 뷰 시스템과 Compose에 대해 정리하면

기존의 뷰 관련 라이브러리들은 결국 Android 시스템 프레임워크의 일부로서, 미리 컴파일된 최적화된 코드로 동작하게 된다.

그러나 Compose는 전체 UI 계층이 디버그 가능한 코드로 실행되므로, 릴리즈 빌드와 디버그 빌드에서의 성능 차이가 훨씬 더 크다.

해석 모드로 실행되는 코드 외에도, 로드 시간에도 영향이 있다.

Android는 뷰 시스템과 같은 공통 자원을 미리 메모리에 올려둠으로써 여러 앱에서 재사용하지만, Compose의 경우 앱 시작 시 Compose와 관련된 모든 클래스들을 새로 메모리에 로드해야 하기 때문에 초기 로딩 시간이 더 길어질 수 있다.

앱을 릴리즈 모드로 컴파일한다고 해서 뷰 시스템과 비교 시 성능 저하 문제가 완전히 해결되지는 않는다.

릴리즈 모드에서는 디버깅 관련 오버헤드가 줄어들어 ART가 코드를 직접 기계어로 실행할 수 있지만, ART는 처음에는 모든 코드를 해석 모드로 실행한 후에 점진적으로 중요한 부분을 컴파일(JIT:Just-In-Time)한다.

그래서 앱을 처음 시행할 때는 약간의 지연이나 끊김 현상이 발생하다가, 이후에는 최적화된 코드로 실행되어 성능이 개선되는 것을 볼 수 있다.

그렇다고 해결 방법이 없는 것은 아니다.

Baseline Profile과 R8로 해결할 수 있다.(자세한 내용은 뒤에서 더 다루도록 한다.)

Compose에 포함된 Baseline Profile은 Compose 라이브러리의 대부분 클래스를 애플리케이션 시작 시 미리 로드하도록, 그리고 대부분의 Compose 라이브러리 메서드를 인터프리터 없이 바로 실행할 수 있도록 컴파일 대상으로 지정한다.

AndroidStudio에서 실행할 때는 Baseline Profile이 사용되지 않기 때문에, 평소 개발 중에는 그 효과를 직접 확인할 수는 없다.

(이를 제대로 테스트하려면 Macrobenchmark를 사용할 수 있다.)

느린 성능에 기여하는 다른 요인들

Compose가 라이브러리 형태로 제공되는 것이 디버그와 릴리즈 빌드 간 성능 차이의 가장 큰 원인이지만,

그 외에도 성능에 상당한 영향을 주는 다른 요인들이 있다.

Live Literals

앱을 디버그 버전으로 빌드할 때 Android Studio는 Compose 컴파일러에 특정 플래그를 전달한다.

이 플래그는 개발 도구 기능인 Live Literals를 활성화하기 위해, 약간 더 비용이 드는 코드를 생성하게 한다.

즉, 생성된 코드의 모든 상수 리터럴을 단순한 상수가 아니라 getter 함수로 변환한다.

이러한 방식은 Android Studio가 런타임에 리터럴 값을 교체할 수 있도록 하여, 개발 환경에서 더 부드러운 경험을 제공한다.

Kotlin, R8, 그리고 ART는 상수를 대상으로 여러 수준의 마이크로 최적화를 적용하여 특정 코드 패턴의 실행하는데, 상수를 getter 함수로 변환하면서 이러한 최적화가 불가능해진다.

앱을 릴리즈 모드로 빌드하면 이러한 최적화 방해 현상이 발생하지 않는다.

Compose 컴파일러는 recomposition 중에 컴포저블 함수들을 건너뛸 수 있는지 판단하기 위해 각 컴포저블의 안정성을 결정한다.

이 과정의 일환으로, 컴파일러는 컴포저블에 전달된 매개변수가 정적인지 동적인지를 검사한다.

Live Literals 기능이 활성화되면, 많은 매개변수가 다른 리터럴로 교체될 수 있도록 동적으로 변환된다.

반면 릴리즈 모드에서는 이런 변화가 없으므로, 디버그 빌드의 코드가 릴리즈 빌드의 동일한 코드보다 recomposition 중 더 많은 작업을 수행한다.

여기서 말하는 상수가 getter 함수로 변환된다는 것은

const val로 선언된 상수 뿐 아니라 16.dp, “Hello World”, Color.Red와 같은 코드 내의 상수 리터럴이 getter 함수로 변환되면서 런타임에 이 값들을 동적으로 수정할 수 있게 해준다는 것이다.

LiveEdit

LiveEdit 역시 Live Literals와 유사하게 활성화되면 성능 저하에 영향을 미친다.

하지만 Live Edit은 기능이 비활성화되어 있다면 성능 저하가 없다. 반면, Live Literals는 디버그 플래그에 묶여 있기 때문에 항상 성능 저하에 영향을 준다.

R8

R8은 코드 축소 및 최적화 도구로, 릴리즈 빌드 시 자동으로 적용된다.

불필요한 코드가 제거되고, 실행 속도를 높일 수 있는 여러 최적화가 진행된다.

Compose는 R8 최적화로부터도 엄청난 이점을 얻는다.

앞서 언급한 바와 같이, R8을 기본 구성으로 추가하면 앱의 시작 성능이 약 75% 개선되고, 프레임 렌더링 성능은 약 60% 향상된다.

R8은 다양한 최적화를 수행하는데, 그 중에서도 Compose 코드에 가장 큰 영향을 주는 몇가지들이 있다.

람다 그룹화(Lambda Grouping)

Compose는 람다를 API 디자인의 핵심 요소로 적극 활용한다. 컴포저블에서는 후행 람다를 사용해 의미적으로 중첩을 표현하고,

많은 API가 람다를 주요 진입점으로 사용한다.

이는 Java로 작성된 View 프레임워크와는 큰 차이가 있는데, Java 기반 View에서는 람다 기반 API가 매우 드물고, 클래스 수를 줄이기 위해 일반적으로 사용을 피한다.

람다를 사용하면, 해당 람다를 구현하기 위해 익명 클래스가 생성되는데, 이 클래스는 여러 Function 인터페이스 중 하나를 구현하며 invoke 메서드를 포함한다.

// 변환 전
val myLambda = {
   println(“Hello world”)
}
// 변환 후
final class myLambda : Function0 {
   fun invoke() {
      println(“Hello world”)
   }
}

컴파일러는 람다를 위와 같이 변환한다.

적당한 규모의 Compose 애플리케이션에서는 비교적 적은 코드에 대해 수많은 익명 람다 클래스가 생성된다.

게다가 Compose 컴파일러는 재시작 가능한 컴포저블 함수마다 구현 세부 사항으로 추가 람다들을 생성한다.

람다는 여러 가지 이유로 비용이 많이 들 수 있는데, 한 가지 중요한 점은 람다가 새로운 고유 클래스라는 점이다.

클래스가 처음 로드될 때 성능 패널티가 발생하기 때문이다. Compose가 이를 해결하기 위해 사용하는 방법 중 하나가 바로 R8의 람다 그룹화 최적화라는 것이다.

이 최적화는 동일한 시그니처를 가진 람다들을 하나의 단일 람다 클래스로 그룹화하며, 이 클래스의 구현은 원래 그룹화된 각 클래스의 구현을 switch 문으로 선택하는 방식이다.

그 결과, 런타임에 로드해야 하는 람다 클래스의 수가 크게 줄어들어 성능에 좋은 영향을 준다.

소스 정보 생략(Omitting Source Information)

Compose가 컴포저블 함수 내부에 생성하는 코드 중, 모든 컴포저블 함수(람다를 포함) 최상단에 sourceInformation 함수를 호출하는 코드가 있다.

이 함수는 문자열을 인자로 받는데, 이 문자열은 지연(lazy) 평가되어 Layout Inspector와 같은 개발 도구가 컴포저블 함수 호출의 소스 위치를 계산하는 데 사용된다.

이 메서드들은 부작용이 없도록 (side effect free) 의도적으로 표시되어 있어, R8이 Compose 코드를 컴파일할 때 이 호출들을 완전히 제거할 수 있다. 이렇게 하면 메서드 호출뿐 아니라 문자열의 지연 평가까지도 피할 수 있다.

상수 폴딩(Constant Folding)

R8은 전체 프로그램에 접근할 수 있으므로, 주어진 함수에 대해 모든 호출 사이트에서 전달된 값을 알 수 있다.

컴포저블 함수는 추가적인 합성 매개변수를 가지는데, 이 중 일부는 호출 사이트에서 상수로 전달되는 Int값이다.

이 값들은 Compose 컴파일러가 생성한 코드의 조건문에 사용된다. 특정 상황에서는 R8 컴파일러가 이 매개변수의 가능한 모든 값을 알기 때문에 결코 실행되지 않는 코드 경로를 제거할 수 있다.

단일 구현 인터페이스(Single Implementation Interface)

컴포접르 함수는 추가 매개변수로 Composer 타입을 받는다. Composer는 인터페이스지만, Compose 라이브러리에는 단 하나의 구현체인 ComposerImpl만 존재한다. 이는 미래에 다른 구현체를 추가하거나 API 유연성을 제공하기 위한 설계이다.

Compose 컴파일러가 생성하는 거의 모든 코드는 이 Composer 객체의 메서드 호출로 끝난다.

이 매개변수의 타입이 인터페이스이므로 Compose 컴파일러가 생성하는 거의 모든 코드는 이 Composer 객체의 메서드 호출로 끝난다.

이 매개변수의 타입이 인터페이스이므로, 결과적으로 Dalvik 바이트코드에서는 모두 invoke-virtual 호출로 처리된다. 그러나 R8은 프로그램 전체에서 이 인터페이스의 구현체가 단 하나뿐임을 인지하여, 이를 invoke-static 호출로 변경할 수 있다. invoke-static은 일반적으로 훨씬 빠르게 실행된다.

profile
🚀 상상을 좋아하는 개발자

0개의 댓글

관련 채용 정보