Skip to content
Published on

Linux 가상 메모리 완전 가이드 2025: Page Table, TLB, Huge Pages, Fork, Copy-on-Write 심층 분석

Authors

들어가며: 당신이 보는 주소는 거짓이다

한 가지 실험

#include <stdio.h>
#include <stdlib.h>

int main() {
    int *p1 = malloc(sizeof(int));
    *p1 = 42;
    printf("Address: %p, Value: %d\n", p1, *p1);
    return 0;
}

실행하면 어떤 주소가 나올까? 예를 들어 0x7ffeea8b4d60. 똑같은 프로그램을 두 번 실행하면 같은 주소가 나올 수도, 다른 주소가 나올 수도 있다. 게다가 두 프로세스가 같은 주소에 다른 데이터를 저장할 수 있다.

어떻게 가능한가? 답은 하나다: 당신이 보는 주소는 거짓이다. 정확히는 가상 주소(virtual address) 다. 실제 물리 메모리의 주소가 아니다.

Virtual Memory라는 환상

현대 OS는 각 프로세스에게 독립된 주소 공간을 제공한다. 64비트 Linux에서:

  • 프로세스는 2^47 바이트 ≈ 128 TB의 주소 공간을 본다.
  • 실제 물리 메모리는 16 GB일 수 있다.
  • 프로세스는 자기가 유일한 사용자처럼 행동한다.

이 환상을 유지하기 위해 CPU의 MMU (Memory Management Unit) 와 OS의 page table이 매 메모리 접근마다 작동한다.

왜 이걸 알아야 하는가?

  • 성능: Huge page 설정으로 수십% 성능 향상 가능.
  • 디버깅: 왜 이 포인터가 segfault? 왜 프로세스 간 메모리가 공유되지?
  • 컨테이너: 컨테이너의 메모리 제한은 어떻게 동작?
  • 데이터베이스: PostgreSQL의 shared_buffers, MySQL innodb_buffer_pool_size 튜닝.
  • 보안: ASLR, Kernel isolation, Spectre/Meltdown 이해.

Virtual memory를 모르면 시스템 프로그래머로서 중요한 절반을 모르는 것이다. 이 글은 그 절반을 채운다.


1. 가상 메모리의 기본 개념

Virtual Memory의 세 가지 목표

Virtual memory는 세 가지 문제를 동시에 해결한다:

  1. 격리: 각 프로세스가 독립된 주소 공간을 봐서 서로 방해 안 됨.
  2. 추상화: 물리 메모리 크기와 무관하게 프로그램 작성 가능.
  3. 유연성: 필요한 만큼만 실제 메모리 할당 (lazy allocation, swap).

Address Space 분할

64비트 Linux 프로세스의 전형적인 address space:

0xFFFFFFFFFFFFFFFF  ┌─────────────────┐ 상위
Kernel Space      (커널만 접근 가능)
0xFFFF800000000000  ├─────────────────┤
                    │                 │
                     (non-canonical)   (사용 불가 구간)
                    │                 │
0x00007FFFFFFFFFFF  ├─────────────────┤
Stack (grow ↓)                    │                 │
.              
.              
.              
                    │                 │
                    │ mmap 영역       │
                    │                 │
Heap (grow ↑)                    ├─────────────────┤
BSS               (초기화 안 된 전역 변수)
                    ├─────────────────┤
Data              (초기화된 전역 변수)
                    ├─────────────────┤
Text (Code)       (read-only 실행 코드)
0x0000000000400000  ├─────────────────┤
                      (guard page)0x0000000000000000  └─────────────────┘ 하위
  • Text: 실행 코드. 읽기+실행만 가능.
  • Data: 초기화된 전역 변수.
  • BSS: 초기화 안 된 전역 변수 (0으로 채워짐).
  • Heap: malloc()이 확장하는 영역.
  • mmap: 파일 매핑, 공유 메모리, 큰 malloc.
  • Stack: 함수 호출 프레임.
  • Kernel: 커널 코드/데이터. 사용자 모드에서 접근 불가.

Canonical Address

64비트 주소 전체를 쓰지 않는다. 현재 x86-64는 48비트만 사용 (256 TB). 나머지는 비어 있는 "canonical" 규칙으로 강제:

  • 상위 16비트가 47번째 비트와 같아야 함.
  • 그렇지 않으면 non-canonical (사용 불가).

이는 CPU가 추후 48비트 이상으로 확장할 여지를 두기 위함이다.

Segmentation vs Paging

x86의 초기 32비트 시절엔 segmentation도 사용되었다. 64비트 x86에선 flat model(segment 없음)을 기본으로 하며, paging이 주 메커니즘이다.

이 글은 paging에 집중한다.


2. Page와 Page Table

Page: 가장 작은 단위

가상 메모리의 기본 단위는 page다. x86-64에서 보통 4 KB.

  • 가상 메모리는 가상 페이지로 분할.
  • 물리 메모리는 physical page (또는 page frame) 로 분할.
  • 둘 다 크기는 같음 (기본 4 KB).

주소 분해

4 KB 페이지일 때, 12비트가 offset (2^12 = 4096):

64비트 가상 주소:
[상위: virtual page number][하위 12비트: offset]

