세 언어 모두 가비지 컬렉터(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-sweepReference counting + generational cycle detectorConcurrent tri-color mark-sweep
회수 시점GC 사이클이 결정refcount 0 즉시 + cycle 가끔GC 사이클이 결정
일반 STWG1 수십 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 가 더 낫다기보다 운영 환경의 우선순위가 어디 있는지가 선택을 결정한다.

참고