NYO_O

GC, 자바는 메모리를 어떻게 스스로 정리할까 본문

BackEnd/Java

GC, 자바는 메모리를 어떻게 스스로 정리할까

NYO_O 2026. 5. 29. 19:17
반응형

지난 시간, 우리는 JVM이 메모리를 어떻게 나누어 관리하는지를 살펴보았습니다. new 키워드로 생성한 객체는 Heap에 올라가고, 더 이상 참조되지 않는 객체는 GC가 자동으로 정리해 준다는 것까지 확인했습니다.

2026.05.29 - [BackEnd/Java] - JVM 메모리 구조 - Heap, Stack

 

JVM 메모리 구조 - Heap, Stack

지난 시간, 우리는 자바라는 언어가 어떤 철학을 바탕으로 설계되었는지, 그리고 왜 수십 년이 지난 지금까지도 현업에서 널리 쓰이는지를 살펴보았습니다. 자바가 가진 가장 강력한 특징 중 하

ddangnyo.tistory.com

그렇다면 GC는 정확히 어떤 기준으로 "이 객체는 이제 필요 없다"고 판단할까요? 그리고 어떻게 Heap을 정리할까요? 오늘은 GC가 무엇인지, 어떻게 동작하는지, 그리고 어떤 GC 알고리즘들이 있는지를 정리해 보겠습니다.

GC는 왜 필요한가

자바에서 객체를 만드는 일은 매우 빈번합니다. 문자열을 처리할 때도, HTTP 요청을 받을 때도, 데이터베이스 결과를 담을 때도 객체가 생성됩니다. 실제로 초당 수천 개의 객체가 만들어졌다가 사라지는 것이 일반적인 자바 애플리케이션의 모습입니다.

그런데 Heap의 크기는 무한하지 않습니다. 쓸모가 없어진 객체들을 방치하면 결국 Heap이 가득 차고, 새로운 객체를 만들 공간이 없어집니다. 이때 발생하는 것이 바로 OutOfMemoryError: Java heap space입니다.

C나 C++에서는 개발자가 직접 free()나 delete를 호출해 메모리를 해제합니다. 하지만 이 방식은 실수하기 쉽습니다. 해제를 빠뜨리면 메모리 누수가 생기고, 이미 해제한 메모리를 또 해제하면 프로그램이 충돌합니다. 자바는 이 부담을 GC에게 넘겨, 개발자가 메모리 관리 대신 비즈니스 로직에 집중할 수 있도록 설계되었습니다.

GC의 기본 동작   Mark and Sweep

GC가 메모리를 정리하는 방식은 크게 두 단계로 이루어집니다. 이를 Mark and Sweep이라고 합니다.

Mark  살아있는 객체를 표시한다

GC는 가장 먼저 "지금 이 순간 누군가 사용하고 있는 객체"를 찾아냅니다. 이 출발점이 되는 객체들을 GC Root라고 합니다. GC Root에는 다음과 같은 것들이 해당됩니다.

GC Root 종류 설명
Stack의 지역 변수 현재 실행 중인 메서드의 지역 변수가 참조하는 객체
static 변수 클래스 레벨에서 선언된 정적 변수가 참조하는 객체
JNI 참조 네이티브 코드에서 참조하는 객체

GC는 GC Root에서 시작해서, 그 객체가 참조하는 객체, 또 그 객체가 참조하는 객체… 이렇게 연결된 모든 객체를 따라가며 "살아있음" 표시를 합니다. 이 과정이 Mark 단계입니다.

public void example() {
    User user = new User("Alice");         // (A) user → User 객체
    Address address = new Address("Seoul"); // (B) address → Address 객체
    user.setAddress(address);              // (C) User 객체 → Address 객체 참조

    address = null; // (D) address 변수가 Address 객체를 더 이상 가리키지 않음
                    // 하지만 User 객체가 여전히 Address 객체를 참조하고 있으므로
                    // Address 객체는 GC 대상이 아닙니다
}
// 메서드 종료 → user 변수도 사라짐
// → User 객체를 가리키는 참조가 없음 → User, Address 모두 GC 대상

위 예시처럼 GC는 참조의 연결 고리를 추적합니다. 직접 참조하는 변수가 없더라도, 다른 살아있는 객체를 통해 간접적으로 참조되고 있다면 그 객체는 살아있는 것으로 판단합니다.

Sweep — 표시되지 않은 객체를 제거한다

Mark 단계가 끝나면 "살아있음" 표시가 없는 객체들을 모두 제거합니다. 이 과정이 Sweep 단계입니다. Sweep이 끝나면 제거된 객체들이 차지하던 공간이 비워지고, 새로운 객체를 할당할 수 있게 됩니다.