Page number → 어떤 페이지? Offset → 페이지 내 몇 번째 바이트?

단순 Page Table의 문제

가장 단순한 page table: 배열.

page_table[virtual_page_number] = physical_page_number

문제: 64비트 주소 공간 전체를 표현하려면:

  • Virtual pages: 2^52개.
  • 각 entry 8바이트.
  • Total: 36 PB (!).

한 프로세스당 36 PB 테이블? 불가능.

Multi-level Page Table

해결책: 계층 구조. 주소를 여러 부분으로 쪼개고, 각 부분이 한 단계의 인덱스가 된다.

x86-64의 4-level page table:

48-bit virtual address:
[9비트: PML4][9비트: PDPT][9비트: PD][9비트: PT][12비트: offset]
  • PML4 (Page Map Level 4): 512개 엔트리.
  • PDPT (Page Directory Pointer Table): 512개.
  • PD (Page Directory): 512개.
  • PT (Page Table): 512개.
  • 각 엔트리 8바이트.

모든 레벨이 512개 엔트리인 이유: 4KB 페이지 ÷ 8바이트 엔트리 = 512.

Walk 과정

주소 0x00007fb812345678 → 물리 주소 변환:

1. CR3 레지스터가 현재 프로세스의 PML4 물리 주소를 가리킴.
2. PML4[PML4_index]PDPT 주소.
3. PDPT[PDPT_index]PD 주소.
4. PD[PD_index]PT 주소.
5. PT[PT_index] → physical page 주소.
6. 최종: physical page + offset.

4번의 메모리 접근이 필요하다. 매우 비싸다. 그래서 TLB가 필요하다.

Sparse Allocation

Multi-level의 이점: 필요한 만큼만 할당. 프로세스가 10 MB만 쓰면, 사용하지 않는 PML4/PDPT 엔트리는 NULL이고 하위 테이블도 없다.

전체 page table 메모리: 수십 KB~수 MB 수준.

5-level Page Table

2017년 Intel은 5-level paging을 도입. 57비트 주소 공간 (128 PB). 거대한 메모리 서버용. 아직 대부분 워크로드는 4-level로 충분.


3. TLB: 변환을 빠르게

문제

매 메모리 접근마다 4번의 추가 메모리 접근은 5배 느려진다는 뜻이다. 당연히 견딜 수 없다.

TLB (Translation Lookaside Buffer)

CPU에는 TLB라는 특수 캐시가 있다. 가상 페이지 → 물리 페이지 매핑의 최근 결과를 저장:

TLB entry:
[virtual page number][physical page number][permissions][valid]

메모리 접근 순서:

  1. TLB hit: 즉시 물리 주소 얻음 (~1 cycle).
  2. TLB miss: Page table walk (수십수백 cycles).

TLB hit rate가 99% 넘으면 평균 매우 빠름.

TLB의 크기

현대 x86-64 CPU의 TLB:

  • L1 TLB (Instruction): 64~128 entries.
  • L1 TLB (Data): 64~128 entries.
  • L2 TLB (Unified): 1024~2048 entries.

단순 계산: 4KB × 2048 = 8 MB의 working set만 TLB로 커버.

TLB Miss의 비용

TLB miss는 몇 가지 이유로 비싸다:

  1. 여러 메모리 접근: 4번의 lookup.
  2. 캐시 미스 가능성: Page table이 L1/L2 캐시에 없을 수 있음.
  3. 파이프라인 stall.

실제 TLB miss는 100~500 cycles. 즉 40~200ns. 빈번하면 치명적.

TLB Shootdown

멀티코어에서 TLB는 각 코어에 독립적이다. 프로세스의 페이지 매핑이 변경되면 (예: munmap, mprotect, 페이지 회수), 모든 코어의 TLB를 무효화해야 한다.

이를 TLB shootdown이라 한다:

  1. 한 코어가 매핑 변경.
  2. 다른 코어들에게 IPI (Inter-Processor Interrupt) 전송.
  3. 각 코어가 자기 TLB entry 제거.
  4. 모두 완료될 때까지 대기.

TLB shootdown은 수천~수만 ns 걸린다. 빈번한 mapping 변경(예: JIT 컴파일러, 공유 메모리)이 성능 문제를 일으킬 수 있다.

ASID (Process Context ID)

context switch마다 TLB를 전부 비워야 할까? 그러면 매번 수백 번의 TLB miss.

해결: ASID (또는 PCID in x86). 각 TLB entry에 프로세스 ID를 함께 저장. Context switch 시:

  • TLB 비우지 않음.
  • 현재 ASID만 사용.
  • 다른 프로세스 entry는 무시.

Linux 4.14+부터 x86 PCID 지원. Context switch 오버헤드 크게 감소.


4. Huge Pages: TLB의 친구

동기

4 KB 페이지의 한계:

  • TLB가 2048 entries → 8 MB만 커버.
  • 데이터베이스 buffer pool이 GB급이면 TLB miss가 빈번.

해결: 더 큰 페이지.

x86-64의 페이지 크기

x86-64는 세 가지 페이지 크기 지원:

  • 4 KB (기본).
  • 2 MB (large page).
  • 1 GB (huge page, Intel Xeon 이상).

