방법

메모리 효율성 우선순위 지정: Android 17의 필수 단계

전문 길이: 10분

앱 성능은 부드러운 UI와 빠른 시작 시간과 동일시되는 경우가 많지만, 메모리는 이러한 눈에 보이는 측정항목이 구축되는 조용한 기반 역할을 합니다. 기기 메모리가 그 어느 때보다 중요해지는 추세가 나타나고 있습니다. Android 17을 통해 Android 메모리 최적화에 상당한 진전을 이루었으며, 올해 말에 더 엄격해질 메모리 요구사항에 대비할 수 있도록 도구와 API 지원을 제공하고 있습니다.

기기 안정성을 보장하기 위해 Android 17부터 시스템에서 기기의 총 RAM을 기준으로 앱 메모리 한도를 적용합니다. 앱이 이러한 한도를 초과하면 Android는 연결된 스택 트레이스 없이 프로세스를 종료합니다.

이러한 강제 종료 외에도 최적화되지 않은 메모리 사용은 사용자 환경을 저하시킵니다. 앱이 힙 메모리 한도에 가까워지면 가비지 컬렉션이 자주 트리거되어 눈에 띄는 UI 끊김 현상이 발생합니다. 또한 기기에 사용 가능한 메모리가 부족하면 시스템에서 페이지를 회수하려고 하므로 CPU 부담, UI 지연 시간, 배터리 소모가 발생합니다. 메모리 부족이 너무 심하면 메모리 부족 종료 (LMK) 이벤트가 발생하여 백그라운드 프로세스가 갑자기 종료되고 앱의 콜드 스타트가 느려지고 사용자 상태가 손실될 수 있습니다.

성능이 우수한 앱을 빌드하고 이러한 강제 종료를 방지하려면 다음 메모리 최적화 전략을 채택하는 것이 좋습니다.

  1. R8로 바이트코드 최적화 극대화
  2. 이미지 로드 최적화
  3. Android 스튜디오로 메모리 누수 감지 및 수정
  4. 앱이 표시 상태를 벗어나면 메모리 트림
  5. ProfilingManager를 사용한 고급 메모리 관측 가능성

이 블로그 게시물의 요약 버전은 동영상 형식으로도 제공됩니다. 확인해 보세요.

Android 17 앱 메모리 제한 이해하기

Android 17에서는 '악의적인 행위자'가 멀티태스킹 환경과 사용자 전체 기기의 안정성을 파괴하는 것을 방지하기 위해 앱 메모리 제한이 도입됩니다.

이 아키텍처 변경을 추진하는 이유는 다음과 같습니다.

  • 연속 종료 방지: 앱이 권한이 있는 상태 (예: 포그라운드 서비스를 실행 중)를 유지하는 동안 앱이 비대해지거나 메모리가 누수되면 처음에는 시스템의 메모리 부족 종료 프로그램 (LMK)에서 보호됩니다. 이 단일 앱이 제어되지 않고 RAM을 독점하면 LMK는 메모리 독점 앱을 위한 공간을 확보하기 위해 수십 개의 더 작고 정상적인 캐시된 앱과 백그라운드 작업을 강제 종료하여 보상해야 합니다.
     
  • 멀티태스킹 및 사용자 상태 유지: 단일 누수 프로세스를 수용하기 위해 시스템에서 캐시된 앱을 강제로 삭제하면 멀티태스킹 환경이 심각하게 저하됩니다. 이전에 캐시된 애플리케이션으로 돌아가는 사용자는 거의 즉각적인 웜 재개 대신 느린 콜드 스타트를 경험합니다. 이러한 비효율성은 CPU 부담을 늘리고 배터리 소모를 가속화합니다. 또한 스크롤 위치, 탐색 스택, 게임 진행 상황 등 최근에 사용한 앱의 사용자 컨텍스트를 삭제할 수도 있습니다.

앱 세션이 필드에서 이러한 제약 조건의 영향을 받았는지 확인하려면 ApplicationExitInfo 내에서 getDescription()을 호출하면 됩니다. 시스템에서 제한을 적용한 경우 종료 이유는 REASON_OTHER로 보고되고 설명 문자열에는 'MemoryLimiter:AnonSwap'이 포함됩니다. TRIGGER_TYPE_ANOMALY를 사용하여 트리거 기반 프로파일링을 활용하여 메모리 한도에 도달했을 때 힙 덤프를 자동으로 캡처할 수도 있습니다. 또한 Android에서는 Google Play Console 내에서 개발자에게 더 많은 실제 메모리 측정항목을 표시하기 위해 적극적으로 노력하고 있습니다.

