
비공개 테스트에 버전 업데이트를 누른 지 세 시간 만에 크래시 리포트가 쏟아졌습니다. 디버그로는 단 한 번도 안 터졌던 화면이었어요. 로그엔 a.b.c.d 같은 암호만 가득했습니다.
처음엔 서버 문제인 줄 알았어요. 그런데 디버그용 폰에서 디버그 빌드를 켜면 멀쩡히 돌아가는거죠.
뭐지 싶어서, 급하게 비공개 테스트 테스터로 제 자신을 등록하고 플레이스토어에서 앱을 받아 켰더니 켜지자마자 죽더군요. 딱 릴리즈 빌드만 죽는거였어요.
이 상황에서 유일한 실마리는 업데이트 전에 적용했던, isMinifyEnabled = true 부분이었어요. — 결국 난독화였던거죠.
디버그는 정상, 릴리즈만 충돌하는 진짜 이유
R8과 ProGuard는 릴리즈 빌드 시 클래스명·메서드명을 a, b, c 같은 짧은 이름으로 바꿔버립니다. 코드 크기를 줄이고 역공학을 어렵게 만들려는 목적인데요, 문제는 이 과정에서 런타임에 필요한 클래스나 메서드가 제거되거나 이름이 바뀌는 상황에서 터집니다. 런타임에 "이 클래스 어디 갔어?" 하는 순간 ClassNotFoundException이 뜨거든요.
디버그 빌드는 난독화를 적용하지 않으니까 모든 심볼이 그대로 살아 있어요. 그래서 디버그에서 멀쩡히 돌아가던 게 릴리즈에서만 죽는 거라는 얘기가 됩니다. 릴리즈 충돌의 상당 부분이 이 패턴에서 온다는 게 안드로이드 개발자 사이에선 거의 공통 경험이기도 할 것 같습니다.
Keep 규칙 누락이 런타임 폭탄이 되는 과정
한 가지 짚고 싶은 게 있어요. 난독화 자체가 "원인"이 아닙니다. 정확히는 불완전한 Keep 규칙이 문제거든요. Keep 규칙은 R8에게 "이 클래스·메서드는 이름 바꾸지 마"라고 알려주는 명령어입니다. Keep 규칙이 빠지면 특히 세 가지 상황에서 바로 터집니다.
첫 번째는 리플렉션(Reflection)을 사용할 때입니다. Class.forName("com.example.MyModel")처럼 문자열로 클래스를 찾는 코드는 난독화 후 원래 이름이 사라지면 즉시 실패해요. Gson, 레트로핏(Retrofit) 같은 라이브러리가 내부적으로 Reflection에 의존하는 편이라 이 부분이 위험합니다.
두 번째는 객체를 직렬화(Serialization) 할 때입니다. Intent나 SharedPreferences로 넘기는 커스텀 객체가 직렬화될 때 필드명이 바뀌어 있으면 역직렬화에서 실패하기도 합니다.
세 번째는 매니페스트(Manifest)에만 등록된 컴포넌트 때문입니다. 매니페스트에만 등록하고 코드에서 직접 참조하지 않은 Activity나 Service가 "사용하지 않는 코드"로 판단돼 제거(Shrinking)해 버릴 위험이 있습니다.
이러한 문제들은 proguard-rules.pro에 아래처럼 규칙을 추가하면 이 문제를 잡을 수 있습니다.
-keep class com.example.model.** { *; }
-keepclassmembers class * implements java.io.Serializable {
private static final java.io.ObjectStreamField[] serialPersistentFields;
private void writeObject(java.io.ObjectOutputStream);
private void readObject(java.io.ObjectInputStream);
java.lang.Object writeReplace();
java.lang.Object readResolve();
}

