Java 메모리 문제를 재현 없이 좁히는 운영 절차

Java 서비스의 메모리 장애는 단순히 힙을 크게 잡는다고 해결되지 않습니다. 실제 운영에서는 배포 직후에는 정상처럼 보이다가 몇 시간 뒤 응답 시간이 늘어나고, GC 시간이 길어지며, 결국 OutOfMemoryError 또는 컨테이너 재시작으로 이어지는 경우가 많습니다. 이때 가장 먼저 해야 할 일은 원인을 추측하는 것이 아니라 현재 JVM이 어떤 메모리 영역을 얼마나 쓰고 있는지, 객체가 계속 늘어나는지, 스레드나 네이티브 메모리까지 함께 증가하는지를 분리해서 보는 것입니다.

이 문서는 운영 서버에서 비교적 안전하게 시작할 수 있는 jcmd, jstat, JFR 기반 점검 순서를 정리합니다. 목표는 한 번의 명령으로 정답을 맞히는 것이 아니라, 힙 누수인지, 캐시 정책 문제인지, 스레드 증가인지, 직접 메모리 또는 메타스페이스 문제인지 빠르게 범위를 좁히는 것입니다. 장애 대응 문서에 그대로 넣을 수 있도록 명령 예제와 판단 기준을 함께 둡니다.

1. 먼저 JVM 프로세스와 기본 상태를 확인합니다

서버에 여러 Java 프로세스가 떠 있으면 잘못된 PID에 덤프를 걸어 장애 대응 시간을 낭비할 수 있습니다. jcmd는 현재 실행 중인 JVM 목록을 보여주고, 선택한 프로세스의 시스템 속성, VM 플래그, 힙 정보를 확인할 수 있습니다. 컨테이너 환경에서는 호스트가 아니라 컨테이너 내부에서 실행해야 PID 네임스페이스가 맞습니다.

jcmd
jcmd <PID> VM.version
jcmd <PID> VM.flags
jcmd <PID> GC.heap_info
jcmd <PID> Thread.print -l > /tmp/thread-dump-$(date +%Y%m%d-%H%M%S).txt

GC.heap_info에서 힙 사용량이 높더라도 곧바로 누수라고 단정하면 안 됩니다. 처리량이 많은 서비스는 피크 시간에 힙을 적극적으로 사용하다가 다음 GC에서 내려갈 수 있습니다. 중요한 것은 사용량의 절대값보다 Full GC 이후에도 old 영역이 계속 상승하는지, 같은 트래픽 수준에서 기준선이 매일 높아지는지입니다.

2. 짧은 간격으로 추세를 봅니다

메모리 문제는 스냅샷 하나보다 추세가 중요합니다. jstat은 GC 횟수, GC 시간, young/old 영역 사용량을 짧은 간격으로 확인할 때 유용합니다. 아래 예시는 5초 간격으로 12회 수집합니다. 운영 중에는 이 결과를 장애 티켓에 붙여서 배포 시간, 트래픽 변화, 오류 로그와 함께 비교하면 원인 추적이 쉬워집니다.

jstat -gcutil <PID> 5000 12
jstat -gccause <PID> 5000 12

old 영역이 GC 뒤에도 계속 상승하고, Full GC 시간이 늘어나며, 애플리케이션 로그에는 큰 트래픽 증가가 없다면 힙에 오래 살아남는 객체를 의심합니다. 반대로 힙은 안정적인데 프로세스 RSS만 증가한다면 direct buffer, mmap, 압축 라이브러리, 네이티브 라이브러리, 스레드 스택처럼 힙 밖 메모리를 확인해야 합니다. 컨테이너가 OOMKilled를 내는 상황에서는 JVM 힙만 보지 말고 컨테이너 메모리 제한과 프로세스 RSS를 같이 봐야 합니다.

3. 힙 덤프는 기준을 정해서 남깁니다

힙 덤프는 강력하지만 파일이 크고, 순간적으로 애플리케이션에 부담을 줄 수 있습니다. 따라서 무조건 여러 번 찍기보다 기준을 정하는 편이 좋습니다. 예를 들어 old 사용률이 80%를 넘고 Full GC 이후에도 내려가지 않을 때 1회 남기고, 같은 조건이 10분 뒤에도 유지되면 한 번 더 남깁니다. 두 덤프를 비교하면 어떤 객체 그래프가 증가하는지 확인할 수 있습니다.

