안동민 개발노트

본문 시작

스와핑과 동적 메모리

메모리 부족 시 스와핑이 만드는 비용과 힙 할당기의 동작을 살펴보고 누수·댕글링 포인터·이중 해제를 진단합니다.

RAM은 유한합니다.

프로세스들이 실제로 필요로 하는 상주 메모리의 합이 가용 RAM을 압박하면 회수할 공간이 필요합니다. 프로세스 개수만으로 부족 여부를 판단하지는 않습니다.

이때 OS가 사용하는 기법이 스와핑(Swapping)입니다.

또한 프로그램 실행 중에 메모리를 동적으로 할당하고 해제하는 과정은 OS의 메모리 관리와 밀접히 연관되어 있으며, 여기서 발생하는 버그(메모리 누수, 댕글링 포인터)는 실무에서 가장 흔한 문제입니다.


스와핑의 원리

전통적인 전체 프로세스 스와핑은 현재 실행 중이지 않은 프로세스의 메모리를 통째로 디스크의 스왑 영역(Swap Space)으로 내보내고, 필요할 때 다시 메모리에 올리는 기법입니다.

  • 스왑 아웃(Swap Out): 프로세스를 메모리 → 디스크로 이동. 우선순위가 낮거나 오래 대기 중인 프로세스가 대상.
  • 스왑 인(Swap In): 프로세스를 디스크 → 메모리로 복원. 실행 시 바인딩이면 다른 물리 위치에 적재 가능.

스와핑의 비용

전통적인 디스크 스와핑에서는 전송 시간이 큰 비용입니다. 다음 접근 지연은 크기 차이를 설명하는 가정값이며 장치의 실측값이 아닙니다.

  • 메모리(DRAM) 접근: ~100ns
  • SSD 접근: ~100μs (메모리의 1,000배)
  • HDD 접근: ~10ms (메모리의 100,000배)

전송률이 100MB/s라고 가정하면 100MB의 순수 전송 시간은 100MB÷100MB/s=1100\text{MB} \div 100\text{MB/s} = 1초입니다. 탐색·대기 등 추가 비용은 제외한 계산입니다.

스왑 인까지 포함하면 2초.

이 동안 해당 프로세스는 완전히 멈춥니다.

SSD의 지연도 전송 크기와 장치·부하에 따라 달라집니다.

현대 시스템의 스와핑

현대 가상 메모리는 페이지 단위로 상주 여부를 관리합니다. 준비하는 방법은 페이지의 출처와 수정 여부에 따라 달라집니다.

페이지를 회수한 뒤 다시 준비하는 방법

파일, 스왑, 최초 접근은 페이지를 준비하는 출처가 다르다.

페이지를 회수한 뒤 다시 준비하는 방법
페이지의 상태RAM에서 회수할 때다시 접근하면
수정하지 않은 파일 페이지파일에 같은 내용이 있으므로 버릴 수 있음원래 파일에서 읽음
수정한 익명 페이지보존하려면 스왑 같은 저장 수단 필요스왑 내용 복원; 메모리 기반 스왑일 수도 있음
처음 접근하는 익명 영역아직 상주 페이지를 만들지 않았을 수 있음0으로 초기화한 페이지 등을 준비
수정하지 않은 파일 페이지
RAM에서 회수할 때: 파일에 같은 내용이 있으므로 버릴 수 있음
다시 접근하면: 원래 파일에서 읽음
수정한 익명 페이지
RAM에서 회수할 때: 보존하려면 스왑 같은 저장 수단 필요
다시 접근하면: 스왑 내용 복원; 메모리 기반 스왑일 수도 있음
처음 접근하는 익명 영역
RAM에서 회수할 때: 아직 상주 페이지를 만들지 않았을 수 있음
다시 접근하면: 0으로 초기화한 페이지 등을 준비

요구 페이징은 접근 시 필요한 페이지를 마련하는 원리입니다. 페이지 폴트가 항상 디스크 I/O나 전체 프로세스 교환을 뜻하지는 않습니다.

9장에서 자세히 다룹니다.

Linux에서 스왑 사용량을 확인하는 코드입니다. 주석의 값은 출력 형식 예시입니다.

swap_check.py
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 kB

Linux의 swappiness는 0~200 범위로 스왑 I/O와 파일 페이지 I/O의 상대 비용을 조정합니다.

  • 100: 두 I/O의 비용을 같게 봅니다.
  • 60: 커널 기본값입니다. 배포 환경의 실제 설정은 다를 수 있습니다.
  • 0: 여유 페이지와 파일 페이지가 zone의 높은 워터마크 아래로 줄 때까지 스왑 시작을 미룹니다. 스왑 비활성화나 OOM까지 무조건 대기한다는 뜻은 아닙니다.
  • 100 초과: zram 같은 메모리 기반 스왑이 파일 I/O보다 저렴한 환경에서 고려할 수 있습니다.

