---
title: "가상 메모리는 램이 모자라서 나왔지만 남은 이유는 따로 있다"
description: "오버레이를 손으로 짜던 시절부터 1962년 아틀라스까지. 메모리가 모자라서 만든 장치가 왜 램이 남아도는 지금까지 모든 OS 의 바닥에 남아 있는지 봅니다."
author: "유니얼"
pubDate: 2026-09-14
category: "programming/os"
tags: ["OS", "메모리"]
canonical: https://blog.unialgames.com/posts/os-virtual-memory-origin/
---
가상 메모리가 무엇이냐고 물으면 이런 답이 자주 나옵니다.

> 램이 모자랄 때 저장 장치를 메모리처럼 빌려 쓰는 것

**이 답은 60년 전에는 맞았습니다.** 처음에 그것 때문에 만들어진 것이 맞습니다.
그런데 램이 남아도는 지금도 가상 메모리는 그대로 돕니다. 빌려 쓸 저장 장치를 꺼 두어도 돕니다.
**동기는 사라졌는데 장치는 남았습니다.** 무엇이 남긴 것인지 보려면 처음 만들어진 자리로 가야 합니다.

## 프로그램이 메모리보다 컸다

1960년대 초입니다. 기계에는 성격이 다른 저장 장치가 둘 있었습니다.

| 무엇 | 성질 |
| --- | --- |
| 코어 메모리 | 빠릅니다. 비싸서 조금밖에 못 답니다 |
| 드럼 | 큽니다. 느립니다 |

당시 가장 큰 축에 들던 기계가 코어 16,000워드에 드럼 96,000워드쯤이었습니다.
**풀고 싶은 문제는 코어에 안 들어갔습니다.** 지금으로 치면 램에 안 들어가는 게임을 돌리는 상황입니다.

그런데 이 시절에는 그 일을 대신해 줄 것이 아무것도 없었습니다.

## 그래서 사람이 조각을 관리했다

방법은 하나뿐이었습니다. 프로그램을 조각으로 자르고, 지금 필요한 조각만 코어에 올리고,
나머지는 드럼에 두었다가 때가 되면 바꿔 치는 것입니다. **오버레이**라고 불렀습니다.

문제는 이 일을 전부 사람이 했다는 것입니다.

- 프로그램을 **어디서 자를지** 정합니다
- 어느 조각이 어느 조각을 부르는지 따져서 **교체표를 만듭니다**
- 교체를 일으키는 호출을 **코드 안에 직접 심습니다**

그래서 이런 것들이 따라왔습니다.

- **논리와 배치가 한 코드에 섞입니다.** 무엇을 계산하는지와 무엇이 지금 메모리에 있는지를 같은 자리에서 관리하게 됩니다
- **기계가 바뀌면 처음부터 다시 짭니다.** 조각 크기를 그 기계의 코어 크기에 맞춰 잘라 뒀기 때문입니다
- **경계를 잘못 자르면 교체만 합니다.** 서로를 번갈아 부르는 두 조각을 갈라 놓으면 계속 바꿔 치기만 하고 일이 안 나갑니다

아틀라스를 만든 사람들은 이 일에 들어가는 시간을 없애면 **프로그래머의 생산성이 세 배까지 좋아질 수 있다고 봤습니다.**
당시 프로그래밍에서 이 작업이 차지하던 비중이 그 정도였다는 뜻입니다.

## 아틀라스가 한 것

1962년 12월, 맨체스터 대학과 페란티가 만든 아틀라스가 가동합니다. 톰 킬번이 이끈 기계입니다.

이 기계에는 코어 16,000워드와 드럼 96,000워드가 달려 있었는데,
**프로그램은 1,000,000워드짜리 주소를 쓸 수 있었습니다.**
없는 크기의 메모리를 있는 것처럼 쓰게 한 것입니다.

이름이 **한 단계 저장소**였습니다. 두 단계로 나뉜 저장소를 프로그램에게는 한 단계로 보이게 했다는 뜻입니다.

방법은 이렇습니다.

1. 주소 공간을 512워드짜리 블록으로 자릅니다
2. 프로그램이 주소를 낼 때마다 그 블록이 지금 코어에 있는지 **하드웨어가 비교합니다**
3. 있으면 그대로 진행하고, 없으면 인터럽트를 걸어 관리 프로그램이 드럼에서 가져옵니다

