Paging: 프로세스 메모리를 고정 크기 페이지로 나눠 관리하는 가상 메모리 기법
페이징은 프로세스의 논리 주소 공간을 고정 크기 페이지(page)로 나누고, 물리 메모리를 동일한 크기의 프레임(frame)으로 나눠 매핑하는 메모리 관리 기법이다. 외부 단편화를 완전히 제거하고 프로세스별로 독립된 주소 공간을 제공하지만, 페이지 테이블 관리 오버헤드와 내부 단편화라는 대가를 치른다. TLB(Translation Lookaside Buff
1. 개념
페이징(Paging)은 프로세스의 논리 주소 공간(logical address space)을 페이지(page)라는 고정 크기 단위로 나누고, 물리 메모리(physical memory)를 동일한 크기의 프레임(frame)으로 나눠서 페이지를 프레임에 매핑하는 메모리 관리 기법이다.
핵심은 프로세스 입장에서는 연속된 메모리처럼 보이는 논리 주소를, 실제 물리 메모리에서는 여기저기 흩어진 프레임에 배치할 수 있다는 점이다. 이 매핑 정보는 페이지 테이블(page table)이라는 자료구조에 저장된다.
2. 왜 사용하는가
세그멘테이션(segmentation)이나 가변 크기 파티션 방식은 외부 단편화(external fragmentation) 문제를 피할 수 없다. 프로세스가 종료되고 새로 할당되는 과정이 반복되면 물리 메모리에 사용 가능한 공간은 있지만 연속된 블록이 아니라서 할당하지 못하는 상황이 생긴다.
페이징은 고정 크기 단위로 메모리를 관리하기 때문에 프로세스를 물리 메모리 어디에나 조각조각 배치할 수 있다. 그 결과 외부 단편화가 원천적으로 사라진다. 대신 마지막 페이지가 다 차지 않아서 낭비되는 내부 단편화(internal fragmentation)는 남는다.
추가로 페이징은 다음을 가능하게 한다.
- 프로세스별 독립된 주소 공간 제공 (메모리 보호)
- 물리 메모리보다 큰 프로세스 실행 (가상 메모리, 스와핑)
- 페이지 단위 공유 메모리 (예: 공유 라이브러리)
3. 동작 원리
논리 주소는 페이지 번호(page number)와 페이지 오프셋(offset)으로 구성된다.
논리 주소 = (페이지 번호 p, 오프셋 d)
물리 주소 = (프레임 번호 f, 오프셋 d)CPU가 논리 주소를 생성하면 MMU(Memory Management Unit)가 페이지 테이블을 참조해서 페이지 번호를 프레임 번호로 변환한다. 오프셋은 그대로 유지된다.
페이지 크기가 4KB(2^12바이트)라면 하위 12비트가 오프셋이고, 나머지 상위 비트가 페이지 번호다.
주소 변환 과정
- 논리 주소에서 페이지 번호 p와 오프셋 d 추출
- 페이지 테이블에서 p에 해당하는 프레임 번호 f 조회
- 물리 주소 = f * 페이지크기 + d
TLB(Translation Lookaside Buffer)
페이지 테이블은 메모리에 있기 때문에 매번 참조하면 메모리 접근이 두 번(테이블 조회 + 실제 데이터 접근) 필요하다. TLB는 최근 사용한 페이지-프레임 매핑을 캐싱하는 작은 연관 메모리(associative memory)로, TLB 히트 시 메모리 접근 한 번으로 끝낸다.
다단계 페이지 테이블 (Multi-level Page Table)
64비트 시스템에서 단일 페이지 테이블은 항목 수가 너무 많아 그 자체로 메모리를 많이 차지한다. 이를 해결하기 위해 페이지 번호를 여러 단계로 쪼개서 트리 구조로 관리한다. x86-64는 보통 4단계(PML4, PDPT, PD, PT)를 사용한다.
4. 코드 예제
실제 하드웨어 MMU 동작을 흉내 낸 단순 페이징 시뮬레이터다.
class PageTable:
def __init__(self, page_size: int):
self.page_size = page_size
self.table: dict[int, int] = {} # page_number -> frame_number
def map_page(self, page_number: int, frame_number: int) -> None:
self.table[page_number] = frame_number
def translate(self, logical_address: int) -> int:
page_number = logical_address // self.page_size
offset = logical_address % self.page_size
if page_number not in self.table:
raise PageFaultError(f"페이지 {page_number}가 페이지 테이블에 없음 (Page Fault)")
frame_number = self.table[page_number]
physical_address = frame_number * self.page_size + offset
return physical_address
class PageFaultError(Exception):
pass
class TLB:
"""직접 사상(direct-mapped) 방식이 아닌 완전 연관(fully associative) 방식의 단순 TLB"""
def __init__(self, capacity: int = 4):
self.capacity = capacity
self.entries: dict[int, int] = {} # page_number -> frame_number
self.order: list[int] = [] # LRU 추적용
def lookup(self, page_number: int) -> int | None:
if page_number in self.entries:
self.order.remove(page_number)
self.order.append(page_number)
return self.entries[page_number]
return None
def insert(self, page_number: int, frame_number: int) -> None:
if page_number in self.entries:
self.order.remove(page_number)
elif len(self.entries) >= self.capacity:
oldest = self.order.pop(0)
del self.entries[oldest]
self.entries[page_number] = frame_number
self.order.append(page_number)
class MMU:
def __init__(self, page_table: PageTable, tlb: TLB):
self.page_table = page_table
self.tlb = tlb
self.tlb_hits = 0
self.tlb_misses = 0
def translate(self, logical_address: int) -> int:
page_number = logical_address // self.page_table.page_size
offset = logical_address % self.page_table.page_size
frame_number = self.tlb.lookup(page_number)
if frame_number is not None:
self.tlb_hits += 1
else:
self.tlb_misses += 1
if page_number not in self.page_table.table:
raise PageFaultError(f"페이지 {page_number} 접근 시 Page Fault 발생")
frame_number = self.page_table.table[page_number]
self.tlb.insert(page_number, frame_number)
return frame_number * self.page_table.page_size + offset
# 사용 예시: 페이지 크기 4096바이트(4KB)
page_size = 4096
pt = PageTable(page_size)
pt.map_page(0, 5) # 논리 페이지 0 -> 물리 프레임 5
pt.map_page(1, 2) # 논리 페이지 1 -> 물리 프레임 2
pt.map_page(2, 9) # 논리 페이지 2 -> 물리 프레임 9
mmu = MMU(pt, TLB(capacity=2))
addresses = [100, 4200, 8300, 150, 4250] # 논리 주소들
for addr in addresses:
physical = mmu.translate(addr)
print(f"논리 주소 {addr:>6} -> 물리 주소 {physical:>6}")
print(f"\nTLB 히트: {mmu.tlb_hits}, TLB 미스: {mmu.tlb_misses}")실행하면 다음과 유사한 출력을 얻는다.
논리 주소 100 -> 물리 주소 20580
논리 주소 4200 -> 물리 주소 8296
논리 주소 8300 -> 물리 주소 36972
논리 주소 150 -> 물리 주소 20630
논리 주소 4250 -> 물리 주소 8346
TLB 히트: 2, TLB 미스: 35. 시간 복잡도 또는 성능 특성
- 페이지 테이블 조회(단일 레벨): O(1) — 배열/해시 기반 인덱싱
- 다단계 페이지 테이블 조회: O(k), k는 레벨 수 (x86-64에서 보통 k=4)
- TLB 히트 시 주소 변환: 메모리 접근 1회 (수 나노초 수준, TLB 자체는 하드웨어 캐시)
- TLB 미스 시: 페이지 테이블을 메모리에서 읽어야 하므로 메모리 접근이 추가로 발생 (다단계면 레벨 수만큼 추가 접근)
- 페이지 폴트(page fault) 발생 시: 디스크 I/O가 필요하면 수 밀리초 단위로 지연 (메모리 접근보다 수만~수십만 배 느림)
실무에서 TLB 히트율은 보통 95% 이상을 목표로 하며, 히트율이 낮으면 애플리케이션 성능에 직접적인 영향을 준다.
6. 실무 사용 사례
- 리눅스/윈도우 가상 메모리 시스템: 모든 프로세스가 페이징 기반 가상 주소 공간을 사용한다.
/proc/[pid]/maps로 프로세스의 가상 메모리 매핑을 확인할 수 있다. - Huge Page (대형 페이지): 기본 4KB 페이지 대신 2MB, 1GB 단위의 큰 페이지를 사용해 TLB 미스를 줄인다. 데이터베이스나 JVM 같은 대용량 메모리 애플리케이션에서 성능 향상을 위해 사용한다.
- Copy-on-Write (COW):
fork()시 부모와 자식 프로세스가 같은 페이지를 공유하다가 쓰기가 발생할 때만 실제로 복사한다. 페이지 단위 매핑이 있기에 가능한 최적화다. - 메모리 매핑 파일(mmap): 파일을 프로세스 주소 공간에 페이지 단위로 매핑해서 파일 I/O를 메모리 접근처럼 다룬다.
# 리눅스에서 현재 시스템의 페이지 크기 확인
getconf PAGESIZE
# Huge Page 설정 확인
cat /proc/meminfo | grep -i huge7. 주의할 점
- 페이지 크기를 너무 작게 잡으면 페이지 테이블 엔트리 수가 늘어나 테이블 자체가 메모리를 많이 차지한다. 반대로 너무 크게 잡으면 내부 단편화가 심해진다.
- TLB 미스가 빈번하게 발생하는 워크로드(대용량 랜덤 접근 패턴)는 성능이 크게 저하될 수 있다. 이런 경우 Huge Page 적용을 검토한다.
- 페이지 폴트가 너무 자주 발생하면(스래싱, thrashing) 디스크 I/O 부하로 시스템 전체 성능이 급격히 떨어진다. 물리 메모리 크기 대비 워킹 셋(working set)이 지나치게 크면 발생한다.
- 다단계 페이지 테이블은 조회 단계가 늘어나는 대신 메모리 사용량을 줄인다. 이 트레이드오프를 이해하지 않고 "페이지 테이블 조회는 O(1)"이라고 단순화하면 안 된다.
8. 핵심 정리
페이징은 논리 주소를 고정 크기 페이지로, 물리 주소를 동일 크기 프레임으로 나눠 매핑함으로써 외부 단편화를 없애고 프로세스별 독립 주소 공간을 제공하는 메모리 관리 기법이다. 페이지 테이블이 매핑 정보를 담당하고, TLB가 이 조회를 캐싱해서 성능 저하를 최소화한다. 실무에서는 페이지 크기 선택, TLB 히트율, Huge Page 적용 여부가 성능에 직접적인 영향을 미치므로 애플리케이션 특성에 맞춰 튜닝이 필요하다.