---
title: "페이지 폴트는 오류가 아니지만 프레임은 흔든다"
description: "가상 주소가 물리 주소로 번역되는 과정과, 그 번역이 빗나갔을 때 OS 가 하는 일. 그 멈춤이 16.6밀리초짜리 프레임 예산에서 얼마나 큰지까지 봅니다."
author: "유니얼"
pubDate: 2026-09-14
category: "programming/os"
tags: ["OS", "메모리"]
canonical: https://blog.unialgames.com/posts/os-virtual-memory-page-fault/
---
프로그램이 손에 쥐는 주소는 전부 가짜입니다. 포인터를 찍어 보면 나오는 그 값은 램의 어느 자리도 가리키지 않습니다.
**CPU 가 그 값을 실제 주소로 번역한 다음에야** 메모리에 닿습니다.
번역표가 프로세스마다 따로라서, 프로세스 A 의 `0x1000` 과 B 의 `0x1000` 은 서로 다른 자리로 갑니다.

여기까지가 가상 메모리입니다. 이런 층이 왜 생겼는지는
[가상 메모리는 램이 모자라서 나왔지만 남은 이유는 따로 있다](/posts/os-virtual-memory-origin/)에 적었습니다.
이 글은 **그 번역이 실제로 어떻게 돌고, 빗나가면 무슨 일이 생기는지**를 봅니다.

## 주소는 어떻게 번역되나

바이트마다 번역표를 만들면 표가 메모리보다 커집니다. 그래서 덩어리로 묶어서 번역합니다.
여기 나오는 이름들을 먼저 맞춰 두겠습니다.

| 용어 | 뜻 |
| --- | --- |
| **페이지** | 가상 주소 공간을 자른 덩어리입니다. 보통 4KB 이고, 애플 실리콘처럼 16KB 를 쓰는 것도 있습니다 |
| **프레임** | 물리 메모리를 같은 크기로 자른 칸입니다. 페이지 하나가 프레임 하나에 들어갑니다 |
| 페이지 테이블 | 페이지 번호를 넣으면 프레임 번호가 나오는 번역표입니다. 프로세스마다 따로 있고 **커널이 만듭니다** |
| MMU | 번역을 실제로 하는 CPU 안의 회로입니다 |
| TLB | 최근 번역 결과를 담아 두는 CPU 안의 작은 캐시입니다 |

페이지와 프레임은 **같은 크기의 칸을 가상 쪽에서 부르는 이름과 물리 쪽에서 부르는 이름**입니다.

주소는 두 토막으로 갈립니다. 위쪽이 페이지 번호, 아래쪽이 페이지 안에서 몇 번째 바이트인지를 적은 오프셋입니다.
4KB 면 오프셋이 12비트라서, 16진수로 쓰면 **아래 세 자리가 통째로 오프셋**입니다.

번역은 앞자리만 갈아 끼웁니다. 오프셋은 손대지 않고 그대로 따라갑니다.

```
0x2A40          가상 주소
  │ └┬─┘
  │  └──────────  오프셋 0xA40   그대로 따라갑니다
  └─────────────  페이지 2       표에서 찾습니다

페이지 2 → 프레임 3

0x3A40          물리 주소
```

이 번역을 CPU 안의 **MMU** 라는 회로가 합니다. 소프트웨어가 하는 일이 아닙니다.

다만 번역할 때마다 페이지 테이블을 읽으면 메모리를 한 번 쓰려고 메모리를 여러 번 읽는 꼴이 됩니다.
그래서 CPU 안에 최근 번역 결과를 담아 두는 작은 캐시가 따로 있습니다. **TLB** 입니다.
TLB 에 있으면 번역은 사실상 공짜고, 없으면 페이지 테이블을 단계별로 걸어가야 합니다.

여기까지가 번역이 성공한 경우입니다. 값이 비싸지는 쪽은 빗나갔을 때입니다.