적절한 값은 워크로드와 저장장치 비용에 따라 측정해 정합니다. 데이터베이스라는 이유만으로 0이나 1이 정답인 것은 아닙니다.

반복 교체와 스래싱

필요한 작업집합에 비해 상주 공간이 부족해 페이지를 내보냈다가 곧 다시 읽는 일이 반복되면, 실행보다 교체에 많은 시간을 쓰는 스래싱(Thrashing)이 생길 수 있습니다.

  • available과 RSS 추이: 회수 가능한 여유와 프로세스의 상주량을 봅니다. RSS 증가만으로 누수를 확정하지는 않습니다.
  • 스왑 인·아웃과 major fault: 실제 교체가 반복되는지 봅니다. major fault에는 파일 읽기도 포함되므로 스왑만의 지표는 아닙니다.
  • I/O 대기와 응답 지연: 페이지 준비 비용이 실제 작업의 지연과 함께 늘어나는지 확인합니다.

한 번의 스왑 사용량이나 고정된 수치 임계값보다, 부하·작업집합·지표의 시간 변화를 함께 해석해야 합니다.


힙 메모리 관리

프로그램은 malloc/free(C) 또는 new/delete(C++)로 실행 중에 메모리를 할당하고 해제합니다.

이 동적 메모리는 힙(Heap) 영역에서 관리됩니다.

malloc의 내부 동작

malloc 호출마다 반드시 새 OS 메모리 요청이 발생하는 것은 아닙니다.

C 라이브러리의 메모리 할당자(Allocator)가 중간에서 관리합니다.

  1. 할당자는 필요에 따라 brk 또는 mmap 등으로 관리 공간을 확보합니다.
  2. 재사용할 공간이 있으면 크기·정렬에 맞는 블록을 찾아 반환합니다.
  3. free는 블록을 할당자에 돌려줍니다. 캐시·빈 목록에 남길 수도 있고, 큰 매핑처럼 OS에 바로 반환할 수도 있습니다.
  4. 공간이 부족하면 추가 확보를 시도하며, 실패하면 malloc은 NULL을 반환할 수 있습니다.
dynamic_memory.c
#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: 크기 클래스와 캐싱으로 단편화·병렬 할당을 관리하는 할당자. Jason Evans가 시작했으며 FreeBSD에 통합된 이력이 있습니다.
  • tcmalloc: Google의 할당자. 구성에 따라 CPU별 또는 스레드별 프런트엔드 캐시로 공유 경합을 줄입니다.
  • mimalloc: Microsoft의 범용 할당자. 크기별 페이지와 분산된 빈 목록을 사용합니다.

메모리 오류의 유형

다음 코드는 오류를 설명하는 조각입니다. error_occurred 같은 주변 정의와 성공한 할당을 전제하며, 오류를 실제로 실행해 얻은 로그가 아닙니다.

메모리 누수 (Memory Leak)

할당한 메모리를 해제하지 않아 점점 메모리 사용량이 늘어나는 현상입니다.

서버처럼 장시간 실행되는 프로그램에서 가장 위험합니다.

memory_leak.c
void process_request() {
    char *data = malloc(1024);
    /* ... 데이터 처리 ... */
    if (error_occurred) {
        return;  /* free 없이 반환 → 1KB 누수! */
    }
    free(data);
}
/* 이 함수가 초당 100번 호출되면, 에러율 1%일 때:
   100 * 0.01 * 1024 = 1024 bytes/sec ≈ 84.4MiB/day 누수 (성공한 할당 가정) */

에러 경로에서 free를 빠뜨리는 실수가 매우 흔합니다.

댕글링 포인터 (Dangling Pointer)

이미 해제된 메모리를 가리키는 포인터입니다.

이 포인터를 통해 해제된 객체에 접근하는 행위가 Use-After-Free입니다.

dangling_pointer.c
int *ptr = malloc(sizeof(int));
*ptr = 42;
free(ptr);            /* 메모리 해제 */
printf("%d\n", *ptr); /* 댕글링 포인터 접근! */
/* 정의되지 않은 동작: 출력값이나 SIGSEGV 여부를 보장하지 않음 */

이중 해제 (Double Free)

같은 할당을 두 번 해제하면 정의되지 않은 동작입니다. 할당자가 오류를 발견해 중단할 수도 있고 내부 상태 손상으로 이어질 수도 있습니다.

