세 언어 모두 가비지 컬렉터(GC)를 쓰지만 알고리즘이 완전히 다르다. Java 는 세대별 가설(Weak Generational Hypothesis)을 바탕으로 리전(Region) 기반 GC 를 쓴다. Python 은 참조 횟수 계산(Reference Counting)으로 메모리를 즉시 회수하고 순환 참조만 별도 detector 로 처리한다. Go 는 동시성 삼색 마크-스윕(Concurrent Tri-color Mark-Sweep) 단일 알고리즘으로 지연(Latency)을 줄인다. 같은 백엔드 워크로드에서 처리량(Throughput)과 지연 특성이 갈리는 출발점이 이 세 가지 결정이다.
Java GC
Java GC 의 핵심 가정은 세대별 가설 이다. 객체 대부분이 짧게 살다 죽는다는 경험적 관찰이다. 이 가정 위에서 힙을 Young (Eden + Survivor) 과 Old 로 나누고, 짧게 살 객체는 Young 에서 빠르게 회수하고 살아남은 객체만 Old 로 promote 한다.
block-beta
columns 4
eden["Eden
(새 객체 할당)"]
s0["Survivor 0"]
s1["Survivor 1"]
old["Old (Tenured)
(promote 된 장수 객체)"]
style eden fill:#FFE0B2
style s0 fill:#FFF59D
style s1 fill:#FFF59D
style old fill:#90CAF9
Minor GC 는 Young 영역만 훑어서 빠르게 끝난다. Major GC (또는 Full GC) 는 Old 까지 포함해 느리다. 운영 부담의 대부분이 Major GC 의 STW(Stop-the-World) 길이에서 온다.
Java 9 부터 기본 GC 는 G1 GC(Garbage-First) 다(JEP 248). G1 은 힙을 균등한 region 으로 분할하고, garbage 가 많은 region 을 우선 회수한다. Young/Old 구분은 region 단위로 동적으로 정해진다. 사용자가 목표 STW (-XX:MaxGCPauseMillis) 를 지정하면 G1 이 그 범위 안에서 region 회수 계획을 짠다.
지연 최소화가 우선이면 ZGC(Z Garbage Collector) 로 간다. Java 15 부터 production-ready(JEP 377), Java 21 에서 generational 버전이 추가됐다(JEP 439). ZGC 는 colored pointer 와 load barrier 로 대부분의 작업을 concurrent 로 처리해 STW 를 1ms 이하 수준으로 유지한다. 대신 처리량 면에서 G1 보다 손해를 본다.
Java GC 의 특징은 GC 선택 자체가 튜닝 도구라는 점이다. 처리량을 원하면 G1, 지연을 원하면 ZGC, 작은 힙엔 Serial — 같은 코드를 GC 만 바꿔서 다른 운영 특성을 낸다.
Python GC
CPython 의 GC 출발점은 참조 횟수 계산 이다. 모든 객체는 자신을 가리키는 참조 개수를 헤더에 들고 있다. 참조가 새로 생기면 +1, 사라지면 -1. 카운트가 0 이 되는 순간 즉시 해제된다.
import sys
a = [1, 2, 3]
print(sys.getrefcount(a)) # 2 (a + getrefcount 의 인자)
b = a
print(sys.getrefcount(a)) # 3 (a + b + 인자)
del b
print(sys.getrefcount(a)) # 2
refcount 의 장점은 명확하다. 회수 시점이 결정적이고, STW 가 없으며, __del__ 같은 finalizer 가 예측 가능한 시점에 호출된다. 단점은 두 가지다. 첫째, 모든 참조 변경마다 카운트 갱신 비용이 발생한다. 둘째, 순환 참조를 처리하지 못한다.
class Node:
pass
a = Node()
b = Node()
a.next = b
b.next = a # 순환
del a
del b # a, b 의 외부 참조는 사라졌지만 서로를 참조해 refcount 가 0 이 되지 않음
이 빈틈을 메우기 위해 CPython 은 gc 모듈에 별도 cycle detector 를 둔다. 객체를 세 세대(generation 0, 1, 2)로 나누고, 주기적으로 generation 단위로 순환을 찾아 끊는다. detector 알고리즘은 객체의 임시 refcount 를 외부 참조만 남기도록 정리한 뒤 0 이 되는 객체를 회수하는 방식이다(gc 모듈 문서 참조).
대부분의 회수가 refcount 로 즉시 일어나고, cycle detector 는 가끔만 도는 보조 장치라는 점이 Python GC 의 특징이다. STW 가 거의 보이지 않는 대신 cycle detector 가 도는 동안에는 GIL 을 잡고 있어 멀티스레드 워크로드에서 갑작스러운 지연이 생길 수 있다.
Go GC
Go 의 GC 는 단일 알고리즘이다. 동시성 삼색 마크-스윕. 세대 구분이 없고, 매번 힙 전체를 훑는다. 설계 우선순위는 처음부터 지연 최소화였다.
flowchart LR
W[White
아직 안 본 객체] -->|root 에서 도달| G[Gray
본 객체 / 자식 미탐색]
G -->|자식 모두 탐색| B[Black
완전히 탐색됨]
W -->|cycle 끝까지 White| X[회수 대상]
style W fill:#FAFAFA
style G fill:#BDBDBD
style B fill:#424242,color:#fff
style X fill:#EF9A9A
탐색이 끝나면 White 로 남은 객체가 회수 대상이다. tri-color 가 어려운 이유는 mark 진행 중에 응용 프로그램(mutator)이 객체 참조를 바꾸기 때문이다. Black 객체가 새로 White 객체를 가리키게 되면 White 가 회수되지 않아야 함에도 회수돼버린다.
Go 는 이 문제를 write barrier 로 막는다. mutator 가 참조를 쓸 때 GC 가 후크를 걸어서 새로 가리키는 White 객체를 Gray 로 올린다. Go 1.5 가 처음 concurrent GC 를 도입했고, 1.8 부터 hybrid write barrier 로 STW 를 더 줄였다.
튜닝 인자는 사실상 둘이다. GOGC (기본 100) 는 다음 GC 가 시작될 힙 크기 비율 — 100 은 직전 회수 후 살아남은 메모리의 2배가 되면 다음 GC 가 도는 식이다. GOMEMLIMIT (Go 1.19 부터) 은 힙 상한을 잡아서 OOM(Out of Memory) 위험이 있을 때 GC 를 더 자주 돌리도록 강제한다.
Java 의 “GC 선택으로 튜닝” 과 달리 Go 는 단일 GC 안에서 두 환경 변수로만 조정한다. 선택지가 적은 만큼 의사결정이 단순하다.
세 모델 비교
세 GC 의 트레이드오프는 한 표에 잡힌다.
| 항목 | Java (G1/ZGC) | Python (CPython) | Go |
|---|---|---|---|
| 핵심 알고리즘 | Generational + region mark-sweep | Reference counting + generational cycle detector | Concurrent tri-color mark-sweep |
| 회수 시점 | GC 사이클이 결정 | refcount 0 즉시 + cycle 가끔 | GC 사이클이 결정 |
| 일반 STW | G1 수십 ms / ZGC 1ms 이하 | 거의 없음, cycle detector 시 짧게 | sub-ms 목표 |
| 처리량 | G1 우수 / ZGC 다소 손해 | refcount 갱신 비용 | mutator 와 동시 실행 |
| 튜닝 영역 | GC 종류 선택 + 영역별 옵션 | gc 모듈 임계값 / 사이클 비활성 | GOGC + GOMEMLIMIT |
| 멀티스레드 영향 | GC 스레드 별도 | cycle detector 가 GIL 점유 | concurrent, mutator 와 병행 |
Java GC 가 적합한 경우:
- 큰 힙(수십 GB 이상) 에서 처리량과 지연 사이를 GC 선택으로 직접 조율하고 싶을 때
- 운영팀이 GC 옵션을 깊이 튜닝할 수 있는 환경
- 일관된 처리량이 sub-ms 지연보다 우선
Python GC 가 적합한 경우:
- 객체 수명이 결정적인 게 중요한 워크로드 (파일/리소스 자동 정리)
- 단일 스레드 또는 I/O bound 워크로드
- 순환 참조가 거의 없는 도메인
Go GC 가 적합한 경우:
- 지연 sub-ms 가 SLA(Service Level Agreement) 인 서비스
- 튜닝 단순성이 우선
- 힙이 너무 크지 않고 (수 GB 수준) 처리량보다 응답성이 중요한 워크로드
세 GC 가 같은 일을 하지만 다른 우선순위 위에서 설계됐다. Java 는 GC 선택지를 늘려서 워크로드에 맞춰 튜닝하게 하고, Python 은 즉시 회수의 결정성을 사 가는 대신 멀티스레드 확장성을 GIL 에 묶었다. Go 는 단일 GC 로 단순함을 잡고 처리량을 일부 양보해 지연을 줄였다. 어느 GC 가 더 낫다기보다 운영 환경의 우선순위가 어디 있는지가 선택을 결정한다.
참고
- 모든 것은 값 복사다 — Go, Java, Python 의 메모리 전달 — 함수 인자 전달이 세 언어에서 어떻게 같은 패턴인지 다룬다. GC 가 회수하는 객체가 어디에 어떻게 자리잡는지가 그 메모리 모델 위에서 결정된다.