# 메모리 기초

## C 메모리 레이아웃 기초: 변수는 어디에 살고 있을까

C를 시작하면서 가장 먼저 마주한 의문은 "변수가 도대체 메모리의 어디에 어떻게 자리잡는가"였습니다. 이 글은 그 의문을 세 번의 작은 실험으로 풀어내고, 그 답을 따라가다가 자연스럽게 만난 후속 의문들 — 가상 메모리, 페이지 테이블, TLB, 페이지 폴트, 캐시 — 까지 한 호흡에 정리한 기록입니다.

### 들어가며

이 글에서 다루는 내용:

* 메모리는 어떻게 생긴 공간인가
* `&` 연산자로 변수의 주소를 직접 들여다보는 법
* 메모리가 용도별로 여러 동네(영역)로 나뉜다는 사실
* 같은 동네 안에서도 변수가 자라는 방향이 다른 이유
* 64비트 시스템의 가상 주소 공간이 사실은 얼마나 거대한가
* 가상 메모리 — 각 프로세스가 가진 자기만의 사적 공간
* 페이지 테이블, MMU, TLB가 어떻게 협력해 가상-물리 번역을 처리하는가
* 물리 메모리에서의 데이터 위치가 성능과 보안에 미치는 영향
* RAM이 부족할 때 무슨 일이 일어나는가 (페이지 폴트와 swap)

전제: gcc 컴파일러와 PowerShell 정도만 있으면 모든 실험을 그대로 따라하실 수 있습니다. 본문의 환경은 Windows 11 + MinGW-w64 (gcc 16.1.0) + PowerShell입니다.

### 핵심 개념

C의 변수를 이해하려면 먼저 메모리 자체의 모양을 머릿속에 그릴 수 있어야 합니다. 메모리는 추상적인 무언가가 아니라, **번호가 매겨진 작은 칸들이 일렬로 늘어선 거대한 벽**입니다. 각 칸은 정확히 1바이트(8비트)이고, 각 칸에는 고유한 번호인 **주소(address)** 가 매겨져 있습니다.

```
주소:    0     1     2     3     4     5     6   ...
        ┌─┐  ┌─┐  ┌─┐  ┌─┐  ┌─┐  ┌─┐  ┌─┐
바이트:  │ │  │ │  │ │  │ │  │ │  │ │  │ │  ...
        └─┘  └─┘  └─┘  └─┘  └─┘  └─┘  └─┘
```

C에서 변수를 선언한다는 것은 이 벽 어딘가의 칸 몇 개를 빌려서 "이 칸들을 `x`라고 부르겠다"고 이름표를 붙이는 행위입니다. 변수가 차지하는 칸의 수는 타입에 따라 다른데, `int`는 4칸, `double`은 8칸을 차지합니다.

```
주소:    198   199   200   201   202   203   204   ...
        ┌──┐  ┌──┐  ┌──┐  ┌──┐  ┌──┐  ┌──┐  ┌──┐
바이트:  │..│  │..│  │05│  │00│  │00│  │00│  │..│  ...
        └──┘  └──┘  └──┘  └──┘  └──┘  └──┘  └──┘
                     ↑─────────────────↑
                     │   x가 차지하는 4칸  │
                     │
                     &x = 200
```

이때 우리가 `&x`라고 부르는 주소는 **그 칸들의 첫 번째 칸 번호**입니다.

### 실습해보기

**환경**: Windows 11 + MinGW-w64 (gcc 16.1.0) + PowerShell

세 번의 실험을 단계적으로 진행하겠습니다. 모든 실험 파일은 `C:\c-study\pointers` 폴더에 저장했습니다.

#### 실험 1: 두 정수의 주소 확인하기

먼저 가장 단순한 코드부터 시작합니다. `address_basics.c`라는 파일에 다음 코드를 작성합니다.

```c
#include <stdio.h>

int main(void) {
    int x = 5;
    int y = 10;

    printf("x  = %d\n", x);
    printf("y  = %d\n", y);
    printf("&x = %p\n", (void*)&x);
    printf("&y = %p\n", (void*)&y);

    return 0;
}
```

여기서 새로 등장한 두 가지가 있습니다. `&x`는 "x의 주소"를 가져오는 연산자이고, `%p`는 `printf`에서 주소(포인터)를 출력할 때 쓰는 포맷 지정자입니다. `(void*)`로 캐스팅하는 이유는 글 후반부에서 다시 다루겠습니다.

컴파일하고 실행합니다.

```powershell
gcc -Wall -Wextra -g address_basics.c -o address_basics.exe
.\address_basics.exe
```

결과는 다음과 같았습니다(주소 값은 실행할 때마다 다를 수 있습니다).

```
x  = 5
y  = 10
&x = 000000AAFCFFFCEC
&y = 000000AAFCFFFCE8
```

여기서 두 가지를 관찰할 수 있었습니다. 첫째, 두 주소의 차이가 정확히 4바이트라는 점입니다. `EC - E8 = 4`. `int`가 차지하는 크기와 정확히 일치합니다. 둘째, 그런데 이상한 점이 있었습니다. 소스 코드에서는 `x`를 먼저 선언했는데, 주소는 `y`가 더 작았습니다. 직관적으로는 먼저 선언된 변수가 앞쪽 주소에 있을 것 같은데 그렇지 않았습니다.

이 의문은 잠시 미뤄두고, 다음 실험으로 넘어가겠습니다.

#### 실험 2: 변수의 종류에 따라 주소를 비교

이번엔 종류가 다른 변수 세 가지를 한 번에 출력해 보겠습니다. `regions.c`라는 파일에 다음 코드를 작성합니다.

