가비지 컬렉션
고친 사람 github-actions[bot]
가비지 컬렉션은 프로그램이 더는 쓰지 않는 메모리를 찾아서 대신 돌려받는 일입니다. 개발자가 메모리를 직접 반납하지 않아도 되게 해 줍니다. 대신 치우는 동안 프로그램이 잠깐 멈추는 값을 치릅니다. 저장장치와 쿠버네티스에도 같은 이름이 있습니다. 이 항목은 프로그램 메모리를 치우는 뜻을 다룹니다.
쉽고 빠른 이해
가비지 컬렉션은 아무도 안 쓰는 객체를 찾아 그 메모리를 다시 쓸 수 있게 돌려놓는 일입니다.
자바에서 new 로 객체를 계속 만들어도 메모리를 반납하는 코드를 따로 안 쓰는 것이 이 덕입니다.
손으로 반납하게 하면 사람이 실수합니다. 잊으면 메모리가 새고, 너무 일찍 반납하면 엉뚱한 값을 읽습니다.
- 프로그램이 지금 손에 쥔 변수에서 출발합니다
- 거기서 참조를 따라 닿는 객체에 모두 표시합니다
- 표시가 없는 객체의 메모리를 돌려받습니다
대가는 멈춤과 여분의 메모리입니다. 치우는 동안 요청 처리가 잠깐 섭니다. 아무도 안 쓰는 객체(쓰레기)가 쌓일 여유만큼 메모리도 더 잡아 둬야 합니다.
그래서 멈춤을 조금도 견딜 수 없는 프로그램은 가비지 컬렉션이 없는 언어(C·C++·Rust)를 고릅니다.
상세
식당에서 손님이 떠난 테이블을 치우는 직원을 떠올려 봅니다. 손님이 「다 먹었어요」라고 알리지 않아도 직원이 둘러보고 빈 테이블을 치웁니다. 가비지 컬렉션도 다시 돌아올 길이 아예 없는 것만 치웁니다.
가비지 컬렉션은 GC(Garbage Collection)라고 줄여 부릅니다. 이 일을 맡은 프로그램 부품은 가비지 컬렉터라고 부릅니다. 컬렉터는 대개 언어의 런타임 안에 들어 있어서, 개발자가 따로 부르지 않아도 알아서 돕니다.
메모리를 손으로 반납하던 방식
프로그램은 실행 중에 메모리를 빌려 쓰고 다 쓰면 돌려줘야 합니다. C 언어에서는 malloc 으로
빌리고 free 로 돌려줍니다. 빌리는 것도 돌려주는 것도 개발자가 코드로 적습니다.
돌려주는 것을 잊으면 메모리 누수가 생깁니다. 오래 도는 서버라면 쓰는 메모리가 조금씩 불어나다가 결국 바닥납니다.
반대로 너무 일찍 돌려주면 더 곤란합니다. 돌려준 메모리를 가리키던 변수가 남아 있으면, 그 변수로 읽을 때 이미 다른 데이터가 들어와 있을 수 있습니다. 이런 변수를 댕글링 포인터라고 부릅니다. 같은 메모리를 두 번 돌려주는 이중 해제도 프로그램을 망가뜨립니다.
가비지 컬렉션은 반납하는 쪽을 사람 손에서 떼어 냅니다. 개발자는 빌리기만 하고, 언제 돌려줄지는 컬렉터가 정합니다. 그래서 위의 세 실수 중 뒤의 둘은 아예 일어나지 않습니다.
힙과 쓰레기
가비지 컬렉션이 치우는 곳은 힙입니다. 힙은 실행 중에 만든 객체가 놓이는 메모리 구역입니다. 메서드가 끝나면 저절로 걷히는 스택과 달리, 힙의 객체는 누군가 치워야 사라집니다.
객체는 다른 객체를 참조로 가리킵니다. 참조는 「저 객체는 메모리의 어디에 있다」를 담은 값입니다. 변수에 객체를 담는다는 것도 변수가 그 객체를 가리키게 한다는 뜻입니다.
쓰레기는 어떤 경로로도 닿을 수 없는 객체입니다. 경로의 출발점은 프로그램이 지금 손에 쥔 것들입니다. 실행 중인 메서드의 지역 변수, 클래스에 붙은 정적 변수가 그렇습니다. 이 출발점들을 모아 루트 집합이라고 부릅니다.
아래 코드에서 두 줄째가 끝나면 kim 객체를 가리키는 것이 하나도 없습니다.
User u = new User("kim");
u = new User("lee"); // kim 은 쓰레기
변수 u 가 새 객체를 가리키게 되면서 첫 객체로 가는 길이 끊겼습니다. 코드 어디에서도 그 객체를
다시 꺼낼 방법이 없으니, 치워도 프로그램은 알아채지 못합니다.
flowchart TD
subgraph 루트["루트 집합"]
L["지역 변수 u"]
S["정적 변수 cache"]
end
L --> A["User lee"]
S --> B["Map"]
B --> C["Order 1"]
X["User kim"]
Y["Order 9"] --> Z["Item 3"]
Z --> Y
그림에서 lee · Map · Order 1 은 루트에서 화살표를 따라 닿습니다. kim 은 가리키는 것이 없습니다.
Order 9 와 Item 3 은 서로를 가리키지만 루트에서 닿는 길이 없으니 둘 다 쓰레기입니다.
「안 쓸 것」 대신 「닿을 수 없는 것」을 치우는 까닭
컬렉터가 알고 싶은 것은 「이 객체를 앞으로 쓸까」입니다. 그런데 이것은 프로그램을 끝까지 돌려 보기 전에는 알 수 없습니다. 입력에 따라 쓸 수도 안 쓸 수도 있기 때문입니다.
그래서 한 발 물러나 확실한 것만 봅니다. 닿을 길이 없는 객체는 앞으로 절대 쓰이지 않습니다. 이 기준으로 치우면 아직 쓸 객체를 잘못 치우는 일이 없습니다.
대가로 놓치는 것이 생깁니다. 닿기는 하지만 다시는 안 쓸 객체입니다. 정적 변수에 달린 캐시에 값을 넣기만 하고 빼지 않으면, 그 값들은 계속 닿으므로 치워지지 않습니다. 가비지 컬렉션이 있는 언어에도 메모리 누수가 생기는 것은 이 때문입니다.
표시하고 쓸어 내는 방식
가장 기본이 되는 방식은 마크 앤 스윕입니다. 이름대로 표시(mark)와 쓸기(sweep) 두 단계로 나뉩니다.
표시 단계에서는 루트 집합에서 출발해 참조를 따라가며 닿는 객체마다 표시를 붙입니다. 이미 표시된 객체를 만나면 더 따라가지 않습니다. 그래서 서로를 가리키는 객체가 있어도 같은 곳을 맴돌지 않습니다.
쓸기 단계에서는 힙을 처음부터 끝까지 훑습니다. 표시가 없는 객체의 메모리는 비어 있는 것으로 돌려놓습니다. 표시가 있는 객체는 다음 수집을 위해 표시만 지웁니다.
루트에서 참조를 따라가는 방식을 통틀어 추적 가비지 컬렉션이라고 부릅니다. 뒤에 나올 세대별 수집도 이 추적 방식 위에 얹힌 것입니다.
빈 곳이 조각나는 문제
쓸기만 하면 힙 곳곳에 작은 빈 곳이 흩어집니다. 빈 곳을 다 더하면 넉넉한데, 큰 객체 하나를 놓을 만큼 이어진 빈 곳이 없을 수 있습니다. 이 상태를 메모리 단편화라고 부릅니다.
해결책은 살아 있는 객체를 한쪽으로 몰아 붙이는 것입니다. 이 작업을 컴팩션이라고 합니다. 객체가 옮겨 가면 그 객체를 가리키던 참조도 새 주소로 모두 고쳐야 해서 일이 늘어납니다.
두 구역을 두고 살아 있는 객체만 반대쪽 구역으로 복사하는 방식도 있습니다. 이것이 복사 가비지 컬렉션입니다. 복사가 끝나면 원래 구역은 전부 비었으니 한 번에 비웁니다. 대신 늘 한 구역을 비워 둬야 해서 메모리를 더 씁니다.
참조 수를 세는 방식
추적과 전혀 다른 길도 있습니다. 객체마다 「나를 가리키는 참조가 몇 개인가」를 세어 두는 참조 카운팅입니다. 누가 가리키면 1을 더합니다. 가리키기를 그만두면 1을 뺍니다.
수가 0이 되는 순간 그 객체는 바로 치워집니다. 수집을 위해 따로 멈출 일이 적습니다. 메모리가 돌아오는 때도 짐작하기 쉽습니다.
약점은 순환 참조입니다. 앞 그림의 Order 9 와 Item 3 처럼 서로를 가리키면, 루트에서 끊겨도
각자의 수가 1로 남아 영영 0이 되지 않습니다. 그래서 참조 카운팅을 쓰는 언어는 순환만 따로 찾아내는
추적 수집을 덧붙이곤 합니다. Python 의 표준 구현이 이렇게 둘을 섞어 씁니다.
두 방식을 나란히 놓으면 이렇습니다.
| 추적 방식 | 참조 카운팅 | |
|---|---|---|
| 치우는 때 | 컬렉터가 돌 때 몰아서 | 수가 0이 되는 즉시 |
| 서로 가리키는 쓰레기 | 치운다 | 혼자서는 못 치운다 |
| 평소에 드는 일 | 기본형은 거의 없다(세대별·동시 수집은 뒤에서 보듯 붙는다) | 참조를 바꿀 때마다 수를 고친다 |
| 멈춤 | 몰아서 치울 때 생긴다 | 거의 없다 |
표의 둘째 줄이 두 방식을 가르는 가장 큰 차이입니다. 셋째 줄은 참조 카운팅이 공짜가 아니라는 뜻입니다. 객체를 변수에 담고 넘길 때마다 수를 고치는 일이 따라붙습니다.
세대로 나눠 자주 치울 곳을 좁히는 방식
프로그램이 만드는 객체는 대부분 금방 버려집니다. 요청 하나를 처리하려고 만든 임시 객체는 응답을 보내면 쓸모가 끝납니다. 반대로 오래 살아남은 객체는 앞으로도 오래 사는 경향이 있습니다. 이 경험적 관찰을 약한 세대 가설이라고 부릅니다.
세대별 가비지 컬렉션은 이 관찰을 이용합니다. 힙을 젊은 세대와 늙은 세대로 나눕니다. 새 객체는 젊은 세대에 놓습니다. 젊은 세대는 좁고 대부분 쓰레기라서 자주, 빨리 치울 수 있습니다.
flowchart TD
N["새 객체"] --> Y["젊은 세대"]
Y -->|"수집 때 쓰레기"| G["메모리를 돌려받는다"]
Y -->|"여러 번 살아남음"| O["늙은 세대로 옮긴다"]
O -->|"가끔 하는 큰 수집"| G
젊은 세대에서 여러 번 살아남은 객체만 늙은 세대로 옮깁니다. 늙은 세대는 가끔만 치웁니다. 힙 전체를 매번 훑지 않으니 한 번의 수집이 짧아집니다.
다만 놓치기 쉬운 참조가 있습니다. 늙은 세대의 객체가 젊은 객체를 가리킬 수 있습니다. 젊은 세대만 훑으면 이 참조를 못 봐서 살아 있는 객체를 치울 수 있습니다.
그래서 늙은 쪽에서 젊은 쪽을 가리키는 참조가 생길 때마다 따로 적어 둡니다. 적는 일은 프로그램이 참조를 바꿀 때 끼워 넣은 짧은 코드가 맡습니다. 젊은 세대를 치울 때는 그 기록도 루트처럼 함께 봅니다.
치우는 동안 프로그램이 멈추는 까닭
컬렉터가 표시하는 도중에 프로그램이 참조를 바꾸면 문제가 생깁니다. 컬렉터가 표시를 끝내고 지나간 객체 A 가 있다고 합시다. 프로그램이 아직 표시를 못 받은 기존 객체 B 를 A 에 매답니다. 그리고 B 를 가리키던 다른 참조는 끊습니다.
컬렉터는 A 를 다시 훑지 않으니 B 에는 끝내 표시가 붙지 않습니다. B 는 A 를 거쳐 여전히 닿는데도 치워집니다.
가장 간단한 해결은 수집하는 동안 프로그램의 스레드를 모두 세우는 것입니다. 이 멈춤을 가비지 컬렉션 정지라고 부릅니다. 세상을 멈춘다는 뜻으로 스톱 더 월드(stop-the-world)라고도 합니다. 멈춘 동안에는 요청을 하나도 처리하지 못합니다.
멈춤을 줄이는 길은 둘입니다. 하나는 한 번에 다 치우지 않고 조금씩 나눠 치우는 증분 가비지 컬렉션입니다. 다른 하나는 프로그램이 도는 옆에서 다른 스레드가 함께 치우는 동시 가비지 컬렉션입니다.
두 방식 모두 수집 도중에 프로그램이 참조를 바꾸므로 위의 문제를 막아야 합니다. 여기에 앞서 세대별 수집에서 본 그 코드가 다시 쓰입니다. 컴파일러나 런타임이 참조를 바꾸는 명령마다 짧은 코드를 끼워 넣어, 바뀐 참조를 컬렉터에게 알립니다. 이 코드를 쓰기 장벽이라고 합니다.
멈춤이 짧아지는 만큼 다른 것을 냅니다. 끼워 넣은 코드만큼 평소 실행에 일이 더 붙습니다. 앞 표에서 추적 방식의 평소 일이 「거의 없다」던 것은 이런 장치가 없는 기본형 이야기입니다. 컬렉터도 프로세서를 나눠 씁니다. 그래서 컬렉터를 고르는 일은 멈춤 시간과 처리량과 메모리 사이에서 고르는 일이 됩니다.
백엔드 서버에서 드러나는 모습
JVM(Java Virtual Machine, 자바 가상 머신) 위에서 도는 서버라면 가비지 컬렉션은 늘 돌고 있습니다. 대부분은 눈에 안 띄지만, 멈춤이 길어지면 몇몇 요청의 응답만 유난히 늦어집니다. 평균 응답 시간은 멀쩡한데 오래 걸린 쪽 꼬리만 튀는 꼬리 지연의 흔한 원인입니다.
멈춤이 더 길어지면 서버 밖에서도 보입니다. 헬스 체크에 제때 답하지 못해 로드 밸런서가 그 서버를 빼거나, 분산 시스템의 다른 노드가 이 노드를 죽은 것으로 여길 수 있습니다.
반대로 치워도 치워도 돌려받는 메모리가 거의 없으면 컬렉터가 쉬지 않고 돕니다. 살아 있는 객체가 힙을 거의 다 채운 상태입니다. 결국 새 객체를 놓을 곳이 없어지면 자바에서는 OutOfMemoryError 가 납니다.
이럴 때 들여다보는 것은 대개 셋입니다. 수집이 얼마나 자주 도는지, 한 번에 얼마나 멈추는지, 수집 뒤에도 남는 메모리가 얼마인지입니다. 런타임이 남기는 가비지 컬렉션 로그에 이 값들이 찍힙니다.
가비지 컬렉션을 쓰지 않는 언어
모든 언어가 가비지 컬렉션을 쓰지는 않습니다. C 는 앞에서 본 것처럼 개발자가 직접 반납합니다.
C++ 는 객체가 범위를 벗어날 때 자동으로 불리는 소멸자에서 메모리를 돌려줍니다. 스마트 포인터는 이 소멸자에 참조 카운팅을 얹어 반납을 대신 맡습니다.
Rust 는 누가 메모리를 소유하는지를 컴파일할 때 따져서, 소유자가 사라지는 지점에 반납 코드를 넣습니다. 이 규칙을 소유권이라고 부릅니다. 실행 중에 컬렉터가 돌지 않으니 수집 때문에 멈추는 일도 없습니다.
멈춤을 조금도 견딜 수 없는 프로그램이 이런 언어를 고르는 까닭이 여기에 있습니다. 대신 개발자가 메모리의 주인을 코드에 드러내야 하는 부담을 집니다.
같은 이름을 쓰는 다른 분야
SSD(Solid State Drive, 반도체 저장장치)에도 가비지 컬렉션이 있습니다. SSD 가 쓰는 플래시 메모리는 데이터를 칸에 적습니다. 블록은 칸 여러 개를 묶은 단위입니다. 지우기는 블록 단위로만 됩니다.
이미 쓴 칸은 덮어쓰지 못합니다. 그래서 고친 데이터는 새 칸에 씁니다. 옛 칸은 무효로 표시해 둡니다.
무효 칸이 쌓이면 저장장치 안의 펌웨어가 무효 칸이 많은 블록을 고릅니다. 그 블록의 살아 있는 데이터만 다른 블록으로 옮긴 뒤 블록을 통째로 지웁니다.
쿠버네티스에도 같은 이름의 기능이 있습니다. 소유자 리소스가 지워졌을 때 그 소유자에 딸린 리소스를 찾아 함께 지우는 일입니다.
세 뜻 모두 「더는 아무도 안 쓰는 것을 찾아 치운다」는 뼈대가 같습니다. 다른 것은 치우는 대상이 메모리 객체인지, 저장장치의 블록인지, 클러스터의 리소스인지입니다.
관련 항목
가비지 컬렉션이 치우는 메모리 구역
힙 · 스택 · 메서드 영역 · 메모리 할당 · 참조 · 포인터
가비지 컬렉션이 쓰레기를 가려내는 기준
루트 집합 · 도달 가능성 · 약한 참조 · 순환 참조 · 약한 세대 가설
가비지 컬렉션의 하위 방식
추적 가비지 컬렉션 · 마크 앤 스윕 · 참조 카운팅 · 복사 가비지 컬렉션 · 세대별 가비지 컬렉션 · 증분 가비지 컬렉션 · 동시 가비지 컬렉션 · 컴팩션
가비지 컬렉션을 구현한 컬렉터
가비지 컬렉터 · G1 GC · ZGC · Shenandoah · Boehm GC
가비지 컬렉션을 품은 실행 환경
런타임 · JVM · CLR · V8 · Go · Python · 자바
가비지 컬렉션이 일으키는 장애
가비지 컬렉션 정지 · 꼬리 지연 · OutOfMemoryError · 메모리 누수 · 메모리 단편화
가비지 컬렉션이 저울질하는 성능 지표
가비지 컬렉션을 대신할 수 있는 다른 수단
수동 메모리 관리 · 소멸자 · 스마트 포인터 · 소유권 · Rust · C · C++
손으로 반납할 때 터지는 오류
댕글링 포인터 · 이중 해제 · 해제 후 사용 · 버퍼 오버플로
같은 이름을 쓰는 다른 분야의 기능
SSD · 플래시 메모리 · 쓰기 증폭 · Kubernetes · ownerReference
다른 이름: GC · garbage collection · 가비지 수집