도구 및 실적

Firebase Crashlytics를 사용한 메모리 문제 해결

스피커

  • 폴리나 고보르코바

    선임 소프트웨어 개발자

정보

강연 소개

Firebase Crashlytics를 사용하여 앱의 메모리 사용량을 최소화하고 세그멘테이션 오류를 방지하세요. 이 세션에서는 Firebase Console과 Android 스튜디오에서 사용할 수 있는 포괄적인 메모리 문제 해결 도구 모음을 살펴봅니다. 먼저 ANR 인사이트와 GWP-ASan 보고서를 활용하여 찾기 어려운 메모리 버그를 진단하는 방법을 살펴본 다음, Android 17에 도입된 새로운 메모리 제한을 이해하고 이에 적응하는 데 도움이 되도록 특별히 설계된 메모리 부족 (OOM) 및 메모리 제한 종료 (MLK) 이벤트용 최신 도구를 살펴보겠습니다.

요약

  • 애플리케이션 응답 없음 오류는 활성 CPU 부하보다는 백그라운드 병목 현상이나 메모리 소진으로 인해 발생하는 경우가 많습니다. Firebase Crashlytics는 ApplicationExitInfo를 쿼리하여 멀티스레드 trace를 표시하고 루트 차단 스레드를 식별합니다.
  • 메모리 부족으로 인한 비정상 종료는 앱이 런타임 자바 힙 한도를 초과할 때 발생합니다. Crashlytics는 Android 17의 ProfilingManager와 통합되어 난독화 해제된 힙 스냅샷을 수집하며, 이는 Perfetto 또는 Android CLI를 통해 검사할 수 있습니다.
  • Android 17의 메모리 제한기는 네이티브 할당을 포함하여 과도한 총 RAM을 소비하는 개별 프로세스를 종료합니다. Crashlytics는 대시보드에서 이러한 종료를 힙 덤프와 함께 그룹화하여 C++ 메모리 누수를 관리형 힙 할당과 구분할 수 있도록 지원합니다.
  • 네이티브 메모리 손상은 앱 코드보다는 시스템 스레드에서 자주 발생합니다. Crashlytics는 GWP-ASan 및 Arm MTE를 지원하며, GWP-ASan 보고서는 use-after-free와 같은 버그의 정확한 할당 및 할당 해제 위치를 정확하게 찾아냅니다.

리소스

Gemini Notebook

주제

  • firebase-crashlytics
  • android-memory
  • anr
  • 메모리 부족
  • profiling-manager
  • perfetto
  • gwp-asan
  • arm-mte

스크립트

소개

  • 폴리나 고보르코바

    안녕하세요.

  • Firebase Crashlytics를 사용한 메모리 문제 해결에 오신 것을 환영합니다.

  • 잠깐만요, 직함이 정말 멋지네요.

  • 조금 섞어 보겠습니다.

  • Firebase Crashlytics로 앱을 간결하고 효율적으로 유지하기

  • 아, 그게 더 낫겠네요.

  • 저는 Firebase Crashlytics팀의 선임 소프트웨어 개발자이자 오늘 발표를 맡은 폴리나입니다.

  • 개발 주기의 어느 시점에서는 모든 버그를 해결했다고 생각할 수 있습니다.

  • JSON 파싱 오타, 잊어버린 null 포인터 검사 등이 모두 사라집니다.

  • '이제 끝났나?'라고 자문할 수도 있습니다.

  • 하지만 안타깝게도 그렇지 않습니다.

  • 남은 문제는 실제로 어려운 문제이기 때문입니다.

  • 그리고 오늘 저는 여러분이 직면하게 될 가장 어려운 문제의 두 가지 범주는 응답성과 메모리라고 주장합니다.

  • 다행히도 Google에서 도와드릴 수 있습니다.

  • 오늘은 Android 및 Crashlytics의 기존 도구와 새로운 도구를 살펴보며 애플리케이션 응답 없음 오류, 메모리 부족 예외, 메모리 누수, 네이티브 메모리 손상으로 나타나는 응답성 문제의 근본 원인을 파악하는 방법을 알아보겠습니다.

  • 자세히 살펴보죠

  • 응답성 및 메모리 문제가 악명 높은 이유는 무엇인가요?

  • 문자열을 선언하고 null로 설정한 후 어느 시점에서 길이를 읽으려고 하는 전형적인 버그를 예로 들어보겠습니다.

  • 어떤 혜택이 있나요?

  • NullPointerException이 발생합니다.

  • 하지만 예외 스택 트레이스가 앱을 중단시킨 정확한 코드 줄을 가리키기 때문에 이 경우에는 쉽게 해결할 수 있습니다.

  • 왼쪽의 스택 트레이스를 살펴보세요.

    java.lang.NullPointerException
      at MainActivity.onCreate(MainActivity.kt:42)
      at android.app.Activity.performCreate
      at android.os.Handler.dispatchMessage
      at android.os.Looper.loopOnce
      at android.app.ActivityThread.main
  • MainActivity.kt의 42번째 줄에 문제가 있다는 것을 바로 알 수 있습니다.

  • 이 접근 방식은 대부분의 사소한 버그에 적용됩니다.

  • 이제 오른쪽의 애플리케이션 응답 없음과 같은 응답성 또는 메모리 문제가 발생하면 스택 트레이스가 실제 원인을 가리키는 경우가 거의 없습니다.

    "main" prio=5 tid=1 Blocked
      | group="main" sCount=1 dsCount=0 flags=1
      | state=S schedstat=( 1450239 48192 145 )
      at DataManager.getData(DataManager.kt:25)
      - waiting to lock <0x0a1b> held by thread 12
      at MainActivity.onResume(MainActivity.kt:50)
      at android.app.Activity.performResume
      at android.os.Handler.dispatchMessage
      at android.os.Looper.loopOnce
      at android.app.ActivityThread.main
  • 가장 좋은 경우, 앱이 정지되거나 메모리가 부족할 때 실행 중이던 무해한 스레드나 할당과 같은 효과만 표시됩니다.

  • 실제 근본 원인은 훨씬 이전에 발생했거나 완전히 다른 곳에서 발생했을 수 있습니다.

  • 다행히 이 문제에 관해서는 Firebase Crashlytics가 답을 제공하므로 추측할 필요가 없습니다.

  • 하지만 문제 해결을 시작하기 전에 Crashlytics에 익숙한 분은 손을 들어주세요.

  • 좋습니다.

  • 많은 분이 손을 들어주셨네요.

  • 처음 오신 분들을 위해 설명해 드리자면 Firebase Crashlytics는 실시간 오류 보고 솔루션으로, 필드 데이터와 함께 상황별 정보와 AI 기반 인사이트, 문제 해결 팁, 새 출시 버전에 대한 명확한 알림을 제공하여 문제를 더 빠르게 해결할 수 있도록 도와줍니다.

  • Crashlytics는 개발자들의 많은 사랑을 받고 있으며, 모바일에서 가장 많이 채택된 비정상 종료 보고 솔루션으로, 상위 Android 앱의 57% 를 포함하여 37만 개 이상의 네이티브 앱을 지원하고 있습니다.

  • Crashlytics가 실제로 버그를 추적하는 데 어떻게 도움이 되는지 알아보기 위해 구체적인 예를 살펴보겠습니다.

  • 오늘은 ChromaClip의 이야기를 들려드리려고 합니다.

  • 제가 개발 중인 동영상 색상 팔레트 추출기입니다. 개발 과정에서 반응성 및 메모리 버그를 어떻게 해결했는지 알려드리겠습니다.

  • ChromaClip의 기본 아이디어는 간단합니다. 사용자가 멋진 일몰 클립이나 색 보정이 잘 된 장면과 같은 동영상을 선택하면 앱이 프레임을 분석하여 맞춤 색상 팔레트를 생성합니다. 그러면 사용자는 이 팔레트를 디자인, 디지털 아트, 무드 보드 등 무엇이든 원하는 곳에 사용할 수 있습니다.

  • 제 이야기는 1.0 버전 출시 직전부터 시작됩니다.

  • 출시 전에 완벽하게 만들고 싶어서 여러 기기에서 로컬로 최종 테스트를 진행하고 있었습니다. 동영상을 선택하고 버튼을 탭하여 색상 팔레트를 추출했는데, 운이 좋게도 모든 Android 개발자가 좋아하는 대화상자가 표시되었습니다.

  • ChromaClip이 응답하지 않습니다.

  • 이것이 바로 애플리케이션 응답 없음 오류, 즉 ANR입니다.

