Linux 메모리·가상 메모리 심화 — 페이지 테이블, 페이지 폴트, ZONE·슬랩과 프로덕션 튜닝

이 글의 핵심

사용자 공간의 가상 주소는 MMU가 물리 프레임으로 매핑하고, 매핑이 비어 있거나 권한이 맞지 않으면 페이지 폴트로 커널이 개입합니다. 페이지 테이블·영역(zone)·버디·슬랩·회수(reclaim) 경로를 알아야 MemAvailable·스왑·OOM·지연 스파이크를 같은 그림으로 설명할 수 있습니다.

들어가며

리눅스는 프로세스마다 가상 주소 공간을 제공하고, 실제 DRAM은 페이지(대개 4KiB) 단위의 물리 프레임으로 나뉘어 그 공간에 매핑됩니다. 이 변환은 CPU의 MMU가 페이지 테이블을 보고 수행하며, 매핑이 없거나 권한이 맞지 않으면 페이지 폴트가 발생해 커널의 폴트 처리 경로(x86-64에서는 exc_page_fault를 거쳐 공통 함수 handle_mm_fault)가 실행됩니다.

이 글은 Linux 커널 내부 동작에서 다룬 페이지 캐시·스왑·OOM 개요를 바탕으로, 페이지 테이블과 페이지 폴트, 메모리 영역(zone)과 버디 할당기, 슬랩 캐시, 회수 경로를 거쳐 프로덕션 튜닝까지 연결합니다. 스케줄러·태스크 상태와의 연결은 Linux 프로세스·스케줄러 심화와 함께 보면 좋습니다.


가상 주소·물리 프레임·MMU

프로세스 관점의 가상 주소

사용자 프로그램이 다루는 포인터는 가상 주소입니다. 같은 주소 값이라도 프로세스마다 다른 물리 프레임을 가리킬 수 있고, 커널은 이 대응을 프로세스별 페이지 테이블로 관리합니다. 이 구조가 프로세스 간 격리의 기반이며, 실제 물리 메모리보다 많은 가상 메모리를 나눠 주는 과커밋(overcommit)도 여기서 가능해집니다.

MMU와 TLB

MMU는 가상 주소를 물리 주소로 바꿀 때마다 페이지 테이블을 여러 단계 읽어야 하므로, 최근 변환 결과를 TLB(Translation Lookaside Buffer)에 캐시합니다. TLB는 엔트리 수가 제한되어 있어서 넓은 메모리를 무작위로 접근하는 프로그램은 TLB 미스가 잦아지고, 미스마다 페이지 테이블을 다시 걷는 비용을 냅니다. 2MiB나 1GiB 대형 페이지(huge page)는 한 엔트리로 더 넓은 범위를 덮어 이 압력을 줄이지만, 뒤에서 볼 것처럼 대가가 따릅니다.


다단계 페이지 테이블과 PTE

x86-64에서는 보통 4단계 페이지 테이블을 쓰고, 이를 지원하는 CPU와 커널 설정에서는 5단계도 쓸 수 있습니다. 48비트 가상 주소 중 하위 12비트는 페이지 안의 오프셋이고, 나머지 36비트를 9비트씩 나눠 각 단계 테이블의 인덱스로 씁니다. 리눅스 커널은 이 단계를 아키텍처와 무관하게 PGD, P4D, PUD, PMD, PTE라고 부르며, 4단계 환경에서는 P4D 단계가 접혀 있습니다.

가상 주소  ──► [PGD 인덱스] ──► [PUD] ──► [PMD] ──► [PTE] ──► 물리 프레임 + 페이지 내 오프셋
                │                │        │        │
                ▼                ▼        ▼        ▼
             각 단계의 엔트리는 다음 단계 테이블의 물리 주소를 담음

단계가 많은 이유는 공간 절약입니다. 64비트 주소 공간 전체를 평평한 테이블 하나로 표현하면 거대한 테이블이 필요하지만, 다단계 구조에서는 실제로 쓰는 주소 범위에 해당하는 하위 테이블만 만들면 됩니다.