```c
#include <stdio.h>

int g = 100;        // 함수 밖에 선언 — "전역 변수"

int main(void) {
    int local = 5;  // 함수 안에 선언 — "지역 변수"

    printf("&g     = %p\n", (void*)&g);
    printf("&local = %p\n", (void*)&local);
    printf("&main  = %p\n", (void*)main);

    return 0;
}
```

여기서 두 가지가 새로 등장합니다. 함수 바깥에 선언된 `g`는 **전역 변수**이고, 함수 안에 선언된 `local`은 **지역 변수**입니다. 그리고 함수 자체에도 주소가 있습니다. 함수도 결국 메모리 어딘가에 명령어로 올라가 있기 때문입니다.

```powershell
gcc -Wall -Wextra -g regions.c -o regions.exe
.\regions.exe
```

결과는 다음과 같았습니다.

```
&g     = 00007FF693414010
&local = 000000831CBFFB8C
&main  = 00007FF693411760
```

이번에는 훨씬 흥미로운 광경이 펼쳐졌습니다. `&g`와 `&main`은 앞 8자리(`00007FF6 9341`)가 거의 똑같습니다. 두 주소의 거리는 약 10KB 정도입니다. 그런데 `&local`은 앞부터 완전히 다릅니다. `0x7FF6...`과 `0x0083...`은 메모리에서 수십 테라바이트 떨어진 위치입니다.

다시 말해, 변수의 종류에 따라 자리잡는 **동네가 완전히 달랐습니다.** `g`(전역 변수)와 `main`(함수)은 같은 동네에 있고, `local`(지역 변수)은 멀리 떨어진 다른 동네에 있었습니다.

#### 실험 3: 같은 동네 안에서의 순서

마지막으로 같은 동네 안에 변수를 두 개씩 두면 어떤 순서로 자리잡는지 확인해 보겠습니다. `regions2.c`를 작성합니다.

```c
#include <stdio.h>

int g  = 100;
int g2 = 200;

int main(void) {
    int local  = 5;
    int local2 = 50;

    printf("&g      = %p\n", (void*)&g);
    printf("&g2     = %p\n", (void*)&g2);
    printf("&local  = %p\n", (void*)&local);
    printf("&local2 = %p\n", (void*)&local2);

    return 0;
}
```

```powershell
gcc -Wall -Wextra -g regions2.c -o regions2.exe
.\regions2.exe
```

결과는 다음과 같았습니다.

```
&g      = 00007FF762054010
&g2     = 00007FF762054014
&local  = 000000BC367FFBCC
&local2 = 000000BC367FFBC8
```

여기서 다시 놀라운 점이 드러났습니다. 같은 동네(전역 변수끼리, 지역 변수끼리)에 있더라도 **자라는 방향이 서로 반대**였습니다.

| 변수        | 주소        | 방향          |
| --------- | --------- | ----------- |
| `&g`      | `...4010` | 작음          |
| `&g2`     | `...4014` | 큼 (위로 자람)   |
| `&local`  | `...FBCC` | 큼           |
| `&local2` | `...FBC8` | 작음 (아래로 자람) |

전역 변수는 나중에 선언된 것이 더 큰 주소에 자리잡고, 지역 변수는 그 반대였습니다.

### 원리

세 번의 실험에서 드러난 현상을 종합하면 **두 가지 핵심 원리**로 정리할 수 있습니다.

#### 메모리는 용도별 동네로 나뉘어 있다

실행 중인 프로그램의 메모리는 단일한 일렬 공간이지만, 운영체제와 컴파일러가 그 공간을 **여러 개의 별개 구역(region)** 으로 나누어 사용합니다. 변수의 종류에 따라 어느 구역에 자리잡을지 미리 정해져 있습니다.

| 구역                | 누가 사는가                | 어떻게 만들어지는가          | 일반적인 크기          |
| ----------------- | --------------------- | ------------------- | ---------------- |
| **코드 (`.text`)**  | 함수의 명령어 (예: `main`)   | EXE 파일에서 그대로 로드     | KB \~ 수십 MB      |
| **데이터 (`.data`)** | 초기값 있는 전역 변수 (예: `g`) | EXE 파일에서 그대로 로드     | 보통 KB 수준         |
| **스택**            | 지역 변수, 함수 호출 정보       | OS가 프로그램 시작 시 별도 할당 | 기본 1MB (Windows) |
| **힙**             | `malloc`으로 받는 동적 메모리  | 필요할 때 OS에 요청해 늘림    | 수 GB까지 가능        |

실험 2에서 `&g`와 `&main`이 가까웠던 이유는 둘 다 **EXE 파일**에서 함께 로드된 코드와 데이터 영역에 있었기 때문입니다. 반면 `&local`은 **스택**이라는 완전히 별개의 동네에 있었기 때문에 멀리 떨어져 있었습니다. 직관적으로 "변수가 앞에서부터 차곡차곡 깔린다"고 생각했던 것은 절반만 맞는 그림이었고, 실제로는 "종류별로 따로 모여 산다"가 정확한 모습이었습니다.

#### 동네마다 자라는 방향이 다르다

같은 동네 안에서도 자라는 방향이 다른 이유는, **두 영역이 만들어지는 시점과 만드는 주체가 완전히 다르기 때문**입니다.

**데이터 영역**의 전역 변수는 프로그램 실행 **전에**, 컴파일러와 링커가 EXE 파일을 만들면서 자리를 미리 정해둡니다. 마치 연극이 시작되기 전에 좌석표를 미리 짜두는 것과 같아서, 소스 코드의 선언 순서대로 자연스럽게 위로(즉 큰 주소 방향으로) 배치합니다. 그래서 `&g < &g2`였습니다.

