← all posts
DEV 2026.05.02 · 16 min read Intermediate

Linux 메모리는 백엔드 성능을 어떻게 결정하는가

가상 메모리와 Page Table 변환부터 Page Fault, Page Cache, mmap/O_DIRECT, 메모리 할당기 단편화, OOM Killer까지 — 백엔드 서비스 메모리 트러블슈팅의 전체 지형을 추적한다.


Redis 레이턴시가 불규칙하게 튄다. JVM은 -Xmx4g인데 컨테이너가 OOM으로 죽는다. free -h는 메모리 부족을 경고하는데 서비스는 멀쩡하다. 이 현상들은 서로 다른 버그처럼 보이지만, 사실 하나의 공통 레이어 — Linux 커널의 메모리 서브시스템 — 에서 비롯된다. 이 레이어를 모르면 증상만 쫓다가 끝난다.

가상 메모리 — 모든 것의 출발점

모든 프로세스는 독립된 가상 주소 공간을 갖는다. 물리 메모리를 직접 공유하면 프로세스 A가 B의 주소를 덮어쓸 수 있고, 물리 메모리보다 큰 프로그램을 실행할 수 없다. 가상 주소는 이 두 문제를 동시에 해결한다.

변환 경로는 x86-64 기준으로 4단계다. CR3 레지스터가 PGD 테이블을 가리키고, 48비트 가상 주소의 상위 비트들이 PGD → PUD → PMD → PTE를 순서대로 인덱싱해 최종 물리 페이지 주소를 얻는다. 메모리 접근 4번이 필요하다는 뜻이다.

이 비용을 줄이는 장치가 **TLB(Translation Lookaside Buffer)**다. TLB는 CPU 내부의 주소 변환 캐시로, 히트 시 1 사이클에 물리 주소를 반환한다. 미스 시에는 Page Table Walk가 발생해 수십수백 사이클을 소비한다. 프로세스 컨텍스트 스위칭은 CR3를 바꾸므로 TLB 전체가 플러시된다.

여기서 HugePages의 의미가 나온다. TLB 512 엔트리 기준으로 4KB 페이지는 2MB만 커버하지만, 2MB HugePages는 1GB를 커버한다. JVM Heap 4GB를 접근할 때 TLB 커버리지 차이가 수백 배다. 그러나 Redis는 반대다. BGSAVE 중 CoW(Copy-on-Write)가 발생하면 2MB 단위로 복사가 일어나 메모리 사용량이 폭증한다. Redis 공식 문서가 THP를 비활성화하라고 강조하는 이유다.

# Redis 권장 설정
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag

Page Fault — 물리 메모리가 할당되는 시점

malloc(1GB)를 호출해도 물리 메모리는 즉시 할당되지 않는다. 커널은 가상 주소 공간에 VMA(Virtual Memory Area)만 예약한다. 첫 접근 시 Page Fault가 발생하고, 커널 핸들러가 물리 페이지를 할당해 Page Table을 업데이트한다. 이것이 Demand Paging이다.

Page Fault는 두 종류다.

  • Minor Page Fault: 디스크 I/O 없음. 익명 페이지 첫 접근, CoW, 이미 Page Cache에 있는 공유 라이브러리. 비용 110 μs.
  • Major Page Fault: 디스크 I/O 필수. 스왑으로 밀려난 페이지에 재접근, mmap 파일이 Page Cache에 없는 경우. 비용 120 ms.

Redis가 스왑으로 밀려나면 명령어 처리마다 Major Page Fault가 발생해 레이턴시가 수십 ms로 폭증한다. Redis가 vm-enabled no를 기본으로 하고 mlock()으로 메모리를 고정하는 이유다.

JVM에서는 Demand Paging이 Warm-up 지연의 원인 중 하나다. -XX:+AlwaysPreTouch는 JVM 시작 시 Heap 전체에 미리 접근해 Page Fault를 선불로 내는 옵션이다. 시작이 느려지는 대신 서비스 중 예측 불가한 지연을 제거한다.

컨테이너 + AlwaysPreTouch 조합

AlwaysPreTouch는 시작 즉시 Heap 전체를 물리 메모리로 확보한다. Kubernetes에서 Heap 4GB + AlwaysPreTouch면 Pod 시작 순간 4GB를 소비해 메모리 limit을 초과할 수 있다. Limit은 실제 최대 사용량 + 여유분으로 설정해야 한다.

Page Cache — 파일 I/O의 숨겨진 가속기

같은 파일을 두 번 읽으면 두 번째가 훨씬 빠르다. 커널이 첫 번째 읽기 결과를 물리 메모리의 Page Cache에 보관하기 때문이다. 두 번째 읽기는 디스크 I/O 없이 메모리 속도(~GB/s)로 처리된다.

free -hbuff/cache가 이 Page Cache다. 많다고 메모리 낭비가 아니다. 애플리케이션이 요청하면 즉시 반환된다. 진짜 가용 메모리는 available 열이다.

$ free -h
              total   used   free  buff/cache  available
Mem:           15Gi   3Gi   1Gi       11Gi       11Gi
#                                              ↑ 이 값이 실제 가용량