**사람이 코드에 적던 교체가 통째로 기계 안으로 들어간 것입니다.**
3번이 지금 페이지 폴트라고 부르는 것의 원형입니다.

바뀐 것은 일의 양이 아니라 **누가 그 일을 하느냐**입니다. 그러면 얻는 것과 잃는 것이 갈립니다.

<x-tabs>
<div class="pc" data-tab="사람이 정한다">
<div class="pc-col" data-kind="good">
<span class="pc-tag">장점</span>
<ul><li><b>언제 바뀔지 안다.</b> 교체를 코드에 직접 적었으니 어느 줄에서 멈추는지가 눈에 보입니다.</li><li><b>헛것이 안 올라온다.</b> 프로그램의 구조를 아는 사람이 잘랐기 때문입니다.</li></ul>
</div>
<div class="pc-col" data-kind="bad">
<span class="pc-tag">단점</span>
<ul><li><b>논리와 배치가 섞인다.</b> 계산하는 코드와 메모리를 관리하는 코드가 같은 자리에 있습니다.</li><li><b>기계가 바뀌면 다시 짠다.</b> 조각 크기가 그 기계에 묶여 있습니다.</li><li><b>잘못 자르면 교체만 한다.</b> 경계를 잘못 잡으면 일이 안 나갑니다.</li></ul>
</div>
</div>
<div class="pc" data-tab="기계가 한다">
<div class="pc-col" data-kind="good">
<span class="pc-tag">장점</span>
<ul><li><b>코드에서 교체가 사라진다.</b> 프로그램은 주소를 쓰기만 하고, 없으면 기계가 가져옵니다.</li><li><b>기계가 바뀌어도 돈다.</b> 실제 메모리 크기를 프로그램이 몰라도 됩니다.</li><li><b>딸려 오는 것이 있다.</b> 주소를 번역하는 층이 생기면 다른 것들이 공짜로 따라옵니다.</li></ul>
</div>
<div class="pc-col" data-kind="bad">
<span class="pc-tag">단점</span>
<ul><li><b>언제 멈출지 모른다.</b> 교체가 코드에 안 적혀 있으니 아무 줄에서나 멈출 수 있습니다.</li><li><b>무엇이 올라와 있는지 안 보인다.</b> 느려졌을 때 원인이 코드 안에 없습니다.</li></ul>
</div>
</div>
</x-tabs>

이 둘은 지금도 같이 씁니다. 손으로 하던 일이 통째로 없어진 것이 아니라 **덩어리가 커진 채로 위층에 남았습니다.**

| 무엇을 바꾸나 | 지금 누가 정하나 |
| --- | --- |
| 페이지 단위 교체 | 기계. 커널과 CPU 가 알아서 합니다 |
| 에셋 단위 교체 | 사람. 게임 엔진의 로드와 언로드가 그것입니다 |

## 그래서 가상 메모리가 무엇인가

지금까지 나온 것을 한 문장으로 모으면 이렇습니다.

> **프로세스마다 가짜 주소 공간을 하나씩 주고, 그 주소를 실제 물리 주소로 바꿔 주는 층**

프로그램이 손에 쥐는 주소는 전부 이 가짜 쪽입니다. 포인터를 찍어 보면 나오는 그 값은 램의 어느 자리도 가리키지 않습니다.
**CPU 가 번역한 다음에야** 메모리에 닿습니다.

이 구조에 이름이 붙은 것이 여섯입니다.

| 용어 | 뜻 |
| --- | --- |
| 가상 주소 공간 | 프로세스가 혼자 다 쓴다고 믿는 주소의 범위 |
| **페이지** | 가상 주소 공간을 자른 덩어리. 보통 4KB |
| **프레임** | 물리 메모리를 같은 크기로 자른 칸 |
| 페이지 테이블 | 페이지 번호를 넣으면 프레임 번호가 나오는 번역표 |
| MMU | 번역을 실제로 하는 CPU 안의 회로 |
| 페이지 폴트 | 번역하려는 페이지에 프레임이 안 적혀 있을 때 CPU 가 던지는 예외 |

이름만 봐서는 안 잡힙니다. **주소 하나를 실제로 따라가 보겠습니다.**