PTE에 담기는 정보

PTE에는 물리 프레임 번호와 함께 present 비트, 읽기/쓰기 권한, 실행 금지(NX) 비트, 사용자/커널 구분, accessed·dirty 같은 플래그가 들어갑니다. 커널은 accessed 비트로 페이지가 최근에 쓰였는지 판단하고, dirty 비트로 디스크에 다시 써야 하는지 판단합니다. 스왑 아웃된 페이지는 present 비트가 꺼진 채 스왑 장치의 위치를 담은 엔트리로 바뀌며, 다시 접근하면 폴트가 나서 스왑 인이 일어납니다. 그 페이지가 아직 스왑 캐시에 남아 있으면 디스크를 읽지 않고 minor fault로 끝나기도 합니다.

mm_struct와 VMA

각 프로세스의 주소 공간은 mm_struct로 표현되고, 여기에 VMA(Virtual Memory Area) 목록과 페이지 테이블 루트가 연결됩니다. mmap, brk, 파일 매핑은 VMA를 추가하거나 바꿀 뿐이며, 실제 물리 페이지는 대개 처음 접근하는 순간에 할당됩니다. 폴트가 났을 때 커널은 먼저 그 주소가 어떤 VMA에 속하는지 찾고, 어디에도 속하지 않거나 VMA의 권한과 맞지 않으면 프로세스에 SIGSEGV를 보냅니다.


페이지 폴트 처리: 왜 폴트가 정상 경로인가

페이지 폴트는 대부분 오류가 아니라, 미뤄 둔 작업을 실제로 수행하는 정상적인 트랩입니다. 폴트를 일으키는 경우는 크게 세 가지입니다. present 비트가 꺼져 있어 아직 물리 페이지가 없거나 스왑·파일에서 읽어 와야 하는 경우, 쓰기를 시도했는데 PTE가 읽기 전용이라 COW 같은 처리가 필요한 경우, 그리고 실행 금지 페이지에서 코드를 실행하려 한 경우입니다. 마지막 경우와 VMA 권한 자체를 위반한 경우는 SIGSEGV로 끝납니다.

Minor와 Major

minor fault는 디스크 I/O 없이 끝나는 폴트입니다. 익명 메모리를 처음 건드려 0으로 채운 새 페이지를 할당하는 경우, COW 복사, 다른 프로세스가 이미 읽어 둔 페이지 캐시를 매핑하는 경우가 여기에 해당합니다. I/O는 없지만 커널 진입과 페이지 할당 비용은 있으므로, 큰 버퍼를 처음 채우는 코드에서는 minor fault만으로도 시간이 눈에 띄게 걸릴 수 있습니다.

major fault는 디스크나 스왑에서 데이터를 읽어 와야 하는 폴트로, 그동안 스레드가 I/O를 기다립니다. 콜드 스타트, 메모리 압박으로 페이지 캐시가 밀려난 뒤의 재접근, 스왑 인에서 나타납니다.

두 폴트는 도구로 바로 확인할 수 있습니다. 프로세스별 누적값과 시스템 전체 추이를 함께 보면, 느려진 시점에 major fault가 늘었는지를 빠르게 판단할 수 있습니다.

# 프로세스별 누적 minor/major fault
ps -o pid,comm,min_flt,maj_flt -p <PID>

# 명령 하나를 실행하면서 폴트 수 측정 (Major/Minor page faults 항목)
/usr/bin/time -v ./app

# perf로 이벤트 단위 측정
perf stat -e page-faults,major-faults,minor-faults ./app

# 시스템 전체: majflt/s가 튀는지, pgscand/s(직접 회수)가 늘어나는지
sar -B 1

