LLM WikiAccess-protected knowledge portal
← 스터디 홈
3편 · 약 24분

메모리 관리와 가상 메모리

가상 메모리가 필요한 이유

현대 운영체제에서 프로세스는 물리적 RAM 주소를 직접 다루지 않는다. 각 프로세스는 자신만의 가상 주소 공간(virtual address space) 을 가지며, 커널이 가상 주소를 물리 주소로 변환한다.

가상 메모리가 제공하는 핵심 기능:

  • 격리: 프로세스 A가 프로세스 B의 메모리를 볼 수 없다.
  • 추상화: 물리 메모리보다 더 큰 메모리가 있는 것처럼 사용할 수 있다 (swap과 함께).
  • 효율: 지연 할당(lazy allocation), 공유 라이브러리, Copy-on-Write.

x86-64 가상 주소 공간 레이아웃

x86-64 시스템에서 가상 주소는 64비트지만, 실제로는 하위 48비트(4레벨 페이지 테이블) 또는 57비트(5레벨 페이지 테이블)만 사용한다. 4레벨 기준으로 유저 공간 128 TiB + 커널 공간 128 TiB 총 256 TiB가 주소 가능하다.

가상 주소 공간 (256 TiB 총계) 커널 공간 (0xFFFF800000000000 ~ 0xFFFFFFFFFFFFFFFF) 커널 코드·데이터, kmalloc 직접 매핑(direct mapping)
vmalloc / ioremap 영역 (불연속 물리 페이지)
커널 모듈, fixmap, vmemmap
── 캐노니컬 홀(Canonical Hole): 유효하지 않은 주소 ── 유저 공간 (0x0000000000000000 ~ 0x00007FFFFFFFFFFF) 텍스트(코드) → 초기화 데이터 → BSS → 힙 ↑
공유 라이브러리 (mmap 영역, ASLR로 랜덤화)
스택 ↓ (최상위 주소 근처)
5레벨 페이징 활성화 시: 각 64 PiB (57비트 주소) — 대형 서버·AI 인프라에서 사용
x86-64 가상 주소 공간 레이아웃 (4레벨 페이징 기준)

ASLR(Address Space Layout Randomization): mmap, 스택, vdso 영역을 무작위 위치에 배치해 ROP·ret2libc 등 취약점 공략을 어렵게 한다. /proc/sys/kernel/randomize_va_space로 제어(0=비활성, 1=부분, 2=완전).

페이지 테이블: 주소 변환의 구조

가상 주소를 물리 주소로 변환하는 자료구조가 페이지 테이블(page table)이다. 4레벨 페이지 테이블에서 48비트 가상 주소는 다음과 같이 분해된다.

비트 47~39  38~30  29~21  20~12  11~0
     PGD     PUD    PMD    PTE   페이지 내 오프셋
     9bit    9bit   9bit   9bit    12bit  (= 4KB 페이지)

변환 과정:

  1. CR3 레지스터가 현재 프로세스의 PGD(Page Global Directory) 물리 주소를 가리킨다.
  2. PGD 인덱스(bit 47~39)로 PUD 테이블 주소 획득.
  3. PUD 인덱스(bit 38~30)로 PMD 주소 획득.
  4. PMD 인덱스(bit 29~21)로 PTE 테이블 주소 획득.
  5. PTE 인덱스(bit 20~12)로 물리 페이지 번호(PFN) 획득.
  6. PFN << 12 + 오프셋(bit 11~0) = 최종 물리 주소.

각 PTE에는 물리 주소 외에도 플래그 비트가 포함된다:

플래그의미
P (Present)1이면 페이지가 메모리에 있음. 0이면 접근 시 페이지 폴트 발생
R/W0이면 읽기 전용, 1이면 읽기/쓰기 허용
U/S0이면 커널만 접근(Supervisor), 1이면 유저 접근 허용
D (Dirty)페이지에 쓰기가 발생했음
A (Accessed)최근에 접근됨 (LRU 판단에 활용)
NX (No-Execute)실행 불가 페이지 (스택 실행 방지)

TLB(Translation Lookaside Buffer): 페이지 테이블 탐색은 메모리를 4번 읽는 비용이 있다. CPU 내부의 TLB가 최근 변환 결과를 캐시해 대부분의 접근을 단 1 사이클에 처리한다. 컨텍스트 스위치 시 CR3 교체로 TLB가 flush된다(ASID 지원 CPU는 일부 보존).

페이지 폴트: 지연 할당의 핵심

페이지 폴트(page fault)는 CPU가 가상 주소에 접근했을 때 PTE의 Present 비트가 0이어서 발생하는 예외다. 커널은 do_page_fault() 핸들러로 원인을 판별하고 처리한다.

페이지 폴트 유형원인처리비용
Minor fault페이지가 메모리에 있지만 PTE 매핑 없음 (COW, 익명 mmap)PTE에 물리 페이지 연결, I/O 없음수 µs
Major fault페이지가 swap이나 파일에 있음I/O로 페이지 적재수 ms
Invalid fault잘못된 주소 접근, 권한 위반SIGSEGV 전송 → 프로세스 종료