ANR 디버깅 및 분석

  • 폴리나 고보르코바

    그렇다면 이 상황에서 당황하는 것 외에 무엇을 해야 할까요?

  • 테스트 휴대전화가 노트북에 바로 연결되어 있으므로 Android 스튜디오를 열고 Logcat을 확인합니다.

  • 기본 스레드의 스택 트레이스가 표시됩니다.

    14:32:15.105  1205-1250/system_process E/ActivityManager: ANR in com.chromaclip.app (com.chromaclip.app/.MainActivity)
        PID: 14205
        Reason: Input dispatching timed out
        Dumped ANR trace to /data/anr/traces.txt
    "main" prio=5 tid=1 Native
      | group="main" sCount=1 dsCount=0 flags=1 obj=0x72a8c3d0 self=0x759fb92000
      | sysTid=14205 nice=-10 cgrp=default sched=0/0 handle=0x7623910548
      | state=S schedstat=( 1450239120 4819203 1453 ) utm=132 stm=13 core=4 HZ=100
      | stack=0x7fed2a0000-0x7fed2a2000 stackSize=8MB
      at sun.misc.Unsafe.park(Native method)
      - waiting on an unknown object
      at java.util.concurrent.locks.LockSupport.park(LockSupport.java:190)
      at kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:279)
      at kotlinx.coroutines.BlockingCoroutine.joinBlocking(Builders.kt:85)
      at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking(Builders.kt:59)
      at kotlinx.coroutines.BuildersKt.runBlocking(Unknown Source:1)
      at com.chromaclip.app.extractor.PaletteExtractor.extractColors(PaletteExtractor.kt:42)
  • 실제로 무엇을 알 수 있을까요?

  • 먼저 맨 위에서 MainActivity에서 실행 중인 ANR임을 확인합니다.

  • 안 되는 사람이랄까?

  • 입력 디스패치 제한 시간 초과라는 이유가 표시됩니다.

  • 즉, 사용자가 화면을 탭한 것입니다.

  • 예, 그렇습니다.

  • 하지만 기본 스레드가 너무 바빠서 Android의 5초 워치독 제한 시간 내에 이벤트를 처리할 수 없었습니다.

  • 그런 다음 시스템이 파일을 traces.txt에 덤프했고 바로 아래에 logcat이 기본 스레드의 스택 트레이스를 출력하는 것을 볼 수 있습니다.

  • 상태가 S와 같다는 것을 알 수 있습니다.

  • 기본 스레드가 CPU를 활발하게 사용하지도 않습니다.

  • S는 수면을 의미합니다.

  • 대기하는 동안 기본 스레드가 절전 모드인 이유는 무엇인가요?

  • 스택 프레임을 자세히 살펴보겠습니다.

  • 트레이스 중간에 기본 스레드가 Unsafe.park 내에 주차되어 알 수 없는 객체를 기다리고 있습니다.

  • 왜 무엇을 기다리고 있는지 알 수 없나요?

  • 실제로 일부 ANR의 경우, 예를 들어 스레드가 기존 Java 동기화 잠금에 의해 차단된 경우 기본 스레드 스택 트레이스가 친절하게도 실제로 무엇을 기다리고 있는지, 무엇이 잠금을 유지하고 있는지 알려줍니다.

  • 하지만 내부적으로 더 높은 수준의 동시 실행 프리미티브를 사용하면 Logcat은 알 수 없는 객체를 기다리고 있다고만 알려줍니다.

  • 따라서 어떤 객체를 기다리고 있는지, 어떤 백그라운드 작업자 스레드를 구체적으로 기다리고 있는지, 왜 5초 이상 걸리는지 알 수 없습니다.

  • 로컬에서 테스트하고 있으므로 기기의 traces.txt를 가져와 수십 개의 휴면 스레드를 수동으로 스크롤하여 원인을 찾거나 AI에 질문하여 토큰을 소모할 수 있습니다.

  • 경우에 따라서는 실제로 작동할 수도 있으며, 사실 이 경우에도 작동할 것입니다.

  • 하지만 원시 스레드 덤프를 직접 읽고 싶지 않을 뿐만 아니라(솔직히 2019년 방식입니다) 이 접근 방식은 프로덕션 단계에서는 전혀 실행 가능하지 않습니다.

  • 사용자 휴대전화의 traces.txt 파일에 직접 액세스할 수 없는 것뿐입니다.

  • Firebase Crashlytics와 통합되어 있어 다행입니다.

  • 검색할 필요가 없고 프로덕션 환경에서도 ANR의 근본 원인을 파악할 수 있기 때문입니다.

  • 방법

  • 이때 Android의 ApplicationExitInfo API가 필요합니다.

  • Android 11부터는 시스템에서 ANR을 신고하거나 앱 프로세스를 종료할 때마다 OS에서 비정상 종료의 정확한 원인, 메모리 상태, 당시 프로세스에서 실행 중인 모든 스레드의 전체 시스템 트레이스를 비롯한 풍부한 종료 메타데이터를 기록합니다.

  • 가장 좋은 점은 추가 플러그인을 설치하거나 코드를 한 줄도 작성할 필요가 없다는 것입니다.

  • Crashlytics SDK 18.3.5 이상을 사용 중이라면 바로 사용할 수 있습니다.

  • 따라서 앱이 다시 시작되는 즉시 Crashlytics SDK가 ApplicationExitInfo를 자동으로 쿼리하고, 기기 원격 분석과 함께 전체 멀티스레드 덤프를 캡처하여 Crashlytics 백엔드로 전송합니다.

  • Crashlytics Console을 열고 어떤 정보가 표시되는지 살펴보겠습니다.

  • 이것은 ANR에 대한 Firebase Crashlytics Console 문제 세부정보 뷰입니다. 스택 트레이스 탭 아래에 DefaultDispatcher 작업자 1이 바로 표시됩니다.

    DefaultDispatcher-worker-1 (Root blocking)
    
    dalvik.system.VMRuntime.allocateNode (Native method)
    android.graphics.Bitmap.createBitmap (Bitmap.java:1124)
    android.graphics.Bitmap.createBitmap (Bitmap.java:1091)
    com.chromaclip.app.extractor.FrameDecoder.getUncompressedFrame (FrameDecoder.kt:112)
    com.chromaclip.app.extractor.PaletteExtractor$processVideo$2.invokeSuspend (PaletteExtractor.kt:65)
    kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith (ContinuationImpl.kt:33)
    kotlinx.coroutines.DispatchedTask.run (DispatchedTask.kt:106)
    java.util.concurrent.ThreadPoolExecutor.runWorker (ThreadPoolExecutor.java:1167)
    java.util.concurrent.ThreadPoolExecutor$Worker.run (ThreadPoolExecutor.java:641)
    java.lang.Thread.run (Thread.java:923)
  • 이 스레드는 실제로 Crashlytics에서 맨 위에 표시된 스레드로, 기본 스레드의 병목 현상이며 Crashlytics는 이를 루트 차단이라는 배지로 표시합니다.

  • 배지의 도움말에 설명된 대로 이 작업자 스레드는 실제로 ANR을 트리거한 스레드를 차단하는 스레드입니다.

  • Crashlytics에는 다른 원인에 대한 스마트 배지도 있습니다.

  • 예를 들어 스레드가 거대한 파일을 읽거나 매우 느린 데이터베이스 쿼리를 실행하는 중에 멈춘 경우 ANR을 트리거하면 스레드가 I/O 루트 차단으로 태그됩니다.

  • 두 스레드가 서로를 기다리며 멈춰 있는 경우 Crashlytics는 이를 교착 상태로 태그합니다.

  • 이제 루트 차단 스레드 내의 스택 프레임 자체를 살펴보겠습니다.

  • 따라서 FrameDecoder getUncompressedFrame를 호출하는 PaletteExtractor processVideo가 파란색으로 강조 표시되어 있으며, 단일 프레임에 메모리를 할당하려고 시도하는 동안 createBitmap 내부에 완전히 멈춰 있습니다.

  • 다른 잠금을 기다리고 있지 않고 네트워크 호출 중이 아니라는 것을 알고 있으므로 메모리가 가장 유력한 원인입니다. Crashlytics Console에서 바로 기기의 여유 메모리를 확인하여 즉시 확인할 수 있습니다.

  • Crashlytics에서 이 정보를 표시하는 두 위치를 살펴보겠습니다.

  • 방금 살펴본 스택 트레이스 탭 바로 옆에 있는 특정 이벤트의 데이터 탭과 모든 이벤트의 평균을 보여주는 오른쪽 상단에 있습니다.

  • 두 경우 모두 100KB도 되지 않는 아주 작은 크기입니다.

  • 그렇다면 생각해 봅시다.

  • 이 경우 프로세스에 메모리가 부족하여 Android 런타임이 가비지 컬렉션을 실행하는 데 모든 시간을 소비하고 다음 비트맵을 위한 충분한 공간을 찾으려고 하지만 찾지 못합니다.

  • 작업자 스레드를 정지하고 5초 넘게 UI를 고갈시킵니다.

  • 간단한 팁을 알려드리겠습니다.

  • 이러한 종류의 낮은 여유 RAM과 페어링된 ANR이 발생하면 ANR이 실제로 메모리 누수 또는 비대화 문제라는 결정적인 증거입니다.

  • 이제 문제는 우리 앱이 피해자인지 아니면 메모리를 독점하고 있는 앱인지입니다.

  • 그럼 프로젝트의 문제 목록으로 돌아가서 관련이 있어 보이는 다른 문제가 있는지 살펴보겠습니다.