mkdir -p /var/log/myapp/heapdump
jcmd <PID> GC.heap_dump /var/log/myapp/heapdump/myapp-$(date +%Y%m%d-%H%M%S).hprof
ls -lh /var/log/myapp/heapdump/*.hprof

덤프 파일에는 사용자 데이터나 토큰이 포함될 수 있으므로 외부 전달 전에 보안 기준을 확인해야 합니다. 운영 서버에 계속 보관하지 말고, 분석이 끝난 파일은 정해진 보관 기간에 맞춰 삭제합니다. 덤프 분석 도구에서는 retained size가 큰 객체, Map이나 List처럼 계속 커지는 컬렉션, ThreadLocal에 매달린 객체, 캐시 키가 제거되지 않는 구조를 우선 확인합니다.

4. JFR로 할당과 잠금을 함께 봅니다

Java Flight Recorder는 일정 시간 동안 JVM 이벤트를 기록해 객체 할당, GC, 스레드, 락 경합, 파일과 소켓 I/O를 함께 볼 수 있게 해줍니다. 장애가 재현 중일 때 짧게 녹화하면 힙 덤프만으로 보이지 않는 흐름을 확인할 수 있습니다. 운영에서는 먼저 낮은 부담의 프로파일로 2분에서 5분 정도 수집하고, 필요한 경우 범위를 늘립니다.

jcmd <PID> JFR.start name=memcheck settings=profile delay=0s duration=180s filename=/tmp/myapp-memcheck.jfr
jcmd <PID> JFR.check
jcmd <PID> JFR.stop name=memcheck filename=/tmp/myapp-memcheck.jfr

JFR에서는 객체 할당 상위 클래스, 특정 요청 처리 중 반복 생성되는 버퍼, 동기화 대기로 쌓이는 스레드, 비정상적으로 긴 GC pause를 함께 봅니다. 예를 들어 응답 JSON을 만들 때 큰 문자열을 반복 생성하거나, 파일 업로드 처리에서 byte 배열을 한 번에 메모리에 올리는 코드가 보이면 스트리밍 처리 또는 제한 크기 검증을 적용해야 합니다.

5. 애플리케이션 코드에서는 수명과 상한을 명확히 둡니다

많은 메모리 장애는 JVM 옵션보다 애플리케이션 객체의 수명 설계에서 시작됩니다. 캐시, 세션 저장소, 이벤트 버퍼, 재시도 큐, 배치 결과 목록처럼 오래 살아남는 컬렉션에는 최대 크기, 만료 시간, 제거 정책이 있어야 합니다. 아래 예시는 단순 Map 캐시의 위험한 형태와 최소한의 상한을 둔 형태를 비교합니다.

// 위험한 예: 요청 키가 계속 늘어나도 제거되지 않습니다.
private final Map<String, byte[]> reportCache = new ConcurrentHashMap<>();

public byte[] getReport(String id) {
    return reportCache.computeIfAbsent(id, this::loadReportBytes);
}

// 개선 방향: 실제 서비스에서는 Caffeine 같은 검증된 캐시와 만료 정책을 사용합니다.
Cache<String, byte[]> reportCache = Caffeine.newBuilder()
    .maximumSize(5_000)
    .expireAfterWrite(Duration.ofMinutes(30))
    .recordStats()
    .build();

ThreadLocal도 자주 놓치는 원인입니다. 요청 단위 데이터를 ThreadLocal에 넣고 remove를 호출하지 않으면, 스레드 풀이 유지되는 동안 큰 객체가 계속 참조될 수 있습니다. 필터, 인터셉터, 비동기 작업에서 ThreadLocal을 사용할 때는 try-finally로 정리하는 규칙을 코드 리뷰 기준에 포함하는 것이 좋습니다.

try {
    RequestContextHolder.set(context);
    return handler.handle(request);
} finally {
    RequestContextHolder.clear();
}

6. JVM 옵션은 관측 가능하게 설정합니다

운영 환경에서는 장애가 난 뒤에야 덤프 설정이 없다는 사실을 발견하는 경우가 많습니다. 최소한 OOM 발생 시 힙 덤프를 남기고, GC 로그를 회전 저장하며, 컨테이너 메모리 제한에 맞는 최대 힙을 명시하는 것이 좋습니다. Xmx를 컨테이너 제한과 같게 잡으면 네이티브 메모리, 스레드 스택, 메타스페이스가 쓸 공간이 부족해질 수 있으므로 여유를 둡니다.

JAVA_TOOL_OPTIONS="  -XX:+HeapDumpOnOutOfMemoryError   -XX:HeapDumpPath=/var/log/myapp/heapdump   -Xlog:gc*,safepoint:file=/var/log/myapp/gc.log:time,uptime,level,tags:filecount=10,filesize=50M   -XX:MaxRAMPercentage=70   -XX:InitialRAMPercentage=40"

메모리 장애가 반복된다면 배포 파이프라인에 부하 테스트와 장시간 안정성 테스트를 추가합니다. 5분짜리 기능 테스트로는 누수를 잡기 어렵습니다. 최소한 주요 API를 일정한 RPS로 30분 이상 호출하고, old 영역 기준선과 프로세스 RSS가 안정되는지 확인하는 자동 리포트를 남기는 편이 좋습니다.

마무리 체크리스트

  • PID를 확인한 뒤 jcmd, jstat으로 힙과 GC 추세를 먼저 본다.
  • Full GC 이후 old 영역이 계속 상승하는지 확인하고, 단일 스냅샷만으로 누수를 단정하지 않는다.
  • 힙이 안정적인데 RSS가 늘면 direct memory, 스레드, 네이티브 라이브러리, 컨테이너 제한을 함께 본다.
  • 힙 덤프와 JFR은 짧고 명확한 기준으로 수집하며, 민감 데이터 보관 정책을 지킨다.
  • 캐시, ThreadLocal, 큐, 배치 목록에는 최대 크기와 정리 조건을 코드 수준에서 강제한다.
  • OOM 힙 덤프, GC 로그 회전, 컨테이너 메모리 대비 JVM 여유분을 기본 운영 옵션으로 준비한다.