[Daily morning study] 메모리 단편화와 가비지 컬렉션
#daily morning study
메모리 단편화 (Memory Fragmentation)
메모리 단편화는 가용 메모리가 충분한데도 실제로 할당이 어려운 현상이다. 크게 두 종류로 나뉜다.
외부 단편화 (External Fragmentation)
전체 여유 공간은 충분하지만 연속된 공간이 없어서 할당에 실패하는 경우다.
[사용 중: 20KB] [빈 공간: 10KB] [사용 중: 15KB] [빈 공간: 10KB] [사용 중: 10KB]
총 20KB가 비어 있지만 15KB 연속 블록을 요청하면 실패한다. 가변 크기 블록으로 동적 할당을 반복할수록 심해진다.
내부 단편화 (Internal Fragmentation)
할당된 공간이 실제 요청 크기보다 큰 경우 낭비되는 내부 여백이다.
요청: 18KB → 할당: 20KB(고정 블록) → 내부 낭비: 2KB
고정 크기 블록을 쓸 때 발생한다. 외부 단편화는 줄지만 내부 단편화가 생긴다.
단편화 해결 방법
| 방법 | 설명 | 단점 |
|---|---|---|
| 압축(Compaction) | 사용 중인 메모리를 한쪽으로 모아 연속 공간 확보 | 실행 중 이동 비용 큼 |
| 페이징(Paging) | 고정 크기(페이지) 단위로 분리 → 외부 단편화 제거 | 내부 단편화 존재 |
| 세그멘테이션(Segmentation) | 논리적 단위로 분리 → 내부 단편화 제거 | 외부 단편화 존재 |
| 슬랩 할당자(Slab Allocator) | 자주 쓰는 객체 크기별로 캐시 유지 | 구현 복잡 |
Linux 커널은 버디 시스템(Buddy System)으로 외부 단편화를 줄이고, 슬랩 할당자로 내부 단편화를 줄이는 이중 구조를 사용한다.
가비지 컬렉션 (Garbage Collection)
GC는 프로그래머가 직접 메모리를 해제하지 않아도 런타임이 자동으로 도달 불가능한(unreachable) 객체를 회수하는 메커니즘이다.
왜 필요한가
C/C++처럼 수동 관리를 하면 다음 문제가 발생하기 쉽다.
- 메모리 누수(Memory Leak):
free()를 빠뜨려 메모리가 계속 쌓임 - 댕글링 포인터(Dangling Pointer): 해제된 메모리를 참조해 undefined behavior 발생
- 이중 해제(Double Free): 같은 주소를 두 번 해제해 힙 손상
GC는 이런 버그를 언어 수준에서 방지하는 대신 런타임 오버헤드를 감수한다.
주요 GC 알고리즘
1. Reference Counting (참조 카운팅)
객체마다 자신을 참조하는 포인터 수를 저장한다. 카운트가 0이 되면 즉시 해제한다.
a = [1, 2, 3] # refcount = 1
b = a # refcount = 2
del a # refcount = 1
del b # refcount = 0 → 즉시 해제
장점: 객체가 즉시 해제되어 메모리 점유 시간이 짧다. 구현이 단순하다.
단점: 순환 참조(Circular Reference)를 처리하지 못한다.
a = {}
b = {}
a['b'] = b # b의 refcount = 1
b['a'] = a # a의 refcount = 1
del a
del b
# 변수는 사라졌지만 서로 참조하므로 refcount가 0이 안 됨 → 누수
Python은 이 문제를 해결하기 위해 참조 카운팅 + 주기적 순환 탐지기(Cycle Detector)를 병행한다.
2. Mark-and-Sweep
두 단계로 동작한다.
- Mark: 루트(전역 변수, 스택 변수 등)에서 출발해 도달 가능한 모든 객체에 표시
- Sweep: 표시되지 않은 객체를 전부 회수
루트 → A → B → D
↓
C
E (도달 불가) → 해제
F (도달 불가) → 해제
장점: 순환 참조를 포함해 모든 쓰레기를 수집할 수 있다.
단점: Sweep 중에 프로그램이 일시 정지하는 Stop-the-World(STW) 문제가 발생한다.
3. 세대별 GC (Generational GC)
“대부분의 객체는 생성 직후 짧게 살다 죽는다”는 약한 세대 가설(Weak Generational Hypothesis)에 기반한다.
객체를 나이(세대)별로 분류한다.
Young Generation (Eden + Survivor)
↓ (살아남으면 Promotion)
Old Generation (Tenured)
↓ (더 살아남으면)
Permanent/Metaspace
Young GC(Minor GC)는 자주, 빠르게 실행된다. Old GC(Major GC/Full GC)는 드물게 실행된다. 전체 힙을 스캔하지 않아도 되므로 효율적이다.
언어별 GC 구현
Java
Java는 여러 GC 구현체를 제공한다.
| GC | 특징 |
|---|---|
| Serial GC | 단일 스레드, 소형 앱용 |
| Parallel GC | 멀티 스레드 처리량 최적화 |
| G1 GC (JDK 9+) | 힙을 Region으로 나눠 STW 최소화 |
| ZGC (JDK 15+) | 수 TB 힙도 STW 1ms 이하 목표 |
G1 GC는 힙을 동일 크기의 Region으로 분할하고, Garbage가 많은 Region을 우선 수집(Garbage First)한다.
힙 전체를 세대로 나누지 않고 동적으로 Young/Old Region 지정
→ 큰 힙에서도 예측 가능한 STW 시간 제공
Python
import gc
# 참조 카운팅이 기본
# sys.getrefcount()로 확인 가능
import sys
x = []
print(sys.getrefcount(x)) # 2 (x 자체 + getrefcount 인자)
# 순환 참조는 gc 모듈이 처리
gc.collect() # 수동으로 순환 탐지 실행
Python의 GC는 세 세대로 나뉘며, 각 세대별 할당 횟수가 임계치를 넘으면 수집이 트리거된다.
Go
Go는 Tri-color Mark-and-Sweep 방식에 동시성(Concurrent GC)을 결합한다.
- 객체를 White(미방문) / Gray(방문 중) / Black(완료)으로 분류
- 대부분의 마킹 작업을 프로그램 실행과 동시에 처리
- STW 구간을 수십~수백 마이크로초 수준으로 유지
// Go에서는 직접 메모리 해제가 없음
// 스코프를 벗어나면 GC 대상이 됨
func createSlice() []int {
s := make([]int, 1000)
return s // 반환하면 살아남음
// 반환 안 하면 함수 종료 후 GC 대상
}
Stop-the-World 문제
STW는 GC가 실행되는 동안 애플리케이션 스레드가 모두 멈추는 현상이다. 실시간 서비스에서 GC 일시 정지는 응답 지연(latency spike)으로 직결된다.
현대 GC의 발전 방향은 STW 시간 최소화다.
| 방식 | STW 특징 |
|---|---|
| 기본 Mark-and-Sweep | 전체 GC 동안 STW |
| Incremental GC | GC를 잘게 나눠 STW를 분산 |
| Concurrent GC | 앱 실행과 GC 병행, STW 극소화 |
| ZGC / Shenandoah | 거의 모든 작업 동시 처리 |
JVM 튜닝에서 -XX:MaxGCPauseMillis=200 같은 옵션으로 목표 STW 시간을 지정하면 GC가 해당 시간 안에 맞추려 Region 선택 전략을 조정한다.
정리
- 메모리 단편화는 외부(연속 공간 부족)와 내부(할당 낭비) 두 종류가 있다
- 페이징은 외부 단편화를, 가변 크기 할당은 내부 단편화를 줄이지만 서로 트레이드오프가 있다
- GC는 참조 카운팅(즉시 해제, 순환 참조 취약) + Mark-and-Sweep(순환 포함 수집, STW) 방식을 조합해 발전했다
- 세대별 GC는 단명 객체가 많다는 통계적 특성을 활용해 GC 비용을 낮춘다
- 현대 GC(ZGC, Go GC)는 동시성 마킹으로 STW를 최소화하는 방향으로 진화하고 있다