sar -B의 majflt/s가 평소 0에 가깝다가 특정 시각에만 튄다면 그 시점에 페이지 캐시가 밀려나 파일을 다시 읽었거나 스왑 인이 일어난 것입니다. 같은 시각 pgscand/s(직접 회수로 스캔한 페이지)가 함께 늘었다면 할당 경로에서 회수가 동기적으로 일어나 지연이 생겼다는 뜻입니다.

Copy-On-Write(COW)

fork 직후 부모와 자식은 같은 물리 페이지를 읽기 전용으로 공유하고, 한쪽이 쓰기를 시도하면 그 페이지만 복사해 각자의 것으로 만듭니다. 덕분에 fork 자체는 빠르지만, 이후 쓰기가 많으면 페이지마다 폴트와 복사가 일어납니다. fork 자체도 페이지 테이블을 복사해야 하므로 수십 GB를 쓰는 프로세스에서는 수십 밀리초 이상 걸릴 수 있습니다.

대표적인 사례가 Redis의 RDB 스냅샷입니다. Redis는 fork한 자식 프로세스가 메모리를 디스크에 쓰는 동안 부모가 계속 요청을 처리하는데, 쓰기 요청이 많으면 부모가 건드리는 페이지마다 COW 복사가 일어나 스냅샷 도중 메모리 사용량이 크게 늘어납니다. THP가 켜져 있으면 문제가 더 커질 수 있습니다. 오래된 커널에서는 대형 페이지의 COW가 2MiB 전체를 복사했고, 최신 커널은 대형 페이지를 쪼개 4KiB 단위로 복사하도록 바뀌었지만 THP 할당과 컴팩션에 따른 지연은 여전히 남습니다. Redis가 시작할 때 THP가 always이면 경고 로그를 남기는 이유입니다. 메모리 여유를 평소 사용량만 보고 잡았다가 스냅샷 시각마다 OOM이 나는 일이 흔하므로, fork를 쓰는 프로세스는 쓰기량에 비례하는 여유를 따로 잡아야 합니다.


메모리 영역(ZONE)과 “남는데 실패”하는 이유

물리 메모리는 남은 바이트 하나로 표현되지 않고 ZONE으로 나뉩니다. x86-64에서는 ZONE_DMA(16MiB 아래), ZONE_DMA32(4GiB 아래), ZONE_NORMAL(나머지)이 기본이고, 설정에 따라 ZONE_MOVABLE이 추가됩니다. 할당 요청은 어느 영역에서 메모리를 받아도 되는지를 플래그로 지정하며, NUMA 시스템에서는 노드마다 이 영역들이 따로 있습니다.

DMA와 주소 제약

일부 장치는 낮은 물리 주소만 DMA로 접근할 수 있습니다. 그런 장치용 할당은 낮은 영역에서만 받아야 하므로, 이 영역이 고갈되면 전체 여유 메모리는 충분한데 특정 할당만 실패하는 현상이 나타납니다. 일반 서버 워크로드에서는 드물지만 드라이버, 임베디드 장치, 일부 NIC·스토리지 환경에서는 실제로 문제가 됩니다.

ZONE_MOVABLE

ZONE_MOVABLE에는 사용자 페이지처럼 다른 위치로 옮길 수 있는 페이지만 들어갑니다. 커널 객체처럼 옮길 수 없는 페이지가 섞이지 않으므로 메모리 핫 언플러그가 가능해지고, 컴팩션으로 큰 연속 영역을 만들기도 쉬워집니다. 대신 커널 할당은 이 영역을 쓸 수 없으므로 너무 크게 잡으면 커널 메모리가 부족해질 수 있습니다. 커널 버전, 부팅 파라미터(movablecore, kernelcore), 하드웨어에 따라 동작이 달라서 다른 환경의 설정을 그대로 가져오는 것은 위험합니다.


버디 할당기와 단편화

물리 페이지는 버디 할당기가 2^order 페이지 크기의 연속 블록 단위로 관리합니다. 블록을 반으로 쪼개 작은 요청에 주고, 해제할 때 짝(buddy) 블록도 비어 있으면 다시 합칩니다. /proc/buddyinfo를 보면 영역별로 order마다 남은 블록 수를 확인할 수 있습니다.

