핵심 요약
Android 앱이 16 KB 페이지 크기 기기에서 설치·실행되지 않을 때 APK ZIP 정렬, ELF LOAD·RELRO 정렬, AGP·NDK·SDK 버전과 페이지 크기 가정을 구분해 확인합니다.
검수 범위
네이티브 .so 파일을 직접 또는 SDK·프레임워크를 통해 포함하는 Android 릴리스 APK·AAB를 대상으로 합니다. Flutter 앱도 최종 산출물에 포함된 엔진과 플러그인 라이브러리를 확인합니다. 저장 공간 부족, 서명 충돌, 네트워크 오류는 별도 원인입니다.
같은 Android 앱이 어떤 기기에서는 정상 실행되는데 다른 기기에서는 설치되지 않거나 시작 직후 종료된다면, 기기의 메모리 페이지 크기와 네이티브 라이브러리 호환성을 확인할 가치가 있습니다. 다만 기기별 실패를 모두 16 KB 문제로 판단하면 ABI 누락이나 서명 충돌을 놓칠 수 있습니다. 먼저 실제 페이지 크기와 실패 지점을 기록해야 합니다.
여기서 16 KB는 APK의 최대 용량이나 앱의 메모리 제한이 아닙니다. 운영체제가 메모리를 관리하는 페이지의 크기입니다. 64비트 ABI 지원 여부와도 별개의 조건입니다. 이 글은 패키지 안의 ZIP 정렬 → 라이브러리 내부 ELF 정렬 → 실행 중 페이지 크기 가정을 나눠 원인을 찾는 순서를 제안합니다.
먼저 확인할 것: 실패 기기가 실제 16 KB 환경인가?
테스트 기기를 연결하고 다음 읽기 전용 명령으로 페이지 크기를 확인합니다. 기기 이름이나 Android 버전만 보고 추정하지 않습니다. AOSP는 Android 15부터 16 KB 환경을 지원하지만 모든 기기가 그 설정을 사용하는 것은 아닙니다.
adb shell getconf PAGE_SIZE
# 4096: 4 KB 환경
# 16384: 16 KB 환경
AOSP의 페이지 크기 확인 문서는 getconf와 프로그램 코드로 실제 값을 얻는 방법을 설명합니다. 이 값과 앱 versionCode, 설치 경로, 기기 ABI, 호환 모드 상태를 한 묶음으로 기록하면 다른 조건을 페이지 크기 문제로 오인하기 어렵습니다.
| 상황 | 우선 확인 | 16 KB 문제의 증거가 아닌 것 |
|---|---|---|
| APK 설치 단계에서 실패 | 패키지 정렬·서명·ABI·설치 오류 코드 | “앱이 설치되지 않음”이라는 화면 문구만 있음 |
| 시작 직후 native crash | LOAD·RELRO 정렬, crash buffer, 로드한 .so | 앱이 꺼졌다는 사실만 있음 |
| 특정 기능을 열 때만 종료 | 그때 처음 로드되는 SDK·플러그인 | 메인 화면이 실행됐으므로 모든 .so가 정상이라고 판단 |
| 호환성 경고 뒤 정상 실행 | page-size compatibility mode 유무 | 호환 모드에서 실행됐다는 사실을 완전한 지원으로 판단 |
| 4 KB·16 KB에서 모두 실패 | 공통 원인부터 분석 | 새 기기라는 이유만으로 페이지 크기 탓을 함 |
세 가지 검사를 서로 대체하지 않기
| 검사 층 | 무엇을 확인하는가? | 주요 도구 | 수정 주체 |
|---|---|---|---|
| APK ZIP 정렬 | 압축되지 않은 .so가 패키지 안의 적절한 경계에 위치하는가? | zipalign | AGP·패키징 설정 |
| ELF 정렬 | .so 내부의 로드·보호 영역이 환경과 맞는가? | llvm-objdump·llvm-readelf | NDK·링커·SDK 공급자 |
| 런타임 가정 | 코드가 4096을 실제 페이지 크기로 고정했는가? | 소스 검색·기능 테스트·crash 로그 | 네이티브 코드 작성자 |
ZIP 정렬은 패키지 배치이고 ELF 정렬은 파일 내부 구조입니다. zipalign으로 APK를 다시 배치해도 이미 빌드된 .so의 LOAD 정렬이 바뀌지는 않습니다. 정적 검사에 통과한 라이브러리도 실행 중 잘못된 mmap 인자를 만들면 실패할 수 있으므로 마지막 런타임 테스트를 생략하지 않습니다.
1단계: 소스가 아니라 실제 릴리스 산출물의 .so 목록 만들기
Android Studio에서 Build > Analyze APK로 배포할 APK나 AAB를 엽니다. APK Analyzer는 ZIP 구조와 최종 파일을 보여주므로, 소스 프로젝트에서 눈에 띄지 않던 간접 의존성도 확인할 수 있습니다. lib/arm64-v8a와 lib/x86_64 등 ABI별 .so를 목록으로 남깁니다.
“내 코드는 Kotlin뿐”이라는 판단보다 최종 파일 목록이 중요합니다. 카메라, 데이터베이스, 압축, 보안, 게임 엔진이나 크로스플랫폼 런타임이 네이티브 코드를 포함할 수 있기 때문입니다. Flutter에서는 Dart 코드만 보지 말고 엔진·앱·플러그인에 해당하는 라이브러리를 함께 조사합니다.
| 기록할 값 | 용도 |
|---|---|
| 파일명과 APK 내부 경로 | 실패한 바이너리를 정확히 특정 |
| ABI | 서로 다른 아키텍처의 파일을 혼동하지 않음 |
| 직접 빌드 / prebuilt 여부 | 내 빌드 설정으로 수정할 수 있는지 판단 |
| SDK·플러그인 이름과 버전 | 호환 버전 요청과 업데이트 대상 확인 |
| SHA-256과 빌드 ID | 로컬·CI·배포 파일이 같은지 비교 |
| ZIP·LOAD·RELRO 검사 결과 | 한 번의 “정상” 판정을 세 검사로 분리 |
이후 명령의 SDK 경로, Build-Tools·NDK 버전과 파일명은 예시입니다. 실제 설치 경로로 바꾸고 원본과 분리한 분석 폴더에서 실행하세요. 이 글의 명령을 독자의 프로젝트에서 실제 수행한 결과로 주장하지는 않습니다.
2단계: zipalign으로 APK 패키지 정렬 확인
Build-Tools의 zipalign을 읽기 전용 검사 모드 -c로 실행합니다. -P 16은 압축되지 않은 .so의 페이지 경계를 검사하며, 마지막 4는 다른 파일의 기본 정렬 단위입니다. 이 두 숫자를 같은 의미로 해석하지 않습니다.
# Windows PowerShell 예시
$sdkRoot = 'C:AndroidSdk'
& "$sdkRootbuild-tools35.0.0zipalign.exe" -c -P 16 -v 4 .app-release.apk
출력과 종료 코드를 함께 보존합니다. 실패하면 AGP와 라이브러리 패키징 설정을 확인하고 새 산출물을 만들지, 서명된 배포 APK를 임의로 덮어쓰지 않습니다. apksigner 문서에 따르면 서명 뒤 APK를 변경하면 서명이 무효가 됩니다. 직접 빌드 시스템에서 정렬을 수행해야 한다면 서명 전에 적용합니다.
검사에 성공해도 ELF와 런타임이 정상이라는 뜻은 아닙니다. 압축 라이브러리를 쓰는 경우에도 내부 .so 검사와 실행 테스트는 필요합니다.
3단계: 모든 64비트 .so의 LOAD 정렬 확인
APK의 복사본을 ZIP으로 풀거나 APK Analyzer에서 라이브러리를 추출합니다. 아래는 새 분석 폴더를 쓰는 PowerShell 예시입니다. Expand-Archive가 ZIP 확장자를 요구하는 환경을 고려해 원본 APK를 유지한 채 복사합니다.
Copy-Item -LiteralPath .app-release.apk -Destination .app-release-inspect.zip
Expand-Archive -LiteralPath .app-release-inspect.zip -DestinationPath .apk-inspect
$ndkRoot = 'C:AndroidSdkndkYOUR_NDK_VERSION'
$objdump = "$ndkRoottoolchainsllvmprebuiltwindows-x86_64binllvm-objdump.exe"
& $objdump -p .apk-inspectlibarm64-v8alibsample.so | Select-String 'LOAD'
llvm-objdump -p는 형식별 헤더를 출력합니다. Android의 16 KB 지원 가이드는 각 LOAD segment의 정렬 값이 2**14보다 작지 않은지 확인하도록 안내합니다. 2**12는 4096이고 2**14는 16384입니다.
LOAD ... align 2**12 # 이 검사 기준에서 미달
LOAD ... align 2**14 # LOAD 정렬 기준 충족
라이브러리 하나의 첫 LOAD 줄만 보지 말고 모든 LOAD 줄을 확인합니다. 같은 이름의 arm64와 x86_64 파일도 서로 다른 바이너리이므로 각각 검사합니다. 하나라도 미달이면 파일을 제공한 SDK 버전을 교체하거나 해당 .so를 호환 설정으로 다시 빌드해야 합니다.
4단계: LOAD가 정상이어도 RELRO 끝 경계 확인
최신 Android 가이드는 RELRO 영역도 별도로 다룹니다. GNU_RELRO는 재배치 후 읽기 전용으로 보호하는 영역이며, 영역 끝이 16 KB 경계와 맞지 않으면 런타임 crash를 일으킬 수 있습니다. LOAD만 통과한 오래된 네이티브 빌드에서 놓치기 쉬운 항목입니다.
$readelf = "$ndkRoottoolchainsllvmprebuiltwindows-x86_64binllvm-readelf.exe"
& $readelf -Wl .apk-inspectlibarm64-v8alibsample.so | Select-String 'Type|GNU_RELRO'
# Android 가이드의 확인식
(VirtAddr + MemSiz) % 0x4000 == 0
llvm-readelf -l은 program header를 보여줍니다. RELRO 판정에서 출력의 Align 열만 보고 실패라고 결론 내리지 않고 VirtAddr와 MemSiz를 사용합니다. 가령 설명용 값 0x2f000 + 0x1000 = 0x30000은 경계에 맞지만, 0x2f000 + 0x2000 = 0x31000은 나머지가 0x1000이므로 맞지 않습니다.
미달한 파일은 올바른 링커 설정으로 재빌드합니다. 검사를 통과하려고 RELRO 보안 보호를 제거하는 방식은 권하지 않습니다. RELRO가 없는 파일을 발견했다면 “문제가 해결됐다”가 아니라 해당 바이너리의 보안·빌드 설정을 별도로 검토할 항목으로 남깁니다.
5단계: AGP·NDK·외부 SDK를 서로 다른 역할로 업데이트
공식 16 KB 가이드의 패키징 기준선은 AGP 8.5.1 이상입니다. NDK r28은 64비트 shared library의 16 KB 정렬과 flexible page size 지원을 기본으로 켰습니다. 이 버전들은 “오늘의 최신 버전”이라는 뜻이 아니라 관련 기본 동작이 갖춰진 기준선입니다. 프로젝트의 Gradle·JDK·Flutter 또는 게임 엔진 호환 범위도 함께 확인합니다.
| 대상 | 변경으로 해결할 수 있는 것 | 변경만으로 해결되지 않는 것 |
|---|---|---|
| AGP | APK·AAB 라이브러리 패키징 | 외부 prebuilt의 ELF 내부 정렬 |
| NDK·링커 | 직접 컴파일하는 .so의 구조 | 소스 없이 그대로 복사하는 prebuilt |
| SDK·플러그인 | 공급자가 재빌드한 호환 바이너리 | 자체 네이티브 코드의 4096 가정 |
| 런타임 코드 | mmap·메모리 관리의 페이지 크기 가정 | 이미 만들어진 배포 파일의 패키지 정렬 |
NDK prebuilt 문서는 이미 빌드한 shared library를 ABI별로 포함하는 구조를 설명합니다. 따라서 내 NDK 버전만 바꿔서는 공급자가 만든 .so가 자동 재컴파일되지 않습니다. SDK 공급자에게 지원 버전·대상 ABI·재빌드 여부를 확인하고 새 파일의 실제 검사 결과까지 비교합니다.
업데이트 뒤에는 마지막 릴리스 산출물을 다시 생성하고 .so 목록과 hash를 대조합니다. 캐시에 이전 바이너리가 남았는지 의심된다면 빌드 시스템이 제공하는 정상적인 clean 절차를 이용하되, 소스·서명 키·사용자 데이터를 임의로 삭제하지 않습니다.
6단계: 로컬 APK뿐 아니라 AAB에서 만들어진 APK도 확인
Google Play에 AAB를 올린다면 로컬에서 따로 만든 APK 하나의 성공으로 배포 호환성을 확정하지 않습니다. bundletool은 AAB에서 기기별 APK 집합을 만들고 필요한 split APK를 설치합니다. 최종 배포 경로와 비슷한 산출물을 검사해야 합니다.
java -jar .bundletool.jar dump config --bundle=.app-release.aab | Select-String 'PAGE_ALIGNMENT'
java -jar .bundletool.jar build-apks --bundle=.app-release.aab --output=.app-test.apks --connected-device
java -jar .bundletool.jar install-apks --apks=.app-test.apks
AAB 설정에서 PAGE_ALIGNMENT_16K는 16 KB ZIP 정렬을 요청한다는 의미입니다. 설정값만 보는 것에 더해 생성된 APK 내부의 .so와 정렬도 확인합니다. 여러 기기가 연결돼 있으면 --device-id를 지정해 다른 기기에 설치하지 않도록 합니다.
bundletool 테스트 서명은 실제 배포 서명과 다를 수 있습니다. 운영 앱을 지우고 덮어씌우는 대신 별도 테스트 기기·계정에서 검증하세요. 마지막 단계는 Play 내부 테스트 트랙에서 동일 versionCode의 앱을 설치해 확인하는 방식이 좋습니다. Flutter의 Android 배포 문서도 app bundle과 bundletool·Play 테스트 경로를 구분합니다.
7단계: 4096 상수를 전부 바꾸지 말고 실제 용도 확인
직접 관리하는 네이티브 코드에서 4096, PAGE_SIZE, mmap, mprotect, 메모리 pool의 정렬 계산을 검색합니다. 파일 I/O buffer가 4096바이트인 것은 실제 메모리 페이지 가정과 다를 수 있으므로 모든 4096을 16384로 일괄 치환하지 않습니다.
#include <unistd.h>
#include <stdexcept>
long systemPageSize() {
const long value = sysconf(_SC_PAGESIZE);
if (value <= 0) {
throw std::runtime_error("cannot read page size");
}
return value;
}
실제 페이지 경계가 필요한 계산은 런타임 값을 사용합니다. 예를 들어 파일 mapping offset을 페이지 경계로 내린 뒤 원래 위치와의 차이를 따로 보관해야 할 수 있습니다. mapping 길이·offset 범위·overflow·해제할 원래 주소도 함께 검토해야 하므로 값 하나를 바꾸는 수정으로 끝내지 않습니다.
NDK r28에서 PAGE_SIZE가 기본 정의되지 않는 것은 고정 페이지 크기 가정을 드러내기 위한 변화입니다. 빌드 오류를 없애기 위해 다시 4096 매크로를 선언하는 것보다, 그 값이 페이지 크기인지 단순 buffer 용량인지 구분해 이름과 계산을 고칩니다. 예외 사용 여부는 해당 C++ 프로젝트의 오류 처리 규칙에 맞춰 적용하세요.
8단계: 실제 16 KB 환경과 호환 모드 없이 기능 테스트
Android Studio SDK Manager에서 16 KB 페이지 크기 system image로 테스트 환경을 만들고, 부팅 뒤 getconf PAGE_SIZE가 16384인지 다시 확인합니다. Android 16의 page-size compatibility mode가 일부 비호환 앱을 실행시킬 수 있으므로, 모드 덕분에 실행된 결과를 기본 호환으로 기록하지 않습니다.
Android 16 동작 변경 문서에서 호환 모드를 확인하고 격리된 테스트 환경에서 앱별 모드를 구분해 검사합니다. 사용자 기기의 시스템 전체 설정을 바꿔 원인을 숨기는 방식은 피합니다.
adb shell getconf PAGE_SIZE
adb logcat -b crash -d
crash buffer에서 실패한 .so, signal, linker 메시지와 호출 스택을 확보합니다. UnsatisfiedLinkError나 segmentation fault 자체는 페이지 크기만의 증거가 아닙니다. 누락된 ABI·의존 라이브러리·symbol 문제인지, 정렬 검사와 일치하는지 함께 판단합니다.
| 테스트 | 기대 결과 |
|---|---|
| 4 KB와 16 KB의 동일 ABI 릴리스 설치 | 둘 다 설치·시작되고 배포 서명 조건이 일치 |
| 16 KB 환경에서 앱별 호환 모드 구분 | 호환 모드에 기대지 않아도 정상 실행 |
| 첫 실행과 재실행 | 초기화와 저장 데이터 재사용 모두 정상 |
| 카메라·DB·압축 등 native 기능 열기 | 늦게 로드되는 SDK에서도 crash 없음 |
| AAB에서 생성한 split APK 설치 | 로컬 단일 APK와 다른 누락·정렬 문제 없음 |
| 파일 mapping·큰 데이터 처리 | offset·길이 경계에서 실패·메모리 급증 없음 |
| 지원하는 각 64비트 ABI | 해당 ABI의 모든 .so가 별도로 검증됨 |
배포 전에 남길 가장 작은 증거 세트
CI 결과를 단순한 “16 KB OK” 문자열 하나로 남기면 실패 층을 다시 찾기 어렵습니다. 다음 항목을 릴리스별 보고서로 보존하면 의존성 업데이트가 호환성을 되돌렸는지도 확인할 수 있습니다.
- versionCode·commit·AGP·NDK·프레임워크·SDK 버전
- 배포 AAB와 검사 APK의 hash
- ABI별 .so 목록·LOAD·RELRO 결과
- zipalign 출력·종료 코드와 AAB 정렬 설정
- 테스트 기기의 PAGE_SIZE·ABI·호환 모드 상태
- 실행한 native 기능과 crash 로그 유무
예를 들어 “ZIP 통과, libsample.so의 LOAD 미달, 공급자 업데이트 필요”처럼 원인과 담당자를 함께 적습니다. 도구가 0으로 끝나도 의미상 미달 값이 남을 수 있으므로 ELF header 출력은 기준값까지 검사해야 합니다.
가장 짧은 해결 순서
- 실패 기기의 실제 페이지 크기와 릴리스 버전을 확인합니다.
- 최종 APK·AAB의 ABI별 .so와 공급자를 목록화합니다.
- APK ZIP 정렬과 .so의 LOAD·RELRO를 각각 검사합니다.
- 패키징 문제는 AGP에서, ELF 문제는 NDK·링커 또는 공급자 바이너리에서 고칩니다.
- 4096 가정과 메모리 mapping 코드를 실제 런타임 값 기준으로 검토합니다.
- AAB 배포 경로와 실제 16 KB 환경에서 native 기능을 테스트합니다.
세 검사를 분리하면 설치 성공이나 첫 화면 실행만으로 호환성을 과신하지 않으면서, 손댈 필요가 없는 앱 코드까지 무작정 바꾸는 일도 줄일 수 있습니다.
검증 기준과 참고 자료
이 글은 2026-09-17에 Android·AOSP·NDK·LLVM·Flutter 공식 문서를 기준으로 패키지 정렬, ELF LOAD·RELRO, prebuilt 관리와 실제 페이지 크기 확인 방법을 검수했습니다. Google Play의 제출 일정과 SDK별 지원 버전은 변경될 수 있으므로 배포 시 공식 안내와 공급자 문서를 다시 확인하세요. 명령과 수치는 진단용 예시이며 특정 독자 앱의 검증 결과를 대신하지 않습니다.
- Android: Support 16 KB page sizes
- AOSP: Get the page size
- Android: zipalign
- Android: APK Analyzer
- Android: bundletool
- Android NDK: r28 changelog
- Android NDK: Use prebuilt libraries
- LLVM: llvm-objdump
- LLVM: llvm-readelf
- Android: apksigner
- Android 16: Behavior changes for all apps
- Flutter: Build and release an Android app