Firebase Crashlytics를 사용하여 앱의 메모리 사용량을 최소화하고 세그멘테이션 오류를 방지하세요. 이 세션에서는 Firebase Console과 Android 스튜디오에서 사용할 수 있는 포괄적인 메모리 문제 해결 도구 모음을 살펴봅니다. 먼저 ANR 인사이트와 GWP-ASan 보고서를 활용하여 찾기 어려운 메모리 버그를 진단하는 방법을 살펴본 다음, Android 17에 도입된 새로운 메모리 제한을 이해하고 이에 적응하는 데 도움이 되도록 특별히 설계된 메모리 부족 (OOM) 및 메모리 제한 종료 (MLK) 이벤트용 최신 도구를 살펴보겠습니다.
ApplicationExitInfo를 쿼리하여 멀티스레드 trace를 표시하고 루트 차단 스레드를 식별합니다.ProfilingManager와 통합되어 난독화 해제된 힙 스냅샷을 수집하며, 이는 Perfetto 또는 Android CLI를 통해 검사할 수 있습니다.스크립트
폴리나 고보르코바
안녕하세요.
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.mainMainActivity.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입니다.
폴리나 고보르코바
그렇다면 이 상황에서 당황하는 것 외에 무엇을 해야 할까요?
테스트 휴대전화가 노트북에 바로 연결되어 있으므로 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로 분석하고 싶다면 슬라이드를 빠르게 사진으로 찍고 링크에 가입하세요.
이제 대부분의 내용을 살펴봤습니다. 오늘 함께해 주시고 시간을 내주셔서 감사합니다.
기본 스레드가 차단되지 않고, 힙이 깨끗하게 유지되며, 다음 출시가 가장 안정적인 출시가 되기를 바랍니다.
즐거운 디버깅 되세요.
다시 한번 감사드립니다.
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"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.main14: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)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)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)# 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># 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)// 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
}// 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
}// 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
}