**스택 영역**의 지역 변수는 함수가 호출되는 **그 순간** 만들어지고, 함수가 끝나면 사라집니다. CPU에는 `RSP`(Stack Pointer)라는 레지스터가 있어 스택의 현재 위치를 추적하는데, 새 변수가 추가될 때마다 `RSP`에서 일정량을 **빼는** 방식으로 동작합니다. 이는 어셈블리어 수준에서 `SUB RSP, 4` 같은 명령어로 구현되어 있습니다. 그래서 새 변수는 항상 더 작은 주소에 자리잡고, 결과적으로 스택은 **아래로 자랍니다**. `&local2 < &local`이었던 이유입니다.

이 원리를 이해하고 나면 첫 번째 실험에서 만났던 의문도 자연스럽게 풀립니다. `int x`와 `int y`는 둘 다 지역 변수이므로 스택에 자리잡았고, 스택이 아래로 자라기 때문에 나중에 선언된 `y`의 주소가 더 작았던 것입니다.

#### "아래로 자란다"는 표현의 단어 함정

여기서 한 가지 분명히 짚고 넘어갈 점이 있습니다. 저 자신이 이 부분에서 한참을 헷갈렸기 때문입니다.

"스택"이라는 단어는 우리에게 책상 위에 책을 쌓는 모습을 떠올리게 합니다. 책을 쌓으면 자연스럽게 위로 올라가지요. 그래서 "스택"이라는 자료구조도 메모리에서 위로 자랄 거라는 직관이 생깁니다. 그런데 실제로는 그 반대입니다. 이게 왜 그런지를 풀려면 두 개념을 분리해서 봐야 합니다.

| 개념                       | 의미                      |
| ------------------------ | ----------------------- |
| **자료구조로서의 스택(LIFO)**     | 마지막에 넣은 것이 먼저 나오는 규칙    |
| **그 자료구조가 메모리에서 자라는 방향** | 주소 숫자가 커지는 쪽인지 작아지는 쪽인지 |

자료구조 측면에서 "쌓는다"는 것은 맞습니다. 함수가 호출되면 새 변수가 들어오고, 함수가 끝나면 가장 최근 변수부터 빠집니다. 책상 위에 책을 쌓고 위에서부터 빼는 모습 그대로입니다.

그러나 "메모리에서 어느 방향으로 자라는가"는 자료구조의 본질과 무관한 별도의 결정입니다. 위로 자라게 만들 수도 있고, 아래로 자라게 만들 수도 있습니다. CPU 설계자가 선택한 결과일 뿐이고, 현대의 거의 모든 CPU(x86, ARM, MIPS 등)는 "아래로 자라는" 방향을 선택했습니다. 일부 옛날 IBM 메인프레임은 반대 방향이기도 했습니다.

그래서 "스택은 아래로 자란다"는 표현에 헷갈리지 않으려면, 머릿속에서 그것을 **"새 변수가 들어올 때마다 주소 숫자가 작아진다"** 라고 풀어 쓰면 됩니다. 같은 말입니다. "아래"는 메모리 다이어그램을 그릴 때 큰 주소를 위쪽에 배치하는 관습 때문에 생긴 비유일 뿐, 물리적인 위/아래가 있는 것은 아닙니다.

### 더 깊은 원리: 가상 메모리

위까지의 설명을 이해하고 나서도 한 가지 큰 의문이 남았습니다. "스택은 메모리의 끝에서 시작해 아래로 자란다"고 했는데, 그러면 그 "끝"이 어디인지를 미리 정해야 한다는 뜻이고, 결국 한 프로세스가 사용할 수 있는 메모리의 총 크기가 미리 정해진다는 의미가 됩니다. 그렇다면 힙이 예상보다 많이 자라면 스택과 충돌하지 않을까요? 이 의문을 풀려면 한 가지 새로운 개념을 도입해야 합니다 — **가상 메모리**입니다.

#### 메모리 공간은 얼마나 클까

처음에 저는 "한 프로세스가 받는 메모리는 5GB 정도"라는 식으로 작은 숫자를 떠올렸습니다. 그래서 힙이 5GB 가까이 차오르면 스택에 부딪히지 않을까 걱정했지요. 그러나 64비트 시스템에서 한 프로세스가 받는 **가상 주소 공간**의 크기는 차원이 다릅니다.

```
0 ────────────────────────────────────────────────── 18,446,744,073,709,551,615
                          (약 1844경 = 18.4 × 10^18)
```

64비트 주소는 `2^64`개의 칸을 가리킬 수 있습니다. 1844경 개입니다. Windows 64비트에서 user-mode 가상 주소 공간으로 실제로 쓸 수 있는 크기는 약 128TB(2^47)이고, 리눅스도 비슷합니다. 어쨌든 일상적인 프로그램이 사용하는 양과 비교하면 사실상 무한대에 가깝습니다.

스택의 기본 크기는 1MB이고, 힙은 보통 GB 단위로 자랍니다. 그러나 둘이 차지할 수 있는 가상 주소 공간 사이의 거리는 약 128TB입니다. 충돌할 일이 사실상 없습니다.

#### 스택이 가득 차면 어떻게 되는가

스택은 가상 주소 공간 안에서 OS가 시작 시점에 **고정 크기로 미리 예약**해 둡니다(Windows 기본 1MB). 그 영역의 끝까지 다 자라면, OS가 "스택 영역 밖 주소에 접근하려 함"을 감지하고 **프로세스를 강제 종료**시킵니다. 이를 **Stack Overflow**라고 부릅니다. 다른 메모리 영역으로 옮겨 가지 않고 그냥 죽습니다.

```c
#include <stdio.h>

void recurse(int depth) {
    int dummy[100];
    printf("depth = %d\n", depth);
    recurse(depth + 1);
}

int main(void) {
    recurse(0);
    return 0;
}
```

이 코드를 컴파일·실행하면 수만 단계 깊이 내려간 후 프로그램이 갑자기 죽습니다. 다른 메모리로 도망가지 않고 정해진 영역 안에서 끝납니다. 유명한 사이트 이름인 stackoverflow\.com도 여기서 유래했습니다.

