본문 바로가기

카테고리 없음

JVM Heap, GC 그리고 OutOfMemoryError

최근 프로젝트에서 외부 API로부터 수십만 건에 달하는 기업 정보를 수집해 저장하는 작업을 진행하던 중, 예상치 못한 OutOfMemoryError가 발생했습니다.
코드상에는 명백한 오류가 없어 보였고, 반복 실행 때마다 프로그램은 일정량의 데이터를 처리한 후 멈춰버렸습니다.

처음에는 단순히 메모리가 부족한가?라고 생각했지만, Heap Dump를 분석해 본 결과, GC가 객체를 제때 수집하지 못한 채 Heap 영역에 과도한 데이터가 적재된 상태였고, 이는 곧바로 JVM 메모리 한계에 도달하게 만들었습니다.

이 글에서는 제가 경험한 OOM 이슈의 발생 원인부터, JVM 메모리 구조와 GC 작동 원리, 그리고 문제를 해결한 코드 리팩터링 과정까지 전체 흐름을 정리해보려 합니다.

JVM Heap 영역이란?

Java 애플리케이션이 실행되면, 프로그램이 생성하는 모든 객체들은 JVM의 Heap 메모리에 저장됩니다.
이 Heap은 애플리케이션의 런타임 동안 객체들이 머무는 공간으로, JVM은 이곳의 메모리를 효율적으로 관리하기 위해 Garbage Collector를 동원합니다.

JVM의 Heap 영역은 크게 Young Generation과 Old Generation이라는 두 세대로 나뉘는데,
이 구분은 객체의 생존 시간을 기준으로 메모리를 다르게 관리하려는 목적에서 출발합니다.

 

Young Generation

애플리케이션에서 생성되는 객체들은 대부분 처음에는 Young Generation에 저장됩니다.
이 영역은 다시 Eden과 두 개의 Survivor 영역(S0, S1)으로 나뉘어 있으며, Eden에 먼저 객체가 들어오고 이후 Survivor로 옮겨집니다.

Young 영역은 상대적으로 크기가 작고, 여기에 저장되는 객체들은 대부분 생명 주기가 짧습니다.
그래서 이 영역에서는 Minor GC가 자주 발생하며, 불필요한 객체를 빠르게 제거합니다.
GC 비용이 작고 처리 속도가 빠르기 때문에 시스템 성능에 큰 부담을 주지 않는 것이 특징입니다.

Young Generation에서 객체는 어떻게 이동할까?

Java 애플리케이션에서 생성되는 대부분의 객체는 Young Generation에서 시작됩니다.
이 영역은 짧은 생명 주기의 객체들을 빠르게 생성하고 제거하기 위한 공간으로, 객체는 Eden 영역에서 시작해 Survivor 영역을 거쳐 필요에 따라 Old Generation으로 이동합니다.

객체는 Eden에서 생성된다

새롭게 생성된 객체는 가장 먼저 Eden 영역에 저장됩니다.
이곳은 객체가 처음으로 메모리에 올라오는 곳으로, 대다수의 객체가 이곳에서 짧은 시간 머물다 사라지게 됩니다.
Eden은 Young Generation 내에서도 가장 활발한 메모리 공간입니다.

 

Eden에서 GC가 발생하고 살아남은 객체는 Survivor로 복사된다

Eden 영역에 객체가 반복적으로 생성되다 보면 공간이 포화되고, 이때 Minor GC가 발생한다.
GC는 참조되지 않는 객체를 제거하고, 참조가 유지되고 있는 객체만 Survivor 영역으로 복사한다.
이 과정은 Eden뿐 아니라 현재 사용 중인 Survivor 영역에도 적용되며,
Survivor 영역은 두 개가 교대로 사용되어, 복사와 비우기를 반복하게 된다.

이처럼 GC가 발생할 때마다 살아남은 객체는 Survivor 공간으로 이동하며 ‘생존 횟수’를 하나씩 기록한다.
이 생존 횟수가 일정 기준을 넘으면, 객체는 Young Generation에서 Old Generation으로 승격된다.

객체의 생존이 반복되면 나이를 먹는다

한 객체가 여러 번의 GC를 통과하면서 계속 참조되고 있다면, **생존 횟수(age)**가 누적됩니다.
JVM은 이 횟수를 기준으로 해당 객체가 Young Generation에 계속 머물 수 있을지를 판단합니다.
이 기준은 MaxTenuringThreshold 값에 의해 결정되며, 기본적으로 약 15회입니다.

기준을 넘으면 Old Generation으로 이동한다

생존 횟수가 임계값을 넘긴 객체는 더 이상 Young Generation에 머무르지 않고 Old Generation으로 이동하게 됩니다.
이 과정을 Promotion이라고 하며, 객체가 장기적으로 유지될 필요가 있다고 판단된 경우입니다.

Old Generation