메모리 부족 예외 조사

  • 폴리나 고보르코바

    여기 보세요.

  • 하단에는 문제 목록이 표시되고, 8개의 이벤트가 있는 ANR 바로 위에는 OutOfMemory 예외가 있습니다.

  • 결국 우리가 범인일 수도 있습니다.

  • 이것은 사실 엄청난 단서입니다.

  • Android에서 Java OutOfMemoryError(줄여서 OOM)은 휴대전화의 RAM이 부족하다는 의미만은 아니기 때문입니다.

  • 이는 자체 앱 프로세스가 Android 런타임 자바 힙 한도를 초과했음을 의미합니다.

  • 따라서 이 시점에서 제 가설은 ANR과 OOM이 실제로는 동전의 양면과 같다는 것입니다.

  • 테스트 실행 중 타이밍에 따라 ChromaClip은 ANR을 트리거할 만큼 충분히 오랫동안 가비지 컬렉션 내에서 스래싱에 갇히거나 힙을 완전히 소진하여 OOM을 초래할 수 있습니다.

  • OutOfMemory 예외를 조사하여 확실히 알아보겠습니다.

  • 스택 트레이스부터 시작합니다.

    java.lang.OutOfMemoryError: Failed to allocate a 256 byte allocation
     at android.graphics.Bitmap.createBitmap
     at android.view.LayoutInflater.inflate(LayoutInflater.java:532)
     at com.chromaclip.app.SettingsActivity.onCreate(SettingsActivity.kt:21)
  • 잠시만 기다려 주세요.

  • Bitmap.createBitmap, 익숙한 이름이네요.

  • 전에 본 적이 있지 않나요?

  • 이번에는 어디에서 호출되는지 살펴보겠습니다.

  • 설정 활동을 열 때 256바이트를 할당하지 못했습니다.

  • 우리는 또다시 같은 함정에 빠지고 있습니다.

  • 작은 설정 아이콘을 확장할 수 없어 앱이 비정상 종료되는 경우 설정 활동이 원인이 아닙니다.

  • 메모리가 없을 때 메모리 할당을 요청한 운이 나쁜 호출자일 뿐입니다.

  • 따라서 실제 원인을 찾으려면 비정상 종료가 발생했을 때 메모리에 있던 모든 항목을 축소하여 확인해야 합니다.

  • 즉, Java 힙 덤프가 필요합니다.

  • 책상에서 OOM을 트리거하는 방법을 알고 있다면 Android 스튜디오 메모리 프로파일러를 사용하여 자바 힙 덤프를 수동으로 가져올 수 있습니다.

  • 하지만 테스트 실행 중에 또는 실제 환경에서 OOM이 예측할 수 없게 발생하는 경우 이 방법은 작동하지 않습니다.