직렬화 라이브러리가 난독화에 취약한 실제 사례
Gson, 룸(Room), 레트로핏은 런타임에 필드명이나 클래스명을 직접 읽어서 작동하는 라이브러리입니다. 그래서 난독화에 유독 예민한 편이에요.
Gson / Moshi: JSON 역직렬화 시 필드명이 키로 쓰입니다. 난독화로 userName이 a로 바뀌면 JSON의 "userName" 키와 매핑이 깨집니다. @SerializedName("userName") 어노테이션을 붙이면 부분적으로 보완되는데요. 이것도 Keep 규칙 없이는 어노테이션 자체가 날아갈 수 있거든요.
룸: @Entity, @Dao 같은 어노테이션으로 DB 구조를 잡는 라이브러리입니다. Keep 규칙이 빠지면 런타임에 테이블 매핑이 실패해서 SQLiteException이 바로 뜨는 경우가 많거든요.
개인적으로 가장 당황스러웠던 건 레트로핏이었어요. API 호출은 되는데 응답 파싱에서만 터지는 케이스를 한참 못 잡았거든요. 나중에 보니 레트로핏 공식 문서에 명시된 ProGuard 권장 규칙을 proguard-rules.pro에 빠뜨린 게 원인이었습니다. 라이브러리마다 공식 문서에 ProGuard 규칙이 따로 있으니, 새 라이브러리를 추가할 때마다 그 부분을 먼저 확인하는 습관이 중요하더라구요.
mapping.txt가 없으면 스택 트레이스를 못 읽습니다
a.b.c.d 같은 난독화된 로그를 그냥 읽으며 디버깅하는 건 거의 불가능합니다. 다행히 R8은 빌드할 때마다 원본 이름과 난독화된 이름의 대응표, 즉 mapping.txt를 자동으로 만들어줍니다.
파일 위치는 build/outputs/mapping/release/mapping.txt예요. 수동으로 스택 트레이스를 복원하려면 아래처럼 씁니다.
retrace mapping.txt crash.stacktrace
a.b.c.d가 실제 클래스·메서드 이름으로 바뀌면서 로그를 읽을 수 있게 됩니다.

파이어베이스 크래시리틱스(Firebase Crashlytics)를 연동해두면 이 과정이 자동화돼요. 배포할 때 mapping.txt를 자동 업로드해서 대시보드에서 바로 읽히는 스택 트레이스를 보여줍니다. 저는 크래시리틱스 없이 릴리즈를 배포했다가 이번에 진짜 고생했네요.
mapping.txt는 릴리즈 버전마다 반드시 별도로 백업해야 합니다. 이 점은 여러번 강조해도 부족합니다. 빌드를 다시 돌리면 파일이 덮어씌워지기 때문에, 배포 직후 따로 챙겨두지 않으면 사후 디버깅이 불가능해질 수 있거든요.

릴리즈 배포 전 반드시 점검해야 할 세 가지
사실 이번 해프닝의 진짜 원인은 릴리즈 전에 난독화 빌드를 따로 테스트하지 않은 것이었어요. 디버그로만 확인하고, 바로 비공개테스트에다가 앱 버전업한다고 배포 버튼을 눌러버렸거든요.
이런 일을 겪지 마시라고 세 가지로 정리해봤습니다.
1. 디버그 빌드의 build.gradle에 minifyEnabled true를 임시로 추가해 로컬에서 먼저 돌려보시면 좋겠어요. 릴리즈와 완전히 동일한 환경은 아니지만, Keep 규칙 누락은 여기서 대부분 잡힙니다.
2. 구글 플레이 내부 테스트 트랙을 활용해 실제 릴리즈 빌드를 소수 인원에게 먼저 배포하시는 걸 추천드립니다. 로그인, API 호출, DB 조회, 파일 저장 같은 주요 흐름을 48~72시간 돌려보고 나서 공개 배포하는 게 안전한 편이거든요. 절대로 비공개테스트에다가 바로 배포하지마세요.
3. 새 라이브러리를 추가할 때마다 공식 문서의 ProGuard/R8 권장 규칙을 바로 proguard-rules.pro에 넣어두세요. 나중에 몰아서 하려다 보면 어디가 빠졌는지 추적이 정말 힘들어집니다.
마치며
Keep 규칙을 꼼꼼히 챙기고, mapping.txt를 버전마다 백업하고, 릴리즈 전에 난독화 빌드를 반드시 따로 검증하는 것. 이 세 가지만 루틴으로 굳혀도 새벽에 크래시 리포트 보며 멘붕 오는 일은 확 줄어든다고 확신합니다.
저도 이번에 한 번 크게 데인 뒤로 빌드 체크리스트에 박아두었는데요. 같은 고생을 하시지 않도록 꼭 챙기시라고 자신있게 말씀드리고 싶습니다.
'Android 개발 > 트러블슈팅' 카테고리의 다른 글
| Android 17 Beta 4 메모리 한도와 ML-DSA — 6월 stable 한 달 전 본인 앱 깨지는 자리 (0) | 2026.05.26 |
|---|---|
| 안드로이드 17 QPR1 베타 2 픽셀에 깔고 5일, 우리 앱이 깨진 자리 3곳 (0) | 2026.05.21 |
| Android 17 호환성, 6월 Stable 출시 전 반드시 확인할 체크리스트 5가지 (0) | 2026.05.11 |