#### 가상 메모리의 등장

가상 메모리의 핵심 아이디어는 한 줄로 요약할 수 있습니다.

> **각 프로세스는 자기만의 거대한 가상 주소 공간을 받고, 그것이 실제 물리 RAM과 어떻게 매핑되는지는 OS가 알아서 처리한다.**

```
물리 RAM (예: 16GB) — 모든 프로세스가 공유
  [▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒]
   ↑ 진짜 메모리 칩

각 프로세스는 자기만의 가상 주소 공간을 가짐 (Windows 64-bit: ~128TB)
  ┌─────────────────────────────────────────────────────┐
  │ 코드│데이터│힙→ ........(128TB)........  ←스택        │
  └─────────────────────────────────────────────────────┘
   0                                                  128TB
```

여러 프로세스가 동시에 실행되어도 **각자 128TB짜리 별개의 가상 주소 공간을 받습니다.** 100개 프로세스가 동시에 돌면 가상 주소가 합쳐서 1만 2800TB가 되지만, 어차피 가상 주소는 페이지 테이블에 적힌 숫자일 뿐이라 물리적으로 아무 문제도 없습니다. 진짜 한정된 자원은 물리 RAM과 디스크 swap 공간이고, 이것은 모든 프로세스가 공유합니다.

#### 페이지 테이블과 MMU — 번역의 메커니즘

가상 주소가 실제로 동작하려면 누군가는 "가상 주소 X는 실제로 물리 RAM의 어느 위치인가?"를 알아내야 합니다. 그 일을 하는 것이 **MMU(Memory Management Unit)** 라는 CPU 안의 하드웨어이고, MMU가 참조하는 표가 **페이지 테이블**입니다.

가상 주소와 물리 주소 모두 **페이지(보통 4KB) 단위**로 관리됩니다. 가상 주소를 4KB 묶음으로 자르면 "가상 페이지"가 되고, 물리 RAM도 4KB 묶음으로 자르면 "물리 프레임"이 됩니다. 페이지 테이블은 "어떤 가상 페이지가 어떤 물리 프레임에 매핑되어 있는가"를 기록합니다.

```
프로그램이 읽음:  *(0x00007FFE_12345678)
                       ↓
MMU가 페이지 테이블 조회:  가상 0x...12345 → 물리 0x4A8B0 매핑 확인
                       ↓
실제 RAM 칩에서:        물리 0x4A8B0 의 값을 읽어옴
```

프로그램은 이 번역의 존재를 모릅니다. "내가 본 주소에 데이터가 있다"고 생각하고 동작할 뿐, 실제로 RAM의 어디에 그 데이터가 있는지는 신경 쓰지 않습니다.

#### 두 프로세스가 같은 가상 주소를 사용해도 문제없는 이유

여기서 가상 메모리의 진짜 마법이 드러납니다. **각 프로세스마다 자기만의 페이지 테이블이 있습니다.** 그래서 프로세스 A의 가상주소 0x140000000과 프로세스 B의 가상주소 0x140000000은, MMU에 의해 서로 다른 물리 RAM 위치로 번역됩니다.

```
프로세스 A의 페이지 테이블          프로세스 B의 페이지 테이블
┌────────┬──────────┐               ┌────────┬──────────┐
│ 가상주소│ 물리주소  │               │ 가상주소│ 물리주소  │
├────────┼──────────┤               ├────────┼──────────┤
│ 0x...0 │ 물리 슬롯 2│ (코드)        │ 0x...0 │ 물리 슬롯 5│ (코드)
│  ...   │   ...    │                │  ...   │   ...    │
└────────┴──────────┘               └────────┴──────────┘
```

OS가 한 프로세스에서 다른 프로세스로 전환할 때(**컨텍스트 스위치**), 다음과 같은 일이 일어납니다.

1. 현재 프로세스의 상태를 저장(레지스터, 페이지 테이블 포인터 등)
2. 다음 프로세스의 페이지 테이블 포인터를 CPU의 `CR3` 레지스터에 로드
3. 다음 프로세스의 명령부터 실행 재개

이 순간부터 같은 CPU가 다른 페이지 테이블로 번역을 수행합니다. 그래서 프로세스끼리는 서로의 메모리를 보지도, 침범하지도 못합니다. 비유하자면 도서관에 여러 명의 손님이 있는데, 손님마다 자기만의 카탈로그를 들고 다니고 사서는 "이 카탈로그의 책 5번"을 보면 손님마다 다른 진열대 위치로 가져다 주는 셈입니다.

#### TLB — 번역을 빠르게 만드는 캐시

매 메모리 접근마다 페이지 테이블을 조회하는 것은 비용이 큽니다. 페이지 테이블 자체도 RAM에 저장되어 있어, 페이지 테이블을 한 번 조회하려면 RAM 접근이 한 번 더 필요해지기 때문입니다. 이를 가속하기 위해 CPU는 **TLB(Translation Lookaside Buffer)** 라는 작은 하드웨어 캐시를 가지고 있습니다.

```
TLB 없을 때:
  매 메모리 접근마다 → 페이지 테이블 조회(RAM 한 번 더 다녀옴) → 물리 주소
  → 모든 메모리 접근이 두 배로 느려짐

TLB 있을 때:
  매 메모리 접근마다 → TLB에서 캐시된 번역 찾음(즉시) → 물리 주소
  → 사실상 추가 비용 0 (캐시 히트일 때)
```

