J.BLOG

Rust 공유 코어 5편: Android에 .so로 넣을 때 확인할 것

진재명···읽기 7분
English

여러 플랫폼이 함께 쓰는 Rust 모듈을 Android에 넣을 때, Kotlin 바인딩을 생성한 것만으로는 통합이 끝나지 않아요. ABI가 빠졌거나 잘못된 자리에 놓였거나, 플랫폼과 스토어의 조건을 채우지 못하면 빌드, 설치, 실행 가운데 어디선가 실패할 수 있어요.

5편은 Rust 바이너리를 Android 배포물에 넣을 때의 빌드 연결과 호환성 점검을 다뤄요. 여기에 나오는 버전과 정책은 2026년 10월 4일에 다시 확인한 Android 문서의 값이에요. 제가 예제를 빌드한 환경은 «예제로 빌드한 .so의 정렬» 절에 따로 적었어요.

ABI마다 .so가 필요해요

NDK가 지원하는 ABI는 armeabi-v7a, arm64-v8a, x86, x86_64 넷이에요. 이 가운데 무엇을 배포할지는 앱의 정책으로 정해요. 고른 ABI마다 그에 맞는 Rust target으로 .so를 만들어야 해요.

산출물은 앱 모듈의 jniLibs 아래에 ABI별 디렉터리로 둬요. 세 ABI를 고른 경우의 예시예요.

apps/android/
  app/
    src/main/
      jniLibs/
        arm64-v8a/libdomain_core.so
        armeabi-v7a/libdomain_core.so
        x86_64/libdomain_core.so

이 배치는 ABI별 파일을 찾기 쉽게 해 줘요. 파일을 손으로 복사하는 방식에는 위험이 있어요. Rust 코드를 고친 뒤에도 앱이 예전 바이너리를 담고 있을 수 있어요.

Gradle 빌드에 Rust 컴파일을 이어요

ABI, 페이지 크기, 호환 버전은 문서에 있는 내용이에요. 이 절의 Gradle 작업 구성과 뒤에 나오는 점검 목록은 문서의 요구가 아니고 그 위에 세운 제 해석이에요.

손으로 복사하지 않으려면 빌드에 연결해요. preBuild가 Rust를 빌드하는 작업에 의존하게 하는 구성이에요.

preBuild
  dependsOn buildRustCoreForAndroid

buildRustCoreForAndroid
  -> ABI별 cargo build
  -> UniFFI Kotlin binding 생성
  -> jniLibs / generated source 경로에 output 배치

위 블록은 작업 사이의 관계를 그린 것이고, 그대로 쓰는 Gradle 설정이 아니에요. cargo로 컴파일하고, UniFFI로 Kotlin 코드를 생성하고, 결과를 jniLibs와 generated source 경로에 놓는 순서예요.

Gradle 작업에 inputs와 outputs를 적지 않으면 필요 없는 재빌드가 생기거나, 반대로 변경을 감지하지 못할 수 있어요. 로컬에서의 속도와 CI에서의 재현을 함께 생각해서 작업의 경계를 정해야 해요.

Kotlin interface로 생성 타입을 가려요

생성된 타입을 ViewModel, Composable, repository에 두루 드러내면 결합이 커져요. Kotlin adapter와 SudokuEngineClient 계약으로 내부 모델을 분리해요. 제안하는 interface의 모양이에요.

interface SudokuEngineClient {
    fun startGame(request: StartGameRequest): GameSnapshot
    fun applyAction(snapshot: GameSnapshot, action: GameAction): Transition
}

구현체가 UniFFI를 부르고, ViewModel은 이 계약만 써요. 테스트에서는 fake를 넣을 수 있어요. 같은 Rust 모듈을 Web에서 WebAssembly 패키지로 쓰는 방법은 6편으로 넘겨요.

무거운 Rust 연산은 main thread 밖에서 처리해요. ViewModel scope에서 dispatcher를 고르는 일과 UI state를 갱신하는 일을 나누고, Compose UI에는 Rust 의존성을 드러내지 않기를 권해요. 연산 비용을 잰 수치와 dispatcher의 지정값은 이 글에 없어요.