힙 덤프 캡처 및 분석

  • 폴리나 고보르코바

    Firebase Crashlytics가 또 한 번 빛을 발하는 순간입니다.

  • 이제 Crashlytics는 애플리케이션 종료 정보 외에도 Android의 ProfilingManager API와 통합됩니다.

  • Android 17의 ProfilingManager API는 시스템 트리거 메모리 프로파일링으로 강화되었으며, 바로 이 기능이 필요합니다.

  • 즉, 앱에서 OutOfMemory 오류가 발생하거나 시스템 메모리 한도를 위반하는 즉시 OS가 백그라운드에서 힙 스냅샷을 캡처하고, Crashlytics SDK 20.1.0 이상을 사용하는 경우 Crashlytics에서 트리거된 수집이 기본적으로 무료로 제공됩니다.

  • 그러면 Crashlytics가 OOM 및 메모리 제한기 종료를 자동으로 그룹화합니다. 이에 대해서는 나중에 대시보드에서 자세히 설명하겠습니다.

  • Firebase Console 또는 CLI를 통해 힙 덤프 수집을 선택하면 Crashlytics가 캡처된 힙 스냅샷을 자동으로 수집하고 난독화를 해제합니다. 기본적으로 비정상 종료 시점에 앱의 Java 힙에 있는 모든 항목을 전체적으로 확인할 수 있습니다.

  • 힙이 캡처되면 Crashlytics Console에 ChromaClip OOM이 다음과 같이 표시됩니다.

  • 상단에는 Java가 힙을 소진한 이유와 그 원인에 대한 설명이 표시됩니다.

  • 그런 다음 유용한 문제 해결 팁과 외부 리소스에 대한 포인터도 확인할 수 있습니다.

  • 스택 트레이스 탭에서 오해의 소지가 있는 설정 활동 바로 위에 Crashlytics는 OOM 스택 트레이스가 결정론적이지 않다고 플래그를 지정하므로 방금 했던 것처럼 스택 트레이스를 살펴보는 데 시간을 낭비할 필요가 없습니다.

  • 그런 다음 Crashlytics는 세션의 난독화 해제된 Perfetto 파일인 힙 덤프를 연결합니다.

  • 여기에서 파일을 다운로드할 수 있습니다.

  • Google Cloud Storage에서 모든 덤프를 탐색하거나 Perfetto UI에서 열 수 있습니다.

  • 따라서 Perfetto UI에서 '분석'을 클릭하면 Perfetto 힙 덤프 탐색기가 열립니다.

  • 이것을 이미 보신 분은 손을 들어주세요.

  • 좋습니다. 몇 분이 손을 드셨네요.

  • 이 화면은 매우 위협적으로 보이지만, 이 경우 문제는 사실 매우 간단합니다.

  • Java 힙 전체에 걸쳐 있는 거대한 막대 집합을 살펴보세요.

  • 따라서 대부분의 경우 색상과 문자열 인스턴스를 래핑하는 쌍 객체의 배열입니다.

  • 오!

  • 무슨 일인지 알 것 같아요.

  • 코드가 모든 프레임의 모든 픽셀에 색상과 일부 지원 데이터를 할당하고 수백만 개의 픽셀을 목록에 보관하고 있습니다.

  • 따라서 설정 활동에서 256바이트를 할당할 수 없는 것은 당연합니다.

  • 적어도 한 가지 수수께끼는 풀렸네요.

  • 이 문제를 해결해 보겠습니다.

  • 이 시점에서 몇 가지 옵션이 있습니다.

  • Perfetto가 이미 PaletteExtractor를 가리켰으므로 IDE를 열고 루프를 직접 다시 작성하거나 AI 코딩 에이전트를 사용할 수 있습니다.

  • 또는 수동 힙 검사를 완전히 우회하고 Android CLI를 활용할 수 있습니다.

    # 1. Install Android CLI & Perfetto skills
    $ android skills add android-cli perfetto
    
    # 2. List recent heap dumps from Crashlytics
    $ android signals heaps list --kind oom --timeframe=90d
    
    # 3. Download a heap dump as a Perfetto trace
    $ android signals heaps download <heap-dump-id> \
        --output ./heaps
    
    # 4. Query top retained objects in the heap dump
    $ trace_processor query ./heaps/oom.perfetto-trace ...
    
    # 5. Fetch matching Crashlytics events
    $ android signals heaps describe <heap-dump-id>
  • 따라서 Android CLI 및 Perfetto 기술을 설치하면 가독화된 Crashlytics 힙 덤프를 나열하고 다운로드하고, trace_processor를 통해 이러한 힙 덤프에서 가장 많이 유지된 객체를 쿼리하고, 커스텀 키, 로그, 탐색경로, 관련 출시 등 일치하는 Crashlytics 문제 데이터를 모두 터미널에서 가져올 수 있습니다.

  • 슬라이드 하단을 보면 더 좋습니다.

  • 이 명령어를 직접 실행하지 마세요.

  • 다시 한번, 자연어로 선호하는 AI 코딩 에이전트에게 Android 명령어를 사용하여 최신 OOM 힙 덤프를 검사하고 수정사항을 배포해 달라고 요청합니다.

    # Or ask your favorite LLM in natural language
    > Using `android` command, inspect the latest heap dump
      for my OOM issue and propose a fix
    ● Ran android signals heaps list · download · describe
    ● Analyzed ./heaps/oom.perfetto-trace (perfetto)
    ● Updated PaletteExtractor.kt (+6 -4 lines)
  • 그리고 전체 흐름을 수행할 수 있는 충분한 컨텍스트가 있습니다.

  • 즉, 프로젝트에서 직접 OOM을 나열하고, trace를 다운로드하고, 분석을 실행하고, 코드 패치를 생성합니다.

  • PaletteExtractor 파일의 이 패치를 살펴보세요.

    // PaletteExtractor.kt
    fun extractPalette(framePixels: IntArray): SparseIntArray {
    -   val pixelColors = mutableListOf<Pair<Color, String>>() // 8.3M objects per 4K frame!
    -   for (px in framePixels) {
    -       pixelColors.add(Color.valueOf(px) to "#%06X".format(px))
    -   }
    
    +   val colorHistogram = SparseIntArray() // Downsampled & packed ARGB ints!
    +   for (i in framePixels.indices step 16) {
    +       val argb = framePixels[i]
    +       colorHistogram.put(argb, colorHistogram.get(argb) + 1)
    +   }
    +   return colorHistogram
    }
  • 이제 수백만 개의 객체 할당 대신 프레임을 다운샘플링하고 원시 정수를 직접 계산합니다.

  • 따라서 수정사항을 적용하고, 문제를 종료하고, 버전을 올리고, 계속 테스트해 보겠습니다.

  • 메모리 부족 오류가 사라질 뿐만 아니라 더 이상 가비지 컬렉션에서 스래싱이 발생하지 않으므로 성가신 ANR도 사라지기를 바랍니다.

  • 따라서 테스트 기기에 패치 빌드를 배포하고 새 클립을 선택한 후 팔레트를 추출하기 시작합니다.

  • 그러면 UI가 다시 정지됩니다.

  • 또 다른 ANR입니다.

  • 흥미롭게도 더 많은 테스트를 진행한 결과, 일부 기기에서는 여전히 ANR이 발생하지만 다른 기기에서는 앱이 무작위로 비정상 종료되며, 그 빈도도 OOM보다 훨씬 더 높았습니다.

  • 하지만 OOM을 수정했는데 무슨 일이 일어나고 있는 걸까요?