여기서 한 가지 짚고 넘어가야 할 점이 있습니다. 가상 메모리를 가능하게 만드는 진짜 메커니즘은 **페이지 테이블 + MMU**이고, **TLB는 그저 빠르게 만들어 주는 캐시**일 뿐입니다. TLB가 없어도 가상 메모리는 동작합니다 — 다만 끔찍하게 느릴 뿐입니다. 1980년대 일부 옛날 CPU는 TLB가 없거나 너무 작아서 가상 메모리가 실용적이지 않았습니다.

#### Swap과 페이지 폴트 — RAM이 부족할 때

물리 RAM은 한정된 자원입니다. 16GB짜리 노트북에서 모든 프로세스의 메모리 사용량 합계가 16GB를 초과하면 어떻게 될까요? 답은 의외입니다. **가상 메모리는 사실 RAM이 아니라 RAM + 디스크의 합입니다.**

OS는 RAM이 부족하면, 자주 쓰이지 않는 페이지를 **디스크의 swap 파일**(또는 페이지 파일)로 옮기고, 그 자리에 새 페이지를 올립니다. 어떤 페이지를 옮길지는 LRU(Least Recently Used) 같은 알고리즘으로 결정합니다.

이 과정의 핵심은 다음과 같습니다 — **swap된 페이지는 가상 주소 공간에서 사라지지 않습니다. 그저 위치가 RAM이 아니라 디스크일 뿐입니다.**

```
가상 주소 공간 (128TB)
  페이지 5   ──→  ★ 디스크 위치 D5 ★   ← RAM에서 내려갔지만 가상엔 여전히 존재
  페이지 N+1 ──→  RAM 슬롯 E           ← 새로 올라옴

물리 RAM (5GB)
  슬롯 E: 페이지 N+1 데이터  ← 새로 올라옴 (전에 페이지 5가 있었던 자리)

디스크 swap 파일
  위치 D5: 페이지 5 데이터  ← 옮겨짐
```

프로그램이 swap된 페이지(예: 페이지 5)에 다시 접근하면 다음과 같은 일이 일어납니다.

1. CPU가 가상 주소 접근
2. MMU가 페이지 테이블 조회 → "**RAM에 없음, 디스크 위치 D5**"
3. CPU가 **Page Fault 예외**(인터럽트)를 발생시킴
4. OS가 Page Fault 핸들러로 점프
5. OS가 또 다른 안 쓰는 페이지를 디스크로 내리고, 비워진 슬롯에 디스크 위치 D5의 데이터를 읽어 옴
6. 페이지 5의 페이지 테이블 항목을 새 RAM 슬롯으로 업데이트
7. OS가 프로그램을 **중단된 명령부터 재실행**시킴

프로그램 입장에서는 그저 한 번의 메모리 접근이었습니다. 그 한 번의 접근이 사실 디스크 I/O를 동반했다는 것을 모릅니다. 다만 그 접근만 유독 느렸을 뿐입니다.

이 비유가 와 닿으실 수도 있습니다. 도서관에 손님이 책을 요청합니다. 사서가 카탈로그를 봅니다. 진열대(=RAM)에 있으면 즉시 줍니다. 진열대에 없고 창고(=디스크)에 있으면, 진열대에서 잘 안 보는 책 한 권을 창고로 옮기고, 창고의 그 책을 진열대로 가져옵니다. **책은 항상 어딘가에 있습니다 — 진열대 아니면 창고. 카탈로그에서 사라지는 일은 없습니다.**

#### Demand paging과 Lazy Allocation

위 메커니즘 덕분에 한 가지 흥미로운 동작이 가능해집니다. 프로그램이 `malloc(5GB)`를 호출하면, OS는 **가상 주소 5GB를 예약만 해두고**, 실제 물리 RAM 매핑은 프로그램이 그 메모리에 처음 **쓰는** 순간(페이지 단위로)에야 일어납니다. 이를 **demand paging** 또는 **lazy allocation**이라고 부릅니다.

그래서 `malloc(5GB)`가 한순간에 끝납니다. 진짜로 5GB의 RAM이 한꺼번에 떼어진 것이 아니라, 가상 주소상 자리만 잡아둔 것이지요. 프로그램이 실제로 그 메모리에 데이터를 쓰기 시작할 때 그 페이지가 처음으로 RAM에 매핑됩니다.

#### 물리 메모리에서의 위치는 무관한가

여기서 한 가지 더 의문이 생겼습니다. 어차피 가상 주소만 보고 동작하니까, 물리 RAM의 어느 위치에 데이터가 저장되든 상관없을 거라고 생각했습니다. 절반은 맞고, 절반은 틀린 직관이었습니다.

**프로그램의 동작 정확성(correctness) 관점에서는 맞습니다.** 데이터가 RAM 슬롯 100번에 있든 200번에 있든, 프로그램은 가상 주소만 보니까 차이를 못 느낍니다.

**그러나 성능 관점에서는 매우 중요합니다.** CPU에는 TLB 외에도 **데이터 자체를 캐시하는** 메모리 계층이 별도로 있기 때문입니다.

```
빠름 ┬── CPU 레지스터  (ns 이하)
     ├── L1 캐시        (~1 ns)        — 32KB 정도
     ├── L2 캐시        (~3 ns)        — 256KB 정도
     ├── L3 캐시        (~10 ns)       — 8MB 정도
느림 ┴── 메인 메모리(RAM) (~100 ns)    — 16GB
```

CPU 캐시는 RAM에서 데이터를 가져올 때 **캐시 라인(보통 64바이트)** 단위로 통째로 가져옵니다. 그래서 메모리에서 인접한 데이터는 한 번에 같이 캐시에 올라옵니다. 자주 같이 쓰는 데이터를 인접한 위치에 두면 캐시 활용도가 올라가고, 흩어 두면 매번 RAM에서 새로 가져와야 해서 100배 가까이 느려질 수 있습니다.

