소프트웨어 개발의 역사에서 '메모리 관리'는 언제나 개발자의 가장 큰 숙제 중 하나였습니다. C/C++와 같은 언어에서 개발자는 malloc과 free, 혹은 new와 delete를 통해 직접 메모리의 할당과 해제를 책임져야 했습니다. 이는 마치 숙련된 장인이 원자재를 직접 다루는 것과 같아서, 최대한의 성능과 제어력을 제공했지만 동시에 치명적인 실수의 문을 활짝 열어두었습니다. 단 한 번의 해제 누락은 '메모리 누수(Memory Leak)'로 이어져 시스템을 좀먹고, 이미 해제된 메모리에 접근하는 '댕글링 포인터(Dangling Pointer)'는 예측 불가능한 충돌을 일으키는 시한폭탄이었습니다. 이러한 문제들은 디버깅하기 가장 까다로운 버그의 원인이 되곤 했습니다.
이러한 혼돈 속에서 "개발자가 비즈니스 로직에 더 집중하게 할 수는 없을까?"라는 질문이 제기되었습니다. 즉, 기계에 더 가까운 저수준의 작업을 시스템에 위임하고, 개발자는 창의적이고 논리적인 문제 해결에 몰두하게 하자는 아이디어였습니다. 이 철학의 정점에서 태어난 것이 바로 가비지 컬렉션(Garbage Collection, GC)입니다. GC는 프로그램이 동적으로 할당한 메모리 영역 중 더 이상 사용되지 않는, 즉 '쓰레기(Garbage)'가 된 영역을 찾아내어 자동으로 해제하는 역할을 수행합니다. 이는 개발자를 메모리 관리의 족쇄로부터 해방시킨 혁명적인 변화였습니다.
하지만 가비지 컬렉션은 결코 '공짜 점심'이 아닙니다. GC가 주는 편리함의 이면에는 성능 저하라는 잠재적 비용이 존재합니다. GC가 동작하는 동안 애플리케이션의 실행이 잠시 멈추는 'Stop-the-World' 현상은 실시간 응답성이 중요한 시스템에서는 치명적일 수 있습니다. 따라서 현대의 프로그래머에게 GC는 '몰라도 되는 마법'이 아니라, '이해하고 협력해야 하는 시스템'이 되었습니다. 이 글에서는 가비지 컬렉션이 어떤 원리로 동작하는지, 그 핵심적인 알고리즘부터 현대적인 JVM의 정교한 GC까지, 그 내부를 깊이 있게 들여다보고자 합니다. 이는 단순히 기술적 지식을 나열하는 것을 넘어, 우리가 작성하는 코드가 메모리 위에서 어떻게 생명을 얻고 소멸하는지에 대한 근본적인 이해를 돕는 여정이 될 것입니다.
1. '쓰레기'란 무엇인가: 도달 가능성(Reachability)의 개념
가비지 컬렉터가 가장 먼저 해결해야 할 문제는 "어떤 메모리가 쓰레기인가?"를 정의하는 것입니다. 직관적으로는 '더 이상 사용되지 않는 객체'라고 할 수 있지만, 컴퓨터는 이 추상적인 개념을 어떻게 판단할 수 있을까요? 대부분의 현대 GC는 '도달 가능성(Reachability)'이라는 개념을 기반으로 쓰레기를 식별합니다.
프로그램의 메모리 공간에 존재하는 수많은 객체들은 서로를 참조하며 거대한 그래프 구조를 이룹니다. 이 그래프의 시작점을 '루트 세트(Root Set)'라고 부릅니다. 루트 세트는 애플리케이션이 어떤 식으로든 직접 접근할 수 있는 메모리 영역을 의미하며, 대표적으로 다음과 같은 것들이 포함됩니다.
- 실행 중인 모든 스레드의 콜 스택(Call Stack)에 있는 지역 변수와 파라미터들
- 전역 변수(Static Variables)
- JNI(Java Native Interface)에 의해 참조되는 객체들
- CPU 레지스터에 의해 직접 참조되는 객체들
GC는 이 루트 세트에서부터 시작하여, 참조 체인을 따라갈 수 있는 모든 객체를 '살아있는(Live)' 객체로 간주합니다. 반대로, 루트 세트로부터 시작하여 어떤 경로로도 도달할 수 없는 객체는 '쓰레기(Garbage)'로 판단합니다. 이 과정은 마치 거미줄의 중심에서부터 연결된 모든 줄을 따라가며 표시를 남기는 것과 유사합니다. 중심(루트)에서 닿지 않는 모든 것은 버려질 대상이 되는 것입니다.
+---------------------------------------------------------------------------------------+ | Heap Memory | | | | [Root Set] --------> [Object A] <-------+ | | | | | | | | V | | | +-----------> [Object B] ----> [Object C] | | | | [Object D] ----> [Object E] | | ^ | | | | V | | +----------- [Object F] | | | | [Unreachable Objects (Garbage)] | | - Object D | | - Object E | | - Object F | | | +---------------------------------------------------------------------------------------+
위의 텍스트 다이어그램에서, 루트 세트는 객체 A와 B를 직접 참조하고 있습니다. 객체 A는 B를 참조하고, B는 C를 참조하며, 심지어 C는 다시 A를 참조하는 순환 참조 구조를 가집니다. 하지만 이들은 모두 루트에서부터 도달 가능하므로 살아있는 객체입니다. 반면, 객체 D, E, F는 서로 참조하고 있지만, 루트 세트로부터 시작하는 어떤 경로도 이들에게 닿지 않습니다. 따라서 이 객체 그룹 전체가 '도달 불가능'하며, 가비지 컬렉션의 대상이 됩니다. 이 '도달 가능성'이라는 명확한 기준 덕분에 GC는 순환 참조 문제에 빠지지 않고 안정적으로 쓰레기를 식별할 수 있습니다.
2. 고전적 GC 알고리즘: 모든 것의 시작
현대의 복잡하고 정교한 GC들도 결국 몇 가지 고전적인 아이디어에서 출발했습니다. 이 기본 알고리즘들을 이해하는 것은 GC의 본질을 파악하는 데 매우 중요합니다. 각각의 알고리즘은 특정 문제를 해결하기 위해 고안되었으며, 저마다의 장점과 명확한 한계를 가지고 있습니다.
2.1. Mark and Sweep: 표시하고 쓸어담기
가장 기본적이고 널리 알려진 알고리즘은 '표시하고 쓸기(Mark and Sweep)' 방식입니다. 이름에서 알 수 있듯이, 이 알고리즘은 두 가지 주요 단계로 동작합니다.
- 표시(Mark) 단계: GC는 루트 세트에서 시작하여, 참조 그래프를 따라 순회하면서 도달 가능한 모든 객체에 '살아있음'을 의미하는 표시(mark)를 남깁니다. 이 과정은 깊이 우선 탐색(DFS)이나 너비 우선 탐색(BFS)과 유사하게 진행됩니다.
- 쓸기(Sweep) 단계: 표시 작업이 끝나면, GC는 힙 메모리 전체를 순차적으로 스캔하면서 표시되지 않은 객체, 즉 쓰레기를 찾아내어 그들이 차지하던 메모리를 해제합니다. 해제된 메모리는 '사용 가능(free)' 목록에 추가되어 다음 할당에 사용될 수 있습니다.
Mark and Sweep은 매우 직관적이고, 앞서 언급한 순환 참조 문제를 자연스럽게 해결할 수 있다는 큰 장점이 있습니다. 하지만 두 가지 고질적인 문제를 안고 있습니다.
- Stop-the-World: Mark 단계와 Sweep 단계가 실행되는 동안에는 애플리케이션의 모든 스레드가 완전히 멈춥니다. 이를 'Stop-the-World'라고 부르며, 힙 크기가 크고 객체가 많을수록 멈춤 시간(pause time)이 길어져 사용자 경험에 악영향을 줄 수 있습니다.
- 메모리 단편화(Memory Fragmentation): Sweep 단계에서 쓰레기가 차지하던 메모리가 해제되면, 힙 전체에 걸쳐 사용 가능한 작은 메모리 공간(hole)들이 듬성듬성 생겨납니다. 이로 인해 전체적으로는 충분한 여유 공간이 있음에도 불구하고, 연속된 큰 메모리 덩어리를 필요로 하는 객체를 할당하지 못하는 상황이 발생할 수 있습니다. 이를 외부 단편화라고 합니다.
+---------------------------------------------------------------------------------------+
| Heap Before Sweep | [Used: 5KB] | [Garbage: 3KB] | [Used: 4KB] | [Garbage: 8KB] |
+---------------------------------------------------------------------------------------+
(Sweep Phase)
|
V
+---------------------------------------------------------------------------------------+
| Heap After Sweep (Fragmented) | [Used: 5KB] | [Free: 3KB] | [Used: 4KB] | [Free: 8KB] |
+---------------------------------------------------------------------------------------+
* Total Free: 11KB, but cannot allocate a 10KB object.
이 단편화 문제를 해결하기 위해 'Mark-Sweep-Compact' 알고리즘이 등장했습니다. 이는 Sweep 이후에 살아남은 객체들을 힙의 한쪽 끝으로 차례대로 이동시켜 단편화된 공간을 하나로 합치는 압축(Compaction) 단계를 추가한 방식입니다. 압축은 단편화를 해결하지만, 객체를 이동시키는 데 추가적인 비용이 발생하며 Stop-the-World 시간을 더욱 증가시키는 원인이 됩니다.
2.2. Copying Collector: 복사를 통한 새로운 시작
Mark and Sweep의 단편화 문제를 근본적으로 해결하기 위해 등장한 것이 '복사(Copying)' 알고리즘입니다. 이 방식은 힙 메모리를 두 개의 동일한 크기 공간, 즉 'From' 공간과 'To' 공간으로 나눕니다. 객체 할당은 항상 From 공간에서만 이루어집니다.
GC가 시작되면 다음과 같은 절차를 따릅니다.
- GC는 루트 세트가 참조하는 객체들을 From 공간에서 To 공간으로 복사합니다.
- 그 다음, To 공간으로 복사된 객체들이 참조하는 다른 객체들을 다시 From 공간에서 찾아 To 공간으로 복사합니다. 이 과정을 To 공간으로 더 이상 복사할 객체가 없을 때까지 반복합니다.
- 모든 살아있는 객체가 To 공간으로 복사되면, From 공간에 남아있는 모든 객체는 쓰레기가 됩니다. 따라서 GC는 From 공간 전체를 단순히 '사용 가능' 상태로 만들어 버립니다.
- 마지막으로 From 공간과 To 공간의 역할을 바꿉니다(flip). 즉, 이제부터는 새로운 To 공간(이전의 From)이 비워지고, 새로운 From 공간(이전의 To)에서 객체 할당이 시작됩니다.
이 방식의 장점은 명확합니다. 첫째, 살아있는 객체만 복사하므로 전체 힙을 스캔할 필요 없이 GC가 빠르게 끝날 수 있습니다(특히 살아있는 객체가 적을 경우). 둘째, 살아있는 객체들이 To 공간으로 차례대로 복사되면서 자연스럽게 메모리 압축이 이루어지므로 단편화가 전혀 발생하지 않습니다. 객체 할당은 단순히 포인터를 앞으로 이동시키는 것만으로 매우 빠르게 처리될 수 있습니다.
+----------------------------------+ +----------------------------------+
| From Space | | To Space |
| [Obj A] [Garbage] [Obj B] [Free] | | [Empty] |
+----------------------------------+ +----------------------------------+
(Copying Live Objects: A and B)
|
V
+----------------------------------+ +----------------------------------+
| [All Garbage] | | [Obj A] [Obj B] [Free] |
+----------------------------------+ +----------------------------------+
(Flip Roles)
하지만 복사 알고리즘의 치명적인 단점은 힙 메모리의 절반을 항상 비워둬야 한다는 점입니다. 이는 메모리 사용 효율성이 매우 낮다는 것을 의미합니다. 또한 객체를 복사하는 작업 자체의 오버헤드도 무시할 수 없습니다. 살아있는 객체의 수가 많고 크기가 클수록 복사 비용은 증가합니다.
2.3. Reference Counting: 참조를 세는 방식
앞의 두 알고리즘과 결이 다른 방식으로 '참조 카운팅(Reference Counting)'이 있습니다. 이 방식은 각 객체에 자신을 참조하는 다른 객체의 수를 저장하는 '카운터(reference count)'를 유지합니다.
- 객체를 참조하는 새로운 변수가 생길 때마다 카운터는 1 증가합니다.
- 객체에 대한 참조가 사라질 때(예: 변수가 범위를 벗어나거나
null이 할당될 때)마다 카운터는 1 감소합니다. - 카운터가 0이 되면, 그 객체는 아무도 참조하지 않는다는 의미이므로 즉시 메모리에서 해제됩니다.
참조 카운팅의 가장 큰 장점은 GC를 위한 별도의 'Stop-the-World'가 거의 없다는 점입니다. 객체가 쓰레기가 되는 시점에 즉시 메모리가 해제되므로, GC로 인한 긴 멈춤 현상이 발생하지 않아 실시간성이 중요한 시스템에 유리할 수 있습니다. 하지만 이 방식 역시 심각한 단점을 가지고 있습니다.
- 순환 참조 문제: 두 개 이상의 객체가 서로를 참조하는 경우, 외부에서 이 객체 그룹을 참조하는 변수가 없어져도 각 객체의 참조 카운트는 0이 되지 않습니다. 이로 인해 이 객체들은 영원히 메모리에서 해제되지 않는 메모리 누수를 발생시킵니다.
- 오버헤드: 참조가 추가되거나 제거될 때마다 카운터를 증가시키고 감소시키는 연산이 필요합니다. 이는 빈번하게 발생할 경우 상당한 성능 저하를 유발할 수 있습니다.
이러한 단점 때문에 Java나 .NET과 같은 주류 플랫폼에서는 참조 카운팅을 기본 GC 방식으로 사용하지 않지만, 일부 시스템(예: Python의 CPython 구현)에서는 다른 알고리즘과 혼합하여 사용하기도 합니다.
3. 현대 GC의 핵심: 세대 가설(Generational Hypothesis)
고전적인 알고리즘들은 각자의 장단점이 뚜렷하여 어떤 상황에서는 효율적이지만 다른 상황에서는 비효율적인 모습을 보였습니다. 현대의 GC는 이러한 알고리즘들을 맹목적으로 사용하기보다, 애플리케이션의 메모리 사용 패턴에 대한 통계적 분석에서 얻은 통찰을 기반으로 이들을 조합하고 최적화합니다. 그 중심에는 '세대 가설(Generational Hypothesis)'이라는 두 가지 강력한 가정이 자리 잡고 있습니다.
- 약한 세대 가설 (Weak Generational Hypothesis): 대부분의 객체는 생성된 직후에 쓰레기가 된다. 즉, 객체의 수명은 매우 짧다.
- 강한 세대 가설 (Strong Generational Hypothesis): 오래 살아남은 객체에서 젊은 객체(최근에 생성된 객체)로의 참조는 거의 존재하지 않는다.
이 가설은 경험적으로 대부분의 소프트웨어에서 사실로 증명되었습니다. 예를 들어, 메서드 내에서 생성된 지역 변수는 메서드가 종료됨과 동시에 쓰레기가 되고, 웹 요청을 처리하기 위해 생성된 수많은 임시 객체들도 요청 처리가 끝나면 바로 버려집니다. 반면, 애플리케이션 전역에서 사용되는 설정 객체나 캐시 데이터는 프로그램이 실행되는 내내 살아남는 경향이 있습니다.
이 가설에 착안하여, 세대형 GC는 힙 메모리를 물리적으로는 아니지만 논리적으로 여러 '세대(Generation)'로 나눕니다. 가장 대표적인 구조는 'Young Generation'과 'Old Generation'입니다.
- Young Generation (젊은 세대): 새롭게 생성된 객체들이 할당되는 공간입니다. 대부분의 객체가 금방 쓰레기가 될 것이므로, 이 영역에서는 작고 빈번한 GC(Minor GC)가 발생합니다.
- Old Generation (늙은 세대): Young Generation에서 여러 번의 GC를 거치고도 살아남은 객체들이 이동(promotion)되는 공간입니다. 이 영역의 객체들은 수명이 길다고 간주되므로, GC가 덜 빈번하게 발생하지만 한 번 발생하면 더 오래 걸립니다(Major GC 또는 Full GC).
Young Generation은 보통 세 개의 영역으로 다시 나뉩니다.
- Eden: 새로 생성된 객체가 처음 할당되는 곳입니다.
- Survivor 0 (S0) / Survivor 1 (S1): Eden 영역에서 GC가 발생했을 때 살아남은 객체들이 이동하는 공간입니다. 이 두 공간은 번갈아 가며 사용됩니다.
세대형 GC의 동작 과정은 다음과 같습니다.
- 새로운 객체는 Eden 영역에 할당됩니다.
- Eden 영역이 가득 차면 Minor GC가 발생합니다.
- GC는 Eden과 현재 활성화된 Survivor 영역(예: S0)에서 살아있는 객체를 찾아 비활성화된 Survivor 영역(예: S1)으로 복사합니다. 이 과정에서 각 객체의 '나이(age)'가 1 증가합니다.
- 살아남은 객체 복사가 끝나면 Eden과 S0 영역은 완전히 비워집니다.
- 다음 Minor GC에서는 Eden과 S1에서 살아남은 객체들을 S0으로 복사합니다. 이처럼 두 Survivor 영역은 복사 알고리즘의 'From/To' 공간처럼 작동합니다.
- 이 과정을 반복하다가 객체의 나이가 특정 임계값(age threshold)에 도달하면, 그 객체는 Old Generation으로 '승격(promotion)'됩니다.
- 시간이 흘러 Old Generation 영역까지 가득 차게 되면, 전체 힙을 대상으로 하는 Major GC(Full GC)가 발생합니다.
+---------------------------------------------------------------------------------------+ | Java Heap | | +-----------------------------------------+ +---------------------------------------+ | | | Young Generation | | Old Generation | | | | +-----------+ +-----------+ +-----------+ | | | | | | | Eden | | S0 | | S1 | | | [Long-lived Objects] | | | | +-----------+ +-----------+ +-----------+ | | | | | +-----------------------------------------+ +---------------------------------------+ | | | (Allocation) ^ | | | | (Promotion) | | +---- New Object +-----------------+ | | | | | Minor GC: Eden+S0 -> S1 (Copying) ---> Objects with high age --+ | | Full GC: Scans entire heap (Mark-Sweep-Compact) | +---------------------------------------------------------------------------------------+
이러한 세대 구조는 매우 효율적입니다. 대부분의 GC 작업이 Young Generation에 국한되므로, 전체 힙을 스캔하는 비용을 크게 줄일 수 있습니다. Young Generation에서는 객체들이 금방 사라지므로 살아남는 객체가 적어 복사(Copying) 알고리즘이 매우 효과적입니다. 반면 Old Generation은 객체들이 빽빽하게 들어차 있고 오랫동안 살아남으므로, 복사보다는 Mark-Sweep(-Compact) 알고리즘이 더 적합합니다. 이처럼 세대형 GC는 각 세대의 특성에 맞는 최적의 알고리즘을 선택적으로 적용함으로써 전체 GC 효율을 극대화합니다.
다만, '강한 세대 가설'을 지키기 위한 추가적인 장치가 필요합니다. 즉, Old Generation의 객체가 Young Generation의 객체를 참조하는 경우를 효율적으로 찾아내야 합니다. 전체 Old Generation을 스캔하는 것은 비효율적이므로, 대부분의 JVM은 '카드 테이블(Card Table)'이라는 자료 구조를 사용합니다. Old Generation의 객체에 대한 참조 쓰기 작업이 발생할 때, 해당 객체가 위치한 메모리 영역을 카드 테이블에 '더티(dirty)' 상태로 표시해 둡니다. Minor GC 시에는 전체 Old Generation을 스캔하는 대신 이 카드 테이블만 확인하여 더티로 표시된 영역의 객체들만 스캔함으로써 GC 루트에 추가합니다. 이는 세대 간 참조를 추적하는 비용을 획기적으로 줄여주는 핵심 기술입니다.
4. JVM GC의 진화: 멈춤 시간과의 전쟁
자바 가상 머신(JVM)은 가비지 컬렉션 기술의 발전을 이끌어온 가장 중요한 플랫폼 중 하나입니다. 초기의 단순한 GC부터 오늘날의 초저지연(ultra-low-latency) GC에 이르기까지, JVM GC의 역사는 'Stop-the-World'로 인한 애플리케이션 멈춤 시간을 줄이기 위한 끊임없는 투쟁의 역사라 할 수 있습니다.
4.1. 초기의 GC들: Serial GC와 Parallel GC
- Serial GC (
-XX:+UseSerialGC): 가장 단순한 형태의 GC로, 이름처럼 모든 GC 작업을 단일 스레드로 처리합니다. Young Generation에서는 Copying을, Old Generation에서는 Mark-Sweep-Compact를 사용합니다. CPU 코어가 하나뿐이던 시절에 적합했으며, 매우 단순하여 오버헤드가 적기 때문에 클라이언트 애플리케이션이나 아주 작은 힙을 사용하는 경우에 여전히 사용될 수 있습니다. 하지만 어떤 작업이든 'Stop-the-World'를 유발하고 단일 스레드로 처리하므로 현대적인 서버 환경에는 부적합합니다. - Parallel GC (
-XX:+UseParallelGC): 'Throughput Collector'라고도 불립니다. Serial GC와 기본 알고리즘은 동일하지만, Minor GC와 Major GC를 여러 개의 스레드를 사용해 병렬로 처리합니다. 이를 통해 GC 작업 시간을 단축시킬 수 있습니다. CPU 코어가 여러 개인 환경에서 애플리케이션의 전체 처리량(throughput)을 높이는 데 초점을 맞춥니다. GC로 인한 멈춤 시간 자체보다는, 단위 시간당 처리할 수 있는 작업의 양이 더 중요하고, 긴 멈춤을 어느 정도 감수할 수 있는 배치(batch) 작업 등에 유리합니다. Java 8까지 기본 GC였습니다.
4.2. 동시성(Concurrency)의 도입: CMS GC
Parallel GC가 처리량을 높이는 데 성공했지만, 수백 밀리초에서 수 초에 달하는 긴 'Stop-the-World'는 웹 서버와 같이 사용자 응답성이 중요한 애플리케이션에는 여전히 부담이었습니다. 이 문제를 해결하기 위해 등장한 것이 CMS(Concurrent Mark Sweep) GC (-XX:+UseConcMarkSweepGC)입니다.
CMS의 핵심 아이디어는 시간이 오래 걸리는 Mark와 Sweep 작업을 애플리케이션 스레드가 실행되는 동안에 '동시에(concurrently)' 수행하는 것입니다. 이로써 'Stop-the-World' 시간을 획기적으로 줄이고자 했습니다. CMS GC는 다음과 같은 복잡한 단계를 거칩니다.
- Initial Mark: 루트 세트가 직접 참조하는 객체들만 잠시 멈추고(Stop-the-World) 빠르게 스캔합니다. 멈춤 시간이 매우 짧습니다.
- Concurrent Mark: 애플리케이션 스레드와 동시에, Initial Mark에서 찾은 객체들로부터 시작하여 전체 참조 그래프를 따라가며 도달 가능한 객체를 표시합니다. 이 단계에서 애플리케이션이 계속 실행되면서 객체 참조 관계가 바뀔 수 있습니다.
- Remark: Concurrent Mark 단계에서 변경된 참조 관계를 다시 확인하기 위해 잠시 멈추고(Stop-the-World) 스캔합니다.
- Concurrent Sweep: 애플리케이션 스레드와 동시에, 표시되지 않은 쓰레기 객체들을 찾아 메모리를 해제합니다.
CMS는 'Stop-the-World' 시간을 수십 밀리초 수준으로 줄이는 데 성공했지만, 몇 가지 태생적인 문제점을 안고 있었습니다. 첫째, 메모리 압축(Compaction)을 기본적으로 수행하지 않아 장시간 운영 시 Mark-Sweep의 고질적인 단편화 문제에 직면했습니다. 둘째, Concurrent Mark 도중 애플리케이션이 계속 새 객체를 할당하여 Old Generation이 가득 차버리면 'Concurrent Mode Failure'가 발생하며, 이때는 전체 시스템을 멈추는 Full GC로 전환되어 오히려 더 긴 멈춤을 유발했습니다. 이러한 복잡성과 불안정성 때문에 CMS는 Java 9부터 deprecated 되었고 Java 14에서 완전히 제거되었습니다.
4.3. 현대적인 표준: G1 GC (Garbage-First)
CMS의 단점을 극복하고 처리량과 응답성을 모두 만족시키기 위해 개발된 것이 G1 GC (-XX:+UseG1GC)입니다. Java 9부터 기본 GC로 채택된 G1은 기존의 연속적인 Young/Old Generation 구조에서 벗어나, 전체 힙을 여러 개의 동일한 크기 '리전(Region)'으로 분할하여 관리하는 혁신적인 방식을 도입했습니다.
각 리전은 동적으로 Eden, Survivor, 또는 Old 역할을 맡을 수 있습니다. G1은 'Garbage-First'라는 이름처럼, 쓰레기가 가장 많이 쌓여있는 리전(가장 효율적으로 메모리를 회수할 수 있는 곳)을 우선적으로 수집합니다. 이를 통해 G1은 사용자가 설정한 목표 멈춤 시간(-XX:MaxGCPauseMillis)을 최대한 준수하려고 노력합니다. G1의 GC 사이클은 다음과 같이 구성됩니다.
- Young-only Phase: 초반에는 기존의 세대형 GC처럼 Young Generation 리전들을 대상으로 Minor GC(여기서는 'Evacuation Pause'라고 부름)를 수행합니다. 살아남은 객체는 Survivor 리전이나 Old 리전으로 이동합니다.
- Space-reclamation Phase: Old Generation의 점유율이 특정 임계값(Initiating Heap Occupancy Percent)을 넘어서면, Young GC와 함께 Old Generation 리전의 일부를 수집하는 'Mixed GC'를 시작합니다. 이 단계는 CMS와 유사하게 동시 마킹(Concurrent Marking) 주기를 포함하며, 마킹이 완료된 후 쓰레기가 많은 Old 리전들을 선택하여 Young 리전과 함께 정리합니다.
G1은 리전 단위로 GC를 수행하고 필요할 때마다 압축을 진행하므로 CMS의 단편화 문제가 없습니다. 또한 예측 가능한 멈춤 시간을 제공하여 대부분의 현대적인 애플리케이션에 적합한 균형 잡힌 성능을 보여줍니다.
4.4. 초저지연 시대를 열다: ZGC와 Shenandoah
G1이 멈춤 시간을 수십~수백 밀리초로 줄이는 데 성공했지만, 핀테크, 실시간 게임, 대규모 마이크로서비스 환경에서는 이마저도 길게 느껴질 수 있습니다. 이러한 요구에 부응하기 위해 등장한 것이 ZGC와 Shenandoah입니다. 이 둘의 목표는 힙 크기에 상관없이 'Stop-the-World'를 10밀리초 미만, 심지어 1밀리초 수준으로 유지하는 것입니다.
- ZGC (Z Garbage Collector,
-XX:+UseZGC): ZGC는 마킹, 객체 이동(재배치), 참조 업데이트 등 시간이 오래 걸리는 거의 모든 작업을 애플리케이션 스레드와 동시에(concurrently) 수행합니다. 이를 가능하게 하는 핵심 기술은 '컬러링 포인터(Coloring Pointers)'와 '로드 배리어(Load Barriers)'입니다. 객체 포인터의 일부 비트를 메타데이터(객체의 상태 등) 저장에 사용하여, GC가 객체를 이동시키는 와중에도 애플리케이션 스레드가 객체에 접근하면 로드 배리어가 이를 가로채 올바른 새 주소를 알려줍니다. 이 덕분에 힙 크기가 수 테라바이트에 달하더라도 'Stop-the-World'는 거의 일정하게 유지됩니다. - Shenandoah GC (
-XX:+UseShenandoahGC): ZGC와 유사한 목표를 가지지만 구현 방식이 다릅니다. Shenandoah는 '브룩스 포인터(Brooks Pointers)'라는 전달 포인터(forwarding pointer)를 사용하여 동시 객체 복사를 구현합니다. 로드 배리어와 함께 작동하여, GC가 객체를 복사하는 중에도 애플리케이션은 이전 주소를 통해 안전하게 새 주소로 접근할 수 있습니다.
이러한 최신 GC들은 애플리케이션 처리량에서 약간의 손해를 감수하는 대신, 극도로 짧고 예측 가능한 멈춤 시간을 제공함으로써 대용량 메모리를 사용하는 차세대 애플리케이션의 문을 활짝 열고 있습니다.
5. GC와 프로그래머: 보이지 않는 관리자와의 협업
가비지 컬렉션은 개발자를 메모리 관리의 고통에서 해방시켰지만, 이것이 메모리를 완전히 잊고 살아도 된다는 의미는 아닙니다. 오히려 GC의 작동 원리를 이해하고 그에 맞게 코드를 작성하는 것이 애플리케이션의 성능을 극대화하는 열쇠가 됩니다. 때로는 GC를 돕는 코드가, 때로는 GC를 방해하는 코드가 될 수 있습니다.
5.1. 객체 생성과 소멸에 대한 새로운 관점
GC 환경에서 객체 생성(new)은 비교적 저렴한 작업입니다. 특히 세대형 GC의 Eden 영역에서의 할당은 포인터를 증가시키는 것만으로 끝나므로 매우 빠릅니다. 문제는 불필요한 객체를 너무 많이, 그리고 너무 오랫동안 살아남게 만드는 것입니다.
- 짧은 생명주기의 중요성: 메서드 안에서 생성되어 그 안에서만 사용되고 버려지는 객체들은 Young GC에서 매우 효율적으로 처리됩니다. 반복문 안에서 불필요하게 객체를 생성하여 컬렉션에 계속 담아두는 행위는 객체의 생명주기를 늘려 Old Generation으로 승격시킬 수 있으며, 이는 결국 더 비용이 비싼 Full GC를 유발하는 원인이 됩니다.
- 불변 객체(Immutable Objects):
String이나 Java의 레코드(Record)와 같은 불변 객체는 상태를 변경할 수 없으므로 스레드 동기화 문제에서 자유롭고 코드를 이해하기 쉽게 만듭니다. GC 관점에서도 일단 생성된 후 내부 상태가 변하지 않으므로 세대 간 참조(Old -> Young)를 추적하기가 더 용이한 측면이 있습니다.
5.2. 객체 풀링(Object Pooling)의 함정
과거 GC의 성능이 좋지 않던 시절에는, 비용이 비싼 객체(예: 데이터베이스 커넥션, 스레드)의 생성을 피하기 위해 미리 여러 개를 만들어두고 재사용하는 '객체 풀링' 기법이 널리 사용되었습니다. 이는 GC 부담을 줄이는 효과적인 방법이었습니다.
하지만 현대의 고성능 GC 환경에서는 단순 객체에 대한 풀링이 오히려 성능을 저하시키는 안티패턴이 될 수 있습니다. 풀에 보관된 객체들은 사용되지 않는 동안에도 계속 살아남아 Old Generation으로 이동하게 됩니다. 이는 Old Generation의 공간을 불필요하게 차지하고 Full GC의 빈도를 높이는 원인이 될 수 있습니다. 대부분의 단기 생존 객체는 그냥 필요할 때 생성하고 GC가 처리하도록 맡기는 것이 훨씬 효율적입니다. 객체 풀링은 여전히 DB 커넥션처럼 생성 비용이 매우 비싸고 외부 자원과 연관된 경우에만 신중하게 사용되어야 합니다.
5.3. 약한 참조(Weak References)와 캐시 구현
때로는 객체를 참조하고는 싶지만, 그 참조 때문에 객체가 GC 대상에서 제외되는 것은 원치 않을 때가 있습니다. 대표적인 예가 캐시(Cache)입니다. 캐시에 보관된 데이터는 유용하지만, 메모리가 부족할 때는 언제든지 사라져도 괜찮아야 합니다. 이러한 요구사항을 위해 자바는 여러 종류의 참조를 제공합니다.
- Strong Reference (강한 참조): 우리가 일반적으로 사용하는
Object obj = new Object();와 같은 참조입니다. 이 참조가 하나라도 존재하는 한 객체는 GC 대상이 되지 않습니다. - Soft Reference (소프트 참조): 메모리가 부족할 때 GC가 회수해 갈 수 있는 참조입니다. 메모리에 민감한 캐시를 구현하는 데 유용합니다.
- Weak Reference (약한 참조): GC가 발생하면 강한 참조가 없는 한 즉시 회수되는 참조입니다.
WeakHashMap은 이를 이용하여 키가 더 이상 사용되지 않으면 맵에서 해당 엔트리를 자동으로 제거하는 캐시를 구현합니다. - Phantom Reference (팬텀 참조): 객체가 완전히 메모리에서 해제되기 직전에 마지막으로 무언가를 처리할 기회를 줍니다. 주로
finalize()메서드를 대체하여 네이티브 리소스 정리 등 복잡한 해제 후 작업을 수행하는 데 사용됩니다.
이러한 참조 유형들을 이해하고 활용하면 GC와 더욱 긴밀하게 협력하여 유연하고 효율적인 메모리 관리 정책을 구현할 수 있습니다.
결론: 보이지 않는 관리자와의 동행
가비지 컬렉션은 Mark-and-Sweep이라는 단순한 아이디어에서 출발하여, 세대 가설을 통해 효율성을 극대화하고, CMS와 G1을 거쳐 ZGC에 이르기까지 애플리케이션의 멈춤 시간을 최소화하는 방향으로 끊임없이 진화해왔습니다. 이 여정은 단순히 쓰레기를 치우는 기술의 발전을 넘어, 개발자가 더 높은 수준의 추상화에 집중하고 더 안정적인 소프트웨어를 만들 수 있도록 지원하는 기반 기술의 역사였습니다.
현대의 개발자에게 GC는 더 이상 블랙박스가 아닙니다. 우리가 작성하는 코드 한 줄 한 줄이 메모리 위에서 객체의 생명주기에 어떤 영향을 미치는지, 그리고 이것이 GC의 동작에 어떻게 반영되는지를 이해하는 것은 고성능 애플리케이션을 개발하기 위한 필수적인 소양입니다. GC 튜닝은 단순히 JVM 옵션을 조정하는 행위를 넘어, 내 애플리케이션의 메모리 사용 패턴을 깊이 있게 이해하고 분석하는 과정입니다. 어떤 객체가 주로 생성되고, 얼마나 오래 살아남으며, 세대 간 이동은 어떻게 일어나는지를 파악할 때 비로소 최적의 GC 전략을 선택하고 애플리케이션의 잠재력을 최대한 이끌어낼 수 있습니다.
가비지 컬렉션은 보이지 않는 곳에서 묵묵히 자신의 일을 수행하는 관리자와 같습니다. 우리는 그 존재를 잊고 지낼 때가 많지만, 시스템의 안정성과 성능은 이 보이지 않는 관리자의 손에 달려있습니다. 그와의 성공적인 동행은 그의 작동 원리를 존중하고, 그의 부담을 덜어주는 방식으로 우리의 코드를 설계하는 것에서부터 시작될 것입니다.
0 개의 댓글:
Post a Comment