## 번역이 빗나가면

페이지 테이블 칸에는 프레임 번호만 있는 것이 아니라 **유효 비트**가 같이 있습니다.
"이 페이지는 지금 물리 메모리에 올라가 있다" 를 나타내는 표시입니다.

그 표시가 꺼져 있는 페이지를 건드리면 CPU 가 번역을 못 합니다.
그 자리에서 예외를 일으켜 OS 에게 넘깁니다. **이것이 페이지 폴트입니다.**

이름이 험해서 오해받지만 오류가 아닙니다. **주소는 멀쩡하고, 내용이 아직 물리 메모리에 없을 뿐입니다.**
매핑 자체가 없는 주소를 건드리는 것은 다른 이야기이고, 그쪽이 세그멘테이션 폴트입니다.

재미있는 것은 **CPU 가 둘을 구분하지 못한다**는 점입니다. 표에 프레임이 없으면 똑같이 예외를 던지고 손을 뗍니다.
불려 온 커널이 자기 기록을 보고 판단합니다. 매핑된 범위 안이면 채워 주고, 범위 밖이면 프로세스를 죽입니다.

**폴트는 CPU 가 던지는 사건이고, 처리는 커널이 합니다.** 순서는 이렇습니다.

1. 그 스레드를 멈춥니다. **멈추는 것은 그 스레드 하나뿐**이고 다른 스레드는 계속 돕니다
2. 커널이 **빈 프레임 목록에서 하나 꺼냅니다.** 물리 메모리를 뒤지는 것이 아닙니다. 목록이 비었으면 올라가 있는 페이지를 골라 내보내 자리를 만듭니다. 그 페이지가 수정된 상태면 먼저 저장 장치에 기록해야 합니다
3. **그 프레임에 들어갈 내용을 채웁니다.** 여기가 시간을 먹는 자리입니다
4. 페이지 테이블의 그 줄을 고칩니다. 표 전체를 다시 쓰는 것이 아닙니다
5. **멈췄던 그 명령부터 다시 실행합니다**

3번에서 채울 내용은 셋 중 하나입니다.

| 어떤 페이지인가 | 무엇을 채우나 |
| --- | --- |
| 새로 잡은 메모리 | 0 으로 밀어 버립니다. 램 안에서 끝납니다 |
| 실행 코드나 에셋 | 파일에서 읽어 옵니다. 파일 캐시에 남아 있으면 연결만 하면 됩니다 |
| 밀어냈던 페이지 | 스왑에서 읽어 옵니다 |

제일 흔한 것이 첫째 줄입니다. 메모리를 할당받아도 그 순간에는 표에 줄만 생기고 프레임은 안 붙습니다.
**처음 만지는 순간에 폴트가 나고, 그때 커널이 빈 프레임을 0 으로 밀어서 붙입니다.**
이것을 요구 페이징이라고 합니다.

<x-page>
<script type="application/json">
{
  "label": "할당한 뒤 처음 만질 때",
  "pages": 4,
  "frames": 4,
  "resident": [],
  "access": [0, 1, 0, 2],
  "fill": "zero",
  "freeList": [3, 2, 1, 0],
  "note": "세 번째 접근을 보세요. 같은 페이지를 다시 만질 때는 표에 이미 적혀 있어서 폴트가 나지 않습니다."
}
</script>
</x-page>

5번이 이 구조의 핵심입니다. **프로그램은 자기가 멈췄다는 것을 모릅니다.**
코드 어디에도 "메모리에 없으면 읽어 온다" 는 줄이 없는데 그 일이 일어납니다.
편한 대신, 어느 줄에서 멈출지가 코드에 안 적혀 있습니다.

폴트는 비용이 전부 다릅니다.