## 주소 하나가 값에 닿기까지

프로그램이 `0x2A40` 번지를 읽는다고 해 봅시다.

**이 값은 가상 주소입니다.** 램의 `0x2A40` 번지와는 아무 상관이 없습니다.
이 프로세스의 **가상 주소 공간** 안에서만 뜻이 있는 번호입니다.
실행 코드도, 스택도, 힙도, 열어 둔 파일도 전부 이 주소 공간 위에 놓입니다.

**CPU 가 이 값을 두 토막으로 자릅니다.** 4KB 짜리 **페이지**로 잘라서 관리하니까,
아래 12비트는 페이지 안에서의 자리이고 위쪽은 몇 번째 페이지인지가 됩니다.
16진수로 쓰면 자리가 딱 맞아떨어집니다.

```
0x2A40
  └─┬─┘ └┬─┘
    │     └─ 오프셋 0xA40   페이지 안에서 몇 번째 바이트인가
    └─────── 페이지 번호 2   몇 번째 페이지인가
```

**페이지 번호로 페이지 테이블을 찾습니다.** 이 표는 **커널이 프로세스마다 하나씩 만들어 둔 것**이고,
2번 줄에 "프레임 3" 이라고 적혀 있다면 그 페이지의 내용은 지금 **프레임** 3 번 칸에 들어 있다는 뜻입니다.
프레임은 물리 메모리를 페이지와 같은 크기로 자른 칸이라, 램 8GB 짜리 기기라면 이 칸이 이백만 개쯤 있습니다.

**이 찾는 일을 하는 것이 MMU 입니다.** CPU 안에 들어 있는 회로이고,
프로그램이 주소를 낼 때마다 예외 없이 여기를 지납니다.

**마지막으로 프레임 번호와 오프셋을 다시 붙입니다.** 프레임 3 의 시작에서 `0xA40` 바이트 지점,
그러니까 물리 주소 `0x3A40` 입니다. CPU 는 그 자리에서 값을 읽습니다.

<x-page>
<script type="application/json">
{
  "label": "주소가 값에 닿기까지",
  "pages": 4,
  "frames": 4,
  "resident": [[0, 2], [1, 0], [2, 3], [3, 1]],
  "access": [2, 3, 1],
  "note": "앞자리만 프레임 번호로 바뀌고 오프셋은 손대지 않습니다. 페이지와 프레임의 크기가 같아서 가능한 일입니다."
}
</script>
</x-page>

정리하면 이렇습니다.

1. 프로그램이 가상 주소를 냅니다
2. CPU 가 페이지 번호와 오프셋으로 자릅니다
3. MMU 가 그 프로세스의 페이지 테이블에서 페이지 번호에 해당하는 줄을 찾아 프레임 번호를 얻습니다
4. 프레임 번호와 오프셋을 합쳐 물리 주소를 만들고, **그 자리를 직접 읽고 씁니다**

여섯 번째 용어인 페이지 폴트는 이 흐름이 3번에서 걸렸을 때의 이야기입니다.
표의 그 줄에 프레임이 아직 안 적혀 있으면 CPU 가 예외를 던지고, 커널이 프레임을 붙인 뒤
멈췄던 명령부터 다시 실행시킵니다.

## 여기서 셋을 짚고 갑니다

셋 다 반대로 알기 쉬운 것들입니다.

| 누가 | 무엇을 | 언제 |
| --- | --- | --- |
| 프로세스 | 주소를 내밉니다. **자기 표를 보지도 고치지도 못합니다** | 매 명령 |
| CPU | 표를 읽어 번역하고 **직접 읽고 씁니다** | 메모리에 닿을 때마다 |
| OS | 표를 만들고 고칩니다. 물리 프레임을 배정합니다 | 할당 요청과 폴트 때만 |

**하나. 표를 만드는 것은 OS 입니다.** 프로세스는 "메모리 좀 주세요" 라고 요청할 뿐이고,
자기 번역표를 건드릴 수 있으면 격리가 무너집니다.

**둘. 번역의 결과는 주소입니다. 크기가 아닙니다.** 몇 바이트를 읽을지는 명령어가 이미 정해 뒀습니다.
표에도 크기는 없습니다. 페이지가 전부 같은 크기라서 적을 필요가 없습니다.

