GC는 무엇을 지우나 — 안 쓰는 객체가 아니라 닿지 않는 객체

@JavaPark · 2026년 8월 18일 · 14 min read

가비지 컬렉션 커버 — 루트에서 참조를 따라 닿는 객체는 살고 끊긴 객체는 회수되는 구조
가비지 컬렉션 커버 — 루트에서 참조를 따라 닿는 객체는 살고 끊긴 객체는 회수되는 구조

안녕하세요, 자바파커입니다.

"메모리는 GC가 알아서 정리해 주는 거 아닌가요? 그런데 왜 OutOfMemoryError가 나죠?"

정리해 줍니다. 다만 여러분이 생각하는 기준으로 정리하지 않습니다.

결론부터 말씀드리면 — GC는 "안 쓰는 객체"를 지우는 게 아니라 "닿을 수 없는 객체"를 지웁니다. 이 둘은 다릅니다. 그리고 그 차이가 메모리 누수의 정체입니다.

참조 하나가 끊기면서 거기 매달린 객체까지 통째로 섬이 되는 장면을 눈으로 보고 싶으시면 52초짜리 영상으로도 정리했습니다. 왼쪽은 회수되고 오른쪽은 안 죽는 이유를 같은 화면에서 비교합니다.

GC 영상 — 참조가 끊겨 섬이 된 객체는 회수되고, static이 붙잡은 객체는 안 쓰는데도 살아남는 화면
GC 영상 — 참조가 끊겨 섬이 된 객체는 회수되고, static이 붙잡은 객체는 안 쓰는데도 살아남는 화면


GC의 판단 기준 — 쓰는가가 아니라 닿는가

GC는 여러분의 의도를 모릅니다. "이 객체는 이제 안 쓸 거야"를 알 방법이 없습니다. 대신 기계적으로 확인 가능한 것 하나를 봅니다.

지금 실행 중인 코드에서 참조를 타고 저 객체까지 갈 수 있는가?

갈 수 있으면 살리고, 없으면 회수합니다. 이걸 도달 가능성(reachability) 이라고 합니다.

출발점 — GC Root

"갈 수 있다"를 따지려면 출발점이 있어야 합니다. 그게 GC Root입니다.

GC Root
실행 중인 스레드의 스택 메서드 안의 지역 변수, 파라미터
static 필드 static Map<String, Session> CACHE
JNI 참조 네이티브 코드가 쥐고 있는 객체
실행 중인 스레드 객체 자체 살아 있는 Thread

두 단계 — 표시하고, 쓸어낸다

  1. Mark — 모든 GC Root에서 출발해 참조를 따라가며 닿는 객체에 표시를 남깁니다. 그래프 순회입니다.
  2. Sweep — 표시가 없는 객체를 회수합니다.
void handle() {
    var a = new Order();      // 지역변수 a 가 루트 → 도달 가능
    var b = new Payment();
    a.setPayment(b);          // a 를 통해 b 에도 도달 가능

    a = null;                 // 이 순간 a 도 b 도 루트에서 끊긴다
}                             // 둘 다 회수 대상

여기서 중요한 건 b를 직접 끊지 않았는데도 회수된다는 점입니다. 참조 그래프에서 섬처럼 떨어져 나가면, 그 섬 전체가 통째로 회수됩니다. 객체끼리 서로를 참조하고 있어도 마찬가지입니다 — 루트에서 그 섬으로 들어가는 길이 없으면 전부 쓰레기입니다.

참조 카운팅(Python·Swift)은 이 순환 참조를 스스로 못 풉니다. 그래서 Python은 참조 카운팅에 순환 수집기를 따로 얹어 씁니다. JVM처럼 그래프를 순회하는 방식(tracing)은 순환이 애초에 문제가 되지 않습니다.


왜 세대로 나누나

객체 전체를 매번 순회하면 비쌉니다. 그래서 GC는 경험칙 하나에 기댑니다.

약한 세대 가설 — 대부분의 객체는 만들어지자마자 죽는다.

요청 하나 처리하면서 만든 DTO, 임시 리스트, 문자열… 거의 전부 그 요청이 끝나면 쓰레기가 됩니다. 반대로 오래 살아남은 객체(캐시, 커넥션 풀, 스프링 빈)는 앞으로도 오래 삽니다.

그래서 힙을 나눕니다.

영역 무엇이 있나 수집 빈도
Young (Eden + Survivor) 갓 만든 객체 자주, 짧게 (Minor GC)
Old 여러 번 살아남아 승격된 객체 드물게, 길게 (Major/Full GC)

새 객체는 Eden에 놓입니다. Eden이 차면 Minor GC가 돌고, 살아남은 것만 Survivor로 옮깁니다. 몇 번 더 살아남으면 Old로 승격됩니다.

핵심은 "살아남은 것만 옮긴다"는 부분입니다. 대부분 죽으므로 옮길 게 별로 없고, 그래서 Minor GC는 빠릅니다. 죽은 객체를 지우는 비용은 들지 않습니다 — 그냥 안 옮기면 끝이니까요.


Stop-the-World — 왜 멈추나

GC가 참조 그래프를 훑는 도중에 애플리케이션이 참조를 바꿔 버리면, 방금 확인한 결과가 거짓이 됩니다. 살아 있는 객체를 쓰레기로 판단하면 그 순간 프로그램이 깨집니다.

그래서 일부 구간에서는 애플리케이션 스레드를 전부 멈춥니다. 이게 Stop-the-World(STW)입니다.

이 멈춤이 실무에서 나타나는 모습:

  • 평균 응답은 멀쩡한데 p99만 튄다
  • 부하와 무관하게 주기적으로 지연이 생긴다
  • 힙을 키웠더니 횟수는 줄었는데 한 번이 더 길어졌다