16 KB 페이지 조건

Android의 16 KB 페이지 안내에 따르면 Google Play에서 Android 15(API 레벨 35) 이상을 타깃으로 하는 앱은 64-bit 기기에서 16 KB 페이지를 지원해야 해요. 2027년 2월 1일부터는 16 KB를 지원하지 않는 업데이트를 출시할 수 없어요. 이 글의 이전 판에는 시행일이 2025년 11월 1일로 적혀 있었는데, 2026년 10월 4일에 다시 확인한 문서에는 이 날짜가 없어요. SDK가 가져오는 네이티브 코드도 대상이므로 Rust의 .so도 포함돼요.

문서상 NDK r28 이상은 기본으로 16 KB 정렬을 써요. 그래도 기본값에 기대지 않고 배포물 안의 모든 라이브러리를 확인해야 해요. 광고나 분석 SDK가 가져온 오래된 바이너리가 앱 전체의 호환성에 영향을 줄 수 있어요.

확인할 항목은 넷이에요.

  • 모든 .so의 ELF 정렬
  • 16 KB 에뮬레이터나 실기기에서의 동작
  • 타사 SDK에 네이티브 코드가 있는지
  • debug가 아니라 release APK나 AAB로 검사했는지

APK의 정렬은 zipalign으로 검사하고, 연결한 기기의 페이지 크기는 adb로 조회해요.

zipalign -c -P 16 -v 4 app-release.apk
adb shell getconf PAGE_SIZE

이 두 명령은 APK 포장의 정렬과 기기의 조건을 보는 것이에요. 이것만으로 모든 .so의 ELF 정렬이나 앱의 동작까지 확인되지는 않아요. AAB를 중심으로 배포하는 프로젝트는 Play Console의 pre-launch report나 bundle에서 만든 APK까지 확인 대상으로 넣어요. 이 두 명령은 실행하지 못했어요. APK를 만들지 못했기 때문이고, 이유는 다음 절에 적었어요.

예제로 빌드한 .so의 정렬

작은 Rust 예제를 세 ABI로 빌드해서 ELF 정렬을 직접 확인해 봤어요. 실제 앱이 아니고 구조를 확인하려고 만든 최소 workspace예요. 실행한 환경은 macOS 27.0.1, cargo 1.96.0, 그리고 이 Mac에 설치된 NDK 30.0.15729638-beta2이고, 날짜는 2026년 10월 4일이에요. 아래 «도구 버전을 고정해요» 절에 나오는 NDK 28.2와는 다른 버전이고, beta예요. Rust의 Android 타깃 셋은 미리 설치돼 있었어요.

cargo에는 타깃마다 NDK의 clang을 linker로 알려 줘요. 정렬은 Android 안내가 적은 방법대로 llvm-objdump -p의 LOAD 줄에서 읽었어요. 빌드 스크립트에서 옮긴 줄이에요. 두 가지는 예제라서 간단히 한 부분이에요. 첫 줄은 설치된 NDK 가운데 버전이 가장 높은 것을 고르므로, 다른 NDK를 설치하면 결과가 달라질 수 있어요. 재현하려면 NDK 경로나 버전을 명시해야 해요. linker 이름의 24는 minSdkVersion을 뜻해요. 앱의 minSdk가 24보다 낮다면 그 값에 맞춰야 해요.

