Memory Management — 메모리 모델부터 페이징·세그먼테이션까지
메모리는 빠를수록 비싸고 클수록 느리다. 게다가 여러 프로세스가 한정된 물리 메모리를 나눠 써야 한다. 운영체제가 프로세스를 시간·공간 양면에서 효율적으로 메모리에 배치하는 일, 그것이 메모리 관리(memory management)다. 그 전에 멀티코어 환경에서 메모리가 어떻게 동작하는지를 규정하는 메모리 모델부터 짚는다.
메모리 모델
메모리 모델(memory model)은 메모리가 어떻게 동작하는지를 정의하는 방식이다. 프로그래머와 시스템 사이의 일종의 계약이며, 메모리 일관성 모델(memory consistency model)이라고도 부른다. 모델은 프로세서 종류에 따라 다르다.
- 순차 일관성 모델(sequential consistency model): 각 코어에서 명령어 재배치(reordering)를 전혀 허용하지 않는 보수적인 모델. 안전하지만 최적화를 막아 성능이 떨어진다. (예: LEON4)
- 완화된 일관성 모델(relaxed/weaker consistency model): 메모리 명령어의 재배치를 허용한다. 대신 프로그래머가 펜스 명령어(fence instruction)로 프로그램 순서를 직접 지정해야 한다. (예: ARM, RISC-V) — 오늘날의 기본값이다.
재배치가 일으키는 문제
전역 변수 x = 0, flag = false에서 두 스레드가 다음과 같이 동작한다고 하자.
// Thread 1 // Thread 2
x = 100; while (flag == false);
flag = true; print(x);
직관적으로는 100이 출력될 것 같다. 하지만 어떤 현대 프로세서도 순차 일관성을 구현하지 않는다. 프로그램 순서(program order)와 실행 순서(execution order)가 일치하지 않고, 명령어를 적극적으로 재배치하기 때문이다. 현대 프로세서는 비동기 메모리 연산을 위해 내부적으로 load/store buffer를 사용하고, 캐시 일관성(cache-coherence) 타이밍 차이로 메모리 연산이 코어마다 다른 순서로 보일 수 있다.
그래서 Thread 1에서 x = 100과 flag = true의 순서가 뒤바뀌어, Thread 2가 flag = true를 x = 100보다 먼저 관측하면 0을 출력한다. 이런 일이 실제로 일어난다.
메모리 배리어
메모리 배리어(memory barrier)는 메모리 연산을 직렬화하는 특수 명령어다. 메모리 펜스(memory fence)라고도 한다. 불필요한 순서 강제 오버헤드를 줄이기 위해 읽기·쓰기 배리어로 세분화된다.
- 쓰기 메모리 배리어(write memory barrier): store 순서를 보장하고 store-store 재배치를 막는다.
- 읽기 메모리 배리어(read memory barrier): load 순서를 보장하고 load-load 재배치를 막는다.
앞의 예제는 배리어로 고칠 수 있다.
x = 100;
memory_barrier(); // 이전의 모든 쓰기가 전역적으로 보이도록 강제
flag = true;
리눅스는 rmb()(읽기 배리어), wmb()(쓰기 배리어), mb()(읽기·쓰기 모두), 컴파일러 재배치만 막는 barrier() 등 메모리 배리어 기능을 제공한다.
메모리 관리 개요
메인 메모리는 크게 두 부분으로 나뉜다.
- OS part: 커널(kernel)이 사용하는 영역
- User part: 각 프로세스가 사용하는 영역. 여러 프로세스를 수용하려면 더 잘게 나뉘어야 한다.
메모리 관리는 프로세스를 시간과 공간 양면에서 효율적으로 메모리에 할당하는 일이며, 멀티프로그래밍 시스템의 핵심 기능이다. 페이징은 페이지(불변 크기) 단위로, 세그먼테이션은 세그먼트(가변 크기) 단위로 관리하며, 어느 영역에 메모리를 할당했는지는 페이지 테이블·세그먼트 테이블에 기록한다.
메모리 관리 요구사항
메모리 관리는 높은 성능 효율뿐 아니라 다음 기능적 요구사항도 만족해야 한다.
재배치(Relocation) — 물리 위치를 미리 알 수 없으므로 동적 주소 변환이 필요하다. 즉 논리 주소와 물리 주소를 분리해야 한다. 프로세스가 다른 메모리 영역으로 옮겨지거나, 실행 중 스와핑(swapping)이 일어날 수 있기 때문이다. 현대 범용 OS에서는 프로세스가 논리 주소로 메모리에 접근하고 MMU(Memory Management Unit)가 이를 물리 주소로 변환한다. 단순 임베디드 OS에서는 프로그램이 물리 주소에 직접 접근하기도 한다.
보호(Protection) — OS는 프로세스 간 불법 메모리 접근을 막아야 한다. 프로세스는 실행 중 재배치될 수 있으므로 컴파일 시점에 절대 주소를 검사하는 것은 불가능하다. 따라서 모든 메모리 참조는 런타임에 검사되어야 한다.

