
onTrimMemory 잘 구현해뒀으니 메모리는 안전하다구요? 안드로이드 17(Android 17) 의 MemoryLimiter 는 협조 요청이 아니라 강제 종료라서, 신호 한 번 없이 그냥 죽이거든요. (공식 문서상으로는 현재 익스트림 메모리 누수·아웃라이어 케이스를 우선 타깃으로 보수적으로 한도가 잡혀있긴 합니다.)
지난 주말에 클로드 코드(Claude Code) 와 안드로이드 스튜디오(Android Studio) 를 띄워놓고 사이드 프로젝트를 Beta 4 픽셀 6a에 올려봤습니다. 평소엔 잘 돌던 온디바이스 캡션 생성기가 5분도 못 가서 그냥 죽더라구요. logcat에 단서가 안 잡혀서 한참 헤맸어요.
Android 17 Beta 4가 사실상 코드 데드라인인 이유
Beta 4는 4월 16일에 풀렸고, stable은 6월로 잡혀있다고 합니다. 이번 베타는 신규 API 추가 없이 안정화에만 집중하는 단계라, API 시그니처와 동작이 stable과 동일하게 굳혀진 것으로 보입니다.
지금 Beta 4에서 터지는 오류는 6월 stable에서도 그대로 터진다는 얘기입니다. 코드 수정 → QA → 스토어 심사 → 단계 적용까지 역산하면 5월 말이 사실상 본인 팀의 데드라인이지요.
compileSdk 37로 일단 빌드부터 돌려보시는 게 가장 빠른 첫 점검이에요 (Android 17 = API 37). deprecated 경고가 줄줄이 뜨더라도 그 목록 자체를 6월 백로그로 가져가시면 됩니다.

ML-DSA 양자내성암호 Keystore 적용 시 페이로드 18배 함정
NIST FIPS 204 표준 기반 디지털 서명 알고리즘인 ML-DSA가 Android Keystore에 들어왔습니다. NIST 카테고리 3인 ML-DSA-65와 카테고리 5인 ML-DSA-87 두 가지를 JCA API로 쓸 수 있고, TEE 같은 보안 하드웨어 안에서 키 생성·서명까지 처리됩니다. (Keystore의 보안 레벨과 헷갈리기 쉬워서 NIST 카테고리로 표현하는 게 정확해요.)
코드 패턴은 기존 RSA/EC 키 생성과 거의 같아요.
val kpg = KeyPairGenerator.getInstance("ML-DSA-65", "AndroidKeyStore")
kpg.initialize(
KeyGenParameterSpec.Builder("my-mldsa-key", KeyProperties.PURPOSE_SIGN)
.setDigests(KeyProperties.DIGEST_NONE)
.build()
)
val keyPair = kpg.generateKeyPair()
val signer = Signature.getInstance("ML-DSA-65")
signer.initSign(keyPair.private)
signer.update(payload)
val signature = signer.sign()
근데 진짜 변수는 페이로드 크기예요. ML-DSA-87 서명은 4,627바이트 정도로, RSA-2048의 256바이트 대비 약 18배 더 큽니다. 18배가 어느 정도냐면, 그동안 한 요청에 헤더로 슬쩍 얹어 보내던 서명을 이제는 본문 한 토막처럼 보내야 하는 크기예요. 저대역폭 환경에서 네트워크 타임아웃이 생길 수 있어서, 서버 팀과 사전에 페이로드 한도부터 맞춰두셔야 합니다.
의료·금융 같은 규제 산업이라면 'Harvest Now, Decrypt Later' 위협까지 같이 봐야 한다고 봅니다. 지금 가로채둔 트래픽을 미래에 양자컴퓨터로 복호화하는 시나리오인데, 신규 키 발급부터 ML-DSA-65를 적용하고 기존 RSA 키는 점진적으로 교체하는 하이브리드 전략이 현실적이지 않을까 싶어요.

MemoryLimiter:AnonSwap 강제 종료 진단법
onTrimMemory 콜백은 어디까지나 시스템이 앱한테 "메모리 좀 비워줘" 하고 노크하는 협조 요청이었습니다. Android 17의 MemoryLimiter는 그게 아니라, 단말 총 RAM에 비례해 산정한 앱 상한선을 넘으면 그냥 프로세스를 죽여요. 다만 공식 문서상 현재는 익스트림 케이스를 보수적으로 타깃하는 단계라, 평범한 앱이라도 누수 한두 군데가 겹치면 바로 걸린다고 보시면 됩니다.
문제는 일반 크래시 리포팅 도구가 이 케이스를 못 잡는다는 점이에요. SIGSEGV도 아니고 ANR도 아니라서, Crashlytics 대시보드를 아무리 들여다봐도 단서가 안 보입니다.
진단은 ApplicationExitInfo에서 들어가야 합니다.
val am = getSystemService(ActivityManager::class.java)
val exits = am.getHistoricalProcessExitReasons(packageName, 0, 20)
exits.forEach { info ->
if (info.description?.contains("MemoryLimiter:AnonSwap") == true) {
// 메모리 한도 초과로 강제 종료된 케이스
// pss/rss는 코틀린 프로퍼티 접근(getPss()/getRss() 매핑)으로 그대로 꺼내 씁니다
reportToAnalytics(info.pss, info.rss, info.timestamp)
}
}
reason이 REASON_OTHER로 잡히고 description에 MemoryLimiter:AnonSwap 문자열이 박혀있으면 메모리 한도 초과로 죽은 거더라구요. 저 문자열을 크래시 분석 대시보드의 수동 필터로 먼저 등록해두는 게 우선이라고 봅니다.
여기에 ProfilingManager의 TRIGGER_TYPE_ANOMALY 콜백까지 같이 걸어두면, 종료 직전 힙 덤프가 자동으로 떨어집니다. 사후에 메모리 누수 위치를 잡을 수 있는 거의 유일한 단서예요.
메모리 한도 시뮬레이션과 힙 덤프 자동화 잡
문제는 본인 개발기가 12GB 이상 RAM이라면, Beta 4를 깔아도 좀처럼 재현이 안 된다는 점이에요. 6GB짜리 픽셀 6a 같은 저사양 단말에서 먼저 터지는 경향이라, 본인 책상 위 단말로는 안심하실 수 없습니다.
개인적으로는 평소엔 실기기 위주로 보지만, 이 케이스만큼은 시뮬레이션 한 번 거치고 가시는 게 좋겠어요. 메모리 상한 자체를 좁히는 표준 ADB 명령은 사실상 없으니, 가장 현실적인 조합은 저메모리 실기기(픽셀 6a / 4a 같은 6GB 이하 단말)에 adb shell am send-trim-memory <pkg> COMPLETE 로 메모리 압박을 강제 트리거하거나, Android Studio Profiler의 메모리 압박 트리거(Force GC / Trim Memory) 를 같이 걸어두는 쪽입니다. 이렇게 해두면 본인 노트북에서도 죽는 순간 가까이까지는 끌고 갈 수 있어요.
CI/CD 쪽으로 한 발 더 가시려면 PR 단위로 hprof 파일을 떨어뜨려서 MAT로 자동 비교하는 잡을 추가해두시는 게 좋습니다. 메인 브랜치 대비 메모리 사용량이 일정 % 이상 늘면 머지 차단으로 잡는 식입니다. 리뷰어 눈에 띄기 전에 릭을 사전 차단하는 가장 확실한 방법이라고 봅니다. (요즘은 여기에 HITL 게이트 — 임계 초과 시 사람이 한 번 확인하고 머지 — 를 한 단계 끼워두는 팀도 늘었어요.)
JNI 의존도가 높은 앱이라면 Safer Dynamic Code Loading 확장도 함께 점검하셔야되는데요, 안드로이드 14에서 DEX/JAR에 적용되던 read-only 규칙이 이번에 .so 네이티브 라이브러리까지 넓혀졌습니다. read-only가 아닌 .so를 System.load()로 부르면 UnsatisfiedLinkError가 나면서 멈춰요. 이걸 재시도 루프로 감싸둔 코드라면 그 동안 메모리만 잔뜩 부풀어서 MemoryLimiter에 또 한 번 걸립니다.

6월 stable 전 compileSdk 37 PR 체크리스트
남은 한 달 안에 본인 레포에 올려둘 PR을 좁혀보면 세 갈래로 정리됩니다.
1. compileSdk 37 빌드 PR
모든 점검의 출발점입니다. deprecated 경고 목록 자체가 그대로 6월 백로그가 되니까, 빌드 한 번 통과시킨 뒤 경고 카운트를 이슈 트래커로 옮겨두시면 됩니다. (Android 17 = API 37 매핑, 헷갈리기 쉬우니 한 번 더 확인하시고요.)
2. MemoryLimiter:AnonSwap 원격 로깅 PR
ApplicationExitInfo 조회를 App Startup 시점에 한 번 돌리도록 박아두세요. 그러고 나서 이미지 캐시·비트맵 풀 같은 큰 소비원 상한을 Runtime.getRuntime().maxMemory() 기반 동적 계산으로 바꿔두면, 6GB 단말과 16GB 단말이 같은 정책으로 돌지 않게 됩니다. 단말별 Task Budgets를 명시적으로 두는 셈이지요.
3. ML-DSA 하이브리드 적용 PR (규제 산업 한정)
신규 키 발급 경로에만 ML-DSA-65를 먼저 깔아두고, 기존 RSA 키는 그대로 둡니다. 서버-클라이언트 동기 배포가 어긋나면 곧장 인증 장애가 나니까, 피처 플래그를 같이 걸어둔 상태로 단계 적용하시는 게 안전해요.
마치며
지난 주말 픽셀 6a 위에서 죽었던 그 캡션 생성기는, 비트맵 캐시 상한을 maxMemory() / 8로 다시 잡고 나서야 살아났습니다. 강제 종료까지 5분이었어요. 6월부터는 그 카운트다운이 본인 앱 위에서 똑같이 돌아갑니다.
'Android 개발 > 트러블슈팅' 카테고리의 다른 글
| 안드로이드 17 QPR1 베타 2 픽셀에 깔고 5일, 우리 앱이 깨진 자리 3곳 (0) | 2026.05.21 |
|---|---|
| Android 17 호환성, 6월 Stable 출시 전 반드시 확인할 체크리스트 5가지 (0) | 2026.05.11 |
| 릴리즈 빌드 직후 앱이 죽었습니다 — 난독화 디버깅 일지 (0) | 2026.05.03 |