AX 노트 · 사고 기록

16KB 페이지 크기 대응기: NDK 27·29에서만 생긴 파란 색조와 다시 측정한 .so 12개

슈가앨범의 .so 6개를 16KB로 정렬한 것은 Android.mk 링커 플래그 한 줄이었고, NDK를 27로 먼저 올린 단계에서 파란 색조 회귀가 생겼습니다.

작성 SugarMount Android 개발 읽는 시간 14분 최종 업데이트

슈가앨범의 arm64 네이티브 라이브러리 12개 가운데 직접 빌드하는 6개가 4KB(0x1000) 정렬이었습니다. 2026년 1월 3일 커밋 aedf831에서 이 6개를 NDK 26.3으로 다시 빌드해 16KB(0x4000)로 맞췄고, 그 과정에서 NDK 27.0과 29.0으로 만든 빌드는 사진이 넘어갈 때 화면에 파란 색조를 남겼습니다. 2026년 10월 27일에 libnative_filter 소스(C 파일 두 개)를 NDK 네 버전으로 다시 빌드해 보니, 커밋이 "16KB 미지원"이라고 적은 NDK 25.1도 링커 플래그를 넣자 0x4000이 나왔습니다.

근거는 슈가앨범 저장소의 git 기록(c3e09b1, d73db50~7efd9c1, aedf831)과 같은 커밋에 들어간 작업 문서, 2026-10-27에 llvm-readelf로 다시 확인한 .so 정렬값, Android Developers 공식 문서입니다.

목차 (9)
  1. targetSdk 36으로 올릴 때 다시 빌드한 .so 3개도 4KB 정렬이었습니다
  2. NDK 27.0과 29.0으로 빌드하면 사진이 넘어갈 때 파란 색조가 끼었습니다
  3. NDK 26.3은 APP_SUPPORT_FLEXIBLE_PAGE_SIZES를 읽지 않습니다
  4. prebuilt FFmpeg 6개는 LOAD 검사를 통과하고 RELRO 공식에서 걸립니다
  5. NDK 25.1에 링커 플래그만 넣는 길이 있었습니다
  6. CMake 빌드와 NDK r28 이후에는 이 절차가 그대로 맞지 않습니다
  7. readelf 두 번과 같은 사진 세트로 확인하는 순서
  8. 아직 하지 않은 확인: 16KB 에뮬레이터 실행과 모듈별 -O3
  9. 자주 묻는 질문

targetSdk 36으로 올릴 때 다시 빌드한 .so 3개도 4KB 정렬이었습니다

슈가앨범은 사진을 골라 영상으로 만드는 앱이고, aedf831 시점에 Java 파일 763개와 Kotlin 파일 32개가 있습니다. 영상과 필터 처리는 ndk-build로 만드는 C/C++ 라이브러리가 맡습니다. 2024년 7월 15일(c3e09b1)에 targetSdk를 33에서 34로, 2025년 8월 27일부터 9월 1일까지(d73db50~7efd9c1) 34에서 36으로 올렸습니다. 8월 31일(3e7e099)에 multimediaFramework의 arm64 .so 3개(libBeatTracker, libPixelCanvas, libPixelUtils)를 다시 빌드했지만 링커에 페이지 크기를 지정하지 않았고, 나머지 3개(libnative_filter, libFFmpegProcessor, libKwpFFmpegMuxer)는 2022년 11월 빌드 그대로였습니다. 그래서 aedf831 직전 6개 모두 LOAD 세그먼트 정렬이 0x1000이었습니다.

Google은 2025년 5월 8일 Android Developers Blog에서 "2025년 11월 1일부터 Android 15 이상을 타깃하는 새 앱과 업데이트는 16KB 페이지를 지원해야 한다"고 공지했습니다. 지금(2026-10-16 갱신) 페이지 크기 안내 문서는 "2027년 2월 1일부터 16KB를 지원하지 않는 업데이트는 출시할 수 없다"고 적습니다. 저장소 문서에는 Play Console 경고를 받고 "2025년 5월까지" 고쳐야 한다고 적혀 있는데, 이 날짜는 두 공식 문서 어디에도 없고 경고 원문도 저장소에 남아 있지 않습니다.

2026년 1월 3일 작업은 Claude Code와 함께 했습니다(커밋에 Co-Authored-By 줄이 있습니다). 같은 커밋에 들어간 작업 문서 ffmpeg.md는 첫 단계를 이렇게 적었습니다. "변경 후: NDK 27.0.12077973. 이유: APP_SUPPORT_FLEXIBLE_PAGE_SIZES 플래그는 NDK r27 이상에서만 지원." 최종 커밋은 26.3을 썼고, ffmpeg.md는 27.0이라고 적힌 채로 커밋됐습니다.

