Android에서 네이티브 코드가 동작하는 이유

2025년 9월 7일 · 읽는 시간 20

안드로이드 개발을 하다보면 특정 디바이스에서만 크래시가 발생하는 경우를 접하거나 혹은 System.loadLibrary("native-lib")로 C++ 코드를 불러오면서 "이게 어떻게 가능한 거지?" 하고 궁금했을 수도 있습니다.

이번 포스팅에서는 Android에서 Java/Kotlin 코드와 C/C++ 네이티브 코드가 어떻게 함께 동작할 수 있는지에 대해서 살펴보려고 합니다. 답은 바로 ABI(Application Binary Interface) 에 있습니다.

참고로 Google Play는 64비트 지원을 의무화했습니다. 모든 네이티브 코드를 포함한 앱은 반드시 64비트 버전을 제공해야 합니다.

간단한 실험으로 시작하기

먼저 이런 코드를 본 적이 있으신가요?

// MainActivity.kt
class MainActivity : AppCompatActivity() {
    companion object {
        init {
            System.loadLibrary("native-lib")
        }
    }
    
    external fun stringFromJNI(): String
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val text = stringFromJNI()  // C++ 함수 호출!
        Log.d("ABI", text)
    }
}
// native-lib.cpp
#include <jni.h>
#include <string>

// JNIEXPORT: __attribute__((visibility("default"))) - 심볼을 외부에 노출
// JNICALL: 플랫폼별 호출 규약 처리
extern "C" JNIEXPORT jstring JNICALL
Java_com_example_myapp_MainActivity_stringFromJNI(
        JNIEnv* env,
        jobject /* this */) {
    std::string hello = "Hello from C++";
    return env->NewStringUTF(hello.c_str());
}

Kotlin 코드에서 C++ 함수를 호출하고 있습니다. 도대체 어떻게 이게 가능한 걸까요?

답은 ABI에 있습니다.

ABI가 뭐길래?

먼저 익숙한 것부터 시작해봅시다. API(Application Programming Interface)는 많이 들어보셨을 것입니다. 간단히 말해 "이 함수를 이렇게 호출하세요"라는 소스코드 레벨의 약속입니다.

// API 레벨의 약속
fun calculateSum(a: Int, b: Int): Int {
    return a + b
}

그런데 이 코드가 컴파일되면 어떻게 될까요? 사실 CPU는 Kotlin이나 Java 코드를 전혀 모릅니다. 오직 0과 1로 이루어진 기계어만 이해할 뿐입니다.

; ARM64 어셈블리로 변환된 모습
add x0, x0, x1   ; x0 = x0 + x1
ret              ; x0 레지스터의 값을 반환

여기서 중요한 질문이 생깁니다.

  • 파라미터 ab는 어느 레지스터에 들어가나요?
  • 반환값은 어디에 저장되나요?
  • 스택은 어떻게 정렬되나요?

이 모든 규칙을 정의한 것이 바로 ABI입니다.

왜 디바이스마다 다른 라이브러리가 필요할까?

여기서 재미있는 실험을 해보겠습니다.

int add(int a, int b) {
    return a + b;
}

똑같은 덧셈 함수를 서로 다른 Android 디바이스용으로 컴파일하면 어떻게 될까요?

ARM32 (armeabi-v7a) - 대부분의 구형 Android 기기

; ARM AAPCS: r0-r3는 인자 전달, r4-r11 보존, r12 스크래치
; 32비트 정수 덧셈
add r0, r0, r1   ; r0 = r0 + r1 (첫 번째 인자 + 두 번째 인자)
bx lr            ; lr(Link Register)로 복귀, r0이 반환값

ARM64 (arm64-v8a) - 현재 대부분의 Android 스마트폰

; AAPCS64: x0-x7는 인자/반환, x8 간접 결과, x9-x15 임시
; 32비트 int는 w레지스터(x레지스터의 하위 32비트) 사용
add w0, w0, w1   ; w0 = w0 + w1 (32비트 정수 덧셈)
ret              ; x30(lr)의 주소로 복귀, w0이 반환값

x86 (32비트) - 구형 Android 태블릿, 에뮬레이터

; System V i386 ABI (cdecl): 모든 인자를 스택으로 전달
; 스택: [반환주소][첫 번째 인자][두 번째 인자]...
mov eax, [esp+4]  ; 첫 번째 인자(a)를 eax에 로드
add eax, [esp+8]  ; 두 번째 인자(b)를 더함
ret               ; eax로 반환, 호출자가 스택 정리