반면에, Young 영역에서 여러 번 GC를 거쳐도 살아남는 객체들은 Old Generation으로 이동하게 됩니다.
이 영역은 상대적으로 크고, 장기간 유지되어야 하는 객체들이 저장됩니다.

Old Generation에서는 Major GC가 발생합니다.
이 GC는 Young 영역보다 훨씬 많은 객체를 대상으로 하며, 정리 작업에 걸리는 시간이 길고, 일시적으로 전체 애플리케이션이 멈추는 Stop-the-World(STW)가 발생합니다.
따라서 이 영역에서 메모리가 부족해지면, 시스템 성능에 직접적인 영향을 줄 뿐 아니라, OutOfMemoryError로 이어질 수 있습니다.

 

  • 모든 객체는 Young Generation에서 시작되며, 대부분은 그 안에서 짧은 생명 주기를 가지고 소멸합니다.
  • 살아남은 객체는 Old Generation으로 승격되며, 이곳에서 장기적으로 유지됩니다.

Stop-The-World

Stop-The-World는 말 그대로, JVM이 내부에서 GC를 수행하기 위해 애플리케이션의 모든 스레드를 일시적으로 정지시키는 현상을 말합니다.

GC는 JVM 메모리를 효율적으로 관리하기 위한 필수 동작이지만, 이 과정은 안전해야 합니다. 객체의 참조 관계가 얽혀있는 와중에 GC가 객체를 임의로 삭제한다면 심각한 문제가 발생할 수 있기 때문입니다.
따라서 JVM은 GC를 실행할 때 안정적인 스냅샷 상태를 확보하기 위해 STW를 강제합니다.

STW는 언제 발생시점

모든 GC가 STW를 유발하는 것은 아닙니다. STW는 GC 알고리즘과 실행되는 세대 영역에 따라 다르게 발생합니다.

Minor GC (Young Generation)

  • STW 발생 O, 하지만 멈추는 시간은 짧고 상대적으로 가볍습니다.
  • 대부분의 객체가 Eden 영역에서 빠르게 제거되기 때문에 성능에 큰 부담을 주지 않습니다.

Major GC (Old Generation)

  • STW 발생 O, 멈추는 시간이 몇 초에서 수십 초 이상까지도 발생할 수 있습니다.
  • 객체의 수명 주기 추적, 참조 관계 해제, 압축등 복잡한 작업이 동반되기 때문에 시스템 정지 시간이 길어집니다.

STW의 단점

1. 사용자 응답 지연

웹 애플리케이션이라면 사용자는 페이지 로딩 지연, API 타임아웃, 일시적 서버 무응답 상태를 경험할 수 있습니다.

 

2. PS/처리량 저하

대량 트래픽을 처리하는 백엔드 서버에서는 STW 동안 모든 요청 처리가 중단되어 성능 지표에 악영향을 미칩니다.

 

3. 시스템 전체의 병목 유발

병렬 처리를 전제로 구성된 시스템일수록 STW는 전체 워크플로우를 멈추게 만들 수 있습니다.

GC는 어떻게 객체를 수집 대상으로 판단할까?

Garbage Collector는 무작정 메모리를 청소하지 않습니다.
지금 이 객체는 더 이상 사용되지 않는다고 판단할 수 있어야 수집 대상으로 삼고, 메모리에서 해제합니다.

그렇다면 GC는 어떤 기준으로 객체가 사용 중이지 않다고 판단할까요?

GC의 기본 원리 참조(Reachability)

JVM은 객체가 메모리 안에서 다른 영역 또는 다른 객체로부터 참조되지 않을 때, 그 객체를 사용되지 않는 객체로 간주하고 GC 대상으로 지정합니다.

객체가 참조되는 주요 경우는 아래와 같습니다.

  1. 스택(Stack)에서의 참조
    로컬 변수, 매개변수 등 현재 실행 중인 메서드의 지역 변수들이 객체를 참조할 수 있습니다.
  2. 힙(Heap) 내 다른 객체로부터의 참조
    예를 들어, 어떤 리스트 객체가 내부에 또 다른 객체를 포함하고 있다면, 해당 포함된 객체도 참조된 상태입니다.
  3. 메서드(Method) 영역의 정적(static) 변수 참조
    클래스 로딩 시 함께 올라온 정적 변수들이 객체를 참조하고 있으면 그 객체는 계속 살아있게 됩니다.
  4. 네이티브(Native) 스택에서의 참조
    JNI(Java Native Interface)를 통해 연결된 네이티브 코드 영역에서도 객체가 참조될 수 있습니다.

 

 

이러한 참조들을 따라가면서, JVM은 **‘루트 객체(GC Root)’로부터 도달 가능한 객체(Reachable Object)**들을 찾고, 이 참조 사슬 밖에 있는 객체들을 GC 대상으로 분류합니다.

GC 동작 방식: Mark → Sweep → Compact

