본문 바로가기
TYLER SONGBlog
블로그 목록
CS학습

Virtual Memory: 프로세스마다 독립된 주소 공간을 주는 추상화

Virtual Memory는 프로세스가 실제 물리 메모리 배치를 몰라도 되게 만드는 주소 변환 계층이다. 페이지 테이블과 MMU를 통해 가상 주소를 물리 주소로 매핑하며, 이 덕분에 프로세스 간 메모리 격리, 물리 메모리보다 큰 주소 공간 사용, 페이징 기반 스와핑이 가능해진다. 웹 서버나 컨테이너 환경에서 메모리 사용량을 이해하려면 이 매핑 구조를 알아야

송민성8분 읽기

1. 개념

Virtual Memory(가상 메모리)는 프로세스에게 실제 물리 메모리(Physical Memory)의 크기와 배치를 감춘 채, 독립적이고 연속적인 것처럼 보이는 주소 공간을 제공하는 메모리 관리 기법이다.

프로세스가 사용하는 주소는 가상 주소(Virtual Address)이고, CPU 안의 MMU(Memory Management Unit)가 이를 물리 주소(Physical Address)로 변환한다. 이 변환 규칙을 담은 자료구조가 페이지 테이블(Page Table)이다.

핵심은 "프로세스는 자신이 메모리 전체를 독점하고 있다고 착각한다"는 점이다. 실제로는 여러 프로세스가 물리 메모리를 나눠 쓰고, 필요하면 디스크까지 동원한다.

2. 왜 사용하는가

Virtual Memory가 없다면 세 가지 문제가 생긴다.

  • 메모리 격리 부재: 프로세스 A가 프로세스 B의 메모리 영역을 직접 가리키는 물리 주소를 알면 침범할 수 있다. 가상 주소 공간을 분리하면 프로세스는 자기 공간 밖을 참조할 방법 자체가 없다.
  • 물리 메모리 크기 제약: 물리 메모리가 8GB인 머신에서 각 프로세스가 최대 몇 GB까지 쓸 수 있는지 프로그래머가 신경 써야 한다. 가상 주소 공간(64비트 시스템 기준 이론상 2^64바이트)을 주면 이 제약이 사라진다.
  • 메모리 단편화 문제: 프로세스에 연속된 물리 메모리를 할당해야 한다면, 실행 중인 프로세스들 사이에 작은 빈 공간들이 흩어져 큰 덩어리를 못 만드는 외부 단편화(External Fragmentation)가 발생한다. 페이징으로 가상 주소는 연속되게 유지하면서 물리 메모리는 조각조각 할당할 수 있다.

3. 동작 원리

3.1 페이징(Paging)

가상 주소 공간과 물리 주소 공간을 모두 고정 크기 블록으로 나눈다. 가상 쪽 블록을 페이지(Page), 물리 쪽 블록을 프레임(Frame)이라 부른다. 리눅스/x86-64 기준 기본 페이지 크기는 4KB이다.

가상 주소는 보통 다음과 같이 나뉜다.

text
가상 주소 = [페이지 번호 (Page Number)] + [페이지 내 오프셋 (Offset)]

MMU는 페이지 번호를 페이지 테이블에서 조회해 대응하는 프레임 번호를 찾고, 오프셋은 그대로 유지한 채 물리 주소를 만든다.

3.2 페이지 테이블과 다단계 구조

프로세스마다 하나의 페이지 테이블을 가진다. 64비트 주소 공간을 단일 레벨 테이블로 만들면 항목 수가 너무 많아 메모리 낭비가 심하므로, x86-64는 4단계(때로는 5단계) 페이지 테이블을 쓴다. 각 레벨이 다음 레벨 테이블의 물리 주소를 가리키는 방식으로, 실제 매핑된 영역만 테이블 엔트리를 만들면 된다.

3.3 TLB(Translation Lookaside Buffer)

페이지 테이블 조회는 메모리 접근을 여러 번 거쳐야 해서 느리다. CPU는 최근 변환 결과를 TLB라는 캐시에 저장해 두고, 같은 페이지에 다시 접근할 때는 테이블을 거치지 않고 바로 물리 주소를 얻는다. TLB 미스(TLB Miss)가 나면 페이지 테이블을 직접 순회하는 페이지 테이블 워크(Page Table Walk)가 발생해 지연이 커진다.

