목차
개요
Android 15부터 Pixel 8 이상, 갤럭시 S26 등 신형 기기는 메모리 페이지 크기를 기존 4KB에서 16KB로 사용하기 시작했습니다. AAB(Android App Bundle) 내부에 포함된 네이티브 라이브러리(.so)가 16KB 정렬을 만족하지 못하면, Play Store가 해당 단말에 대해 앱을 호환 불가로 표시하여 검색 결과에서 노출되지 않습니다.
이 글에서는 Flutter 프로젝트에서 16KB 페이지 크기 호환성을 확보하기 위해 수행한 작업을 정리합니다.
- 빌드 설정에서 정렬 보존을 명시
- AAB 산출물의 정렬을 자동으로 검증하는 정적 게이트 구축
- 16KB 에뮬레이터에서 런타임 동작 확인
왜 16KB 페이지 크기가 문제가 되는가
리눅스 커널은 가상 메모리를 페이지 단위로 관리합니다. 4KB 페이지를 사용하던 기기에서는 ELF(Executable and Linkable Format) 바이너리의 LOAD 세그먼트가 4KB(0x1000) 정렬만 만족해도 메모리 매핑이 가능했습니다.
16KB 페이지 기기에서는 LOAD 세그먼트가 16KB(0x4000) 정렬을 만족해야 합니다. 정렬되지 않은 .so를 dlopen()하면 런타임에 다음과 같은 오류가 발생합니다.
dlopen failed: "libxxx.so" is not page-aligned
Play Store는 AAB 업로드 시점에 이 정렬을 검사합니다. AGP 8.5+ 는 미정렬 라이브러리에 대해 경고를 출력하고, AGP 8.7+ 는 빌드 자체를 실패 처리합니다. 그럼에도 사전 빌드된 플러그인의 .so가 정렬을 만족하지 못하는 경우가 종종 있으므로, CI 단계에서 산출물을 직접 검증할 필요가 있습니다.
빌드 설정 변경
AGP 버전 확인
먼저 android/settings.gradle.kts(또는 android/build.gradle)에서 Android Gradle Plugin 버전이 8.5 이상인지 확인합니다.
plugins {
id("com.android.application") version "8.7.0" apply false
// ...
}
8.5 미만이라면 업그레이드가 필요합니다. AGP 8.5+ 는 빌드 시점에 16KB 정렬을 강제하므로, 정렬되지 않은 플러그인이 포함되어 있다면 빌드가 실패하면서 어떤 플러그인이 원인인지 알려줍니다.
useLegacyPackaging 명시
android/app/build.gradle.kts 의 android { ... } 블록에 packaging 설정을 명시적으로 선언합니다.
android {
// ...
// 16KB page-size compatibility — keep extractNativeLibs=false so AGP/Android packs
// .so files uncompressed and preserves their LOAD-segment alignment for Galaxy S26
// and other 16KB-page devices. AGP 8.5+ enforces 16KB alignment at packaging time.
packaging {
jniLibs {
useLegacyPackaging = false
}
}
}
useLegacyPackaging = false 는 .so 파일을 APK/AAB 내부에서 압축하지 않고 그대로 패키징한다는 의미입니다. 압축하지 않아야 ZIP 엔트리 단위로 정렬이 보존되고, 런타임에 mmap()으로 직접 매핑할 수 있습니다.
AGP 8.0+ 부터 이미 기본값이 false 입니다만, 회귀 방지를 위해 명시적으로 선언합니다. 다른 플러그인이 manifest merger에서 extractNativeLibs="true" 를 주입하는 경우가 있어, 기본값에 의존하면 의도치 않게 압축 패키징으로 돌아갈 수 있기 때문입니다.
정적 검증 게이트 구축
빌드 설정만으로는 부족합니다. CI에서 AAB 산출물을 직접 풀어 정렬을 검증하는 스크립트를 만들어, 회귀를 사전에 차단합니다.
검증은 두 단계로 구성됩니다.
- 단계 A: AAB(ZIP) 안의
base/lib/arm64-v8a/*.so와base/lib/x86_64/*.so의 ELF LOAD 세그먼트 정렬 확인 - 단계 B: bundletool로 universal APK 를 만들어 ZIP 엔트리 단위로 16KB 정렬 확인
llvm-readobj로 .so의 LOAD 정렬 확인
llvm-readobj 는 ELF 헤더를 파싱하여 LOAD 세그먼트의 Alignment 값을 출력합니다. Android NDK 에 동봉되어 있어 별도 설치가 필요 없습니다.
#!/usr/bin/env bash
# verify_16kb_alignment.sh (요약)
AAB_PATH="${1:-build/app/outputs/bundle/release/app-release.aab}"
WORK_DIR="$(mktemp -d -t verify16kb_XXXXXX)"
trap 'rm -rf "${WORK_DIR}"' EXIT
# AAB(ZIP) 풀기
unzip -q "${AAB_PATH}" -d "${WORK_DIR}/aab"
ISSUES=0
for abi in arm64-v8a x86_64; do
for so in "${WORK_DIR}/aab/base/lib/${abi}"/*.so; do
# LOAD 세그먼트의 Alignment 값이 16384(0x4000) 이상이어야 정상
align=$(llvm-readobj -l "${so}" \
| awk '/Type: PT_LOAD/{found=1} found && /Alignment:/{print $2; exit}')
if [ "${align}" -lt 16384 ]; then
echo "FAIL: ${abi}/$(basename ${so}) p_align=${align} (< 0x4000)"
ISSUES=$((ISSUES + 1))
else
echo "OK: ${abi}/$(basename ${so}) aligned p_align=${align}"
fi
done
done
if [ "${ISSUES}" -eq 0 ]; then
echo "RESULT: PASS"
exit 0
else
echo "RESULT: FAIL (${ISSUES} issues)"
exit 1
fi
핵심은 llvm-readobj -l 로 추출한 PT_LOAD 세그먼트의 Alignment 값이 16384(0x4000) 이상인지 확인하는 것입니다. arm64-v8a 와 x86_64 두 ABI 를 모두 검사합니다. 32-bit ABI(armeabi-v7a, x86)는 16KB 페이지 기기가 지원하지 않으므로 검사 대상에서 제외됩니다.
zipalign으로 universal APK ZIP 정렬 확인
ELF 정렬뿐 아니라 APK ZIP 자체의 엔트리 정렬도 검증합니다. bundletool 로 universal APK 를 만들고 zipalign -P 16 옵션으로 검사합니다.
# universal APK 생성
bundletool build-apks \
--bundle="${AAB_PATH}" \
--output="${WORK_DIR}/app-release.apks" \
--mode=universal \
--ks=android/key.jks --ks-pass=pass:... --ks-key-alias=...
# .apks 에서 universal.apk 추출
unzip -j "${WORK_DIR}/app-release.apks" universal.apk -d "${WORK_DIR}"
# 16KB ZIP 정렬 검사 (-P 16 = page size 16KB, -c = check, -v = verbose)
zipalign -c -v -P 16 4 "${WORK_DIR}/universal.apk"
-P 16 은 페이지 크기 16KB 기준으로 정렬을 검사하라는 의미이고, 4 는 일반 파일의 ZIP 엔트리 정렬 단위입니다. zipalign 의 종료 코드가 0이 아니면 정렬되지 않은 엔트리가 있다는 뜻입니다.
CI 워크플로에서는 이 두 단계를 묶어 release 빌드의 사후 검증 단계로 배치합니다.
# .github/workflows/release_android.yml (요약)
- name: Build release AAB
run: flutter build appbundle --release
- name: Verify 16KB alignment
run: bash scripts/verify_16kb_alignment.sh build/app/outputs/bundle/release/app-release.aab
만약 FAIL 이 출력되면 원인 플러그인의 업스트림 PR 을 트래킹하거나, 임시 fork 또는 대체 플러그인으로 우회합니다. 어떤 경우든 CI 가 실패한 상태로 release 가 진행되지 않는 것이 핵심입니다.
16KB 에뮬레이터에서 런타임 검증
정적 검증을 통과해도 JNI 호출 시점에 16KB 정렬이 깨지는 경우가 있을 수 있습니다(예: 광고 SDK 가 동적으로 .so 를 로드하는 케이스). 런타임 동작은 정적으로 잡을 수 없으므로, 릴리스 전 1회는 16KB 에뮬레이터에서 직접 실행합니다.
16KB 시스템 이미지 설치
16KB 페이지 크기 시스템 이미지의 SDK 패키지명은 ..._ps16k 접미사를 사용합니다. in_app_purchase 같은 결제 SDK 검증을 위해 Play 스토어가 포함된 변형(...playstore_ps16k)을 권장합니다.
# 라이선스 미수락 시 1회 실행
"${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" --licenses
# 16KB 시스템 이미지 설치 (API 36 / Apple Silicon 예시)
"${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" --install \
"system-images;android-36;google_apis_playstore_ps16k;arm64-v8a"
# AVD 생성
"${ANDROID_HOME}/cmdline-tools/latest/bin/avdmanager" create avd \
--name "Pixel_8_Pro_16KB_API36" \
--package "system-images;android-36;google_apis_playstore_ps16k;arm64-v8a" \
--device "pixel_8_pro" \
--force
Apple Silicon Mac 에서는 arm64-v8a ABI 를, Intel 호스트에서는 x86_64 ABI 를 선택합니다.
부팅 후 페이지 크기 확인
# 부팅
"${ANDROID_HOME}/emulator/emulator" -avd Pixel_8_Pro_16KB_API36 &
# 페이지 크기 확인 — 16384 가 출력되어야 정상
adb shell getconf PAGE_SIZE
16384 가 아니라 4096 이 출력된다면 일반 4KB AVD 를 부팅한 것입니다. 시스템 이미지 패키지명에 _ps16k 가 포함되어 있는지 다시 확인합니다.
릴리스 AAB 설치 및 동작 확인
bundletool 로 universal APK 를 생성하여 16KB 에뮬레이터에 설치합니다.
# universal APK 생성 (release 서명 사용)
bundletool build-apks \
--bundle=build/app/outputs/bundle/release/app-release.aab \
--output=build/app/outputs/bundle/release/app-release.apks \
--mode=universal \
--ks=android/key.jks --ks-pass=pass:... --ks-key-alias=...
# 설치
bundletool install-apks --apks=build/app/outputs/bundle/release/app-release.apks
설치 후 다음 항목을 체크리스트로 확인합니다.
- 앱 부팅 (스플래시 → 홈 화면 진입)
- DB 조회 (sqlite3 네이티브 라이브러리 동작)
- TTS 재생 (
flutter_tts) 및 음성 인식 (speech_to_text) - 광고 로드 (
google_mobile_ads) - 결제 화면 진입 시 상품 목록 로드 (
in_app_purchase) - Firebase Analytics / Crashlytics / Performance 초기화
Logcat 으로 크래시나 not page-aligned 메시지가 없는지 모니터링합니다.
adb logcat -v time | grep -E "(FATAL|ANR|page-size|JNI|ELF)" --color=never
FATAL EXCEPTION, ANR, dlopen failed, not page-aligned 메시지가 5분 이상 정상 사용 중에 나타나지 않으면 통과로 판단합니다.
Play Console 측 사전 검증
실제 release 없이 Play 콘솔까지 정상 통과할지 사전에 검증할 수 있습니다. Fastlane 의 supply --validate-only 경로로 AAB 를 전송하면, 실제 release / track 변경 없이 다음 항목이 검증됩니다.
- AAB 서명 (keystore alias 정합)
applicationId정합versionCode충돌 여부- 16KB 정렬 Play 측 enforcement (정적 게이트의 이중 확인)
- track / metadata 정합
# android/fastlane/Fastfile
lane :validate_release do
upload_to_play_store(
aab: "../build/app/outputs/bundle/release/app-release.aab",
track: "production",
validate_only: true,
)
end
cd android && bundle exec fastlane validate_release
성공 시 Successfully finished the upload to Google Play 가 출력되며, 실제 업로드는 발생하지 않습니다. 실패 시 명확한 사유(예: versionCode 1234 has already been used)와 함께 종료됩니다. release 워크플로가 태그 푸시 시 마주칠 오류를 사전에 식별할 수 있어 유용합니다.
정리
갤럭시 S26 등 16KB 페이지 크기 기기에서 Flutter 앱이 Play Store 에 정상 노출되도록 하기 위한 작업을 정리하면 다음과 같습니다.
요약:
- AGP 8.5+ 사용 +
packaging.jniLibs.useLegacyPackaging = false를build.gradle.kts에 명시적으로 선언하여 회귀 방지 - 빌드 설정만 믿지 말고, CI 에서 AAB 산출물의 ELF LOAD 정렬과 ZIP 엔트리 정렬을 직접 검사하는 게이트 구축
llvm-readobj -l로 PT_LOAD 세그먼트 Alignment ≥ 0x4000 확인- bundletool +
zipalign -P 16으로 universal APK 검증
- 런타임 동작(JNI / 광고 / 결제 / Firebase)은 정적 검증으로 잡을 수 없으므로, 릴리스 전 16KB 에뮬레이터(
_ps16k시스템 이미지)에서 1회 수동 검증 - Play 콘솔 enforcement 는 Fastlane
validate_only로 사전에 dry-run 가능
신형 단말이 16KB 페이지 크기를 채택하는 흐름은 한동안 계속될 것입니다. 빌드 설정 + 정적 게이트 + 런타임 SOP 세 단계를 한번 정비해 두면, 신형 단말이 출시될 때마다 release 가 흔들리는 일을 막을 수 있습니다.
관련 자료
제 블로그가 도움이 되셨나요? 하단의 댓글을 달아주시면 저에게 큰 힘이 됩니다!
앱 홍보
Deku가 개발한 앱을 한번 사용해보세요.Deku가 개발한 앱은 Flutter로 개발되었습니다.관심있으신 분들은 앱을 다운로드하여 사용해 주시면 정말 감사하겠습니다.