Compact

Sweep만 수행하면 메모리 곳곳에 빈 공간이 불규칙하게 생깁니다. 마치 치아 사이사이가 빠진 것처럼 듬성듬성한 형태가 됩니다. 이를 메모리 단편화(Fragmentation) 라고 합니다. 단편화가 심하면 실제 빈 공간은 충분한데도 큰 객체를 연속된 공간에 할당하지 못하는 상황이 발생합니다.

이를 해소하기 위해 살아남은 객체들을 한쪽으로 모으는 Compact 단계를 추가로 수행하기도 합니다. 다만 Compact는 객체의 주소가 바뀌기 때문에 그 객체를 참조하는 모든 포인터를 업데이트해야 해서 비용이 큽니다.

Heap 구조와 GC 전략 — Young과 Old를 나누는 이유

1편에서 Heap이 Young Generation과 Old Generation으로 나뉜다고 소개했습니다. 왜 이렇게 구분하는지 이번에는 좀 더 깊이 살펴보겠습니다.

그 배경에는 약한 세대 가설(Weak Generational Hypothesis) 이라는 경험적 관찰이 있습니다. 수많은 자바 애플리케이션을 분석한 결과, 대부분의 객체는 생성된 직후 짧은 시간 안에 사용이 끝난다는 것이 밝혀졌습니다.

예를 들어 HTTP 요청을 처리할 때를 생각해 보겠습니다.

public ResponseDto handleRequest(RequestDto request) {
    // 아래 객체들은 이 메서드가 끝나는 순간 모두 쓸모가 없어집니다
    String parsedValue = request.getValue();
    ValidationResult result = validator.validate(parsedValue);
    ResponseDto response = ResponseDto.of(result);
    return response;
}

parsedValue, result, response는 요청 하나를 처리하는 동안만 필요합니다. 응답을 반환하고 나면 이 객체들은 모두 버려집니다. 반면 validator 같은 서비스 객체나 스프링 Bean은 애플리케이션이 종료될 때까지 계속 살아있습니다.

이 특성을 활용하면 GC 전략을 효율적으로 설계할 수 있습니다. 수명이 짧은 객체가 모이는 영역에서는 자주, 빠르게 GC를 수행하고, 수명이 긴 객체가 모이는 영역에서는 드물게 GC를 수행하는 것입니다.

Young Generation은 전체 Heap 중 일부만 차지하기 때문에 GC 대상이 적고 빠릅니다. Old Generation은 크기가 크고 오래된 객체들이 모여 있어 GC 비용이 훨씬 높습니다. 이렇게 세대를 나눔으로써 전체적인 GC 부담을 크게 줄일 수 있습니다.

Young Generation은 내부적으로 세 공간으로 나뉩니다.

영역 역할
Eden 새로 생성된 객체가 처음 배치되는 곳입니다. 대부분의 객체가 여기서 생성되고 여기서 사라집니다.
Survivor 0 Eden에서 살아남은 객체가 이동하는 곳입니다.
Survivor 1 Survivor 0에서 살아남은 객체가 이동하는 곳입니다. 두 Survivor 영역은 번갈아가며 사용됩니다.

Minor GC — Young Generation 청소

Young Generation에서 발생하는 GC를 Minor GC라고 합니다. 동작 방식을 단계별로 살펴보겠습니다.

1단계 — Eden이 가득 찬다

새로 생성되는 객체들이 Eden을 채웁니다. Eden이 가득 차면 Minor GC가 시작됩니다.

2단계 — Eden에서 살아남은 객체를 Survivor로 이동한다

GC는 Eden에 있는 객체 중 아직 참조되고 있는 객체를 찾아 Survivor 0으로 이동시킵니다. 참조가 끊긴 객체는 Eden과 함께 정리됩니다. 이동한 객체에는 age라는 숫자가 기록됩니다. 처음 Survivor로 이동하면 age가 1이 됩니다.

3단계 — 다음 Minor GC에서 Survivor 간 이동이 일어난다

다시 Eden이 가득 차면 Minor GC가 또 실행됩니다. 이번에는 Eden과 Survivor 0에 있는 살아남은 객체들이 Survivor 1로 이동합니다. 이때 age가 1씩 증가합니다.

4단계 — age가 일정 이상이 되면 Old Generation으로 이동한다

Survivor를 오가며 살아남은 객체들의 age가 임계값(기본값 15)에 도달하면, 해당 객체는 오래 살아남을 것이라 판단하여 Old Generation으로 이동됩니다. 이 과정을 Promotion이라고 합니다.