위 그림이 재배치·보호를 지원하는 하드웨어다. 프로세스의 상대 주소에 베이스 레지스터(base register)를 더해(Adder) 절대 주소를 만들고, 바운드 레지스터(bounds register)와 비교(Comparator)해 영역을 벗어나면 인터럽트를 건다. 이 두 동작이 MMU에서 런타임에 이뤄진다.
공유(Sharing) — 여러 프로세스가 같은 프로그램(또는 라이브러리)을 실행할 때, 각자 복사본을 올리는 대신 메모리의 같은 코드를 공유하게 할 수 있다. 커널 코드·데이터도 공유 주소 공간에 매핑되어, 모든 사용자 프로세스가 모드 전환(mode switch)을 통해 동일한 커널 메모리에 접근한다. 중복 메모리 사용을 줄인다.
논리/물리 조직(Logical/Physical organization) — 실제 물리 메모리는 용량·속도·비용이 다른 여러 계층으로 구성된다. 논리 조직과 물리 조직을 분리하면 메모리 관리가 단순해진다. 물리 메모리를 직접 관리하기엔 너무 복잡하기 때문이다.

OS는 각 프로세스에 독립적인 논리 주소 공간을 제공하고, 논리 주소와 물리 위치의 매핑을 관리하며, DRAM·CXL 메모리·스토리지(NVMe/SSD/HDD) 같은 메모리 계층 사이의 데이터 이동을 처리한다.
주소의 종류와 변환
주소는 추상화 수준에 따라 세 가지로 나뉜다.
| 주소 | 다른 이름 | 의미 |
|---|---|---|
| 심볼릭 주소(symbolic address) | — | 소스 코드에서 쓰는 주소. 변수명·상수·레이블 |
| 상대 주소(relative address) | 논리(logical)·가상(virtual) 주소 | 프로세스 내 위치. 0에서 시작. (페이지 번호+오프셋으로 표현되기도) |
| 절대 주소(absolute address) | 물리(physical)·실제(real) 주소 | 메인 메모리의 실제 위치 |
컴파일 시점에 컴파일러가 심볼릭 주소를 논리 주소로 변환한다. 논리 주소는 프로세스 입장에서 보는 주소로, x86이라면 주소 공간이 0번지에서 시작한다.
동적 주소 변환(dynamic address translation)은 가상(논리) 주소를 실제(물리) 주소로 변환하는 것이다. 프로세서와 메모리 사이에 위치한 하드웨어 장치 MMU가 담당한다. 예를 들어 프로세스가 물리 베이스 주소 0x8000에 재배치됐다면, 논리 주소 0x0080은 런타임에 0x0080 + 0x8000으로 변환된다. (이 글에서 논리 주소와 가상 주소는 같은 의미로 쓴다.)
메모리 할당
할당 단위는 시스템에 따라 프로세스, 커널 객체, 파일 캐시 등으로 다양하다. 할당 방식은 크게 둘로 나뉜다.
- 연속 할당(contiguous allocation): 하나의 연속된 물리 메모리 블록으로 할당. 단순하고 예측 가능한 워크로드에 적합. → 고정 분할, 동적 분할, 버디 시스템
- 비연속 할당(non-contiguous allocation): 여러 분리된 물리 메모리 영역에 흩어서 할당. 복잡하고 동적인 워크로드에 적합. → 페이징, 세그먼테이션
고정 분할 (Fixed Partitioning)
연속 할당의 가장 단순한 방식이다. 메모리를 고정된 경계(fixed boundaries)로 미리 나눠 둔다.
균등 크기 분할(equal-size partitions)은 모든 파티션을 같은 크기로 만든다. 어느 파티션을 고르든 상관없으므로 배치 정책(placement policy)이 필요 없어 단순하고 빠르며, 구현이 비교적 쉽다.
단점도 명확하다.
- 내부 단편화(internal fragmentation): 적재된 데이터가 파티션보다 작으면 남는 공간이 낭비된다. 아무리 작은 프로그램도 파티션 하나를 통째로 차지한다.
- 활성 프로세스 수가 파티션 개수로 제한된다.
- 프로그램 크기가 파티션 크기로 제한된다. 더 크면 오버레이(overlay) 기법으로 설계해야 한다.
비균등 크기 분할(unequal-size partitions)은 파티션 크기를 다양하게 둬서 균등 분할의 약점을 일부 보완하지만, 워크로드에 따라 효율이 달라진다. 프로세스를 파티션에 배정하는 방식은 두 가지다.