네이티브 메모리 문제 조사

  • 폴리나 고보르코바

    Crashlytics로 돌아가 보겠습니다.

  • 좋은 소식부터 전해 드리겠습니다.

  • 문제 목록을 확인합니다.

  • 메모리 부족 예외가 완전히 사라졌습니다.

  • 나쁜 소식은 무엇인가요?

  • ChromaClip이 조금 더 오래 살아남으면서 그 아래에 숨겨져 있던 다른 것들을 발견할 시간이 생겼습니다.

  • 따라서 ANR이 사라지지 않았습니다.

  • 이제 하단에 네이티브 충돌이 두 개 표시됩니다. 다행히 이벤트가 몇 개 없습니다.

  • 그리고 맨 위에는 11개의 이벤트가 있는 메모리 제한기 종료가 있습니다. 다행히도 이미 힙 덤프가 있는 것으로 표시되어 있습니다.

  • 잠깐만요.

  • 메모리 제한기 종료란 무엇인가요?

  • 이 용어를 들어보신 분은 손을 들어주세요.

  • 좋습니다.

  • 드문 경우입니다.

  • 함께 배워보겠습니다.

  • 메모리 제한 기능이 종료되는 것을 본 적이 없다면 Android 17에 새로 도입되었기 때문입니다.

  • Java OOM은 관리되는 Java 힙 한도를 초과할 때만 트리거되지만, 앱의 총 메모리 사용량에 그래픽 버퍼 및 C++ 할당과 같은 네이티브 RAM도 포함되는 경우 이전에는 앱별 한도가 없었습니다.

  • 전역적인 로우 메모리 킬러가 있었고,

  • 앱이 시스템 RAM을 모두 소모하여 휴대전화 전체가 먹통이 될 때 개입합니다.

  • 이제 Android 17부터 메모리 제한기가 각 앱의 개별 메모리 사용량을 제한합니다.

  • 따라서 프로세스가 과도한 양의 RAM을 소비하면 OS는 해당 앱을 종료합니다.

  • Crashlytics는 이러한 모든 세부정보를 캡처하고 대시보드에서 종료를 그룹화합니다.

  • Crashlytics에서 메모리 제한 종료를 클릭하면 OOM과 마찬가지로 OS가 개입한 이유에 관한 설명이 표시되므로 방금 말씀드린 내용을 기억할 필요가 없습니다.

  • 또한 다음 단계로 진행하는 방법에 관한 도움말과 Android 메모리 적용 문서로 바로 연결되는 링크도 제공됩니다.

  • OOM과 마찬가지로 스택 트레이스는 OS가 플러그를 뽑았을 때 무고한 프레임을 비난하고 Crashlytics 대시보드는 이를 강조 표시합니다.

  • 이제 메모리 제한기 종료는 주로 네이티브 코드에서 발생합니다. 네이티브 환경에는 메모리 안전 및 할당을 처리하는 레이어가 없기 때문입니다.

  • 그렇지 않으면 OOM이 발생했을 것입니다.

  • 따라서 첫 번째 질문은 Kotlin 객체가 대규모 네이티브 지원 할당을 보유하고 있는지 여부입니다.

  • 아니면 자체적으로 누출되는 원시 C++ 코드인가요?

  • Perfetto UI에서 'Analyze'를 클릭하여 알아보겠습니다.

  • Perfetto 힙 덤프 탐색기의 개요 탭을 확인합니다.

  • 일반 정보에서 anon RSS와 스왑을 합한 총 프로세스 메모리가 4GB에 도달한 것을 확인할 수 있습니다.

  • 좋습니다.

  • 하지만 그 아래를 보면 힙에서 유지되는 바이트 아래에 비트맵과 같은 Kotlin 객체에 연결된 네이티브 메모리를 포함한 전체 관리 힙이 42MB에 불과합니다.

  • 좋습니다.

  • 따라서 10초 만에 모든 Kotlin 및 Java 코드를 배제했습니다.

  • 따라서 네이티브 C++ 레이어에서 누수가 발생한 것입니다.

  • ChromaClip에는 프레임 처리를 위한 얇은 C++ 레이어가 있으므로 멀리서 찾을 필요가 없습니다.

  • 코딩 에이전트나 Android 스튜디오의 네이티브 프로파일러를 NativePaletteEngine.cpp processFrame에 지정하면 버그가 바로 표시됩니다.

    // NativePaletteEngine.cpp
    void NativePaletteEngine::processFrame(const uint8_t* rgba, size_t size) {
        uint8_t* scratch = new uint8_t[size]; // Temporary frame buffer
        transformColorSpace(rgba, scratch, size);
    
        // BUG: Allocated every frame, but never freed!
    +   delete[] scratch; // Free native buffer when done
    }
  • 따라서 모든 동영상 프레임에서 C++ 코드가 임시 스크래치 버퍼를 할당합니다. 해제하지 않으면 임시가 아니게 되죠. 초보적인 실수입니다.

  • 이제 각 단계를 연결할 차례입니다.

  • 무슨 문제가 있나요?

  • 자바 누수를 수정하면 C++ 누수가 누적되어 제어할 수 없게 됩니다.

  • Android 17에서는 메모리 제한기가 이를 감지하고 앱을 종료했습니다.

  • 하지만 메모리 제한기가 아직 없었던 이전 기기에서는 실제로 ANR로 나타났습니다.

  • 이제야 모든 것이 이해가 됩니다.

  • 여기에 delete[] scratch를 추가하고 버전을 올린 후 업데이트를 출시하겠습니다.

  • 메모리 제한기 종료 및 ANR이 마침내 사라졌으므로 1.0으로 출시할 수 있습니다.

  • 흥미롭네요.

  • ChromaClip이 빠르고 안정적으로 작동하면서 앱이 성공을 거둡니다.

  • 매일 수천 명의 사용자가 동영상을 업로드하고 리믹스하고 있습니다.

  • 하지만 트래픽이 많아지면서 앞서 대시보드 하단에서 보았던 알 수 없는 네이티브 충돌이 발생했습니다.

  • 그들도 제멋대로 굴고 있어요.

  • 갑자기 훨씬 더 많은 수의 광고가 표시됩니다.

  • 아직 할 일이 남아 있습니다.

