코드 한 줄의 기록

Java GC 기초부터 로그 분석까지: 세대별 GC와 마크-스윕 완벽 이해하기 본문

JAVA

Java GC 기초부터 로그 분석까지: 세대별 GC와 마크-스윕 완벽 이해하기

CodeByJin 2025. 12. 13. 09:15
반응형

처음 Java를 배울 때 가장 신기한 부분 중 하나가 메모리 관리였다. C나 C++처럼 직접 free() 함수를 호출하거나 포인터를 관리할 필요가 없다는 게 정말 편했다. 하지만 실제 프로덕션 환경에서 고성능의 백엔드 시스템을 구축하려면 이 "자동"이라는 개념이 정확히 어떻게 동작하는지 알아야 한다. 특히 GC(Garbage Collection)는 애플리케이션의 응답 속도와 처리량에 직접적인 영향을 미치기 때문에 개발자가 반드시 이해해야 할 핵심 개념이다.

 
이번 글에서는 Java GC의 기초 원리부터 시작해서 세대별 GC가 왜 필요한지, 그리고 마크-스윕 알고리즘이 어떻게 동작하는지 상세히 설명할 것이다. 마지막으로 실제 GC 로그를 읽고 분석하는 방법까지 다룰 예정이니, 이 글을 읽으면 GC에 대한 근본적인 이해를 얻을 수 있을 거다.

Java의 메모리 구조와 GC가 필요한 이유

Java 프로그램이 실행될 때 JVM(Java Virtual Machine)은 운영체제로부터 메모리를 할당받는다. 이 메모리 공간은 여러 영역으로 나뉘는데, 그중 Heap이라는 영역이 동적으로 할당되는 객체들을 저장하는 곳이다. Stack에는 지역변수나 메서드 호출 정보가 저장되고, Method Area에는 클래스 정보나 상수, 정적 변수 등이 저장된다.
 
중요한 건 Heap이라는 공간이 한정되어 있다는 것이다. 프로그램이 계속 객체를 생성하면서 메모리를 사용하다 보면 언젠가는 이 공간이 가득 찰 수밖에 없다. 만약 GC가 없다면 어떻게 될까? 더 이상 필요 없는 객체들이 계속 메모리를 차지하게 되고, 결국 새로운 객체를 생성할 수 없게 되어 OutOfMemoryError가 발생할 것이다.
 
그래서 Java는 GC라는 메커니즘을 제공한다. GC의 역할은 간단하다. Heap에서 더 이상 필요 없는 객체들을 자동으로 찾아내서 메모리를 해제하는 것이다. 개발자가 직접 관리할 필요가 없으니 메모리 누수를 걱정할 필요가 없고, 객체를 생성한 후 버리기만 하면 된다.

약한 세대 가설(Weak Generational Hypothesis)

Java의 GC가 효율적으로 동작하는 기반에는 경험적 관찰이 있다. 이를 "약한 세대 가설"이라고 부르는데, 두 가지 중요한 가정으로 이루어져 있다.
 
첫 번째 가정: 대부분의 객체는 매우 짧은 시간 동안만 사용된다. 즉, 생성되자마자 얼마 지나지 않아 더 이상 참조되지 않는다. 예를 들어, 웹 요청을 처리할 때 생성되는 임시 객체들은 요청이 끝나면 더 이상 필요 없어진다.
 
두 번째 가정: 오래된 객체에서 새로운 객체로의 참조는 매우 드물다. 즉, 이미 오래 살아남은 객체가 최근에 생성된 객체를 참조하는 경우는 거의 없다. 반대 방향(새 객체가 오래된 객체를 참조)은 자주 있지만 말이다.
 
이 두 가정이 실제 대부분의 프로그램에서 참이라면, 효율적인 GC 전략을 세울 수 있다. 모든 객체를 동일하게 취급할 필요가 없다는 뜻이기 때문이다. 새로 생성된 객체들이 있는 영역을 자주 점검하고, 오래된 객체들이 있는 영역은 더 드물게 점검하면 효율성을 크게 높일 수 있다.

세대별 GC(Generational Garbage Collection) 구조

이 약한 세대 가설을 바탕으로 Java의 Heap 메모리는 크게 두 영역으로 나뉘어 관리된다.
 

Young 영역(Young Generation)

Young 영역은 새로 생성된 객체들이 위치하는 공간이다. 이 영역은 다시 세 부분으로 나뉜다.

  • Eden: 새로 생성된 모든 객체가 먼저 할당되는 영역이다. 대부분의 객체가 여기서 빠르게 사라진다.
  • Survivor 0, Survivor 1: Eden에서 살아남은 객체들이 이동하는 영역이다. 두 개의 Survivor 영역 중 하나는 항상 비어있어야 한다는 규칙이 있다.

