안드로이드 R8 난독화: 보안 기능이 아닌 이유
Play Console에 앱 번들을 올리면 이런 경고가 붙는 경우가 있습니다.
이 App Bundle 유형과 연결된 가독화 파일이 없습니다.
난독화된 코드(R8/proguard)를 사용하는 경우 가독화 파일을 업로드하면
비정상 종료 및 ANR을 더 쉽게 분석하고 디버그할 수 있습니다.읽어보면 이상한 데가 있습니다. 난독화가 코드를 감추는 기술이라면, 스토어가 그걸 되돌리는 파일을 왜 달라고 할까요. 그리고 그 파일을 올리면 왜 스택 트레이스가 원래 이름으로 복원될까요.
되돌리기가 이렇게 간단하면, 그건 감추는 장치가 아닙니다.
흔히 난독화를 보안 기능으로 이해합니다
minifyEnabled true를 켜는 이유를 물으면 대체로 "코드를 못 보게 하려고"라는 답이 돌아옵니다. 한국어 "난독화"라는 이름이 이 이해를 강하게 밀어줍니다. 읽기 어렵게 만든다는 뜻이니까요.
이 모델로는 설명되지 않는 것이 둘 있습니다.
첫째, R8이 만드는 mapping.txt는 바뀐 이름과 원래 이름을 1:1로 나열한 표입니다. 파일 하나면 전부 되돌아갑니다. 둘째, 안드로이드 진영은 그 파일을 스토어에 올리라고 안내합니다. 보호를 목적으로 하는 기술이라면 복원 키를 배포처에 제출하라고 권하지 않습니다.
R8의 이름 바꾸기는 보호 장치가 아니라 부산물입니다.
R8이 실제로 하는 일은 네 가지입니다
minifyEnabled라는 플래그 이름이 이미 답을 말하고 있습니다. minify, 즉 작게 만들기입니다. R8은 안드로이드의 기본 축소 도구이고, 한 번 돌 때 네 가지 일을 합니다.
코드 축소가 첫 번째입니다. 진입점에서 출발해 도달 가능한 클래스와 메서드를 추적하고, 닿지 않는 것을 지웁니다. 앱이 쓰지 않는 라이브러리 코드가 여기서 날아갑니다.
리소스 축소는 참조되지 않는 drawable, layout, string을 지웁니다. shrinkResources는 코드 축소 결과에 의존하기 때문에 minifyEnabled 없이는 동작하지 않습니다.
최적화는 인라인, 죽은 분기 제거, 클래스 병합 같은 변형입니다.
이름 바꾸기가 네 번째이고, 우리가 난독화라고 부르는 그것입니다. com.example.repository.CustomerRepository가 a.b.c가 됩니다. 짧은 이름은 DEX의 문자열 풀을 줄이므로 이것도 결국 크기 작업입니다.
네 가지 중 셋이 크기고, 나머지 하나도 크기 때문에 있습니다. 이 도구는 처음부터 끝까지 축소 도구입니다.
난독화와 이웃 개념들의 경계
암호화와 다릅니다. 암호화된 데이터는 키 없이 읽을 수 없습니다. 이름이 바뀐 코드는 그냥 읽힙니다. 읽는 데 시간이 더 걸릴 뿐입니다. APK를 풀어 DEX를 디컴파일하면 a.b.c의 내용은 그대로 나옵니다. 문자열 리터럴은 아예 손대지 않으므로, 코드에 박아둔 API 키는 난독화를 켜든 안 켜든 똑같이 보입니다.
무결성 검증과도 다릅니다. 앱이 변조됐는지, 정품 설치인지 판단하는 것은 별개의 문제고 별개의 장치가 필요합니다. 이름 바꾸기는 아무것도 검증하지 않습니다.
제가 보기에 실무에서 갈리는 지점은 여기입니다. 클라이언트에 두면 안 되는 것은 난독화를 켜도 여전히 두면 안 됩니다. 서버가 지켜야 할 것을 앱이 지키게 만들 수는 없습니다.
깨지는 이유는 하나입니다
R8은 호출 그래프를 정적으로 따라갑니다. 코드가 클래스 이름을 문자열로 들고 런타임에 찾아 쓰면, 그 경로는 그래프에 나타나지 않습니다. R8이 보기에 그 클래스는 아무도 안 쓰는 코드입니다. 지워지거나 이름이 바뀌고, 앱은 실행 중에 클래스를 못 찾아 죽습니다.
리플렉션, JNI, 애노테이션 기반 직렬화가 전부 이 패턴입니다. Gson이나 Moshi가 필드 이름으로 JSON을 매핑하는 것도 마찬가지입니다. 필드 이름이 a로 바뀌면 매핑이 어긋납니다.
그래서 -keep 규칙이 있습니다. 손대지 말라고 R8에게 직접 알려주는 것입니다.
-keep class com.swmansion.reanimated.** { *; }
-keep class com.facebook.react.turbomodule.** { *; }React Native 프로젝트의 proguard-rules.pro에서 흔히 보이는 줄입니다. TurboModule은 자바스크립트 쪽에서 이름 문자열로 네이티브 모듈을 찾습니다. R8은 그 문자열을 읽지 않으므로, 지키라고 적어주지 않으면 없앱니다.
이것이 React Native와 Expo에서 축소가 기본으로 꺼져 있는 이유입니다. 자바스크립트와 네이티브가 이름 문자열로 만나는 구조에서는 정적 분석이 볼 수 없는 경로가 많습니다. 라이브러리마다 필요한 keep 규칙이 다르고, 빠졌을 때 증상은 릴리스 빌드에서만 나타납니다. 디버그에서는 재현되지 않습니다.
-keep을 넉넉하게 쓸수록 축소 효과는 줄어듭니다. 안전과 크기가 정확히 반대 방향입니다.
mapping.txt를 다시 보면
축소 도구라는 관점에서 보면 처음의 경고가 자연스럽게 읽힙니다.
이름 바꾸기는 크기를 줄이려고 한 일이고, 그 대가로 스택 트레이스가 읽을 수 없게 됩니다. 잃고 싶지 않으니 대응표를 남깁니다. 그게 app/build/outputs/mapping/release/mapping.txt이고, retrace 도구나 Play Console이 이 표로 트레이스를 복원합니다.
보호 장치의 열쇠를 넘기는 것이 아닙니다. 압축 과정에서 잃어버린 이름표를 돌려받는 것입니다.
그래서 설계에서 무엇이 달라지나
켜는 이유는 크기입니다. 보안을 이유로 켜기로 했다면 판단의 근거가 틀린 것이고, 실제로 지키려던 것은 여전히 안 지켜집니다.
켜지 않기로 해도 잃는 보안은 없습니다. 크기 이득을 포기하는 것뿐입니다. 출시 일정이 급하다면 저는 켜지 않고 넘어갑니다. 나중에 켜는 것은 언제든 가능하고, 급할 때 켜는 것이 위험합니다.
켠다면 릴리스 빌드로 전 화면을 직접 돌아봐야 합니다. keep 규칙 누락은 컴파일도 통과하고 테스트도 통과합니다. 실행해서 그 화면에 들어가야 드러납니다.
진짜 보호가 필요하면 다른 것을 봐야 합니다. 비밀은 서버에 두고, 설치 무결성이 필요하면 그 목적의 장치를 씁니다. 난독화는 그 자리에 놓을 물건이 아닙니다.
여기까지가 확실한 부분
R8이 무슨 일을 하는지, 왜 깨지는지, mapping.txt가 무엇인지는 도구의 동작이라 분명합니다.
얼마나 줄어드는지는 앱마다 다릅니다. 쓰지 않는 라이브러리 코드가 많을수록 많이 줄고, 이미 얇은 앱이면 거의 안 줄어듭니다. 네이티브 라이브러리(.so)와 에셋은 R8이 손대지 않으므로, 용량의 대부분이 그쪽인 앱은 켜도 체감이 없습니다. 일반화된 비율을 말하는 글이 있다면 저는 믿지 않겠습니다. 자기 앱에서 두 번 빌드해 재보는 것 말고 답이 없습니다.
어떤 라이브러리가 깨지는지도 미리 알 수 없습니다. 잘 만든 라이브러리는 자기 keep 규칙을 함께 배포하지만, 전부 그렇지는 않습니다.
함께 읽기
- EAS 빌드 큐 40분, 로컬 5분: 로컬로 옮기기 전에 백업해야 할 것앱 하나를 출시하려고 EAS에 안드로이드 production 빌드를 걸었습니다. 큐에서 40분을 기다렸습니다. 무료 티어라 빌드 머신이 빌 때까지 순서를 기다리는 구조입니다.
- Expo 앱 다국어: 사전의 키를 한국어 원문으로 둔 이유성경 지도 앱에 언어 다섯 개를 더했습니다. 영어, 일본어, 중국어 간체와 번체, 스페인어입니다. 화면이 하나뿐이고 서버도 없는 앱이라 붙이는 일 자체는 간단할 줄 알았는데, 번역문을 어디에 둘지에서 막혔습니다.
- Google Play 출시 트랙 정리: 내부·비공개·공개·프로덕션과 App Store 비교오늘 성경 지도라는 앱을 App Store와 Google Play 양쪽에 올렸습니다. iOS를 먼저 끝내고 Play Console로 넘어왔는데, 출시 개요 페이지에서 카드 네 장을 보고 잠깐 멈췄습니다.
- 스토어 메타데이터 API 정리: 항목별로 되는 것과 안 되는 것앱 설명과 키워드를 채우면서 두 스토어의 API 를 항목별로 다 두드려 봤습니다. 콘솔에 들어가지 않고 어디까지 갈 수 있는지 궁금했거든요.
- 바이너리 등록 후 남은 것들: 자동화되는 일과 사람이 해야 하는 일빌드가 끝나고 양쪽 스토어 콘솔에 바이너리가 올라갔습니다. 저는 그때 "이제 다 됐다"고 생각했습니다. 실제로는 거기서부터가 절반이었습니다.