이 시점에서 한 가지 좋은 소식이 있습니다. **한 페이지(4KB) 안에서는 가상 주소와 물리 주소가 모두 연속**입니다. 페이지 단위로 가상-물리가 매핑되기 때문입니다. 캐시 라인은 64바이트로 페이지보다 64배 작으니, 작은 자료구조(예: `int arr[100]` = 400바이트)는 한 페이지 안에 통째로 들어가서 자동으로 캐시 친화적입니다.

문제는 페이지 경계를 넘어가는 큰 자료구조입니다. 이 경우 가상 주소상 인접한 두 페이지가 물리 RAM에서는 멀리 떨어져 있을 수 있습니다. 다행히 OS와 프로그래머는 이 문제를 풀기 위해 여러 메커니즘을 만들어 두었습니다 — 그 이야기는 아래의 "한 걸음 더 들어가보면" 섹션에서 다루겠습니다.

### Java/Kotlin과의 비교

JVM 언어를 다루던 시점에는 이 모든 것이 보이지 않게 가려져 있었습니다. C와 비교하면 다음과 같이 정리할 수 있습니다.

| 측면        | Java/Kotlin                       | C                             |
| --------- | --------------------------------- | ----------------------------- |
| 변수의 주소    | 절대 볼 수 없음 (JVM이 가림)               | `&x`로 직접 확인 가능                |
| 메모리 영역 구분 | 힙 위주, 스택은 추상화됨                    | Code/Data/Stack/Heap 명확히 분리   |
| 전역 변수     | `companion object`나 `static`으로 흉내 | 함수 밖에 선언하면 곧바로 데이터 영역         |
| 함수의 주소    | 클래스 메서드는 JIT 컴파일 후 변동             | `&main`으로 즉시 주소 확인 가능         |
| 변수 = 무엇   | 객체에 대한 이름표(참조)                    | 메모리 칸들에 붙은 이름표                |
| 메모리 할당    | `new`로 자동 힙 할당, GC가 정리            | `malloc` 직접 호출, `free`로 직접 반환 |
| 가상 메모리    | 존재하지만 JVM이 위에 또 한 겹 추상화           | 그대로 노출됨                       |

JVM에도 스택은 분명히 존재합니다. 메서드를 호출할 때마다 스택 프레임이 만들어지고 지역 변수가 그 안에 들어갑니다. 단지 그 주소를 우리에게 보여주지 않을 뿐입니다. C는 그 가림막을 걷어내고 우편함 번호를 직접 보여주는 것에 불과합니다.

### 한 걸음 더 들어가보면

#### 스택이 아래로 자라는 역사적 이유

스택이 왜 굳이 "아래로" 자라는지에 대한 답은 1960\~70년대 컴퓨터 메모리 배치 관습에서 비롯되었습니다. 당시 설계자들은 메모리의 **낮은 주소**부터 코드와 정적 데이터를 깔고, **반대편 끝(높은 주소)** 에서 시작해 스택을 아래로 자라게 했습니다. 이렇게 하면 힙(중간에서 위로 자람)과 스택(끝에서 아래로 자람)이 서로를 향해 자라면서, 메모리 공간을 최대한 늦게까지 충돌 없이 쓸 수 있었기 때문입니다.

```
낮은 주소
┌──────────────┐
│ 코드          │  (실행 명령어)
│ 정적 데이터    │  (전역 변수)
├──────────────┤
│ 힙 ↓          │  ← malloc은 위로 자람
│              │
│ (빈 공간)     │
│              │
│ 스택 ↑        │  ← 함수 호출은 아래로 자람
└──────────────┘
높은 주소
```

이 50년 넘는 관습이 굳어져, 지금도 모든 x86/x64 CPU의 `push`와 `call` 명령어는 `RSP`를 자동으로 감소시키도록 만들어져 있습니다. 사실 이 디자인은 가상 메모리가 일반화되기 전, 메모리 공간이 정말로 부족하던 시절의 산물입니다. 지금처럼 한 프로세스에 128TB의 가상 공간을 줄 수 있는 시대에는 사실 이 설계의 본래 동기는 사라졌지만, 50년의 관성이 워낙 강해 그대로 굳어져 있습니다.

#### ASLR의 정렬 단위

같은 프로그램을 두 번 실행하면 스택 주소가 매번 다르게 나옵니다. 그러나 마지막 한 자리(16진수)는 변하지 않습니다. 이는 **ASLR(Address Space Layout Randomization)** 이라는 OS의 보안 메커니즘이 16바이트 단위로만 무작위화하기 때문입니다. 이 주제는 별도의 글에서 더 깊이 다루겠습니다.

#### 큰 메모리를 다룰 때 OS가 하는 일

페이지 경계를 넘어가는 큰 메모리 할당에서는 가상 주소상 인접하다고 물리적으로 인접하다는 보장이 없습니다. OS는 이 문제를 풀기 위해 다층적인 메커니즘을 만들어 두었습니다.

**Buddy Allocator**: OS 커널은 빈 물리 프레임을 2의 거듭제곱 크기 묶음으로 추적합니다. 누가 16KB(4 프레임)를 요청하면, 이미 묶여 있는 4 프레임짜리 묶음을 통째로 줍니다. 즉 물리적으로 연속입니다. 이 알고리즘이 Linux 커널과 Windows 모두의 물리 메모리 관리자 핵심입니다.

**메모리 컴팩션(Defragmentation)**: 오래 동작한 시스템은 메모리 단편화로 큰 연속 묶음이 부족해질 수 있습니다. 그러면 OS가 백그라운드에서 사용 중인 페이지들을 다른 위치로 옮기고 페이지 테이블만 업데이트해서 큰 묶음을 다시 만듭니다. 가상 주소상으로는 변화 없고 매핑만 바뀝니다.