네이티브 메모리 손상으로 인한 비정상 종료 조사

  • 폴리나 고보르코바

    Crashlytics에서 네이티브 충돌을 열고 스택 트레이스를 살펴보겠습니다.

  • 자체 앱의 코드는 한 줄도 없습니다.

  • 메모리를 확보하는 동안 libc.so 내부에서 RenderThread가 비정상 종료됩니다.

  • 여기서 또 다른 팁을 드리자면, 앱 코드가 없는 메모리 할당자 내부에서 시스템 스레드가 비정상 종료되면 이는 네이티브 메모리 손상의 전형적인 징후입니다.

  • 따라서 C++ 코드가 이전에 버퍼를 손상시키거나 해제했고 렌더링 스레드가 나중에 버퍼를 트립했습니다.

  • 그렇다면 손상이 발생한 바로 그 순간에 진짜 범인을 어떻게 잡을 수 있을까요?

  • Android는 두 가지 강력한 도구를 제공하며 Crashlytics는 이 두 가지를 모두 기본적으로 지원합니다.

  • 첫 번째는 프로덕션용으로 특별히 제작된 경량 안전망인 GWP-ASan입니다.

  • 네이티브 힙 할당의 아주 작은 부분을 샘플링하고 가드 페이지로 둘러싸서 CPU 및 메모리 오버헤드가 거의 0이 되도록 합니다.

  • 실제 환경에서 수천 개의 사용자 세션에 걸쳐 버그가 샘플링된 할당에서 해제된 메모리나 범위를 벗어난 메모리를 터치하면 GWP-ASan이 즉시 이를 포착합니다.

  • 하지만 GWP-ASan은 본질적으로 확률적이므로 로컬에서 테스트할 때 무작위 샘플을 얻기 위해 테스트 휴대전화를 16시간 이상 실행하지 않는 것이 좋습니다.

  • 그렇다면 어떤 결과가 나올까요?

  • 결정론적 로컬 디버깅을 위해서는 ARM MTE(Memory Tagging Extension)를 사용합니다.

  • MTE는 하드웨어 포인터와 메모리 태그를 사용하여 모든 할당을 확인하며, Pixel 8 이상과 같은 ARM v9 기기에서 사용할 수 있습니다.

  • 따라서 프로덕션에서 GWP-ASan과 함께 사용하면 두 가지 장점을 모두 누릴 수 있습니다.

  • 예상하셨겠지만, 두 도구 중 하나가 버그를 포착하면 Crashlytics는 자동으로 문제에 배지를 지정하고 GWP-ASan 또는 MTE로 필터링할 수 있도록 하며, 대시보드에 정확한 손상 원인이 포함된 정보 카드를 고정합니다.

  • GWP-ASan이 비정상 종료를 포착하면 Crashlytics 콘솔에서 어떤 일이 발생하는지 살펴보겠습니다.

  • 일반 스택 트레이스 탭 옆에 메모리 스택 트레이스가 있습니다.

  • 상단에는 정확한 손상 원인인 Use After Free가 표시됩니다.

  • 이제 GWP-ASan은 libc.so에서 렌더링 스레드가 비정상 종료되는 것을 보여주는 대신 자체 effect_pipeline.cc 내에서 해당 버퍼의 전체 기록을 제공합니다.

  • 따라서 Allocated By 아래에서 버퍼가 생성된 위치를 확인할 수 있습니다.

  • Deallocated By 바로 아래에는 렌더링 스레드에서 메모리를 해제한 정확한 원인이 표시됩니다.

  • 효과 파이프라인의 208번째 줄에 숨겨져 있습니다.

  • 따라서 EffectPipeline에서 네이티브 코드를 열면 버그가 우리를 비웃고 있습니다.

    // effect_pipeline.cc
    void EffectPipeline::onFilterSwitched(FilterPresetId preset) {
        // BUG: Freed on worker thread while RenderThread is still reading it!
    +   std::lock_guard<std::mutex> lock(lut_mutex_);
    
        releaseLutBuffer();       // Frees old color-grading LUT
        loadFilterPreset(preset); // Allocates new color-grading LUT
    }
  • 사용자가 백그라운드 스레드에서 색상 필터를 전환할 때마다 RenderThread가 여전히 읽고 있는 동안 이전 버퍼를 해제합니다.

  • 버퍼 스왑에 뮤텍스 잠금을 추가하고 업데이트를 배포한 후 GWP-ASan 보고서와 미스터리한 비정상 종료가 거의 하룻밤 사이에 사라지는 것을 확인했습니다.

  • 비정상 종료가 발생하지 않은 사용자 측정항목은 100%로 급증했습니다.

  • 축하합니다.

  • 강연 초반에 '완료되었는가?'라는 질문을 던졌던 것을 기억하시나요?

  • 반응성과 메모리 관련 문제가 한꺼번에 발생하기 직전입니다.

  • 이제 ANR, 메모리 부족 오류, 메모리 제한기 종료, 네이티브 메모리 손상 비정상 종료가 모두 해결되었습니다.

  • 스택 트레이스가 거짓이고 범인이 C++의 스레드 어딘가에 숨어 있더라도 Android와 Firebase Crashlytics는 추적하고 자신 있게 출시할 수 있는 도구를 제공합니다.