Young 영역이 가득 차면 Minor GC가 발생한다. Minor GC는 GC 작업이 비교적 빠르고 자주 발생한다.
 

Old 영역(Old Generation)

Old 영역은 Young 영역에서 여러 번의 GC를 거쳐도 살아남은 객체들이 이동하는 공간이다. 일반적으로 Young 영역보다 훨씬 크게 할당되며, 그 대신 GC가 덜 자주 발생한다.
 
객체가 Young에서 Old로 이동하는 것을 "승격(Promotion)"이라고 부른다. 각 객체는 GC 횟수를 기록하는 age라는 카운터를 가지고 있으며, 이 age가 특정 임계값에 도달하면 Old 영역으로 승격된다.
 
Old 영역이 가득 차면 Major GC(또는 Full GC)가 발생한다. Major GC는 Minor GC보다 훨씬 시간이 오래 걸리고, 이 과정에서는 애플리케이션의 모든 스레드가 멈춘다(Stop the World).
 

Young 영역의 GC 프로세스

구체적으로 Young 영역에서 어떤 일이 벌어지는지 살펴보자.

  1. 초기 상태: 모든 새로운 객체가 Eden에 할당된다.
  2. Eden이 가득 참: Minor GC가 트리거된다.
  3. 첫 Minor GC: Eden의 살아있는 객체들은 Survivor 0으로 이동하고, 죽은 객체들은 제거된다.
  4. 두 번째 Minor GC: Eden과 Survivor 0의 살아있는 객체들을 Survivor 1로 이동한다. 이때 Survivor 0은 비워진다.
  5. 반복: 이후 Minor GC가 발생할 때마다 두 Survivor 영역을 오가며 객체들이 이동하고, age가 증가한다.
  6. Old로 승격: age가 충분히 높은 객체들은 Old 영역으로 승격된다.

이런 구조 덕분에 대부분의 단기 객체들은 Young 영역에서만 처리되며, 오래된 객체들은 Old 영역에서 덜 자주 점검된다.

마크-스윕(Mark-Sweep) 알고리즘의 동작 원리

이제 실제로 GC가 어떻게 객체를 식별하고 메모리를 해제하는지 알아보자. 가장 기본적인 알고리즘이 바로 마크-스윕이다.
 

마크(Mark) 단계

Mark 단계에서는 Heap의 모든 객체를 순회하면서 "살아있는" 객체를 찾아내고 표시한다. 여기서 "살아있다"는 것은 프로그램의 루트(root)에서 시작한 참조 사슬로 도달 가능하다는 의미다.
 
루트가 무엇인지 정확히 이해해야 한다. 루트는 다음을 포함한다.

  • 스택에 있는 지역변수들
  • 정적 변수들
  • 메서드 영역의 상수들
  • JNI(Java Native Interface)를 통해 참조되는 객체들

GC는 이 루트들로부터 시작해서 깊이 우선 탐색(DFS)을 수행한다. 루트가 참조하는 객체, 그 객체가 참조하는 객체, 그 다음 객체... 이런 식으로 계속 따라가면서 각 객체에 "mark bit"을 1로 설정한다.
 
예를 들어 보자.


A(참조) -> B(참조) -> C(참조) -> D
                              \\-> E

 
A가 루트라면, Mark 단계에서 A, B, C, D, E 모두가 mark되고, F와 G처럼 어떤 객체도 참조하지 않는 객체들은 mark되지 않는다.
 

스윕(Sweep) 단계

Sweep 단계에서는 Heap을 선형으로 순회하면서 mark bit이 0인(표시되지 않은) 모든 객체를 메모리에서 제거하고, 그 메모리를 Free List에 추가해서 다시 사용할 수 있게 만든다.
 

// 의사코드
for each object p in heap:
  if p.marked == false:
    heap.release(p) // 메모리 해제
  else:
    p.marked = false // 다음 GC를 위해 초기화

 
Mark-Sweep의 장점은 순환 참조를 완벽하게 처리한다는 것이다. 객체 A가 B를 참조하고 B가 A를 참조하는 상황에서도, 이들이 루트로부터 도달 불가능하면 정확히 제거된다.
 

컴팩션(Compaction) 단계

다만 Mark-Sweep 알고리즘만으로는 문제가 하나 있다. Sweep 후에 메모리가 조각화된다.
예를 들어 객체들이 다음과 같이 흩어져 있다면
 

[Object A][Free][Object B][Free][Object C][Free]

 
새로운 객체를 할당하려고 할 때 연속된 충분한 공간이 없을 수 있다. 그래서 보통 Mark-Sweep 알고리즘은 Compaction 단계를 포함한다. Compaction은 살아있는 모든 객체들을 Heap의 한쪽으로 몰아서 연속된 메모리 영역을 만드는 작업이다.
 