- 파티션마다 큐(multiple queue): 들어갈 수 있는 가장 작은 파티션에 배정 → 메모리 공간 효율 중시
- 전체 공용 큐 하나(single queue): 들어갈 수 있는 사용 가능한 가장 작은 파티션에 배정 → 할당 시간 효율 중시
고정 분할은 구현이 쉽고 OS 오버헤드가 작지만, 내부 단편화로 메모리 이용이 비효율적이고 멀티프로그래밍 정도와 프로세스 크기가 제한된다. (예: IBM OS/360)
동적 분할 (Dynamic Partitioning)
고정 분할의 한계를 극복하기 위해 등장했다. 파티션을 런타임에 동적으로 생성하며, 길이와 개수가 가변적이다. 프로세스가 메모리를 요청하면 필요한 만큼 정확히 할당한다. (예: IBM OS/360 MVT)
내부 단편화는 사라지지만 새로운 문제가 생긴다.

프로세스가 적재·제거(swap out)되기를 반복하면, 메모리 곳곳에 작은 빈 공간이 흩어진다. 이것이 외부 단편화(external fragmentation)다. 전체 빈 공간을 합치면 충분한데도 연속된 블록이 없어 할당하지 못하는 상황이다. 해결책은 메모리 압축(memory compaction)으로, OS가 프로세스들을 한쪽으로 몰아 빈 공간을 하나의 큰 블록으로 합친다. 단, 이 작업은 CPU 시간을 소모한다.
배치 알고리즘
동적 분할에서는 여러 빈 블록 중 어디에 할당할지 정하는 배치 알고리즘(placement algorithm)이 필요하다.

| 알고리즘 | 동작 |
|---|---|
| First-fit | 메모리 맨 앞부터 스캔해 처음 만나는 충분한 블록에 할당 |
| Next-fit | 마지막으로 할당한 위치부터 스캔 |
| Best-fit | 요청 크기에 가장 가까운 블록을 선택 |
흥미롭게도 best-fit은 이름과 달리 보통 가장 나쁜 성능을 낸다. 요청에 딱 맞는 블록을 고르다 보니 쓸모없이 작은 자투리 블록을 양산해 외부 단편화를 악화시키기 때문이다.
버디 시스템 (Buddy System)
고정 분할과 동적 분할의 절충안이다. 메모리 블록은 $2^k$ 워드 크기로만 존재한다.
버디 할당(buddy allocation)의 핵심은 분할(split)과 병합(coalesce)이다. 크기 $s$인 요청이 $2^{U-1} < s \leq 2^U$를 만족하면 크기 $2^U$ 블록을 통째로 할당하고, 그렇지 않으면 블록을 크기 $2^{U-1}$인 두 개의 버디(buddy)로 쪼갠다. 블록이 반환되면 인접한 버디와 다시 병합한다.
예를 들어 7KB 블록을 요청하면, 64KB → 32KB → 16KB → 8KB로 계속 절반씩 쪼개 8KB 버디 하나를 할당한다.

위는 1MB 블록에서 여러 요청·반환이 일어나는 과정이다. 반환 시 인접 버디가 비어 있으면 병합해 더 큰 블록으로 되돌린다. 리눅스를 비롯한 UNIX 커널의 메모리 할당에 변형된 버디 시스템이 쓰인다.
페이징 (Paging)
여기서부터 비연속 할당이다. 페이징과 세그먼테이션의 공통 돌파구는 이것이다 — 프로세스를 여러 조각(페이지 또는 세그먼트)으로 쪼개고, 그 조각들이 메모리에 연속으로 있지 않아도 실행할 수 있게 한다.
페이징은 프로세스를 고정된 균등 크기 조각으로 나눈다.
- 페이지(page): 프로세스를 쪼갠 조각
- 프레임(frame): 물리 메모리를 페이지와 같은 크기로 쪼갠 조각
- 페이지 테이블(page table): 프로세스의 각 페이지가 어느 프레임에 있는지 기록. OS가 프로세스마다 관리한다.

