
Android assets를 미리 gzip한 실측: APK 이득은 0, .pack.gz는 .pack이 되어 FileNotFoundException
미리 gzip한 레벨 팩은 APK 안 압축본보다 팩마다 18바이트 컸습니다. 이름 끝의 .gz는 빌드에서 빠져 기기에서 열리지도 않았습니다.
미리 gzip해 둔 레벨 팩 5개는 합계 705,234바이트였고, 같은 팩을 평문으로 넣은 릴리스 APK 안의 압축 크기는 705,144바이트였습니다. 설계 단계에서 gzip을 고른 근거 "4.4배"는 원본과 gz를 비교한 숫자였고, APK가 스스로 압축한 결과와는 비교하지 않았습니다. 이 결정은 2026년 10월 3일 22시 28분, 화살표 매듭풀기의 1,000레벨 개편 빌드가 S21에서 실행 직후 FileNotFoundException으로 죽으면서 드러났습니다. 같은 팩을 읽는 JVM 테스트는 모두 통과한 상태였습니다.
근거는 게임 저장소의 git 기록(f5b50c0, e3c7781, 4d46413), 같은 날 에이전트 세션 기록에 남은 몽키 로그와 APK 목록, 2026년 10월 28일에 만든 릴리스·디버그 APK의 압축 크기(Python zipfile로 읽음)입니다. 기준일은 2026년 10월 30일입니다.
목차 (9)
gz 705,234바이트 대 APK 안 705,144바이트
2026년 10월 28일에 만든 릴리스 APK를 열어 레벨 팩 5개의 원본 크기와 압축 크기를 읽고, 커밋 4d46413 직전까지 저장소에 있던 .gz 파일 크기와 나란히 놓았습니다. 팩 내용은 4d46413 이후 바뀌지 않았고, 옛 .gz를 풀면 SHA-1이 평문 팩과 같으니 같은 바이트를 비교한 것입니다.
| 팩 | 원본 | 옛 .gz 파일 | 릴리스 APK 안 압축 | 차이 |
|---|---|---|---|---|
| p1 (1~200) | 122,966 | 25,896 | 25,878 | 18 |
| p2 (201~500) | 524,852 | 111,113 | 111,095 | 18 |
| p3 (501~700) | 679,344 | 147,461 | 147,443 | 18 |
| p4 (701~1000) | 1,397,319 | 310,108 | 310,090 | 18 |
| endless (1001~1100) | 492,814 | 110,656 | 110,638 | 18 |
| 합계 | 3,217,295 | 705,234 | 705,144 | 90 |
다섯 팩 모두 차이가 정확히 18바이트입니다. gzip 파일은 deflate 데이터 앞에 10바이트 헤더, 뒤에 8바이트 트레일러(CRC32와 원본 길이)를 붙이니, 압축된 본문 크기는 같고 차이는 gzip 포장뿐입니다. 텍스트 에셋은 미리 gzip하지 마십시오.