x86_64 (64비트) - 최신 Android 에뮬레이터, Chrome OS

; System V AMD64 ABI: rdi, rsi, rdx, rcx, r8, r9로 처음 6개 인자 전달
; 32비트 정수는 하위 32비트(edi, esi) 사용
add edi, esi     ; edi = edi + esi (32비트 덧셈)
mov eax, edi     ; 결과를 eax에 복사 (반환값)
ret              ; rax의 하위 32비트(eax)로 반환

놀랍게도 각 디바이스의 CPU 아키텍처마다 완전히 다른 방식으로 함수를 호출합니다!

  • ARM: 레지스터 중심 (r0-r3 또는 x0-x7 사용)
  • x86 (32비트): 스택 중심 (모든 인자를 스택으로)
  • x86_64: 레지스터 우선 (처음 6개는 레지스터로)

이게 바로 Android APK 파일 내부에서 lib/armeabi-v7a/, lib/arm64-v8a/, lib/x86/, lib/x86_64/ 같은 폴더를 보게 되는 이유입니다. 각 CPU 아키텍처가 이해하는 '기계어'와 '호출 규약'이 완전히 다르기 때문에, 각각에 맞게 컴파일된 네이티브 라이브러리(.so 파일)가 필요한 것입니다.

Android의 ABI

Android는 다양한 디바이스를 지원하기 위해 여러 ABI를 제공합니다. 현재 지원 현황은 다음과 같습니다.

ABI 아키텍처 상태 시장 점유율
arm64-v8a 64비트 ARM 필수 ~95%
armeabi-v7a 32비트 ARM 레거시 지원 ~5%
x86_64 64비트 Intel 에뮬레이터 <1%
x86 32비트 Intel Deprecated -

1. armeabi-v7a (32비트 ARM)

레거시 디바이스 지원을 위해 여전히 사용되는 32비트 ARM 아키텍처입니다.

// 이 ABI의 특징을 코드로 확인해보기
fun checkArmeabiV7a() {
    // 주요 특징:
    // - 32비트 포인터 (4바이트)
    // - 16개의 범용 레지스터 (r0-r15)
    // - VFPv3 부동소수점 지원
    // - NEON SIMD 명령어 지원
    
    val pointerSize = 4  // 바이트
    val maxMemory = 4_294_967_296L  // 4GB (2^32)
}

실제 메모리 레이아웃 (일반적인 예시)

[Low Memory]
0x00000000: Reserved
0x08048000: Text Segment (코드)
0x10000000: Data Segment (전역 변수)
0x40000000: Heap (동적 할당)
0xBFFFFFFF: Stack (함수 호출)
[High Memory]

2. arm64-v8a (64비트 ARM)

현재 대부분의 최신 Android 디바이스가 사용하는 아키텍처입니다.

// 포인터 크기 차이 실습
struct VideoFrame {
    void* buffer;      // 32비트: 4바이트, 64비트: 8바이트
    size_t size;       // 32비트: 4바이트, 64비트: 8바이트
    int64_t timestamp; // 둘 다 8바이트
};

// 32비트에서 이 구조체 크기: 16바이트
// 64비트에서 이 구조체 크기: 24바이트!

왜 이런 차이가 생길까요? 패딩(padding) 때문입니다.

// 32비트 메모리 정렬
struct VideoFrame_32 {
    void* buffer;      // 0-3번 바이트
    size_t size;       // 4-7번 바이트
    int64_t timestamp; // 8-15번 바이트
}; // 총 16바이트

// 64비트 메모리 정렬
struct VideoFrame_64 {
    void* buffer;      // 0-7번 바이트
    size_t size;       // 8-15번 바이트
    int64_t timestamp; // 16-23번 바이트
}; // 총 24바이트

3. x86 / x86_64 (Intel 아키텍처)

주로 에뮬레이터와 일부 태블릿에서 사용됩니다.

// x86의 특별한 점: 스택 기반 호출 규약
external fun processData(a: Int, b: Int, c: Int, d: Int): Int

// ARM: 처음 4개 인자는 레지스터로 전달
// x86: 모든 인자를 스택으로 전달 (32비트)
// x86_64: 처음 6개 인자는 레지스터로 전달

Name Mangling

C++에서 함수 오버로딩을 지원하려면 어떻게 해야 할까요?

