[Java] Garbage Collection

buckshot·2026년 4월 26일

JAVA

목록 보기
18/18
post-thumbnail

GC를 왜 알아야 하는가?

서비스를 운영하면 가끔 트래픽이 평소와 다르지 않은데 갑자기 응답 지연 발생, CPU 사용률 상승, 서비스 잠시 멈춤 등의 문제가 발생할 수 있다. 이 상황에서 콘솔을 봐도 에러에 대한 로그도 나타나지 않는다. 이러한 경우는 상당수 GC관련으로 발생된 문제이다.

GC는 Java 언어에서 큰 장점 중 하나이다. C/C++의 경우에는 개발자가 메모리를 직접 할당을 하고 해제를 해야 하지만, Java는 JVM이 알아서 사용되지 않는 객체를 수거하여 메모리를 관리하기 떄문에 개발자는 비교적으로 메모리 누수나 여러 문제에서 자유롭게 작업할 수 있다는 장점이 있다.

하지만, GC가 동작하는 동안 JVM은 일정 시간 어플리케이션 스레드를 멈추게 한다. 이러한 현상을 Stop-The-World (STW)라고 한다. 이러한 현상이 길어진다면 응답 지연, 타임아웃, 최악의 걍우에는 Out Of Memory Error)로 이어지게 된다.

GC를 이해하면 객체 생명 주기를 의식하면서 코드를 작성하게 되고, 장애 상황에서 GC로그를 읽어 빠르게 원인을 해결할 수 있다.


JVM 복습

JVM의 Runtime Data Area는 크게 다음으로 구성된다.

  • Method Area: 클래스 정보, static 변수, 상수 풀이 저장되는 영역. Java 8부터 Metaspace로 대체되어 네이티브 메모리를 사용한다.
  • Heap: 인스턴스 객체와 배열이 할당되는 영역. GC의 주요 활동 무대다.
  • Stack: 각 스레드마다 존재하며, 메서드 호출 시 스택 프레임이 쌓인다. 지역 변수와 참조가 여기에 저장된다.
  • PC Register / Native Method Stack: JVM 내부 동작을 위한 영역으로 GC와 직접적인 관련은 적다.


GC가 관리하는 핵심 영역은 Heap이다. Heap은 아래와 같은 구조를 갖는다.

Heap-Structure.png

  • Eden: 새로 생성된 객체가 최초로 할당되는 곳.
  • Survivor(S0, S1): Eden에서 살아남은 객체가 옮겨지는 곳. 두 개가 한 쌍으로 동작한다.
  • Old Generation: 오랫동안 살아남은 객체가 승격(Promotion)되어 관리되는 곳.


이 세대 구조는 GC의 핵심 가설인 "대부분의 객체는 금방 죽는다"는 관찰에 기반한다. 이를 약한 세대 가설(Weak Generational Hypothesis) 이라 한다.


GC 역활

GC의 본질적인 역할은 Heap 메모리에서 더 이상 사용되지 않는 객체를 찾아 제거하고, 메모리를 재사용 가능한 상태로 만드는 것이다.

"더 이상 사용되지 않는 객체"를 어떻게 판단하는가? JVM은 도달 가능성(Reachability) 개념을 사용한다. GC Root에서 시작해 참조 관계를 따라갔을 때 도달할 수 있으면 살아있는(Live) 객체, 도달할 수 없으면 죽은(Dead) 객체로 판단한다.

GC Root의 종류:

  • 현재 실행 중인 스레드의 스택에 있는 지역 변수 및 파라미터
  • 메서드 영역(Metaspace)의 static 변수
  • JNI 참조
GC Root
  │
  ├──▶ Object A (Live)
  │       └──▶ Object B (Live)
  │
  └──▶ Object C (Live)

Object D (Dead) ← 아무도 참조하지 않음

Object E (Dead) ← D만 참조하고 있었음

GC는 D와 E를 수거 대상으로 판단하고 메모리에서 제거한다. 참조 카운팅(Reference Counting) 방식은 순환 참조 문제가 있어 현대 JVM에서는 사용하지 않는다.


동작 원리

Minor GC

young Generation, 특히 Eden이 포화되면 Minor GC가 발생된다.

  1. Eden의 살아있는 객체를 survivor 영역(S0 or S1 중 비어있는 쪽)으로 복사한다.
  2. 이미 Survivor에 있는 살아있는 객체도 함께 복사하며 age()를 1 증가한다.
  3. Eden과 이전 Survivor는 통째로 비운다.
  4. age()의 임계값이 넘는 객체는 Old Generation으로 승격(Promter)된다.

    Minor GC는 Young Generation만 대상으로 하기 때문에 빠르게 끝난다. 하지만 STW는 발생한다.

Major GC / Full GC

old Generation이 꽉 차거나 일정 임계치에 도달하면 Major GC(또는 Full GC)가 발생한다. 범위가 넓어 Minor GC보다 훨씬 오래 걸리며, STW 시간도 길다.

Full GC는 일반적으로 Young과 Old Generation을 포함한 Heap 전체를 대상으로 수행되며,
경우에 따라 Metaspace까지 영향을 줄 수 있다. 수행 비용이 크기 때문에
발생 빈도가 낮을수록 이상적이다.

