LabHub

블로그

가비지 컬렉션 알고리즘 완전 정복 — Mark-Sweep부터 ZGC 1ms까지, 세대별 GC와 현대 저지연 컬렉터의 모든 것 (2025)

0. GC 는 왜 여전히 어려운가

2000년대에 "Java GC 는 나쁘다, C++ 를 써라" 가 유행했다. 2020년대에는 Java 의 ZGC 가 수 TB 힙에서도 1ms 이하 일시정지. Go 는 10ms 이하로 수렴. 근데 왜 여전히 프로덕션에서 GC 튜닝으로 밤을 새우는가?

간단한 이유: GC 는 시간/공간/정확성의 3차원 트레이드오프. "하나를 잘하면 다른 게 망가진다." 지난 65년간 이 세 축 사이에서 균형점을 찾는 알고리즘이 계속 발명됐고, 언어마다 다른 타협 을 선택했다.

이 글은 GC 알고리즘이 어떻게 진화해왔는지, 왜 각 언어가 다른 GC 를 쓰는지, 그리고 현대 저지연 GC 들이 어떻게 "불가능해 보이던 1ms 일시정지" 를 달성했는지 파헤친다.

1. GC 가 해결하는 문제

1.1 수동 메모리 관리의 악몽

C 에서의 네 가지 영원한 버그:

Chrome 의 2019년 보안 버그의 70% 가 메모리 안전성 문제 (Microsoft 도 비슷한 통계). 이게 Rust 의 ownership 이나 GC 언어의 존재 이유.

1.2 GC 의 약속

"도달 불가능한 (unreachable) 객체는 자동으로 회수." 여기서 "도달 불가능" = "프로그램의 root 에서 시작해 포인터 추적으로 도달할 수 없는 객체".

Roots:

이 root 집합에서 그래프 탐색 → 도달 못하는 객체는 죽음.

1.3 GC 가 안 해주는 것

2. 고전 알고리즘들

2.1 Mark-and-Sweep (1960, McCarthy, Lisp)

John McCarthy 의 Lisp 인터프리터에 최초 GC. 두 단계:

Mark: Root 에서 DFS/BFS → 도달 가능한 모든 객체에 "살아있음" 표시. Sweep: 힙 전체를 스캔 → 표시 없는 객체를 free list 에 추가.

Before:
[A(m)] [B] [C(m)] [D(m)] [E] [F(m)] [G] [H(m)]

After Sweep:
[A] [FREE] [C] [D] [FREE] [F] [FREE] [H]
       ↓    ↓           ↓
    free list 연결

문제:

2.2 Copying GC (1970, Cheney)

힙을 반으로 나눔. 한 쪽 (from-space) 만 사용. GC 시:

  1. Root 에서 시작해 살아있는 객체를 to-space 로 복사.
  2. 복사 시 from-space 의 원본에 "forwarding pointer" 남김.
  3. 복사 중 다른 참조가 원본을 찾으면 forwarding 따라감.
  4. 복사 완료 후 from-space 전체를 한 번에 비움.

장점:

단점:

2.3 Mark-Compact

Mark + Compact (살아있는 객체를 앞으로 밀착 복사).

Mark-Sweep 의 단편화 문제 해결. 하지만 일시정지 길어짐.

2.4 Reference Counting

각 객체에 카운터. 참조 추가 시 +1, 제거 시 -1. 0 이 되면 즉시 해제.

장점:

단점:

Python (CPython), Objective-C, Rust (Rc/Arc) 가 채택.

3. Generational GC — 1984년의 돌파구

3.1 Generational Hypothesis

David Ungar (1984) 의 관찰:

"대부분의 객체는 아주 짧게 살다가 죽는다. 살아남은 객체는 대체로 오래 산다."

3.2 Young / Old Generation 분리

Young Generation (Eden + Survivor 0 + Survivor 1)
                ↓ 여러 번 살아남은 객체 promotion
Old Generation (Tenured)

3.3 일반적 숫자

JVM 기본:

