용량을 줄이려고 JSON을 gzip으로 압축해 assets에 넣었는데, 앱에서 열면 FileNotFoundException이 납니다. 안드로이드 빌드 도구가 빌드 중에 gzip을 풀어서 넣고 .gz 확장자까지 떼어 버리기 때문입니다. 더 나쁜 건 앱이 멀쩡히 켜진다는 점입니다 — 그 파일을 여는 화면으로 들어가야만 죽습니다.
지역별 데이터를 들고 다니는 앱을 만들면서 지역마다 JSON 하나씩을 assets에 넣었습니다. 합치면 꽤 커서 gzip으로 압축했습니다.
app/src/main/assets/
r0000.json.gz
r0001.json.gz
...
코드는 평범합니다.
val stream = context.assets.open("r0000.json.gz")
val json = GZIPInputStream(stream).bufferedReader().readText()
설치하고 실행했더니 앱은 잘 떴습니다. 로그캣에도 이상이 없었습니다.
ActivityTaskManager: Displayed com.example/.MainActivity: +2s800ms
그런데 지역을 하나 탭하는 순간 앱이 죽었습니다.
java.io.FileNotFoundException: r0000.json.gz
APK 안을 열어 보면 파일 이름이 다르다
분명히 넣었는데 없다고 합니다. APK 안을 직접 봤습니다.
unzip -l app-release.apk | grep r0000
들어 있는 것은 이것이었습니다.
assets/r0000.json # .gz 가 없다
빌드 도구가 gzip을 풀어서 평문 JSON으로 넣고, 확장자에서 .gz까지 떼어 버린 것입니다. 파일이 사라진 게 아니라 이름이 바뀐 겁니다. 그래서 r0000.json.gz로 여는 코드는 당연히 못 찾습니다.
가장 먼저 시도한 게 이것이었습니다.
androidResources {
noCompress += "gz"
}
이 설정은 "APK를 만들 때 이 확장자는 더 압축하지 마라"는 뜻이지, "내가 넣은 gzip을 건드리지 마라"가 아닙니다. 방향이 반대라 원하는 일이 일어나지 않습니다.
해결 — 평문으로 넣는다
결론부터 말하면 assets에 gzip을 넣을 이유가 거의 없습니다. APK 자체가 zip이고 그 안에서 deflate로 압축되기 때문입니다. 제 경우 평문 JSON을 넣었더니 APK 안에서 약 4분의 1로 줄었습니다. 직접 압축해서 얻는 것이 사실상 없었습니다.
app/src/main/assets/
r0000.json # 그냥 평문
// 코드도 단순해진다
val json = context.assets.open("r0000.json").bufferedReader().readText()
정말 gzip인 채로 들고 가야 한다면
서버에서 받은 gzip을 그대로 캐시해 두는 것처럼, 압축된 상태를 유지해야 하는 경우가 있습니다. 그럴 때는 빌드 도구가 내용을 들여다보지 않는 확장자를 쓰면 됩니다. r0000.json.bin처럼 바꿔서 넣고, 코드에서는 여전히 GZIPInputStream으로 읽으면 됩니다. 확장자는 이름일 뿐이라 내용과 무관합니다.
다만 이렇게까지 할 값어치가 있는지는 먼저 재 보세요. 평문으로 넣고 APK 크기를 비교하는 데 10분이면 됩니다.
진짜 문제는 이게 늦게 발견된다는 것
이 글을 쓰는 이유입니다. 이 결함은 앱이 정상으로 켜지는 동안 숨어 있습니다.
- 로그캣에
Displayed가 정상으로 찍힙니다 - 프로세스도 살아 있습니다
- 첫 화면이 멀쩡히 그려집니다
스크린샷 한 장으로 "실행 확인"을 끝냈다면 이 결함을 안고 배포했을 것입니다. 실제로 저는 첫 화면을 보고 넘어갈 뻔했고, 목록에서 항목을 하나 탭해 보고 나서야 알았습니다.
assets든 네트워크든, 데이터를 처음 여는 지점은 대개 첫 화면이 아니라 그다음 화면입니다. 목록에서 하나 눌러 상세까지 들어가 보는 것까지가 "실행 확인"입니다. 확인은 이렇게 합니다.
adb shell screencap -p /sdcard/s.png
adb pull /sdcard/s.png
윈도우 PowerShell에서 > s.png로 받으면 PNG가 깨집니다(인코딩이 끼어듭니다). 기기에 파일로 찍고 pull 하세요.
정리
- assets의
.gz는 빌드 중에 풀리고 확장자까지 떨어집니다. APK 안에는.json으로 들어 있습니다. noCompress는 "더 압축하지 마라"라서 이 문제와 방향이 다릅니다.- 확인은
unzip -l app-release.apk로 APK 안의 실제 이름을 보면 1초입니다. - APK가 어차피 압축하므로 평문으로 넣는 것이 정답입니다. 제 경우 약 4분의 1이 됐습니다.
- 압축 상태를 유지해야 하면
.bin같은 확장자로 위장합니다. - 앱은 정상 실행되다가 그 파일을 여는 순간에만 죽습니다. 첫 화면만 보면 못 잡습니다.
자주 묻는 것
res/raw에 넣으면 어떤가요?
res/raw도 빌드 도구가 관리하는 영역이라 같은 처리를 기대하는 편이 안전합니다. 확실히 하고 싶으면 넣어서 빌드한 뒤 unzip -l로 결과물을 확인하세요. 어느 쪽이든 결과물을 보고 판단하는 습관이 답입니다. 설정 문서를 읽고 추측하는 것보다 빠릅니다.
디버그 빌드에서는 잘 되던데요?
빌드 타입에 따라 리소스 처리 설정이 다르게 걸려 있으면 그럴 수 있습니다. 그래서 더 위험합니다 — 개발 중에는 멀쩡하다가 릴리스에서만 죽으면 원인을 찾는 데 시간이 훨씬 오래 걸립니다. 릴리스 APK를 풀어서 확인하세요.
데이터가 정말 크면 어떻게 하나요?
APK에 넣지 않고 실행 중에 내려받는 쪽을 고려해 보세요. 저는 60MB짜리 지역 데이터를 별도 저장소에 두고 사용자가 자기 지역 하나만 받도록 바꿔서, 릴리스 APK를 21MB에서 1.1MB로 줄였습니다. 받은 파일은 filesDir에 캐시해 두면 오프라인에서도 열립니다. 전송 구간 압축은 서버가 알아서 해 줍니다.