요약 및 사전 체험판 신청

  • 폴리나 고보르코바

    노트북으로 돌아가서 바로 사용할 수 있는 기능을 요약해 보겠습니다. ApplicationExitInfo를 통한 ANR 스레드 인사이트 및 기타 관련 메타데이터, ProfilingManager를 통한 OOM 및 메모리 제한기 종료 트리거의 자동 수집, 네이티브 메모리 손상에 대한 전체 GWP-ASan 및 Arm MTE 지원이 있습니다.

  • Android 17 이상에서 필드 힙 덤프가 비공개 프리뷰로 출시됩니다.

  • Firebase Crashlytics에서 자동 힙 덤프 수집을 가장 먼저 사용해 보고 Perfetto 및 Android CLI로 분석하고 싶다면 슬라이드를 빠르게 사진으로 찍고 링크에 가입하세요.

  • 이제 대부분의 내용을 살펴봤습니다. 오늘 함께해 주시고 시간을 내주셔서 감사합니다.

  • 기본 스레드가 차단되지 않고, 힙이 깨끗하게 유지되며, 다음 출시가 가장 안정적인 출시가 되기를 바랍니다.

  • 즐거운 디버깅 되세요.

  • 다시 한번 감사드립니다.

코드

코드 스니펫

'소개'의 01:45에 있는 텍스트 스니펫
java.lang.NullPointerException
  at MainActivity.onCreate(MainActivity.kt:42)
  at android.app.Activity.performCreate
  at android.os.Handler.dispatchMessage
  at android.os.Looper.loopOnce
  at android.app.ActivityThread.main