또한 로컬 디버깅 명령어를 포함하도록 메모리 제한 문서를 확대하여 로컬 환경에서 메모리 제약을 시뮬레이션하고 메모리 제한 시행에 따른 애플리케이션의 동작을 검증할 수 있습니다. 

R8로 바이트코드 최적화 극대화

앱의 메모리 사용량을 줄이는 매우 효과적인 방법은 R8 최적화 도구를 사용 설정하는 것입니다. 클래스, 메서드, 필드를 더 짧은 이름으로 축소하고 사용하지 않는 코드와 리소스를 삭제하여 R8은 실행 중에 필요한 상주 코드의 양을 최소화하여 앱의 메모리 사용 공간을 크게 줄입니다. 

R8은 상주 코드를 최소화하여 메모리 사용 공간을 줄이고 LMK 종료 위험을 낮춥니다. 따라서 느린 콜드 스타트보다 웜 스타트가 더 자주 발생합니다. 또한 간소화된 바이트 코드는 기본 스레드 CPU 오버헤드를 줄여 ANR 발생률을 직접적으로 낮춰 더 원활한 사용자 환경을 제공합니다. 예를 들어 디지털 은행인 Monzo는 전체 R8 최적화를 사용 설정하여 ANR 발생률이 35% 감소하고, 콜드 스타트 비율이 30% 개선되고, 전체 앱 크기가 9% 감소했습니다.

pic1-IO26_113_TSV-monzo-casestudy.jpg
디지털 은행인 Monzo는 완전한 R8 최적화를 지원하여 성능 측정항목을 최대 35%까지 향상했습니다.

build.gradle 파일에서 R8을 올바르게 구성하려면 다음을 따르세요.

  • isShrinkResources = true와 isMinifyEnabled = true를 설정합니다.
  • 실제로 최적화를 방지하고 Android Gradle 플러그인 9에서 더 이상 지원되지 않는 기존 proguard-android.txt 대신 proguard-android-optimize.txt을 사용하세요.
  • gradle.properties에서 android.enableR8.fullMode = false를 삭제합니다.

코드베이스에서 리플렉션을 사용하는 경우 R8이 코드의 해당 부분을 최적화하지 못하도록 Keep 규칙을 추가하세요. 최적화를 최대한 활용하려면 보관 규칙의 범위를 좁혀야 합니다. 

최대한 최적화하려면 보관 규칙 파일에서 다음 권장사항을 따르세요.

  • R8이 전체 코드베이스를 최적화하지 못하도록 하는 -dontoptimize-dontshrink, -dontobfuscate와 같은 전역 옵션 삭제
  • 활동, 서비스, 뷰, 브로드캐스트 리시버와 같은 Android 구성요소를 최적화하지 못하도록 하는 keep 규칙을 삭제합니다.
  • 특정 클래스 또는 메서드만 타겟팅하도록 광범위한 패키지 전체 유지 규칙을 수정합니다. 

권장사항을 자세히 알아보려면 보관 규칙 문서를 참고하세요.

라이브러리 개발자 R8 권장사항

라이브러리 개발자인 경우 소비자가 필요로 하는 규칙을 consumer-rules file에 엄격하게 배치하고 라이브러리의 내부 보호 규칙은 proguard-rules.pro 파일에 보관하세요. 라이브러리를 최적화하는 방법에 관한 자세한 내용은 라이브러리 작성자를 위한 최적화를 참고하세요.

R8 구성 분석기

R8 최적화를 감사하려면 구성 분석기를 사용하세요. 구성 분석기에는 난독화, 최적화, 축소 점수와 함께 최적화의 현재 상태가 표시됩니다. 구성 분석기를 사용하면 각 유지 규칙에 의해 최적화가 방지되는 클래스, 메서드 또는 필드의 수도 파악할 수 있습니다. 이러한 광범위한 패키지 전체 유지 규칙을 미세 조정하여 최적화를 최대한 활용하세요. 