같은 팩이 디버그 APK에서는 1,195,288바이트로, 릴리스의 약 1.7배였습니다. 이 차이의 원인은 확인하지 않았습니다. 에셋 크기를 비교할 때는 릴리스 APK를 여십시오.
설계의 4.4배는 원본과 gz를 비교했습니다
gzip을 고른 근거는 그날 오후 에이전트가 쓴 설계안의 한 줄이었습니다. "300레벨이 원본 535KB, gz 121KB(4.4배)"라고 적고, 1,000레벨이면 gz로 0.7~0.85MB가 된다고 추정했습니다. 원본 크기는 APK에 그대로 실리지 않으니(평문 팩도 deflate로 줄여 넣습니다), 비교할 짝은 gz 파일과 APK가 압축한 평문 팩입니다.
이 형식은 17시 18분에 커밋된 개편 명세(f5b50c0)에 들어갔습니다. 커밋 본문에 따르면 에이전트 24개의 교차 검증을 거친 문서이고, 팩을 .pack.gz로 두고 GZIPInputStream으로 읽으라고 적었습니다. 18시 32분에는 커밋 e3c7781이 팩 다섯 개를 이 형식으로 만들었습니다. 이 커밋을 포함해 구현·리뷰·병합을 맡은 에이전트 41개의 워크플로가 22시 27분에 "실패 없음"으로 끝났습니다. 그 '완료'가 JVM 테스트와 디버그 빌드 기준이었던 사정은 '에이전트 수백 개 써도 돼'의 실제에 적어 두었습니다.
APK 목록에는 arrows-p1.pack만 있었습니다
크래시를 처음 잡은 것은 운영자가 첫 요청에 "S21에 설치해서 E2E 테스트까지"를 넣어 둔 덕에 돌린 신규 설치 몽키 테스트였습니다. 앱 데이터를 지우고 시드 7, 21, 99로 무작위 입력 800회를 넣는 스크립트인데, 시드 7과 21에서 각각 fatal 2건이 나왔고 기록에 남은 로그는 모두 같았습니다.
E AndroidRuntime: java.lang.RuntimeException: Unable to start activity
ComponentInfo{…/sugar.arrows.MainActivity}:
java.io.FileNotFoundException: arrows-p1.pack.gz
30초 뒤 에이전트가 디버그 APK의 assets 목록을 열었습니다. 소스 폴더의 arrows-p1.pack.gz는 25,896바이트였고, APK에는 122,966바이트의 assets/arrows-p1.pack이 있었습니다. 팩 다섯 개가 모두 이름에서 .gz가 빠지고 압축이 풀린 크기로 들어 있더군요. 커밋 메시지는 이 동작을 aapt가 한다고 적었습니다.