3.4 페이지 폴트와 스와핑

가상 페이지에 대응하는 물리 프레임이 아직 없거나(디스크에서 로드 안 됨) 스왑 아웃(Swap Out)된 상태면 페이지 폴트(Page Fault)가 발생한다. 커널은 이 예외를 잡아서 디스크에서 해당 페이지를 읽어와 프레임에 채우고 페이지 테이블을 갱신한 뒤 실행을 재개한다. 물리 메모리가 부족하면 자주 안 쓰는 페이지를 디스크의 스왑 영역(Swap Space)으로 내보내는 스와핑(Swapping)이 일어난다.

3.5 Demand Paging과 Copy-on-Write

프로세스 실행 시 모든 페이지를 미리 로드하지 않고, 실제로 접근할 때 페이지 폴트를 통해 로드하는 방식을 지연 로딩(Demand Paging)이라 한다. fork()로 자식 프로세스를 만들 때도 부모의 페이지를 즉시 복사하지 않고, 쓰기가 일어나는 시점에만 복사하는 Copy-on-Write(CoW) 기법을 쓴다.

4. 코드 예제

페이지 테이블 변환을 단순화한 시뮬레이션이다. 4KB 페이지, 단일 레벨 테이블을 가정한다.

python
PAGE_SIZE = 4096 # 4KB OFFSET_BITS = 12 # 2^12 = 4096 class PageTable: def __init__(self): # 가상 페이지 번호 -> 물리 프레임 번호 self.table: dict[int, int] = {} self.next_free_frame = 0 def map_page(self, virtual_page: int, frame: int | None = None) -> int: if frame is None: frame = self.next_free_frame self.next_free_frame += 1 self.table[virtual_page] = frame return frame def translate(self, virtual_address: int) -> int: virtual_page = virtual_address >> OFFSET_BITS offset = virtual_address & (PAGE_SIZE - 1) if virtual_page not in self.table: raise PageFault(f"virtual page {virtual_page} not mapped") frame = self.table[virtual_page] physical_address = (frame << OFFSET_BITS) | offset return physical_address class PageFault(Exception): pass # 사용 예시 pt = PageTable() pt.map_page(virtual_page=0, frame=5) # 가상 페이지 0 -> 물리 프레임 5 pt.map_page(virtual_page=1, frame=2) # 가상 페이지 1 -> 물리 프레임 2 va = 0x0FFF # 가상 주소 0xFFF -> 페이지 0, 오프셋 0xFFF pa = pt.translate(va) print(f"가상 주소 0x{va:X} -> 물리 주소 0x{pa:X}") # 출력: 가상 주소 0xFFF -> 물리 주소 0x5FFF try: pt.translate(0x2000) # 매핑 안 된 페이지 -> 페이지 폴트 except PageFault as e: print(f"페이지 폴트 발생: {e}")

실제 리눅스 프로세스의 가상 메모리 매핑을 확인하는 방법이다.

shell
# 특정 프로세스의 가상 메모리 영역 확인 cat /proc/$(pgrep -n node)/maps # 프로세스의 가상 메모리 사용량 요약 (VSZ, RSS 비교) ps -o pid,vsz,rss,comm -p $(pgrep -n node) # VSZ: 가상 메모리 크기(가상 주소 공간에 예약된 양) # RSS: 실제 물리 메모리에 올라온 크기 # 시스템 전체 스와핑 발생 여부 확인 vmstat 1 5 # si/so 컬럼이 0이 아니면 스와핑이 실제로 일어나고 있다는 뜻