구성 분석기를 사용하면 다른 유지 규칙을 포함하는 유지 규칙, 중복된 유지 규칙, 사용되지 않는 유지 규칙도 식별할 수 있습니다.

pic2-r8-config-analyzer.png
구성 분석기에는 난독화, 최적화, 축소 점수를 통해 최적화의 현재 상태가 표시됩니다.

R8 상담사 기술 

Android 스튜디오 에이전트 또는 기타 AI 도구와 함께 R8 에이전트 스킬을 활용하여 잘못된 구성을 해결하고 규칙을 개선하여 앱 성능을 향상할 수도 있습니다. (AI 기반 기술에서 얻은 통계에는 기술적 확인이 필요함)

이미지 로드 최적화

비트맵은 일반적으로 앱의 메모리에 상주하는 가장 큰 공통 객체입니다. JPEG 또는 PNG와 같은 압축 파일이 표시를 위해 원시 픽셀 데이터로 디코딩되는 이미지 로드 프로세스의 최종 단계를 나타냅니다. 즉, 메모리 소비는 이미지의 픽셀 크기와 색상 깊이에 따라 결정되므로 100KB의 작은 압축 이미지가 수 메가바이트의 RAM으로 늘어날 수 있습니다. 비트맵 작업은 프레임 그리기의 중요한 경로에 있는 경우가 많으므로 최적화되지 않은 이미지는 심각한 메모리 팽창과 UI 버벅거림을 유발합니다.

Google에서는 Kotlin 우선 프로젝트의 경우 이미지 로드 라이브러리 Coil을 활용하고, 특히 Jetpack Compose로 개발하는 경우 Glide를 Java 기반 애플리케이션에 사용하는 것을 권장합니다.

다음 5가지 권장사항을 따르세요.

  1. 이미지 다운샘플링: 비트맵을 수동으로 로드하는 경우 작은 썸네일 뷰에 대규모 이미지를 로드하지 마세요. inSampleSize를 사용하여 더 작은 버전을 로드하세요. Glide와 Coil은 기본적으로 이미지를 다운샘플링하며, DownsampleStrategy와 ImageLoader를 각각 사용하여 이 다운샘플링 전략을 구성할 수 있습니다.
  2. 자르기: 레터박스 목적으로 이미지 파일에 직접 패딩을 삽입하지 마세요 (예: 이미지 크기를 확장하기 위해 투명 테두리를 만드는 경우). 이러한 테두리를 베이킹하는 대신 InsetDrawable을 사용하거나 비트맵을 포함하는 뷰 또는 컴포저블 내에서 직접 패딩을 적용하세요.
  3. 구성: 올바른 픽셀 형식을 선택하여 메모리와 품질의 균형을 맞춥니다. 투명도가 필요하지 않은 경우 기본 ARGB_8888 형식의 절반 메모리를 사용하는 RGB_565를 사용합니다. Glide에서는 DecodeFormat을 사용하여 이를 구성할 수 있고 Coil에서는 bitmapConfig 속성을 사용할 수 있습니다.
  4. 벡터 드로어블 우선순위 지정: 기본 기하학적 애셋의 경우 래스터화된 비트맵을 디코딩하는 대신 ShapeDrawable을 가벼운 대안으로 활용합니다. XML을 통해 이러한 애셋을 한 번 정의하면 리소스 기반 메모리 팽창을 효과적으로 제거하면서 모든 디스플레이 밀도에서 원활하게 확장할 수 있습니다.
  5. 재사용: 애플리케이션이 비트맵을 수동으로 관리하는 경우 메모리 급변을 최소화하려면 비트맵이 더 이상 필요하지 않을 때 앱이 bitmap.recycle()를 호출하고 Bitmap 참조를 즉시 삭제해야 합니다. Glide 또는 Coil과 같은 이미지 로드 라이브러리를 사용하는 경우 비트맵을 라이브러리의 관리 풀에 반환합니다. 향후 메모리 요구사항에 맞게 기존 버퍼를 제공함으로써 풀은 새로운 할당의 오버헤드를 효과적으로 방지합니다.

자세한 내용은 이미지 성능 최적화에 관한 문서를 참고하세요.