// Compaction 후
[Object A][Object B][Object C][Free____________]

 
이렇게 하면 새로운 객체를 할당할 때 단순히 "위치 + 크기"만으로 빠르게 할당할 수 있다.
 

Serial GC와 GC 알고리즘의 변화

Java는 버전에 따라 여러 GC 알고리즘을 제공한다. 그중 가장 기본적인 것이 Serial GC다.
Serial GC는

  • Young 영역에서는 Mark-Copy 알고리즘을 사용한다(Survivor 영역으로의 복사).
  • Old 영역에서는 Mark-Sweep-Compact 알고리즘을 사용한다.
  • 모든 GC 작업을 단일 스레드에서 수행한다.
  • 작은 힙(몇 MB)에서는 괜찮지만, 프로덕션 서버에서는 절대 사용하면 안 된다.

Serial GC 이후로는

  • Parallel GC: GC를 여러 스레드에서 병렬로 처리한다. 멀티코어 시스템에서 더 빠르다.
  • CMS GC(Concurrent Mark and Sweep): GC 작업 중 일부(Concurrent Mark 단계)를 애플리케이션과 함께 실행해서 Stop the World 시간을 줄인다. 하지만 메모리와 CPU를 더 많이 사용한다.
  • G1GC(Garbage First): Java 9부터 기본 GC다. Heap을 작은 영역들(Region)로 나누고, garbage 비율이 높은 영역부터 수집한다. 대용량 Heap(4GB 이상)에 최적화되어 있다.

GC 로그의 활성화와 기본 이해

이제 실제로 GC가 어떻게 동작하는지 확인해보자. GC 로깅을 활성화하려면 JVM 옵션을 설정해야 한다.
 

JDK 8과 JDK 11 이후의 차이

 

JDK 8
java -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log MyApp

 

JDK 11 이후
java -Xlog:gc*:file=gc.log:time:filecount=5,filesize=100m MyApp

 

JDK 11부터는 새로운 로깅 프레임워크를 도입했다. -Xlog 옵션의 구조는

-Xlog:[selections]:[output]:[decorators][:output-options]

 
각 부분을 설명하면

  • selections: gc*는 gc로 시작하는 모든 로그를 선택한다. gc+age는 객체의 age 정보도 포함한다.
  • output: file=gc.log는 파일에 저장, stdout은 콘솔 출력.
  • decorators: time은 타임스탬프, uptime은 JVM 시작 이후 경과 시간, pid는 프로세스 ID.
  • output-options: filecount는 로테이션할 파일 개수, filesize는 파일 크기 제한.

GC 로그 읽기

실제 GC 로그의 예시를 살펴보자.

[36.705s][info][gc,heap] GC(7) Tenured: 16474K->22M(483M) 9.374ms
[36.705s][info][gc,start] GC(8) Pause Young (Allocation Failure)
[36.705s][info][gc,heap] GC(8) DefNew: 142766K->6086K(153728K)
[36.705s][info][gc,heap] GC(8) Tenured: 16474K->16474K(341376K)
[36.705s][info][gc] GC(8) Pause Young (Allocation Failure) 155M->22M(483M) 9.811ms

 
이 로그를 해석해보자.

  1. [36.705s]: JVM 시작 후 36.705초 지난 시점
  2. [gc,heap]: 이 로그가 gc와 heap 관련 메시지임을 나타낸다.
  3. GC(7): 7번째 GC 이벤트
  4. Tenured: 16474K->22M(483M): Old 영역이 16474KB에서 22MB로 변경됨. 전체 Old 영역 크기는 483MB.
  5. 9.374ms: 이 GC가 소요한 시간
  6. Pause Young (Allocation Failure): Young 영역 GC이며, 원인은 할당 실패(Eden이 가득 찬 것)

로그의 크기 표기법도 주의해야 한다. K는 킬로바이트, M은 메가바이트, G는 기가바이트를 의미한다.

중요한 GC 메트릭 이해하기

GC 로그를 분석할 때 자주 나오는 용어들을 정리하자.

  • Minor GC 빈도: Young 영역 GC가 얼마나 자주 발생하는가? 너무 빈번하면 객체가 많이 할당되고 있다는 의미다.
  • Major GC 빈도: Old 영역 GC가 얼마나 자주 발생하는가? 자주 발생하면 메모리 누수나 과도한 장기 객체가 있을 수 있다.
  • GC 시간(GC Pause Time): GC로 인해 애플리케이션이 멈춘 시간. 이 값이 크면 사용자가 느낄 수 있는 지연이 발생한다.
  • Heap 사용량: GC 전후의 메모리 사용량 변화. 155M->22M이라면 GC로 133MB의 메모리를 회수했다는 뜻이다.
  • Full GC: Young과 Old 영역을 모두 정리하는 GC. 매우 오래 걸린다.

GC 로그 분석 도구