[Eden 가득 참] → Minor GC 실행
  → 살아남은 객체: Eden → Survivor 0 (age: 1)
  → 다음 Minor GC: Eden + Survivor 0 → Survivor 1 (age: 2)
  → 다음 Minor GC: Eden + Survivor 1 → Survivor 0 (age: 3)
  → ... age가 15에 도달하면 Old Generation으로 Promotion

Minor GC는 Young Generation만 대상으로 하기 때문에 범위가 좁고 빠릅니다. 수 밀리초 안에 끝나는 경우가 많습니다.

Major GC — Old Generation 청소

Old Generation이 가득 차면 Major GC(혹은 Full GC)가 실행됩니다. Major GC는 Minor GC보다 훨씬 오래 걸립니다. Old Generation은 크기가 크고, 살아있는 객체도 많아서 Mark, Sweep, Compact 과정이 모두 오래 걸리기 때문입니다.

Minor GC가 수 밀리초 단위라면, Major GC는 수백 밀리초에서 수 초까지 걸릴 수 있습니다. 이 시간 동안 애플리케이션이 멈추기 때문에 Major GC가 자주 발생하면 사용자 입장에서 눈에 띄는 응답 지연이 나타납니다. 따라서 GC 튜닝의 목표는 대부분 Major GC 발생 빈도를 줄이거나, Major GC로 인한 정지 시간을 최소화하는 것입니다.

Stop-The-World — GC가 성능에 미치는 영향

GC에서 가장 중요한 개념 중 하나가 STW(Stop-The-World) 입니다. GC가 실행될 때 JVM은 GC를 수행하는 스레드를 제외한 모든 애플리케이션 스레드를 일시 정지시킵니다. 이 현상을 Stop-The-World라고 합니다.

왜 멈춰야 할까요? GC가 객체 간 참조 관계를 추적하는 도중에도 애플리케이션이 계속 객체를 만들고 지운다면, 마치 청소를 하는 도중에 누군가 계속 쓰레기를 만드는 것처럼 정확한 분석이 불가능하기 때문입니다.

STW가 발생하는 동안에는 아무리 빠른 서버라도 모든 요청 처리가 중단됩니다. 짧은 STW는 문제가 없지만, Major GC처럼 수 초짜리 STW가 발생하면 타임아웃이 발생하거나 사용자에게 심각한 지연이 느껴집니다. GC 알고리즘의 발전 방향은 대부분 이 STW 시간을 줄이는 것을 목표로 합니다.

GC 알고리즘의 발전 — Serial부터 ZGC까지

자바는 버전을 거듭하면서 STW 시간을 줄이기 위해 다양한 GC 알고리즘을 발전시켜 왔습니다.

GC 알고리즘 특징 적합한 상황
Serial GC 단일 스레드로 GC를 수행합니다. GC 중 항상 STW가 발생합니다. 단일 코어 환경, 소규모 애플리케이션
Parallel GC 여러 스레드로 GC를 수행해 처리량을 높입니다. STW는 여전히 발생합니다. Java 8의 기본값입니다. 처리량이 중요한 배치 작업
CMS GC GC 작업 일부를 애플리케이션 스레드와 동시에 수행합니다. STW 시간을 줄이지만 CPU를 많이 사용합니다. Java 9부터 deprecated되었습니다. 응답 시간이 중요한 서비스
G1GC Heap을 작은 Region으로 나누어 관리합니다. STW 시간을 예측 가능하게 제어할 수 있습니다. Java 9부터 기본값입니다. 대용량 Heap, 응답 시간과 처리량의 균형이 필요한 서비스
ZGC GC 작업 대부분을 애플리케이션과 동시에 수행합니다. STW 시간이 수 밀리초 이하로 매우 짧습니다. Java 15부터 정식 도입되었습니다. 매우 짧은 응답 시간이 요구되는 서비스, 대용량 Heap

G1GC — 현재의 표준

G1GC(Garbage First GC)는 Java 9부터 기본 GC로 채택된 알고리즘입니다. 기존 GC들이 Heap을 Young, Old 같은 고정된 영역으로 나눈 것과 달리, G1GC는 Heap 전체를 동일한 크기의 Region으로 나눕니다. 각 Region은 그때그때 Eden, Survivor, Old 역할을 동적으로 맡습니다.

G1GC의 가장 큰 특징은 예측 가능한 STW 시간입니다. -XX:MaxGCPauseMillis 옵션으로 목표 정지 시간을 설정하면, G1GC는 그 시간 안에 GC를 마칠 수 있도록 정리할 Region을 스스로 선택합니다. 완벽하게 보장되지는 않지만, 이전 GC 알고리즘들에 비해 훨씬 안정적인 응답 시간을 제공합니다.

ZGC — 다음 세대의 GC

