Virtual memory는 각 process에게 독립적인 address space를 제공하는 방식입니다. process는 자신만의 연속된 memory를 쓰는 것처럼 보지만, 실제로는 OS와 hardware가 virtual address를 physical address로 변환합니다.
이 구조 덕분에 process 간 memory 격리가 가능하고, 실제 RAM보다 큰 address space를 다루거나 필요한 page만 memory에 올리는 전략을 사용할 수 있습니다. 다만 필요한 page가 memory에 없으면 page fault가 발생하고 disk에서 가져와야 해서 비용이 큽니다.
가상 메모리 이전에는 프로그램이 물리 주소를 직접 사용했습니다. 여러 프로그램을 동시에 실행하면 각 프로그램이 어떤 물리 주소를 쓸지 미리 정해야 했고, 주소가 충돌하면 한 프로그램이 다른 프로그램의 메모리를 덮어쓰는 문제가 생겼습니다. 프로그램마다 메모리 배치를 전부 다르게 컴파일해야 했고, 동시 실행 자체가 위험했습니다.
가상 메모리는 "각 프로세스에게 자기만의 주소 공간이 있다고 착각하게 만드는" 방식으로 이 문제를 해결합니다. OS와 MMU(Memory Management Unit)가 그 착각을 투명하게 유지합니다. Chrome과 VS Code가 동시에 실행될 때 둘 다 가상 주소 0x400000에서 자기 코드가 시작된다고 생각할 수 있지만, 실제 물리 메모리에서는 전혀 다른 위치에 올라가 있습니다.
꼬리질문
조금 더 깊게 물어본다면
답변 뒤에 이어질 수 있는 질문들을 하나씩 열어볼 수 있어요.
가상 메모리의 핵심 이점은 무엇인가요?
물리 메모리보다 큰 주소 공간 사용과 프로세스 간 메모리 격리, 두 가지입니다.
첫째, 실제로 필요한 페이지만 RAM에 올리고 나머지는 디스크에 두면서 프로그램 입장에서는 RAM이 실제보다 큰 것처럼 동작합니다(demand paging, swapping).
둘째, 각 프로세스가 독립된 가상 주소 공간을 가지므로 한 프로세스가 다른 프로세스의 메모리를 침범할 수 없습니다. 주소 공간 추상화(프로그램이 항상 0번지부터 시작하는 것처럼 보임)와 ASLR 같은 보안 기법도 여기서 나옵니다.
RAM이 충분한 환경에서도 가상 메모리는 격리와 추상화를 위해 반드시 필요합니다.
가상 주소와 물리 주소의 차이는?
가상 주소는 프로세스가 사용하는 주소로, 실제 RAM 위치와 무관합니다. 물리 주소는 실제 RAM에서의 위치입니다. CPU가 가상 주소를 내면 MMU가 page table을 참고해 물리 주소로 변환합니다.
출처: GeeksforGeeks — Virtual Address Space in Operating System
변환은 page 단위로 일어납니다. 가상 주소를 VPN(Virtual Page Number)과 offset으로 나눠, VPN을 page table에서 찾아 PPN(Physical Page Number)으로 바꾸고 offset을 그대로 붙입니다. page 크기는 보통 4KB입니다.
page 단위를 쓰는 이유는 바이트마다 매핑 정보를 저장하면 page table 자체가 너무 커지기 때문입니다. 4KB page 기준으로 4GB 주소 공간은 약 100만 개의 page table entry로 표현할 수 있습니다.
프로세스는 자기가 어떤 물리 주소를 쓰는지 알 필요 없이, 자기만의 연속된 가상 주소 공간을 사용합니다. 연속된 가상 주소가 물리 메모리에서는 흩어져 있어도 됩니다.
실제 RAM이 충분해도 가상 메모리가 필요한가요?
필요합니다. swapping은 부가 기능이고, 가상 메모리의 핵심 목적은 따로 있습니다.
프로세스 격리가 가능해집니다. 각 프로세스가 다른 프로세스의 메모리에 접근하지 못하게 막을 수 있습니다.
주소 공간 독립성이 생깁니다. 프로그램을 만들 때 다른 프로그램이 어떤 주소를 쓰는지 신경 쓸 필요가 없습니다. 더 정확하게는, 프로그램은 가상 주소만 쓰고 물리 주소 배치는 OS가 page table에 정해놓으며 실제 변환은 MMU가 하드웨어로 처리합니다. 프로그램이 직접 관여할 일이 없습니다.
ASLR 같은 보안 기법을 구현할 수 있습니다. 가상 주소와 물리 주소가 분리되어 있으면 실행할 때마다 배치를 무작위로 바꿀 수 있어서 exploit이 어려워집니다.
lazy allocation이 가능해집니다. malloc으로 메모리를 요청해도 실제 접근 전까지 물리 페이지를 할당하지 않아도 됩니다.
OS 커널도 가상 메모리를 사용합니다. 커널 영역의 가상 주소는 모든 프로세스의 주소 공간 상단에 매핑되어 있지만, 커널 모드에서만 접근 가능합니다. 사용자 프로세스가 커널 메모리에 직접 접근하려 하면 MMU가 차단합니다. RAM이 아무리 넉넉해도 이 보호 기능은 반드시 필요합니다.
virtual memory가 process 격리에 어떻게 도움이 되나요?
page table은 프로세스마다 OS가 하나씩 관리하는 '가상 주소 - 물리 주소' 매핑 테이블입니다. 두 프로세스가 모두 가상 주소 0x1000을 쓰더라도, 각자의 page table이 다른 물리 주소를 가리키기 때문에 실제로는 완전히 다른 메모리를 사용합니다.
출처: Wikipedia — Page table actions
Chrome의 page table: 0x1000 → 물리 주소 0xA000VS Code의 page table: 0x1000 → 물리 주소 0xF000
context switch할 때 OS가 MMU가 참조하는 page table을 바꿔치기합니다. 이후 같은 가상 주소라도 다른 물리 메모리를 가리키게 됩니다.
한 프로세스가 다른 프로세스의 가상 주소에 접근하려 하면 MMU가 page table에서 해당 매핑을 찾지 못하거나 보호 비트를 확인해 page fault를 발생시킵니다. OS는 이 주소가 해당 프로세스에게 유효하지 않다고 판단하고 segmentation fault로 처리해 프로세스를 종료합니다. page fault와 segfault의 차이는 해당 가상 주소가 그 프로세스에게 유효한 주소인지 여부입니다.
MMU는 어떤 역할을 하나요?
Memory Management Unit의 약자로, CPU 안에 내장된 하드웨어입니다. CPU가 가상 주소를 내면 MMU가 page table을 참조해 물리 주소로 변환하고, 접근 권한도 함께 검사합니다. 읽기 전용 page에 쓰기를 시도하거나 존재하지 않는 주소에 접근하면 MMU가 page fault를 발생시켜 OS가 처리합니다.
MMU는 CPU와 메모리 버스 사이에 위치합니다. CPU가 내는 모든 메모리 주소는 MMU를 거쳐 물리 주소로 변환된 뒤 버스에 나갑니다. 소프트웨어가 관여하지 않아도 하드웨어가 투명하게 처리합니다.
64비트 시스템에서는 page table이 너무 커지므로 multi-level page table을 씁니다. x86-64는 4단계 page table을 사용해, 가상 주소를 4개의 9비트 인덱스와 12비트 offset으로 나눠 단계마다 참조합니다. 대부분의 주소 공간이 비어 있어도 중간 레벨 table이 없으면 하위 레벨을 만들지 않아 공간을 아낄 수 있습니다.
매번 page table을 메모리에서 읽으면 느리기 때문에, MMU 안에 TLB(Translation Lookaside Buffer)라는 캐시가 있어 최근 변환 결과를 저장합니다.
TLB는 왜 필요한가요?
주소 변환을 매번 page table에서 찾으면 메모리 접근이 두 번 일어납니다. TLB는 최근 virtual-to-physical address 변환 결과를 cache하는 hardware cache로, 변환 비용을 줄입니다.
TLB가 없으면 모든 메모리 접근이 page table 조회(메모리 접근 1회) + 실제 데이터 접근(메모리 접근 1회)으로 두 배 느려집니다. TLB hit율은 지역성 덕분에 보통 99% 이상입니다.
context switch가 비싼 이유 중 하나가 TLB 때문입니다. 프로세스가 바뀌면 이전 프로세스의 가상 주소 변환 결과는 새 프로세스에서 유효하지 않습니다. TLB를 flush(비우기)해야 하고, 새 프로세스가 다시 TLB를 채울 때까지 miss가 많이 납니다.
이를 줄이기 위해 ASID(Address Space ID)를 TLB entry에 붙이는 방식을 씁니다. entry마다 어느 프로세스의 변환인지 기록해두면 context switch 시 전체를 flush하지 않아도 됩니다.
같은 라이브러리를 여러 프로세스가 쓰면 메모리가 중복되나요?
읽기 전용 code page는 여러 프로세스가 같은 물리 page를 공유할 수 있습니다. 가상 주소는 프로세스마다 달라도, 물리 주소는 하나만 있으면 됩니다.
동적 라이브러리(shared library, .so/.dll)가 이 방식으로 동작합니다. libc 같은 표준 라이브러리를 100개의 프로세스가 쓴다고 해서 같은 코드가 100개 복사되지 않습니다. 물리 메모리에는 코드가 하나만 있고, 각 프로세스의 page table이 같은 물리 page를 가리킵니다.
공유 가능한 건 코드(text) 영역처럼 읽기 전용인 부분입니다. 데이터 영역은 프로세스마다 다를 수 있으므로 Copy-on-Write로 처음에는 공유하다가 수정 시점에 복사합니다.
32bit와 64bit 시스템에서 가상 주소 공간의 최대 크기는?
32bit 시스템은 2³²= 4GB, 64bit 시스템은 이론상 2⁶⁴이지만 실제로는 128TB 정도입니다.
32bit 시스템은 주소를 32비트로 표현하므로 2³² = 4,294,967,296 바이트, 약 4GB가 최대입니다. 이 4GB를 커널과 사용자 공간이 나눠 씁니다. Linux는 보통 커널 1GB + 사용자 3GB, Windows는 커널 2GB + 사용자 2GB로 나눕니다. 그래서 32bit 프로세스는 실제로 2-3GB밖에 못 씁니다. 2000년대 중반 RAM이 4GB를 넘기 시작하면서 32bit의 한계가 체감됐고, 64bit 전환이 빨라졌습니다.
64bit 시스템은 이론상 2⁶⁴ = 16 엑사바이트이지만, 현재 하드웨어는 48비트 가상 주소만 씁니다. 2⁴⁸ = 256TB이고, 절반이 커널이므로 사용자 프로세스가 쓸 수 있는 공간은 128TB입니다. 실제 물리 메모리는 그보다 훨씬 작지만, 가상 주소 공간은 넉넉해서 한동안 한계에 부딪힐 일이 없습니다.
page fault는 무엇인가요?
process가 접근하려는 virtual page가 현재 physical memory에 없을 때 발생하는 예외입니다. OS가 해당 주소가 유효한지 판단해, 유효하면 disk에서 page를 가져와 RAM에 올리고 실행을 재개합니다. 주소 자체가 없는 경우(null pointer, 해제된 메모리 등)라면 segmentation fault를 발생시켜 프로세스를 종료합니다.
page fault는 두 종류로 나뉩니다. minor page fault는 page가 이미 메모리 어딘가에 있지만 현재 프로세스의 page table에 매핑이 없는 경우입니다. disk I/O 없이 매핑만 추가하면 되어 비용이 작습니다. major page fault는 page가 disk에 있어서 실제로 읽어와야 하는 경우입니다. disk I/O가 발생해 수ms에서 수십ms가 걸립니다.
lazy allocation이 minor page fault를 활용합니다. malloc(1GB)를 호출해도 실제로 접근하기 전까지 물리 페이지를 할당하지 않습니다. 처음 접근할 때 minor page fault가 나고 그때 할당합니다. 선언만 해두고 쓰지 않는 메모리는 물리 RAM을 차지하지 않습니다.
swapping은 무엇인가요?
RAM이 부족할 때 일부 memory page를 disk로 내보내고 필요할 때 다시 가져오는 방식입니다. memory를 더 넓게 쓰는 것처럼 만들 수 있지만 disk는 RAM보다 훨씬 느립니다.
swapping이 과도해지면 thrashing이 발생합니다. page를 disk에서 가져오자마자 다른 page를 내보내야 하는 상황이 반복되어, 실제 작업보다 swapping에 더 많은 시간을 쓰게 됩니다. CPU 사용률이 급격히 떨어지면서 시스템이 사실상 멈춘 것처럼 보입니다.
이를 막는 개념이 working set입니다. 프로세스가 현재 활발히 사용하는 page 집합이 모두 RAM에 올라와 있어야 thrashing을 피할 수 있습니다.
Linux는 메모리가 극단적으로 부족해지면 OOM(Out Of Memory) killer가 작동해 메모리를 많이 쓰는 프로세스를 강제 종료합니다. 시스템 전체가 죽는 것보다 일부를 희생시키는 선택입니다.
Copy-on-write(COW)란?
여러 프로세스가 같은 physical page를 읽기 전용으로 공유하다가, 한 프로세스가 그 page에 쓰기를 시도하는 순간 OS가 그 page를 복사해 해당 프로세스에게 독립된 copy를 제공하는 기법입니다. 즉시 전체를 복사하지 않으므로 메모리 사용량과 시간을 아낄 수 있습니다.
fork()가 대표적인 예입니다. 부모 프로세스를 복제할 때 전체 메모리를 즉시 복사하면 비용이 크고, 대부분의 fork는 바로 exec()를 호출해 전혀 다른 프로그램을 실행하므로 복사가 낭비가 됩니다. COW로 처음에는 부모와 자식이 같은 물리 page를 공유하다가 어느 쪽이든 수정하면 그때 복사합니다.
COW는 다른 곳에도 쓰입니다. Redis의 RDB 스냅샷은 fork()와 COW를 활용해 백그라운드에서 데이터를 디스크에 쓰면서 메인 프로세스는 계속 요청을 처리합니다. 스냅샷 시점의 메모리를 물리적으로 복사하지 않고, 수정이 일어나는 page만 COW로 분리합니다.
가상 메모리가 있어도 exploit은 가능한가요?
가능합니다. 버퍼 오버플로우 같은 공격은 함수의 return address를 공격자가 원하는 가상 주소로 덮어쓰는 방식으로 동작합니다. ASLR, DEP/NX, stack canary 같은 추가 기법이 함께 사용됩니다.
버퍼 오버플로우는 스택에 있는 버퍼에 크기 초과 데이터를 써서 같은 스택 프레임의 return address를 덮어씁니다. 함수가 리턴할 때 덮어쓴 주소로 점프하면 공격자의 코드가 실행됩니다. 가상 메모리는 주소를 가상화할 뿐, 같은 프로세스 내에서의 오버플로우는 막지 못합니다.
방어 기법들은 각각 다른 각도를 막습니다.
ASLR: 스택, 힙, 라이브러리 위치를 무작위화해 공격자가 주소를 예측하지 못하게 합니다.
NX/DEP: 스택과 힙을 실행 불가능으로 표시해, 거기에 올린 shellcode를 실행하지 못하게 합니다.
Stack canary: 함수 진입 시 스택에 무작위 값(canary)을 심고 리턴 전에 확인해, 오버플로우로 canary가 바뀌면 프로세스를 종료합니다.
이 기법들을 모두 우회하려면 공격이 훨씬 복잡해집니다. 하나씩 독립적으로 적용해도 의미가 있지만, 함께 쓸 때 효과가 극대화됩니다.
ASLR이란?
Address Space Layout Randomization의 약자입니다. 실행할 때마다 스택, 힙, 라이브러리의 가상 주소 배치를 무작위로 바꿔서 공격자가 대상 주소를 예측하기 어렵게 만드는 기법입니다. 가상 메모리가 있어야 같은 물리 page를 매번 다른 가상 주소에 붙일 수 있기 때문에 ASLR이 가능합니다.
ASLR이 효과적으로 동작하려면 실행 파일 자체도 PIE(Position Independent Executable)로 컴파일되어야 합니다. PIE가 아닌 실행 파일은 코드 섹션이 항상 고정된 가상 주소에 올라가기 때문에 라이브러리만 무작위화해도 실행 파일 코드 위치는 예측 가능합니다.
ASLR을 우회하는 방법도 있습니다. 프로그램이 어떤 주소를 출력하거나 노출하면(information leak) 그 오프셋을 계산해 전체 레이아웃을 추론할 수 있습니다. 32비트 시스템에서는 주소 공간이 작아 brute force로 맞출 수도 있습니다. 64비트 시스템에서는 주소 공간이 훨씬 넓어 brute force가 현실적으로 불가능합니다.
부가 설명
필요한 이유
RAM은 물리적으로 하나의 덩어리입니다. Chrome, VS Code, 터미널 — 모든 프로세스가 그 안에 함께 올라가야 합니다. 프로세스마다 별도의 RAM 칩이 있는 게 아닙니다.
가상 메모리가 없으면 프로세스들이 이 하나의 공간을 물리 주소 그대로 나눠 씁니다. Chrome이 코드 버그로 VS Code 영역의 물리 주소에 뭔가를 써버려도 CPU는 막을 방법이 없습니다. "이 주소가 누구 거야?"를 알지 못하기 때문입니다.
"그럼 접근할 때마다 검사해서 막으면 되지 않나?"라고 생각할 수 있습니다. 실제로 초기 방식이 그랬는데, 두 가지 문제가 있습니다. 메모리 접근이 초당 수십억 번 일어나는데 매번 소프트웨어 검사를 거치면 너무 느립니다. 더 근본적인 문제는 프로세스를 다른 물리 주소로 옮겨야 할 때입니다. Chrome 내부에는 수백만 개의 포인터가 있는데, 이게 전부 틀린 주소가 됩니다. 어떤 값이 포인터이고 어떤 값이 그냥 정수인지 구분할 수 없어 전부 고치는 것 자체가 불가능에 가깝습니다.
가상 메모리는 검사로 막는 게 아니라 Chrome이 VS Code의 물리 주소를 표현조차 못 하게 만드는 구조입니다. Chrome은 가상 주소만 알고, 그 가상 주소 공간 안에 VS Code 영역으로 가는 경로가 애초에 존재하지 않습니다.
동작 방식
page table은 가상 주소와 물리 주소의 매핑을 담은 목차입니다. OS가 프로세스마다 하나씩 만들어 관리합니다. MMU(Memory Management Unit)는 CPU 안에 내장된 하드웨어로, CPU가 메모리에 접근할 때마다 page table을 읽어 실제 물리 주소로 변환합니다.
비유하자면 가상 주소는 층 번호, 프로세스는 건물, page table은 건물마다 있는 층 안내도입니다. Chrome 3층과 VS Code 3층은 층 번호가 같아도 건물이 다르니 전혀 다른 공간입니다. 같은 가상 주소여도 프로세스가 다르면 각자의 page table이 다른 물리 주소로 안내합니다.
Chrome의 page table: 가상 0x400000 → 물리 0xA000VS Code의 page table: 가상 0x400000 → 물리 0xF000
프로세스를 물리적으로 다른 위치로 옮겨야 할 때도 page table의 숫자만 바꾸면 됩니다. Chrome 내부의 포인터들은 여전히 가상 주소를 가리키고 있어 그대로 유효합니다. 물리 위치가 바뀌어도 Chrome은 자기가 옮겨진 줄 모릅니다.
swapping은 부가 기능
많은 사람이 가상 메모리를 "RAM이 부족할 때 디스크를 쓰는 것"으로 알고 있는데, 그건 가상 메모리의 부가 기능(swapping)이지 핵심 목적이 아닙니다.
RAM이 충분해도 가상 메모리는 필요합니다. 프로세스 격리, 주소 공간 독립성, 보안(ASLR) 같은 기능이 swapping과 무관하게 동작하기 때문입니다.
하지만 page fault가 자주 발생하면 성능이 크게 떨어질 수 있습니다. working set이 RAM에 잘 올라와 있지 않으면 disk I/O가 병목이 됩니다.