| 무엇 | 언제 | 비용 |
| --- | --- | --- |
| 마이너 폴트 | 내용이 이미 메모리 어딘가에 있습니다. 파일 캐시에 남아 있거나, 처음 쓰는 자리라 0 으로 채우면 끝나는 경우입니다. 매핑만 이어 주면 됩니다 | 커널을 한 번 다녀오는 값. 대개 1마이크로초 아래 |
| 메이저 폴트 | 저장 장치에서 실제로 읽어 와야 합니다 | NVMe 로도 수십에서 수백 마이크로초 |
| 잘못된 접근 | 매핑 자체가 없는 주소입니다 | 폴트 처리가 아니라 프로세스 종료 |

프로그램을 실행할 때 실행 파일 전체를 메모리에 올리지도 않습니다.
처음 건드리는 순간에 그 페이지만 올라옵니다. 이것을 **요구 페이징**이라고 합니다.
앱을 켠 직후나 씬에 처음 들어간 직후에 폴트가 몰리는 이유가 이것입니다.

## 프레임이 모자라면

빈 프레임 목록이 바닥나면 이야기가 하나 더 붙습니다. **붙여 줄 자리를 먼저 만들어야 합니다.**

커널은 올라가 있는 페이지 중 하나를 골라 내보냅니다. 고르는 기준은 대체로 오래 안 쓴 것입니다.
그리고 내보내는 비용이 페이지마다 다릅니다.

- 내용이 안 바뀐 페이지는 **그냥 버립니다.** 원본이 파일에 있으니 나중에 다시 읽으면 됩니다
- 내용이 바뀐 페이지는 **저장 장치에 먼저 기록해야** 버릴 수 있습니다

그래서 폴트 하나가 저장 장치를 두 번 다녀오기도 합니다. 내보낼 페이지를 기록하고, 필요한 페이지를 읽어 오는 것입니다.

<x-page>
<script type="application/json">
{
  "label": "물리 프레임이 모자랄 때",
  "pages": 6,
  "frames": 3,
  "resident": [[0, 0], [1, 1], [2, 2]],
  "access": [1, 4, 1, 0],
  "note": "내보낼 페이지를 여기서는 들어온 지 가장 오래된 것으로 고릅니다. 실제 커널은 더 영리하게 고르지만, 고르는 방법보다 멈추는 자리가 중요합니다."
}
</script>
</x-page>

내보낸 페이지를 곧바로 다시 만지면 또 폴트가 납니다.
그 상태가 이어지면 프로그램이 일은 안 하고 페이지만 들락날락시킵니다. 이것을 스래싱이라고 합니다.

## 번역이 끝난 뒤 닿는 곳은 언제나 물리 메모리다

여기서 헷갈리기 쉬운 자리를 하나 짚고 가겠습니다.
가상 메모리를 쓴다고 해서 프로그램이 저장 장치를 읽고 쓰는 것이 아닙니다.

```
가상 주소 → (MMU 번역) → 물리 주소 → 램의 그 자리를 읽고 쓴다
```

**이 흐름에 저장 장치는 없습니다.** 스왑은 램이 모자랄 때만 끼어드는 예외 경로입니다.
지금 안 쓰는 페이지를 저장 장치로 잠깐 밀어내 두었다가, 그 페이지를 다시 만지는 순간 폴트가 나서
**램으로 도로 올려놓은 다음에야** 프로그램이 그 값을 봅니다. 저장 장치의 값을 직접 쓰는 일은 없습니다.

그래서 이런 오해도 같이 풀립니다. **주소 공간이 크다고 쓸 수 있는 메모리가 큰 것이 아닙니다.**
64비트면 주소 공간은 사실상 무한하지만, 동시에 올려 둘 수 있는 양은 램 크기까지입니다.

**할당한 양과 실제로 쓰고 있는 양도 다릅니다.** 큰 배열을 하나 잡아도 그 순간에는 표에 줄만 생기고
프레임은 만지는 페이지마다 붙습니다. 그래서 프로세스 메모리를 보면 숫자가 둘 나옵니다.
주소 공간에 잡아 둔 전체와, 실제로 램을 먹고 있는 상주 크기입니다. **앞의 것이 몇 배 큰 것이 정상입니다.**
게임에서 의미가 있는 쪽은 뒤엣것입니다.