**셋. 평소 동작에는 OS 가 없습니다.** 표에 제대로 적혀 있는 동안은 CPU 가 번역해서 램에 바로 닿습니다.
OS 는 상주하며 심부름하는 것이 아니라 **번역이 빗나갔을 때 불려 오는 쪽**입니다.

그리고 메모리를 요청해서 받았다고 그 자리가 물리 메모리에 생긴 것도 아닙니다.
OS 가 해 주는 것은 주소 범위를 잡고 표에 줄을 만드는 것까지이고, 그 줄의 프레임 칸은 대개 비어 있습니다.
**프레임은 그 페이지를 처음 만질 때 붙습니다.**

## 번역표가 프로세스마다 따로라는 것

여기까지 오면 이 구조가 무엇을 막는지가 보입니다.
프로세스 A 와 B 가 똑같이 `0x1000` 번지를 읽어도, **각자의 표를 거치기 때문에 서로 다른 프레임으로 갑니다.**

<figure class="diagram">
<svg viewBox="0 0 680 250" role="img" aria-label="프로세스 A 와 B 가 똑같이 0x1000 을 써도 각자의 페이지 테이블을 거쳐 서로 다른 물리 프레임으로 번역되는 그림">
<defs>
<marker id="vm-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M0 0 L10 5 L0 10 z" fill="var(--text-muted)"></path>
</marker>
</defs>
<g fill="var(--bg)" stroke="var(--border)">
<rect x="8" y="30" width="150" height="60" rx="8"></rect>
<rect x="8" y="150" width="150" height="60" rx="8"></rect>
<rect x="220" y="30" width="170" height="60" rx="8"></rect>
<rect x="220" y="150" width="170" height="60" rx="8"></rect>
<rect x="470" y="20" width="200" height="200" rx="8"></rect>
</g>
<g fill="var(--bg-subtle)" stroke="var(--border)">
<rect x="484" y="36" width="172" height="34" rx="5"></rect>
<rect x="484" y="120" width="172" height="34" rx="5"></rect>
</g>
<rect x="484" y="78" width="172" height="34" rx="5" fill="var(--accent-soft)" stroke="var(--accent)"></rect>
<rect x="484" y="162" width="172" height="34" rx="5" fill="var(--accent-soft)" stroke="var(--accent)"></rect>
<g font-size="12" fill="var(--text-muted)">
<text x="8" y="22">프로세스 A 의 주소 공간</text>
<text x="8" y="142">프로세스 B 의 주소 공간</text>
<text x="220" y="22">A 의 페이지 테이블</text>
<text x="220" y="142">B 의 페이지 테이블</text>
<text x="470" y="12">물리 메모리</text>
</g>
<g font-family="var(--font-mono)" font-size="12">
<text x="28" y="66" fill="var(--text)">0x1000 을 읽는다</text>
<text x="28" y="186" fill="var(--text)">0x1000 을 읽는다</text>
<text x="240" y="66" fill="var(--accent)">0x1000 → 프레임 3</text>
<text x="240" y="186" fill="var(--accent)">0x1000 → 프레임 1</text>
<text x="498" y="58" fill="var(--text-muted)">프레임 0</text>
<text x="498" y="100" fill="var(--accent)">프레임 1</text>
<text x="498" y="142" fill="var(--text-muted)">프레임 2</text>
<text x="498" y="184" fill="var(--accent)">프레임 3</text>
</g>
<g stroke="var(--text-muted)" fill="none" marker-end="url(#vm-arrow)">
<path d="M162 60 L214 60"></path>
<path d="M162 180 L214 180"></path>
<path d="M394 60 L462 176"></path>
<path d="M394 180 L462 100"></path>
</g>
</svg>
<figcaption>같은 0x1000 인데 번역표가 달라서 서로 다른 자리로 간다.</figcaption>
</figure>

A 가 어떤 주소를 찍어도 B 의 자리에는 닿지 않습니다.
**B 의 프레임을 가리키는 칸이 A 의 번역표에 아예 없기 때문입니다.**
남의 메모리를 안 건드리기로 약속한 것이 아니라 가리킬 방법이 없는 것입니다.

## 딸려 온 것이 더 컸다