// C++ 코드
class Calculator {
public:
    int add(int a, int b);
    float add(float a, float b);
    double add(double a, double b);
};

컴파일러는 이 함수들을 구분하기 위해 Name Mangling을 사용합니다.

// Mangled names (컴파일러마다 다름)
_ZN10Calculator3addEii    // int add(int, int)
_ZN10Calculator3addEff    // float add(float, float)
_ZN10Calculator3addEdd    // double add(double, double)

각 부분을 해석하면,

  • _Z: C++ mangled name 시작
  • N: nested name 시작
  • 10Calculator: 10글자 클래스명 "Calculator"
  • 3add: 3글자 메서드명 "add"
  • E: nested name 끝
  • ii/ff/dd: 파라미터 타입 (int/float/double)

JNI에서 extern "C"를 사용하는 이유가 바로 이것입니다.

// Name Mangling 방지
extern "C" {
    JNIEXPORT jstring JNICALL
    Java_com_example_MainActivity_getString(JNIEnv* env, jobject obj) {
        // extern "C"가 없으면 이 함수명이 망글링되어
        // Java에서 찾을 수 없게 됩니다!
    }
}

ELF와 System V ABI

Android의 네이티브 라이브러리는 ELF(Executable and Linkable Format) 형식을 따릅니다. 실제 .so 파일을 분석해보겠습니다.

# .so 파일 분석
$ readelf -h libnative-lib.so

ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00  # ELF 시그니처
  Class:   ELF64                     # 64비트
  Data:    2's complement, little endian
  Version: 1 (current)
  OS/ABI:  UNIX - System V           # System V ABI!
  Machine: AArch64                   # ARM64

ELF 파일 구조

+------------------+
|   ELF Header     |  파일 타입, 아키텍처, 엔트리 포인트
+------------------+
| Program Headers  |  런타임 세그먼트 정보
+------------------+
|     .text        |  실행 코드
+------------------+
|     .data        |  초기화된 전역 변수
+------------------+
|     .bss         |  초기화되지 않은 전역 변수
+------------------+
|    .dynamic     |  동적 링킹 정보
+------------------+
|    .symtab      |  심볼 테이블
+------------------+
| Section Headers  |  섹션 메타데이터
+------------------+

System V ABI는 이 모든 것의 표준을 정의합니다.

  • 데이터 타입 크기와 정렬
  • 함수 호출 규약
  • 레지스터 사용 규칙
  • 스택 프레임 레이아웃
  • 예외 처리 메커니즘

실전: Multi-ABI 지원하기

실제 프로젝트에서 여러 ABI를 지원하는 방법을 알아보겠습니다.

1. Gradle 설정

android {
    defaultConfig {
        ndk {
            // 권장: x86 제거, 64비트 우선
            abiFilters 'arm64-v8a', 'armeabi-v7a'
        }
    }
    
    // ABI별 APK 분할 (APK 크기 최적화)
    splits {
        abi {
            enable true
            reset()
            include 'armeabi-v7a', 'arm64-v8a'
            universalApk false  // 모든 ABI 포함 APK 생성 여부
        }
    }
}

2. CMake 설정

# CMakeLists.txt
cmake_minimum_required(VERSION 3.22.1)
project("native-lib")

# C++20 표준 사용
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# ABI별 최적화
if(${ANDROID_ABI} STREQUAL "arm64-v8a")
    # ARM64: NEON 필수, 고급 최적화
    set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O3 -march=armv8-a+crc+crypto")
    set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -ftree-vectorize")
elseif(${ANDROID_ABI} STREQUAL "armeabi-v7a")
    # ARM32: NEON 선택적, 호환성 중시
    set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O2 -mfpu=neon-vfpv4")
    set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -mfloat-abi=softfp")
endif()

# 보안 강화
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fstack-protector-strong")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -D_FORTIFY_SOURCE=2")
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl,-z,relro -Wl,-z,now")

add_library(native-lib SHARED native-lib.cpp)

3. 런타임 ABI 감지

class AbiManager {
    fun getCurrentAbi(): String {
        return Build.SUPPORTED_ABIS[0]  // 기본 ABI
    }
    
    fun loadOptimizedLibrary() {
        when (getCurrentAbi()) {
            "arm64-v8a" -> {
                System.loadLibrary("native-lib-arm64")
                Log.d("ABI", "64비트 최적화 라이브러리 로드")
            }
            "armeabi-v7a" -> {
                System.loadLibrary("native-lib-arm32")
                Log.d("ABI", "32비트 라이브러리 로드")
            }
            else -> {
                System.loadLibrary("native-lib")
                Log.d("ABI", "기본 라이브러리 로드")
            }
        }
    }
    
