GC(가비지 컬렉션) 동작 원리 완전 정리 – 면접 대비·실무 적용


GC 동작 원리는 Java 백엔드 개발자라면 반드시 넘어야 할 핵심 관문입니다. “GC가 뭔지 설명해보세요”, “Stop-the-World가 왜 문제인가요?”, “G1GC와 ZGC의 차이는?” — 이 질문들은 신입부터 시니어까지 면접장에서 어김없이 등장합니다. 단순히 “사용하지 않는 메모리를 자동으로 해제한다”는 한 줄 답변으로는 중급 이상 개발자 평가를 통과할 수 없습니다. GC가 어떤 알고리즘으로 쓰레기를 판별하고, JVM 힙 메모리 구조와 어떻게 연동되며, 실제 서비스에서 Stop-the-World 문제를 어떻게 줄이는지까지 연결해서 설명할 수 있어야 합니다. 이 글에서는 GC의 기초 개념부터 최신 저지연 GC까지 한 번에 정리합니다.


목차

  1. GC란 무엇인가 – 왜 필요하고 어떤 문제를 해결하는가
  2. GC 판별 알고리즘 – 쓰레기를 찾아내는 두 가지 방법
  3. JVM 힙 메모리 구조 – GC가 작동하는 공간
  4. GC 수집 알고리즘 – Mark·Sweep·Compact·Copy
  5. GC 종류별 완전 비교 – Serial·Parallel·G1·ZGC
  6. 실무 심화 – Stop-the-World 줄이기와 GC 튜닝

1. GC란 무엇인가 – 왜 필요하고 어떤 문제를 해결하는가

GC 동작 원리를 이해하기 전에, GC가 해결하는 근본 문제부터 파악해야 합니다. 문제를 정확히 알아야 해결책의 설계 이유를 이해할 수 있습니다.

수동 메모리 관리의 문제점

C·C++ 같은 언어에서는 개발자가 직접 메모리를 할당(malloc)하고 해제(free)합니다. 이 방식에서는 두 가지 치명적인 문제가 발생합니다.

첫째, **메모리 누수(Memory Leak)**입니다. 개발자가 free를 호출하지 않으면, 사용이 끝난 메모리가 해제되지 않고 계속 점유됩니다. 장기간 실행되는 서버 애플리케이션에서 메모리 누수가 쌓이면 결국 OOM(Out of Memory) 오류로 프로세스가 죽습니다.

둘째, **댕글링 포인터(Dangling Pointer)**입니다. 이미 해제한 메모리 주소를 가리키는 포인터를 사용하면 예측 불가능한 동작이나 보안 취약점이 발생합니다.

Java는 이 두 문제를 GC로 해결합니다. 개발자가 new로 객체를 생성하면 JVM이 힙 메모리에 공간을 할당하고, 그 객체가 더 이상 필요 없어지면 JVM이 자동으로 메모리를 회수합니다.

GC의 핵심 질문 – 무엇이 쓰레기인가

GC의 가장 핵심적인 판단은 어떤 객체가 더 이상 사용되지 않는가입니다. Java에서 객체는 참조(Reference)를 통해 사용됩니다. 아무도 참조하지 않는 객체는 이후 코드에서 절대 접근할 수 없으므로 쓰레기(Garbage)입니다.

java

public void createGarbage() {
    String s = new String("Hello"); // 힙에 "Hello" 객체 생성
    s = new String("World");        // s가 "World"를 가리킴
    // "Hello" 객체는 아무도 참조하지 않음 → GC 대상
}
// 메서드 종료 후 s도 스택에서 제거 → "World"도 GC 대상

이 ‘아무도 참조하지 않는다’를 어떻게 판별하느냐가 GC 알고리즘의 핵심입니다.

GC의 트레이드오프 – 자동화의 대가

GC는 강력한 편의성을 제공하지만, 공짜가 아닙니다. GC가 실행될 때는 일정 시간 동안 애플리케이션이 멈추는 Stop-the-World(STW) 현상이 발생합니다. 이 멈춤이 길면 사용자는 응답 지연을 경험합니다. Java GC 역사의 대부분은 이 STW를 최소화하기 위한 알고리즘 진화의 역사입니다.


2. GC 판별 알고리즘 – 쓰레기를 찾아내는 두 가지 방법