3.4 Cross-Generation Reference 문제

Old → Young 참조 있으면 Young GC 시 Old 도 봐야 함? Old 전체 스캔이면 "빠른 Young GC" 의미 없음.

해결: Card Table / Remembered Set.

쓰기 시마다 체크 (write barrier): if (new_ref is young) mark_dirty(card). 할당 약간 느려지지만 Young GC 가 엄청 빨라짐.

3.5 Generational 의 비용

이 튜닝이 Java 에서 -Xmn, -XX:NewRatio 같은 옵션의 근원.

4. JVM GC 의 진화 — Serial 에서 ZGC 까지

4.1 Serial GC (1998)

단일 스레드 Mark-Sweep-Compact. 작은 힙 (수백 MB) 에 적합. 지금도 -client 모드 기본.

4.2 Parallel GC (2002)

여러 스레드가 동시에 GC. Young/Old 모두. 처리량 (throughput) 우선.

4.3 CMS (Concurrent Mark Sweep, 2004-2020)

Old GC 의 대부분을 애플리케이션 실행 중 수행. Young GC 는 여전히 STW.

Java 14 에서 제거.

4.4 G1 GC (Garbage First, 2012 정식)

Java 9 부터 기본:

핵심 혁신:

Garbage First 의 의미: "쓰레기가 가장 많이 찬 region 부터 수거" → 동일 시간 내 최대 메모리 회수.

장점:

단점:

4.5 ZGC (Z Garbage Collector, 2019 정식)

Oracle 이 Azul C4 에서 영감. TB 단위 힙에서 1ms 이하 일시정지 목표.

4.5.1 Colored Pointers

64비트 주소의 상위 비트를 색깔 로 사용 :

[reserved | finalizable | remapped | marked1 | marked0 | actual address]
     3         4            5          6         7       8-42

각 색깔이 GC 상태 표현. 한 포인터에 여러 정보.

4.5.2 Load Barrier

포인터 읽기 마다 체크:

ref = object.field     // load barrier 삽입됨
if (!is_good_color(ref))
    ref = fix(ref);    // GC 상태에 맞게 수정
use(ref);

4.5.3 거의 모든 단계 Concurrent

4.5.4 비용

4.6 Shenandoah (Red Hat, 2014-)

ZGC 와 비슷한 목표, 다른 접근:

Brooks Pointer: 각 객체 앞에 "실제 위치" 포인터 헤더. 객체 이동 시 이 포인터만 갱신.

4.7 비교 요약

GC목표일시정지처리량힙 크기
Parallel배치 처리수 초최고~10GB
G1범용100-300ms높음~100GB
ZGC저지연10ms 미만중간수 TB
Shenandoah저지연10ms 미만중간수백 GB

5. Go GC — 저지연 우선의 단일 철학

5.1 초기 (Go 1.0)

Mark-Sweep, STW. 일시정지 수백 ms. "Go 는 GC 때문에 못 쓴다" 평.

5.2 Go 1.5 (2015) 의 대수술

Concurrent Tri-color Mark-Sweep:

결과: Go 사용층 급속 확대.

5.3 Go 1.8+ 의 개선

5.4 Generational 없음

Go 는 지금까지 세대별 GC 를 채택하지 않음. 이유:

하지만 2023년부터 "green tea" 프로젝트로 generational GC 실험 중.

5.5 GC Tuning

Go 는 튜닝 노브가 매우 적음:

Java 의 수십 개 옵션 대비 단순.

6. V8 GC (Chrome, Node.js)

6.1 Orinoco — 병렬 + 동시 + 증분 GC

V8 의 현대 GC 이름. 여러 기법 조합:

6.2 Concurrent Marking (2018)

JavaScript 실행 스레드와 별도의 마킹 스레드. Chrome 에서 GC 로 인한 프레임 드롭 제거.

6.3 Sweeping Optimization

Minor GC (Young) 이후 오래된 객체의 Compact 는 백그라운드에서 천천히. Idle time 에 실행.

6.4 Node.js 특화