Mark-Sweep-Compact

GC의 고전적인 3단계 알고리즘이다.

1 Mark: GC Root에서 시작해 살아있는 객체에 표시한다.
2 Sweep: 표시되지 않은 객체를 제거한다.
3 Compact: 남아있는 객체들을 한쪽으로 모아 단편화(Fragmentation)를 해소한다.

[객체 존재]
 O O X O X
 ↓
[Mark]
 O O _ O _
 ↓
[Sweep]
 O O   O 
 ↓
[Compact]
 O O O


Compact 단계는 비용이 비싸기 때문에, 일부 GC는 Compact 대신 Copy(복사) 방식을 사용한다. Young Generation의 Survivor 방식이 대표적이다.

Stop-The-World

GC가 동작하는 동안 JVM은 GC 스레드 외의 모든 애플리케이션 스레드를 일시 정지시킨다. 이것이 STW다. STW가 발생하는 이유는 GC가 힙을 스캔하는 도중 객체가 생성·수정되면 일관성 있는 스냅샷을 얻을 수 없기 때문이다.

STW를 줄이는 것이 현대 GC 설계의 핵심 목표다.


GC 종류

Serial GC

단일 스레드로 GC를 수행한다. 클라이언트 애플리케이션이나 단일 CPU 환경을 위해 설계되었다. -XX:+UseSerialGC로 활성화한다. 서버 환경에는 적합하지 않다.

Parallel GC (Throughput Collector)

Minor GC와 Major GC 모두 여러 스레드로 병렬 수행한다. STW는 발생하지만 GC 자체가 빠르게 끝난다. 처리량(Throughput) 최대화가 목표다. Java 8의 기본 GC였다. -XX:+UseParallelGC로 활성화한다.

CMS (Concurrent Mark Sweep) GC

Old Generation의 GC를 애플리케이션 스레드와 동시에(Concurrent) 수행하여 STW를 최소화한다. 단, Compact 단계가 없어 메모리 단편화가 발생하고, CPU를 더 많이 사용한다. Java 9부터 Deprecated되었고 Java 14에서 제거되었다.

G1 GC (Garbage First)

Java 9부터 기본 GC다. 힙을 Region이라는 고정 크기의 작은 블록으로 나눈다. Young/Old 구분이 고정적이지 않고 Region 단위로 동적으로 할당된다. 가비지가 많은 Region부터 우선적으로 수거하는 방식으로 예측 가능한 STW 시간을 목표로 한다.

[ G1 Heap 구조 ]
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ E │ E │ S │ O │ O │ H │ E │ O │
└───┴───┴───┴───┴───┴───┴───┴───┘

E: Eden, S: Survivor, O: Old, H: Humongous(대형 객체)

-XX:MaxGCPauseMillis로 STW 목표 시간을 설정할 수 있다는 점이 실무에서 매우 유용하다.

ZGC

Java 15부터 정식 지원. STW를 수 밀리초 이하로 유지하는 것을 목표로 한다. 대부분의 GC 작업을 애플리케이션 스레드와 동시에 수행한다. TB급 힙에서도 안정적이다. 지연 시간이 극도로 중요한 서비스에 적합하다.

Shenandoah GC

ZGC와 유사한 목표를 가진 저지연 GC. Red Hat이 개발했으며 OpenJDK 12부터 포함되었다. Compact 단계도 동시에 수행하는 것이 특징이다.

비교 요약

GCSTW 목표기본 적용 버전특징
Serial없음-단일 스레드, 소규모
Parallel처리량 최대Java 8멀티 스레드, 배치에 적합
CMS짧은 STW(Deprecated)동시 수행, 단편화 문제
G1예측 가능 STWJava 9 이후Region 기반, 범용
ZGC수 ms 이하Java 15초저지연, 대용량 힙
Shenandoah수 ms 이하Java 12동시 Compact

개선 방법

Full GC를 줄이는 방법

Full GC가 자주 발생한다면 다음 원인을 먼저 의심한다.

  • 메모리 누수: 캐시, static 컬렉션, 리스너 등록 후 해제 누락이 흔한 원인이다.
  • Promotion Failure: Survivor가 꽉 차서 Old로 조기 승격이 과도하게 발생하는 경우. Young Generation 크기를 늘린다.
  • Humongous Object: G1에서 Region 크기의 절반을 초과하는 대형 객체는 직접 Old에 할당된다. Region 크기를 키우거나 대형 객체 생성을 줄인다.

코드 레벨에서 할 수 있는 것

튜닝은 JVM 옵션만의 영역이 아니다. 코드 수준에서도 GC 부담을 줄일 수 있다.

  • 불필요한 객체 생성을 줄인다. 루프 안에서 동일한 객체를 반복 생성하지 않는다.
  • String 연산에는 StringBuilder를 사용한다.
  • 대용량 캐시는 WeakReference나 외부 캐시(Redis 등)를 고려한다.
  • 스트림을 열었다면 반드시 닫는다(try-with-resources).

profile
let's go insane

0개의 댓글