스와핑과 동적 메모리
RAM은 유한합니다. 16GB RAM에 수십 개의 프로세스가 동시에 실행되면 물리 메모리가 부족해집니다. 이때 OS가 사용하는 기법이 스와핑(Swapping)입니다. 또한 프로그램 실행 중에 메모리를 동적으로 할당하고 해제하는 과정은 OS의 메모리 관리와 밀접히 연관되어 있으며, 여기서 발생하는 버그(메모리 누수, 댕글링 포인터)는 실무에서 가장 흔한 문제입니다.
스와핑의 원리
스와핑은 현재 실행 중이지 않은 프로세스의 메모리를 통째로 디스크의 스왑 영역(Swap Space)으로 내보내고, 필요할 때 다시 메모리에 올리는 기법입니다.
- 스왑 아웃(Swap Out): 프로세스를 메모리 → 디스크로 이동. 우선순위가 낮거나 오래 대기 중인 프로세스가 대상.
- 스왑 인(Swap In): 프로세스를 디스크 → 메모리로 복원. 실행 시 바인딩이면 다른 물리 위치에 적재 가능.
스와핑의 비용
스와핑에서 가장 큰 비용은 디스크 전송 시간입니다.
- 메모리(DRAM) 접근: ~100ns
- SSD 접근: ~100μs (메모리의 1,000배)
- HDD 접근: ~10ms (메모리의 100,000배)
100MB 프로세스를 HDD에 스왑하면: 초. 스왑 인까지 포함하면 2초. 이 동안 해당 프로세스는 완전히 멈춥니다. SSD에서도 수백 밀리초가 걸립니다.
현대 시스템의 스와핑
현대 시스템은 프로세스 전체를 스왑하는 전통적 방식 대신, 페이지 단위로 필요한 부분만 디스크와 교환하는 요구 페이징(Demand Paging)을 사용합니다. 9장에서 자세히 다룹니다.
Linux에서 스왑 상태를 확인하고 설정하는 방법:
import subprocess
# 스왑 사용량 확인
result = subprocess.run(["free", "-h"], capture_output=True, text=True)
print(result.stdout)
# 출력 예:
# total used free shared buff/cache available
# Mem: 16Gi 8.0Gi 2.0Gi 500Mi 6.0Gi 7.0Gi
# Swap: 4.0Gi 500Mi 3.5Gi
# /proc/meminfo에서 상세 정보
with open("/proc/meminfo") as f:
for line in f:
if "Swap" in line:
print(line.strip())
# SwapTotal: 4194304 kB
# SwapFree: 3670016 kB
# SwapCached: 51200 kBswappiness 커널 매개변수(값 0~100)는 OS가 얼마나 적극적으로 스왑을 사용할지 결정합니다.
swappiness = 0: 가능한 한 스왑 사용 안 함 (OOM까지 버팀)swappiness = 60: 기본값. 적당히 스왑 사용swappiness = 100: 적극적으로 스왑 사용
데이터베이스 서버에서는 swappiness = 1 또는 0으로 설정하여 스왑을 최소화합니다. 디스크 I/O로 인한 지연이 쿼리 성능을 크게 떨어뜨리기 때문입니다.
힙 메모리 관리
프로그램은 malloc/free(C) 또는 new/delete(C++)로 실행 중에 메모리를 할당하고 해제합니다. 이 동적 메모리는 힙(Heap) 영역에서 관리됩니다.
malloc의 내부 동작
malloc은 OS에게 직접 메모리를 요청하지 않습니다. C 라이브러리의 메모리 할당자(Allocator)가 중간에서 관리합니다.
- 프로그램 시작 시 할당자가 OS에게 큰 메모리 블록을 요청합니다(
brk/sbrk또는mmap시스템 콜). malloc호출 시 할당자가 이 블록에서 적절한 크기를 잘라 반환합니다.free호출 시 할당자의 프리 리스트(Free List)에 반환합니다. OS에 즉시 돌려주지 않습니다.- 프리 리스트에 충분한 공간이 없으면 그때 OS에게 추가 메모리를 요청합니다.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main() {
/* 할당: 힙에서 100바이트 확보 */
char *buf = (char *)malloc(100);
if (buf == NULL) {
perror("malloc");
return 1;
}
strncpy(buf, "Hello, OS!", 100);
printf("%s\n", buf);
/* 해제: 할당자의 프리 리스트에 반환 */
free(buf);
buf = NULL; /* 댕글링 포인터 방지 — 중요! */
return 0;
}대표적인 메모리 할당자:
- ptmalloc2: glibc의 기본 할당자. 아레나(arena) 기반으로 멀티스레드 경합을 줄임.
- jemalloc: Facebook(Meta)에서 개발. 사이즈 클래스별 캐싱으로 단편화를 줄임. Firefox, Redis가 사용.
- tcmalloc: Google에서 개발. 스레드-로컬 캐시(Thread-local Cache)로 락 경합을 최소화.
- mimalloc: Microsoft에서 개발. 소형 객체에 특화.
메모리 오류의 유형
메모리 누수 (Memory Leak)
할당한 메모리를 해제하지 않아 점점 메모리 사용량이 늘어나는 현상입니다. 서버처럼 장시간 실행되는 프로그램에서 가장 위험합니다.
void process_request() {
char *data = malloc(1024);
/* ... 데이터 처리 ... */
if (error_occurred) {
return; /* free 없이 반환 → 1KB 누수! */
}
free(data);
}
/* 이 함수가 초당 100번 호출되면, 에러율 1%일 때:
100 * 0.01 * 1024 = 1024 bytes/sec ≈ 86MB/day 누수 */에러 경로에서 free를 빠뜨리는 실수가 매우 흔합니다.
댕글링 포인터 (Dangling Pointer)
이미 해제된 메모리를 가리키는 포인터입니다. Use-After-Free라고도 합니다.
int *ptr = malloc(sizeof(int));
*ptr = 42;
free(ptr); /* 메모리 해제 */
printf("%d\n", *ptr); /* 댕글링 포인터 접근! */
/* 운이 좋으면 42가 출력되고, 나쁘면 쓰레기 값이나 SIGSEGV */이중 해제 (Double Free)
같은 메모리를 두 번 해제하면 할당자의 내부 자료구조가 손상됩니다. 보안 취약점(exploit)으로 악용될 수 있습니다.
메모리 오류 탐지 도구
Valgrind는 C/C++ 프로그램의 메모리 오류를 탐지하는 도구입니다.
# Valgrind 사용법 (개념 설명용)
# $ valgrind --leak-check=full --show-leak-kinds=all ./program
#
# 출력 예:
# ==12345== LEAK SUMMARY:
# ==12345== definitely lost: 1,024 bytes in 1 blocks
# ==12345== indirectly lost: 0 bytes in 0 blocks
# ==12345== possibly lost: 0 bytes in 0 blocks
# ==12345== still reachable: 0 bytes in 0 blocks
# ==12345==
# ==12345== 1,024 bytes in 1 blocks are definitely lost in loss record 1
# ==12345== at 0x4C2AB80: malloc
# ==12345== by 0x400600: process_request (leak.c:3)AddressSanitizer(ASan)은 컴파일 타임 계측 도구로, Valgrind보다 빠릅니다. -fsanitize=address 옵션으로 컴파일하면 메모리 오류를 런타임에 즉시 잡아냅니다. Use-After-Free, 버퍼 오버플로우, 이중 해제를 모두 감지합니다.
가비지 컬렉션
C/C++의 수동 메모리 관리는 최고의 성능을 제공하지만 실수하기 쉽습니다. Java, Python, Go, JavaScript 같은 언어는 가비지 컬렉터(Garbage Collector, GC)가 자동으로 사용하지 않는 메모리를 회수합니다.
가비지 컬렉션의 기본 원리는 도달 가능성(Reachability)입니다. 루트 집합(전역 변수, 스택 프레임의 지역 변수, CPU 레지스터)에서 참조 체인을 따라 도달할 수 있는 객체는 살아 있고, 도달할 수 없는 객체는 쓰레기입니다.
주요 GC 알고리즘
참조 카운팅(Reference Counting): 각 객체가 참조 횟수를 유지합니다. 참조가 추가되면 +1, 제거되면 -1. 카운트가 0이 되면 즉시 해제합니다. Python(CPython)이 기본적으로 사용합니다. 장점은 즉시 해제로 메모리 지연이 적은 것. 단점은 순환 참조를 처리하지 못하는 것(A→B→A이면 둘 다 카운트가 0이 되지 않음). Python은 별도의 순환 참조 탐지기를 추가로 실행합니다.
마크 앤 스윕(Mark and Sweep): 루트에서부터 도달 가능한 객체를 모두 마크(Mark)하고, 마크되지 않은 객체를 쓸어냅니다(Sweep). 순환 참조도 처리 가능합니다. 단점은 GC 실행 중 프로그램이 일시 정지(Stop-the-World)하는 것.
세대별 GC(Generational GC): JVM의 핵심 GC 전략. "대부분의 객체는 생성 직후 짧은 시간 내에 쓰레기가 된다"는 약한 세대 가설(Weak Generational Hypothesis)에 기반합니다. 객체를 Young/Old 세대로 나누어, Young 세대를 자주 수집합니다. Young GC는 매우 빠르고(수 밀리초), Old GC(Full GC)는 드물지만 느립니다(수백 밀리초~수 초).
| 알고리즘 | 순환 참조 | STW 일시 정지 | 사용 언어 |
|---|---|---|---|
| 참조 카운팅 | 불가 | 없음 | Python, Swift |
| 마크 앤 스윕 | 가능 | 있음 | JavaScript(V8) |
| 세대별 | 가능 | 있음(최소화) | Java, C#, Go |
GC가 있어도 메모리 누수는 가능하다
GC가 불필요한 객체를 자동으로 수거하지만, 의도치 않게 참조를 유지하면 GC가 회수할 수 없습니다.
cache = {}
def process(key, data):
result = expensive_compute(data)
cache[key] = result # 캐시에 계속 추가만 함
return result
# cache 딕셔너리가 무한히 커짐 → 논리적 메모리 누수
# 해결: LRU 캐시 사용, 만료 시간 설정, weakref 사용정적 컬렉션에 계속 추가만 하고 제거하지 않으면 논리적 메모리 누수(Logical Memory Leak)가 발생합니다. Java에서는 WeakReference, Python에서는 weakref 모듈로 GC가 회수할 수 있는 참조를 만들 수 있습니다.
Rust의 접근: 소유권 시스템
Rust는 GC 없이 컴파일 타임에 메모리 안전성을 보장합니다. 소유권(Ownership) + 빌림(Borrowing) + 수명(Lifetime) 규칙으로 댕글링 포인터, 이중 해제, 데이터 경쟁을 컴파일러가 잡아냅니다. 런타임 오버헤드가 없으면서 메모리 안전성을 제공하는 혁신적 접근입니다.
다음 장에서는 메모리 관리의 핵심인 가상 메모리와 페이징을 본격적으로 다루겠습니다.