가상 메모리
가상 메모리는 실제 물리 메모리보다 더 큰 주소 공간을 프로세스에 제공하는 기법입니다. 필요한 페이지만 물리 메모리에 올리고(Demand Paging), 부족하면 교체 알고리즘으로 어떤 페이지를 내보낼지 결정합니다.
핵심 개념
요구 페이징 (Demand Paging)
프로세스 실행 시 모든 페이지를 메모리에 올리지 않고, 실제 접근할 때만 적재합니다. 페이지 폴트(Page Fault)가 발생하면 OS가 디스크에서 해당 페이지를 읽어옵니다.
핵심 개념
FIFO (First-In, First-Out)
가장 먼저 적재된 페이지를 교체합니다. 구현이 단순하지만 프레임 수를 늘려도 폴트가 증가하는 Belady's Anomaly가 발생할 수 있습니다.
핵심 개념
LRU (Least Recently Used)
가장 오랫동안 사용되지 않은 페이지를 교체합니다. 최근 사용 기록을 기반으로 미래를 예측합니다. 구현 비용이 높지만 실용적으로 좋은 성능을 보입니다.
핵심 개념
Optimal (OPT)
앞으로 가장 오랫동안 사용되지 않을 페이지를 교체합니다. 미래를 알아야 하므로 실제 구현 불가능하지만 다른 알고리즘 성능 비교의 기준(가장 적은 폴트를 내는 이상적인 알고리즘)이 됩니다.
핵심 개념
스래싱 (Thrashing)
프로세스가 페이지 교체에만 시간을 쏟아 실제 작업을 못하는 현상입니다. 물리 메모리 프레임 수가 프로세스의 워킹셋(Working Set)보다 작을 때 발생합니다.
핵심 개념
워킹셋 (Working Set)
특정 시간 윈도우 내에 프로세스가 참조한 페이지 집합입니다. 워킹셋 크기만큼 프레임이 확보되면 폴트율이 급격히 낮아집니다. 지역성(Locality) 원리를 기반으로 합니다.
가상 메모리가 주는 것
가상 메모리는 프로세스마다 크고 연속된 자신만의 주소 공간을 주고, 실제로 쓰는 페이지만 물리 메모리에 올리는 기법입니다. 덕분에 프로세스끼리 서로의 메모리를 볼 수 없고, 물리 메모리보다 큰 프로그램도 실행할 수 있으며, 같은 물리 페이지를 여러 프로세스가 공유할 수도 있습니다. 주소 변환의 기본은 메모리 관리 토픽에서 다룹니다.
숫자로 확인하기
자주 하는 오해
면접에서 이렇게 물어요
면접 질문
가상 메모리가 왜 필요한가요?
프로세스마다 독립된 주소 공간으로 보호와 격리를 주고, 물리 메모리보다 큰 프로그램을 실행하게 하며, 필요한 페이지만 올려 메모리를 효율적으로 씁니다. 프로그램이 물리 메모리 배치를 신경 쓰지 않아도 됩니다.
면접 질문
페이지 폴트가 났을 때 어떤 일이 일어나나요?
MMU가 유효하지 않은 항목을 발견해 운영체제로 트랩합니다. 운영체제가 접근이 유효한지 확인하고, 빈 프레임을 찾거나 교체 알고리즘으로 희생 페이지를 정한 뒤 디스크에서 페이지를 읽어 페이지 테이블을 고치고 명령을 다시 실행합니다.
면접 질문
스래싱은 무엇이고 어떻게 해결하나요?
프로세스가 필요한 프레임을 얻지 못해 폴트와 교체만 반복하는 상태입니다. 워킹셋을 기준으로 프레임을 배분하고, 동시에 실행하는 프로세스 수를 줄이거나 메모리를 늘려 해결합니다.
면접 질문
LRU는 어떻게 구현하나요?
정확한 LRU는 접근마다 순서를 갱신해야 해서 비용이 큽니다. 그래서 참조 비트를 쓰는 Clock 같은 근사 알고리즘을 씁니다.
실제 시스템에서는
/proc/meminfo는 시스템 전체 메모리와 스왑 사용량을, /proc/PID/status의 VmSize와 VmRSS는 프로세스의 가상 크기와 실제 올라온 크기를 보여 줍니다(proc_meminfo(5), proc_pid_status(5) 매뉴얼).
vm.swappiness를 스왑 I/O와 파일시스템 페이징의 상대적 비용을 나타내는 값(0~200)으로 설명하며, 값에 따라 페이지 캐시와 스왑 대상 페이지에 가하는 회수 압박의 비중이 달라집니다.
함께 보면 좋은 토픽
참고 자료
C — LRU 페이지 교체 시뮬레이션
#include <stdio.h> #include <string.h> #include <limits.h> #define N_FRAMES 3 #define N_REFS 13 int refs[N_REFS] = {7,0,1,2,0,3,0,4,2,3,0,3,2}; int lru() { int frames[N_FRAMES]; int lastUsed[N_FRAMES]; // 각 프레임의 마지막 사용 시각 int faults = 0; memset(frames, -1, sizeof(frames)); memset(lastUsed, -1, sizeof(lastUsed)); for (int i = 0; i < N_REFS; i++) { int pg = refs[i]; int hit = 0; /* 1. Hit 확인 */ for (int f = 0; f < N_FRAMES; f++) { if (frames[f] == pg) { lastUsed[f] = i; // 사용 시각 갱신 hit = 1; break; } } if (!hit) { faults++; /* 2. 빈 프레임 먼저 */ int slot = -1; for (int f = 0; f < N_FRAMES; f++) { if (frames[f] == -1) { slot = f; break; } } /* 3. 빈 프레임 없으면 LRU 교체 */ if (slot == -1) { int minTime = INT_MAX; for (int f = 0; f < N_FRAMES; f++) { if (lastUsed[f] < minTime) { minTime = lastUsed[f]; slot = f; } } } frames[slot] = pg; lastUsed[slot] = i; } /* 상태 출력 */ printf("ref=%d [%s] frames: ", pg, hit ? "HIT " : "FAULT"); for (int f = 0; f < N_FRAMES; f++) { if (frames[f] >= 0) printf("%d ", frames[f]); else printf("- "); } printf("\n"); } return faults; } int main() { int f = lru(); printf("총 페이지 폴트: %d / %d\n", f, N_REFS); return 0; }
LRU는 각 프레임의 마지막 사용 시각(lastUsed)을 추적합니다. 교체 시 lastUsed 값이 가장 작은(가장 오래 전에 사용된) 프레임을 선택합니다.
실제 OS에서 완전한 LRU 구현은 모든 메모리 접근마다 타임스탬프를 갱신해야 해 하드웨어 지원 없이는 비용이 큽니다. Linux는 Clock 알고리즘(Second Chance)으로 LRU를 근사합니다.
참조 지역성(Locality of Reference)이 있는 프로그램에서 LRU는 FIFO 대비 폴트를 크게 줄입니다.
이 예제에서 FIFO는 10폴트, LRU는 9폴트, Optimal은 7폴트입니다.
Java — LinkedHashMap으로 LRU 캐시 구현
import java.util.LinkedHashMap; import java.util.Map; /** * LinkedHashMap의 accessOrder=true로 LRU 캐시 구현 * OS 페이지 교체의 사용자 공간 유사체 — HashMap보다 접근 순서 보장 */ public class LRUCache<K, V> extends LinkedHashMap<K, V> { private final int capacity; public LRUCache(int capacity) { // accessOrder=true: get/put 호출 시 해당 항목을 tail(최근)로 이동 super(capacity, 0.75f, true); this.capacity = capacity; } // 용량 초과 시 가장 오래된(head) 항목 제거 — 페이지 교체에 해당 @Override protected boolean removeEldestEntry(Map.Entry<K, V> eldest) { return size() > capacity; } public static void main(String[] args) { LRUCache<Integer, String> cache = new LRUCache<>(3); int[] refs = {7, 0, 1, 2, 0, 3, 0, 4, 2, 3, 0, 3, 2}; int faults = 0; for (int pg : refs) { boolean hit = cache.containsKey(pg); if (!hit) { faults++; cache.put(pg, "P" + pg); // 용량 초과 시 자동으로 LRU 제거 } else { cache.get(pg); // accessOrder=true: 접근 시 tail로 이동 } System.out.printf("ref=%d [%s] frames=%s%n", pg, hit ? "HIT " : "FAULT", cache.keySet()); } System.out.println("총 폴트: " + faults); } }
Java의 LinkedHashMap은 accessOrder=true 설정으로 LRU 순서를 자동 관리합니다. get()·put() 호출 시 해당 항목이 내부적으로 tail(가장 최근)로 이동합니다.
removeEldestEntry()를 오버라이드하면 용량 초과 시 head(가장 오래된, LRU 대상)가 자동 제거됩니다. 코드 몇 줄로 페이지 교체와 동일한 동작을 구현합니다.
실제로 Java의 LinkedHashMap 기반 LRU는 웹 서버 캐시, 데이터베이스 버퍼 풀, CDN 콘텐츠 캐시에 널리 사용됩니다.
시간 복잡도는 get/put 모두 O(1)입니다.
Q1
FIFO 페이지 교체 알고리즘의 방식은?
Q2
LRU 페이지 교체가 FIFO보다 일반적으로 성능이 좋은 이유는?
Q3
Optimal(최적) 페이지 교체 알고리즘이 실제 시스템에서 쓰이지 않는 이유는?