리눅스 내부 구조 04 - 메모리 관리
가상 메모리는 왜 존재하는가?
초기 컴퓨터에서는 프로그램이 물리 메모리에 직접 접근했다. 프로그램 A가 주소 0x1000을 사용하고 있을 때 프로그램 B도 같은 주소를 사용하려 하면 충돌이 발생했다. 프로그램 하나가 다른 프로그램의 메모리를 덮어쓸 수 있었고, 물리 메모리보다 큰 프로그램은 실행 자체가 불가능했다.
가상 메모리는 이 문제를 해결한다. 각 프로세스에게 자신만의 연속적인 주소 공간을 제공하되, 이 주소가 물리 메모리의 어디에 매핑되는지는 프로세스가 알 필요 없게 하는 것이다. 프로세스 A가 주소 0x1000을 읽으면 물리 메모리 0x50000에 접근하고, 프로세스 B가 같은 주소 0x1000을 읽으면 물리 메모리 0x80000에 접근하는 식이다. 두 프로세스 모두 자신이 메모리 전체를 독점하고 있다고 생각하지만, 실제로는 커널이 그 환상을 유지하고 있는 셈이다.
주소 공간의 구조
리눅스에서 각 프로세스의 가상 주소 공간은 일정한 구조를 갖는다. 64비트 시스템을 기준으로, 하위 영역은 유저 공간이고 상위 영역은 커널 공간이다.
높은 주소 ┌──────────────────┐
│ 커널 공간 │ (모든 프로세스가 동일한 매핑)
├──────────────────┤
│ 스택 (Stack) │ ↓ 아래로 성장
│ ... │
│ 빈 공간 │
│ ... │
│ 힙 (Heap) │ ↑ 위로 성장
├──────────────────┤
│ BSS (초기화되지 │
│ 않은 전역변수) │
├──────────────────┤
│ Data (초기화된 │
│ 전역변수) │
├──────────────────┤
│ Text (코드) │
낮은 주소 └──────────────────┘
Text 영역에는 실행할 기계어 코드가 담기고, Data와 BSS 영역에는 전역 변수가 위치한다. 힙은 malloc() 같은 동적 할당에 사용되며 위쪽으로 성장하고, 스택은 함수 호출과 지역 변수에 사용되며 아래쪽으로 성장한다. 힙과 스택 사이의 넓은 빈 공간이 존재하는 이유는 양쪽이 서로를 향해 성장할 수 있도록 여유를 두기 위한 것이다.
페이지 테이블과 MMU
가상 주소를 물리 주소로 변환하는 작업은 CPU의 MMU(Memory Management Unit)가 수행한다. 이 변환은 메모리 전체를 한 번에 매핑하는 것이 아니라, 페이지라는 고정 크기 단위(일반적으로 4KB)로 이루어진다.
커널은 각 프로세스마다 페이지 테이블을 유지한다. 페이지 테이블은 가상 페이지 번호를 물리 프레임 번호로 매핑하는 자료구조이다. 프로세스가 가상 주소에 접근하면, MMU가 페이지 테이블을 참조하여 해당 물리 주소를 찾아내는 것이다.
가상 주소: [가상 페이지 번호 | 페이지 내 오프셋]
│
▼ (페이지 테이블 조회)
물리 주소: [물리 프레임 번호 | 페이지 내 오프셋]
64비트 시스템에서 가상 주소 공간은 방대하기 때문에, 단일 페이지 테이블로는 메모리가 부족하다. 그래서 리눅스는 다단계 페이지 테이블을 사용한다. x86-64 아키텍처에서는 4단계 페이지 테이블(PGD → PUD → PMD → PTE)을 사용하며, 최신 프로세서는 5단계까지 지원한다. 이 다단계 구조 덕분에 실제로 사용되는 주소 범위에 대해서만 페이지 테이블 항목을 할당하면 되므로, 메모리를 크게 절약할 수 있는 것이다.
매번 다단계 테이블을 순회하면 메모리 접근마다 여러 번의 추가 메모리 접근이 발생한다. 이 비용을 줄이기 위해 CPU는 TLB(Translation Lookaside Buffer)라는 캐시를 사용한다. TLB는 최근에 변환된 가상-물리 주소 쌍을 저장해두고, 같은 가상 주소에 다시 접근할 때 페이지 테이블 순회 없이 즉시 물리 주소를 반환한다. TLB 적중률이 성능에 미치는 영향은 매우 크며, 컨텍스트 스위칭 시 TLB가 무효화되는 것이 성능 저하의 주요 원인인 것이다.
요구 페이징
과연 프로세스가 시작될 때 모든 페이지를 물리 메모리에 올려야 하는가? 그렇지 않다. 리눅스는 요구 페이징(demand paging)을 사용한다. 프로세스의 가상 주소 공간은 처음부터 모두 물리 메모리에 매핑되지 않으며, 실제로 접근하는 시점에 비로소 물리 페이지가 할당되는 것이다.
프로세스가 아직 물리 메모리에 매핑되지 않은 가상 주소에 접근하면 페이지 폴트가 발생한다. 페이지 폴트는 CPU가 발생시키는 예외로, 커널의 페이지 폴트 핸들러가 이를 처리한다. 핸들러는 해당 접근이 유효한지 확인한 뒤, 유효하다면 물리 페이지를 할당하고 페이지 테이블을 업데이트하여 프로세스가 계속 실행되게 한다. 유효하지 않은 접근이라면 세그멘테이션 폴트(SIGSEGV)를 발생시켜 프로세스를 종료하는 것이다.
페이지 폴트는 크게 두 종류로 나뉜다. 마이너 페이지 폴트는 디스크 I/O 없이 해결되는 경우로, 새 페이지를 할당하거나 Copy-on-Write 처리가 여기에 해당한다. 메이저 페이지 폴트는 디스크에서 데이터를 읽어와야 하는 경우로, 스왑 영역에서 페이지를 복원하거나 파일 매핑된 페이지를 디스크에서 읽어오는 것이 여기에 해당한다. 메이저 페이지 폴트는 디스크 I/O를 수반하므로 마이너 페이지 폴트보다 수백 배 이상 느린 것이다.
스왑
물리 메모리가 부족해지면 커널은 당장 사용되지 않는 페이지를 디스크의 스왑 영역으로 내보낸다. 나중에 해당 페이지에 다시 접근하면 메이저 페이지 폴트가 발생하고, 커널은 스왑 영역에서 해당 페이지를 다시 물리 메모리로 읽어오는 것이다.
스왑은 물리 메모리보다 큰 작업 세트를 다룰 수 있게 해주지만, 디스크는 메모리보다 수만 배 느리므로 스왑이 빈번하게 발생하면 시스템 성능이 극적으로 저하된다. 이 현상을 스래싱(thrashing)이라 하며, 페이지를 내보내자마자 다시 필요해져서 끊임없이 디스크 I/O가 발생하는 악순환에 빠지는 것이다.
# 스왑 사용량 확인
free -h
total used free shared buff/cache available
Mem: 16Gi 8.2Gi 1.1Gi 512Mi 6.7Gi 7.0Gi
Swap: 4.0Gi 256Mi 3.7Gi
# 스왑 활동 모니터링 (si: swap in, so: swap out)
vmstat 1
procs memory swap io
r b swpd free si so bi bo
1 0 256k 1.1G 0 0 12 28
메모리 할당: 버디 시스템과 슬랩 할당자
커널 내부에서 물리 메모리를 할당하는 방식도 중요하다. 리눅스는 두 가지 수준의 할당 메커니즘을 사용한다.
버디 시스템은 물리 메모리를 2의 거듭제곱 크기의 블록으로 관리한다. 4KB 페이지를 기본 단위로 하여, 1페이지(4KB), 2페이지(8KB), 4페이지(16KB) 등의 블록 목록을 유지한다. 메모리 할당 요청이 들어오면 요청 크기에 맞는 가장 작은 블록을 찾아 제공한다. 적합한 크기의 블록이 없으면 더 큰 블록을 반으로 나누고(이 과정에서 두 블록이 "버디"가 된다), 해제 시에는 인접한 버디끼리 합쳐져서 더 큰 블록을 복원한다. 이 방식은 외부 단편화를 최소화하면서도 할당과 해제가 빠른 것이다.
하지만 커널에서는 task_struct나 inode 같은 작은 객체를 빈번하게 할당하고 해제한다. 이런 객체 하나에 4KB 페이지 전체를 사용하는 것은 낭비이다. 슬랩 할당자는 이 문제를 해결하기 위해 페이지를 동일한 크기의 작은 객체들로 미리 나누어 놓는다. 같은 유형의 객체가 반복적으로 할당되고 해제되므로, 해제된 객체의 메모리를 그대로 재사용할 수 있어 할당 속도가 빠르고 내부 단편화도 줄어드는 것이다.
OOM Killer
물리 메모리와 스왑이 모두 소진되면 어떻게 되는가? 리눅스 커널은 OOM(Out of Memory) Killer를 호출한다. OOM Killer는 시스템 전체가 멈추는 것을 방지하기 위해 프로세스를 강제로 종료하여 메모리를 확보하는 최후의 수단이다.
OOM Killer는 각 프로세스에 대해 점수를 매긴다. 메모리를 많이 사용하는 프로세스가 높은 점수를 받으며, 점수가 가장 높은 프로세스가 종료 대상이 된다. PID 1이나 커널 스레드는 보호되며, /proc/[pid]/oom_score_adj 값을 통해 특정 프로세스의 점수를 조정할 수 있다. 값을 -1000으로 설정하면 OOM Killer의 대상에서 제외되는 것이다.
# 프로세스의 OOM 점수 확인
cat /proc/1234/oom_score
# OOM Killer에서 보호 (-1000 ~ 1000)
echo -1000 > /proc/1234/oom_score_adj
과연 OOM Killer가 올바른 프로세스를 종료하는가? 항상 그렇지는 않다. 메모리를 가장 많이 사용하는 프로세스가 가장 중요한 프로세스일 수 있기 때문이다. 데이터베이스 서버가 메모리를 많이 사용한다는 이유로 종료되면 시스템의 핵심 서비스가 중단되는 셈이다. 그래서 프로덕션 환경에서는 중요한 프로세스의 oom_score_adj를 적절히 설정해두는 것이 일반적인 관행인 것이다.
메모리 매핑: mmap
mmap()은 파일이나 장치를 프로세스의 가상 주소 공간에 직접 매핑하는 시스템 콜이다. 매핑된 영역에 대한 읽기와 쓰기가 곧 파일에 대한 읽기와 쓰기가 되므로, read()와 write() 시스템 콜을 반복하지 않아도 된다.
// 파일을 메모리에 매핑
int fd = open("data.bin", O_RDONLY);
void *addr = mmap(NULL, file_size, PROT_READ, MAP_PRIVATE, fd, 0);
// 이제 addr을 통해 파일 내용에 직접 접근 가능
printf("%c", ((char *)addr)[0]);
munmap(addr, file_size);
mmap()의 중요한 쓰임새 중 하나는 공유 라이브러리의 로딩이다. libc.so 같은 공유 라이브러리는 여러 프로세스가 동시에 사용하는데, 각 프로세스가 라이브러리의 복사본을 갖는 것은 물리 메모리 낭비이다. mmap()으로 공유 라이브러리를 매핑하면 같은 물리 페이지를 여러 프로세스가 공유할 수 있으므로, 물리 메모리 사용량이 크게 줄어드는 것이다.
익명 매핑(anonymous mapping)도 있다. 파일과 연결되지 않은 메모리 영역을 매핑하는 것으로, malloc()이 큰 메모리 블록을 할당할 때 내부적으로 mmap()의 익명 매핑을 사용한다. 또한 프로세스 간 공유 메모리를 구현할 때도 mmap()의 MAP_SHARED | MAP_ANONYMOUS 플래그 조합이 사용되는 것이다.
다음 포스트에서는 파일 시스템과 VFS를 살펴본다.