
사내 프로젝트에 Claude Code skill을 통해 로컬 자동 배포 환경을 구축하면서 각 Phase 별 로그를 확인하던 도중, uploadCrashlyticsMappingFile라는 Phase의 존재를 확인할 수 있었다.
클로드에게 물어보니 'R8 난독화 역추적용 매핑 파일을 Firebase에 올리는 단계' 라고 하는데, 문득 'Crashlytics는 어떻게 난독화된 앱의 크래시 로그를 수집할 수 있는가?' 에 대해 한번도 의문을 품은 적이 없던 나를 발견할 수 있었다.
따라서 이번 글에선 이에 대해 알아보고자 하겠다.
핵심 질문인 "Crashlytics는 어떻게 난독화된 앱의 크래시 로그를 수집할 수 있는가?"에 답하려면 다음 세 가지를 순서대로 살펴볼 필요가 있다.
mapping.txt)마지막으로 빌드 성능을 위해 이 과정을 variant 별로 제어하는 법도 짧게 덧붙이도록 하겠다.
R8(또는 ProGuard, DexGuard)은 릴리즈 빌드 시 다음을 수행한다.
a, b, c 같은 짧고 의미 없는 문자열로 치환at a.b.c.a(Unknown Source:3)
at a.b.c.b(Unknown Source:12)
원본 HomeViewModel.loadArticles() 같은 메서드명이 a.b.c.a()가 되어버리니, 이 상태로는 어느 코드 실행시 크래시가 발생했는지 알 방법이 없다.
난독화 과정에서 R8은 원본 이름 ↔ 난독화된 이름의 대응 관계를 기록한 mapping.txt 파일을 생성한다.
경로: app/build/outputs/mapping/release/mapping.txt
파일 내용은 아래와 같이 구성된다.
예시)
com.example.feature.home.HomeViewModel -> a.b.c:
void loadArticles() -> a
void refreshFeed(boolean) -> b
난독화된 이름(-> 오른쪽)이 원본(-> 왼쪽) 어떤 심볼에 대응되는지 한 줄씩 매핑되어 있다. 이 파일만 있으면 난독화된 스택 트레이스를 원본으로 되돌릴 수 있다(이 과정을 deobfuscation / retrace라고 부름).
프로그래밍에서 심볼 = 사람이 읽을 수 있는 이름표(식별자). 변수명, 함수명, 클래스명 등이 전부 심볼이다.
단, 메서드 이름을 돌리는 것만으로는 부족하고 줄 번호와 소스 파일명도 필요하기 때문에 ProGuard 설정에 다음이 필수다.
-keepattributes SourceFile,LineNumberTable
-keep public class * extends java.lang.Exception # 커스텀 예외 유지 (선택)
이걸 빼면 스택 트레이스에서 라인 넘버가 사라져 역추적이 모호해진다.
참고로 난독화된 심볼명은 매 빌드마다 랜덤하게 바뀌지 않는다.
R8의 이름 할당은 결정론적(deterministic)이라 같은 소스 + 같은 R8 버전 + 같은 룰이면 항상 같은 결과가 나온다(재현 가능한 빌드를 위한 의도된 동작).
소스/룰/R8 버전이 바뀌면 그때는 달라지게 되며, 보안 강도는 "이름이 매번 바뀌는지"가 아니라 "mapping 파일이 공개되지 않는지" 에 전적으로 달려있다.
mapping.txt는 APK/AAB에 포함되지 않는다. 바이너리에 들어가면 난독화 의미가 사라지기 때문!
대신 빌드 산출물(app/build/outputs/mapping/release/)로 로컬에만 남고, Crashlytics는 이걸 별도로 서버에 올려둔다. 바이너리에는 "어느 mapping과 짝인지" 식별자(Build ID UUID)만 심기므로, APK를 까봐도 심볼 복원은 불가능하다.
단,mapping.txt를 git에 커밋하거나 CI 아티팩트를 public으로 노출하지 않도록 주의 필요
Firebase Crashlytics가 난독화된 앱의 크래시를 해독해 보여줄 수 있는 이유는, 빌드 시점에 mapping.txt를 서버로 미리 올려두기 때문이다. 이 일을 하는 Gradle task가 바로 uploadCrashlyticsMappingFile{Variant}다.
빌드 시점 (CI/로컬)
mapping.txt 생성com.google.firebase.crashlytics)이 난독화 활성화(minifyEnabled = true) 여부를 자동 감지uploadCrashlyticsMappingFile{Variant} task가 mapping.txt를 고유한 mappingFileId와 함께 Firebase 서버에 업로드런타임 (사용자 기기에서 크래시 발생)
서버 측 (Crashlytics 백엔드)
즉, 서버에 크래시가 업로드되는 시점에는 앱이 이미 "어떤 mapping 파일과 짝이어야 하는지"까지 가지고 오기 때문에, 서버가 그 매핑 파일로 디코딩만 해주면 되는 구조다.
빌드시 매번 mapping 업로드 과정이 수반되면 그만큼 빌드 시간은 당연히 느려지게 된다.
특정 variant에서만 끄고 싶다면 mappingFileUploadEnabled 옵션을 쓰면 된다!
android {
buildTypes {
getByName("debug") {
isMinifyEnabled = true
configure<CrashlyticsExtension> {
mappingFileUploadEnabled = false // debug는 업로드 스킵
}
}
}
productFlavors {
create("staging") {
configure<CrashlyticsExtension> {
mappingFileUploadEnabled = false
}
}
create("prod") {
configure<CrashlyticsExtension> {
mappingFileUploadEnabled = true // prod만 업로드
}
}
}
}
참고로 mappingFileUploadEnabled = false 가 실제로 의미를 가지려면 해당 variant에 minifyEnabled = true 가 함께 켜져 있어야 한다. uploadCrashlyticsMappingFile Phase는 난독화가 활성화되어 mapping.txt가 생성될 때만 동작하기 때문이다.
class FirebaseCrashlyticsInitializer : Initializer<Unit> {
override fun create(context: Context) {
FirebaseCrashlytics.getInstance().isCrashlyticsCollectionEnabled = BuildConfig.BUILD_TYPE != "debug"
}
override fun dependencies(): List<Class<out Initializer<*>>> {
return emptyList()
}
}
또한, Firebase Crashlytics를 초기화하는 코드에 Debug 환경일때 로그를 수집하지 않는 설정은 mappingFileUploadEnabled 옵션과 완전히 별개이다.(별개의 레이어)
두 설정은 시점도, 주체도, 차단하는 데이터도 다르다.
| 설정 | 언제 | 어디서 → 어디로 | 뭘 차단 |
|---|---|---|---|
isCrashlyticsCollectionEnabled | 런타임 (앱 실행 중) | 앱이 설치된 기기 → Firebase | 크래시 리포트 전송 |
mappingFileUploadEnabled | 빌드 타임 (Gradle 실행 중) | 빌드 프로세스 → Firebase | mapping.txt 업로드 |
전자는 "앱이 돌다가 터졌을 때 그 리포트를 서버로 보낼지"를, 후자는 "빌드할 때 해독 열쇠(mapping)를 서버에 미리 올려둘지"를 결정한다.
Firebase Crashlytics를 연동해본 사람들은 알겠지만 setup 과정이 정말 간단하다.
build.gralde.kts에 플러그인 한 줄 추가하고, Application이나 Initializer에서 초기화 한 번만 해주면 끝이다.
근데 그 단순한 외관 뒤에 이렇게 많은 메커니즘(R8의 mapping 생성 → Gradle task의 자동 hook-in → Build ID 기반 빌드-매핑 매칭 → 서버 측 retrace까지) 이 단계적으로 돌아가고 있었다는 점이 새삼 놀라웠다.
서두에 언급한 uploadCrashlyticsMappingFile task의 정체를 한 줄로 요약하면 — "빌드 산출물인 mapping.txt를 Firebase 서버에 올려서, 나중에 유저 기기에서 올라오는 난독화된 스택 트레이스를 서버가 원본 심볼로 되돌릴 수 있게 해주는 단계" 이다.
핵심은 "앱에는 난독화된 코드만 배포하고, 해독 열쇠(mapping 파일)는 서버가 따로 가지고 있다"는 비대칭 구조다.
이 덕분에 보안(리버스 엔지니어링 방어)과 디버깅 편의성(사람이 읽을 수 있는 크래시 로그)이라는, 원래라면 trade-off 관계인 두 가치를 동시에 챙길 수 있다.
평소 릴리즈 빌드에서 Crashlytics 로그가 술술 읽히는 걸 당연하게 여겨왔는데, 이번 기회에 그 "당연함" 을 떠받치는 토대를 들여다보았다.
앞으로 빌드 로그에서 uploadCrashlyticsMappingFile Phase가 지나갈 때, 적어도 저 단계에서 어떤 일들이 벌어지는지는 머릿속으로 그려질 것이라 생각된다.
앞으로도 당연하게 해왔던 것들에 대한 의문을 품고, 이에 대한 글을 꾸준히 작성해봐야겠다.
reference)
https://firebase.google.com/docs/crashlytics/android/get-deobfuscated-reports?hl=ko
https://www.guardsquare.com/manual/tools/retrace
https://github.com/firebase/firebase-android-sdk/issues/2818