Android 스튜디오 도구

Android 스튜디오 Narwhal 4를 사용하여 중복된 비트맵을 삭제할 수도 있습니다. 다음은 간단한 5단계로 이를 찾는 방법입니다.

  1. Android 스튜디오에서 프로파일러 탭을 엽니다.
  2. 힙 덤프 (또는 '메모리 사용량 분석')를 클릭하고 녹화를 눌러 앱의 현재 메모리 상태 스냅샷을 찍습니다.
  3. Android 스튜디오에서 중복 비트맵이 여러 번 저장되었음을 나타내는 데 사용하는 노란색 경고 삼각형 ⚠️을 분석 결과에서 스캔합니다. 또는 프로파일러 헤더로 이동하여 '다음으로 필터링'을 선택하고 '중복 비트맵' 설정을 선택합니다.
  4. 플래그가 지정된 항목을 클릭하면 비트맵 미리보기 창이 열려 반복되는 이미지를 정확하게 확인할 수 있습니다.
  5. 이 시각적 확인을 사용하여 코드에서 중복된 로드 로직을 추적하고 더 나은 캐싱 전략을 구현하세요.
pic3-IO26_113_TSV -dup-bitmaps-cropped.jpg
Android 스튜디오 프로파일러를 사용할 때 힙 덤프에서 노란색 경고 삼각형 ⚠️을 찾습니다.

Android 스튜디오로 메모리 누수 감지 및 수정

Android의 메모리 누수는 수명 주기가 끝난 후에도 코드가 객체의 참조를 오랫동안 보유할 때 발생합니다. 이렇게 하면 가비지 컬렉터 (GC)가 해당 메모리를 회수하지 못해 결국 성능이 저하되거나 OutOfMemoryError (OOM)가 발생합니다.

Android 스튜디오 Panda 3에는 전용 LeakCanary 프로파일러 작업이 있어 개발자가 실시간 메모리 누수를 분석하고 IDE 내에서 직접 트레이스를 매핑할 수 있습니다.

Android 스튜디오의 LeakCanary 프로파일러 작업은 메모리 누수 분석을 기기에서 개발 머신으로 적극적으로 이동하므로 누수 분석 단계에서 기기 내 누수 분석에 비해 성능이 크게 향상됩니다.

pic4-android-studio-leaks.png
 디버깅을 위해 선언으로 이동을 사용하여 컨텍스트화된 LeakCanary 메모리 누수 분석

또한 이제 IDE 내에서 누수 분석의 컨텍스트가 제공되고 소스 코드와 완전히 통합되어 선언으로 이동과 같은 유용한 코드 연결 기능이 제공되므로 메모리 누수를 조사하고 수정하는 데 필요한 마찰과 시간이 크게 줄어듭니다.  

일반적인 메모리 누수 사례 

메모리 누수는 객체가 의도한 수명을 넘어 메모리에 유지될 때 발생합니다. 일반적으로 다음과 같은 이유로 발생합니다.

  • 더 이상 사용되지 않는 프래그먼트, 활동 또는 뷰에 대한 참조를 유지합니다.
  • 컨텍스트 참조를 잘못 관리합니다.
  • 관찰자, 리스너, 리시버를 적절하게 등록 해제하지 않음
  • 수명 주기가 짧은 구성요소에 바인딩된 객체에 대한 정적 참조를 만듭니다.

다음은 몇 가지 예시 시나리오입니다.

시나리오Compose 기반 예시 뷰 기반 예시
컨텍스트 유출

예:
ViewModel에 LocalContext.current 전달

수정:
컨텍스트 종속 로직을 UI 레이어 내에 유지합니다. UI가 아닌 레이어의 경우 종속 항목 삽입을 사용하도록 리팩터링하거나 Kotlin Flow를 사용하여 UI 상태를 관찰합니다.

예: 
동반 객체 또는 정적 변수에 Activity을 저장합니다.

수정:
UI 구성요소에 대한 정적 참조를 보유하지 마세요. 종속 항목 삽입을 사용하도록 리팩터링하거나 Kotlin 흐름을 사용하여 UI 상태를 관찰합니다.

리스너 누출

예:
DisposableEffect을 사용하여 리스너를 시작하지만 onDispose을 비워 둡니다.