작은 할당과 해제가 반복되면 외부 단편화가 생겨, 빈 페이지 수는 충분한데 연속된 큰 블록이 없는 상태가 됩니다. 커널은 사용 중인 페이지를 옮겨 큰 연속 영역을 만드는 컴팩션(compaction)으로 이를 해결하려 하는데, 메모리 압박이 클 때 할당 경로에서 동기적으로 컴팩션이 일어나면 지연으로 체감됩니다.


슬랩 할당자(SLUB)와 커널 객체

커널 내부에서 작고 자주 할당되는 객체(task_struct, inode, dentry 등)는 페이지 단위로 할당하면 낭비가 크므로, 객체 크기별 슬랩 캐시에서 관리합니다. 현재 리눅스의 기본이자 사실상 유일한 슬랩 구현은 SLUB입니다(SLAB은 6.8에서 제거되었습니다).

운영자가 슬랩을 알아야 하는 이유는, 메타데이터가 많은 워크로드에서 메모리가 사용자 프로세스가 아니라 커널 슬랩에서 늘기 때문입니다. 수백만 개의 작은 파일을 훑는 백업이나 검색 작업이 돌고 나면 dentry와 inode 캐시가 수 GB까지 커질 수 있고, 이는 어떤 프로세스의 RSS에도 잡히지 않습니다. slabtop이나 /proc/slabinfo, /proc/meminfo의 SReclaimable/SUnreclaim 항목이 단서가 됩니다. dentry·inode 캐시는 대부분 회수 가능(SReclaimable)하므로 그 자체로는 문제가 아니지만, SUnreclaim이 계속 늘어난다면 커널 쪽 누수를 의심해야 합니다.


회수(reclaim): kswapd와 직접 회수

메모리가 부족해지면 커널은 페이지 회수를 시작합니다. 디스크와 내용이 같은 깨끗한 파일 페이지는 그냥 버리면 되고, 더티 파일 페이지는 디스크에 쓴 뒤 버리며, 익명 페이지는 스왑으로 내보내야 합니다.

여기서 흔히 오해하는 지표가 free 명령의 free 열입니다. 리눅스는 남는 메모리를 페이지 캐시로 적극 활용하므로, 오래 돈 서버일수록 free 값은 작고 buff/cache가 큽니다. 이는 메모리가 부족하다는 뜻이 아닙니다. 깨끗한 페이지 캐시는 필요하면 즉시 버릴 수 있기 때문에, 실제로 새 프로그램에 줄 수 있는 양은 available 열(/proc/meminfo의 MemAvailable)이 더 정확하게 보여 줍니다. free가 작다는 이유로 echo 3 > /proc/sys/vm/drop_caches를 주기적으로 실행하는 습관은 캐시를 버려 오히려 디스크 읽기를 늘릴 뿐이므로 피해야 합니다.

백그라운드 kswapd와 직접 회수

각 영역에는 min, low, high 워터마크가 있습니다. 여유 페이지가 low 아래로 내려가면 커널 스레드 kswapd가 깨어나 high에 도달할 때까지 백그라운드에서 회수합니다. 할당 속도가 이보다 빨라 여유 페이지가 min 근처까지 떨어지면, 할당을 요청한 프로세스가 직접 회수(direct reclaim)를 수행합니다. 직접 회수는 할당 경로 안에서 동기적으로 일어나므로, 그 프로세스의 요청 처리가 그만큼 멈춥니다.

운영에서 “가끔 갑자기 느려진다”는 증상은 직접 회수, 할당 경로의 컴팩션, 스왑 I/O 중 하나와 연결되는 경우가 많습니다. sar -B의 pgscand/s, /proc/vmstat의 allocstall_*와 compact_stall이 그 시각에 늘었는지 확인해 보면 어느 쪽인지 좁힐 수 있습니다.