그럼 20GB 짜리 게임이 8GB 기기에서 어떻게 도는가. 셋이 같이 일합니다.

**하나. 한 시점에 실제로 필요한 것은 전체의 일부입니다.**
지금 이 방을 그리는 데 필요한 메시와 텍스처는 몇백 MB 입니다.
지금 실제로 만지는 페이지들을 워킹 셋이라고 부르고, 이것만 램에 들어가면 게임은 돕니다.

**둘. 에셋은 파일에 있고 만지는 페이지만 올라옵니다.**
실행 파일과 메모리 매핑한 에셋은 원본이 저장 장치에 있으니, 커널은 안 쓰는 페이지를 그냥 버리면 됩니다.
필요해지면 다시 읽습니다. 밀어낼 자리를 따로 마련할 필요가 없어서 스왑이 없는 기기에서도 이건 됩니다.

**셋. 엔진이 직접 올리고 내립니다.** 이게 제일 큽니다.
씬과 에셋 번들 로드, 텍스처 밉 스트리밍, 월드를 타일로 잘라 가까운 것만 올리기가 전부 이 일입니다.
OS 의 페이징에 맡기면 언제 튈지 모르니 **폴트가 나기 전에 엔진이 미리 올려 둡니다.**
로딩 화면과 좁은 복도와 엘리베이터 구간이 대체로 이 시간을 버는 장치입니다.

즉 페이지 단위의 교체는 기계가 알아서 하고, **에셋 단위의 교체는 여전히 사람이 정합니다.**

그래서 남는 질문은 하나입니다. **기계가 알아서 하는 그 교체가 프레임 한복판에서 일어나면 얼마나 비싼가.**

## 프레임 예산에서 보면

60프레임으로 돌리면 한 프레임에 쓸 수 있는 시간이 대략 16.6밀리초입니다.

- 메인 메모리를 한 번 읽는 데 100나노초 남짓
- 메이저 폴트 한 번에 100마이크로초쯤

**천 배 차이입니다.** 한 번 나는 것은 티도 안 납니다. 문제는 한 번만 나지 않는다는 것입니다.
100마이크로초짜리가 166번이면 한 프레임이 통째로 없어집니다.
씬을 처음 열거나 새 에셋을 만지기 시작하면 이 정도는 쉽게 납니다.

평균 프레임 타임에는 거의 안 잡힙니다. 대부분의 프레임이 멀쩡하기 때문입니다.
**흔들리는 것은 평균이 아니라 최악값이고, 그것이 언제 올지 모른다는 쪽이 더 나쁩니다.**

멈추는 것은 폴트가 난 스레드 하나뿐입니다. 커널은 그 스레드를 대기로 돌려놓고 다른 스레드를 CPU 에 올립니다.
서버라면 그동안 다른 요청을 처리하면 되니 괜찮습니다.
**게임에서 나쁜 이유는 멈춘 것이 메인 스레드일 때입니다.** 이번 프레임을 끝내야 하는 것이 그 스레드인데,
다른 스레드가 대신 돌아 봐야 그 프레임은 이미 늦습니다.

게임에서 이게 실제로 몰리는 자리는 대체로 정해져 있습니다.

- **앱을 켜고 처음 몇 초.** 실행 코드도 에셋도 아직 하나도 안 올라와 있어서 요구 페이징이 그 구간에 전부 일어납니다
- **씬에 처음 들어갈 때.** 그 씬에서만 지나가는 코드 경로와 그 씬에서만 쓰는 에셋을 처음 만집니다
- **처음 재생되는 이펙트와 사운드.** 두 번째부터는 멀쩡한데 첫 번째만 걸리는 것이 대체로 이것입니다. 셰이더 변형을 처음 만드는 비용과 겹쳐 있어서 둘을 헷갈리기 쉽습니다
- **백그라운드에 뒀다가 돌아왔을 때.** 그동안 커널이 깨끗한 페이지부터 버렸기 때문에, 복귀 직후 한동안 다시 읽어 옵니다