단계 그림: 2025년 8월 targetSdk 36 대응 때 .so 3개를 다시 빌드했지만 나머지 3개와 함께 6개 모두 0x1000 정렬로 남음, 2026년 1월 NDK 27.0으로 올림, 27.0과 29.0에서 사진 전환에 파란 색조, 26.3과 링커 플래그로 0x4000, 10월 재측정에서 NDK 25.1도 링커 플래그로 0x4000.
2025년 8월 재빌드 뒤에도 6개 모두 0x1000이었고, 2026년 1월 NDK 26.3과 링커 플래그로 0x4000이 됐습니다.

NDK 27.0과 29.0으로 빌드하면 사진이 넘어갈 때 파란 색조가 끼었습니다

커밋 메시지의 NDK 비교 표는 세 줄입니다. 25.x는 16KB 미지원, 26.3은 정상(채택), 27.0과 29.0은 "-O3 최적화 시 파란색 색조 버그 발생". 같은 커밋의 progress.md는 증상을 "사진 전환 시 파란색 계열 색조가 끼면서 전환됨"으로, 영향 범위를 multimediaFramework의 PixelUtils와 libyuv 코드로, 검증을 "-O0(최적화 없음)으로 빌드하면 정상 동작"으로 적었습니다. multimediaFramework의 Android.mk는 libYUV, libMasp, PixelCanvas, PixelUtils, BeatTracker 다섯 모듈 모두 -O3로 컴파일합니다.

이 회귀를 잡은 자동 검사는 없었습니다. 저장소의 테스트는 프로젝트를 만들 때 생기는 예제 테스트 두 개(ExampleUnitTest, ExampleInstrumentedTest)뿐이고, 색이나 렌더링 결과를 비교하는 테스트는 없습니다. 빌드는 NDK 27.0에서도 29.0에서도 성공했고, 정렬도 맞았습니다. 색조는 앱을 실행해 사진 전환을 화면으로 봐야 보이는 증상이었습니다.

progress.md는 원인을 "Clang 컴파일러의 -O3 최적화 버그"로 단정합니다. 기록에 있는 근거는 -O0이면 정상이라는 것 하나입니다. NDK에 든 clang 버전을 보면 26.3은 17.0.2, 27.0은 18.0.1, 29.0은 21.0.0이라 색조는 clang 18 이후와 겹칩니다. 그러나 -O0에서 정상인 것은 컴파일러 버그일 때도, 우리 코드의 미정의 동작을 새 최적화가 다르게 처리할 때도 똑같이 나옵니다. 어느 함수에서 색이 바뀌는지 좁힌 기록은 없습니다.

같은 커밋에서 libyuv의 NEON 빌드 줄도 정리됐기 때문에 이것이 원인인지도 확인했습니다. libPixelUtils.so의 동적 심볼 가운데 이름에 NEON이 든 것이 커밋 전에도 후에도 198개였습니다. NEON 코드는 예전 빌드에도 들어 있었으니 색조의 변수에서 뺐습니다.

NDK 26.3은 APP_SUPPORT_FLEXIBLE_PAGE_SIZES를 읽지 않습니다

최종 커밋은 정렬 설정을 세 곳에 넣었습니다. gradle.properties의 android.ndk.maxPageSize=16384, Application.mk 세 개의 APP_SUPPORT_FLEXIBLE_PAGE_SIZES := true, 그리고 Android.mk 여섯 모듈의 LOCAL_LDFLAGS += -Wl,-z,max-page-size=16384입니다. 이 가운데 무엇이 정렬을 바꿨는지 보려고, NDK 폴더의 build/core에서 플래그 이름을 찾았습니다. 25.1과 26.3에는 그 문자열이 없고, 27.0과 29.0의 build-binary.mk에만 있습니다. 27.0은 값이 true일 때 max-page-size=16384를 링커에 붙이고, 29.0은 기본이 16KB라 값이 false일 때만 4096으로 되돌립니다.

그다음 aedf831의 libnative_filter 소스를 NDK별로 다시 빌드하고 llvm-readelf -lW로 LOAD 정렬을 읽었습니다(2026-10-27).

NDKAndroid.mk 링커 플래그arm64-v8a LOAD 정렬
25.1.8937393없음0x1000
25.1.8937393있음0x4000
26.3.11579264없음(Application.mk 플래그만)0x1000
26.3.11579264있음(커밋과 같음)0x4000
27.0.12077973없음(Application.mk 플래그만)0x4000
29.0.14206865없음0x4000