**HugePage / Large Page**: 성능이 중요한 프로그램(데이터베이스, 가상 머신, 게임 엔진)을 위해 OS가 2MB 또는 1GB짜리 큰 페이지를 통째로 할당해 주는 기능이 있습니다. 일반 페이지(4KB)로 1MB를 다루면 256번의 매핑이 필요하고 TLB도 256개를 쓰지만, 2MB 큰 페이지 하나로 다루면 매핑 1개, TLB 엔트리 1개로 끝납니다. Windows에선 `VirtualAlloc(... MEM_LARGE_PAGES ...)` 플래그로, Linux에선 `madvise()` 시스템콜이나 transparent hugepages로 활용합니다.

**NUMA 인식 할당**: 다중 CPU 서버에선 OS가 "이 프로세스가 CPU 0에서 돈다"는 것을 보고 그 CPU에 가까운 RAM 모듈에서 페이지를 할당합니다. 멀리 있는 RAM에 접근하면 느려지기 때문입니다.

#### 프로그래머가 할 수 있는 일

OS가 다 해주는 것은 아니고, 프로그래머도 데이터를 캐시 친화적으로 배치하는 책임이 있습니다.

**자료구조 레이아웃**: 자주 같이 쓰이는 필드를 묶어두면 한 캐시 라인에 같이 올라와 빠릅니다.

```c
struct Bad {
    int    rarely_used_field;
    char   padding[60];
    int    hot_field;          // 자주 접근하지만 캐시 라인 끝에 위치
};

struct Good {
    int    hot_field1;          // 자주 접근하는 것끼리 모음
    int    hot_field2;
    int    hot_field3;
    int    rarely_used_field;
};
```

**Array-of-Structs vs Struct-of-Arrays**: 게임 엔진과 시뮬레이션의 단골 토픽입니다. 객체 단위 처리에는 AoS가, 한 종류 필드만 쭉 처리할 때는 SoA가 캐시 친화적입니다.

**메모리 풀**: 표준 `malloc`은 작은 할당이 흩어지기 쉽습니다. 큰 덩어리를 미리 받아두고 그 안에서 작게 잘라 쓰는 자기만의 풀을 만들면 자동으로 물리적 인접성을 확보할 수 있습니다.

**캐시 라인 정렬**: `__attribute__((aligned(64)))`로 캐시 라인 경계에 맞추면 false sharing 같은 멀티스레드 성능 문제를 피할 수 있습니다.

**고성능 할당자**: `jemalloc`(Facebook), `tcmalloc`(Google), `mimalloc`(Microsoft) 같은 표준 `malloc` 대체품들이 있습니다. 비슷한 크기 할당을 한 영역에 모아 지역성을 높이고, 스레드별 캐시로 lock 경합을 줄입니다. 프로덕션 시스템 대부분이 표준 `malloc` 대신 이런 것들을 사용합니다.

#### Thrashing — 시스템이 멈춰서는 현상

디스크는 RAM보다 1000\~10만 배 느립니다.

| 저장소        | 접근 시간                   |
| ---------- | ----------------------- |
| CPU 캐시     | \~1 ns                  |
| RAM        | \~100 ns                |
| SSD (NVMe) | \~50,000 ns (50 μs)     |
| 하드디스크      | \~10,000,000 ns (10 ms) |

만약 프로그램이 자기가 쓰는 메모리 양이 RAM보다 너무 크면, 거의 모든 메모리 접근이 페이지 폴트가 됩니다. CPU는 거의 일을 못 하고 디스크 I/O 대기만 하게 되어 전체 시스템이 멈춰선 듯한 상태가 됩니다. 이를 **Thrashing**이라고 부릅니다. RAM이 부족하면 컴퓨터가 갑자기 끔찍하게 느려지는 현상의 정체입니다. SSD 시대에는 좀 견딜 만하지만, 옛날 HDD 시절에는 사실상 시스템 정지였습니다.

### 처음에 헷갈렸던 점

이 글을 쓰는 과정에서 직접 부딪힌 오해가 여러 개 있었습니다.&#x20;

**스택이 위로 자랄 거라는 직관**. "쌓는다"라는 단어가 자연스럽게 위로 올라가는 그림을 떠올리게 만들었습니다. 알고 보니 스택이라는 자료구조와 그것이 메모리에서 자라는 방향은 별개의 결정이었고, "아래로 자란다"는 표현은 단지 "주소 숫자가 작아진다"는 의미일 뿐이었습니다.

**메모리가 앞에서부터 차곡차곡 채워질 거라는 가정**. 실제로는 메모리가 종류별 동네로 나뉘어 있었고, 같은 동네 안에서도 자라는 방향이 달랐습니다.

**메모리 공간이 작을 거라는 오해**. 처음에는 한 프로세스가 5GB 정도의 메모리를 받는 모습을 머릿속에 그렸습니다. 그래서 힙이 그 크기 가까이 차오르면 스택과 충돌할 거라고 걱정했습니다. 사실은 한 프로세스가 받는 가상 주소 공간이 약 128TB라는 것을 알게 되면서, 힙과 스택의 가상 주소 거리는 실질적으로 무한대에 가깝다는 사실을 받아들이게 되었습니다.

**한 프로세스에 128TB를 주면 다른 프로세스가 못 쓸 거라는 오해**. 가상 주소 공간이 프로세스마다 별개라는 사실을 알기 전까지는 "100개 프로세스가 동시에 돌면 합쳐서 1만 2800TB가 필요한 거 아닌가?"라고 생각했습니다. 알고 보니 가상 주소는 그저 페이지 테이블에 적힌 숫자일 뿐이고, 진짜 한정된 자원은 물리 RAM뿐이었습니다.