수정:
onDispose 블록 내에서 등록 취소 및 정리 로직을 실행합니다.

예:
SensorManager 업데이트를 등록하고 등록 취소하는 것을 잊었습니다.

수정:onStop() 또는 onDestroy() 수명 주기에서 unregisterListener()를 수동으로 호출합니다.

뷰 누수

예:출시 전략 없이 AndroidView 내부에 기존 View에 대한 참조를 보유합니다.


수정:
AndroidView 컴포저블의 release 블록을 사용하여 기존 View를 정리합니다.

예:
Fragment이 소멸된 후 뷰 바인딩 객체에 대한 참조를 유지합니다.

 

수정:
onDestroyView() 수명 주기 메서드 내에서 바인딩 변수를 null로 설정합니다.

앱이 표시 상태를 벗어나면 메모리 트림

메모리 관리 개요에 설명된 대로 Android는 중요한 작업을 위한 메모리를 확보하기 위해 필요한 경우 앱에서 메모리를 회수하거나 앱을 완전히 중지할 수 있습니다. Android는 일반적으로 앱이 사용자에게 표시되지 않을 때 메모리에서 앱의 일부 코드 및 데이터 페이지를 삭제하거나 힙 할당을 압축하는 등의 방법으로 앱에서 메모리를 회수합니다. 사용자가 앱을 재개하고 앱이 회수된 메모리에 액세스하려고 하면 OS는 요청 시 해당 메모리를 다시 스왑합니다. 이 스왑 동작은 느릴 수 있으며 앱에서 예기치 않은 버벅거림이나 끊김 현상을 일으킬 수 있습니다.

앱에서 회수할 메모리를 OS가 결정하도록 하면 앱을 재개한 직후에 필요한 메모리가 OS에 의해 회수될 수 있습니다. 대신 앱은 나중에 필요할 때 저렴한 비용으로 재생성할 수 있는 메모리 할당을 자발적으로 삭제할 수 있습니다. 이렇게 하려면 ComponentCallbacks2 인터페이스를 구현하면 됩니다. ActivityFragmentService 또는 맞춤 Application 클래스에서 onTrimMemory를 구현할 수 있습니다. Application 클래스에서 사용하면 전역 캐시 관리에 매우 효과적입니다.

제공된 onTrimMemory() 콜백 메서드는 앱이 자발적으로 메모리 사용량을 줄일 수 있는 좋은 기회를 제공하는 수명 주기 또는 메모리 관련 이벤트를 앱에 알립니다.

메모리 수명 주기 관리 측면에서 구현은 TRIM_MEMORY_UI_HIDDEN 및 TRIM_MEMORY_BACKGROUND에 전적으로 집중해야 합니다. Android 14부터 시스템은 Android 15에서 공식적으로 지원 중단된 다른 기존 상수의 알림을 전송하지 않습니다.

TRIM_MEMORY_UI_HIDDEN: 이 신호는 애플리케이션의 UI가 사용자의 뷰에서 전환되었음을 나타냅니다. 이를 통해 비트맵, 동영상 재생 버퍼, 복잡한 애니메이션 리소스와 같이 인터페이스에 엄격하게 연결된 상당한 메모리 할당을 해제할 수 있습니다.

TRIM_MEMORY_BACKGROUND: 이 수준에서 프로세스는 백그라운드에 상주하며 이제 시스템의 전역 메모리 요구사항을 충족하기 위해 종료될 수 있습니다. 프로세스가 캐시된 상태로 유지되는 시간을 늘리고 앱 콜드 스타트 수를 줄이려면 사용자가 세션을 재개한 후 쉽게 재구성할 수 있는 리소스를 적극적으로 해제해야 합니다.

import android.content.ComponentCallbacks2
// Other import statements.

class MainActivity : AppCompatActivity(), ComponentCallbacks2 {

    /**
     * Release memory when the UI becomes hidden or when system resources become low.
     * @param level the memory-related event that is raised.
     */
    override fun onTrimMemory(level: Int) {

        if (level >= ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN) {
            // Release memory related to UI elements, such as bitmap caches.
        }

        if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) {
            // Release memory related to background processing, such as by
            // closing a database connection.
        }
    }
}

