스와핑과 동적 메모리
물리 메모리가 모자랄 때 메모리의 내용을 디스크로 내보내는 스와핑과 오늘날의 페이지 회수를 살펴보고, swappiness와 스래싱을 진단하는 방법을 정리합니다. malloc이 운영체제에서 메모리를 받아 나누어 주는 방식, 메모리 누수와 해제 뒤 사용과 이중 해제 같은 오류와 찾는 도구, 가비지 컬렉션의 원리와 그 아래서도 생기는 누수를 예제로 확인합니다.
물리 메모리는 한정되어 있습니다. 실행 중인 프로세스들이 실제로 쓰는 메모리를 모두 합친 양이 RAM을 넘어서려 하면, 운영체제는 어딘가에서 공간을 마련해야 합니다. 이때 쓰는 방법이 메모리의 내용을 잠시 디스크로 내보내는 스와핑(swapping)입니다. 실행 중인 프로세스의 수가 아니라, 그 프로세스들이 실제로 메모리에 올려 두어야 하는 양이 문제라는 점을 기억해 두세요.
또 프로그램은 실행 중에 메모리를 할당하고 해제하는데, 이 동적 메모리 관리는 운영체제의 메모리 관리와 맞닿아 있습니다. 여기서 생기는 메모리 누수나 해제한 메모리 사용 같은 버그는 실무에서 가장 흔한 문제에 속합니다. 이 글에서는 스와핑과 페이지 회수, 프로그램의 힙 관리, 메모리 오류와 가비지 컬렉션을 차례로 살펴봅니다.
스와핑: 메모리의 내용을 디스크로 내보내기
전통적인 스와핑은 지금 실행되지 않는 프로세스의 메모리를 통째로 디스크의 스왑 영역(swap space)에 내보내고(swap out), 다시 실행할 때 메모리로 들여오는(swap in) 방식입니다. 우선순위가 낮거나 오래 기다린 프로세스를 내보내면, 그 자리에 다른 프로세스를 올릴 수 있습니다. 8장 1편의 실행할 때 바인딩을 쓰는 시스템이라면, 다시 들여올 때 원래와 다른 물리 위치에 놓아도 됩니다.
문제는 비용입니다. 메모리 접근이 약 100ns라면 SSD는 수십에서 수백 μs, 하드 디스크는 약 10ms가 걸려, 메모리보다 수백에서 십만 배 느립니다. 프로세스 전체를 옮기려면 전송 시간도 큽니다. 디스크가 1초에 100MB를 옮길 수 있다고 가정하면, 100MB짜리 프로세스를 내보내는 데만 1초, 다시 들여오는 데도 1초가 걸리며, 그동안 그 프로세스는 아무 일도 하지 못합니다. 실제로는 디스크의 탐색 시간과 다른 입출력과의 경쟁까지 더해집니다.
오늘날의 페이지 회수
그래서 오늘날의 운영체제는 프로세스 전체를 옮기지 않고, 9장에서 다룰 페이지 단위로 메모리를 관리합니다. 메모리가 모자라면 당분간 쓰이지 않을 것 같은 페이지를 골라 그 물리 메모리를 회수하고, 다시 필요해지면 그 페이지만 준비합니다. 이때 페이지를 어떻게 회수하고 다시 준비할지는 페이지의 내용이 어디서 왔고 바뀌었는지에 따라 다릅니다.
페이지를 회수한 뒤 다시 준비하는 방법
수정한 익명 내용 7을 잃지 않아야 한다. 매핑이 유지된 정상 경로에서 내용을 복원한다. 각 단계의 값과 전환 이유를 비교합니다.
요구 페이징 · 내용의 출처
파일·스왑·최초 익명 접근은 페이지 내용을 준비하는 출처가 다릅니다
첫 두 행은 상주 페이지를 회수한 뒤 다시 접근하는 경우입니다. 마지막 행은 아직 상주 페이지를 만들지 않은 최초 접근으로, 회수한 페이지의 복원이 아닙니다.
| 페이지의 상태 | RAM에서 회수할 때 | 다시 접근하면 |
|---|---|---|
| 수정하지 않은 파일 페이지 | 파일에 같은 내용이 있으므로 버릴 수 있음 | 원래 파일에서 읽음 |
| 수정한 익명 페이지 | 보존하려면 스왑 같은 저장 수단 필요 | 스왑 내용 복원; 메모리 기반 스왑일 수도 있음 |
| 처음 접근하는 익명 영역 | 아직 상주 페이지를 만들지 않았을 수 있음 | 0으로 초기화한 페이지 등을 준비 |
- 수정하지 않은 파일 페이지
- RAM에서 회수할 때: 파일에 같은 내용이 있으므로 버릴 수 있음다시 접근하면: 원래 파일에서 읽음
- 수정한 익명 페이지
- RAM에서 회수할 때: 보존하려면 스왑 같은 저장 수단 필요다시 접근하면: 스왑 내용 복원; 메모리 기반 스왑일 수도 있음
- 처음 접근하는 익명 영역
- RAM에서 회수할 때: 아직 상주 페이지를 만들지 않았을 수 있음다시 접근하면: 0으로 초기화한 페이지 등을 준비
요구 페이징은 접근 시 필요한 페이지를 마련하는 원리입니다. 페이지 폴트가 항상 디스크 I/O나 전체 프로세스 교환을 뜻하지는 않습니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 회수 전 보존부터 재접근 준비까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 관계 설명 | 첫 영역은 파일 F에서 RAM F를 다시 준비한다. 두 번째 영역은 RAM 7을 스왑에 보존한 뒤 스왑 7에서 RAM 7을 복원한다. 세 번째 영역은 이전 RAM 내용 없이 첫 접근에 0을 준비한다. 화살표는 내용의 보존 및 준비이고 실제 시간 측정이나 모두 디스크 I/O라는 뜻이 아니다. |
| 표의 값 | 수정하지 않은 파일 페이지. RAM에서 회수할 때: 파일에 같은 내용이 있으므로 버릴 수 있음. 다시 접근하면: 원래 파일에서 읽음. 수정한 익명 페이지. RAM에서 회수할 때: 보존하려면 스왑 같은 저장 수단 필요. 다시 접근하면: 스왑 내용 복원; 메모리 기반 스왑일 수도 있음. 처음 접근하는 익명 영역. RAM에서 회수할 때: 아직 상주 페이지를 만들지 않았을 수 있음. 다시 접근하면: 0으로 초기화한 페이지 등을 준비. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 회수 전 보존 수정한 익명 내용 7을 잃지 않아야 한다. |
|
|
| RAM 회수 내용의 출처가 남은 상태에서 상주 페이지를 회수한다. |
|
|
| 재접근 준비 매핑이 유지된 정상 경로에서 내용을 복원한다. |
|
|
페이지를 회수한 뒤 다시 준비하는 방법
수정한 익명 내용 7을 잃지 않아야 한다. 매핑이 유지된 정상 경로에서 내용을 복원한다. 각 단계의 값과 전환 이유를 비교합니다.
요구 페이징 · 내용의 출처
파일·스왑·최초 익명 접근은 페이지 내용을 준비하는 출처가 다릅니다
첫 두 행은 상주 페이지를 회수한 뒤 다시 접근하는 경우입니다. 마지막 행은 아직 상주 페이지를 만들지 않은 최초 접근으로, 회수한 페이지의 복원이 아닙니다.
| 페이지의 상태 | RAM에서 회수할 때 | 다시 접근하면 |
|---|---|---|
| 수정하지 않은 파일 페이지 | 파일에 같은 내용이 있으므로 버릴 수 있음 | 원래 파일에서 읽음 |
| 수정한 익명 페이지 | 보존하려면 스왑 같은 저장 수단 필요 | 스왑 내용 복원; 메모리 기반 스왑일 수도 있음 |
| 처음 접근하는 익명 영역 | 아직 상주 페이지를 만들지 않았을 수 있음 | 0으로 초기화한 페이지 등을 준비 |
- 수정하지 않은 파일 페이지
- RAM에서 회수할 때: 파일에 같은 내용이 있으므로 버릴 수 있음다시 접근하면: 원래 파일에서 읽음
- 수정한 익명 페이지
- RAM에서 회수할 때: 보존하려면 스왑 같은 저장 수단 필요다시 접근하면: 스왑 내용 복원; 메모리 기반 스왑일 수도 있음
- 처음 접근하는 익명 영역
- RAM에서 회수할 때: 아직 상주 페이지를 만들지 않았을 수 있음다시 접근하면: 0으로 초기화한 페이지 등을 준비
요구 페이징은 접근 시 필요한 페이지를 마련하는 원리입니다. 페이지 폴트가 항상 디스크 I/O나 전체 프로세스 교환을 뜻하지는 않습니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 회수 전 보존부터 재접근 준비까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 관계 설명 | 첫 영역은 파일 F에서 RAM F를 다시 준비한다. 두 번째 영역은 RAM 7을 스왑에 보존한 뒤 스왑 7에서 RAM 7을 복원한다. 세 번째 영역은 이전 RAM 내용 없이 첫 접근에 0을 준비한다. 화살표는 내용의 보존 및 준비이고 실제 시간 측정이나 모두 디스크 I/O라는 뜻이 아니다. |
| 표의 값 | 수정하지 않은 파일 페이지. RAM에서 회수할 때: 파일에 같은 내용이 있으므로 버릴 수 있음. 다시 접근하면: 원래 파일에서 읽음. 수정한 익명 페이지. RAM에서 회수할 때: 보존하려면 스왑 같은 저장 수단 필요. 다시 접근하면: 스왑 내용 복원; 메모리 기반 스왑일 수도 있음. 처음 접근하는 익명 영역. RAM에서 회수할 때: 아직 상주 페이지를 만들지 않았을 수 있음. 다시 접근하면: 0으로 초기화한 페이지 등을 준비. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 회수 전 보존 수정한 익명 내용 7을 잃지 않아야 한다. |
|
|
| RAM 회수 내용의 출처가 남은 상태에서 상주 페이지를 회수한다. |
|
|
| 재접근 준비 매핑이 유지된 정상 경로에서 내용을 복원한다. |
|
|
단계 개념도는 프로그램이 값 7을 써 넣은 익명 페이지를 따라갑니다. 익명 페이지(anonymous page)는 힙이나 스택처럼 파일과 연결되지 않은 메모리입니다. 이 페이지의 내용은 RAM에만 있으므로, 물리 메모리를 회수하기 전에 반드시 스왑 영역에 7을 저장해 두어야 합니다. 저장이 끝난 뒤에야 물리 프레임을 돌려받고, 나중에 프로그램이 이 페이지에 다시 접근하면 스왑에서 7을 읽어 와 새 프레임에 놓습니다.
모든 페이지가 이렇게 스왑을 거치는 것은 아닙니다. 실행 파일의 코드처럼 파일에서 읽어 와 바꾸지 않은 페이지는, 파일에 같은 내용 F가 남아 있으므로 저장하지 않고 그냥 버렸다가 필요할 때 파일에서 다시 읽으면 됩니다. 한 번도 접근하지 않은 익명 페이지는 처음 접근할 때 0으로 채운 프레임을 주면 되므로 디스크에서 읽을 것이 없습니다. 그래서 디스크에서 페이지를 읽어 오는 메이저 폴트(major fault)가 늘었다고 해서 모두 스왑 때문이라고 단정할 수 없습니다. 파일을 읽는 것도 메이저 폴트를 만들기 때문입니다.
스왑 사용량 확인하기
Linux에서는 다음 명령으로 메모리와 스왑의 상태를 볼 수 있습니다. free -h의 -h는 바이트 수를 MiB나 GiB 같은 읽기 쉬운 단위로 바꿔 보여 주고, /proc/meminfo의 값은 kB 단위로 적혀 있습니다. 그 가운데 SwapCached는 스왑에서 다시 읽어 왔지만 스왑 영역에도 그대로 남아 있는 페이지의 양으로, 이런 페이지는 다시 내보낼 때 디스크에 쓰지 않아도 됩니다.
free -h # 전체 메모리와 스왑의 사용량
grep -i swap /proc/meminfo # SwapTotal, SwapFree, SwapCached
cat /proc/sys/vm/swappiness # 지금의 swappiness 값
vmstat 1 # si, so 열: 초당 스왑에서 들여온 양과 내보낸 양free의 출력에서 available 열은 새 프로그램이 스왑 없이 쓸 수 있는 메모리의 추정치로, 페이지 캐시처럼 회수할 수 있는 메모리까지 포함합니다. free 열만 보고 메모리가 모자라다고 판단하면 안 되는 이유입니다. Linux는 남는 메모리를 파일의 페이지 캐시로 적극적으로 쓰기 때문에 free는 대개 작게 보입니다. vmstat의 si와 so는 지금 실제로 스왑이 일어나고 있는지 보여 주므로, 스왑 영역을 얼마나 쓰고 있는지보다 더 직접적인 정보입니다. 예전에 내보낸 페이지가 스왑에 남아 있을 뿐 지금은 아무 일도 없을 수 있기 때문입니다.
swappiness는 메모리를 회수할 때 익명 페이지를 스왑으로 내보내는 것과 파일 페이지를 버리는 것 가운데 어느 쪽을 얼마나 선호할지 정하는 값입니다. 커널 문서에 따르면 0부터 200까지 정할 수 있고 기본값은 60입니다. 100은 두 가지 입출력의 비용을 같게 본다는 뜻이고, 100보다 크면 스왑을 더 선호하는데, 이는 압축한 메모리를 스왑으로 쓰는 zram처럼 스왑이 파일 입출력보다 싼 환경을 위한 것입니다. 0으로 정하면 여유 메모리와 파일 페이지가 아주 적어질 때까지 스왑을 시작하지 않지만, 스왑을 끈다는 뜻은 아닙니다. 데이터베이스 서버라서 0이나 1로 정해야 한다는 식의 정답은 없으며, 작업의 성격과 저장 장치의 속도에 맞추어 측정하며 정해야 합니다.
스래싱
프로세스들이 자주 쓰는 페이지를 모두 올려 둘 만큼 메모리가 없으면, 페이지를 내보내자마자 다시 필요해져 들여오고, 그 자리를 마련하려고 또 다른 페이지를 내보내는 일이 되풀이됩니다. 시스템이 실제 일보다 페이지를 옮기는 데 대부분의 시간을 쓰는 이 상태를 스래싱(thrashing)이라고 합니다. CPU 사용률은 낮은데 디스크는 쉬지 않고 바쁘고, 프로그램들은 기어가듯 느려집니다.
스래싱인지 확인하려면 여러 지표를 함께 봅니다. available이 줄어들고 프로세스의 실제 상주 메모리(RSS)가 압박받는지, vmstat의 si와 so가 계속 큰 값을 보이는지, 입출력 대기(wa)와 응답 시간이 함께 늘어나는지를 확인합니다. 한순간의 스왑 사용량이나 정해진 숫자 하나보다, 부하와 함께 이 지표들이 시간에 따라 어떻게 바뀌는지를 봐야 합니다. 스래싱의 원리와 해결 방법은 9장에서 자세히 다룹니다.
힙 메모리 관리
프로그램은 실행 중에 C의 malloc과 free, C++의 new와 delete로 메모리를 할당하고 해제합니다. 이 메모리는 힙(heap)에서 나옵니다. 그런데 malloc을 부를 때마다 운영체제에 메모리를 요청하는 것은 아닙니다. C 라이브러리 안의 메모리 할당기(allocator)가 운영체제에서 큰 덩어리를 받아 두고, 그것을 잘게 나누어 주기 때문입니다.
할당기는 대략 다음과 같이 일합니다. 필요하면 brk로 힙의 끝을 늘리거나 mmap으로 새 영역을 매핑해 운영체제에서 메모리를 받습니다. malloc 요청이 오면 이미 받아 둔 공간 가운데 크기와 정렬이 맞는 블록을 찾아 돌려줍니다. free로 돌아온 블록은 보통 운영체제에 바로 돌려주지 않고 빈 목록에 남겨 두었다가 다음 요청에 다시 씁니다. 공간이 모자라면 운영체제에 더 요청하고, 그것도 실패하면 malloc은 NULL을 돌려줍니다. glibc의 할당기는 기본적으로 128KiB 이상의 큰 요청은 mmap으로 따로 매핑해 주고, 이런 블록은 free할 때 바로 운영체제에 돌려줍니다.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void)
{
char *buf = malloc(100);
if (buf == NULL) { /* 할당은 실패할 수 있다. */
perror("malloc");
return 1;
}
snprintf(buf, 100, "Hello, OS!");
printf("%s\n", buf);
free(buf); /* 할당기에 돌려준다. */
buf = NULL; /* 이 변수로 다시 쓰는 실수를 막는다. */
return 0;
}해제한 뒤 포인터를 NULL로 바꾸어 두면, 실수로 이 변수를 다시 써도 해제한 메모리 대신 NULL을 건드려 문제가 빨리 드러납니다. 다만 같은 메모리를 가리키는 다른 포인터가 있다면 그 포인터는 여전히 해제된 메모리를 가리킨다는 점에 주의해야 합니다.
할당기는 여러 가지가 있으며, 여러 스레드가 동시에 할당할 때의 경쟁과 단편화를 줄이는 방법이 서로 다릅니다.
| 할당기 | 특징 |
|---|---|
| ptmalloc2 | glibc의 기본 할당기. 아레나(arena)를 여러 개 두어 스레드 사이의 경쟁을 줄인다 |
| jemalloc | 제이슨 에번스(Jason Evans)가 만들어 FreeBSD의 기본 할당기가 되었다. 크기별 분류와 캐시로 단편화를 줄인다 |
| tcmalloc | Google의 할당기. CPU나 스레드마다 작은 캐시를 두어 공유 자료구조의 경쟁을 줄인다 |
| mimalloc | Microsoft의 할당기. 크기별 페이지와 나누어 둔 빈 목록을 쓴다 |
더 깊이: free한 메모리가 운영체제로 바로 돌아가지 않는 이유
free를 불렀는데도 프로세스의 상주 메모리가 줄지 않는 일은 흔하며, glibc 할당기의 규칙을 보면 이유가 보입니다. mallopt(3)에 따르면 brk로 늘린 힙은 맨 위쪽의 메모리가 해제될 때만 줄일 수 있고, 맨 위에 연속으로 빈 공간이 M_TRIM_THRESHOLD(기본 128KiB) 이상 쌓여야 운영체제에 돌려줍니다. 힙의 위쪽에 쓰이는 블록이 하나라도 남아 있으면 그 아래의 빈 공간은 할당기의 빈 목록에 남을 뿐입니다. 반면 mmap으로 받은 블록은 하나씩 따로 돌려줄 수 있지만, 해제한 공간을 다음 할당에 다시 쓰지 못하고, 페이지 단위로 맞추느라 공간이 남으며, 커널이 매번 메모리를 0으로 채워야 합니다. 앞에서 본 128KiB라는 기준은 이 장단점을 저울질해 정한 값입니다.
게다가 이 기준은 고정되어 있지 않습니다. 지금의 glibc는 기본으로 동적 기준을 써서, 128KiB에서 시작하되 현재 기준보다 크고 상한 이하인 블록이 해제되면 기준을 그 블록의 크기로 올립니다. 상한은 64비트 시스템에서 4*1024*1024*sizeof(long), 곧 32MiB이고, 힙을 줄이는 기준도 이때 새 기준의 두 배로 함께 바뀝니다. 그래서 큰 블록을 한 번 해제한 뒤로는 그보다 작은 요청이 mmap 대신 힙에서 처리되어, 해제해도 운영체제로 바로 돌아가지 않을 수 있습니다. mallopt()로 M_MMAP_THRESHOLD나 M_TRIM_THRESHOLD 같은 값을 직접 정하면 이 동적 조절은 꺼집니다.
메모리 오류의 종류
손으로 메모리를 관리하는 언어에서는 몇 가지 실수가 되풀이해 나타납니다. 아래 코드들은 오류의 모양을 보여 주기 위한 조각이며, 실행해서 얻은 결과가 아닙니다.
메모리 누수
메모리 누수(memory leak)는 할당한 메모리를 다 쓰고도 해제하지 않아, 프로그램이 실행되는 동안 쓰는 메모리가 계속 늘어나는 것입니다. 금방 끝나는 프로그램이라면 끝날 때 운영체제가 모두 회수하므로 티가 나지 않지만, 몇 주씩 실행되는 서버에서는 결국 메모리가 바닥납니다.
void process_request(void)
{
char *data = malloc(1024);
/* ... 데이터 처리 ... */
if (error_occurred)
return; /* free 없이 반환하므로 1KB가 샌다. */
free(data);
}가장 흔한 실수는 이처럼 오류가 난 경로에서 해제를 빠뜨리는 것입니다. 이 함수가 1초에 100번 불리고 그 가운데 1%가 오류 경로로 빠진다면, 1초에 1,024바이트, 하루에 약 84.4MiB가 샙니다. 정상 경로만 시험해서는 드러나지 않는다는 점이 문제입니다.
해제 뒤 사용과 이중 해제
이미 해제한 메모리를 가리키는 포인터를 댕글링 포인터(dangling pointer)라고 하고, 이 포인터로 해제한 메모리에 접근하는 것을 해제 뒤 사용(use-after-free)이라고 합니다. 앞 글에서 보았듯이 해제한 메모리는 여전히 매핑되어 있는 경우가 많아 폴트 없이 엉뚱한 값을 읽거나, 그 자리를 새로 할당받은 다른 객체를 덮어씁니다. 같은 메모리를 두 번 해제하는 이중 해제(double free)도 할당기의 내부 자료구조를 망가뜨릴 수 있습니다. 둘 다 C에서 정의되지 않은 동작이며, 공격자가 이를 이용해 원하는 코드를 실행시키는 보안 취약점이 되기도 합니다.
int *ptr = malloc(sizeof *ptr);
*ptr = 42;
free(ptr);
printf("%d\n", *ptr); /* 해제한 메모리 읽기: 무엇이 나올지 정해져 있지 않다. */메모리 오류를 찾는 도구
이런 오류는 도구로 찾습니다. Valgrind의 Memcheck는 프로그램을 다시 컴파일하지 않고 실행하면서 모든 메모리 접근을 지켜봅니다.
valgrind --leak-check=full --show-leak-kinds=all ./program프로그램이 끝나면 Valgrind는 해제되지 않은 블록을 definitely lost(가리키는 포인터가 하나도 남지 않은 블록), indirectly lost, possibly lost, still reachable 같은 종류로 나누어 요약하고, 각 블록이 어느 함수의 어느 줄에서 할당되었는지 호출 스택과 함께 보여 줍니다. 대신 프로그램이 수십 배 느려집니다. AddressSanitizer는 -fsanitize=address로 컴파일할 때 검사 코드를 넣어, 해제 뒤 사용, 버퍼 범위를 벗어난 접근, 이중 해제를 접근하는 순간 찾아내며 Valgrind보다 훨씬 빠릅니다. 두 도구 모두 실행한 경로만 검사하므로, 시험에서 지나가지 않은 경로의 버그까지 찾아 주지는 않습니다.
가비지 컬렉션
C와 C++의 수동 메모리 관리는 비용이 적고 세밀하게 다룰 수 있지만, 앞에서 본 실수를 저지르기 쉽습니다. Java, Python, Go, JavaScript 같은 언어는 더 이상 쓰이지 않는 메모리를 런타임이 찾아 회수하는 가비지 컬렉터(garbage collector, GC)를 둡니다.
가비지 컬렉션은 도달 가능성(reachability)으로 쓰레기를 판단합니다. 전역 변수, 각 스레드의 스택에 있는 지역 변수, 레지스터처럼 프로그램이 직접 쥐고 있는 참조를 루트 집합(root set)이라고 하고, 루트에서 참조를 따라가 닿을 수 있는 객체는 살아 있다고 봅니다. 어떤 경로로도 닿을 수 없는 객체는 프로그램이 다시 쓸 방법이 없으므로 쓰레기입니다.
GC · 판단과 수집 범위
도달 가능성의 판단과 세대별 수집 범위는 다른 선택입니다
참조 카운팅/마크 앤 스윕과 세대 구분은 함께 쓰일 수 있습니다. 실제 회수 시점·병행 처리·정지 시간은 런타임과 설정에 따라 확인합니다.
| 전략 | 판단 또는 구분 기준 | 추가로 확인할 조건 |
|---|---|---|
| 참조 카운팅 | 참조 수의 증감을 유지 | 단독으로는 서로 참조하는 고립된 순환을 회수하지 못함 |
| 마크 앤 스윕 | 루트에서 도달 가능한 객체를 추적 | 순환도 판별 가능; 병행 처리와 정지 시간은 구현에 따름 |
| 세대별 수집 | 객체의 나이로 수집 대상을 나눔 | 추적 알고리즘 등과 결합하는 전략; 언어 이름만으로 구현을 고정하지 않음 |
- 참조 카운팅
- 판단 또는 구분 기준: 참조 수의 증감을 유지추가로 확인할 조건: 단독으로는 서로 참조하는 고립된 순환을 회수하지 못함
- 마크 앤 스윕
- 판단 또는 구분 기준: 루트에서 도달 가능한 객체를 추적추가로 확인할 조건: 순환도 판별 가능; 병행 처리와 정지 시간은 구현에 따름
- 세대별 수집
- 판단 또는 구분 기준: 객체의 나이로 수집 대상을 나눔추가로 확인할 조건: 추적 알고리즘 등과 결합하는 전략; 언어 이름만으로 구현을 고정하지 않음
이 분류는 서로 배타적인 세 제품이 아닙니다. 예를 들어 CPython은 참조 카운팅에 순환 참조 수집을 함께 사용합니다.
그림처럼 가비지 컬렉션의 방법은 두 축으로 나누어 생각할 수 있습니다. 첫째는 쓰레기를 판단하는 방법입니다. 참조 카운팅(reference counting)은 객체마다 자기를 가리키는 참조의 수를 세다가, 그 수가 0이 되는 순간 회수합니다. 마크 앤 스윕(mark and sweep) 같은 추적(tracing) 방식은 루트에서 출발해 닿는 객체에 표시를 한 뒤, 표시되지 않은 객체를 한꺼번에 회수합니다. 둘째는 한 번에 어느 범위를 살필지입니다. 세대별 수집(generational collection)은 대부분의 객체가 만들어진 직후 금방 쓰이지 않게 된다는 약한 세대 가설(weak generational hypothesis)을 이용해, 새로 만든 객체들의 영역을 자주, 오래 살아남은 객체들의 영역을 드물게 살핍니다. 두 축은 서로 독립이어서, 추적 방식에 세대 구분을 더하는 식으로 함께 씁니다.
참조 카운팅에는 순환 참조라는 약점이 있습니다. 두 객체가 서로를 가리키면, 루트에서 아무도 닿을 수 없게 되어도 각자의 참조 수가 1로 남아 0이 되지 않습니다. CPython은 참조 카운팅을 기본으로 쓰면서 이런 순환을 찾는 수집기를 따로 둡니다. 다음 코드로 확인해 보겠습니다.
import gc
import weakref
class Node:
def __init__(self, name):
self.name, self.other = name, None
gc.disable() # 순환을 찾는 수집기를 잠시 끈다
a, b = Node("A"), Node("B")
a.other, b.other = b, a # 서로를 가리키는 순환
probe = weakref.ref(a) # a가 회수되었는지 지켜보는 약한 참조
del a, b # 이름이 가리키던 참조를 모두 없앤다
print("참조 카운팅만으로 회수됨:", probe() is None)
gc.collect() # 루트에서 닿지 않는 순환을 찾아 회수한다
print("순환 수집기를 돌린 뒤 회수됨:", probe() is None)
gc.enable()참조 카운팅만으로 회수됨: False
순환 수집기를 돌린 뒤 회수됨: Truedel a, b로 이름이 가리키던 참조를 없애면 두 객체는 루트에서 닿을 수 없게 되지만, A는 B가, B는 A가 가리키고 있어 각자의 참조 수가 1로 남습니다. 그래서 참조 카운팅만으로는 회수되지 않습니다. gc.collect()를 부르면 순환 수집기가 루트에서 닿지 않는 순환을 찾아 회수합니다. weakref.ref는 객체를 살려 두지 않는 약한 참조(weak reference)여서, 객체가 회수되면 None을 돌려주므로 회수 여부를 지켜보는 데 썼습니다.
가비지 컬렉터가 있어도 메모리는 샌다
가비지 컬렉터는 닿을 수 없는 객체만 회수합니다. 그러므로 프로그램이 더는 쓰지 않을 객체라도 어딘가에서 참조를 쥐고 있으면 회수되지 않습니다. 가장 흔한 예가 넣기만 하고 지우지 않는 캐시입니다.
from functools import lru_cache
cache = {}
def unbounded(key):
if key not in cache:
cache[key] = [key] * 1000 # 한번 넣으면 지우지 않는다
return cache[key]
@lru_cache(maxsize=32) # 가장 오래 안 쓴 것부터 32개만 남긴다
def bounded(key):
return [key] * 1000
for key in range(100):
unbounded(key)
bounded(key)
print("제한 없는 캐시에 남은 항목:", len(cache))
print("lru_cache에 남은 항목:", bounded.cache_info().currsize)제한 없는 캐시에 남은 항목: 100
lru_cache에 남은 항목: 32제한 없는 캐시는 새 키가 올 때마다 항목이 늘어, 서로 다른 키가 끝없이 들어오는 서버라면 메모리가 계속 늘어납니다. 캐시가 모든 항목을 참조하고 있으므로 가비지 컬렉터는 하나도 회수하지 못합니다. 이런 것을 논리적 메모리 누수라고 합니다. lru_cache처럼 크기에 상한을 두고 오래 쓰지 않은 항목부터 버리거나, 항목마다 만료 시간을 두거나, Python의 weakref.WeakValueDictionary나 Java의 WeakReference처럼 다른 곳에서 쓰지 않으면 회수될 수 있는 약한 참조로 담으면 막을 수 있습니다.
Rust의 소유권
Rust는 가비지 컬렉터 없이 메모리 안전성을 얻는 다른 길을 택했습니다. 값마다 소유자(owner)가 하나 있고, 소유자가 범위를 벗어나면 값이 해제되며, 이 규칙은 Rust 교재의 소유권이 뭔가요?에서 자세히 다룹니다. 다른 코드는 값을 빌려(borrow) 쓸 수 있지만, 빌린 참조가 원래 값보다 오래 살 수 없다는 수명(lifetime) 규칙을 컴파일러가 검사합니다. 그래서 안전한 Rust 코드에서는 해제 뒤 사용, 이중 해제, 데이터 경쟁이 컴파일 단계에서 막힙니다. 다만 메모리 안전과 누수가 없는 것은 다른 성질이어서, 참조 카운팅 포인터 Rc로 순환을 만들면 안전한 Rust에서도 메모리가 샐 수 있습니다(순환 참조와 메모리 누수). 또 unsafe 블록과 다른 언어와의 연결 부분에서는 프로그래머가 규칙을 직접 지켜야 합니다.
정리
- 스와핑은 메모리의 내용을 디스크로 내보내 공간을 마련하는 방법입니다. 오늘날에는 프로세스 전체가 아니라 페이지 단위로 회수하며, 바뀐 익명 페이지는 스왑에 저장한 뒤 회수하고, 파일에서 온 바뀌지 않은 페이지는 버렸다가 파일에서 다시 읽습니다.
free의available과vmstat의si,so로 메모리 압박과 실제 스왑을 확인합니다.swappiness는 스왑과 파일 페이지 회수의 선호를 정하는 값이며, 작업에 맞추어 측정하며 정합니다.- 쓰는 페이지를 모두 올려 둘 메모리가 없으면 페이지를 내보내고 들여오기를 되풀이하는 스래싱이 생깁니다.
malloc은 할당기가 운영체제에서 받아 둔 공간을 나누어 주는 것이며, 누수, 해제 뒤 사용, 이중 해제 같은 오류는 Valgrind나 AddressSanitizer로 찾습니다.- 가비지 컬렉션은 루트에서 닿을 수 없는 객체를 회수합니다. 참조 카운팅은 순환을 놓치므로 추적 방식과 함께 쓰며, 계속 참조를 쥐는 캐시 같은 논리적 누수는 가비지 컬렉터도 막지 못합니다.
연습 문제
1. 세 페이지를 다시 준비하는 방법
파일에서 읽어 와 바꾸지 않은 페이지 F, 값 7을 써 넣은 익명 페이지 A, 한 번도 접근하지 않은 익명 페이지 Z가 있습니다. F와 A의 물리 메모리를 회수한 뒤 다시 접근하고, Z에는 처음 접근합니다. 각 페이지의 내용은 어디서 오나요? 메이저 폴트의 횟수만 보고 세 경우를 모두 스왑에서 읽은 것이라고 판단할 수 있을까요?
해설 보기
F는 바뀌지 않았으므로 회수할 때 저장하지 않고 버렸다가, 다시 접근하면 원래 파일에서 읽어 옵니다. A는 RAM에만 있던 내용 7을 회수하기 전에 스왑에 저장해 두었으므로, 다시 접근하면 스왑에서 7을 읽어 옵니다. Z는 처음 접근하는 익명 페이지이므로 디스크에서 읽을 것이 없고, 커널이 0으로 채운 프레임을 줍니다. 파일에서 다시 읽는 F도 디스크 입출력이 필요해 메이저 폴트가 될 수 있으므로, 메이저 폴트의 횟수만으로는 스왑인지 파일인지 구분할 수 없습니다. 스왑 때문인지 보려면 vmstat의 si처럼 스왑 입출력을 따로 보여 주는 지표를 함께 봐야 합니다.
2. 누수량 계산
본문의 process_request가 1초에 200번 불리고, 그 가운데 0.5%가 오류 경로로 빠지며, 오류 경로마다 1,024바이트가 샌다고 합시다. 하루에 새는 양을 MiB로 구하세요. 이 서버의 프로세스가 쓸 수 있는 여유 메모리가 2GiB라면 대략 며칠 뒤에 문제가 될까요?
해설 보기
1초에 200 × 0.005 = 1번 오류 경로로 빠지므로 1초에 1,024바이트가 샙니다. 하루는 86,400초이므로 1,024 × 86,400 = 88,473,600바이트, 곧 88,473,600 ÷ 1,048,576 = 84.375MiB가 하루에 샙니다. 2GiB는 2,048MiB이므로 2,048 ÷ 84.375 ≈ 24.3일 뒤에 여유 메모리를 다 쓰게 됩니다. 실제로는 그보다 먼저 페이지 회수와 스왑이 늘어 느려지고, 결국 메모리가 바닥나면 OOM 킬러가 프로세스를 끝낼 수 있습니다. 서버를 몇 주마다 다시 시작하면 문제가 사라지는 것처럼 보이는 것도 이런 누수의 흔한 징후입니다.
3. 같은 키와 새 키가 섞인 캐시
본문의 unbounded 캐시를 100번 불렀는데, 처음 70번은 같은 키 "same"으로, 나머지 30번은 매번 새로운 키로 불렀습니다. 중간에 지우는 일은 없고 캐시 말고는 결과를 참조하는 곳이 없습니다. 마지막에 캐시에 남는 항목은 몇 개이고, 100번 모두 서로 다른 키로 불렀다면 몇 개일까요?
해설 보기
같은 키로 부르면 이미 있는 항목을 그대로 돌려주므로, 처음 70번은 "same" 항목 하나만 남깁니다. 나머지 30번은 새 키마다 항목을 하나씩 더하므로 모두 31개가 남습니다. 100번 모두 서로 다른 키였다면 100개가 남습니다. 캐시가 이 항목들을 모두 참조하고 있으므로 가비지 컬렉터는 하나도 회수하지 못합니다. 그래서 캐시의 메모리 사용량은 호출 횟수보다 서로 다른 키가 얼마나 나오는지에 달려 있으며, 키의 종류가 끝없이 늘어날 수 있는 곳에는 크기 상한이나 만료 시간을 두어야 합니다.
다음 장의 페이징에서는 메모리를 같은 크기의 페이지로 나누어 연속 공간의 제약을 없애는 페이징과, 가상 주소를 물리 주소로 바꾸는 페이지 테이블을 살펴봅니다.