🧹 가비지 컬렉터(Garbage Collector)란? 자바의 자동 메모리 관리, 그 깊이를 파헤치다
자바 개발자라면 한 번쯤은 들어봤을 법한 가비지 컬렉터(Garbage Collector, GC). C나 C++처럼 개발자가 직접 메모리를 할당하고 해제하는 번거로움 없이, 자바는 GC를 통해 자동으로 사용하지 않는 객체를 찾아 메모리에서 해제합니다. 이 편리함 뒤에는 어떤 원리가 숨어 있을까요? 이번 포스팅에서는 자바의 자동 메모리 관리 시스템인 GC의 기본 개념부터 동작 원리, 최적화 전략까지 심층적으로 탐구하며, 고성능 자바 애플리케이션 개발의 비밀을 파헤쳐 봅니다.
1. 가비지 컬렉션의 기본 개념: 왜 자동으로 메모리를 관리할까?
**가비지 컬렉터(GC)**는 **자바 가상 머신(JVM)**의 핵심 기능 중 하나로, 더 이상 프로그램에서 도달할 수 없는(unreachable) 객체, 즉 사용하지 않는 객체를 자동으로 탐지하고 메모리에서 해제하는 역할을 수행합니다.
C나 C++과 같은 언어에서는 개발자가 malloc()으로 메모리를 할당하고 free()로 명시적으로 해제해야 합니다. 만약 free()를 잊으면 **메모리 누수(Memory Leak)**가 발생하여 프로그램이 점점 느려지거나 결국 멈추는 현상이 발생할 수 있습니다. 자바는 이러한 수동 메모리 관리의 위험과 복잡성을 제거하고, 개발자가 비즈니스 로직에 더 집중할 수 있도록 GC라는 자동화된 시스템을 도입했습니다.
🔸 GC의 주요 목적
- 메모리 누수(Memory Leak) 방지: 개발자의 실수로 메모리 해제를 잊어버리는 경우를 원천적으로 방지합니다. 물론, GC가 있음에도 메모리 누수가 발생하는 경우가 있는데, 이는 뒤에서 자세히 다룰 것입니다.
- 메모리 효율 극대화: 사용되지 않는 메모리 공간을 주기적으로 회수하여 다른 객체들이 효율적으로 사용할 수 있도록 합니다. 이는 한정된 자원인 메모리를 최적화하는 데 기여합니다.
- 개발 생산성 향상: 개발자가 메모리 관리에 대한 부담을 덜고 핵심 비즈니스 로직 구현에 집중할 수 있게 하여 전체적인 개발 속도와 효율성을 높입니다.
2. GC는 언제 발생하는가? '필요'가 '시작'을 알릴 때
GC는 항상 백그라운드에서 실행되는 것이 아니라, 특정 조건이 충족될 때 JVM에 의해 자동으로 **트리거(trigger)**됩니다. GC의 대상이 되는 객체는 더 이상 어떤 변수도 해당 객체를 참조하고 있지 않을 때, 즉 '도달 불가능한(unreachable)' 상태가 되었을 때입니다.
🔸 GC가 자동으로 트리거되는 시점
- 힙 메모리가 부족할 때: 새로운 객체를 할당해야 하는데, 현재 힙 메모리에 여유 공간이 부족할 경우 JVM은 GC를 실행하여 공간을 확보하려 합니다.
- 명시적 호출: System.gc() (권장하지 않음): 개발자가 System.gc()를 호출하여 GC 실행을 '요청'할 수 있습니다. 하지만 이는 강제적인 실행을 보장하지 않습니다. JVM은 이 요청을 참고만 할 뿐, 실제 GC 실행 여부와 시점은 JVM의 내부 정책에 따라 결정됩니다. 오히려 성능 저하를 유발할 수 있어 운영 환경에서는 거의 사용하지 않습니다.
- 메모리 압박 감지 시: JVM은 자체적인 모니터링을 통해 메모리 사용량이 특정 임계값을 초과하거나, 메모리 할당 속도가 급증하는 등 메모리 압박이 심해진다고 판단될 때 GC를 능동적으로 트리거할 수 있습니다.
3. GC의 동작 원리: 죽은 객체를 찾아내는 기술
GC는 다양한 알고리즘을 사용하여 메모리를 관리하지만, 그 기반이 되는 핵심 원리는 몇 가지로 요약할 수 있습니다.
🔸 (1) Mark and Sweep 알고리즘: GC의 고전적인 방식
거의 모든 GC 알고리즘의 기본이 되는 방식입니다. 이름 그대로 '표시하고(Mark)' '제거하는(Sweep)' 두 단계로 이루어집니다.
- Mark 단계: GC는 **루트 객체(Root Objects)**에서부터 시작하여 객체 그래프를 탐색합니다. 루트 객체는 프로그램의 시작점이라고 할 수 있으며, 주로 다음과 같은 것들이 포함됩니다:
- 스택(Stack) 영역의 변수: 현재 실행 중인 메서드의 지역 변수 등
- 메서드 영역(Method Area)의 Static 변수: 프로그램 시작부터 끝까지 유지되는 클래스 변수
- JNI(Java Native Interface)를 통해 참조되는 객체: 네이티브 코드에서 참조하는 자바 객체
- 활성화된 스레드(Thread): 스레드가 사용 중인 객체 GC는 이 루트 객체들로부터 시작하여, 참조하는 모든 객체를 재귀적으로 따라가며 '도달 가능한 객체'로 표시(Mark)합니다. 이 과정에서 표시되지 않은 객체는 '도달 불가능한 객체'로 간주됩니다.
- Sweep 단계: Mark 단계에서 '표시되지 않은' 모든 객체들, 즉 더 이상 사용되지 않는 객체들을 힙 메모리에서 실제로 제거(Sweep)합니다. 이 과정에서 확보된 메모리 공간은 새로운 객체 할당에 사용될 수 있습니다.
🔸 (2) Generational Garbage Collection (세대별 GC): 객체의 수명 주기를 활용한 최적화
Mark and Sweep 알고리즘은 모든 객체를 탐색해야 하므로 힙 크기가 커지면 성능 저하를 일으킬 수 있습니다. 자바의 객체들은 대부분 수명이 짧다는 통계적 특성(약 90% 이상의 객체가 생성 후 금방 사라짐)을 기반으로, JVM은 세대별 GC 방식을 도입하여 효율성을 극대화합니다. 힙 메모리를 여러 세대(Generation)로 나누어 관리합니다.
- 영역 설명:
- Young Generation (Eden, Survivor 0, Survivor 1): 새로 생성된 대부분의 객체가 처음 저장되는 공간입니다. 일반적으로 매우 작고, GC가 자주 발생하지만 빠르게 완료됩니다 (Minor GC 또는 Young GC). 대부분의 객체는 이곳에서 생성되고 수명이 다해 소멸됩니다.
- Eden 영역: 새로운 객체가 할당되는 최초의 공간입니다.
- Survivor 0/1 영역: Eden 영역에서 살아남은 객체들이 잠시 머무는 공간입니다. GC가 발생할 때마다 두 Survivor 영역 중 하나는 비워지고, 살아남은 객체는 다른 Survivor 영역으로 이동하며 수명이 증가합니다.
- Old (Tenured) Generation: Young 영역에서 여러 번의 Young GC를 거치면서도 살아남은 (오랫동안 사용될 것으로 예상되는) 객체들이 옮겨지는 공간입니다 (이동 과정을 Promotion 또는 Tenuring이라고 합니다). Old Generation에 대한 GC는 Full GC (Major GC)라고 불리며, 발생 빈도는 낮지만 한 번 발생하면 시간이 오래 걸릴 수 있습니다.
- Permanent Generation (Java 8부터 Metaspace로 변경): 클래스 메타데이터, 메서드 정보, static 변수 등 프로그램의 구조적인 정보가 저장되던 영역입니다. Java 8부터는 이 영역이 Metaspace로 대체되었으며, Metaspace는 OS의 native memory를 사용하고 동적으로 확장 가능하여 PermGen의 메모리 부족 문제를 해결했습니다.
- Young Generation (Eden, Survivor 0, Survivor 1): 새로 생성된 대부분의 객체가 처음 저장되는 공간입니다. 일반적으로 매우 작고, GC가 자주 발생하지만 빠르게 완료됩니다 (Minor GC 또는 Young GC). 대부분의 객체는 이곳에서 생성되고 수명이 다해 소멸됩니다.
4. GC의 종류 (JVM GC 알고리즘): 선택과 집중의 미학
JVM은 다양한 GC 알고리즘을 제공하며, 각 알고리즘은 특정 워크로드와 성능 목표에 최적화되어 있습니다. 애플리케이션의 특성(처리량 vs. 응답 시간)에 따라 적절한 GC를 선택하는 것이 중요합니다.
| Serial GC | 단일 스레드로 모든 GC 작업을 수행합니다. 가장 간단하지만, GC 중 모든 애플리케이션 스레드를 멈춥니다 (Stop-the-world). | 메모리가 작고, CPU 코어가 적은 소규모 애플리케이션 또는 클라이언트 환경 |
| Parallel GC (Throughput Collector) | 멀티스레드를 사용하여 Young 및 Old Generation의 GC를 병렬로 수행합니다. GC 작업에 더 많은 CPU를 사용하여 애플리케이션의 **처리량(throughput)**을 극대화하는 데 중점을 둡니다. | 배치 처리 시스템, 대규모 데이터 처리 등 응답 속도보다 전체 처리량이 중요한 일반적인 서버 애플리케이션 |
| CMS (Concurrent Mark Sweep) GC | 애플리케이션 스레드와 GC 스레드가 동시에(concurrently) Mark 단계를 수행하여 Stop-the-world(STW) 시간을 최소화합니다. 그러나 컴팩션(메모리 조각 모음)을 수행하지 않아 **단편화(fragmentation)**가 발생할 수 있습니다. | 웹 서버, 실시간 응답이 중요한 서비스 (Java 9부터 deprecated 됨) |
| G1 GC (Garbage First GC) | Java 9부터 기본 GC로 채택되었습니다. 힙을 Region이라는 작은 단위로 나누어 관리하며, 가장 효율적인 Region부터 GC를 수행합니다. 예측 가능한 짧은 Pause Time을 목표로 하며, CMS의 단편화 문제를 해결합니다. | 대규모 힙(4GB 이상)을 사용하는 애플리케이션, 짧고 예측 가능한 GC Pause가 필요한 경우 |
| ZGC / Shenandoah | Java 11+에서 도입된 초저지연(Ultra-low Latency) GC입니다. 매우 짧고 예측 가능한 STW 시간을 제공하며, 수십 기가바이트에서 테라바이트에 이르는 거대한 힙에서도 거의 일정한 Pause Time을 유지합니다. | 금융 거래 시스템, 게임 서버 등 레이턴시에 극도로 민감한 시스템 |
5. Stop-the-world (STW)란? GC의 양면성
**Stop-the-world (STW)**는 GC가 수행될 때 JVM이 애플리케이션의 모든 스레드를 일시적으로 멈추는 현상을 의미합니다. 이 시간 동안 애플리케이션은 아무런 작업을 수행할 수 없으며, 사용자 입장에서는 프로그램이 잠시 멈추거나 '버벅거리는' 것처럼 느껴질 수 있습니다.
모든 GC 알고리즘은 어떤 형태로든 STW를 수반합니다. 하지만 알고리즘의 발전과 함께, STW의 발생 빈도와 지속 시간은 크게 줄어들고 예측 가능하게 변화했습니다. 특히 실시간성, 즉각적인 응답이 중요한 프로그램(예: 게임 서버, 금융 시스템)에서는 짧고 예측 가능한 STW가 GC 선택의 핵심 기준이 됩니다. G1, ZGC, Shenandoah 같은 최신 GC들은 이 STW를 최소화하는 데 초점을 맞춥니다.
6. 메모리 누수와 GC의 한계: 만능은 아니다
가비지 컬렉터가 있다고 해서 메모리 누수가 전혀 발생하지 않는 것은 아닙니다. GC의 역할은 '더 이상 참조되지 않는' 객체를 수거하는 것인데, 때로는 객체가 논리적으로는 더 이상 사용되지 않지만, 코드 상에서는 여전히 어떤 참조가 남아 있어 GC가 이를 '도달 가능한 객체'로 착각하여 수거하지 못하는 경우가 발생합니다. 이것이 바로 GC가 있어도 발생하는 메모리 누수입니다.
❗ 메모리 누수의 대표적인 예시
import java.util.ArrayList;
import java.util.List;
public class MemoryLeakExample {
private static List<Object> problematicList = new ArrayList<>();
public static void main(String[] args) {
// 이 루프는 영원히 돌면서 새로운 객체를 problematicList에 추가합니다.
// problematicList는 static 변수이므로 프로그램 종료 시까지 참조가 유지됩니다.
while (true) {
problematicList.add(new Object()); // 객체가 계속 참조되므로 GC가 수거하지 못함 → 누수 발생
// 만약 여기서 list.remove(0); 와 같은 로직이 없다면,
// list의 크기는 무한히 커지고 메모리 사용량도 계속 증가합니다.
}
}
}
위 예시에서는 problematicList가 static으로 선언되어 JVM 종료 시까지 메모리에 유지됩니다. while(true) 루프 내에서 계속해서 새로운 Object 인스턴스를 추가하면, 이 객체들은 problematicList에 의해 참조되고 있으므로 GC 대상이 되지 않습니다. 결과적으로 메모리 사용량이 계속 증가하여 **OutOfMemoryError**가 발생할 수 있습니다.
❗ 또 다른 메모리 누수 원인
- 오래된 리스너/콜백: 이벤트 리스너나 콜백 함수를 등록했지만, 사용 후 명시적으로 해제하지 않아 객체가 계속 참조되는 경우
- 잘못된 캐시 관리: 한 번 캐시에 추가된 객체가 더 이상 필요 없지만, 캐시에서 제거되지 않아 계속 메모리에 남아 있는 경우
- 내부 클래스(Inner Class)의 외부 클래스 참조: 내부 클래스가 외부 클래스 인스턴스를 암묵적으로 참조하여, 외부 클래스 인스턴스가 예상보다 오래 메모리에 유지되는 경우
7. 개발자가 할 수 있는 GC 최적화 전략: GC를 돕는 현명한 개발자
GC는 자동화되어 있지만, 개발자의 코드 작성 방식이 GC의 효율성에 큰 영향을 미칩니다. GC의 부담을 줄이고 애플리케이션 성능을 최적화하기 위한 전략들은 다음과 같습니다.
(1) 객체 생명주기 줄이기: 불필요한 객체는 빨리 놓아주세요
- 지역 변수 활용: 가능한 한 변수의 스코프를 좁게(지역 변수로) 가져가 객체가 빨리 도달 불가능한 상태가 되도록 합니다.
- 불필요한 static 객체 지양: static 변수는 프로그램 생명주기 내내 메모리에 남아 있으므로, 꼭 필요한 경우가 아니라면 사용을 최소화해야 합니다.
- null 할당 (선택적): 객체가 더 이상 필요 없다는 것을 명확히 하기 위해 null을 할당하는 것이 도움이 될 수 있습니다. (그러나 대부분의 경우 GC가 알아서 처리하므로 과도한 null 할당은 코드 가독성을 해칠 수 있습니다.)
(2) 캐시 관리 주의: 캐시는 양날의 검
- 캐시는 성능 향상에 매우 유용하지만, 크기와 수명 주기를 적절히 관리하지 않으면 메모리 누수의 주요 원인이 됩니다.
- **만료 정책(Eviction Policy)**을 가진 캐시 라이브러리(예: Caffeine, Ehcache)를 사용하거나, **약한 참조(WeakReference)**를 활용하여 객체가 다른 곳에서 참조되지 않을 때 GC가 수거할 수 있도록 설계합니다.
(3) 컬렉션 초기 용량 지정: 낭비를 줄이는 습관
- ArrayList, HashMap 등 동적으로 크기가 조절되는 컬렉션은 내부적으로 배열을 사용하여 데이터를 저장합니다. 이 배열의 크기가 부족할 경우, 더 큰 새 배열을 생성하고 기존 요소를 복사하는 오버헤드가 발생합니다.
- 예상되는 요소의 개수를 알고 있다면, 초기 용량(initial capacity)을 지정하여 불필요한 배열 재할당과 복사 작업을 줄이고 메모리 사용을 최적화할 수 있습니다.
Java
// 초기 용량을 지정하지 않으면 기본 10 -> 필요에 따라 계속 확장 List<String> list = new ArrayList<>(); // 100개의 요소를 저장할 것임을 미리 알고 있다면 List<String> optimizedList = new ArrayList<>(100);
(4) GC 로그 확인: GC의 상태를 파악하는 눈
- JVM 옵션을 통해 GC의 동작을 상세하게 기록하는 GC 로그를 활성화할 수 있습니다.
- 주요 옵션:
- -XX:+PrintGCDetails: GC의 상세 정보를 출력합니다.
- -Xlog:gc: Java 9 이후의 표준 GC 로깅 옵션입니다. GC 활동에 대한 자세한 정보를 제공합니다.
- GC 로그를 분석하면 어떤 GC가 얼마나 자주, 얼마나 오래 실행되는지, STW 시간은 어느 정도인지 등을 파악하여 GC 튜닝의 기초 자료로 활용할 수 있습니다. GCEasy, GCViewer 등 GC 로그 분석 도구를 활용하는 것도 좋습니다.
(5) GC 튜닝: JVM 설정으로 최적화
- 힙 사이즈 조정: -Xms<initial size> (초기 힙 크기), -Xmx<maximum size> (최대 힙 크기) 옵션을 통해 애플리케이션의 메모리 요구사항에 맞게 힙 크기를 조절합니다. 너무 작으면 Full GC가 빈번하고, 너무 크면 STW 시간이 길어질 수 있습니다.
- GC 종류 선택: 앞서 언급된 Serial, Parallel, G1, ZGC 등 다양한 GC 알고리즘 중 애플리케이션의 특성(처리량 vs. 응답 시간)에 가장 적합한 것을 선택합니다. (예: -XX:+UseG1GC)
- Pause Time 목표 설정: G1 GC와 같은 알고리즘은 -XX:MaxGCPauseMillis=<milliseconds> 옵션을 통해 최대 Pause Time 목표를 설정할 수 있어, 예측 가능한 응답 시간을 제공하는 데 도움을 줍니다.
8. 핵심 정리: 가비지 컬렉터 한눈에 보기
| 정의 | 자바의 자동 메모리 관리 시스템으로, JVM이 더 이상 참조되지 않는 객체(가비지)를 자동으로 찾아 메모리에서 해제합니다. 개발자의 수동 메모리 해제 부담을 줄여줍니다. |
| Mark & Sweep | GC의 기본 알고리즘. Mark 단계에서 루트 객체부터 도달 가능한 객체들을 '표시'하고, Sweep 단계에서 표시되지 않은(도달 불가능한) 객체들을 메모리에서 제거합니다. |
| 세대 구분 | 객체의 수명 주기에 따라 힙 메모리를 Young Generation (Eden, Survivor 영역)과 Old Generation으로 나눕니다. 대부분의 객체는 Young Generation에서 생성되어 사라지며, 오래 살아남은 객체는 Old Generation으로 이동합니다. 이는 GC의 효율성을 높입니다. |
| GC 종류 | 애플리케이션의 성능 목표에 따라 다양한 GC 알고리즘을 선택할 수 있습니다.<br>- Serial GC: 단일 스레드, 소규모 앱<br>- Parallel GC: 멀티스레드, 처리량(throughput) 중시<br>- CMS GC: 동시성, 응답 속도 중시 (단편화 발생 가능)<br>- G1 GC: Java 9+ 기본, 예측 가능한 짧은 Pause Time, 대규모 힙<br>- ZGC / Shenandoah: 초저지연, 대규모 힙, 최신 기술 |
| STW (Stop-the-world) | GC 수행 중 JVM이 애플리케이션 스레드를 일시적으로 멈추는 현상. 모든 GC가 STW를 수반하지만, 최신 GC들은 STW 시간을 최소화하고 예측 가능하게 만듭니다. |
| 메모리 누수 | GC가 있어도 발생할 수 있습니다. 객체가 논리적으로는 불필요하지만, 코드 상에서 여전히 유효한 참조가 남아 있어 GC가 이를 수거하지 못할 때 발생합니다. static 컬렉션, 해제되지 않은 리스너, 캐시 등이 원인이 될 수 있습니다. |
| 최적화 전략 | 객체 생명주기 줄이기, 캐시 관리 주의, 컬렉션 초기 용량 지정, GC 로그 확인 및 분석, 적절한 GC 알고리즘 선택 및 힙 사이즈 튜닝 등이 있습니다. 개발자의 현명한 코딩 습관과 JVM 설정이 GC 효율에 큰 영향을 미칩니다. |
🔚 마무리: GC, 이해하고 다루는 자바 개발자의 숙명
가비지 컬렉션은 자바 개발자에게 엄청난 편리함을 제공하지만, 결코 만능은 아닙니다. GC의 내부 동작 원리와 한계를 정확히 이해하는 것은 복잡한 자바 애플리케이션의 성능을 최적화하고 잠재적인 메모리 문제를 진단하고 해결하는 데 필수적인 지식입니다.
'코드의 해부학' 카테고리의 다른 글
| Lazy Loading : "나중에"를 외치는 고효율 전략 (1) | 2025.06.01 |
|---|---|
| 이벤트 위임(Event Delegation) : '분산'을 '집중'으로 전환하는 지능형 패턴 (1) | 2025.06.01 |
| 디바운스(Debounce)와 스로틀(Throttle) : 이벤트 폭주 시대의 지휘자 (0) | 2025.06.01 |
| 메모이제이션(Memoization) : CPU를 위한 다이어트 (0) | 2025.06.01 |
| 클로저: 죽지 않는 변수들의 이야기 (0) | 2025.06.01 |