5. 시간 복잡도 또는 성능 특성

  • 주소 변환 자체: 다단계 페이지 테이블 조회는 레벨 수만큼의 메모리 접근이 필요하다. x86-64 4단계 구조에서는 최악의 경우 페이지 테이블 접근 4회 + 실제 데이터 접근 1회로 총 5회의 메모리 접근이 필요하다.
  • TLB 히트: TLB에 변환 결과가 있으면 O(1)에 가까운 추가 지연만 발생한다. 현대 CPU에서 TLB 히트 시 지연은 보통 1 CPU 사이클 수준으로 무시할 만하다.
  • TLB 미스: 페이지 테이블 워크가 필요해 수십~수백 사이클의 추가 지연이 발생할 수 있다. 정확한 사이클 수는 CPU 마이크로아키텍처와 캐시 상태에 따라 다르므로 일반화하기 어렵다.
  • 페이지 폴트(Major Fault): 디스크 I/O가 동반되므로 밀리초 단위 지연이 발생한다. 이는 메모리 접근(나노초 단위)보다 수만 배 이상 느리다.
  • 페이지 폴트(Minor Fault): 디스크 접근 없이 커널 내부에서 매핑만 갱신하는 경우로, 마이크로초 단위로 상대적으로 빠르다.

6. 실무 사용 사례

  • 컨테이너 메모리 제한: Docker의 --memory 옵션은 cgroup을 통해 컨테이너가 사용할 수 있는 물리 메모리(RSS) 상한을 건다. 컨테이너 안 프로세스는 여전히 큰 가상 주소 공간을 볼 수 있지만, 실제 물리 프레임 할당이 제한을 넘으면 OOM Killer가 개입한다.
  • Node.js 힙 메모리 관측: process.memoryUsage()가 반환하는 rss는 물리 메모리 사용량, heapTotal/heapUsed는 V8이 관리하는 가상 주소 공간 내 논리적 힙 크기다. 이 둘의 의미 차이를 모르면 메모리 누수 진단을 잘못한다.
  • mmap 기반 파일 I/O: 대용량 파일을 읽을 때 mmap()으로 파일을 가상 주소 공간에 매핑하면, 실제 파일 내용은 접근하는 시점에 Demand Paging으로 로드된다. 파일 전체를 미리 읽지 않아도 되므로 초기 로딩 비용이 줄어든다.
  • 스와핑과 서비스 지연: 물리 메모리가 부족한 서버에서 스와핑이 발생하면 디스크 I/O가 급증해 응답 지연이 커진다. 프로덕션 환경에서 스왑을 아예 비활성화(swapoff)하고 대신 메모리 부족 시 컨테이너를 재시작하는 전략을 쓰는 경우도 많다.

7. 주의할 점

  • 가상 메모리 크기와 실제 메모리 사용량을 혼동하지 않는다. ps의 VSZ 값이 크다고 해서 실제 물리 메모리를 그만큼 쓰는 것은 아니다. 실제 상주 크기는 RSS를 봐야 한다.
  • 스와핑이 항상 나쁜 것은 아니지만, 지속적인 스와핑(Thrashing)은 시스템 전체 성능을 붕괴시킨다. 스와핑이 빈번하면 CPU가 실제 연산보다 페이지 교체에 더 많은 시간을 쓰게 된다.
  • TLB 미스 비용을 무시하면 안 된다. 메모리 접근 패턴이 무작위적이면(예: 큰 해시 테이블을 임의 순서로 순회) TLB 미스가 잦아져 캐시 친화적인 순차 접근보다 훨씬 느려질 수 있다. 이는 대용량 데이터 처리 코드의 성능 튜닝에서 실제로 고려되는 요소다.
  • Copy-on-Write와 fork()를 함께 쓸 때 메모리 사용량 착시에 주의한다. fork() 직후에는 부모와 자식이 물리 메모리를 공유하므로 RSS 합계가 실제 사용량보다 부풀어 보일 수 있다.

8. 핵심 정리

Virtual Memory는 프로세스에게 독립된 주소 공간이라는 착각을 만들어주는 계층이며, 그 실체는 페이지 테이블을 통한 가상 주소-물리 주소 매핑이다. 페이징으로 메모리 격리와 유연한 할당을 얻지만, 그 대가로 주소 변환 비용(TLB 미스, 페이지 테이블 워크)과 페이지 폴트에 따른 지연이 발생한다. 실무에서는 VSZ와 RSS의 차이, 스와핑 발생 여부, mmap 기반 I/O 특성을 이해하는 것이 메모리 관련 성능 문제를 진단하는 출발점이 된다.

© 2026 Tyler Song