대응은 전부 한 문장으로 모입니다. **프레임 중에 처음 만지는 것을 없애면 됩니다.**
로딩 화면에서 미리 한 번 만져 두고, 첫 이펙트를 안 보이는 데서 한 번 재생해 두고, 풀에 미리 만들어 둡니다.
비용을 없애는 것이 아니라 **비용이 나는 시점을 사람이 정하는 것**입니다.

## 모바일에는 밀려날 곳이 없다

데스크톱은 물리 메모리가 모자라면 안 쓰는 페이지를 저장 장치로 밀어내고 버팁니다.
느려질 뿐 프로그램은 삽니다. 모바일은 그 보험이 거의 없습니다.

- 안드로이드는 저장 장치로 스왑하지 않고 **압축 스왑(zram)** 을 씁니다. 메모리가 모자라면 커널이 먼저 깨끗한 파일 페이지를 버리고 나머지를 압축해 밀어냅니다. 그래도 모자라면 lmkd 가 **프로세스를 죽입니다**
- iOS 는 앱 메모리를 저장 장치로 스왑하지 않습니다. 압축해서 버티다가 한도를 넘으면 jetsam 이 앱을 종료합니다

여기서 게임에 중요한 것은 **깨끗한 파일 페이지가 제일 먼저 버려진다**는 점입니다.
실행 코드와 메모리 매핑해 둔 에셋이 그쪽입니다.
저장 장치에서 다시 읽으면 되니까 커널 입장에서는 버리기 제일 쉬운 것들입니다.

그래서 **스왑이 없는 기기에서도 메이저 폴트는 납니다.** 버려진 코드 페이지를 다시 만지는 순간 다시 읽어 옵니다.
백그라운드에 뒀다 돌아온 직후에 프레임이 흔들리는 것이 이것입니다.

결국 손에 남는 것은 **워킹 셋을 줄이는 것 하나**입니다.
상주 메모리가 작으면 커널이 버릴 이유가 없고, 버릴 것이 없으면 다시 읽어 올 일도 없습니다.

## 정리

- 번역은 페이지 단위로 합니다. 페이지 번호만 프레임 번호로 바뀌고 오프셋은 그대로 따라갑니다
- 페이지 폴트는 오류가 아닙니다. 주소는 멀쩡한데 내용이 물리 메모리에 없는 상태이고, OS 가 채워 넣은 뒤 멈췄던 명령부터 다시 실행합니다
- 폴트가 나면 커널이 **빈 프레임 목록에서 하나 꺼내 내용을 채웁니다.** 시간을 먹는 것은 프레임을 찾는 일이 아니라 내용을 채우는 일입니다
- 마이너 폴트와 메이저 폴트는 비용이 두 자릿수 넘게 차이 납니다. 프레임을 흔드는 쪽은 메이저입니다
- **번역이 끝난 뒤 닿는 곳은 언제나 램입니다.** 주소 공간이 크다고 쓸 수 있는 메모리가 커지지 않습니다
- 큰 게임이 도는 것은 워킹 셋이 램에 들어가기 때문이고, 그 워킹 셋을 관리하는 것은 OS 가 아니라 엔진입니다
- 모바일은 밀려날 곳이 없어서 커널이 **깨끗한 파일 페이지부터 버리고**, 그래도 모자라면 앱을 종료합니다. 스왑이 없어도 메이저 폴트는 납니다
- 폴트를 없앨 수는 없습니다. **나는 시점을 프레임 밖으로 옮기는 것**이 할 수 있는 전부입니다