쓰레기 객체를 판별하는 알고리즘은 크게 두 가지로, 참조 카운팅과 **도달 가능성 분석(Reachability Analysis)**입니다.

참조 카운팅(Reference Counting)

가장 단순한 방법입니다. 각 객체가 현재 몇 개의 참조를 받고 있는지를 카운터로 관리합니다. 카운터가 0이 되는 순간 즉시 메모리를 해제합니다.

객체 A 생성 → 참조 카운트 = 1
변수 B도 A를 참조 → 참조 카운트 = 2
변수 A 참조 해제 → 참조 카운트 = 1
변수 B 참조 해제 → 참조 카운트 = 0 → 즉시 해제

직관적이고 즉시 해제된다는 장점이 있습니다. 하지만 치명적인 한계가 있습니다. 순환 참조(Circular Reference)문제입니다.

java

class Node {
    Node next;
}

Node a = new Node();
Node b = new Node();
a.next = b; // b의 참조 카운트 = 1
b.next = a; // a의 참조 카운트 = 1

a = null;   // a의 참조 카운트 = 1 (b가 참조 중이라 0이 아님!)
b = null;   // b의 참조 카운트 = 1 (a가 참조 중이라 0이 아님!)
// a와 b는 서로만 참조하고 있어 실제로는 쓰레기지만 해제 안 됨!

Java는 이 한계 때문에 참조 카운팅을 주 GC 방식으로 사용하지 않습니다. Python은 참조 카운팅 + 순환 참조 감지 GC를 함께 사용합니다.

도달 가능성 분석(Reachability Analysis) – Java의 선택

Java GC의 핵심 판별 방식입니다. GC Roots라는 출발점에서 시작해 참조 체인을 따라 탐색할 수 있는 객체는 살아있는(Reachable) 객체이고, 탐색할 수 없는 객체는 쓰레기(Unreachable)로 판별합니다.

GC Roots:
├── 현재 실행 중인 메서드의 스택 지역변수
├── 정적(static) 변수
├── JNI 참조
└── 활성 스레드 객체

GC Roots → 객체 A → 객체 B → 객체 C  (모두 Reachable, 생존)
             객체 D (GC Roots에서 도달 불가 → Unreachable, 쓰레기)

순환 참조 문제를 완벽히 해결합니다. A→B→A로 순환 참조하더라도, GC Roots에서 이 두 객체에 도달할 수 없다면 모두 쓰레기로 판별합니다.


3. JVM 힙 메모리 구조 – GC가 작동하는 공간

GC는 힙(Heap) 메모리 위에서 작동합니다. JVM 힙 메모리의 구조를 이해하면 Minor GC·Major GC·Full GC의 차이, 그리고 세대별 GC의 원리가 자연스럽게 이해됩니다.

JVM 메모리 전체 구조

┌─────────────────────────────────────────────┐
│                   JVM Memory                │
│  ┌─────────────────────────────────────┐   │
│  │              Heap Area              │   │  ← GC 대상
│  │  ┌──────────────┐  ┌────────────┐  │   │
│  │  │  Young Gen   │  │  Old Gen   │  │   │
│  │  │ ┌──┐┌──┬──┐ │  │            │  │   │
│  │  │ │E0││S0│S1│ │  │            │  │   │
│  │  │ └──┘└──┴──┘ │  │            │  │   │
│  │  └──────────────┘  └────────────┘  │   │
│  └─────────────────────────────────────┘   │
│  ┌─────────────────────────────────────┐   │
│  │         Metaspace (Non-Heap)        │   │  ← 클래스 메타데이터
│  └─────────────────────────────────────┘   │
│  ┌──────────┐  ┌──────────────────────┐   │
│  │  Stack   │  │   Method Area / etc  │   │  ← 스레드별 스택
│  └──────────┘  └──────────────────────┘   │
└─────────────────────────────────────────────┘

Young Generation – 새 객체의 요람

Young Generation은 새로 생성된 객체가 처음 배치되는 공간입니다. 세 구역으로 나뉩니다.

Eden 영역new 키워드로 생성된 객체가 최초로 배치됩니다. Eden이 가득 차면 Minor GC가 발생합니다.

Survivor 0(S0), Survivor 1(S1): Minor GC에서 살아남은 객체들이 이동하는 공간입니다. 항상 둘 중 하나만 사용되고 나머지는 비어있습니다.