    fun checkAbiCompatibility() {
        val primaryAbi = Build.SUPPORTED_ABIS[0]
        // API 21 이상에서만 사용 가능
        val all32BitAbis = Build.SUPPORTED_32_BIT_ABIS
        val all64BitAbis = Build.SUPPORTED_64_BIT_ABIS
        
        Log.d("ABI", """
            Primary ABI: $primaryAbi
            32-bit ABIs: ${all32BitAbis.joinToString()}
            64-bit ABIs: ${all64BitAbis.joinToString()}
        """.trimIndent())
    }
}

ABI별 고려사항

NEON SIMD 활용 (ARM)

#ifdef __ARM_NEON__
#include <arm_neon.h>

void processAudio_NEON(float* samples, int count, float gain) {
    // 4개씩 병렬 처리 (128비트 NEON 레지스터)
    // 실제 성능 향상: 1.5-3배 (메모리 대역폭 제한)
    for (int i = 0; i < count; i += 4) {
        // 프리페치로 캐시 미스 감소
        __builtin_prefetch(&samples[i + 16], 0, 1);
        
        float32x4_t vec = vld1q_f32(&samples[i]);  // 4개 로드
        vec = vmulq_f32(vec, vdupq_n_f32(gain));   // 4개 동시 곱셈
        vst1q_f32(&samples[i], vec);               // 4개 저장
    }
}
#else
void processAudio_Standard(float* samples, int count, float gain) {
    // 일반 처리
    for (int i = 0; i < count; i++) {
        samples[i] *= gain;
    }
}
#endif

64비트 최적화

// 64비트의 장점 활용
void processLargeData(uint8_t* data, size_t size) {
    #ifdef __LP64__  // 64비트 환경
        // 더 큰 주소 공간 활용
        // 더 많은 레지스터 사용 (ARM64: 31개의 64비트 범용 레지스터 vs ARM32: 16개)
        
        // 8바이트씩 처리 (64비트 레지스터)
        uint64_t* ptr = (uint64_t*)data;
        size_t count = size / 8;
        
        for (size_t i = 0; i < count; i++) {
            ptr[i] = processChunk(ptr[i]);
        }
    #else  // 32비트 환경
        // 4바이트씩 처리
        uint32_t* ptr = (uint32_t*)data;
        size_t count = size / 4;
        
        for (size_t i = 0; i < count; i++) {
            ptr[i] = processChunk(ptr[i]);
        }
    #endif
}

실제 디버깅은 어떻게?

1. UnsatisfiedLinkError

// 흔한 에러
java.lang.UnsatisfiedLinkError: 
    dlopen failed: "/data/app/.../lib/arm/libnative.so" 
    is 32-bit instead of 64-bit

해결 방법

class NativeLibLoader {
    companion object {
        fun loadLibrarySafely(libName: String) {
            try {
                System.loadLibrary(libName)
            } catch (e: UnsatisfiedLinkError) {
                // ABI 불일치 처리
                Log.e("ABI", "Library load failed: ${e.message}")
                
                // 대체 라이브러리 로드 시도
                val abi = Build.SUPPORTED_ABIS[0]
                System.loadLibrary("$libName-$abi")
            }
        }
    }
}

2. 크래시 디버깅

// ABI별 크래시 정보 수집
extern "C" JNIEXPORT void JNICALL
Java_com_example_CrashHandler_collectAbiInfo(JNIEnv* env, jobject obj) {
    #ifdef __arm__
        __android_log_print(ANDROID_LOG_INFO, "ABI", "ARM32 detected");
    #endif
    
    #ifdef __aarch64__
        __android_log_print(ANDROID_LOG_INFO, "ABI", "ARM64 detected");
    #endif
    
    #ifdef __i386__
        __android_log_print(ANDROID_LOG_INFO, "ABI", "x86 detected");
    #endif
    
    #ifdef __x86_64__
        __android_log_print(ANDROID_LOG_INFO, "ABI", "x86_64 detected");
    #endif
    
    // 포인터 크기 확인
    __android_log_print(ANDROID_LOG_INFO, "ABI", 
        "Pointer size: %zu bytes", sizeof(void*));
    
    // 정렬 확인
    __android_log_print(ANDROID_LOG_INFO, "ABI",
        "Alignment of long: %zu", alignof(long));
}