투명 대형 페이지(THP)와 트레이드오프

THP는 애플리케이션 수정 없이 익명 메모리에 2MiB 대형 페이지를 자동으로 쓰는 기능입니다. TLB 효율은 좋아지지만, 일부만 쓰는 영역에도 2MiB를 잡아 메모리를 낭비할 수 있고, 대형 페이지를 만들기 위한 컴팩션이 할당 경로에서 일어나면 지연이 튑니다. 그래서 Redis, MongoDB 등 여러 데이터베이스가 THP를 always로 두지 말라고 권고합니다.

현재 설정은 cat /sys/kernel/mm/transparent_hugepage/enabled로 확인하며, [always] madvise never처럼 대괄호로 표시된 값이 적용 중인 모드입니다. always는 모든 익명 메모리에 THP를 시도하고, madvise는 애플리케이션이 madvise(MADV_HUGEPAGE)로 요청한 영역에만 씁니다. 배포판마다 기본값이 달라, 같은 애플리케이션이 한 배포판에서는 괜찮다가 다른 배포판에서만 지연 스파이크를 보이는 원인이 되기도 합니다. 전부 끄기보다 madvise로 두어 THP를 원하는 프로그램만 쓰게 하는 것이 무난한 절충이며, 어느 쪽이든 바꾸기 전후로 지연 시간과 메모리 사용량을 비교해 보고 정하는 것이 맞습니다.


프로덕션 튜닝: sysctl·cgroup·관측

vm.swappiness

vm.swappiness는 회수할 때 익명 페이지를 스왑으로 내보내는 쪽과 파일 캐시를 버리는 쪽 중 어디에 더 무게를 둘지 정하는 값입니다(최신 커널에서 범위는 0~200). 값을 낮추면 파일 캐시를 먼저 버리지만, 0으로 두어도 메모리가 충분히 부족해지면 스왑은 일어납니다. 값만 바꾼다고 성능이 좋아지는 것이 아니므로, 작업 집합 크기와 디스크 지연, 서비스의 지연 목표를 함께 보고 정해야 합니다.

더티 페이지와 writeback

vm.dirty_background_ratio(또는 _bytes)를 넘으면 백그라운드 스레드가 더티 페이지를 디스크에 쓰기 시작하고, vm.dirty_ratio를 넘으면 쓰기를 하는 프로세스 자신이 writeback이 따라잡을 때까지 멈춥니다. 메모리가 큰 서버에서 비율 기반 기본값을 그대로 쓰면 수 GB의 더티 페이지가 쌓였다가 한꺼번에 내려가며 긴 정지가 생길 수 있어, 로그나 배치 쓰기가 많은 서버는 바이트 기반 값으로 상한을 낮게 잡는 경우가 많습니다.

min_free_kbytes와 워터마크

vm.min_free_kbytes는 min 워터마크의 기준이 되어, 원자적 할당처럼 회수를 기다릴 수 없는 요청을 위해 남겨 두는 메모리 양을 정합니다. 너무 낮으면 네트워크 패킷 처리 같은 원자적 할당이 실패할 수 있고, 너무 높으면 그만큼의 메모리를 애플리케이션이 쓰지 못하고 회수가 일찍 시작됩니다. 다른 서버에서 쓰던 값을 그대로 가져오기보다 기본값에서 출발해 문제가 관측될 때 조정하는 편이 안전합니다.

cgroup 메모리: memory.high와 memory.max

cgroup v2에서 memory.high를 넘으면 해당 cgroup의 프로세스는 강하게 회수 압력을 받고 할당이 느려지지만 OOM으로 죽지는 않습니다. memory.max는 하드 리밋이라, 회수로도 한도 아래로 내려가지 못하면 그 cgroup 안에서 OOM killer가 동작합니다. Kubernetes의 메모리 limits는 memory.max로 설정됩니다.