테스트는 앱과 다른 함수로 같은 파일을 열었습니다
앱과 테스트는 팩을 읽는 클래스 PackStore를 함께 쓰지만, 파일을 여는 함수는 따로 넣었습니다. MainActivity는 PackStore(open = assets::open)이고, ProgressTest는 PackStore(open = { FileInputStream(File(ASSETS, it)) })이며 ASSETS는 src/main/assets입니다. 테스트는 저장소의 파일을 그대로 읽으니 .gz 이름도 gzip 내용도 그대로 보입니다. 생성기 모듈의 테스트 헬퍼 Shipped에는 "앱과 도구가 읽는 방식으로 읽는다"는 주석까지 붙어 있었는데, 앱이 실제로 읽는 길은 AssetManager였습니다.
평문 팩으로 바꿨고, 확장자 우회는 얻는 게 없습니다
22시 31분 커밋 4d46413은 팩을 풀어 평문 .pack으로 바꾸고, Levels.kt가 GZIPInputStream 없이 파일을 읽게 했습니다. 생성기의 writeGz는 평문을 쓰는 writePack이 됐습니다. 앱이 쓰지 않던 옛 팩 arrows-v2.pack(534,864바이트)도 지우고, 그 팩을 읽던 테스트 두 개를 새 팩으로 옮겼습니다. 커밋 직전 테스트 256개(엔진 83, 생성기 40, 앱 133)가 통과했고, 22시 34분 다시 설치한 S21에서 몽키 시드 3개가 모두 fatal 0이었으며 튜토리얼을 지나 1레벨까지 들어갔습니다.
다른 길도 두 가지 있습니다. 확장자를 다른 이름으로 바꾸면 빌드가 이름을 건드리지 않으니 gzip을 지킬 수 있습니다. 그러나 위 표대로 얻는 크기가 없고, 남는 것은 GZIPInputStream과 같은 바이트를 만들려고 넣은 mtime 0 헤더 처리입니다. 소스는 .gz로 두고 앱만 .pack을 열게 고치는 방법은 기기에서는 동작하지만, 테스트가 계속 앱과 다른 이름과 형식을 읽습니다. 이번 사고의 원인을 그대로 두는 길입니다. 두 대안은 저장소에서 시도한 기록이 없고, 이 비교는 글을 쓰며 한 판단입니다.
19줄 이름 검사가 보지 않는 폴더와 모듈
4d46413이 붙인 AssetNamesTest의 본문은 두 문장입니다. 고치기 전에 먼저 돌려 22시 29분에 no asset name ends in gz FAILED를 확인했습니다.
val gz = File("src/main/assets").listFiles().orEmpty().map { it.name }.filter { it.endsWith(".gz") }
assertTrue(gz.isEmpty(), "aapt renames these on install: $gz")
listFiles()는 하위 폴더를 내려가지 않으니 assets/levels/x.gz 같은 파일은 지나갑니다. src/debug/assets 같은 다른 소스 세트와, 같은 저장소의 다른 앱 모듈 assets도 검사 밖입니다. 메시지의 "on install"도 정확하지 않습니다. 설치하기 전 APK 목록에 이미 .pack이 있었으니 이름이 바뀌는 때는 빌드입니다. AGP 9.4.1의 어느 단계가 이 일을 하는지는 확인하지 않았습니다. 크기 측정도 텍스트 팩 1,100판에서만 했으므로, deflate가 줄이지 못하는 파일은 이 결론 밖에 있습니다.
내 APK에서 assets 이름과 크기를 확인하는 순서
- 릴리스 APK의 목록과 소스 폴더를 나란히 봅니다:
unzip -l app-release.apk | grep assets/,ls src/main/assets. 이름과 개수가 다르면 그 파일을 여는 코드부터 찾습니다. - 이름이
.gz로 끝나는 에셋이 있으면 실패하는 JVM 테스트를 붙이고, 고치기 전에 빨간불을 먼저 봅니다. 하위 폴더가 있으면walkTopDown()을 씁니다. - 미리 압축할지는 릴리스 APK 안의 압축 크기로 정합니다. Python이면
zipfile.ZipFile(apk).infolist()의compress_size를 더해 gz 파일 크기와 비교합니다. - 앱 데이터를 지운 신규 설치 상태로 실기기에서 첫 화면을 엽니다. 스크립트는 신규 설치 몽키 테스트 글에 있습니다.
남은 일: 명세에 남은 .pack.gz
개편 명세 docs/09-parts-1000-spec.md는 2026년 10월 29일 HEAD(2310d1f)에도 .pack.gz를 네 줄(다섯 군데)에 적고 있고, 읽기 방법도 GZIPInputStream으로 남아 있습니다. 평문을 읽는 헬퍼 이름은 아직 Shipped.gzLines입니다. 명세를 고치는 김에 AssetNamesTest의 검사 범위도 정합니다.
자주 묻는 질문
Android assets에 넣은 .gz 파일을 열면 왜 FileNotFoundException이 나나요?
이 프로젝트 빌드(AGP 9.4.1)가 이름 끝의 .gz를 떼고 내용을 풀어 APK에 넣었기 때문입니다. APK 목록에는 x.pack.gz 대신 압축이 풀린 x.pack이 있었습니다. 코드는 .gz 없는 이름으로 열고, GZIPInputStream도 빼야 합니다.
에셋을 미리 gzip하면 APK가 작아지나요?
텍스트 레벨 팩 1,100판에서는 작아지지 않았습니다. 릴리스 APK의 deflate 결과가 705,144바이트, 미리 gzip한 파일이 705,234바이트로 팩마다 gzip 헤더와 트레일러 18바이트만큼 컸습니다.
JVM 단위 테스트로 이런 패키징 문제를 잡을 수 있나요?
이름 규칙 하나는 잡을 수 있습니다. 실제로 AssetManager가 파일을 여는지는 기기나 에뮬레이터에서 앱을 실행해야 확인됩니다.
이 글은 SugarMount가 직접 측정한 값으로 쓰고, 초안 작성에 AI 코딩 에이전트를 썼습니다.
다른 AX 노트
- SKT 판매 삼성 폰에서 market://을 열면 스토어 선택 창이 뜬다: Play 패키지부터 지정하기
- addOnCompleteListener(this)가 onStop에서 지운 리스너 때문에 스플래시에 멈춘 버그
- "6.7" < "6.11"이 거짓이라 콘텐츠 갱신이 멈췄다: 6.2~6.9 기기만 막힌 Remote Config 버전 비교
- R8이 지우지 못한 테스트 광고 ID 5개: listOf().any 한 줄과 dex 건수 검사
- 화살표 퍼즐 1,100판을 만든 역구성 생성기와 G2 게이트
- 대개편 직후 DAU는 올랐고, 자연 유입 코호트 D1은 15.2%에서 11.2%로 내려갔다