GC는 객체 수집 시 다음 세 가지 단계를 거칩니다. 이 과정을 통해 메모리를 정리하고, 다시 객체를 위한 공간을 확보합니다.

Mark 단계

가장 먼저, GC는 GC Root(스택, 정적 변수 등)에서 시작하여,
이들이 참조하고 있는 객체들, 그리고 그 객체들이 또 참조하는 객체들을 따라가며 **모두 마킹(mark)**합니다.

이 단계의 목적은 "지금도 사용 중인 객체"를 표시하는 것입니다.

Sweep 단계

Mark 단계가 끝나면, JVM은 Heap 영역을 전체적으로 스캔합니다.
이 과정에서 마킹되지 않은 객체들, 즉

어느 곳에서도 참조되지 않는 객체들을 찾아내고, 이들을 메모리에서 제거합니다.

이것이 GC의 핵심 동작이며, 삭제된 공간은 이후 새로운 객체 생성을 위해 재사용됩니다.

Compact 단계

Sweep이 끝난 후에는 Heap 영역이 여기저기 조각난 메모리 빈칸으로 남게 됩니다.
그래서 GC는 살아남은 객체들을 한쪽으로 몰아넣고, 빈 공간을 한데 모으는 Compact 작업을 수행합니다.

이 정리 작업을 통해 이후 객체 생성 시 연속된 메모리 공간을 할당할 수 있게 되어, 할당 비용도 줄고 성능이 향상됩니다.

 

  • GC는 단순히 오래된 객체를 지우지 않습니다. 참조 사슬에서 벗어난 객체만을 대상으로 합니다.
  • Mark 단계에서 살아있는 객체를 추적하고, Sweep 단계에서 사용되지 않는 객체를 제거하며, Compact 단계로 공간을 재정렬합니다.
  • 이 흐름은 JVM의 메모리 효율을 높이기 위한 핵심 전략이며, 특히 대용량 데이터 처리에서 메모리 안정성 유지에 매우 중요합니다.

실제로 제가 겪은 OutOfMemoryError 이슈도, GC가 수집해야 할 객체들이 참조 해제되지 않고 계속 연결된 상태로 남아 있었기 때문에 발생했습니다.

대용량 데이터 적재 중 OutOfMemoryError 발생

당시 저는 외부 API로부터 수십만 건의 기업 정보를 받아와 엔티티로 저장하는 작업을 수행했습니다.
그러나 모든 데이터를 한꺼번에 메모리에 적재한 뒤 저장하려다 다음과 같은 예외를 마주했습니다.

public void migrateAllCompanies() {
    List<LegacyCompany> rawData = legacyRepository.findAll();

    List<NewCompany> entities = rawData.stream()
                                       .map(legacy -> new NewCompany(legacy))
                                       .collect(Collectors.toList());

    newCompanyRepository.saveAll(entities);
}
  • List<NewCompany>가 한 번에 수십만 건 생성됨 → JVM Heap 사용량 급증
  • GC가 처리할 여유 없이 객체가 생성됨 → Heap 영역 초과

Heap Dump 분석 결과

Heap Dump를 열어보니 Company 객체 수십만 건이 Old 영역에 남아 있었습니다.
GC 로그에는 Full GC가 반복되지만 메모리는 해소되지 않고, 결국 OOM으로 이어졌습니다.

데이터를 페이징하여 순차 저장

데이터를 일정 크기 단위로 나누어 처리하고, 각 저장 이후에는 GC가 개입할 수 있도록 메모리 여유를 주었습니다.

public void migrateCompaniesInChunks() {
    int page = 0;
    int pageSize = 1000;

    while (true) {
        List<LegacyCompany> batch = legacyRepository.findPage(page, pageSize);
        if (batch.isEmpty()) break;

        List<NewCompany> entities = batch.stream()
                                         .map(legacy -> new NewCompany(legacy))
                                         .collect(Collectors.toList());

        newCompanyRepository.saveAll(entities);

        entities.clear();
        batch.clear();

        page++;
    }
}

 

  • GC가 반복 저장 주기 사이에 충분히 개입할 수 있게 됨
  • Old 영역 포화 현상이 사라짐
  • 전체 처리 시간도 줄고, 서버의 안정성 향상

마무리

대용량 데이터를 한 번에 처리하려다 발생한 OutOfMemoryError는, 단순한 메모리 부족 문제가 아니었습니다.
JVM의 Heap 구조와 GC 동작 방식에 대한 이해 부족이 근본적인 원인이었습니다.

객체가 생성되고 제거되는 흐름, GC가 어떤 기준으로 대상을 수집하는지, 그리고 어떤 상황에서 Stop-the-World가 발생하는지를 알고 있었다면 더 빠르게 문제를 진단하고 대응할 수 있었을 것입니다.

앞으로도 GC에 부담을 주지 않는 구조, 대용량 데이터를 안정적으로 처리할 수 있는 설계를 계속 고민해 나갈 필요가 있습니다.