NDK=$(ls -d "$HOME"/Library/Android/sdk/ndk/* | sort -V | tail -1)
TC="$NDK/toolchains/llvm/prebuilt/darwin-x86_64/bin"
# …
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$TC/aarch64-linux-android24-clang"
export CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_LINKER="$TC/armv7a-linux-androideabi24-clang"
export CARGO_TARGET_X86_64_LINUX_ANDROID_LINKER="$TC/x86_64-linux-android24-clang"
for target in aarch64-linux-android armv7-linux-androideabi x86_64-linux-android; do
  cargo build -q -p sudoku-uniffi --release --target "$target"
  so="target/$target/release/libsudoku_uniffi.so"
  echo "$target: $(stat -f %z "$so") bytes"
  # …
done

정렬은 스크립트를 하나 더 써서 읽었어요. sort -u로 같은 값의 줄을 하나로 줄여요.

OBJDUMP="$NDK/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-objdump"
for target in aarch64-linux-android armv7-linux-androideabi x86_64-linux-android; do
  echo "$target"
  "$OBJDUMP" -p "target/$target/release/libsudoku_uniffi.so" | awk '/LOAD/ {print "  LOAD " $(NF-1) " " $NF}' | sort -u
done

두 스크립트의 출력이에요. 위가 ABI별 파일 크기이고 아래가 LOAD 줄의 정렬 값이에요.

aarch64-linux-android: 716632 bytes
armv7-linux-androideabi: 505140 bytes
x86_64-linux-android: 686000 bytes

aarch64-linux-android
  LOAD align 2**14
armv7-linux-androideabi
  LOAD align 2**12
x86_64-linux-android
  LOAD align 2**14

64-bit인 arm64-v8a와 x86_64용은 모든 LOAD 세그먼트가 214, 곧 16 KB였어요. 안내는 LOAD 세그먼트의 값이 214보다 작지 않아야 한다고 적어요. 32-bit인 armeabi-v7a용은 2**12였어요. 16 KB 조건은 64-bit 기기에 대한 것이에요. linker 옵션은 따로 주지 않았어요.

확인하지 못한 것이 더 많아요. 이 Mac에는 Gradle이 없어서 APK와 AAB를 만들지 못했어요. 그래서 zipalign 검사, jniLibs 배치, release 빌드, 기기나 에뮬레이터에서의 로드는 해 보지 못했어요. Kotlin 바인딩은 생성만 했고 컴파일하지 않았어요.

도구 버전을 고정해요

2026년 10월 4일에 다시 확인한 AGP 9.1 릴리스 노트의 호환성 표에서 AGP 9.1.1에 대응하는 값은 Gradle 9.3.1, JDK 17, NDK 28.2.13676358이에요. AGP 9.0부터 기본 NDK가 r28 계열이고, 9.1.1도 같은 기본값을 써요. 개발자마다 NDK가 다르면 정렬과 링커의 동작이 달라지는 요인이 될 수 있어요.

그래서 아래 여섯 가지를 한곳에 고정해서 일관되게 관리해요.

AGP version
Gradle version
JDK version
NDK version
Rust toolchain
Android Rust targets

새 설정 파일을 만들 필요는 없어요. 이미 관리하고 있는 version catalog, Gradle wrapper, CI image, README를 기준으로 삼으면 돼요. 새로 clone한 상태에서 ./gradlew assembleRelease나 CI의 release 작업이 Rust 산출물 생성까지 포함해서 성공하는지도 확인해요. Gradle이 아닌 빌드 시스템과 NDK를 함께 쓸 때에는 NDK의 다른 빌드 시스템 안내를 참고해요.

debug가 되어도 release는 따로 봐요

R8, minify, packagingOptions, ABI split, App Bundle 처리 때문에 debug 빌드의 결과만으로는 release를 판단할 수 없어요. release 배포물에서 확인할 것은 다섯 가지예요.

  • 필요한 ABI가 모두 들어 있는지
  • 바인딩이 찾는 이름과 바이너리의 이름이 같은지
  • System.loadLibrary가 성공하는지
  • 16 KB 조건에 맞는지
  • 새로 설치한 직후에 Rust 호출이 성공하는지

CI에서 자동으로 확인하기를 권해요. 그렇게 하기 어렵다면 release candidate마다 손으로 보는 점검표가 최소한의 대안이에요. 네이티브 호출의 기본 개념은 Android의 JNI 안내에 있어요.

배포 단계에서 보면 Rust 모듈도 네이티브 바이너리 의존성 가운데 하나예요. 어떤 ABI를 지원할지, Gradle 작업의 증분 빌드를 어떻게 짤지, 어느 스레드에서 실행할지, 타사 코드가 조건에 맞는지, 최종 배포물이 실제로 동작하는지는 프로젝트에서 따로 확인해야 해요. 정책의 날짜와 버전 값은 2026년 10월 4일에 다시 확인한 문서의 것이에요.

광고Coupang Partners

이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.