마지막 항목이 중요합니다. 힙을 키우는 건 공짜가 아닙니다. 볼 게 많아지니 한 번의 정지가 길어집니다. 최신 컬렉터들은 이 문제를 "그래프 순회를 애플리케이션과 동시에 수행"하는 방식으로 줄이고 있습니다.


GC가 있는데 왜 메모리 누수가 나나

여기가 실무에서 제일 자주 걸리는 지점입니다. 앞의 원리를 그대로 뒤집으면 답이 나옵니다.

GC는 "닿을 수 있는" 객체를 절대 지우지 않습니다. 여러분이 안 쓴다고 해도요.

즉 자바의 메모리 누수는 "안 쓰는데 참조가 남아 있는 상태" 입니다. 흔한 네 가지입니다.

1. static 컬렉션에 넣고 안 뺀다

static final Map<String, Session> SESSIONS = new HashMap<>();

void login(User u) {
    SESSIONS.put(u.getId(), new Session(u));   // 넣기만 하고
}                                              // 로그아웃 때 remove 안 하면 영원히 산다

static은 GC Root입니다. 여기 걸린 건 애플리케이션이 살아 있는 한 절대 회수되지 않습니다.

2. 캐시에 상한이 없다

만료도 없고 최대 크기도 없는 캐시는 그냥 천천히 자라는 누수입니다. Map을 직접 캐시로 쓰지 마시고, TTL과 최대 크기를 주는 캐시 라이브러리를 쓰세요.

3. 리스너·콜백을 등록하고 해제하지 않는다

등록하는 쪽이 대상 객체를 참조합니다. 해제하지 않으면 대상이 계속 살아 있습니다. 등록과 해제는 항상 짝으로 다뤄야 합니다.

4. ThreadLocal 을 정리하지 않는다

스레드 풀에서는 스레드가 재사용되므로, remove() 하지 않으면 값이 스레드 수명만큼 남습니다. 요청이 끝나는 지점(필터·인터셉터)에서 반드시 정리해야 합니다.

네 가지의 공통점은 하나입니다 — 전부 "참조를 남긴 쪽"의 문제이지 GC의 문제가 아닙니다.


튜닝하기 전에 볼 것

GC 옵션부터 만지는 건 대개 순서가 틀렸습니다.

  1. 왜 늘어나는지 먼저 봅니다. 힙 사용량이 톱니 모양으로 오르내리면 정상입니다. GC 후에도 바닥이 계속 올라가면 그건 튜닝 대상이 아니라 누수입니다.
  2. 누수라면 힙 덤프를 봅니다. 무엇이 얼마나 쌓였고, 그것을 붙잡고 있는 참조 경로가 무엇인지(GC Root까지의 경로)를 확인합니다. 도구가 이 경로를 그려 줍니다.
  3. 누수가 아니라면 그때 컬렉터와 힙 크기를 검토합니다.

내 런타임의 기본값 확인하기

컬렉터 기본값은 JDK 버전마다 달라지고, 인터넷 글은 금방 낡습니다. 직접 확인하는 게 확실합니다.

# 지금 어떤 컬렉터가 켜져 있나
java -XX:+PrintFlagsFinal -version | grep -E "Use.*GC"

# GC 로그 남기기 (JDK 9+)
java -Xlog:gc*:file=gc.log:time,uptime -jar app.jar

큰 그림만 말씀드리면 — G1은 JDK 9부터 오랫동안 기본 컬렉터 자리를 지켜 왔고 메모리 효율이 좋습니다. ZGC는 힙 크기와 거의 무관하게 정지 시간을 아주 짧게 유지하는 대신 메모리와 CPU를 더 씁니다(JDK 21에서 세대별 방식이 들어갔습니다). 지연 시간이 실제로 문제일 때 바꾸는 것이지, 기본값이 나빠서 바꾸는 게 아닙니다.


자주 묻는 것

System.gc() 를 부르면 되나요?

권장하지 않습니다. 요청일 뿐 강제가 아니고, 실행되면 대개 Full GC라 오히려 크게 멈춥니다. 운영 코드에 있다면 지우는 쪽이 맞습니다.

null 을 대입하면 도움이 되나요?

거의 아닙니다. 지역 변수는 메서드가 끝나면 어차피 스택에서 사라집니다. 의미가 있는 경우는 오래 사는 객체의 필드입니다 — 긴 수명의 객체가 짧게 쓸 큰 객체를 붙잡고 있을 때는 명시적으로 끊어 줄 이유가 있습니다.

자바 말고 다른 언어도 같나요?

도달 가능성이라는 원리는 같습니다. Go·자바스크립트도 그래프를 순회하는 방식입니다. Python·Swift는 참조 카운팅이 기본이고, 순환 참조를 처리하는 장치가 따로 붙습니다. 그래서 "참조를 남기면 안 지워진다"는 결론은 어느 언어에서나 그대로입니다.


정리하며

GC를 한 문장으로 줄이면 이렇습니다. 닿을 수 없으면 쓰레기다.

여기서 나머지가 전부 따라옵니다. 대부분의 객체가 금방 닿지 않게 되니까 세대로 나누는 게 이득이고, 닿는지 확인하는 동안 그래프가 바뀌면 안 되니까 잠깐 멈춰야 하고, 참조를 남겨 두면 안 쓰는데도 닿으니까 누수가 납니다.

그래서 실무에서 할 일은 GC 옵션을 만지는 게 아니라, 무엇이 무엇을 붙잡고 있는지 확인하는 것입니다.

여러분은 메모리 문제를 어디서 잡으셨나요? 원인이 이 네 가지 중 하나였나요?


참고 자료

@JavaPark
AI 시대의 개발자 도구, 실전 경험을 공유합니다