Minor GC 동작 과정

1. Eden 가득 참 → Minor GC 발생
2. Eden의 살아있는 객체를 S0으로 복사
3. S0의 살아있는 객체를 S1으로 복사 (Age +1)
4. Eden과 S0을 완전히 비움
5. 다음 Minor GC에서는 S1 → S0 방향으로 반전
6. Age가 임계값(기본 15) 초과 → Old Gen으로 승격(Promotion)

Minor GC는 Young Gen만 대상으로 하기 때문에 빠릅니다. 대부분의 객체가 Young Gen에서 생성·소멸되는 특성상, Minor GC만으로 힙의 대부분을 관리합니다.

Old Generation – 장수 객체의 공간

Minor GC를 여러 번 살아남아 Age 임계값을 초과한 객체들이 Old Gen으로 이동합니다. 설정 정보, 캐시, 커넥션 풀처럼 애플리케이션 생명주기 동안 유지되는 장수 객체들이 여기 모입니다.

Old Gen이 가득 차면 **Major GC(또는 Full GC)**가 발생합니다. Old Gen은 Young Gen보다 훨씬 크고, GC 대상 객체 탐색 범위가 넓어 Stop-the-World 시간이 길어집니다. 이것이 GC 성능 최적화의 핵심 문제입니다.

약한 세대 가설(Weak Generational Hypothesis)

세대별 힙 구조의 이론적 근거입니다. 실증 데이터에 따르면 대부분의 객체는 생성 직후 아주 빨리 죽습니다. 루프 내 임시 객체, 메서드 반환 값, 이벤트 핸들러 등이 대표적입니다. 반면 오래 살아남은 객체는 그 이후에도 오랫동안 살아있을 가능성이 높습니다. 이 가설을 기반으로 짧게 사는 객체(Young Gen)와 오래 사는 객체(Old Gen)를 분리해 각각에 최적화된 GC를 적용하는 것이 세대별 GC의 핵심 아이디어입니다.


4. GC 수집 알고리즘 – Mark·Sweep·Compact·Copy

쓰레기를 판별(도달 가능성 분석)한 후, 실제로 메모리를 회수하는 과정에는 여러 알고리즘이 사용됩니다.

Mark and Sweep

가장 기본적인 GC 알고리즘입니다. 두 단계로 구성됩니다.

[Mark 단계]
GC Roots에서 시작 → 도달 가능한 모든 객체에 "살아있음" 표시(Mark)
표시되지 않은 객체 = 쓰레기

[Sweep 단계]
힙 전체를 순회하며 Mark되지 않은 객체의 메모리 해제

힙 상태 변화:
전: [A(live)][B(dead)][C(live)][D(dead)][E(live)]
후: [A      ][      ][C      ][      ][E      ]

단순하고 순환 참조도 처리합니다. 하지만 메모리 단편화(Fragmentation) 문제가 발생합니다. 중간중간 빈 공간이 생겨 큰 객체를 할당할 수 없는 상황이 생깁니다.

Mark, Sweep, and Compact

Mark & Sweep에 Compact(압축) 단계를 추가합니다.

[Compact 단계 추가]
살아있는 객체들을 힙의 한쪽 끝으로 모아 배치

전: [A][  ][C][  ][E][  ][  ]
후: [A][C][E][               ] ← 연속된 빈 공간 확보

메모리 단편화를 해소하고 할당 속도가 빨라집니다(단순한 포인터 증가로 할당 가능). 그러나 Compact 과정 자체에 추가 시간이 걸리고 STW가 길어지는 단점이 있습니다. Old Gen GC에서 주로 사용됩니다.

Copying(복사 알고리즘)

힙을 두 영역(From, To)으로 나누고, GC 시 살아있는 객체만 To 영역으로 복사합니다.

From 영역: [A][X][C][X][E]  (X는 쓰레기)
복사 후
To 영역:   [A][C][E]
From 영역: [              ]  ← 전체 비움

다음 GC에서 From/To 역할 교체

단편화가 없고 할당이 빠릅니다. 살아있는 객체만 복사하므로 쓰레기가 많을수록 효율적입니다. 이것이 Young Gen(Eden→Survivor)에 이 알고리즘이 적합한 이유입니다. 단, 항상 절반의 메모리가 비어있어야 하는 공간 낭비가 단점입니다.