TLB 효율 비교

2048 entries TLB로 커버되는 메모리:

페이지 크기커버 범위
4 KB8 MB
2 MB4 GB
1 GB2 TB

1 GB 페이지면 2 TB도 TLB로 커버. TLB miss 거의 없음.

Huge Pages 설정

Linux의 huge page 옵션:

  1. Static Huge Pages (HugeTLB): 부팅 시 또는 런타임에 예약.
  2. Transparent Huge Pages (THP): 커널이 자동으로 큰 페이지로 합병/분할.

HugeTLB

정적으로 예약:

# 1024개의 2MB 페이지 예약 = 2 GB
echo 1024 > /proc/sys/vm/nr_hugepages

# 또는 부팅 시
# GRUB: hugepages=1024

애플리케이션이 mmapMAP_HUGETLB 플래그 사용:

void *p = mmap(NULL, size, PROT_READ | PROT_WRITE,
               MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB, -1, 0);

장점: 명시적, 예측 가능. 단점: 사전 예약, 메모리 고정.

Transparent Huge Pages (THP)

커널이 자동으로 4KB 페이지들을 2MB로 합병:

# THP 상태 확인
cat /sys/kernel/mm/transparent_hugepage/enabled
# [always] madvise never
  • always: 가능한 모든 곳에서 THP 시도.
  • madvise: 애플리케이션이 madvise(MADV_HUGEPAGE) 했을 때만.
  • never: 비활성화.

THP의 함정

THP가 "항상 좋은" 것은 아니다. 문제 사례:

1. Fragmentation 스톨: 큰 연속 영역이 없으면 커널이 memory compaction 실행. 이 중에 애플리케이션이 멈춤. 수백 ms pause 가능.

2. 메모리 낭비: 2 MB 중 1 byte만 사용해도 2 MB 소모. MongoDB, Redis에서 문제 발생 사례 있음.

3. 예측 불가능: 어떤 페이지가 THP인지, 언제 분할되는지 명확하지 않음.

권장 (DB 워크로드): Redis, MongoDB는 THP 비활성화 권장.

echo never > /sys/kernel/mm/transparent_hugepage/enabled

Java, HPC, ML 워크로드는 THP 활성화가 좋을 수 있다. 반드시 벤치마크로 확인.

실측 예시

PostgreSQL 벤치마크 (64 GB buffer pool):

설정처리량
4 KB pages100% (기준)
Static 2 MB (HugeTLB)115~120%
THP always105~110% (가변)

5~20% 향상. 대용량 메모리 워크로드에선 무시할 수 없는 차이.


5. Page Fault: Lazy Allocation의 마법

malloc()이 실제로 하는 일

int *p = malloc(1024 * 1024);  // 1 MB

이 호출 후 물리 메모리 1 MB가 실제로 할당되었을까?

아니다. Linux의 기본 동작:

  1. malloc은 glibc가 처리.
  2. 큰 요청이면 mmap 시스템콜 호출.
  3. 커널은 가상 주소 범위만 할당. 물리 페이지는 아직.
  4. Page table 엔트리도 아직 없음.
  5. p를 반환.

실제 물리 메모리는 접근할 때 할당된다.

Page Fault

p[0] = 1;  // 여기서 실제 할당 발생!
  1. CPU가 p[0]의 가상 주소 접근.
  2. MMU가 TLB에서 찾음 → miss.
  3. Page table walk → entry 없음.
  4. Page fault 예외.
  5. 커널이 page fault handler 실행:
    • 새 물리 페이지 할당.
    • 0으로 초기화.
    • Page table에 등록.
    • 프로세스에 제어권 반환.
  6. CPU가 명령어 재시도 → 성공.

Lazy Allocation의 이점

int *big = malloc(100 * 1024 * 1024 * 1024);  // 100 GB

시스템 메모리가 16 GB여도 성공한다! 실제 접근한 만큼만 할당되기 때문.

이것이 왜 중요한가:

  • 프로그램이 "최악의 경우"를 고려한 큰 버퍼를 안전하게 할당 가능.
  • 실제 사용하지 않으면 메모리 낭비 없음.
  • 희소 데이터 구조(sparse array) 가능.

Minor vs Major Fault

Page fault에는 종류가 있다:

Minor Fault (soft fault):

  • 메모리에는 이미 있지만 page table에 매핑 안 됨.
  • 예: 처음 접근, COW 후 접근.
  • 비용: 낮음 (~1 μs).

Major Fault (hard fault):

  • 디스크(swap 또는 파일)에서 읽어야 함.
  • 비용: 높음 (~수 ms, 디스크 속도에 따라).

측정

# 프로세스의 page fault 통계
ps -o pid,min_flt,maj_flt,cmd <pid>

# 시스템 전체
vmstat 1
# r b swpd free buff cache si so bi bo in cs us sy id wa
#                       ^  ^
#                       swap in/out (major faults)

Major fault가 많으면 메모리 부족 또는 swap 남용. 조사 필요.


6. Fork와 Copy-on-Write

fork()가 진짜 하는 일

fork()는 프로세스의 전체 복사본을 만드는 것처럼 보인다:

pid_t pid = fork();
if (pid == 0) {
    // 자식 프로세스
} else {
    // 부모 프로세스
}

10 GB 메모리를 쓰는 프로세스를 fork하면 10 GB 복사? 수 초 걸려야 맞다.

그러나 실제론 수 밀리초면 된다. 비밀은 Copy-on-Write (COW) 다.

Copy-on-Write의 작동

  1. fork 시: 부모의 page table을 복사. 물리 페이지는 그대로 공유.
  2. 모든 페이지를 read-only로 마킹.
  3. 부모든 자식이든 쓰기 시도 시 page fault.
  4. 커널이 실제로 페이지 복사. 복사본을 writable로 설정.
  5. 원본은 다른 쪽이 여전히 공유.

효과

  • Fork 즉시 완료: page table 복사만.
  • 읽기 전용 데이터는 영원히 공유 (복사 없음).
  • 쓰기가 많은 데이터만 실제로 복사됨.

전형적인 fork() + exec() 패턴에선 거의 아무것도 복사 안 된다.

Redis의 background save

Redis는 SAVE/BGSAVE 할 때 fork() 한다:

  1. 부모: 계속 요청 처리.
  2. 자식: 메모리 스냅샷을 디스크로 저장.
  3. 자식 완료 후 exit.

Copy-on-Write 덕분에 자식이 별도의 스냅샷을 복사할 필요 없이 일관된 뷰를 본다. 부모의 쓰기는 복사로 처리되므로 자식은 fork 시점의 상태를 계속 본다.

함정: 쓰기 트래픽이 높으면 대부분 페이지가 복사되어 메모리 2배 필요. OOM kill 위험.

Linux에서의 fork 최적화

Linux는 fork를 더 최적화한다:

  • Page table도 lazy 복사: 4-level의 상위 레벨만 즉시 복사.
  • Huge page COW: 2 MB 단위 복사 대신 4 KB로 분할 후 복사.
  • Transparent page sharing (KSM): 동일 내용 페이지 공유.

7. Swap: 물리 메모리의 연장

Swap의 기본

메모리가 부족하면 커널이 덜 쓰이는 페이지를 디스크로 내보낸다 (swap out). 필요하면 다시 가져온다 (swap in).