MySQL은 Page Cache와의 관계가 핵심이다. innodb_flush_method=fsync(기본값)면 InnoDB Buffer Pool과 OS Page Cache가 같은 파일 데이터를 이중으로 캐싱한다. O_DIRECT로 전환하면 InnoDB가 Page Cache를 우회해 Buffer Pool만 사용한다. 메모리 사용이 예측 가능해지고 innodb_buffer_pool_size 설정이 정확하게 의미를 가진다.

Kafka는 반대 전략이다. Page Cache를 브로커의 메모리처럼 적극 활용한다. Producer가 쓴 메시지는 Page Cache에 머물고, Consumer는 대부분 Page Cache에서 직접 읽는다. Kafka Heap을 6GB 이하로 유지하라는 권장이 나오는 이유가 여기 있다. Heap이 크면 GC가 Page Cache를 밀어내 실제 데이터 캐시가 줄어든다.

mmap과 O_DIRECT — 복사 횟수의 차이

read() 시스템 콜은 Page Cache → 유저 버퍼로 데이터를 복사한다. mmap()은 Page Cache 물리 페이지를 프로세스 가상 주소에 직접 매핑한다. 복사가 없다.

read():  디스크 → Page Cache → 유저 버퍼 (복사 1회, 시스템 콜 매번)
mmap():  디스크 → Page Cache ← PTE 직접 연결 (복사 0회, Page Fault만)

Elasticsearch가 Lucene 인덱스에 MMapDirectory를 사용하는 이유다. 인덱스 파일 접근이 직접 메모리 접근으로 처리되고, GC 대상이 아닌 OS 관리 메모리에서 자동으로 LRU Eviction된다. JVM Heap을 물리 메모리의 50% 이하로 유지하라는 권장의 나머지 50%가 Lucene 인덱스를 위한 Page Cache다.

O_DIRECT는 Page Cache를 완전히 우회한다. DMA가 디스크 데이터를 애플리케이션 버퍼에 직접 쓴다. 자체 캐시를 갖는 DB 엔진(MySQL O_DIRECT, RocksDB Direct I/O)이 이중 캐싱을 방지하기 위해 사용한다. 단, 버퍼 주소·오프셋·크기가 모두 섹터 크기(512B 또는 4096B)의 배수여야 한다는 정렬 제약이 있다.

메모리 할당기와 단편화, OOM Killer

malloc()은 커널에 직접 요청하지 않는다. 할당기(ptmalloc, jemalloc, tcmalloc)가 brk()mmap()으로 큰 블록을 미리 확보한 뒤 내부적으로 분할해 제공한다. free()도 즉시 OS에 반환하지 않는다. 내부 free list에 보관하다 재사용한다.

다양한 크기의 할당과 해제가 반복되면 빈 블록이 조각나 단편화가 발생한다. RSS(물리 메모리)는 높은데 실제 사용 데이터는 적은 상태다. Redis에서 mem_fragmentation_ratio = used_memory_rss / used_memory로 이를 측정한다. 1.5를 넘으면 주의, 2.0을 넘으면 심각하다. Redis가 jemalloc을 선택한 이유가 여기 있다. jemalloc은 크기 클래스를 세분화해 내부 단편화를 줄이고, 비어있는 페이지를 OS에 적극적으로 반환한다.

물리 메모리가 고갈되면 커널은 Page Cache 회수 → 스왑 아웃 순으로 버티다가 그래도 부족하면 OOM Killer를 발동한다. 종료 대상은 oom_score로 결정된다.

oom_score = (RSS / 전체 물리 메모리) * 1000 + oom_score_adj

메모리를 많이 쓰는 프로세스일수록 먼저 죽는다. oom_score_adj로 이를 조정할 수 있다. Redis를 보호하려면 -900으로 낮추고, 희생 가능한 배치 작업은 +500으로 높인다. 컨테이너 환경에서는 JVM OOM(OutOfMemoryError, 종료 코드 1)과 커널 OOM(SIGKILL, 종료 코드 137)을 반드시 구별해야 한다. 커널 OOM은 로그가 없으므로 dmesg | grep -i oom으로 확인한다.

트레이드오프

JVM -Xmx만 기준으로 컨테이너 limit을 설정하면 필연적으로 OOM이 발생한다. Heap 외에 Metaspace, Thread Stack, Direct Buffer, JIT 코드 캐시가 Native Memory를 추가로 사용하기 때문이다. 안전한 공식은 컨테이너 limit = Heap / 0.70이다. -XX:+UseContainerSupport-XX:MaxRAMPercentage=75.0을 함께 설정하면 JVM이 컨테이너 limit을 인식해 자동으로 Heap 크기를 조정한다.

정리

  • 가상 메모리의 TLB 미스와 HugePages 선택은 워크로드 특성에 따라 다르다. JVM은 HugePages가 유리하고, Redis는 CoW 때문에 THP를 꺼야 한다.
  • malloc()은 즉시 물리 메모리를 할당하지 않는다. 첫 접근 시 Page Fault가 발생하며, 스왑된 페이지 접근은 Major Page Fault로 1000배 느리다.
  • free -hbuff/cache는 낭비가 아니라 Page Cache다. 실제 가용량은 available 열이다. MySQL은 O_DIRECT로 이중 캐싱을 막고, Kafka는 Page Cache를 전략적으로 키워야 한다.
  • mem_fragmentation_ratio > 1.5activedefrag yes를 켜고, 컨테이너 OOM은 종료 코드 137과 dmesg로 커널 O