참고: onTrimMemory 통합은 SDK 지원에 따라 달라질 수 있습니다. 예를 들어 일부 게임은 게임 엔진을 사용하여 이 기능을 사용 설정합니다. 게임 메모리 최적화 문서를 확인하세요.

ProfilingManager를 사용한 고급 메모리 관측 가능성

로컬에서 재현할 수 없는 필드의 메모리 문제를 포착하고 진단하려면 ProfilingManager API를 활용해야 합니다. Android 15에서 도입된 이 고급 관측 가능성 API를 사용하면 실제 사용자 Perfetto 프로필을 프로그래매틱 방식으로 수집할 수 있습니다. 

성능 아티팩트를 관리하고 호스팅할 전용 인프라가 없는 팀을 위해 Crashlytics에서는 이 워크플로를 간소화할 수 있는 전문 솔루션을 모색하고 있습니다. 개발자에게 의견을 제공하도록 초대하고 있습니다.

Android 17에서는 새로운 이벤트 기반 트리거(특히 TRIGGER_TYPE_OOM 및 TRIGGER_TYPE_ANOMALY)를 도입합니다.

  • OOM 트리거는 OutOfMemoryError 비정상 종료가 발생하는 정확한 순간에 Java 힙 덤프를 자동으로 수집하여 정확한 할당 상태를 제공합니다. 수집된 OOM 프로필은 앱이 다음에 시작되고 registerForAllProfilingResults 콜백을 등록할 때 제공됩니다.
  • 비정상 감지기는 과도한 바인더 스팸이나 메모리 임계치 위반과 같은 심각한 성능 문제를 감지합니다. 메모리 비정상 종료는 시스템이 앱을 종료하기 직전에 힙 덤프를 제공합니다.
    val profilingManager = 
applicationContext.getSystemService(ProfilingManager::class.java)
    val triggers = ArrayList<ProfilingTrigger>()  


    triggers.add(ProfilingTrigger.Builder(
                 ProfilingTrigger.TRIGGER_TYPE_ANOMALY))
    val mainExecutor: Executor = Executors.newSingleThreadExecutor()
    val resultCallback = Consumer<ProfilingResult> { profilingResult ->
        if (profilingResult.errorCode != ProfilingResult.ERROR_NONE) {
            // upload profile result to server for further analysis          
            setupProfileUploadWorker(profilingResult.resultFilePath)
        } 

    profilingManager.registerForAllProfilingResults(mainExecutor, resultCallback)
    profilingManager.addProfilingTriggers(triggers)

힙 덤프를 수집한 후 서버에서 프로필을 다운로드하거나 adb pull을 통해 로컬로 다운로드하고 파일을 Perfetto UI로 드래그 앤 드롭할 수 있습니다. 메모리 디버깅 워크플로를 간소화하려면 힙 덤프 탐색기를 사용하세요. Perfetto UI의 힙 덤프에 대한 새로운 기본 뷰입니다. 이 도구는 Java 힙 덤프를 검사하기 위한 직관적인 인터페이스를 제공하여 객체 할당 계층 구조를 시각화하고, 유지된 메모리 크기를 계산하고, 가비지 컬렉션 루트에서 가장 짧은 경로를 식별할 수 있습니다. 힙 덤프 탐색기를 활용하면 메모리 누수, 과도한 비트맵 할당과 같은 부풀려진 보관 객체를 빠르게 찾아내고 힙 객체 할당을 한곳에서 분석할 수 있습니다.

pic5-perfettoheapdump-analyzer.png
힙 덤프 탐색기의 내장 플레임 그래프를 사용하여 힙 할당이 가장 높은 객체를 시각적으로 검사하고 탐색합니다.

결론

R8로 바이트 코드를 최적화하고, 이미지 로드 권장사항을 채택하고, 메모리 누수를 해결하는 것은 압박 속에서 리소스를 효과적으로 관리하면서 고품질 사용자 환경을 제공하기 위한 중요한 단계입니다. 이러한 사전 조치를 취하면 앱 안정성과 성능을 유지하고, 사용자 컨텍스트를 보호하면서 예기치 않은 종료를 방지할 수 있습니다. 성능 전문성을 높이려면 개정된 메모리 가이드를 살펴보세요.

작성자:
계속 읽기