메모리 증가를 먼저 누수로 단정하지 않기

Java 서버의 RSS나 힙 사용량이 시간이 지날수록 커진다고 해서 곧바로 메모리 누수라고 판단하면 안 됩니다. JVM은 한 번 확보한 힙 영역을 운영체제에 즉시 반납하지 않을 수 있고, 트래픽 증가에 따라 캐시·스레드·버퍼가 자연스럽게 늘어날 수도 있습니다. 중요한 기준은 GC를 여러 번 수행한 뒤에도 이전 세대의 사용량이 지속적으로 상승하는지, 같은 요청량에서 기준선으로 돌아오지 않는지입니다. 먼저 메트릭에서 heap used, heap committed, old generation, GC pause, process RSS를 분리해 추적합니다.

관측 기간은 최소 한 번의 업무 피크와 배치 실행을 포함해야 합니다. 배포 직후의 워밍업, 클래스 로딩, JIT 컴파일, 캐시 초기화 구간은 별도로 표시해 비교 대상에서 제외합니다. 특정 엔드포인트 호출 뒤에만 증가하는지, 큐 적체나 외부 API 지연이 동반되는지도 함께 확인하면 원인을 훨씬 빨리 좁힐 수 있습니다.

운영 환경에서는 JFR부터 짧게 수집하기

문제가 재현되는 운영 서버에서 가장 먼저 선택할 도구는 Java Flight Recorder(JFR)입니다. JFR은 JVM 내부 이벤트를 낮은 오버헤드로 기록하므로, 무작정 힙 덤프를 만들기 전에 객체 할당, GC, 락, 파일·소켓 I/O, 스레드 활동의 흐름을 확인하는 데 적합합니다. 장애가 진행 중인 서버에 긴 시간의 상세 기록을 남기면 디스크를 압박할 수 있으므로 기간과 최대 파일 크기를 명확히 제한합니다.

# 실행 중인 JVM의 PID를 확인한 뒤 10분간 JFR 기록 시작
jcmd 18420 JFR.start name=memory-check settings=profile duration=10m filename=/var/tmp/memory-check.jfr maxsize=256m

# 기록 상태와 완료된 파일 확인
jcmd 18420 JFR.check
ls -lh /var/tmp/memory-check.jfr

# 필요할 때만 JVM 힙 요약 확인
jcmd 18420 GC.heap_info

JFR 파일은 JDK Mission Control에서 열어 Allocation in new TLAB, Allocation outside TLAB, Garbage Collections, Old Object Sample을 우선 봅니다. 특정 클래스의 할당률이 갑자기 커졌는지, 요청 처리 스레드가 살아 있는 객체를 계속 붙잡는지, 예외 생성이나 문자열 조합이 과도한지 확인합니다. 할당량이 크다고 반드시 누수는 아닙니다. 짧은 수명의 객체가 많이 만들어져도 GC 뒤에 회수되면 처리량 또는 객체 생성 비용 문제일 수 있습니다. 반대로 오래 살아남는 객체의 누적 경로가 반복되면 누수 가능성이 높습니다.

힙 덤프는 증거가 필요할 때 한 번만 확보하기

GC 이후에도 old generation이 내려오지 않고 동일한 유형의 객체 수가 계속 증가한다면 힙 덤프를 확보합니다. 힙 덤프 생성은 파일 크기가 매우 크고 순간적인 부하를 유발할 수 있으므로 디스크 여유 공간과 수집 위치를 먼저 점검해야 합니다. 컨테이너 환경에서는 컨테이너 파일시스템의 용량 제한과 덤프 파일의 반출 경로도 사전에 준비합니다. 민감한 요청 데이터, 토큰, 개인정보가 객체에 남아 있을 수 있으므로 덤프는 접근 통제된 저장소로만 옮기고 보존 기간을 정합니다.

# 라이브 객체만 대상으로 힙 덤프 생성: Full GC가 발생할 수 있음
jcmd 18420 GC.heap_dump -gz=1 /var/tmp/app-live-20260908.hprof.gz

# 객체 수와 얕은 크기 증감 비교용 히스토그램
jcmd 18420 GC.class_histogram filename=/var/tmp/class-histogram.txt

Eclipse MAT 같은 분석 도구에서는 Dominator Tree로 retained heap이 큰 객체를 먼저 정렬합니다. 그다음 Path to GC Roots를 따라가며 왜 회수되지 않는지 확인합니다. 흔한 원인은 정리되지 않은 static 컬렉션, 만료 정책 없는 로컬 캐시, 종료되지 않은 ExecutorService, ThreadLocal 값, 리스너 등록 후 해제 누락, 대기 중인 CompletableFuture 체인입니다. 단순히 가장 큰 객체를 제거하는 것이 아니라, 그 객체를 계속 참조하는 소유자와 수명 정책을 수정해야 합니다.

코드에서 자주 놓치는 참조 경로

캐시는 최대 항목 수뿐 아니라 만료 시간과 관측 지표를 같이 가져야 합니다. 사용자 입력이나 식별자를 키로 삼는 캐시에 상한이 없으면 트래픽이 정상이어도 메모리는 계속 증가합니다. 스레드 풀 작업에서는 요청 객체 전체를 람다 캡처로 넘기지 말고 필요한 값만 복사합니다. 웹 요청 스레드에서 ThreadLocal을 사용한다면 finally 블록에서 remove를 호출해야 재사용 스레드에 값이 남지 않습니다.

private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();

public void handle(Request request) {
    try {
        TRACE_ID.set(request.traceId());
        service.process(request.id());
    } finally {
        TRACE_ID.remove();
    }
}

또한 커넥션 풀, HTTP 클라이언트 응답 본문, 파일 스트림은 메모리 누수처럼 보이는 리소스 고갈을 만들 수 있습니다. try-with-resources를 기본으로 사용하고, 비동기 응답은 취소·타임아웃·예외 경로에서도 본문과 버퍼가 해제되는지 확인합니다. 힙이 아닌 native memory가 증가한다면 DirectByteBuffer, 메타스페이스, 스레드 수, JNI 라이브러리도 분리해 조사해야 합니다. 이 경우 힙 덤프만으로는 결론을 내릴 수 없습니다.

재발 방지를 위한 점검 목록

  • GC 후 old generation의 기준선과 요청량을 같은 시간축에서 비교한다.
  • JFR로 할당률과 오래 살아남는 객체의 후보를 먼저 좁힌다.
  • 힙 덤프는 디스크·민감정보·부하 조건을 확인한 뒤 제한적으로 생성한다.
  • Dominator Tree 다음에는 반드시 GC Root 경로로 실제 참조 소유자를 확인한다.
  • 캐시 상한·만료, ThreadLocal 제거, 작업 취소와 리소스 종료를 테스트로 검증한다.
  • 수정 후에는 같은 부하 시나리오에서 GC 후 기준선이 안정되는지 다시 측정한다.

메모리 문제는 한 번의 덤프 화면으로 끝나지 않습니다. 증가 현상을 수치로 재현하고, 참조 경로를 코드와 연결해 수정한 뒤, 동일한 조건에서 회귀 측정까지 완료해야 운영 환경의 재발을 막을 수 있습니다.