[page_X]          [page_X']
    ↓                ↑
[swap partition]   [swap partition]
   swap out          swap in

Swap의 장단점

장점:

  • 물리 메모리보다 큰 working set 가능.
  • 오래 안 쓴 페이지 회수.
  • OOM kill 방어.

단점:

  • 느리다: 디스크 I/O.
  • Latency 예측 불가: 언제 swap in 될지 모름.
  • SSD 마모: 자주 스왑하면 SSD 수명 감소.

swappiness 파라미터

sysctl vm.swappiness
# vm.swappiness = 60

0~100, 커널이 얼마나 적극적으로 스왑할지:

  • 0: 거의 안 함. OOM 직전까지 page cache만 회수.
  • 10: 서버 권장.
  • 60: 데스크톱 기본.
  • 100: 매우 공격적.

DB 서버: 일반적으로 vm.swappiness = 1을 권장. 스왑을 최소화해 레이턴시 일관성 유지.

Zram: 압축 Swap

Zram은 swap 디바이스를 메모리에 만든다 (압축된 상태로):

  • 느린 디스크 대신 RAM 사용.
  • Lz4/Zstd 압축으로 실효 용량 증가.
  • 컨테이너 환경에서 유용.

Swap을 아예 끄기?

"RAM이 충분하면 swap 꺼!"는 나쁜 조언일 수 있다. Swap 없이도:

  • 커널이 여전히 page cache 회수.
  • 일부 앱은 "거의 안 쓰는" 메모리를 가짐 → swap out이 오히려 유리.
  • OOM kill이 즉시 발생 (완충재 없음).

권장: 작은 swap (2~8 GB) 유지 + swappiness 낮게 설정.


8. 컨테이너와 메모리

cgroup의 메모리 제한

Kubernetes/Docker가 memory.limit를 걸면:

resources:
  limits:
    memory: "512Mi"

내부적으로 cgroup의 memory.max = 512Mi. 이 한도 초과 시:

  1. 커널이 페이지 회수 시도.
  2. 회수 불가능하면 OOM Kill.

컨테이너는 Page Cache도 포함

중요한 함정: cgroup의 memory.current는 page cache도 포함한다.

예시:

Process: 200 MB resident
Page cache: 300 MB
cgroup memory.current: 500 MB
memory.max: 512 MB

Page cache 때문에 limit에 근접. 커널이 cache를 회수할 수 있으므로 OOM 안 남. 하지만 모니터링 시 혼란을 줌.

RSS vs PSS vs USS

프로세스 메모리 측정에는 여러 개념이 있다:

  • VSZ (Virtual Size): 가상 메모리 전체. 의미 없는 경우 많음 (1 TB VSZ도 가능).
  • RSS (Resident Set Size): 현재 물리 메모리에 있는 양. 하지만 공유 페이지도 포함.
  • PSS (Proportional Set Size): 공유 페이지를 공유자 수로 나눔. 더 공정.
  • USS (Unique Set Size): 이 프로세스만 쓰는 메모리.
# /proc/[pid]/smaps
cat /proc/1234/smaps_rollup
Rss:             102400 kB
Pss:              85000 kB
Uss:              70000 kB

PSS의 합계 = 전체 물리 사용량. RSS의 합계 > 물리 사용량 (공유 때문).

JVM과 컨테이너

Java 프로세스는 특히 까다롭다:

  • JVM은 원래 호스트의 전체 메모리를 보려 함.
  • JVM 8u131 이전: cgroup 인식 안 함 → 호스트 RAM 기준으로 heap 설정 → OOM.
  • JVM 8u191+: -XX:+UseContainerSupport (기본 on).
  • JVM 10+: 자동 감지.

권장:

java -XX:MaxRAMPercentage=75 -jar app.jar

컨테이너 메모리의 75%를 heap으로. 나머지는 native (metaspace, stack, direct buffer, GC, JIT).


9. /proc/[pid]/maps: 자신의 주소 공간 들여다보기

전체 매핑 보기

cat /proc/self/maps
55a9b7c00000-55a9b7c01000 r--p 00000000 fd:01 123456    /bin/cat
55a9b7c01000-55a9b7c05000 r-xp 00001000 fd:01 123456    /bin/cat
55a9b7c05000-55a9b7c07000 r--p 00005000 fd:01 123456    /bin/cat
55a9b7c07000-55a9b7c08000 r--p 00006000 fd:01 123456    /bin/cat
55a9b7c08000-55a9b7c09000 rw-p 00007000 fd:01 123456    /bin/cat
55a9b87cc000-55a9b87ed000 rw-p 00000000 00:00 0         [heap]
7f3afc000000-7f3afc021000 rw-p 00000000 00:00 0
7f3afc021000-7f3b00000000 ---p 00000000 00:00 0
7f3b04f6d000-7f3b04f95000 r--p 00000000 fd:01 234567    /lib/libc.so
...
7fffd5f3c000-7fffd5f5d000 rw-p 00000000 00:00 0         [stack]
7fffd5fce000-7fffd5fd2000 r--p 00000000 00:00 0         [vvar]
7fffd5fd2000-7fffd5fd4000 r-xp 00000000 00:00 0         [vdso]
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0 [vsyscall]

각 행은 VMA (Virtual Memory Area):

  • 주소 범위.
  • 권한 (r/w/x/p=private s=shared).
  • 파일 offset (있으면).
  • 디바이스:inode.
  • 경로 또는 레이블 ([heap], [stack], [anon]).

주요 영역 해석

  • /bin/cat (여러 줄): 실행 파일의 segments (text, rodata, data 등).
  • [heap]: brk로 확장되는 heap.
  • /lib/libc.so: 공유 라이브러리 매핑.
  • ---p 권한: guard page (접근 시 segfault).
  • [stack]: 스택.
  • [vvar], [vdso], [vsyscall]: 커널이 제공하는 특수 매핑 (빠른 시스템콜용).

디버깅

메모리 누수 디버깅 시 유용:

# 특정 프로세스의 모든 매핑
pmap -X <pid>

# 세부 정보
cat /proc/<pid>/smaps | head -50

smaps는 각 매핑의 RSS, PSS, Private Dirty, Shared Clean 등 자세한 통계 제공.


10. 보안과 가상 메모리

ASLR (Address Space Layout Randomization)

매 실행마다 stack, heap, libraries의 주소를 무작위화:

# 기본 활성화
sysctl kernel.randomize_va_space
# kernel.randomize_va_space = 2
  • 0: 비활성화.
  • 1: Stack, mmap만.
  • 2: 모두 (기본).

효과: 버퍼 오버플로우 공격에서 특정 주소로 점프하기 어려워짐. "어디로 return 할지 모름".

NX Bit / DEP (Data Execution Prevention)

Stack과 heap은 실행 불가로 표시. 공격자가 코드를 stack에 주입해도 실행 안 됨.

CPU의 NX bit가 지원. 현대 x86-64는 모두 지원.

KASLR (Kernel ASLR)

커널 이미지의 위치도 무작위화. Meltdown/Spectre 시대에 중요.

KPTI (Kernel Page Table Isolation)

Meltdown 취약점 방어. 사용자 프로세스의 page table에 커널 매핑을 제거. 시스템콜 시 커널 page table로 전환.

대가: 시스템콜 오버헤드 증가 (~5-30%). 특히 syscall-heavy 워크로드에 영향.

# KPTI 상태 확인
grep pti /proc/cpuinfo

11. 실전 튜닝

데이터베이스 서버

PostgreSQL:

# /etc/sysctl.conf
vm.swappiness = 1
vm.overcommit_memory = 2
vm.overcommit_ratio = 80
vm.dirty_background_ratio = 3
vm.dirty_ratio = 10

MySQL/InnoDB:

# Huge pages for buffer pool
echo 1000 > /proc/sys/vm/nr_hugepages
# my.cnf: innodb-buffer-pool-size=12G + large-pages=1

웹 서버 / 응용 서버

# 기본값 괜찮지만
vm.swappiness = 10   # 불필요한 swap 감소

THP: 언어 런타임 벤치마크로 결정. Java는 보통 THP on이 좋다.

컨테이너 호스트

# 많은 컨테이너 = 많은 프로세스 = 많은 page table
# 메모리 fragmentation 방지
vm.min_free_kbytes = 131072  # 128 MB

Redis / MongoDB

# THP 끔 (권장)
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag

부팅 시 자동화:

# /etc/rc.local
echo never > /sys/kernel/mm/transparent_hugepage/enabled

HPC / 대용량 메모리

# Static huge pages
echo 10000 > /proc/sys/vm/nr_hugepages
# 10000 × 2MB = 20 GB

애플리케이션이 mmap(..., MAP_HUGETLB, ...) 으로 사용.


12. 디버깅: 가상 메모리 문제들

문제 1: "Out of Memory"

kernel: Out of memory: Killed process 1234 (my_app)

원인:

  • 실제 물리 메모리 부족.
  • cgroup limit 초과.

진단:

dmesg | grep -i "killed process"
# 어떤 프로세스, 얼마나 썼는지 로그.

# 현재 메모리 상태
free -h
cat /proc/meminfo

해결: 메모리 누수 찾기, limit 조정, swap 확장.

문제 2: 느린 page fault

증상: 프로세스가 간헐적으로 멈춤.

원인: Major fault (디스크 swap in).

진단:

pidstat -r 1    # 프로세스별 page fault
# minflt/s majflt/s 컬럼

Major fault가 높으면 메모리 부족 또는 swap 과도.

문제 3: THP 스톨

증상: 랜덤하게 수백 ms pause.

원인: THP compaction.

진단:

grep -i "compact" /proc/vmstat
grep -i "huge" /proc/vmstat

compact_stall이 증가하면 문제.

해결:

  • echo madvise > /sys/kernel/mm/transparent_hugepage/defrag
  • 또는 echo never

문제 4: OOM Killer가 엉뚱한 프로세스 죽임

원인: OOM score 계산으로 대상 선택. 가장 크거나 가장 최근.

해결: 보호할 프로세스의 oom_score_adj 낮추기:

echo -1000 > /proc/$(pidof important_process)/oom_score_adj

문제 5: 공유 라이브러리가 여러 번 매핑됨

증상: RSS는 높지만 실제 메모리 사용은 그만큼 안 됨.

원인: 여러 프로세스가 같은 libc, libssl 등을 공유. Linux는 한 번만 로드.

확인: pmap 또는 smapsShared_Clean 값.


퀴즈로 복습하기

Q1. 4-level page table이 왜 필요한가? 왜 단순 배열로 안 되는가?

A. 메모리 낭비 때문이다. 64비트 주소공간을 단순 배열로 표현하면:

  • Virtual pages: 2^(48-12) = 2^36 ≈ 680억 개.
  • 각 엔트리 8바이트.
  • 전체 크기: 512 GB.

이 테이블을 각 프로세스가 가져야 한다. 물리적으로 불가능하다.

4-level page table의 해결:

  • 주소를 4부분(9+9+9+9 bits)으로 분할.
  • 각 레벨이 하나의 4KB 페이지에 들어감 (512 entries × 8 bytes).
  • 실제로 사용되는 부분만 할당.

프로세스가 1 MB만 쓴다면: PML4 페이지 하나 + PDPT 하나 + PD 하나 + PT 하나 = 16 KB의 page table. 512 GB에서 16 KB로.

대가: TLB miss 시 4번의 메모리 접근. 그래서 TLB가 중요하다. TLB hit rate가 99% 넘으면 4단계 walk의 평균 비용이 무시할 수 있게 된다.

Intel은 최근 5-level paging (57-bit 주소, 128 PB)을 도입해 대용량 메모리 서버에 대응하고 있다. 트리 깊이는 증가하지만 같은 원리다.

Q2. Copy-on-Write가 fork()를 어떻게 빠르게 만드는가?

A. 전통적인 "fork는 전체 메모리 복사"라는 의미대로 구현하면 10 GB 프로세스 fork에 수 초가 걸린다. Linux는 COW로 이를 수 밀리초로 줄인다.

메커니즘:

  1. fork 시 물리 페이지 복사 안 함. 대신:
    • 자식 프로세스 생성.
    • 부모의 page table만 복사 (크기 작음, 수 KB).
    • 모든 페이지의 page table 엔트리를 read-only로 설정.
    • 참조 카운트 증가 (두 프로세스가 공유).
  2. 읽기: 어느 쪽이든 자유롭게 읽기 가능. 실제 복사 없음.
  3. 쓰기 시도: Page fault 발생 (read-only 페이지에 쓰기).
  4. 커널의 page fault handler:
    • 실제로 페이지 하나 복사.
    • 쓰려는 프로세스에게 private 페이지 할당, writable로.
    • 다른 쪽은 원본 공유 계속.

이득:

  • Fork 자체는 거의 즉시 (수 ms).
  • 읽기 전용 데이터는 영원히 공유: 실행 코드, 정적 데이터, 대부분의 데이터베이스 버퍼.
  • 쓰기 위치만 실제로 복사: 전체 복사 대비 수십~수백 배 절약.

비유: 책을 복사하는 대신 "이 책 읽어도 돼, 단 쓰려면 그 페이지만 복사해서 써" 라고 하는 것.

실전 사용: Redis BGSAVE, Nginx worker fork, Python multiprocessing, exec() 전의 일반 fork. 이 모든 곳에서 COW가 작동한다. Linux 성능의 핵심 기능 중 하나다.

Q3. Huge Pages가 성능을 높이는 주 메커니즘은?

A. TLB miss 감소다. 다른 이점도 있지만 이것이 가장 크다.

TLB의 한계:

  • 현대 CPU의 L2 TLB는 2048 entries 정도.
  • 4 KB 페이지면 TLB는 8 MB만 커버.
  • 워킹셋이 100 MB면 TLB miss가 빈번.

Huge page의 해결:

  • 2 MB 페이지 사용 시: 2048 × 2 MB = 4 GB 커버.
  • 1 GB 페이지 사용 시: 2048 × 1 GB = 2 TB 커버.

결과:

  • TLB hit rate 급증.
  • TLB miss당 비용(100~500 cycles) 감소.
  • 전체 메모리 접근 속도 향상.

추가 이점:

  1. Page walk 단축: 2 MB 페이지는 3단계 walk (PT 생략), 1 GB는 2단계 walk.
  2. Memory fragmentation 감소: 큰 단위로 할당되므로.
  3. Page table 크기 감소: 레벨이 줄어 메모리 절약.

실전 효과 (DB buffer pool 기준):

  • PostgreSQL: 5~20% 처리량 증가.
  • MongoDB: THP 활성화 시 ~10% (단, MongoDB는 THP 비권장).
  • Java heap: 5~15%.

그러나 함정이 있다:

  • THP compaction stall: 커널이 2 MB 연속 영역을 만들려고 하다가 애플리케이션 stall. Redis, MongoDB에서 특히 문제.
  • 메모리 과다 사용: 2 MB 페이지에 1 KB만 써도 2 MB 소모.
  • Fork 시 복사 단위가 커짐: COW가 4 KB가 아닌 2 MB 복사.

권장:

  • Static huge pages (HugeTLB): 대용량 DB 버퍼 풀. 예측 가능.
  • THP with madvise: Java heap, 일부 HPC.
  • THP never: Redis, MongoDB, 짧은 생명주기 프로세스.

반드시 벤치마크로 검증. "Huge page는 항상 좋다"는 맹목적 믿음은 위험하다.

Q4. Linux에서 "10 GB 메모리 할당 성공했는데 시스템 RAM은 8 GB"가 어떻게 가능한가?

A. Linux의 Lazy Allocation + Overcommit 때문이다.

Lazy Allocation: malloc() 또는 mmap()가상 주소 공간만 할당한다. 실제 물리 페이지는 접근 시점에 page fault를 통해 할당된다.

void *p = malloc(100LL * 1024 * 1024 * 1024);  // 100 GB
// 여기서 실제 물리 메모리 사용 = 0
p[0] = 1;  // 여기서 첫 4 KB 페이지 할당 (실제 메모리 사용 4 KB)

Overcommit: Linux는 vm.overcommit_memory 설정에 따라 물리 메모리보다 더 많은 가상 메모리 할당을 허용한다:

  • 0 (기본): 휴리스틱. 명백히 불가능하면 거부.
  • 1: 항상 허용 (overcommit_always).
  • 2: Strict. RAM × ratio + swap만 허용.

이 조합의 효과:

  1. 희소 데이터 구조 (예: 1 TB 해시테이블 중 1 GB만 사용) 가능.
  2. 프로그램이 "최악의 경우"에 대비한 큰 버퍼 할당 가능.
  3. fork() 후 exec() 패턴이 효율적 (전체 메모리 복사 요구 안 함).

위험:

  • 여러 프로세스가 실제로 많이 쓰기 시작하면 OOM.
  • OOM Killer가 출동해 프로세스 종료.
  • 어느 프로세스가 죽을지 예측 불가.

통제:

  • Strict overcommit: vm.overcommit_memory = 2.
  • cgroup limit: 컨테이너별 강제 제한.
  • vm.overcommit_ratio: strict 모드에서 허용 비율.

실전:

  • 대부분의 Linux 시스템은 기본값 (heuristic)으로 동작.
  • 데이터베이스나 메모리 민감 서비스는 vm.overcommit_memory = 2로 예측 가능하게.
  • 컨테이너 환경은 cgroup이 이미 관리.

이것이 "Linux는 메모리를 게으르게 할당한다"라는 문장의 진짜 의미다. 가상 주소 할당 ≠ 물리 메모리 할당. 이 구분을 이해하면 많은 미스터리가 풀린다.

Q5. TLB shootdown이 무엇이고 왜 성능 문제를 일으키는가?

A. 멀티코어 시스템에서 TLB 일관성을 유지하기 위해 필요한 작업이다.

배경:

  • TLB는 각 CPU 코어의 독립적인 캐시다.
  • 프로세스 페이지 매핑이 바뀌면 (예: munmap, mprotect, 페이지 swap out), 모든 코어의 TLB에 캐시된 구버전을 제거해야 한다.
  • 그렇지 않으면 stale 매핑을 사용해 잘못된 데이터 읽기/쓰기 발생.

Shootdown 과정:

  1. Core 0이 페이지 매핑 변경 (예: munmap).
  2. 커널은 해당 매핑을 사용 중일 수 있는 모든 코어에 IPI (Inter-Processor Interrupt) 전송.
  3. 각 코어가 interrupt를 받고 현재 작업 중단.
  4. 자기 TLB에서 해당 entry 무효화 (invlpg 명령).
  5. Core 0에 완료 신호.
  6. Core 0은 모든 완료를 기다린 후 진행.

비용:

  • IPI 전송: 수 μs.
  • 각 코어의 context switch: 수 μs.
  • 동기화 대기: 코어 수 × IPI 시간.
  • 총: 수십~수천 μs (코어 수에 따라).

코어 수가 많을수록 비용이 증가한다. 64-core 시스템에선 수 ms까지 갈 수 있다.

문제를 일으키는 상황:

  1. 빈번한 mmap/munmap: 동적 메모리 할당이 많은 애플리케이션. JVM, v8 (JIT 컴파일).
  2. 공유 메모리 리매핑: 프로세스 간 공유.
  3. 페이지 회수: 메모리 부족 시 커널이 페이지 swap out.
  4. NUMA migration: 페이지를 다른 NUMA 노드로 이동.
  5. Transparent Huge Pages: 2 MB 페이지 merge/split.

증상:

  • perf topsmp_call_function_many, flush_tlb_func가 자주 나타남.
  • CPU 사용률은 낮은데 처리량이 나쁨.
  • Latency spike.

완화:

  1. Mapping 변경 최소화: 큰 버퍼 풀을 사전 할당.
  2. Huge pages: TLB entry 수가 줄어 shootdown 비용 감소.
  3. CPU affinity: 프로세스를 특정 코어에 고정 → 다른 코어 무관.
  4. madvise(MADV_DONTNEED) 대신 MADV_FREE: 지연 회수.
  5. Linux 커널 업그레이드: 최근 버전은 shootdown 최적화 있음.

고성능 서버에서 "왜 확장이 안 되지?"의 원인이 TLB shootdown인 경우가 의외로 많다. 특히 mmap 기반 데이터베이스(MongoDB, LMDB)나 JIT 컴파일러(V8, JVM)에서 나타난다. 이를 측정하고 완화하는 것이 고성능 엔지니어링의 한 부분이다.


마치며: 환상의 공학

핵심 정리

  1. Virtual Memory는 환상이다. 당신의 포인터는 거짓말이다.
  2. 4-level page table: 계층 구조로 메모리 낭비 방지.
  3. TLB: 변환 캐시. Hit rate 99%+가 관건.
  4. Huge pages: TLB 효율 극대화. 단, 함정 주의.
  5. Lazy allocation + COW: Fork와 malloc의 마법.
  6. Page fault: Minor는 빠르고 Major는 느리다.
  7. Swap: 물리 메모리의 연장, 그러나 신중히.
  8. ASLR, NX, KPTI: 보안과 메모리의 만남.

왜 이 지식이 가치 있는가

Virtual memory는 운영체제의 가장 복잡하고 가장 영향력 있는 기능 중 하나다. 현대 OS의 거의 모든 성능 최적화, 보안 기능, 프로세스 격리가 이 위에 있다.

이 지식이 없으면:

  • "왜 내 프로세스가 이만큼 메모리를 쓰지?" → 답 못함.
  • "왜 fork가 빠르지?" → 추측.
  • "Huge page를 켜야 할까?" → 감으로 결정.
  • "OOM kill이 왜 났지?" → 미스터리.
  • "컨테이너 메모리 제한은 어떻게 작동?" → 불명확.

이 지식이 있으면:

  • 성능 튜닝에 데이터 기반 결정.
  • 이상한 증상의 근본 원인 파악.
  • 시스템 설계 시 메모리 효율 고려.
  • 보안 기능을 올바르게 활용.
  • 더 좋은 엔지니어.

마지막 생각

Virtual memory의 매력은 우아한 추상화다. 수많은 기술(MMU 하드웨어, page table, TLB, COW, lazy allocation, swap)이 조합되어 "모든 프로세스가 독립된 무한 주소 공간을 가진 것처럼" 보이게 한다. 이는 60년 전 Multics 연구에서 시작되어 오늘날 모든 현대 OS의 기반이 되었다.

당신이 다음에 malloc()을 호출할 때, 기억해 보자:

  • 이 포인터는 가상 주소다.
  • 실제 물리 메모리는 아직 할당되지 않았을 수 있다.
  • 첫 접근 시 page fault가 일어날 것이다.
  • MMU가 4단계 page table을 탐색해 물리 주소를 찾을 것이다.
  • 아니면 TLB가 즉시 답할 것이다.
  • 이 모든 것이 나노초 단위에서 일어난다.

그 복잡성이 우리가 쉽게 프로그램을 짤 수 있는 이유다. 환상을 유지하는 것이 진짜 엔지니어링이다.


참고 자료