**물리 RAM에서 내려간 페이지가 사라진다는 오해**. swap된 페이지는 RAM에서는 빠져나갔어도 가상 주소 공간 안에서는 여전히 살아있고, 디스크 swap 파일에 옮겨진 것뿐이라는 사실을 깨닫고 나서야 가상 메모리의 진짜 그림이 잡혔습니다. 가상 메모리는 RAM이 아니라 RAM과 디스크의 합이었습니다.

**TLB가 가상 메모리를 가능하게 만드는 메커니즘이라는 착각**. 사실 가상 메모리를 가능하게 만드는 진짜 메커니즘은 페이지 테이블 + MMU이고, TLB는 그 위에 얹은 가속기일 뿐이었습니다.

### EDR 관점에서

지금까지 다룬 메모리 모델은 EDR 분석가에게 그저 흥미로운 배경 지식이 아니라 일상적으로 사용하는 도구입니다.

#### 스택 기반 공격과 방어

스택이 아래로 자란다는 사실은 **버퍼 오버플로우 공격**의 출발점입니다.

```c
void foo(void) {
    char buf[8];
    gets(buf);     // 입력이 8바이트를 초과하면?
}
```

스택은 아래로 자라지만, `gets`로 받은 데이터는 `buf`의 시작(낮은 주소)에서 위로 채워집니다. 그래서 8바이트를 초과하는 입력이 들어오면, 자동으로 위쪽에 있는 리턴 주소를 덮어쓰게 됩니다. 공격자가 이 리턴 주소를 자신이 원하는 코드 위치로 바꾸면 임의 코드 실행으로 이어집니다. 지난 30년간 수많은 보안 사고가 정확히 이 패턴에서 비롯되었습니다.

이런 공격을 막기 위해 컴파일러와 EDR 솔루션은 여러 방어 메커니즘을 겹겹이 사용합니다.

* **Stack canary** — 리턴 주소 앞에 "관문 값"을 두고 함수 종료 시 검사하여 덮어쓰기를 탐지
* **DEP (Data Execution Prevention)** — 스택 영역의 코드 실행 자체를 차단
* **CFG (Control Flow Guard)** — 점프 가능한 주소를 미리 화이트리스트로 제한

#### 가상 메모리 시스템과 인젝션 기법

가상 메모리 시스템은 EDR 분석의 절반을 차지합니다. 악성코드의 단골 기법인 **프로세스 인젝션**은 가상 메모리 시스템 덕분에 가능합니다. 한 프로세스가 다른 프로세스의 가상 주소 공간에 코드를 써넣고 실행시키는 패턴이지요. 다음 Win32 API들이 인젝션의 골격입니다.

* **`VirtualAlloc`/`VirtualAllocEx`** — 가상 주소 공간에 직접 페이지를 할당. 자기 프로세스 또는 다른 프로세스의 가상 공간에 빈 페이지를 만듦
* **`WriteProcessMemory`** — 다른 프로세스의 메모리에 직접 데이터를 씀
* **`CreateRemoteThread`** — 다른 프로세스에서 새 스레드를 만들어 인젝션된 코드를 실행시킴
* **`VirtualProtect`** — 페이지의 권한을 변경. `PAGE_EXECUTE_READWRITE`로 바꾸는 행위는 EDR이 매우 의심스럽게 봄. 정상 프로그램에서는 거의 일어나지 않는 패턴

**Reflective DLL Injection**은 정상 DLL 로딩 절차를 우회해 자기 코드를 메모리에 직접 매핑해서 실행합니다. 정상 DLL은 코드 영역에 매핑되지만, 이런 인젝션 코드는 비정상적인 위치(힙이나 스택 근처)에 매핑되므로, EDR은 "이상한 영역에서 코드가 실행 중"임을 감지합니다.

**Heap Spray**는 공격자가 힙을 특정 패턴으로 가득 채워서 익스플로잇이 적중할 확률을 높이는 기법입니다. 페이지 단위 패턴 분석으로 탐지할 수 있습니다.

#### 페이지 단위 보안

가상 주소 공간 자체의 무작위화인 **ASLR**은 공격자가 주소를 예측 못 하게 합니다. 페이지마다 부여되는 **NX bit / DEP**은 데이터 페이지에서 코드 실행 시도를 차단합니다. 이 모든 기능은 가상 메모리 + 페이지 테이블 + MMU 인프라 위에서만 가능합니다.

#### Memory Scanning과 Swap

EDR이 의심 프로세스의 메모리를 스캔할 때, 디스크에 swap된 페이지까지 검사하려면 강제로 다시 RAM에 올려야 합니다. 이는 비용이 큰 작업이라, EDR 설계자는 어떤 페이지를 우선 스캔할지를 신중하게 결정해야 합니다. 일부 악성코드는 자기 코드를 의도적으로 swap-out시켜 EDR 스캔을 피하는 anti-debugging 기법을 사용합니다.

페이지 폴트 빈도 분석도 의미 있는 신호입니다. 정상 프로그램과 다르게 비정상적으로 페이지 폴트가 자주 일어나는 패턴은 인젝션이나 후킹의 흔적일 수 있습니다.

#### 물리 메모리 레이아웃과 하드웨어 공격

물리 메모리 위치는 보안에도 영향을 줍니다.

* **Rowhammer** 공격 — DRAM 칩의 같은 메모리 행을 빠르게 반복 접근하면 인접한 행의 비트가 뒤집힙니다(전기적 간섭). 공격자는 자기 가상 주소를 적당한 물리 위치에 매핑시켜 다른 프로세스의 메모리를 변조할 수 있습니다.
* **캐시 사이드 채널** (Spectre, Meltdown) — 캐시에 데이터가 있느냐 없느냐(접근 시간 차이)로 다른 프로세스의 비밀을 추론합니다. 물리적으로 같은 캐시를 공유한다는 사실에서 출발하는 공격입니다.


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://wonjoon.gitbook.io/joons-til/c/undefined.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