'소개'의 01:58에 있는 텍스트 스니펫
"main" prio=5 tid=1 Blocked
  | group="main" sCount=1 dsCount=0 flags=1
  | state=S schedstat=( 1450239 48192 145 )
  at DataManager.getData(DataManager.kt:25)
  - waiting to lock <0x0a1b> held by thread 12
  at MainActivity.onResume(MainActivity.kt:50)
  at android.app.Activity.performResume
  at android.os.Handler.dispatchMessage
  at android.os.Looper.loopOnce
  at android.app.ActivityThread.main
'ANR 디버깅 및 분석'의 04:45에 표시되는 텍스트 스니펫
14:32:15.105  1205-1250/system_process E/ActivityManager: ANR in com.chromaclip.app (com.chromaclip.app/.MainActivity)
    PID: 14205
    Reason: Input dispatching timed out
    Dumped ANR trace to /data/anr/traces.txt
"main" prio=5 tid=1 Native
  | group="main" sCount=1 dsCount=0 flags=1 obj=0x72a8c3d0 self=0x759fb92000
  | sysTid=14205 nice=-10 cgrp=default sched=0/0 handle=0x7623910548
  | state=S schedstat=( 1450239120 4819203 1453 ) utm=132 stm=13 core=4 HZ=100
  | stack=0x7fed2a0000-0x7fed2a2000 stackSize=8MB
  at sun.misc.Unsafe.park(Native method)
  - waiting on an unknown object
  at java.util.concurrent.locks.LockSupport.park(LockSupport.java:190)
  at kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:279)
  at kotlinx.coroutines.BlockingCoroutine.joinBlocking(Builders.kt:85)
  at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking(Builders.kt:59)
  at kotlinx.coroutines.BuildersKt.runBlocking(Unknown Source:1)
  at com.chromaclip.app.extractor.PaletteExtractor.extractColors(PaletteExtractor.kt:42)
'ANR 디버깅 및 분석'의 08:08에 있는 텍스트 스니펫
DefaultDispatcher-worker-1 (Root blocking)

dalvik.system.VMRuntime.allocateNode (Native method)
android.graphics.Bitmap.createBitmap (Bitmap.java:1124)
android.graphics.Bitmap.createBitmap (Bitmap.java:1091)
com.chromaclip.app.extractor.FrameDecoder.getUncompressedFrame (FrameDecoder.kt:112)
com.chromaclip.app.extractor.PaletteExtractor$processVideo$2.invokeSuspend (PaletteExtractor.kt:65)
kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith (ContinuationImpl.kt:33)
kotlinx.coroutines.DispatchedTask.run (DispatchedTask.kt:106)
java.util.concurrent.ThreadPoolExecutor.runWorker (ThreadPoolExecutor.java:1167)
java.util.concurrent.ThreadPoolExecutor$Worker.run (ThreadPoolExecutor.java:641)
java.lang.Thread.run (Thread.java:923)
'메모리 부족 예외 조사'의 11:54 지점 텍스트 스니펫
java.lang.OutOfMemoryError: Failed to allocate a 256 byte allocation
 at android.graphics.Bitmap.createBitmap
 at android.view.LayoutInflater.inflate(LayoutInflater.java:532)
 at com.chromaclip.app.SettingsActivity.onCreate(SettingsActivity.kt:21)
'힙 덤프 캡처 및 분석'의 15:54에 있는 셸 스니펫
# 1. Install Android CLI & Perfetto skills
$ android skills add android-cli perfetto

# 2. List recent heap dumps from Crashlytics
$ android signals heaps list --kind oom --timeframe=90d

# 3. Download a heap dump as a Perfetto trace
$ android signals heaps download <heap-dump-id> \
    --output ./heaps

# 4. Query top retained objects in the heap dump
$ trace_processor query ./heaps/oom.perfetto-trace ...

# 5. Fetch matching Crashlytics events
$ android signals heaps describe <heap-dump-id>
'힙 덤프 캡처 및 분석'의 16:27에 있는 텍스트 스니펫
# Or ask your favorite LLM in natural language
> Using `android` command, inspect the latest heap dump
  for my OOM issue and propose a fix
● Ran android signals heaps list · download · describe
● Analyzed ./heaps/oom.perfetto-trace (perfetto)
● Updated PaletteExtractor.kt (+6 -4 lines)
'힙 덤프 캡처 및 분석'의 16:47에 나오는 Kotlin 스니펫
// PaletteExtractor.kt
fun extractPalette(framePixels: IntArray): SparseIntArray {
-   val pixelColors = mutableListOf<Pair<Color, String>>() // 8.3M objects per 4K frame!
-   for (px in framePixels) {
-       pixelColors.add(Color.valueOf(px) to "#%06X".format(px))
-   }

+   val colorHistogram = SparseIntArray() // Downsampled & packed ARGB ints!
+   for (i in framePixels.indices step 16) {
+       val argb = framePixels[i]
+       colorHistogram.put(argb, colorHistogram.get(argb) + 1)
+   }
+   return colorHistogram
}
'Investigating native memory issues'의 21:06에 나오는 C++ 스니펫
// NativePaletteEngine.cpp
void NativePaletteEngine::processFrame(const uint8_t* rgba, size_t size) {
    uint8_t* scratch = new uint8_t[size]; // Temporary frame buffer
    transformColorSpace(rgba, scratch, size);

    // BUG: Allocated every frame, but never freed!
+   delete[] scratch; // Free native buffer when done
}
'Investigating native memory corruption crashes'의 25:14에 있는 C++ 스니펫
// effect_pipeline.cc
void EffectPipeline::onFilterSwitched(FilterPresetId preset) {
    // BUG: Freed on worker thread while RenderThread is still reading it!
+   std::lock_guard<std::mutex> lock(lut_mutex_);

    releaseLutBuffer();       // Frees old color-grading LUT
    loadFilterPreset(preset); // Allocates new color-grading LUT
}