지연 할당(lazy allocation): malloc(1GB)를 호출해도 실제 물리 메모리는 즉시 할당되지 않는다. PTE는 설정되지만 물리 페이지는 첫 접근(페이지 폴트) 시에 비로소 할당된다. 이것이 Linux가 물리 메모리보다 더 많은 가상 메모리를 프로세스에 허용(overcommit)하는 원리다.

# 프로세스별 페이지 폴트 카운터
/usr/bin/time -v ./myapp 2>&1 | grep -E 'Minor|Major'
# Minor (reclaiming a frame) page faults: 3847
# Major (requiring I/O) page faults: 12

# 시스템 전체 페이지 폴트 추이
vmstat 1
# pgfault: minor + major 합계, pgmajfault: major만

메모리 존(Zone): 물리 메모리 분류

Linux 커널은 DMA 가용성과 주소 범위 기준으로 물리 메모리를 존으로 분류한다.

Zone범위 (x86-64)용도
ZONE_DMA0 ~ 16 MB구식 ISA DMA 장치용
ZONE_DMA320 ~ 4 GB32비트 PCI DMA 장치용
ZONE_NORMAL4 GB 이상일반 커널·유저 메모리
ZONE_MOVABLE상위 영역메모리 핫플러그, 대용량 페이지 이동 가능
# 존별 free 페이지 수 확인
cat /proc/zoneinfo | grep -A5 "Node 0, zone"

버디 할당자와 SLUB은 요청에 맞는 존에서 메모리를 할당하되, 해당 존이 부족하면 상위 존(fallback)에서 가져온다.

버디 할당자: 페이지 단위 물리 메모리 관리

버디 할당자(buddy allocator)는 Linux의 물리 페이지 할당을 담당한다. 메모리를 2의 거듭제곱 크기(order)의 블록으로 관리하며, 총 11가지 order(0~10)를 사용한다.

  • order 0 = 4 KB (1페이지), order 1 = 8 KB, ..., order 10 = 4 MB (1024페이지)
  • 각 order마다 존별로 free list(연결 리스트)를 유지한다.
초기: order-2 블록 (16 KB)
4KB 4KB 4KB 4KB
→ 8KB 요청
order-2 → 두 개의 order-1로 분할
4KB ✓ 4KB ✓ 할당됨
4KB 4KB 버디 → order-1 free list
→ 반환 후
버디끼리 병합 → order-2 복귀
4KB 4KB 4KB 4KB
외부 단편화 최소화
/proc/buddyinfo: 존·order별 free 블록 수 실시간 확인
버디 할당자: 블록 분할과 병합 메커니즘

버디 할당자는 외부 단편화(external fragmentation)를 최소화하지만, 4 KB보다 훨씬 작은 객체에는 부적합하다. 그 역할은 SLUB이 담당한다.

SLUB 할당자: 커널 객체 캐시

커널 내부에서 자주 생성·소멸되는 작은 구조체(task_struct, inode, dentry, sk_buff 등)를 위해 SLUB 할당자가 있다. Linux 2.6.23부터 SLAB을 대체한 기본 구현이다.

동작 원리:

  1. 버디 할당자로 연속 페이지를 확보한다.
  2. 동일 크기의 객체 슬롯(slot)으로 쪼갠다.
  3. per-CPU free list를 통해 락 없이(lock-free) 빠르게 할당·해제한다.
  4. per-CPU 리스트가 비면 numa node별 partial slab → 새 슬랩(버디 요청) 순으로 확장.
# 슬랩 캐시 목록 및 메모리 사용량 확인
sudo slabtop

# 또는 직접 읽기
cat /proc/slabinfo | awk 'NR<=20'
# 예시 출력 (컬럼: name, active, num, objsize, ...)
# task_struct  1024  1024  5376  ...
# inode_cache  8192  8192   704  ...
# dentry       32768 32768  192  ...

kmalloc(size, GFP_KERNEL)은 size에 맞는 슬랩 캐시를 찾아 객체를 반환하며, kfree(ptr)는 슬랩에 반환한다.

mmap: 메모리와 파일의 교차점

mmap() 시스템 콜은 파일이나 디바이스를 프로세스의 가상 주소 공간에 직접 매핑한다. read()/write()와 달리 커널 버퍼 → 유저 버퍼 복사가 없어 제로카피(zero-copy)에 가깝다.

// 파일 내용을 메모리처럼 읽기 (file-backed mmap)
int fd = open("/data/bigfile.bin", O_RDONLY);
void *p = mmap(NULL, size, PROT_READ, MAP_SHARED, fd, offset);
// p[i]로 파일 내용 접근 — 페이지 폴트로 필요한 페이지만 적재

