가비지 컬렉션(Garbage Collection, GC)은 프로그램에서 더 이상 참조되지 않는 객체들을 자동으로 찾아서 메모리에서 해제하는 메커니즘입니다. 즉, 개발자가 일일이 free() 등의 함수를 호출하지 않아도 JVM이 필요 없어진 객체(garbage)를 알아서 삭제하고 메모리를 회수합니다. C나 C++같은 언어에서는 프로그래머가 직접 malloc/free로 메모리를 관리해야 해서 실수로 메모리를 해제하지 않으면 누수가 생겼지만, Java에서는 GC 덕분에 그런 부담이 줄어듭니다. 이를 통해 한정된 힙 메모리를 효율적으로 사용하고, 개발자는 메모리 관리보다는 비즈니스 로직 구현에 집중할 수 있다는 장점이 있습니다. GC는 Java만의 개념이 아니며, Python, JavaScript, Go 등 다른 많은 언어들도 자체 GC로 메모리를 자동 관리합니다.
GC의 동작 원리는 “Reachability(도달 가능성)”에 기반한 트레이싱 방식입니다. JVM은 여러 GC Root(예: 스택 변수, 정적 변수, JNI 레퍼런스 등)로부터 시작해 객체 그래프를 따라가며 접근 가능한 객체들을 마크(mark)합니다. 그런 다음, 한 번도 마크되지 않은, 어떤 Root에서도 도달 불가능한(Unreachable) 객체들을 “쓰레기”로 간주해 제거(sweep)합니다. 이 과정에서 순환 참조 등으로 서로를 가리키지만 Root에서 접근할 수 없는 객체들도 모두 식별되어 제거됩니다. 이러한 방식 덕분에 Java의 GC는 단순 참조 횟수(reference counting)로 인한 한계(순환 참조를 수집 못하는 문제)를 극복하고 효율적으로 메모리를 관리합니다.
Java 프로그램의 메모리는 여러 영역(Stack, Heap, Metaspace 등)으로 나뉘며, 이 중 힙(Heap) 영역이 동적으로 객체가 할당되는 공간입니다. 메서드 호출 중 지역 변수나 primitive 타입은 스택에 저장되지만, new로 생성된 객체는 모두 힙에 저장되고, 스택이나 레지스터에는 힙 객체를 가리키는 참조(reference)만 위치합니다. 힙에 할당된 객체는 해당 참조가 유효한 동안 메모리에 남아있어야 하지만, 메서드가 끝나거나 참조를 다른 곳으로 바꾸는 등으로 더 이상 그 객체를 사용할 수 없게 되면 메모리를 차지할 필요가 없습니다. 이때 GC가 그 “쓰레기” 객체를 정리하여 힙 공간을 재활용합니다.
JVM에서 GC가 필요한 가장 큰 이유는 한정된 힙 메모리를 효율적으로 사용하고 메모리 누수를 방지하기 위해서입니다. 예를 들어, 어떤 루프 안에서 수천 개의 객체를 생성하는 코드를 생각해보면, 루프를 도는 동안 만들어진 객체들은 루프 바깥에서는 쓸모가 없어집니다. 만약 GC가 없다면 이 쓰이지 않는 객체들이 계속 힙을 점유하여 결국 메모리를 고갈시킬 것입니다. 그러나 GC가 주기적으로 이런 객체들을 삭제해주기 때문에 Java 프로그램은 오랫동안 동작해도 사용 중인 객체만 메모리에 남기고 나머지는 정리하여 OutOfMemoryError를 예방할 수 있습니다. 요약하면 GC는 프로그래머의 수고를 덜어줄 뿐 아니라, 장기간 실행되는 애플리케이션에서 메모리 누수(memory leak)를 완화하고 시스템 안정성을 높이는 필수 요소입니다.
다만, GC가 자동으로 메모리를 관리해준다고 해서 만능은 아닙니다. 개발자가 의도치 않게 객체 레퍼런스를 계속 유지하면 GC는 그 객체를 "필요한 것"으로 간주하여 수거하지 않습니다. 이런 경우 힙 사용량이 계속 늘어나 OOM 문제가 생길 수 있는데, 이것은 GC의 한계가 아니라 프로그램 논리상의 메모리 누수로 볼 수 있습니다. 따라서 필요 없어진 객체는 컬렉션 등에서 제거하거나 참조를 null로 설정하여 명시적으로 Unreachable 상태로 만들어주는 것이 좋습니다. 이런 관점에서, GC는 "메모리 관리 실패로 인한 문제를 줄여주지만, 잘못된 코드로 인한 누수를 완전히 막아주진 못한다"는 점을 염두에 두어야 합니다.
Java의 GC 알고리즘은 시대와 요구에 따라 발전을 거듭해왔습니다. 기본 원리는 Mark(표시)와 Sweep(정리)이지만, 효율과 성능을 높이기 위해 Compaction(압축)이나 Copying(복사) 기법이 도입되었고, 객체의 세대(Generation)를 구분하여 관리하는 Generational GC 개념이 널리 쓰입니다. Java 8까지는 Serial, Parallel, CMS 같은 여러 수집기가 존재했고, Java 9부터는 G1 GC가 기본으로 채택되었으며, 이후 Shenandoah나 ZGC 같은 최첨단 수집기도 도입되었습니다.
각 알고리즘의 동작 방식을 하나씩 살펴보겠습니다.
가장 기본적인 Mark & Sweep 알고리즘은 앞서 설명한 대로, GC Root에서 시작해 살아있는 객체를 Mark하고, Mark 안 된 객체를 Sweep으로 제거하는 방식입니다. 이 방법은 구현이 비교적 단순하고 필요 없는 객체를 확실하게 제거할 수 있지만 한 가지 단점이 있습니다. Sweep(삭제) 단계에서는 단순히 객체를 메모리에서 없애기만 하므로, 삭제된 자리들이 메모리 여기저기에 흩어져 “단편화(fragmentation)”를 일으킬 수 있습니다. 예를 들어 Mark-Sweep 후에 힙 메모리를 보면 사용 중인 객체와 해제된 빈 공간들이 뒤섞여 있어서, 비록 전체 빈 용량은 충분해도 연속된 큰 빈 공간이 없으면 큰 객체를 할당하기 어려운 문제가 생길 수 있습니다. Java의 CMS (Concurrent Mark-Sweep) 수집기가 대표적으로 Mark-Sweep만을 사용하는 알고리즘이었으며, 높은 처리량을 보이지만 압축을 하지 않기 때문에 메모리 단편화 문제가 나타날 수 있었습니다. CMS GC의 경우 이런 단편화를 해소하려고 일정 조건에서 “compaction”을 위한 Stop-The-World Full GC를 추가 실행하기도 했는데, 이로 인해 지연시간이 예측하기 어려워지는 단점이 있었습니다.
Mark-and-Compact 알고리즘은 Mark-Sweep의 단편화 문제를 해결하기 위해 Sweep 단계 후에 메모리 압축(compaction)을 추가한 변형입니다. Mark 단계에서 살아있는 객체를 표시한 후, Sweep+Compact 단계에서 죽은 객체를 삭제함과 동시에 남은 객체들을 한쪽으로 몰아 연속된 빈 공간을 확보합니다. 이렇게 하면 GC 이후 힙 메모리가 “사용 중인 영역 + 한 덩어리의 여유 영역” 형태로 정리되므로, 이후 큰 객체를 할당할 때도 문제가 없고 할당 효율도 높아집니다. Mark-Compact 수집기는 구현이 조금 더 복잡하고 객체 이동 시 포인터를 업데이트해야 하지만, 메모리 활용 면에서 보다 안정적입니다. Serial GC와 Parallel GC 등이 힙의 Old 영역 수집에 Mark-Sweep-Compact 방식을 사용하며, 대다수 현대 GC 알고리즘은 무엇인가 형태로 객체 이동(복사 또는 압축)을 수행하여 단편화를 방지합니다.
Copying GC는 말 그대로 살아있는 객체들을 다른 메모리 공간으로 복사(Evacuate)함으로써 GC를 수행하는 알고리즘입니다. 기본적인 과정은 Mark 단계에서 동시에 복사를 수행하는 것으로, 사용 중인 객체를 새 공간으로 복사하면서 원래 공간을 비우는 식입니다. 복사가 끝나면 기존 영역 전체를 한 번에 비울 수 있으므로 단편화 문제가 자연스럽게 해결됩니다 (어차피 기존 영역은 몽땅 비워지기 때문입니다). Copying 알고리즘의 단점은 두 배의 메모리 공간(overhead)이 필요할 수 있다는 점입니다. 이론적으로 복사 대상(from-space)와 동일한 크기의 빈 공간(to-space)을 별도로 확보해야 하기 때문에 힙 공간이 작을 때는 비효율적일 수 있습니다. 그러나 대부분의 객체가 금방 죽는 현실적인 시나리오에서는 복사해야 할 살아있는 객체의 비율이 작아 복사 비용이 크지 않고, 대신 Sweep 단계가 아예 필요 없으므로 매우 빠른 수집이 가능합니다.Java의 Young 영역 수집 (Minor GC)이 대표적인 Copying 알고리즘의 활용 예입니다. HotSpot JVM의 Young Generation은 Eden, Survivor 공간들로 분리되는데, Minor GC가 발생하면 Eden의 살아있는 객체들을 Survivor 영역으로 한꺼번에 복사하고 Eden 전체를 비워버립니다. 또한 두 개의 Survivor 영역 중 하나는 항상 비워 두어, 다음 Minor GC 시 복사 대상 공간으로 활용합니다. 이러한 Semi-space 복사 수집 덕분에 Young 영역은 GC 후 바로 연속된 빈 공간이 되어 새로운 객체 할당이 매우 빠르고 단순해집니다. Copying GC는 대개 단일 세대의 수집에 사용되며, Young Generation 관리에 특히 효과적이기 때문에 거의 모든 JVM의 Young GC 알고리즘으로 채택되어 있습니다.
Generational GC는 Java GC의 핵심 최적화 개념으로, 객체를 생존 기간에 따라 여러 영역으로 나누어 관리하는 방식입니다. 이는 약한 세대 가설(Weak Generational Hypothesis), 즉 “대부분의 객체는 금세 불필요해지고, 오래 살아남는 객체는 일부에 불과하다”는 가정에 기반합니다. JVM은 이 가설을 활용하여 힙을 Young 세대와 Old 세대로 구분합니다. Young Generation(영 젠)에는 새로 생성된 객체가 저장되며, 대부분 금방 사라지기 때문에 크기를 작게 유지하고 자주 GC(Minor GC)하여도 부하가 크지 않습니다. Young GC는 앞서 설명한 Copying 방식 등으로 매우 빠르게 실행되며, 애플리케이션에 미치는 일시 정지 영향이 작습니다. Young GC에서 살아남아 여러 번 GC를 통과한 객체는 Old Generation(노령 세대)으로 승격(Promotion)되는데, Old 영역은 상대적으로 크게 할당되어 오래된 객체들을 모아둡니다. Old 영역은 Young보다 가비지 발생률이 낮으므로 GC를 가끔만 시행하지만, 한번 Major GC(Old GC)를 할 때는 **전 세대를 대상으로 하므로 시간이 오래 걸릴 수 있습니다】. 일반적으로 Major GC = Full GC 라 불리며, 힙 전체를 수집하기 때문에 애플리케이션에 더 긴 Stop-the-World 일시 정지를 유발합니다.Generational GC의 도입으로 대부분의 GC는 Young에서의 짧은 멈춤으로 처리되고, 드물게 일어나는 Old 영역 GC만 길게 멈추게 되어 전체 애플리케이션 성능을 향상시켰습니다. Java의 Serial GC, Parallel GC, CMS, G1 GC 등 전통적인 수집기들은 모두 Generational 구조를 띠고 있습니다. 예를 들어 Serial/Parallel GC는 Young 세대에는 복사(Copying) GC를, Old 세대에는 Mark-Sweep(-Compact) GC를 각각 적용합니다. CMS는 Old 세대의 Mark-Sweep을 애플리케이션 스레드와 병행(concurrent)하여 수행하도록 최적화한 버전이고, G1 GC도 기본적으로 Young/Old 세대 개념 위에 region 단위 관리 및 병행 방식을 도입한 것입니다. 한편, 최근의 ZGC나 Shenandoah 같은 수집기는 'Generational'이 아닌 단일 세대 처리를 했지만(모든 객체를 동일하게 취급), 2023년 이후 Generational ZGC 등의 발전이 이루어져 다시 세대 개념을 도입하는 추세입니다. 전반적으로, Generational GC는 현대 JVM 메모리 관리의 토대이며, 대부분의 튜닝도 Young과 Old의 밸런스를 조절하는 방향으로 이루어집니다.
G1 GC는 Java 7에 도입되어 Java 9부터 기본 GC로 채택된 최신 수집기입니다. 이름처럼 "Garbage First", 즉 가비지가 가장 많은 영역을 우선 수집하는 방식을 취하여 짧고 예측 가능한 멈춤 시간(low pause time)을 목표로 설계되었습니다. G1의 가장 큰 특징은 기존처럼 젠별로 물리적 구획을 나누는 대신, 힙을 일정 크기의 Region으로 잘게 분할한다는 점입니다. 각 Region은 상황에 따라 Eden, Survivor, Old 등 역할이 동적으로 할당되며, 필요한 경우 여러 Region에서 동시에 GC를 수행할 수도 있습니다. G1은 전체 힙을 한 번에 훑는 대신 Region별로 Marking과 Evacuation을 수행하고, 특히 가비지가 많은(살아있는 객체가 적은) Region을 우선적으로 수거함으로써 한 번에 많은 메모리를 회수합니다. 이를 통해 GC 빈도를 줄이고, 매번 stop-the-world 시간을 일정 수준(목표 값 이하)으로 통제할 수 있습니다.
G1 GC의 내부 동작을 간략히 보면, 우선 Concurrent Marking(동시 마크) 단계에서 전체 힙의 객체 그래프를 추적하여 각 Region의 유효 객체량을 파악합니다. 그 정보로 Region별 “가비지 비율”을 계산한 후, Stop-the-World로 전환하여 가비지 비율이 높은 Region들부터 순차적으로 수집(evacuate)합니다. Evacuation 과정에서는 해당 Region의 살아있는 객체들을 다른 Region으로 모두 복사하고 그 Region을 비웁니다. 객체 복사는 멀티스레드로 병렬 수행되며, 이렇게 이동된 객체들로 남은 Region들은 자연스럽게 메모리가 압축(compact)된 상태가 됩니다. G1은 이러한 Region 단위 복사(compaction)를 지속적으로 수행하여 힙 단편화를 누적되지 않도록 관리합니다. 또한 G1에서는 목표 최대 pause time (-XX:MaxGCPauseMillis)을 설정하여 GC 간격과 작업량을 조절할 수 있는데, 설정한 목표를 달성하기 위해 한 번의 GC에서 얼마나 많은 Region을 수거할지 조정하는 지능적인 정책을 갖추고 있습니다.
요약하면, G1 GC는 병렬(Parallel) + 부분 동시(Concurrent) + 분할(Region) + 복사(Evacuation) 등의 기법을 종합적으로 활용하여 Stop-the-World 시간을 낮추면서도 메모리 효율을 유지하는 수집기입니다. 대용량 힙(수 GB 이상)과 다중 코어 환경에서 특히 효과적이며, 서버 애플리케이션에서 짧은 지연 시간과 안정적인 성능을 제공하는 것으로 평가됩니다. (참고로, G1은 CMS를 대체하기 위해 나왔으며, JDK 14부터는 CMS가 완전히 제거되어 선택 불가능해졌습니다.)
ZGC는 JDK 11에 실험적으로 도입되어 JDK 15부터 정식 지원된 초저지연(ultra low-latency) 수집기입니다. ZGC의 가장 큰 목표는 힙 크기에 상관없이 GC로 인한 일시정지 시간을 10ms 이하로 유지하는 것으로, 몇 TB에 달하는 대용량 힙에서도 애플리케이션 작업을 거의 멈추지 않고 메모리 회수를 수행할 수 있습니다. 앞서 소개한 G1, CMS 등이 짧은 멈춤 시간을 지향하지만 여전히 힙 크기가 커지면 Mark 단계 등에서 일정 부분 STW 시간이 늘어날 수 있는 데 반해, ZGC는 객체 참조에 특수한 핸들(Colored Pointer 기법)을 사용하고, Marking과 객체 이동을 대부분 병행 처리(concurrent relocation) 함으로써 힙 크기와 무관하게 STW 시간을 수 밀리초 수준으로 유지합니다. 간단히 말해, ZGC에서는 애플리케이션 스레드가 실행되는 동안에도 GC 스레드가 대부분의 작업을 같이 수행하고, Stop-the-World는 GC 사이클마다 아주 짧은 초기/최종 단계에만 발생합니다 (각각 수 밀리초 미만). 이런 특성 때문에 실시간 처리가 중요하거나, 매우 큰 메모리 공간을 다루는 Java 애플리케이션(예: 수십 GB~수백 GB 이상의 힙)에서 유용합니다.
ZGC의 구현 세부사항으로, 힙을 일정 크기의 "ZPages"로 나누어 관리하는데 G1의 Region과 유사하나 크기가 고정이 아니어서 2MB 단위로 커지거나(또는 더 큰 Granule) 유동적으로 운용됩니다. 또한 포인터의 일부 비트를 메타데이터로 활용하는 Colored Pointer 기술과 Read/Write Barrier를 통해, 애플리케이션 스레드가 객체를 접근할 때 약간의 추가 작업으로 GC와 상호 협력하도록 합니다. 이러한 기법 덕분에 객체 이동이나 참조 수정 작업도 대부분 동시에 진행할 수 있으며, GC 중에도 애플리케이션은 거의 계속 실행된다는 큰 장점이 있습니다. ZGC를 사용하려면 자바 실행 시 옵션으로 -XX:+UseZGC를 지정하면 되고 (JDK 15+부터는 실험 옵션 언락 불필요), 힙 크기만 충분하다면 (-Xmx로 지정) GC 튜닝 없이도 놀라운 저지연 성능을 얻을 수 있습니다. 다만, ZGC는 최대 처리량(througput) 면에서는 Parallel GC 등에 비해 약간 손해를 볼 수 있는데, 이는 GC가 애플리케이션 스레드와 CPU 자원을 나누어 쓰기 때문입니다. 결국 짧은 지연시간 vs. 높은 처리량은 트레이드오프이며, ZGC는 전자에 극단적으로 최적화된 선택지라 할 수 있습니다. (한편, 비슷한 저지연 수집기로 Shenandoah GC가 있는데, 이는 RedHat 주도로 개발되어 OpenJDK 12부터 도입되었으며 원리는 다르지만 목표는 ZGC와 유사합니다.)
위에서 다룬 알고리즘 외에도 Serial GC, Parallel GC, CMS, Shenandoah, Epsilon GC 등 다양한 수집기가 있습니다. Serial GC는 단일 스레드로 Mark-Sweep-Compact를 수행하는 가장 단순한 수집기로, 작은 애플리케이션이나 임베디드 장치용으로 사용됩니다. Parallel GC는 Serial의 Young 컬렉션을 다중 스레드로 수행하여 throughput을 높인 것으로, JDK8까지 기본 GC였습니다. Parallel Old GC는 Old 영역도 병렬로 처리하여 Parallel GC의 단점을 개선한 버전입니다. CMS (Concurrent Mark-Sweep)는 소개했듯이 구세대 객체 수집을 여러 단계로 나누어 애플리케이션과 병행 실행하는 방식이며 한때 많이 사용됐으나 지금은 G1로 대체되었습니다. Shenandoah GC는 ZGC와 마찬가지로 10ms 이하의 멈춤 시간을 목표로 한 수집기로, 객체에 Forwarding Pointer를 두고 백그라운드 압축을 수행하는 등 차별화된 기법을 사용합니다. Epsilon GC는 실험적으로 제공되는 “아무것도 안 하는” GC로, 메모리 할당만 하고 수집을 전혀 하지 않는 수집기입니다. Epsilon은 특수한 테스트나 실험 목적 (GC 오버헤드 측정 등)에나 쓰이며 일반 애플리케이션에는 사용되지 않습니다.
JVM의 GC 수행은 보통 어떤 이벤트에 의해 트리거(trigger)됩니다. 가장 흔한 예가 힙의 Young 영역(Eden)이 가득 찼을 때 발생하는 Minor GC이고, Old 영역까지 꽉 차면 Major GC (Full GC)가 발생합니다. 또한 System.gc()를 호출하여 명시적으로 Full GC를 유발할 수도 있는데, 이는 가급적 피하는 것이 권장됩니다 (System.gc()는 강제로 Full GC를 일으키고 해당 작업이 끝날 때까지 애플리케이션을 기다리게 만드므로 성능에 악영향을 줄 수 있습니다).
Minor GC는 Young 세대만 수집하며, 짧은 시간동안(Eden이 작기 때문) 정지가 일어납니다. Minor GC 시 Eden의 객체들은 검사 후 Survivors로 이동하거나 수거되고, 필요하다면 Survivor에서 Old로 승격되기도 합니다. Minor GC는 매우 자주 발생할 수 있는데 (Eden이 꽉 찰 때마다), 일반적으로 수 밀리초~수십 밀리초 내에 완료되므로 애플리케이션에 큰 지장 없이 반복됩니다. 반면, Major GC(또는 Full GC)는 Old 영역을 포함한 힙 전체를 수집하기 때문에 보통 Minor GC보다 5~10배 이상 시간이 오래 걸립니다. 예를 들어 Minor GC가 수십 밀리초 걸리는 앱에서 Major GC는 수백 밀리초~수 초까지도 걸릴 수 있습니다. Major GC가 실행되면 “Stop-The-World” 현상이 두드러져 애플리케이션이 일시적으로 정지 또는 버벅거림을 체감할 수 있습니다. 따라서 Java에서는 Major/Full GC 횟수를 줄이는 것이 성능 튜닝의 중요 과제가 됩니다.
Stop-the-World (STW)란 GC 등의 특별한 작업을 수행하기 위해 JVM이 애플리케이션의 모든 실행 스레드를 멈추는 것을 말합니다. Mark 단계에서 객체 그래프를 안전하게 탐색하려면 애플리케이션이 객체 참조를 변경하면 안 되므로, 기본적인 GC 알고리즘들은 Mark 시작 전에 세계(stop the world)를 멈추고 작업을 합니다. 전통적인 Serial/Parallel GC는 GC 전체 과정을 STW로 수행하므로 GC 중에는 앱이 완전히 멈춰있게 됩니다
. CMS나 G1 같은 Concurrent GC들은 STW 구간을 줄이기 위해 일부 단계(대부분의 Marking 등)를 애플리케이션과 동시에 진행하지만, 그래도 Initial Mark나 Final Remark 등 몇몇 단계에서는 짧은 STW가 발생합니다
. ZGC와 Shenandoah는 이 STW 시간을 극단적으로 줄여 몇 밀리초 수준으로 만든 것이고요.
정리하면, Minor GC든 Major GC든 어떤 형태로든 STW가 수반됩니다. Minor GC는 짧은 STW이기에 무시할만하지만, Major GC의 STW는 응답 지연을 야기하므로 관리 대상이 됩니다. JVM 로그에서는 보통 “Full GC”라는 표현이 STW GC 이벤트를 가리키는데, Minor GC도 애플리케이션 스레드를 잠깐 멈추므로 넓은 의미에서는 STW에 해당합니다. 다만 관용적으로 “Full GC”는 Old 영역까지 수집하는 큰 GC를 뜻하고, “STW pause”는 GC로 인해 애플리케이션이 멈춘 시간을 가리킵니다. GC 튜닝의 목표 중 하나는 Minor GC는 빈번하게, Major GC는 드물게 발생하도록 하여 길고 큰 STW를 최소화하는 것입니다.
GC 튜닝은 애플리케이션의 특성(Throughput vs Latency vs Footprint)에 맞춰 GC의 동작을 조절하는 작업입니다. 만능 설정은 없고, 애플리케이션 유형, 처리량 요구, 허용 가능한 지연시간, 사용 가능한 메모리 등에 따라 다르게 접근해야 합니다. 아래에 일반적인 GC 튜닝 기법과 고려사항을 정리했습니다:
적절한 GC 알고리즘 선택: 가장 먼저 애플리케이션의 목표에 맞는 GC를 고르는 것이 중요합니다. 예를 들어 최대 처리량(throughput)을 원한다면 Parallel GC가 유리하고, 응답 지연시간(latency)을 최소화해야 한다면 G1 GC나 ZGC 같은 저지연 수집기가 적합합니다. 메모리 사용량(footprint)을 줄이고 싶다면 약간 덜 aggressive한 수집기가 나을 수도 있습니다. 한마디로 “처리량-지연시간-메모리” 세 축은 상충 관계이므로, 자신의 우선순위 2가지를 정하고 그에 맞춰 GC를 선택 및 설정해야 합니다. JVM의 기본 GC (Java 8까지는 Parallel, 9부터는 G1)는 대부분의 경우 무난하지만, 만약 GC로 인해 성능 문제가 발생한다면 다른 GC로 스위칭 해보는 것만으로도 개선될 수 있습니다.
힙 크기 및 세대 크기 조절: 가장 기본적인 튜닝 파라미터는 Heap 메모리 크기(-Xms, -Xmx)입니다. 충분한 힙을 주면 GC 빈도가 줄어 전체 처리량이 높아질 수 있지만, 너무 크게 잡으면 한 번 GC할 때 시간이 오래 걸릴 수 있습니다. Young/Old 세대 비율도 튜닝 요소인데 (-XX:NewRatio 등), Young 영역을 크게 하면 Minor GC 빈도는 줄지만 한번 실행 시 시간이 늘어나고, 작게 하면 자주 GC하나 매우 빨리 끝납니다. Survivor 영역 비율(-XX:SurvivorRatio)이나 객체 승격 임계값(-XX:MaxTenuringThreshold)도 객체가 Old로 너무 빨리 올라가지 않도록 조절하여 Old GC를 지연시키는 데 활용됩니다. 예를 들어 Young에서 몇 번 이상 살아남은 객체만 Old로 올리고 싶다면 MaxTenuringThreshold를 늘릴 수 있습니다 (HotSpot 기본은 15회 정도입니다).
GC 스레드 수 조절: Parallel GC나 G1 GC 등은 GC 작업을 수행하는 스레드 개수를 조절할 수 있습니다 (-XX:ParallelGCThreads=N). 일반적으로 CPU 코어 수에 맞춰 자동으로 설정되지만, 매우 많은 코어 환경에서 과도한 GC 스레드는 오버헤드를 증가시킬 수 있어 적절히 제한할 필요가 있습니다. 반대로, GC 스레드 수가 너무 적으면 GC 시간이 길어지므로, 애플리케이션에 할당된 CPU 리소스 범위 내에서 최적의 스레드 수를 찾아야 합니다.
Pause Time 목표 및 GC 사이클 조절: G1 GC나 Parallel GC에서는 목표 최대 pause time (-XX:MaxGCPauseMillis)을 설정하여 JVM이 GC 빈도와 작업량을 자동 조율하게 할 수 있습니다. 예를 들어 -XX:MaxGCPauseMillis=100으로 하면 G1은 GC를 자주 하더라도 0.1초 이내로 끝내려 노력합니다. 하지만 이 값을 너무 작게 하면 JVM이 목표를 맞추기 위해 Young 크기를 줄이고 매우 잦은 GC를 발생시켜 오히려 전체 처리량이 떨어질 수 있으므로 주의해야 합니다. 반복되는 짧은 멈춤 vs. 드문 긴 멈춤 사이에서 균형을 찾아야 합니다.
메모리 풀 및 객체 재사용 고려: GC 부담을 줄이는 근본적인 방법은 아예 객체 생성을 줄이는 것입니다. 가능하면 불변 객체를 재사용하거나, String Pool과 같이 캐싱 기법을 활용하여 객체 할당/소멸을 최소화합니다. 객체 생성 자체는 빠르지만, 단기간에 수많은 객체를 생성하면 결국 GC 비용으로 돌아오기 때문입니다. 특히 대용량 컬렉션을 반복해서 할당/해제하는 대신 clear() 등을 통해 재사용하거나, 필요한 경우 Object Pool(객체 풀링)을 구현하는 것도 한 방법입니다 (다만 현대 JVM에서는 불필요한 풀링은 오히려 역효과일 수 있으니 신중히 판단합니다).
System.gc() 남용 금지: 앞서 언급했듯 System.gc()는 Full GC를 유발하여 장시간 STW를 만들 수 있으므로 가급적 코드에 사용하지 않는 것이 좋습니다. 일부 라이브러리나 프레임워크에서 메모리 반환을 위해 호출하는 경우가 있는데, 대개는 득보다 실이 크니 꼭 필요하지 않다면 피해야 합니다. JVM 옵션으로 -XX:+DisableExplicitGC를 주면 System.gc() 호출을 무시할 수도 있습니다. 대신, 프로그램적으로 메모리를 회수하고 싶다면 해당 객체들을 컬렉션에서 제거하거나 참조를 null 처리하여 GC 대상이 되도록 유도하는 것이 바람직합니다.
GC 로그 및 모니터링 활용: 튜닝을 시작하기 전, 현재 GC 동작을 객관적으로 파악하는 것이 우선입니다. -Xlog:gc* (또는 Java 8 이하에선 -verbose:gc -XX:+PrintGCDetails) 옵션을 통해 GC 로그를 수집하면, 얼마나 자주 GC가 발생하고 각각 얼마나 걸렸는지, 힙 사용량이 어떻게 변했는지 등의 유용한 정보가 상세히 기록됩니다. 이 로그를 분석하여 Minor GC에 비해 Major GC가 너무 잦다든가, 한 번 GC할 때 너무 오래 멈춘다든가 하는 문제점을 찾아냅니다. JDK Mission Control/Flight Recorder(JFR), jstat 커맨드, VisualVM 등의 모니터링 툴을 사용하면 실시간으로 힙 사용량과 GC 빈도를 시각적으로 볼 수도 있습니다. 이러한 데이터를 기반으로, Young 영역 크기 조절이나 GC 알고리즘 변경 등의 튜닝 방향을 결정해야 합니다. 또한, 튜닝 전후를 동일한 조건에서 비교하여 효과를 검증하는 것도 잊지 말아야 합니다 (튜닝으로 인한 부작용이 없는지 확인하기 위함).
환경 변화에 대비: GC 튜닝은 현재 환경에 맞춤 최적화되는 경향이 있어서, 만약 하드웨어 스펙이 바뀌거나 애플리케이션 부하 패턴이 변하면 설정을 다시 조정해야 할 수도 있습니다. 예를 들어 코어 수가 늘면 병렬 GC 스레드 수도 조정하는 게 좋고, 메모리가 늘면 힙 사이즈도 조정하여 더 많은 객체를 Old에 담을 수 있게 할 수 있습니다. 그러므로 튜닝 설정은 주기적으로 재평가하고, 가능하면 너무 극단적인 값보다는 다소 보수적인 범위에서 변경하는 것이 유지보수에 유리합니다. 또한, GC 튜닝만으로 해결 안 되는 성능 문제가 있다면 코드 레벨 최적화나 하드웨어 증설도 고려해야 합니다.
요약하면, GC 튜닝은 “GC 알고리즘 선택 → 힙/세대 크기 등 메모리 파라미터 조정 → 목표 지표에 따른 추가 옵션 설정”의 순서로 이뤄지며, 매 단계에서 모니터링을 통해 피드백받는 과정이 필요합니다. 작은 애플리케이션은 기본 설정으로도 잘 돌아가므로 너무 이른 튜닝은 피하고, 문제가 될 때 신중히 접근하는 것이 좋습니다.
GC의 동작과 성능을 모니터링하기 위한 도구는 여러 가지가 있습니다. 대표적인 것들을 소개하면 다음과 같습니다:
JVisualVM: JDK에 포함된 GUI 기반 모니터링 툴로, 애플리케이션의 메모리, 스레드, GC 활동을 실시간으로 시각화해줍니다. VisualVM을 실행하면 로컬 또는 원격 JVM 프로세스에 붙어서 Heap 사용량 그래프, Young/Old 영역 크기 변화, GC 횟수와 일시정지 시간 등을 볼 수 있습니다. VisualVM에는 Visual GC라는 플러그인도 있는데, 이것을 쓰면 Eden/Survivor/Old별 사용량과 GC 이벤트를 한 눈에 모니터링할 수 있습니다. 또한 VisualVM은 힙 덤프 뜨기, CPU/메모리 프로파일링, 스레드 덤프 분석 등의 기능도 있어, GC뿐만 아니라 전반적인 JVM 성능 분석에 유용한 종합 도구입니다. 사용법도 비교적 쉬워서, GC 튜닝의 출발점으로 많이 활용됩니다.
GC 로그 (Garbage Collection Logs): 앞서 언급한 -Xlog:gc* 옵션을 통해 남겨지는 텍스트 로그입니다. 이 로그에는 각 GC 이벤트마다 발생 시각, 종류(Minor/Full), 각 영역의 메모리 변화량(Eden X->Y, Old X->Y 등), 소요 시간 등이 상세히 기록됩니다. 예시로, Parallel GC의 로그 한 줄은 다음과 같을 수 있습니다:
[GC (Allocation Failure) [ParNew: 8192K->512K(9216K), 0.0050 secs] 25000K->18000K(32768K), 0.0052 secs]
이는 Young GC가 일어나 Eden+Survivor 합 8192KB에서 512KB로 줄었고 (8MB 중 512KB 생존), 전체 힙은 25MB->18MB로 감소, 걸린 시간은 5.2ms였다는 뜻입니다. 이러한 GC 로그를 분석하면 Minor GC와 Full GC의 빈도와 영향을 정밀히 파악할 수 있습니다. 로그를 직접 파싱해봐도 좋지만, 다소 번거로울 수 있으므로 전문 툴을 사용하는 것이 편리합니다.
GCEasy 등의 GC 분석 툴: 수집된 GC 로그 파일을 업로드하면 자동으로 해석해주는 온라인 서비스들이 있습니다. GCEasy (gceasy.io)는 유명한 GC 로그 분석기로, 로그를 통해 총 GC 시간, 평균/최대 pause time, throughput 손실률, 메모리 사용 패턴 그래프 등을 리포트로 보여줍니다. 또한 잠재적 문제점(예: Full GC 너무 빈번, 메모리 누수 가능성 등)을 지적하고 튜닝 가이드를 제시해주기도 합니다. 이외에도 HP JMeter GC Analyzer, IBM PMAT, Eclipse MAT (메모리 분석 툴) 등이 GC 및 메모리 문제를 진단하는 데 쓰일 수 있습니다.
JConsole & Java Mission Control: JConsole은 JDK에 포함된 간단한 모니터링 툴로, Heap 메모리와 Perm Gen(메타스페이스) 사용량, 스레드, 클래스 로드 수 등을 실시간으로 보여줍니다. VisualVM만큼 상세하지는 않지만 가볍게 상태를 볼 때 쓰입니다. Java Mission Control(JMC)은 JDK 7부터 도입된 강력한 진단 툴로, Flight Recorder와 연동하여 애플리케이션의 상세 실황을 수집/분석할 수 있습니다. JFR을 통해 GC 이력, 힙 할당 패턴, 객체 할당 추적 등 낮은 수준의 데이터까지 확인 가능하며, Mission Control GUI로 이를 분석해 병목을 찾을 수 있습니다. 다만 JMC는 학습 곡선이 좀 있는 편이므로, 보통은 문제 원인을 어느 정도 좁혀낸 뒤 심층 분석할 때 사용됩니다.
요약하면, 개발/운영 단계에서 GC를 모니터링하는 것은 매우 중요합니다. 이 도구들을 활용하여 GC로 인한 애플리케이션 정지 시간이 허용 한도 내인지, 메모리 누수는 없는지, 튜닝이 필요한지 등을 주기적으로 점검해야 합니다. 특히 장기 실행되는 서버에서는 GC 로그를 적절히 남겨두고 (별도 파일로 저장 권장) 분석하는 습관이 안정성 향상에 큰 도움이 됩니다.
마지막으로, GC 동작을 이해하기 위한 간단한 코드 예제와 실무 적용 방안을 알아보겠습니다.먼저, 짧은 생명주기의 객체들이 GC로 수거되는 모습을 보여주는 자바 코드 예시입니다:
public class GCDemo {
public static void main(String[] args) {
for (int i = 1; i <= 1000000; i++) {
// 반복문마다 새로운 객체 생성
Object obj = new Object();
// obj를 사용 (여기서는 단순히 해시코드 출력)
obj.hashCode();
// 반복 100000번마다 진행 상황 출력
if (i % 100000 == 0) {
System.out.println(i + " objects created");
}
}
System.out.println("Loop finished");
}
}
위 코드에서는 for 루프 내에서 1,000,000개의 Object 인스턴스를 생성합니다. 중요한 점은 루프 바깥에서 그 객체들을 더 이상 참조하지 않는다는 것입니다 (obj 변수는 매 반복마다 재사용되므로 이전 객체를 가리키던 참조는 사라집니다). 따라서 루프가 진행되는 동안 이전에 만든 객체들은 점차 GC의 대상이 됩니다. 이 프로그램을 실행하면 중간중간 진행 상황이 출력되고, 루프가 끝난 시점에서 힙에는 마지막에 생성된 객체만 남고 나머지 999,999개는 모두 수거됩니다. GC 덕분에 이렇게 많은 객체를 만들어도 프로그램이 무리 없이 완료되는 것이죠. 만약 GC가 없다면 이 많은 객체가 힙에 쌓여 메모리 부족이 났을 겁니다.
이 예제를 실제로 실행할 때 JVisualVM으로 해당 프로세스를 모니터링해보면, 힙 사용량이 일정 수준까지 상승했다가 GC에 의해 뚝 떨어지는 패턴을 볼 수 있습니다. 특히 -Xms32m -Xmx32m 같이 힙을 작게 주고 실행하면 GC가 더 빈번히 발생하는데, VisualVM의 메모리 그래프에서 “톱니 모양”으로 상승/하강을 반복하는 모습을 관찰할 수 있습니다. 이 톱니가 바로 Minor GC 사이클을 나타내며, 객체 할당이 계속 이루어지다가 Eden이 차면 한 번 GC로 비워내고, 다시 채우고를 반복하는 것입니다. GC 로그를 활성화해서 살펴보면 아마 [GC (Allocation Failure)...]라는 Minor GC 로그가 다수 출력되고, 마지막에 [Full GC (Ergonomics)...] 등이 한두 번 찍힐 수 있습니다 (마지막에 잔존 객체를 수거하거나, JVM 종료 전에 정리하는 과정).
실무 적용 측면에서, 위 예제는 단순히 GC 동작을 보여주는 용도이고 실제 애플리케이션에서는 이렇게 무의미한 객체 생성을 남발하진 않을 것입니다. 그러나 대규모 애플리케이션에서는 짧은 수명 객체(예: 요청/응답 처리 중 생성되는 각종 임시 객체)가 매우 많고, 이들을 효율적으로 처리하는 것이 중요합니다. 이를 위해 Generational GC가 Young 영역에서 짧은 주기의 수집을 수행하고 있는 것이며, 개발자는 가급적 불필요하게 장수하는 객체를 만들지 않도록 신경 써야 합니다. 예를 들어 캐시에 넣지 않아도 될 데이터를 글로벌 컬렉션에 넣어두거나 하면 그 객체가 오래 살아남아 Old 영역까지 가고, 결국 Full GC 부담을 늘릴 수 있습니다. 그러므로 객체의 생명주기를 코드 수준에서 관리하는 것도 성능 튜닝의 한 부분입니다.
또한 GC 튜닝을 실전에 적용할 때는 다음과 같은 절차를 권장합니다:
마지막으로, 메모리/GC 문제에 대처하는 자세에 대해 언급하자면: GC 튜닝은 때로는 수고 대비 효과가 제한적일 수 있고, 코드상의 비효율을 가리는 미봉책일 수도 있습니다. GC 설정을 건드려도 해결되지 않는 문제가 있다면 애플리케이션 로직을 개선하거나, 구조를 변경해야 할 수도 있습니다. 예컨대 메모리 누수가 의심된다면 GC 튜닝보다 히스토그램/힙덤프 분석으로 어떤 객체가 쌓이는지 찾아서 그 원인을 제거하는 게 정석입니다. 또한 정말 메모리가 부족하다면 하드웨어적으로 메모리를 늘리는 편이 나을 수 있습니다. GC는 도구이지 목표가 아니므로, 결국에는 원활한 메모리 관리와 안정적인 애플리케이션 동작이라는 큰 그림 속에서 활용되어야 합니다.
요약하면 다음과 같습니다: