---
title: "고의 새 가비지 컬렉터 ‘그린 티’, 힙을 훑는 방식이 어떻게 달라졌나"
published: 2026-07-25T07:55:19.000Z
canonical: https://jeff.news/article/5139
---
# 고의 새 가비지 컬렉터 ‘그린 티’, 힙을 훑는 방식이 어떻게 달라졌나

고 1.25에서 도입되고 1.26에서 기본값이 된 새 가비지 컬렉터 ‘그린 티’를 실제 힙 배치와 성능 측정으로 뜯어본 글이다. 핵심은 포인터를 무작정 따라가는 대신 메모리 span 단위로 훑어 캐시 친화성을 높였다는 점이고, 동시에 고의 비이동식 컬렉터가 여전히 sparse page 회수에 약하다는 한계도 같이 보여준다.

## 고 힙을 직접 걸어보면 런타임의 성격이 보임

- 고는 객체를 아무 데나 흩뿌리는 게 아니라, 비슷한 크기 객체끼리 꽤 질서 있게 모아 둠
  - 객체 크기는 size class로 반올림되고, 같은 size class 객체들은 하나 이상의 8KiB page로 구성된 span 안에 배치됨
  - 이 방식은 고 allocator가 영향을 받은 tcmalloc 계열에서도 흔한 size-segregated allocation 스타일임

- 글쓴이는 작은 객체, 중간 객체, 큰 객체를 랜덤하게 할당한 뒤 힙 주소를 직접 걸어보는 실험을 함
  - 32바이트 단위로 주소 공간을 훑으면서 자기 객체를 만나면 문자로 찍는 식임
  - 랜덤하게 할당했는데도 출력 결과를 보면 같은 크기 객체가 서로 가까이 붙어 있음

- 여기서 중요한 포인트는 GC를 돌려도 고는 객체를 옮기지 않는다는 거임
  - C# 예제와 비교하면 차이가 더 선명해짐
  - C#은 같은 크기 객체가 고처럼 딱딱 모여 있지 않고, 다른 workload에서는 객체 이동도 관찰됨

## 그린 티는 ‘포인터를 따라간다’는 습관을 바꿈

- 고의 전통적인 GC는 Mark and Sweep 방식임
  - globals, locals 같은 root에서 시작해 도달 가능한 객체를 mark함
  - 이후 sweep 단계에서 mark되지 않은 객체를 죽은 객체로 보고 해제함

- 문제는 포인터를 따라가는 방식이 캐시에 별로 안 착하다는 데 있음
  - A 객체가 B, C, D처럼 서로 다른 크기의 객체를 가리키면, 이 객체들은 고 힙의 서로 다른 영역에 있을 가능성이 큼
  - 같은 타입 A끼리 연결돼 있어도 생성 시점이 많이 다르면 메모리상으로 멀리 떨어질 수 있음
  - GC가 포인터를 만나는 대로 따라가면 결과적으로 랜덤 메모리 접근이 늘어남

- 그린 티는 이 흐름을 span 중심으로 바꿈
  - 포인터 하나를 발견하자마자 곧장 따라가기보다, span 안의 객체와 포인터를 훑고 다음에 스캔할 span을 큐에 넣는 식임
  - 말하자면 ‘객체 그래프 산책’보다 ‘메모리 구역 단위 청소’에 가까워짐

> [!IMPORTANT]
> 그린 티의 핵심은 GC 알고리즘 이름이 멋져졌다는 게 아니라, mark 단계의 메모리 접근 패턴을 캐시 친화적으로 바꿨다는 데 있음.

## perf 숫자는 그냥 보면 함정이 있음

- 글쓴이는 두 가지 workload를 만들고 기존 GC와 그린 티를 비교함
  - 하나는 포인터 접근이 비교적 잘 모인 경우고, 다른 하나는 랜덤하게 흩어진 경우임
  - 랜덤 인덱스 생성 비용이 측정에 섞이지 않도록 Python으로 미리 index 파일을 만들어 둠

- 처음 perf 결과에서는 기대와 다르게 cache miss가 늘어난 것처럼 보임
  - perf의 cache-references와 cache-misses는 보통 L3 캐시 기준으로 해석되는 경우가 많음
  - 그런데 실제로는 프로그램 실행 시간이 이미 빨라졌고, L1이나 L2 레벨의 변화가 가려져 있었음

- 그래서 instruction 수를 같이 보고 MPKI로 정규화했지만, VM 환경에서는 여전히 결정적인 그림이 잘 안 나옴
  - L1 이벤트를 보려면 PMU counter가 필요한데, 많은 VM이 이걸 제대로 노출하지 않음
  - 글쓴이는 결국 Vultr bare metal x86/amd64 Linux 머신으로 옮겨 L1-dcache-loads와 L1-dcache-load-misses를 직접 봄

- bare metal에서야 기대한 결과가 나옴
  - L1 cache miss percentage는 같거나 줄어드는 수준이었지만, L1 MPKI는 더 뚜렷하게 감소함
  - 더 많은 read가 L1이나 L2에서 해결되면서 L3 cache miss가 덜 중요해진 셈임

## 그래도 고 GC의 오래된 약점은 남아 있음

- 고는 non-moving collector라서 메모리를 compact하거나 defragment하지 않음
  - 이 설계는 포인터 안정성이나 런타임 단순성 측면에서는 장점이 있음
  - 대신 객체를 많이 지워도 남은 객체가 page 곳곳에 듬성듬성 박혀 있으면 page 전체를 회수하기 어려움

- 글쓴이는 처음 힙 시각화 예제를 변형해 객체의 90퍼센트를 해제하는 실험을 함
  - 기대만 보면 메모리 사용량도 90퍼센트 가까이 줄어야 할 것 같음
  - 실제로는 고가 객체를 옮기지 않기 때문에 빈 공간이 생겨도 OS에 깔끔하게 돌려주지 못하는 경우가 생김

- 단순한 경우에는 사용자가 직접 객체를 새 위치로 복사해 단편화를 줄일 수도 있음
  - 물론 이건 애플리케이션 코드에서 항상 쓸 수 있는 일반 해법은 아님
  - 그래도 문제의 본질이 ‘GC가 안 돈다’가 아니라 ‘살아남은 객체 배치 때문에 page가 회수되지 않는다’는 걸 잘 보여줌

- C# 샘플에서 90퍼센트 객체를 지웠을 때는 compacting GC의 장점이 더 잘 드러남
  - 객체 이동이 가능한 런타임은 남은 객체를 모아 메모리 회수를 더 잘할 수 있음
  - 반대로 고는 낮은 지연과 단순한 런타임 모델을 얻는 대신 이런 최악 케이스를 감수하는 쪽임

---

## 기술 맥락

- 고가 그린 티에서 바꾼 건 ‘무엇을 지울까’보다 ‘어떤 순서로 읽을까’에 가까워요. GC가 하는 일은 여전히 살아 있는 객체를 찾고 죽은 객체를 치우는 거지만, 큰 힙에서는 같은 일을 해도 메모리를 어떤 패턴으로 읽느냐가 실행 시간을 꽤 갈라요.

- 왜 span 단위 접근이 중요하냐면 CPU 캐시는 근처 메모리를 한꺼번에 가져오는 쪽에 유리하거든요. 포인터를 따라 힙 여기저기를 뛰어다니면 L1이나 L2에서 못 잡고 더 먼 캐시나 메모리까지 내려갈 확률이 커져요.

- 기사에서 perf 해석이 길게 나오는 이유도 이 때문이에요. L3 miss 비율만 보면 그린 티가 별로인 것처럼 보일 수 있는데, 실제 병목이 L1 쪽에서 풀렸다면 L3 숫자만으로는 결론을 내리기 어려워요.

- 고가 여전히 객체를 옮기지 않는다는 점도 의도적인 선택이에요. 객체 이동이 없으면 런타임과 포인터 모델은 단순해지지만, 오래 살아남은 객체가 page 곳곳에 남는 workload에서는 메모리 회수가 생각보다 안 될 수 있어요.

## 핵심 포인트

- 고 런타임은 같은 크기 클래스의 객체를 8KiB page 기반 span에 모아 배치함
- 그린 티는 포인터 추적 순서를 바꿔 랜덤 메모리 접근을 줄이고 L1 캐시 효율을 개선함
- 일반 perf의 L3 지표만 보면 개선이 잘 안 보이고, bare metal에서 L1 지표를 봐야 변화가 드러남
- 고의 GC는 객체를 이동하지 않기 때문에 90퍼센트 객체를 해제해도 메모리 단편화 때문에 회수가 제한될 수 있음
- C#처럼 compacting GC가 있는 런타임과 비교하면 고의 설계 장단점이 꽤 선명하게 보임

## 인사이트

고 GC 이야기는 보통 ‘빠르다/느리다’로 끝나는데, 이 글은 힙 주소를 직접 걸어보고 캐시 계층까지 내려가서 왜 빨라지는지 보여주는 게 맛있다. 서버 고 개발자라면 메모리 사용량이 줄지 않는 상황을 GC 탓으로만 뭉개기 전에 단편화와 object layout을 같이 봐야 함.