26.3에서 16KB를 만든 것은 Android.mk의 링커 플래그였고, Application.mk의 플래그는 아무 일도 하지 않았습니다. 같은 링커 플래그는 25.1에서도 0x4000을 냈습니다. 커밋 표의 "NDK 25.x: 16KB 미지원"은 플래그를 넣고 재 본 결과가 아니었고, "플래그는 r27 이상"이라는 처음 판단이 거기까지 이어졌습니다. 공식 문서도 "Android NDK r27 이하" 항목에서 max-page-size와 common-page-size 링커 플래그로 16KB 정렬을 만들라고 안내하고, APP_SUPPORT_FLEXIBLE_PAGE_SIZES는 언급하지 않습니다.

prebuilt FFmpeg 6개는 LOAD 검사를 통과하고 RELRO 공식에서 걸립니다

progress.md는 미리 빌드된 FFmpeg 라이브러리 6개(libavcodec-56, libavfilter-5, libavformat-56, libavutil-54, libswresample-1, libswscale-3)를 "별도로 16KB 재빌드 필요"로 남겼습니다. 다시 재 보니 이 6개는 커밋 전부터 LOAD 정렬이 0x10000(64KB)이었습니다. 64KB는 16KB의 배수라, 공식 문서의 "LOAD가 2**14보다 작으면 안 된다"는 검사는 통과합니다.

공식 문서에는 검사가 하나 더 있습니다. GNU_RELRO 세그먼트의 (VirtAddr + MemSiz)를 0x4000으로 나눈 나머지가 0이어야 하고, 0이 아니면서 RELRO가 뒤따르는 쓰기 데이터(.data, .bss)와 한 LOAD를 같이 쓰면 앱이 실행 중에 세그폴트로 죽을 수 있다고 적습니다. RELRO만 든 LOAD이고 다음 쓰기 LOAD가 다른 16KB 페이지에서 시작하면 이 공식이 적용되지 않습니다. 문서는 이런 배치의 예를 16KB 호환으로 봅니다. aedf831의 arm64 라이브러리 12개를 이 공식으로 계산했습니다.

라이브러리RELRO 끝 % 0x4000판정
libnative_filter, libBeatTracker, libFFmpegProcessor, libKwpFFmpegMuxer0x0통과
libPixelCanvas, libPixelUtils0x1000공식 적용 안 됨(RELRO만 든 LOAD)
prebuilt FFmpeg 6개0x1000~0x3000RELRO와 .data/.bss가 같은 LOAD

libavcodec-56을 예로 들면 쓰기 가능 LOAD의 메모리 크기가 0x257f00이고 그 앞부분 0xfbf8만 RELRO입니다. 문서가 세그폴트 사례로 든 모양과 같습니다. 커밋은 링커 플래그로 max-page-size만 넣었고, 공식 예시는 common-page-size=16384도 함께 씁니다. 직접 빌드한 6개는 그 플래그 없이도 통과했습니다. FFmpeg 6개는 저장소에 소스와 빌드 스크립트가 있지만, 지금 들어 있는 .so를 어떤 플래그로 만들었는지는 기록에 없습니다. ffmpeg.md는 두 빌드 스크립트의 ADDI_LDFLAGS에 -Wl,-z,max-page-size=16384를 넣었다고 적었는데, 이 커밋에서 두 스크립트는 경로만 옮겨졌고 build_arm64-v8a.sh 28행의 ADDI_LDFLAGS에는 그 플래그가 없습니다.

비교 그림: 커밋 기록은 NDK 25 미지원, 26.3 채택, FFmpeg 재빌드 필요, 원인 Clang -O3 버그라고 적었고, 다시 측정하니 NDK 25.1도 링커 플래그로 0x4000, 26.3은 Application.mk 플래그를 읽지 않음, FFmpeg는 LOAD 0x10000이지만 RELRO 나머지 0x1000~0x3000, 원인은 확정 못 함.
FFmpeg 6개는 LOAD 0x10000이지만 RELRO 나머지는 0x1000~0x3000이었습니다.

NDK 25.1에 링커 플래그만 넣는 길이 있었습니다

1월에 고를 수 있었던 길은 세 가지였습니다. 실제로 고른 것은 NDK 26.3 고정이고, 문서에 "NDK 27 이상 버전 사용 금지"를 남겼습니다.

NDK 버전부터 올리지 마십시오. 지금 쓰는 NDK에 링커 플래그를 넣고 readelf로 정렬부터 재십시오. NDK를 올려야 할 다른 이유가 있다면 그때 따로 올리고, 렌더링 결과를 이전 빌드와 비교하십시오.

CMake 빌드와 NDK r28 이후에는 이 절차가 그대로 맞지 않습니다