5. GC 종류별 완전 비교 – Serial·Parallel·G1·ZGC

Java는 다양한 GC 구현체를 제공하며, 버전과 환경에 따라 선택이 달라집니다.

Serial GC – 단순하지만 느린 단일 스레드 GC

java

// JVM 옵션
-XX:+UseSerialGC

단일 스레드로 GC를 수행합니다. GC 실행 중 애플리케이션 스레드가 완전히 멈춥니다. CPU가 하나인 환경이나 메모리가 작은 임베디드 시스템에 적합합니다. 프로덕션 서버에는 거의 사용하지 않습니다.

Parallel GC – Java 8 기본, 처리량 우선

java

-XX:+UseParallelGC
-XX:ParallelGCThreads=4  // GC 스레드 수

여러 스레드가 병렬로 GC를 수행합니다. STW 시간은 Serial GC보다 줄어들지만, GC 중 애플리케이션은 여전히 멈춥니다. 처리량(Throughput) 중심의 배치 작업이나 데이터 처리 애플리케이션에 적합합니다.

G1GC(Garbage-First GC) – Java 9+ 기본, 예측 가능한 STW

java

-XX:+UseG1GC
-XX:MaxGCPauseMillis=200  // 목표 최대 STW 시간(ms)

G1GC는 힙을 고정 크기의 Region이라는 작은 영역들로 나눕니다. 각 Region은 동적으로 Eden·Survivor·Old 역할을 맡습니다.

힙을 N×M 그리드로 분할 (각 Region 1~32MB):

[E][E][S][O][O][E]
[O][H][E][S][E][O]  (E=Eden, S=Survivor, O=Old, H=Humongous)
[E][O][O][E][H][S]

G1GC의 핵심 특징은 쓰레기가 가장 많은(Garbage-First) Region부터 수집한다는 것입니다. 효율이 높은 Region을 우선 청소해 STW 시간을 목표치(MaxGCPauseMillis) 내로 예측 가능하게 유지합니다.

G1GC의 4단계 사이클은 다음과 같습니다.

1. Young GC: Eden Region 수집 (STW, 빠름)
2. Concurrent Mark: 힙 전체를 병렬·동시 마킹 (애플리케이션 실행 중)
3. Remark: 마킹 완료 처리 (짧은 STW)
4. Mixed GC: Old Region 일부 포함 수집 (STW, 제어 가능)

Java 9 이후 기본 GC이며, 4GB 이상 힙에서 특히 효과적입니다.

ZGC – 초저지연, 10ms 미만 STW 목표

java

-XX:+UseZGC              // Java 15+ 기본 포함
-XX:SoftMaxHeapSize=16g

ZGC는 대부분의 GC 작업을 **애플리케이션 실행 중(Concurrent)**에 처리합니다. STW는 GC Roots 스캔과 같은 최소 작업에만 발생하여 힙 크기와 관계없이 10ms 미만 STW를 목표로 합니다.

ZGC의 핵심 기술은 Colored Pointer와 Load Barrier입니다.

Colored Pointer: 객체 참조 주소의 상위 비트를 GC 상태 정보로 활용
 64비트 주소: [GC 상태 비트(4비트)][실제 주소(44비트)]

Load Barrier: 객체 참조를 읽을 때마다 GC 상태를 확인하는 코드 삽입
 → 애플리케이션 실행 중에도 객체 이동·재배치 가능

단점은 Load Barrier로 인한 약 5~10%의 CPU 오버헤드와 메모리 사용량 증가입니다. 응답 시간이 절대적으로 중요한 대규모 실시간 서비스(금융 거래·게임 서버)에 적합합니다.

GC 종류 한눈에 비교

GC 종류STW 시간처리량적합 환경Java 기본 버전
Serial GC매우 길다낮음소형·임베디드Java 1~4
Parallel GC길다높음배치·데이터 처리Java 5~8 기본
G1GC예측 가능중~높음범용 서버Java 9+ 기본
ZGC10ms 미만중간초저지연 서비스Java 15+
Shenandoah10ms 미만중간저지연 (OpenJDK)Java 12+ (OpenJDK)

6. 실무 심화 – Stop-the-World 줄이기와 GC 튜닝