보안 취약점(exploit)으로 악용될 수 있습니다.

메모리 오류 탐지 도구

Valgrind는 C/C++ 프로그램의 메모리 오류를 탐지하는 도구입니다.

valgrind_usage.py
# 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)는 컴파일 시 계측을 넣고 런타임에 실행된 접근을 검사합니다. -fsanitize=address를 사용하면 지원 범위의 Use-After-Free, 버퍼 범위 초과, 이중 해제 등을 찾는 데 도움이 됩니다. 실행하지 않은 경로나 모든 정의되지 않은 동작을 증명하는 도구는 아닙니다.


가비지 컬렉션

C/C++의 수동 메모리 관리는 낮은 오버헤드와 세밀한 제어를 제공하지만 실수하기 쉽습니다.

Java, Python, Go, JavaScript 같은 언어는 가비지 컬렉터(Garbage Collector, GC)가 자동으로 사용하지 않는 메모리를 회수합니다.

가비지 컬렉션의 기본 원리는 도달 가능성(Reachability)입니다.

루트 집합(전역 변수, 스택 프레임의 지역 변수, CPU 레지스터)에서 참조 체인을 따라 도달할 수 있는 객체는 살아 있고, 도달할 수 없는 객체는 쓰레기입니다.

주요 GC 알고리즘

회수 판단과 수집 범위는 다른 선택

참조 카운팅과 추적 방식은 회수 판단이고 세대 구분은 수집 범위 전략이다.

회수 판단과 수집 범위는 다른 선택
전략판단 또는 구분 기준추가로 확인할 조건
참조 카운팅참조 수의 증감을 유지단독으로는 서로 참조하는 고립된 순환을 회수하지 못함
마크 앤 스윕루트에서 도달 가능한 객체를 추적순환도 판별 가능; 병행 처리와 정지 시간은 구현에 따름
세대별 수집객체의 나이로 수집 대상을 나눔추적 알고리즘 등과 결합하는 전략; 언어 이름만으로 구현을 고정하지 않음
참조 카운팅
판단 또는 구분 기준: 참조 수의 증감을 유지
추가로 확인할 조건: 단독으로는 서로 참조하는 고립된 순환을 회수하지 못함
마크 앤 스윕
판단 또는 구분 기준: 루트에서 도달 가능한 객체를 추적
추가로 확인할 조건: 순환도 판별 가능; 병행 처리와 정지 시간은 구현에 따름
세대별 수집
판단 또는 구분 기준: 객체의 나이로 수집 대상을 나눔
추가로 확인할 조건: 추적 알고리즘 등과 결합하는 전략; 언어 이름만으로 구현을 고정하지 않음

이 분류는 서로 배타적인 세 제품이 아닙니다. 예를 들어 CPython은 참조 카운팅에 순환 참조 수집을 함께 사용합니다.

단순한 참조 카운팅에서는 참조를 추가할 때 +1, 제거할 때 -1을 반영하고 0이 되면 회수합니다. 참조 갱신과 연쇄 해제에도 비용이 들며, 실제 회수 시점은 객체 종류와 구현에 영향을 받습니다.

세대별 수집은 "대부분의 객체가 생성 후 짧은 시간 안에 쓰이지 않게 된다"는 약한 세대 가설을 활용합니다. Young/Old를 나눠 젊은 객체를 자주 살피면 전체 힙 탐색을 줄일 수 있습니다. 여러 JVM 컬렉터가 이를 사용하지만, 정지 시간은 힙과 생존율에 따라 달라지고 Old 영역 수집이 항상 Full GC인 것도 아닙니다.

GC가 있어도 메모리 누수는 가능하다

GC가 불필요한 객체를 자동으로 수거하지만, 의도치 않게 참조를 유지하면 GC가 회수할 수 없습니다.

gc_leak.py
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의 안전한 코드에서는 소유권·빌림 규칙과 필요한 런타임 검사로 메모리 안전성을 지킵니다. unsafe 구현과 FFI의 계약도 올바르게 지켜야 합니다.

소유권(Ownership), 빌림(Borrowing), 수명(Lifetime) 규칙으로 댕글링 포인터, 이중 해제, 데이터 경쟁을 컴파일러가 잡아냅니다.

안전한 Rust에서도 참조 순환 등으로 메모리를 누수할 수 있습니다. 메모리 안전성과 모든 불필요한 할당의 회수는 별개의 성질입니다.

다음 장에서는 메모리 관리의 핵심인 가상 메모리와 페이징을 본격적으로 다루겠습니다.