이 기록은 ndk-build(Android.mk, Application.mk) 프로젝트입니다. CMake로 빌드한다면 공식 문서의 CMake 예시처럼 target_link_options에 플래그를 넣어야 하고, Android.mk 줄은 쓸모가 없습니다. NDK r28 이상은 기본으로 16KB 정렬이라 링커 플래그 자체가 필요 없습니다(29.0 측정에서도 arm64는 플래그 없이 0x4000이었습니다).

다른 코드베이스에서 NDK 27이 같은 문제를 일으킨다고 말할 근거는 없습니다. 32비트(armeabi-v7a) 라이브러리는 정렬을 따지지 않았습니다. 공식 문서의 요건은 64비트 기기 대상입니다.

readelf 두 번과 같은 사진 세트로 확인하는 순서

  1. APK나 AAB에 들어가는 arm64-v8a .so마다 LOAD 정렬을 봅니다. 마지막 열이 0x4000 이상이어야 합니다.
    llvm-readelf -lW libfoo.so | grep -E "LOAD|GNU_RELRO"
  2. GNU_RELRO 줄이 있으면 VirtAddr와 MemSiz를 더해 0x4000으로 나눈 나머지를 봅니다. 0이 아니면, RELRO가 든 LOAD에 .data/.bss까지 들어 있는지(LOAD의 MemSiz가 RELRO보다 훨씬 큰지) 확인합니다.
  3. NDK를 바꾸기 전에 지금 NDK의 Android.mk에 공식 문서의 두 플래그를 넣고 다시 1번을 합니다.
    LOCAL_LDFLAGS += -Wl,-z,max-page-size=16384 -Wl,-z,common-page-size=16384
  4. APP_SUPPORT_FLEXIBLE_PAGE_SIZES는 NDK r27 이상에서만 읽힙니다. r26 이하에서 이 줄만 넣고 끝내지 않습니다.
  5. NDK나 플래그를 바꿀 때마다 jniLibs의 .so를 지우고 다시 빌드합니다. 슈가앨범 기록에 따르면 Android Studio의 Clean Project는 jniLibs를 지우지 않아, 빌드 스크립트가 먼저 지우게 했습니다.
  6. 같은 사진 세트로 이전 빌드와 새 빌드의 영상을 만들어 화면 전환 장면을 나란히 봅니다.
  7. 비압축 .so를 쓰면 Android Gradle Plugin 8.5.1 이상인지 보고 zipalign -v -c -P 16 4 app.apk로 zip 정렬을 확인합니다. 슈가앨범은 AGP 8.12.0입니다.

Play Console 쪽 경고를 다룬 다른 기록으로는 Play Console 'DEX 최적화 39%'의 범인: 쓰지도 않는 Kakao SDK keep 규칙이 있습니다. 앱 소개는 슈가앨범 페이지에 있습니다.

아직 하지 않은 확인: 16KB 에뮬레이터 실행과 모듈별 -O3

16KB 페이지 에뮬레이터 이미지에서 영상 만들기를 끝까지 실행해 FFmpeg 라이브러리가 RELRO 때문에 죽는지 봐야 합니다. 죽는다면 FFmpeg를 common-page-size=16384와 함께 다시 빌드해야 합니다. 색조는 아직 어느 모듈에서 생기는지도 모릅니다. 좁히려면 multimediaFramework 다섯 모듈에서 하나씩 -O3를 빼 보는 방법이 있습니다. ffmpeg.md의 NDK 27.0 표기는 26.3으로 고쳐야 합니다.

자주 묻는 질문

16KB 페이지를 지원하려면 NDK를 r27 이상으로 올려야 하나요?

아닙니다. NDK 25.1에서도 Android.mk에 -Wl,-z,max-page-size=16384를 넣자 다시 빌드한 libnative_filter.so(arm64)의 LOAD 정렬이 0x4000이 됐습니다(2026-10-27 측정). r27 이상은 APP_SUPPORT_FLEXIBLE_PAGE_SIZES 한 줄로 처리하려는 경우에만 있으면 됩니다.

.so가 16KB 정렬인지 어떻게 확인하나요?

llvm-readelf -lW나 llvm-objdump -p로 LOAD 세그먼트의 정렬이 0x4000(2**14) 이상인지 봅니다. GNU_RELRO가 쓰기 데이터와 한 LOAD에 있으면 (VirtAddr + MemSiz) % 0x4000도 0인지 확인합니다.

64KB로 정렬된 미리 빌드된 .so는 그대로 써도 되나요?

LOAD 정렬 검사는 통과합니다. 슈가앨범의 FFmpeg 6개는 0x10000이라 LOAD 검사는 통과했지만 RELRO 공식의 나머지가 0x1000~0x3000이었습니다.

이 글은 SugarMount가 직접 측정한 값으로 쓰고, 초안 작성에 AI 코딩 에이전트를 썼습니다.

Android 개발 글 전체 보기 · AX 노트 전체 보기