
현재 C++ 바이너리가 포함되는 Android 앱을 하나 개발하고 있는데, 빌드 후에 갑자기 평소에 보이지 않던 경고가 발생했다. 그 경고의 원인을 따라 가보니 다음 링크를 찾을 수 있었다: Support 16 KB page sizes - Android Developers
이 공지에서는 2025년 11월 1일부터 Play 스토어에 제출되는 64 비트 디바이스 대상 모든 앱은 반드시 16 KB 페이지 크기를 지원해야 한다고 안내하고 있다. 대충 간단히 읽어보니 원래는 4 KB가 최적의 크기였으나, 현재는 디바이스의 평균 성능이 올라가면서 16 KB로 변경하게 되었다는 것 같더라. 근데 페이징 관련하여 운영체제 강의에서 배웠던 내용들이 기억이 잘 안 나더라. 그래서 리마인드차 간단하게 관련 게시글을 작성해보고자 한다.
페이징(paging)은 메모리를 잘게 쪼개는 것을 말한다. 조금 더 전공적(?)으로는 프로세스의 논리 메모리를 같은 크기의 페이지(page)로 나누고, 이를 물리 메모리의 프레임(frame)에 매핑하여 연속되지 않은 물리 메모리를 연속된 공간처럼 사용할 수 있게 하는 메모리 관리 기법이다.
메모리 파편화를 방지하고, 메모리를 효율적으로 사용하기 위해서이다. 페이징을 하지 않았을 때 가능한 한 가지 상황을 가정해보자:
이 상황에서 메모리를 4 KB 먹는 앱 D를 실행시키려고 하면, 가능할까? 답은 불가능이다. 왜냐하면 칸의 개수만 따지면 4 KB 남긴 했지만, 공간이 파편화되어 있기 때문에 '연속적인' 4 KB를 할당하는 것은 불가능하기 때문이다. 페이징 정책이 없는 OS는 부팅 후 시간이 지남에 따라 새 프로세스가 실행되고 종료되는 과정을 반복하면서 파편화 정도가 더욱 더 심각해진다. 종국에는 아무 프로세스도 실행할 수 없는 상태에 이르게 된다.
이러한 메모리 파편화를 해결하기 위한 방법이 바로 페이징 정책이다.
페이징에 대해 논하기 위해서는 프레임(frame), 페이지(page) 그리고 페이지 테이블(page table)에 대해 알아봐야 한다.
프로세스의 논리 주소 공간을 고정된 크기로 나눈 블록을 의미한다.
물리 메모리를 페이지와 같은 단위로 나눈 것을 의미한다.
논리 메모리인 페이지를 물리 메모리의 프레임과 매핑하는 표이다. 이게 핵심이다.
앞의 상황에서 앱 D를 실행하지 못했던 이유는 페이징을 하지 않는 OS에서는 반드시 메모리가 연속적으로 할당되어야 하기 때문이다. 그러나 페이징 정책을 사용하는 OS에서는 반드시 연속적으로 메모리를 할당할 필요가 없다. 분리되어 있어도 된다.
| 인덱스 | 프로세스 A의 메모리 (페이지) | 페이지 테이블 | 물리 메모리 공간 (프레임) |
|---|---|---|---|
| 0 | 0번 페이지 | 0번 페이지-4번 프레임 | 0번 프레임 (비어 있음) |
| 1 | 1번 페이지 | 1번 페이지-3번 프레임 | 1번 프레임 (2번 페이지 저장) |
| 2 | 2번 페이지 | 2번 페이지-1번 프레임 | 2번 프레임 (비어 있음) |
| 3 | - | - | 3번 프레임 (1번 페이지 저장) |
| 4 | - | - | 4번 프레임 (0번 페이지 저장) |
위 표처럼 페이지 테이블은 각 페이지와 실제 프레임 번호를 매핑함으로써, 논리 메모리가 분할된 채로 물리 메모리에 저장될 수 있게 해준다. 이 경우에서는 메모리 2칸을 요구하는 프로세스를 비어 있는 0번 프레임과 2번 프레임에 나누어 배치할 수 있으므로, 메모리 파편화를 어느 정도는 방지하고 더 효율적으로 메모리를 사용할 수 있게 된다.
물론 이 방식도 아래와 같은 단점이 있다:
그럼에도 불구하고 심각한 메모리 파편화로 인해 메모리를 아예 사용하지 못하는 상황은 유발하지 않는다는 점에서, 페이징 정책으로 얻는 장점이 단점에 비하면 압도적으로 크다고 볼 수 있을 것이다.
위에 안내한 링크를 읽어보면, Android는 페이징 단위를 기존 4 KB에서 16 KB로 증가시켰다. 이유가 뭘까?
나 어릴 적에는 4 GB 하면 많은 메모리였지만, 지금은 16 GB 메모리를 가지는 디바이스도 많이 출시되고 있다. 만약 4 KB 페이징 정책을 유지한다면, 4 GB 디바이스에서는 1백만 개의 테이블 엔트리를 관리하면 된다. 그러나 16 GB 메모리에서는 그 4배인 4백만 개의 테이블 엔트리를 관리해야 한다. 테이블도 결국 저장 공간을 필요로 하는 만큼, 메모리가 커지고 있는데 페이징 단위는 그대로라면, OS는 페이지 테이블 관리를 위해 저장 공간도 더 많이 써야 할 것이다.
테이블도 일종의 공유 자원인 만큼 (적어도 쓰기 작업에서는) 원자적인 접근이 보장되어야 할 것이다. 그러나, 테이블 크기가 크다면, 그만큼 접근 요청도 매우 많을 것이고, 이는 곧 병목 현상의 발생을 의미한다. 테이블 크기를 줄이게 되면 접근 요청도 그만큼 줄어들고, 접근에 필요한 시간(레이턴시)도 크게 줄어들 것이다.
보통 CPU는 페이지를 프레임으로 바꾸기 위해, 논리적인 페이지 테이블이 아니라 TLB라고 불리는 오직 페이징만을 위해 존재하는 물리적인 메모리를 사용한다. 페이징 단위를 4 KB에서 16 KB로 증가시킨다는 것은, 곧 TLB의 테이블 엔트리 1개의 효율이 4배 올라간다는 말과 같다.
따라서, 16 KB 로 페이징 단위를 증가시키는 것은 최근 하드웨어의 발전에 발 맞추려는 Google의 적절한 조치라고 볼 수 있겠다.
만약 당신이 오직 Kotlin과 Java로만 프로젝트를 개발하고 있다면, 이런 변경 사항에 신경 쓸 필요 없다. Kotlin/Java 쪽은 이미 알아서 16 KB 페이징에 맞추어 처리하는 것 같다. 그러나 C/C++은 얘기가 다르다.
C/C++는 다른 고급 언어와는 다르게, 메모리 공간에 직접 접근이 가능하다. 그래서 C/C++에서는 베이스 레지스터에 인덱스를 더하는 등 메모리 주소에 대한 직접적인 연산이 필요한 경우가 있다. 그런데, 만약 C/C++ 코드는 4 KB를 페이징 단위로 알고 있는데, 실제 동작 환경에서는 16 KB를 페이징 단위로 쓰고 있다면 문제가 생긴다.
이런 과정에 의해 오류가 발생하게 된다. 따라서, C/C++을 Android 프로젝트에 포함하고 있다면, 이 바이너리가 16 KB 단위로 정렬되도록 수정해주어야 한다.
다행히도, 위에서 소개한 안내에 따르면, NDK r27 버전과 Android Gradle Plugin 8.5.1 이후부터는 , C/C++를 코드에 포함하고 있어도 알아서 16 KB 단위로 코드를 정렬해 준다. 따라서 일반적인 경우에는 딱히 뭘 건들 필요가 없다.
근데 이 글을 왜 썼겠는가? 자동으로 변환해주고 있다는 안내에도 불구하고, 내 코드는 4 KB 단위로 정렬되는 문제가 발생했기 때문이다.
나는 Microsoft에서 개발한 오픈 소스 동형 암호화 라이브러리인 SEAL을 내 Android 프로젝트에 포함했다. 그리고 위 안내의 내용처럼 16 KB 단위로 컴파일될 것을 기대했으나 그렇지 않았다. 아마 SEAL 코드 내부에 무언가 4 KB 페이징을 강제하는 옵션이 있을 것이다. 그러나 나는 그걸 섣불리 찾아 수정할 수 없었는데...
그래서, 나는 그냥 코드 내부에 존재할지도 모르는 4 KB 페이징 설정을 강제로 16 KB로 덮어씌우는 방법을 택하기로 했다.
보통 Android 프로젝트에서 C/C++을 사용하고 있다면, NDK와 CMake를 통해 컴파일을 할 것이다. 이 때, 가장 최상위에 위치한 CMakeLists.txt에 아래 코드를 적어주면 된다:
# CMake 버전 특정
cmake_minimum_required(VERSION 3.18)
# 16 KB 호환성 강제 설정
if(ANDROID_ABI STREQUAL "arm64-v8a" OR ANDROID_ABI STREQUAL "x86_64")
target_link_options(project_name PRIVATE "-Wl,-z,max-page-size=16384")
endif()
코드를 상세하게 파 보자:
아래에서 사용할 target_link_options 명령어는 CMake 3.18 이상에서만 지원된다. 그래서 반드시 최소 버전을 명시해주어야 한다.
ANDROID_ABI STREQUAL "arm64-v8a" OR ANDROID_ABI STREQUAL "x86_64" 코드는 64 비트 ABI가 타겟일 경우에만 실행되도록 조건을 걸어주는 역할을 한다.
각 옵션을 상세히 살펴보자:
target_link_options()은 링킹 단계에 옵션을 주겠다는 말이다.PRIVATE는 이 라이브러리를 가져다 쓰는 다른 프로젝트에는 이 변경 사항을 적용하지 않겠다는 말이다.-Wl은 이어지는 옵션을 컴파일러가 아니라 링커에게 전달하라는 말이다.-z,는 링커에게 콤마 뒤에 키워드 옵션을 주겠다는 말이다.max-page-size=16384에서 16,384 = 16 * 1,024이며, 이는 곧 16 KB를 의미한다.물론 위에서 명시한 대로 AGP와 NDK를 모두 최신 버전으로 업데이트해 쓰면 굳이 이 문제를 겪을 일은 없을 수도 있다. 다만, 나의 경우처럼 외부에서 가져온 라이브러리가 강제로 4 KB 단위로 잘려 있을 수도 있고, 또는 나도 모르게 페이징 시 문제가 발생할지도 모르는 일이다.
따라서 방어적인 관점에서는 위 옵션을 CMake에 전달하여, 혹시나 모를 상황을 방지하는 것도 나쁘지 않을 수 있겠다는 생각이 든다.