컨테이너 환경에서 자주 놓치는 점은 cgroup 메모리 사용량에 그 컨테이너가 만든 페이지 캐시도 포함된다는 것입니다. 로그 파일을 많이 쓰는 컨테이너는 애플리케이션 힙이 작아도 사용량이 한도 근처까지 올라가 보이는데, 이 캐시는 회수 가능하므로 대개 문제가 없습니다. 반대로 한도에 닿아도 회수할 캐시가 없으면 컨테이너 안에서 OOM이 나고, Kubernetes에서는 파드 상태가 OOMKilled, 종료 코드가 137(SIGKILL)로 보입니다. 이때 노드 전체 메모리는 넉넉할 수 있다는 점이 혼란을 부릅니다. 커널 로그에도 시스템 OOM의 Out of memory: Killed process ...가 아니라 Memory cgroup out of memory로 기록되므로, 어느 쪽 한도에 걸렸는지 로그로 먼저 구분해야 합니다.

과커밋(overcommit)과 malloc의 “성공”

리눅스의 기본값 vm.overcommit_memory=0은 휴리스틱 모드입니다. 전체 메모리와 스왑을 합친 것보다 명백히 큰 요청만 거절하고, 그 안에서는 실제로 남은 메모리보다 많은 가상 메모리 예약을 허용합니다. 그래서 큰 malloc이 성공해도 실제 페이지는 처음 쓸 때 잡히고, 그 시점에 메모리가 없으면 malloc의 반환값이 아니라 OOM killer가 문제를 처리합니다. “malloc이 NULL을 반환하면 처리한다”는 방어 코드가 리눅스에서 거의 실행되지 않는 이유입니다.

예약 시점에 엄격히 제한하려면 vm.overcommit_memory=2를 씁니다. 이때 커밋 한도는 스왑 크기에 물리 메모리의 overcommit_ratio%(기본 50)를 더한 값입니다. 다만 fork가 잦은 프로세스나 큰 가상 영역을 미리 예약하는 런타임(JVM, 일부 DB)이 할당 실패를 겪을 수 있으므로 신중하게 적용해야 합니다.

관측 지표

/proc/meminfo의 MemAvailable은 회수 가능한 캐시까지 고려한 가용 메모리 추정치라, 여유 메모리 경보의 기준으로 MemFree보다 적합합니다. /proc/pressure/memory(PSI)는 메모리 부족 때문에 태스크가 멈춰 있던 시간 비율을 보여 주므로, 사용량이 아니라 실제로 메모리 압박이 성능에 영향을 주고 있는지를 판단할 수 있습니다. vmstat, sar -B, iostat을 함께 보면 페이지 인/아웃과 I/O 대기를 같은 시간축에서 비교할 수 있습니다.


OOM killer: 최후의 방어선

회수로도 메모리를 마련하지 못하면 OOM killer가 동작합니다. 커널은 각 프로세스의 메모리 사용량에 oom_score_adj 조정을 더한 oom_score를 계산해 가장 높은 프로세스를 종료하며, cgroup OOM이라면 그 cgroup 안에서만 고릅니다. 무엇이 죽었는지만 보지 말고, 왜 그 시점에 압력이 커졌는지(누수, 캐시 폭증, 한도 부족)까지 거슬러 올라가야 재발을 막을 수 있습니다. OOM 직전 dmesg에 출력되는 프로세스별 메모리 표와 영역별 free 목록이 그 출발점입니다.


증상별로 볼 지점

증상내부적으로 의심할 지점다음 관측
콜드 스타트만 느림Major fault, 디스크페이지 폴트 비율, I/O
메모리 여유인데 할당 실패ZONE/DMA 제약, 단편화dmesg, /proc/buddyinfo
가끔 매우 긴 지연직접 회수, 컴팩션PSI, vmstat, allocstall/compact_stall
커널 메모리만 증가슬랩, dentry/inodeslabtop, SUnreclaim
스왑 사용 후 체감 지연스왑 I/Osar -W, 디스크 지연

같이 보면 좋은 글