위 그림에서 프로세스 A·C·D의 페이지들이 메모리 프레임에 흩어져 적재돼 있다. 연속될 필요가 없으므로 외부 단편화가 없다. 내부 단편화는 마지막 페이지의 일부에서만, 그것도 아주 조금 생긴다.
페이징 주소 변환
페이지 크기는 반드시 2의 거듭제곱이어야 한다. 그래야 논리 주소를 그냥 비트 단위로 잘라 페이지 번호와 오프셋으로 나눌 수 있다.

페이지 번호 $n$비트, 오프셋 $m$비트라고 하면 변환 단계는 이렇다.
- 논리 주소의 왼쪽 $n$비트를 페이지 번호로 추출한다.
- 페이지 번호를 페이지 테이블의 인덱스로 써서 프레임 번호 $k$를 찾는다.
- 프레임의 시작 물리 주소는 $k \times 2^m$이고, 참조하는 바이트의 물리 주소는 거기에 오프셋을 더한 값이다.
- 결국 프레임 번호 뒤에 오프셋을 그대로 이어 붙이면(append) 된다.
구체적인 예로, 프레임 크기가 1KB이고 프로세스 P1의 페이지 1이 프레임 12에 매핑돼 있다면, 논리 주소 $\langle 1, 222 \rangle$의 물리 주소는 다음과 같다.
\[12 \times 1024 + 222 = 12510\]세그먼테이션 (Segmentation)
세그먼테이션도 비연속 할당이지만, 프로세스를 가변 크기(unequal-size)의 세그먼트로 나눈다. 코드, 힙(heap), 스택, 데이터처럼 의미 단위로 쪼갠다는 점이 페이징과 다르다.
세그먼트 테이블(segment table)은 각 세그먼트의 limit(길이)와 base(시작 주소)를 담는다. 역시 OS가 프로세스마다 관리한다.

세그먼테이션은 동적 분할처럼 내부 단편화가 없지만, 가변 크기이므로 외부 단편화를 겪는다.
세그먼테이션의 강점은 메모리 영역의 의미를 보존한다는 데 있다. 페이징이 고정 크기 페이지 단위로 메모리를 관리하는 반면, 세그먼테이션은 논리적 프로그램 모듈 단위로 관리하므로 공유와 보호가 쉽다. 예를 들어 세그먼트마다 읽기·쓰기·실행 권한(permission)을 부여하고, 두 프로세스가 같은 코드 세그먼트를 같은 물리 주소로 공유하게 할 수 있다.
세그먼테이션 주소 변환

세그먼트 번호 $n$비트, 오프셋 $m$비트일 때 변환 단계는 이렇다.
- 논리 주소의 왼쪽 $n$비트를 세그먼트 번호로 추출한다.
- 세그먼트 번호로 세그먼트 테이블을 인덱싱해 세그먼트의 시작 물리 주소(base)를 찾는다.
- 오른쪽 $m$비트의 오프셋을 세그먼트 길이(limit)와 비교한다. 오프셋이 길이 이상이면 잘못된 주소(segment fault)다.
- 물리 주소는 세그먼트 시작 주소(base)에 오프셋을 더한 값이다.
페이징은 오프셋을 단순히 이어 붙였지만, 세그먼테이션은 base에 오프셋을 더하고 limit 검사를 거친다는 점이 다르다.
정리
| 기법 | 설명 | 강점 | 약점 |
|---|---|---|---|
| 고정 분할 | 시스템 생성 시점에 정적 파티션으로 분할 | 구현 단순, OS 오버헤드 작음 | 내부 단편화, 활성 프로세스 수 고정 |
| 동적 분할 | 프로세스 크기에 맞춰 동적 생성 | 내부 단편화 없음, 메모리 효율 ↑ | 외부 단편화, 압축에 CPU 소모 |
| 단순 페이징 | 균등 크기 프레임/페이지로 분할 | 외부 단편화 없음 | 약간의 내부 단편화 |
| 단순 세그먼테이션 | 가변 크기 세그먼트로 분할 | 내부 단편화 없음, 동적 분할보다 효율적 | 외부 단편화 |
연속 할당(고정·동적 분할)은 단편화 문제를 근본적으로 피하지 못한다. 비연속 할당인 페이징은 외부 단편화를, 세그먼테이션은 내부 단편화를 각각 제거한다. 둘의 장점을 합친 것이 현대 OS가 쓰는 세그먼테이션 기반 페이징이다.