이론을 실무에 연결합니다. STW를 최소화하고 GC를 튜닝하는 실전 방법을 정리합니다.

GC 로그 활성화 – 문제 파악의 첫 단계

bash

# Java 11+ GC 로깅 옵션
-Xlog:gc*:file=gc.log:time,uptime,pid:filecount=5,filesize=20m

# 주요 로그 항목 예시
[2.345s][info][gc] GC(3) Pause Young (Normal) (G1 Evacuation Pause)
[2.345s][info][gc] GC(3)   Eden regions: 25->0(25)
[2.345s][info][gc] GC(3)   Survivor regions: 2->3(3)
[2.345s][info][gc] GC(3)   Old regions: 10->10
[2.345s][info][gc] GC(3) Pause Young (Normal) 512M->256M(1024M) 12.345ms

로그에서 확인할 핵심 항목은 STW 시간(마지막 숫자, ms)힙 사용량 변화(512M→256M)GC 빈도입니다.

힙 크기 설정 – 가장 기본적인 튜닝

bash

-Xms4g          # 초기 힙 크기 (최소)
-Xmx4g          # 최대 힙 크기
# Xms == Xmx로 설정하면 힙 리사이징 오버헤드 제거 → 권장
-XX:NewRatio=2  # Old:Young = 2:1 (Young = 힙의 1/3)
-XX:SurvivorRatio=8  # Eden:S0:S1 = 8:1:1

힙이 너무 작으면 GC가 자주 발생하고, 너무 크면 GC당 STW 시간이 길어집니다. 일반적으로 실제 사용량의 2~3배를 최대 힙으로 설정하는 것을 권장합니다.

메모리 누수 감지 – 실무 핵심 패턴

java

// 메모리 누수 패턴 1 – static 컬렉션에 계속 추가
public class LeakyCache {
    // static이라 애플리케이션 생명주기 동안 유지됨
    private static final Map<String, byte[]> cache = new HashMap<>();

    public void addToCache(String key) {
        cache.put(key, new byte[1024 * 1024]); // 1MB씩 누적
        // 제거 로직 없음 → 메모리 누수!
    }
}

// 해결: WeakHashMap 또는 캐시 만료 정책 적용
private static final Map<String, byte[]> cache =
    Collections.synchronizedMap(new WeakHashMap<>());

// 메모리 누수 패턴 2 – 리스너/콜백 미등록 해제
button.addActionListener(this); // 리스너 등록
// 화면 종료 시 button.removeActionListener(this) 빠뜨림 → 누수!

면접 모범 답변 – “GC 동작 원리를 설명해주세요”

“Java GC는 힙 메모리에서 더 이상 참조되지 않는 객체를 자동으로 회수하는 메커니즘입니다. 쓰레기 판별은 GC Roots에서 도달 가능한 객체를 탐색하는 도달 가능성 분석으로 이루어집니다. 힙은 Young Generation과 Old Generation으로 나뉘며, 대부분의 객체는 생성 직후 빠르게 죽는다는 약한 세대 가설을 기반으로 짧게 사는 객체를 Young Gen에서 효율적으로 처리합니다. Young Gen이 가득 차면 Minor GC가, Old Gen이 가득 차면 Major GC가 발생합니다. GC 실행 중에는 Stop-the-World로 애플리케이션이 멈추는데, G1GC는 이를 예측 가능하게 제어하고 ZGC는 대부분을 동시에 처리해 10ms 미만으로 줄입니다.”


결론

GC 동작 원리는 단순 암기가 아닌, 메모리 관리의 근본 문제에서 출발해 알고리즘 진화 과정을 하나의 논리 흐름으로 이해해야 하는 개념입니다. 참조 카운팅의 한계 → 도달 가능성 분석 → 세대별 힙 구조 → Mark·Sweep·Compact·Copy 알고리즘 → G1GC·ZGC의 STW 최소화 혁신까지, 각 단계가 이전 단계의 문제를 해결하기 위해 등장했습니다. 면접에서는 이 흐름을 논리적으로 연결해 설명하고, 실무에서는 GC 로그를 분석하며 힙 크기와 GC 종류를 환경에 맞게 선택하는 능력을 갖추는 것이 목표입니다. 오늘 정리한 개념을 IDE에서 직접 GC 로그를 출력해보며 눈으로 확인해보세요.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다