ZGC는 Java 15부터 정식으로 사용할 수 있는 최신 GC 알고리즘입니다. Mark, Compact 같은 무거운 작업을 애플리케이션 스레드와 동시에 수행해서 STW 시간을 수 밀리초 이하로 유지합니다. 수십 GB에서 수 TB에 달하는 매우 큰 Heap에서도 이 수준의 STW 시간을 유지할 수 있다는 점이 인상적입니다.

다만 동시에 작업하는 만큼 CPU를 더 사용한다는 트레이드오프가 있습니다. 응답 시간보다 전체 처리량이 중요한 배치 작업이라면 G1GC나 Parallel GC가 더 적합할 수 있습니다.

어떤 GC를 선택해야 할까

현실적인 기준으로 정리하면 다음과 같습니다.

Java 8 환경이라면 기본값인 Parallel GC를 쓰거나, 응답 시간이 중요한 서비스라면 -XX:+UseG1GC 옵션으로 G1GC로 전환하는 것이 좋습니다.

Java 11 이상이라면 G1GC가 기본값이고, 대부분의 서비스에서 별도 설정 없이도 준수한 성능을 보여 줍니다.

Java 17 이상 환경에서 응답 지연에 매우 민감한 서비스를 운영한다면 ZGC를 검토해 볼 수 있습니다. 단, 충분한 테스트 후 적용하는 것을 권장합니다.

GC 알고리즘보다 더 중요한 것은 힙 사이즈와 GC 로그를 올바르게 설정하는 일입니다. 어떤 GC를 쓰든 Heap이 너무 작으면 GC가 과도하게 자주 실행되고, 너무 크면 GC 한 번에 걸리는 시간이 길어집니다. GC 로그를 수집하고 분석하는 방법은 다음 편에서 자세히 다루겠습니다.

정리

GC의 동작 방식

GC는 크게 Mark와 Sweep 두 단계로 동작합니다. Mark 단계에서는 GC Root(Stack의 지역 변수, static 변수 등)에서 시작해 참조 관계를 따라가며 살아있는 객체를 표시합니다. Sweep 단계에서는 표시되지 않은 객체를 제거합니다. 메모리 단편화를 줄이기 위해 살아남은 객체를 한쪽으로 모으는 Compact 단계를 추가로 수행하기도 합니다.

Minor GC와 Major GC의 차이

Minor GC는 Young Generation을 대상으로 하는 GC입니다. 범위가 좁고 빠르게 끝납니다. Major GC(Full GC)는 Old Generation을 포함한 전체 Heap을 대상으로 하며, 처리 범위가 넓어 Minor GC보다 훨씬 오래 걸립니다. STW 시간도 Major GC가 훨씬 길기 때문에, GC 튜닝의 핵심 목표 중 하나는 Major GC 발생 빈도를 줄이는 것입니다.

Stop-The-World의 발생 이유

GC가 실행될 때 JVM이 GC 스레드를 제외한 모든 애플리케이션 스레드를 멈추는 현상입니다. GC가 객체 간 참조 관계를 추적하는 도중에 다른 스레드가 객체를 생성하거나 참조를 변경하면 정확한 분석이 불가능하기 때문입니다. STW 시간이 길면 응답 지연이나 타임아웃이 발생할 수 있으며, GC 알고리즘의 발전 방향은 대부분 이 STW 시간을 줄이는 것을 목표로 합니다.

G1GC와 기존 GC의 차이점

기존 GC는 Heap을 Young, Old 같은 고정된 영역으로 나누었지만, G1GC는 Heap 전체를 동일한 크기의 Region으로 나누어 관리합니다. 각 Region이 동적으로 역할을 바꿀 수 있고, GC가 필요한 Region을 우선순위에 따라 선택해서 정리합니다. -XX:MaxGCPauseMillis로 목표 정지 시간을 설정할 수 있어 STW 시간을 어느 정도 예측 가능하게 제어할 수 있다는 점이 가장 큰 특징입니다.

마무리

오늘은 GC가 어떤 기준으로 객체를 제거하는지, Heap을 Young과 Old로 나누는 이유가 무엇인지, 그리고 Serial GC부터 ZGC까지 알고리즘이 어떻게 발전해 왔는지를 살펴보았습니다. GC는 자바 애플리케이션의 성능과 안정성에 직결되는 핵심 메커니즘이며, STW 시간을 줄이는 방향으로 꾸준히 발전해 왔습니다.

반응형

'BackEnd > Java' 카테고리의 다른 글

GC 튜닝 — GC 로그를 읽고 성능 문제를 잡는 방법  (0) 2026.05.29
JVM 메모리 구조 — Heap, Stack  (0) 2026.05.29
Java 25  (0) 2026.05.29
Java 21  (1) 2026.05.29
Java 17  (0) 2026.05.29