들어가며: "왜 Spring Batch 테스트가 OOM으로 터졌을까?"
어느 날 Spring Batch 통합 테스트를 돌리다 갑자기 빌드가 java.lang.OutOfMemoryError: Java heap space로 실패했다.
메모리 누수도 없고, 데이터도 적은데 왜 떨어지는 걸까. 며칠간 삽질한 끝에 알게 된 사실은 충격적이었다. Context 캐싱 한계로 Spring TestContext Framework가 새로운 Application Context를 계속 생성했고, GC가 충분히 빠르지 못해 메모리가 쌓여가고 있었던 것이다.
그때 느낀 것이다.
GC를 모르면, Java 개발자는 왜 느린지, 왜 터지는지 원인을 절대 알 수 없다.
이 글에서는 GC의 동작 원리를 실제 운영 관점에서 설명하고, OOM 같은 문제를 대응하는 법을 공유한다.
1부: GC는 "자동 정리"가 아니라 "계획된 중단"이다
문제 상황: 배포 후 초기 응답이 느린 이유
Java 서버를 배포하고 처음 5분간 응답 속도가 평소의 50%도 안 나온다는 불평을 들어본 적이 있는가?
왜 그럴까? JVM이 코드를 컴파일하고 최적화하는 동안 GC를 제대로 하지 못하기 때문이다.
일반적으로 Java 실행 순서는 이렇다:
- 메모리 할당: OS로부터 필요한 메모리를 미리 할당
- 컴파일:
.java→.class(바이트코드) - 로딩: 클래스 로더가 필요할 때 바이트코드를 메모리에 적재
- 실행: 실행 엔진이 바이트코드를 기계어로 변환하며 실행
- 관리: GC가 필요에 따라 메모리를 정리
여기서 중요한 건 4번과 5번이 동시에 일어난다는 것. 초기에는 JIT 컴파일러가 자주 실행되는 코드(약 2,000회 이상)를 기계어로 변환하느라 바쁘고, GC도 그 와중에 돌아야 한다. 결과는 느린 응답.
GC의 핵심: Stop-The-World
GC가 실행될 때 애플리케이션의 모든 스레드가 멈춘다. 이를 Stop-The-World(STW)라고 부른다.
왜 그럴까?
메모리 정리는 단순히 "쓰지 않는 객체만 골라내기"가 아니다. 더 복잡하다:
- Mark 단계: Root Space(Stack의 로컬 변수, 메서드 영역의 Static 변수)에서 시작해 그래프를 순회하며 도달 가능한 모든 객체에 표시
- Sweep 단계: 표시되지 않은 객체를 메모리에서 해제
- Compaction 단계 (선택적): 메모리 파편화 방지를 위해 객체들을 재배치
이 과정 중에 애플리케이션이 계속 실행되면 어떻게 될까?
- GC가 객체를 정리하려는 순간 그 객체가 새로 참조될 수 있다.
- Compaction 중 메모리 주소가 바뀌는데 애플리케이션은 여전히 이전 주소로 접근할 수도 있다.
- 데이터 불일치가 발생하고 시스템이 불안정해진다.
그래서 GC 중에는 모든 스레드를 반드시 멈춰야 한다. 이것이 STW의 이유다.
2부: Heap 메모리 구조를 이해해야 성능이 보인다
Young Generation vs Old Generation
JVM의 Heap은 두 영역으로 나뉜다. Young Generation은 Eden, Survivor 0, Survivor 1 구역으로 구성된다:
┌──────────────────────────────────────┐
│ Young Generation (전체의 ~33%) │
│ ┌────────┬──────────┬──────────┐ │
│ │ Eden │Survivor 0│Survivor 1│ │
│ └────────┴──────────┴──────────┘ │
├──────────────────────────────────────┤
│ Old Generation (전체의 ~67%) │
│ (수명 긴 객체들이 모여있는 곳) │
└──────────────────────────────────────┘왜 이렇게 나눴을까?
통계적으로 대부분의 객체는 생성되자마자 금방 사용되지 않는다. 즉, 수명이 짧다.
따라서:
- Minor GC: Eden이 꽉 차면 이 영역만 정리. 빠르고 STW 시간이 짧다 (수 ms~수십 ms)
- Major GC: Old가 꽉 차면 전체 Heap을 정리. 느리고 STW 시간이 길다 (수백 ms~수 초)
이게 성능의 핵심이다. Major GC 한 번이 실행되면 금융 시스템은 타임아웃이 나고, 게임 서버는 유저가 끊어진다.
Mark 단계: 도달 가능한 객체는 어떻게 찾나
GC의 Mark 단계에서 어떤 객체가 살아있는지 판단할 때, Root Space에서 시작해 그래프를 순회한다.
Root Space는:
- Stack의 로컬 변수들
- 메서드 영역의 Static 변수들
- JNI 참조들
이 Root들로부터 출발해 참조를 따라가며 도달 가능한 모든 객체를 찾는다. 이때 순회는 일반적으로 DFS(깊이 우선 탐색)로 구현된다. 재귀 호출 대신 명시적 스택을 사용해서 콜 스택 오버플로우를 방지한다.
3부: 현대 GC 구현체들이 성능을 다투는 방법
Serial GC (구식)
단일 스레드로 GC를 수행. STW 시간이 길다. 요즘은 거의 안 쓴다.
Parallel GC (Java 8의 기본값)
여러 스레드를 사용해 GC를 병렬로 처리. Serial보다 STW 시간이 짧지만, 여전히 긴 편이다.
CMS GC (Concurrent Mark Sweep)
GC의 대부분을 애플리케이션 스레드와 동시에 수행한다. STW를 최소화하는 대신 메모리와 CPU를 많이 사용하고, Compaction이 기본으로 안 된다(메모리 파편화 문제).
G1 GC (Java 11 이후의 기본값, 권장)
가장 스마트한 GC. Heap을 고정 크기 Region으로 나누고:
- Young-Only 단계: Young Region만 주기적으로 정리
- Concurrent Marking 단계: Old Generation의 쓰레기 비율을 측정 (애플리케이션과 동시 실행)
- Space Reclamation 단계: Garbage가 많은 Region을 우선 정리 ("Garbage First" 전략)
결과적으로:
- Old를 전체 정리하지 않으므로 STW 시간이 예측 가능하다
-XX:MaxGCPauseMillis=200옵션으로 최대 STW 시간을 제어할 수 있다- Concurrent Marking이므로 STW 오버헤드가 적다
실무에서 대부분의 신규 프로젝트는 G1 GC를 사용하거나, ZGC/Shenandoah 같은 저지연 GC를 선택한다.
4부: 실무 경험담 - Spring Batch 테스트의 OOM 대응
문제 발생 상황
Spring Batch 통합 테스트가 @MockKBean 과다 사용으로 Context 캐싱 한계(32개 max)에 도달했다. 매 테스트마다 새로운 Application Context가 생성되면서:
- Context 초기화 → 수백 MB 메모리 할당
- GC 실행 필요
- 하지만 Minor GC만으로는 Old Generation의 Context 메타데이터를 정리할 수 없음
- Major GC 반복 → STW 누적 → 테스트 타임아웃 또는 OOM
근본 원인 분석
메모리 프로파일링 결과:
Eden: 거의 항상 꽉 차 있음 (Minor GC 반복)
Survivor: 자주 넘침 (Old로 승격되는 객체 많음)
Old: 계속 증가 → Major GC 유발Spring Context 메타데이터는 수명이 길어서 Old로 승격되는데, 테스트가 끝났어도 GC Roots에서 도달 가능했다(클래스 로더가 홀딩 중).
해결책
// Before: @MockKBean (Spring Context 캐싱 방해)
@SpringBootTest
class BatchJobTest {
@MockKBean
private SomeService someService;
}
// After: mockk() (Spring Context 독립적)
@DataJpaTest // 필요한 기능만 로드
class RepositoryTest {
private val someService = mockk<SomeService>()
}
// Full job test만 @SpringBatchTest 사용
@SpringBatchTest
class JobIntegrationTest {
// 여기만 무거운 Context 사용
}
결과: 메모리 사용량 34-50% 감소, 테스트 타임아웃 완전 해소
5부: 실무 모니터링 - OOM 문제를 미리 잡는 법
GC 로그 수집 설정
# JVM 옵션에 추가
-Xms4g -Xmx4g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:+PrintGCDetails \
-XX:+PrintGCDateStamps \
-Xloggc:/var/log/jvm/gc-%t.log \
-XX:+UseGCLogFileRotation \
-XX:NumberOfGCLogFiles=10 \
-XX:GCLogFileSize=100M \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/jvm/heapdump.hprof
이렇게 설정하면:
- GC 로그: 언제, 얼마나 오래, 어느 정도 메모리를 정리했는지 기록
- Heap Dump: OOM 발생 순간의 메모리 상태를 파일로 저장
모니터링 지표
1. Minor GC 빈도 & STW 시간
정상: 수초마다 수 ms
이상: 매초마다 수십 ms → Old Generation으로 빠르게 승격 중
2. Major GC 간격
정상: 하루에 0-2회 (충분한 Heap이 있으면 더 드뭄)
이상: 시간마다 → Heap 부족하거나 메모리 누수
3. Old Generation 사용률
정상: 30-60% (충분한 여유)
위험: 80% 이상 → Major GC 임박
치명: 95% 이상 → OOM 몇 분 내 발생 가능
실시간 모니터링 도구
# 터미널에서 GC 상태 실시간 확인
jstat -gc -h20 <PID> 1000 # 1초마다 갱신
# 출력 해석
YGCN: Young Generation GC 횟수
YGCT: Young Generation GC 누적 시간
FGCN: Full (Major) GC 횟수
FGCT: Full GC 누적 시간
또는 프로덕션에서는:
Prometheus: JVM 메트릭 수집
Grafana: 시각화 & 알림 설정
AlertManager: Major GC 빈도 이상 감지 → 알림6부: OOM이 발생하면 어떻게 대응할까
1단계: Heap Dump 분석
OOM 발생 시 자동으로 생성된 heapdump.hprof 파일을 Memory Analyzer Tool(MAT) 또는 JProfiler로 분석한다.
Leak Suspects Report:
- SomeCache (50.2 MB, 어? 이게 이렇게 커?)
- LinkedHashMap (메모리 누수 후보)
- references: xxx에서 추가됨, yyyy에서 정리 안 됨2단계: 근본 원인 파악
일반적인 원인들:
| 원인 | 특징 | 대처 |
|---|---|---|
| 컬렉션에 무한정 데이터 누적 | Cache/Queue 계속 증가 | TTL/용량 제한 추가 |
| 순환 참조 (Reference Counting GC만 문제) | Java는 해당 안 함 | - |
| 스레드 로컬 변수 미정리 | ThreadPool + ThreadLocal | cleanup() 호출 |
| 외부 라이브러리 버그 | 알려진 누수 패턴 | 버전 업그레이드 |
| Heap 설정 부족 | 정상 동작이지만 부족 | Heap 확대 (임시) |
3단계: 코드 수정
예를 들어 Spring Cache가 무한정 커지는 경우:
// Before: 무한정 캐시
@Cacheable("users")
public User getUser(Long id) { ... }
// After: TTL 설정
@Cacheable(value = "users", cacheManager = "usersCacheManager")
public User getUser(Long id) { ... }
// Cache Manager 설정
@Bean
public CacheManager usersCacheManager() {
return CaffeineCacheManager.create()
.withMaximumSize(10000)
.withExpireAfterWrite(Duration.ofMinutes(10))
.build();
}
4단계: 모니터링 강화
수정 후에는:
- Staging에서 장시간 부하 테스트 실시
- Heap 사용률 변화 추적 (계속 증가하는가 아닌가)
- Major GC 빈도 확인 (심화되는가 안정적인가)
- 프로덕션 배포 전에 최소 3일 이상 테스트
7부: 자주 하는 오해와 Best Practice
오해 1: "GC를 없애거나 비활성화할 수 있다"
거짓. GC를 비활성화하면 Heap이 꽉 찼을 때 OOM이 즉시 발생한다. 조정만 가능하지 제거는 불가능하다.
오해 2: "Heap을 크게 설정하면 모든 문제가 해결된다"
반은 맞고 반은 틀렸다.
- Heap이 너무 작으면 → Major GC 빈번, STW 길어짐
- Heap이 너무 크면 → Major GC 시간이 매우 길어짐, 메모리 낭비
권장: 피크 메모리 사용량의 1.5배 정도로 설정
# 예: 피크 메모리가 2GB면
-Xms3g -Xmx3g
오해 3: "-XX:+UseG1GC를 설정하면 끝"
아니다. 기본 설정 외에 환경에 맞춘 튜닝이 필요하다.
# 기본 권장 설정 (원본 자료 기반)
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 # STW 목표시간 (밀리초)
# 선택적 고급 튜닝 (운영 중 필요 시)
# -XX:G1HeapRegionSize=16m # Region 크기
# -XX:InitiatingHeapOccupancyPercent=35 # Concurrent Marking 임계값
Best Practice 체크리스트
- GC 로그와 Heap Dump 자동 저장 설정 (원본 자료 권장)
- 배포 전 부하 테스트로 Heap 크기 결정
- Major GC 빈도를 모니터링하여 메모리 누수 감지
- 모니터링 대시보드 구축 (VisualVM, Prometheus+Grafana 등 선택)
- OOM 발생 시 Heap Dump 분석으로 원인 파악
- 메모리 누수 의심 시 전문 도구(JProfiler, YourKit, MAT) 활용
8부: 면접 답변 압축본
Q1. GC는 무엇이고, 왜 필요한가?
A. GC는 Mark-and-Sweep 알고리즘을 사용해 더 이상 사용되지 않는 객체를 자동으로 정리합니다. 개발자가 메모리 할당과 해제를 직접 관리하지 않아도 되므로 휴먼 에러를 방지하고, 안정적인 서비스를 보장합니다. 대신 GC 실행 중에 Stop-The-World가 발생해 애플리케이션 스레드가 일시 정지됩니다.
Q2. Heap을 Young과 Old로 나눈 이유는?
A. 통계적으로 대부분의 객체는 수명이 짧습니다. 따라서 Young Generation만 자주 정리하면 전체 Heap을 정리할 때보다 GC 성능이 좋아집니다. Minor GC는 빠르지만 Major GC는 느리므로, Old로 승격되는 객체를 최소화하는 것이 성능 최적화의 핵심입니다.
Q3. G1 GC의 장점은?
A. Heap을 Region으로 나누고 Garbage가 많은 영역을 우선 정리("Garbage First")하므로, STW 시간이 예측 가능합니다. -XX:MaxGCPauseMillis로 최대 STW 시간을 제어할 수 있고, Concurrent Marking으로 STW 오버헤드를 줄입니다.
Q4. OOM이 발생하면 어떻게 대응할까?
A. 첫째, 자동으로 생성된 Heap Dump를 MAT로 분석해 메모리를 가장 많이 차지하는 객체를 파악합니다. 둘째, 메모리 누수 원인(캐시 TTL 미설정, ThreadLocal 미정리 등)을 찾아 코드를 수정합니다. 셋째, Staging 환경에서 장시간 부하 테스트로 메모리 누수 여부를 재확인한 후 프로덕션에 배포합니다.
마무리
GC는 Java의 가장 강력한 기능이자 가장 복잡한 부분이다. 하지만 동작 원리를 이해하고 모니터링하면, OOM 같은 문제는 사전에 감지할 수 있고, 성능도 획기적으로 개선할 수 있다.
특히 MSA 환경에서 마이크로서비스의 개수가 늘어나고 각각이 독립적인 JVM을 띄우는 요즘, GC 설정과 모니터링은 비용 절감의 직결된다. 불필요하게 큰 Heap을 할당해 메모리만 낭비하지 말고, 올바른 설정과 모니터링으로 효율적인 리소스 관리를 하자.
마지막으로 기억할 것:
GC를 모르면 Java 개발자가 아니다. GC 로그 한 줄을 읽을 수 없다면 본인의 서비스에서 무엇이 일어나고 있는지 알 수 없다.
참고자료
- Oracle OpenJDK GC Tuning Guide — 공식 GC 튜닝 가이드
- G1 GC 동작 원리 — G1 GC의 상세 구조
- jstat - JVM Statistics Monitoring Tool — GC 상태 실시간 모니터링
- jmap - Memory Map — Heap Dump 생성
- MAT (Memory Analyzer Tool) — Heap Dump 분석 도구
- Spring Testing Guide — Spring Test Context 최적화
'Language > Java & Kotlin' 카테고리의 다른 글
| Java 개발자라면 알아야 할 JVM의 모든 것: 클래스 로더부터 실행 엔진까지 (0) | 2026.07.17 |
|---|---|
| 배포 후 첫 5분이 느린 이유: JVM 워밍업 없이 트래픽을 받으면 안 되는 이유 (0) | 2026.07.17 |
| [Java] Stream API 톺아보기 (0) | 2025.06.03 |
| [Java] 모듈 결합도와 응집도, SRP와 OCP 원칙 (3) | 2025.06.03 |
| [Kotlin] 자주 쓰이는 코틀린 문법 정리 1. 코틀린 스코프함수 (apply, run, with, let) (1) | 2024.08.23 |
댓글