7. Python — 참조 카운팅 + 사이클 GC

7.1 기본: 참조 카운팅

CPython 의 모든 객체는 ob_refcnt 필드. 참조 생성/소멸마다 갱신.

7.2 사이클 수집기

참조 카운팅이 못 잡는 순환 참조 해결:

7.3 PyPy — 세대별 Mark-Sweep

PyPy 의 MiniMark GC 는 CPython 보다 훨씬 빠름. 세대별, 병렬, 할당 최적화. 수치 연산에서 CPython 대비 10배+.

7.4 Python 3.12 의 실험

"No GIL" Python 제안과 함께 참조 카운팅을 biased 방식으로 (스레드 로컬로) 변경 실험. 장기적으로 Python GC 도 재설계 가능성.

8. Azul C4 — 상업적 극단

8.1 Continuously Concurrent Compacting Collector

Azul Systems 가 개발한 JVM GC. ZGC 의 원형:

8.2 용도

상업 제품 (Azul Platform Prime). ZGC, Shenandoah 가 오픈 소스 대안.

9. 튜닝 실전 — JVM 예시

9.1 먼저 측정

-Xlog:gc*:file=gc.log:time,uptime,level,tags

도구:

9.2 메트릭

9.3 일반 원칙

9.4 자주 보는 증상

10. GC 가 없는 선택 — Rust, Zig

10.1 Rust 의 Ownership

컴파일 타임에 소유권 추적:

let s = String::from("hello");  // s 가 메모리 소유
let t = s;                       // 소유권 이전. s 는 이제 못 씀.
// s 범위 벗어나면 메모리 해제.

참조 카운팅이 필요하면 Rc<T>, 멀티스레드면 Arc<T>. 순환 참조는 Weak<T> 로 끊음.

10.2 Zig 의 명시적 Allocator

GC 없음. malloc/free 더 추상화. 모든 함수가 allocator 를 인자로 받음:

const allocator = std.heap.page_allocator;
const list = try std.ArrayList(u32).init(allocator);
defer list.deinit();

defer 로 RAII 유사. 명시적이지만 안전.

10.3 C++ RAII + 스마트 포인터

현대 C++ 는 사실상 GC 없는 자동 메모리 관리. Rust 이전부터 이 방향.

11. 실전 질문들

11.1 "메모리 누수 같은데 GC 가 안 치우네?"

GC 는 도달 가능한 객체를 못 치움. "쓰레기인데 아직 root 에서 참조됨" = 논리적 누수:

해결: 메모리 프로파일러로 "가장 많은 인스턴스" 탐색 → 참조 체인 추적.

11.2 "GC 가 너무 자주 돌아"

할당률이 너무 높음:

11.3 "Full GC 가 길다"

Old 가 너무 크거나 참조 구조가 복잡:

11.4 "GC 튜닝 후 더 느려짐"

가장 흔한 실수. 측정 없이 옵션 추가. 기본값은 수많은 벤치마크로 최적화됨. 근거 있는 변경만:

  1. 프로파일.
  2. 가설.
  3. A/B 비교.
  4. 한 번에 하나 변경.

12. 마치며 — 65년의 진화, 계속되는 여정

1959 년 McCarthy 의 Lisp 첫 GC → 1984 년 Generational → 2004 년 CMS → 2012 년 G1 → 2019 년 ZGC.

각 세대는 이전의 한계를 극복했다. 처리량 vs 지연 vs 메모리 의 3차원에서 끊임없이 새 타협점을 찾아왔다. 현재의 frontier:

다음 10년은 아마도:

매번 new Object() 할 때 이 65년의 역사가 작동한다. 공짜가 아니지만, 정말 정교한 엔지니어링이다.

다음 글에서는 컨테이너 내부 — Docker 의 마법 뒤에 있는 Linux namespaces, cgroups, overlayfs, 그리고 seccomp/capabilities — 를 파볼 예정이다. docker run 한 줄이 어떻게 격리된 실행 환경을 만드는지.

참고 자료

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다