손으로 로그를 읽는 것도 가능하지만, 대량의 로그를 분석할 때는 전문 도구를 사용하는 게 훨씬 효율적이다.
GCeasy.io는 가장 인기 있는 온라인 GC 분석 도구다. 로그 파일을 업로드하면 다음 정보를 제공한다.

  • 시간대별 GC 발생 빈도 그래프
  • 메모리 누수 감지
  • Heap 사용량 추이
  • 각 GC 유형별 통계
  • 성능 문제 진단 및 권장사항

사용 방법은 간단하다.

  1. gceasy.io에 접속
  2. GC 로그 파일 업로드 (.log, .gz, .zip 등 지원)
  3. Analyze 버튼 클릭
  4. 상세한 분석 리포트 확인

실무에서의 GC 튜닝 기초

이제 GC를 이해했으니 실무에서 어떻게 활용할까?

  1. Heap 크기 결정: -Xms(초기 Heap)와 -Xmx(최대 Heap) 옵션으로 조정한다. 일반적으로 두 값을 같게 설정해서 GC 오버헤드를 줄인다.

java -Xms2g -Xmx2g -Xlog:gc*:file=gc.log MyApp

  1. Young과 Old의 비율: -XX:NewRatio=N으로 조정할 수 있다. 기본값은 2(Old:Young = 2:1)다.
  2. GC 알고리즘 선택:
    - 응답 속도가 중요하면 CMS나 G1GC
    - 처리량이 중요하면 Parallel GC
    - 4GB 이상 Heap이면 G1GC 사용
  3. 모니터링: GC 로그를 정기적으로 확인해서 이상 패턴을 감지한다. Major GC가 갑자기 증가하면 메모리 누수의 신호다.

실제 사례로 배우기

프로덕션 서버의 GC 로그를 분석해본다고 가정해보자. 다음 패턴들을 보면 무엇을 의심할까?
 

Case 1: 계속 증가하는 Old 영역 사용량

[시간 1] Tenured: 100M->101M(2G)
[시간 2] Tenured: 500M->501M(2G)
[시간 3] Tenured: 1000M->1001M(2G)
[시간 4] Full GC 발생, Tenured: 1800M->1500M(2G)

이 경우 메모리 누수를 의심할 수 있다. 어떤 객체가 오래 살아남고 있거나, 캐시가 계속 증가하고 있을 가능성이 높다.
 

Case 2: 매우 빈번한 Minor GC

[매초] GC(N) Pause Young (Allocation Failure) 150M->50M 50ms

이 경우 객체 할당이 매우 많다는 뜻이다. 문자열 연결이 반복되거나, 불필요한 객체 생성이 많을 수 있다.
 

Case 3: 긴 GC Pause Time

GC(100) Pause Young 2000ms

2초라는 건 매우 긴 시간이다. Young 영역이 너무 크거나, GC 알고리즘이 부적절할 수 있다.

GC 이해하기의 중요성

Java GC는 자동이지만 "마법"은 아니다. 내부 동작을 이해하면 다음이 가능해진다.

  • 애플리케이션의 성능 문제를 분석할 수 있다.
  • 효율적인 JVM 옵션을 설정할 수 있다.
  • 메모리 누수를 감지할 수 있다.
  • 대용량 트래픽을 처리할 때 안정적인 시스템을 구축할 수 있다.

특히 백엔드 개발자라면 GC에 대한 이해는 필수다. 크리티컬한 성능 문제가 발생했을 때 GC 로그를 읽고 분석할 수 있다면, 문제 해결이 훨씬 빨라질 것이다.
 
처음에는 마크-스윕 같은 알고리즘이 복잡해 보일 수 있지만, 한 번 이해하면 매우 직관적이다. 객체를 "살아있는 것"과 "죽은 것"으로 구분하고, 죽은 것들을 제거한다는 기본 원리만 기억하면 된다.
 
마지막으로 한 가지 당부할 말은, GC 로그 분석은 시각과 경험이 중요하다는 것이다. 다양한 애플리케이션의 GC 로그를 분석해보면서 "정상"과 "이상"을 구분하는 감을 기른다면, 나중에 실제 문제를 만났을 때 빠르게 대응할 수 있을 거다. 지금부터 개발 환경에서 GC 로깅을 활성화하고, 자신의 애플리케이션이 어떻게 메모리를 사용하는지 관찰해보길 권장한다.

Java 클래스 로딩부터 실행까지: 바이트코드와 ClassLoader의 모든 것

처음 Java를 배울 때 이상한 점이 하나 있었어요. C나 C++는 소스 코드를 컴파일하면 바로 실행 파일(*.exe)이 나오는데, Java는 왜 *.class라는 중간 산물이 생기고, JVM이라는 것이 필요할까? 이 의문이

byteandbit.tistory.com

반응형