주소를 번역하는 층을 한 번 넣고 나니, 메모리 용량과 아무 상관 없는 것들이 같이 됐습니다.

**하나. 격리.** 방금 본 그림이 그것입니다. 번역표가 프로세스마다 따로라서 남의 자리를 가리킬 방법이 없습니다.

**둘. 재배치.** 물리 메모리 어디에 올려도 됩니다.
프로그램은 자기가 몇 번지에 올라갔는지 몰라도 되고, 같은 프로그램을 여러 벌 띄워도 주소가 안 겹칩니다.

**셋. 이어 붙이기.** 물리 메모리가 조각조각 흩어져 있어도 주소 공간은 이어져 보입니다.
빈 자리의 총합은 충분한데 이어진 자리가 없어서 못 올리는 일이 없어집니다.

**넷. 공유.** 같은 물리 자리를 여러 프로세스의 주소 공간에 동시에 매핑할 수 있습니다.
공용 라이브러리가 메모리에 한 벌만 올라가는 것, 프로세스를 복제할 때 내용을 실제로 복사하지 않고
**고칠 때만 복사하는 것**이 전부 이 위에서 나옵니다.

**다섯. 보호.** 번역표 칸마다 권한을 적어 둘 수 있습니다.
코드가 놓인 페이지는 읽기와 실행만, 데이터 페이지는 쓰기 가능으로 두는 식입니다.
커널 메모리를 사용자 코드가 못 건드리게 막는 것도 여기입니다.

여러 프로그램을 동시에 올려 두고 돌리는 것이 안전해진 것이 이 다섯 덕분입니다.
**용량을 늘리려고 만든 장치가 실제로는 여럿이 한 기계를 나눠 쓰게 만든 장치가 된 것입니다.**

## 그래서 램이 싸진 뒤에도 남았다

원래 동기였던 용량 문제는 약해졌습니다. 그런데 위의 다섯은 메모리가 아무리 남아돌아도 필요합니다.

그래서 지금 이렇습니다.

- **스왑을 완전히 꺼도 가상 메모리는 돕니다.** 반대로 가상 메모리를 끄면 OS 가 아예 못 돕니다
- 용량 쪽 후손도 형태를 바꿔 남았습니다. 프로그램을 켤 때 실행 파일 전체를 올리지 않고 **건드리는 페이지만 올리는 것**,
  파일을 통째로 주소 공간에 붙여 두고 읽는 것이 전부 아틀라스가 하던 그 일입니다

처음 질문으로 돌아가면, "램이 모자랄 때 빌려 쓰는 것" 은 가상 메모리가 **처음에 팔린 이유**였습니다.
지금 안 없어지는 이유는 그 아래에 깔린 **주소를 번역하는 층** 쪽입니다.

## 정리

- 오버레이는 프로그램을 조각내고 교체 시점을 **사람이 코드에 적던 방식**입니다. 논리와 메모리 배치가 한자리에 섞이고, 기계가 바뀌면 다시 짜야 했습니다
- 1962년 아틀라스가 그 교체를 하드웨어와 관리 프로그램에 넘겼습니다. 코어 16,000워드짜리 기계에서 1,000,000워드 주소를 쓰게 한 것이 시작입니다
- 바뀐 것은 일의 양이 아니라 **누가 그 일을 하느냐**입니다. 대신 언제 멈출지는 코드에서 안 보이게 됐습니다
- 그래서 지금의 가상 메모리는 **프로세스마다 가짜 주소 공간을 주고 실제 주소로 번역해 주는 층**입니다. 가상 주소 공간, 페이지 테이블, MMU 셋으로 굴러가고, 번역표에 없으면 OS 를 부릅니다
- **요청한다고 프레임이 바로 붙지 않습니다.** 표에 줄만 생기고, 그 페이지를 처음 만질 때 커널이 빈 프레임 목록에서 하나 꺼내 붙입니다
- 주소를 번역하는 층이 생기자 격리, 재배치, 이어 붙이기, 공유, 보호가 딸려 왔습니다. **전부 메모리 용량과 무관한 것들입니다**
- 그래서 램이 싸진 뒤에도 남았습니다. 스왑을 꺼도 가상 메모리는 돌고, 끄면 OS 가 못 돕니다