// 익명 mmap (glibc의 malloc이 큰 할당에 사용)
void *buf = mmap(NULL, size, PROT_READ | PROT_WRITE,
                 MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
유형플래그특징
File-backed sharedMAP_SHARED변경이 파일에 반영, 프로세스 간 공유
File-backed privateMAP_PRIVATECOW 적용, 변경이 파일에 미반영
Anonymous private`MAP_PRIVATE\MAP_ANONYMOUS`순수 RAM, malloc/스택 확장
# 프로세스의 가상 주소 공간 매핑 보기
cat /proc/1234/maps
# 55b8a3000000-55b8a3001000 r--p 00000000 fd:01 1234567  /usr/bin/myapp
# 7f9b2c000000-7f9b2c200000 r-xp 00000000 fd:01 2345678  /lib/x86_64-linux-gnu/libc.so.6
# 7ffd3e200000-7ffd3e221000 rw-p 00000000 00:00 0         [stack]

거대 페이지(Huge Pages): TLB 부담 줄이기

기본 4 KB 페이지는 대용량 메모리를 다룰 때 TLB 항목을 과도하게 소모한다. MySQL, PostgreSQL, Redis 같은 메모리 집약적 DB는 거대 페이지로 TLB miss를 크게 줄일 수 있다.

명시적 HugeTLBfs (Explicit Huge Pages):

  • 부팅 시 또는 /proc/sys/vm/nr_hugepages로 미리 예약한다.
  • 기본 크기 2 MB (x86-64 PMD 레벨), mmap(MAP_HUGETLB) 또는 hugetlbfs 마운트로 사용.
  • 예약된 후 조각화 없이 항상 사용 가능하다는 장점이 있다.

THP (Transparent Huge Pages):

  • 커널이 런타임에 연속된 4 KB 페이지를 2 MB 페이지로 자동 승격(promote)한다.
  • khugepaged 커널 데몬이 주기적으로 스캔·병합한다.
  • 애플리케이션 코드 수정 없이 동작하지만, 병합 과정의 지연 스파이크가 발생할 수 있다.
# THP 설정 확인 및 변경
cat /sys/kernel/mm/transparent_hugepage/enabled
# [always] madvise never

# madvise 모드: 앱이 명시적으로 요청한 범위에만 THP 적용
# madvise(addr, length, MADV_HUGEPAGE);  // C 코드에서

# /proc/meminfo에서 THP 사용량 확인
grep AnonHugePages /proc/meminfo
# AnonHugePages:   524288 kB  (= 256개의 2MB 페이지)

DB 운영 주의: MySQL InnoDB나 Elasticsearch는 THP의 always 모드가 오히려 지연 스파이크를 유발할 수 있다. madvise 또는 never로 설정하는 것이 안전하다.

OOM Killer: 메모리 고갈 시 최후 수단

Linux는 기본적으로 overcommit을 허용한다. 물리 RAM + 스왑보다 더 많은 가상 메모리를 프로세스에 할당한다. 실제 물리 메모리가 고갈되면 OOM Killer가 개입한다.

OOM Killer 동작:

  1. 각 프로세스에 oom_score(0~1000)를 계산한다. 메모리를 많이 쓸수록, 자식이 많을수록 높다.
  2. oom_score_adj(-1000~+1000)로 휴리스틱을 조정할 수 있다. -1000은 "절대 종료하지 말 것"을 의미한다.
  3. 가장 높은 oom_score를 가진 프로세스를 SIGKILL로 강제 종료.
# 현재 oom_score 확인
cat /proc/1234/oom_score

# 중요한 프로세스 OOM에서 보호 (예: 데이터베이스)
echo -1000 > /proc/1234/oom_score_adj

# overcommit 정책 확인/변경
cat /proc/sys/vm/overcommit_memory
# 0: 휴리스틱 (기본) — 명백히 불가능한 요청만 거부
# 1: 항상 허용
# 2: 엄격 제한 (RAM + swap × overcommit_ratio% 이하만 허용)

OOM이 발생하면 커널은 dmesg에 상세 로그를 남긴다.

dmesg | grep -A20 "Out of memory"
# Out of memory: Killed process 4567 (java) total-vm:8GB, anon-rss:6GB

/proc/meminfo 핵심 필드 읽기

cat /proc/meminfo
# MemTotal:       32716804 kB  ← 전체 물리 메모리
# MemFree:         1234567 kB  ← 즉시 사용 가능한 빈 메모리
# MemAvailable:   15000000 kB  ← 앱이 실제로 쓸 수 있는 양(캐시 회수 포함)
# Buffers:          512000 kB  ← 블록 디바이스 메타데이터 버퍼
# Cached:          8000000 kB  ← 파일 페이지 캐시 (회수 가능)
# SwapTotal:       8388608 kB
# SwapFree:        8000000 kB
# AnonHugePages:    524288 kB  ← THP 사용량
# SReclaimable:    1000000 kB  ← 회수 가능한 슬랩(inode/dentry 캐시 포함)
# SUnreclaim:       200000 kB  ← 회수 불가 슬랩(task_struct 등)

MemAvailableMemFree + 회수 가능한 캐시의 근삿값으로, 실제로 애플리케이션이 사용 가능한 메모리를 나타낸다. MemFree만 보면 파일 캐시를 무시해 메모리가 부족한 것으로 오해하기 쉽다.

References