APK 크기 최적화 전략

1. App Bundle 사용

// build.gradle
android {
    bundle {
        abi {
            // 특정 ABI 제외
            enableSplit = true
        }
    }
}

Google Play의 App Bundle을 사용하면 각 사용자의 디바이스에 맞는 ABI만 선택적으로 전달됩니다. 실제로 이 방식을 적용하면 네이티브 라이브러리를 포함한 앱의 경우 설치 크기가 평균 20-35% 정도 줄어들고, 다운로드 시간도 단축되며 사용자의 저장 공간도 절약할 수 있습니다.

2. ABI별 리소스 최적화

class ResourceOptimizer {
    fun loadOptimizedResource() {
        val isArm64 = Build.SUPPORTED_ABIS[0] == "arm64-v8a"
        
        if (isArm64) {
            // 64비트 디바이스는 보통 고성능
            loadHighQualityAssets()
        } else {
            // 32비트 디바이스는 저사양일 가능성
            loadOptimizedAssets()
        }
    }
}

왜 Android ABI를 알아야 하나요?

이제 처음 질문으로 돌아가보겠습니다. 왜 우리가 Android ABI를 알아야 할까요?

unsatisfied link error

사실 대부분의 Android 개발자는 ABI를 몰라도 앱을 만들 수 있습니다. 하지만 UnsatisfiedLinkError: is 32-bit instead of 64-bit 같은 에러를 만났을 때, 혹은 앱 크기를 줄여달라는 요구를 받았을 때, 또는 특정 디바이스에서만 크래시가 발생할 때 ABI 지식이 없다면 막막할 수밖에 없습니다.

예를 들어, 카메라 필터 앱을 개발한다고 가정해봅시다. 이미지 처리를 위해 C++로 작성된 필터 엔진을 사용하는데, 갑자기 회사 내 운영팀으로부터 특정 삼성 갤럭시 디바이스에서만 필터 적용이 느리다는 문의가 들어옵니다. 디버깅해보니 해당 디바이스가 32비트 모드로 동작하고 있었고, NEON SIMD 명령어를 활용하지 못해 픽셀 하나하나를 순차적으로 처리하고 있었을 수 있습니다. 또는 음성 인식 SDK를 만들다가 중국산 저가형 태블릿에서 오디오 처리가 버벅거린다면? 대부분 32비트 x86 아키텍처의 한계일 가능성이 높습니다.

또한 APK 크기 문제도 무시할 수 없습니다. 모든 ABI를 포함한 universal APK는 각 ABI별 네이티브 라이브러리를 모두 포함하기 때문에 크기가 커집니다. 하지만 Google Play의 App Bundle을 활용하면 사용자는 자신의 디바이스에 맞는 ABI만 다운로드받게 되어 데이터 사용량도 줄일 수 있습니다.

무엇보다 네이티브 라이브러리를 사용하는 순간 ABI는 피할 수 없는 주제가 됩니다. OpenCV, FFmpeg, TensorFlow Lite 같은 라이브러리를 통합하려면 각 ABI별로 빌드된 .so 파일이 필요합니다. 이때 ABI를 이해하지 못하면 "왜 arm64-v8a 폴더가 필요한지", "왜 같은 라이브러리인데 용량이 다른지" 같은 기본적인 것조차 이해하기 어렵습니다.

마치며

ABI는 눈에 보이지 않는 곳에서 우리 앱이 제대로 동작하도록 만드는 핵심 메커니즘입니다. Java/Kotlin 코드와 네이티브 코드가 대화할 수 있는 것도, 다양한 디바이스에서 앱이 동작하는 것도 모두 ABI 덕분입니다.

처음엔 복잡해 보일 수 있지만, 한 번 이해하고 나면 더 깊은 레벨의 최적화와 문제 해결이 가능해집니다. 특히 고성능이 필요한 게임, 미디어 처리, AI 추론 등의 분야에서는 ABI 지식이 필수적입니다.

다음에 abiFilters를 설정하거나 JNI 함수를 작성할 때, 이제는 그 뒤에서 일어나는 일들을 완전히 이해하고 있을 겁니다.

코드는 결국 CPU에서 실행되고, ABI는 그 실행 규칙을 정의합니다.

참고 자료

공식 문서

About Me
@onseok
배움을 배포하기