티스토리 뷰

안녕하세요. 이번 글에서는 JVM 리소스 모니터링의 필요성과 이를 위한 유틸리티 클래스의 구현 및 활용 방법에 대해 알아보겠습니다. 특히 APM(Application Performance Monitoring) 미연동 환경에서, 표준 API를 활용하여 메모리 사용량을 로깅하는 방법을 중심으로 설명드리겠습니다.

1. 소개 & 배경

JVM(Java Virtual Machine)은 자바 애플리케이션의 실행을 담당하는 가상 머신으로, 메모리 관리와 가비지 컬렉션 등의 핵심 기능을 제공합니다. 그러나 애플리케이션의 성능 최적화와 안정적인 운영을 위해서는 JVM의 리소스 사용량을 지속적으로 모니터링하는 것이 필수적입니다.

 

특히 힙 메모리 사용량은 애플리케이션의 메모리 누수나 성능 저하의 주요 원인이 될 수 있습니다. 예를 들어, 서버 애플리케이션이 장시간 실행되면서 메모리 사용량이 점진적으로 증가하거나, 배치 작업 중 특정 단계에서 메모리 사용량이 급증하는 경우가 있습니다. 이러한 문제를 사전에 감지하고 대응하기 위해서는 JVM 리소스 상태를 주기적으로 로깅하여 추적하는 것이 필요합니다.

 

이 글에서는 이러한 문제를 해결하기 위해, JVM의 표준 API를 활용하여 메모리 사용량을 수집하고 로깅하는 유틸리티 클래스를 설계하고 구현하는 방법을 소개합니다.

2. 목표 & 필요성

APM의 주요 목적 중 하나는 애플리케이션의 성능을 실시간으로 모니터링하고, 이상 징후를 빠르게 감지하여 대응하는 것입니다. JVM 리소스 로깅은 다음과 같은 상황에서 특히 유용합니다.

 

서버 애플리케이션에서는 다수의 사용자가 동시에 접속하는 환경에서 메모리 사용량이 급증할 수 있습니다. 예를 들어, 특정 시간대에 트래픽이 집중되거나 장시간 실행되면서 메모리 누수가 발생하는 경우가 있습니다. 이러한 상황에서 힙 메모리 사용량을 주기적으로 로깅하면 메모리 누수나 과도한 GC(Garbage Collection)로 인한 성능 저하를 조기에 감지할 수 있습니다.

 

배치 작업에서는 대량의 데이터를 처리하는 과정에서 메모리 사용량이 많아질 수 있습니다. 각 작업 단계(Step)에서 메모리 사용량을 기록하면, 특정 단계에서의 비효율성을 파악하고 최적의 메모리 설정을 찾는 데 도움이 됩니다. 또한 메모리 부족으로 인한 작업 실패를 예방할 수 있습니다.

 

따라서 JVM 리소스 로깅 유틸리티를 구현하여 애플리케이션의 메모리 사용 현황을 지속적으로 기록하는 것은 안정적인 시스템 운영에 큰 도움이 됩니다.

3. 설계 & 구현 전략

JVM 리소스 로깅 유틸리티를 설계하기 위해 다음과 같은 의사결정 과정을 거쳤습니다.

3.1 메모리 정보 수집 방법 선택

메모리 정보를 수집하는 방법에는 여러 가지가 있습니다. 각 방법의 장단점을 비교하여 최적의 방법을 선택합니다.

  • MemoryMXBean 사용: JVM의 표준 API로, 힙 메모리의 사용량, 최대 크기, 커밋된 메모리, 초기 메모리 등을 안정적으로 제공합니다. 추가 라이브러리 없이 사용할 수 있으며, 상세한 정보를 얻을 수 있습니다.
  • Runtime.getRuntime() 사용: 간단하게 사용할 수 있지만, 제공하는 정보가 제한적이며 정확도가 떨어질 수 있습니다.
  • MemoryPoolMXBean 사용: 세부적인 메모리 풀 정보를 제공하지만, 복잡도가 증가하고 과도한 정보로 인해 분석이 어려울 수 있습니다.
  • Micrometer Metrics 사용: Prometheus 등과 연동하여 장기 모니터링에 적합하지만, 로그 파일에서 직접 확인하기는 어렵습니다.

이 중에서 MemoryMXBean을 선택하였습니다. 표준 API로서 안정적이며, 필요한 메모리 정보를 충분히 제공하기 때문입니다.

3.2 로깅 시점 결정

로깅 시점을 결정할 때는 로그의 빈도와 의미 있는 정보 제공 사이의 균형을 고려해야 합니다.

  • Step 단위 로깅: 각 Step의 시작과 종료 시점에 메모리 사용량을 로깅합니다. 적절한 빈도로 의미 있는 정보를 제공하며, 로그의 양도 적절하게 유지됩니다.
  • Chunk 단위 로깅: 더 세밀한 모니터링이 가능하지만, 로그의 양이 증가하여 성능에 영향을 미칠 수 있습니다.
  • Item 단위 로깅: 가장 세밀한 모니터링이 가능하지만, 로그의 폭주로 인해 성능 저하가 발생할 수 있습니다.

따라서 Step 단위 로깅을 선택하였습니다. 이는 적절한 빈도로 의미 있는 정보를 제공하며, 성능에 미치는 영향도 최소화할 수 있습니다.

3.3 구현 패턴 선택

구현 패턴을 선택할 때는 기존 코드와의 결합도를 낮추고 재사용성을 높이는 것을 고려합니다.

  • StepListener 확장 (Decorator Pattern): 기존 코드와 분리되어 재사용성이 높으며, 단일 책임 원칙을 준수합니다.
  • 기존 StepListener 수정: 간단하지만 기존 코드를 변경해야 하며, 단일 책임 원칙을 위배할 수 있습니다.
  • ItemWriter/Processor에 직접 삽입: 세밀한 제어가 가능하지만, 코드 중복이 발생하고 유지보수가 어려워집니다.

따라서 StepListener를 확장하여 구현하는 것이 가장 적합합니다. 이는 기존 코드와 분리되어 재사용성이 높으며, 유지보수도 용이합니다.

 

4. 핵심 코드 설명

위의 전략을 바탕으로 구현된 유틸리티 클래스들을 살펴보겠습니다.

4.1 HeapMemoryInfo 클래스

힙 메모리 정보를 담는 불변 객체입니다. 메모리 사용량을 안전하게 전달하기 위해 Value Object 패턴을 적용하였습니다.

 

package ##;

import lombok.Builder;
import lombok.Value;

/**
 * * JVM 힙 메모리 사용량 정보를 담는 불변 객체
 */
@Value
@Builder
public class HeapMemoryInfo {
    /**
     * 현재 사용 중인 힙 메모리 (bytes)
     */
    long used;

    /**
     * JVM이 사용할 수 있는 최대 힙 메모리 (bytes)
     * -1인 경우 무제한을 의미
     */
    long max;

    /**
     * JVM이 현재 할당한 힙 메모리 (bytes)
     */
    long committed;

    /**
     * 힙 메모리 초기 크기 (bytes)
     */
    long init;

    /**
     * 힙 메모리 사용률 (%)
     * max가 -1인 경우 0.0 반환
     */
    double usagePercentage;

    /**
     * 메가바이트 단위로 변환된 사용 메모리
     */
    public double getUsedInMB() {
        return used / (1024.0 * 1024.0);
    }

    /**
     * 메가바이트 단위로 변환된 최대 메모리
     */
    public double getMaxInMB() {
        return max != -1 ? max / (1024.0 * 1024.0) : 0.0;
    }

    /**
     * 메가바이트 단위로 변환된 할당 메모리
     */
    public double getCommittedInMB() {
        return committed / (1024.0 * 1024.0);
    }
}

 

4.2 MemoryInfoCollector 인터페이스

MemoryMXBean을 사용하여 현재 힙 메모리 사용량을 수집하는 인터페이스입니다. Singleton 패턴을 적용하여 MemoryMXBean 인스턴스를 재사용합니다.

 

package ##;

/**
 * 메모리 정보를 수집하는 전략 인터페이스
 * Strategy Pattern 을 활용하여 다양한 메모리 수집 방식 지원
 *
 */
public interface MemoryInfoCollector {
    HeapMemoryInfo collectHeapMemoryInfo();
}

 

 

4.3 MemoryInfoCollectorImpl 클래스

package ##;

import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import java.lang.management.MemoryUsage;
import lombok.extern.slf4j.Slf4j;

/**
 * MemoryMXBean을 사용하여 메모리 정보를 수집하는 구현체
 *
 * 참고 문서:
 * - https://docs.oracle.com/javase/8/docs/api/java/lang/management/MemoryMXBean.html
 * - https://docs.oracle.com/javase/8/docs/api/java/lang/management/MemoryUsage.html
 *
 */
@Slf4j
public class MemoryInfoCollectorImpl implements MemoryInfoCollector {

    private static final MemoryMXBean MEMORY_MX_BEAN = ManagementFactory.getMemoryMXBean();

    /**
     * 싱글톤 인스턴스 (Thread-safe)
     * MemoryMXBean 은 JVM 당 하나이므로 싱글톤으로 관리
     */
    private static final MemoryInfoCollectorImpl INSTANCE = new MemoryInfoCollectorImpl();

    private MemoryInfoCollectorImpl() {
        // Private constructor for singleton
    }

    public static MemoryInfoCollectorImpl getInstance() {
        return INSTANCE;
    }

    @Override
    public HeapMemoryInfo collectHeapMemoryInfo() {
        try {
            MemoryUsage heapUsage = MEMORY_MX_BEAN.getHeapMemoryUsage();

            long used = heapUsage.getUsed();
            long max = heapUsage.getMax();
            long committed = heapUsage.getCommitted();
            long init = heapUsage.getInit();

            // 사용률 계산 (max 가 -1인 경우는 무제한을 의미)
            double usagePercentage = max != -1
                ? (double) used / max * 100.0
                : 0.0;

            return HeapMemoryInfo.builder()
                .used(used)
                .max(max)
                .committed(committed)
                .init(init)
                .usagePercentage(usagePercentage)
                .build();

        } catch (Exception e) {
            log.error("Failed to collect heap memory information", e);
            // 예외 발생 시 기본값 반환
            return HeapMemoryInfo.builder()
                .used(0L)
                .max(0L)
                .committed(0L)
                .init(0L)
                .usagePercentage(0.0)
                .build();
        }
    }
}

 

4.4 MemoryLogger 클래스

 

수집된 메모리 정보를 로깅하는 클래스입니다. 메모리 크기를 바이트에서 메가바이트로 변환하여 가독성을 높입니다.

package ##;


import java.time.Duration;
import java.time.LocalDateTime;
import lombok.extern.slf4j.Slf4j;


/**
 * JVM 힙 메모리 사용량을 로깅하는 유틸리티 클래스
 * Facade Pattern 을 활용하여 복잡한 메모리 수집 로직을 단순화
 */
@Slf4j
public final class MemoryLogger {
    // 유틸리티 클래스이므로 인스턴스화 방지
    private MemoryLogger() {
        throw new AssertionError("Utility class should not be instantiated");
    }

    /**
     * 힙 메모리 사용량을 로깅합니다.
     *
     * @param context 컨텍스트 정보
     * @param phase 단계 정보
     * @param memoryInfo 메모리 정보 객체
     */
    public static void logHeapMemory(
        String context
        , String phase
        , HeapMemoryInfo memoryInfo) {
        String threadName = Thread.currentThread().getName();

        // 구조화된 로그 포맷 (INFO 레벨)
        log.info(
            "[{}] [MEMORY] [{}] [{}] Heap Memory - Used: {} MB / Max: {} MB / Committed: {} MB / Usage: {} %",
            threadName,
            context,
            phase,
            String.format("%.2f", memoryInfo.getUsedInMB()),
            String.format("%.2f", memoryInfo.getMaxInMB()),
            String.format("%.2f", memoryInfo.getCommittedInMB()),
            String.format("%.2f", memoryInfo.getUsagePercentage())
        );

        // 상세 정보는 DEBUG 레벨로
        log.debug(
            "[{}] [MEMORY] [{}] [{}] Heap Memory Details - Used: {} bytes, Max: {} bytes, Committed: {} bytes, Init: {} bytes",
            threadName,
            context,
            phase,
            memoryInfo.getUsed(),
            memoryInfo.getMax(),
            memoryInfo.getCommitted(),
            memoryInfo.getInit()
        );
    }

    /**
     * Step 시작 전 메모리 사용량을 로깅합니다.
     *
     * @param stepName Step 이름
     * @param memoryInfo 힙메모리 정보
     */
    public static void logBeforeStep(String stepName, HeapMemoryInfo memoryInfo) {
        logHeapMemory(stepName, "BEFORE_STEP", memoryInfo);
    }

    /**
     * Step 종료 후 메모리 사용량을 로깅합니다.
     *
     * @param stepName Step 이름
     * @param memoryInfo 메모리 정보 객체
     */
    public static void logAfterStep(String stepName, HeapMemoryInfo memoryInfo) {
        logHeapMemory(stepName, "AFTER_STEP", memoryInfo);
    }

    /**
     * Step 종료 후 메모리 사용량과 사용량 차이를 로깅합니다.
     *
     * @param context 컨텍스트 정보
     * @param endMemoryInfo 종료 시점의 메모리 정보
     * @param startTime 작업 시작 시간
     * @param endTime 작업 종료 시간
     * @param startMemoryInfo 작업 시작 시점의 메모리 정보 (메모리 사용량 차이 계산용)
     */
    public static void logAfterStep(
            String context,
            LocalDateTime startTime,
            LocalDateTime endTime,
            HeapMemoryInfo startMemoryInfo,
            HeapMemoryInfo endMemoryInfo) {
        
        // 종료 시점 메모리 정보 로깅
        logHeapMemory(context, "AFTER_STEP", endMemoryInfo);
        
        // 메모리 사용량 차이 계산 및 로깅
        logMemoryDelta(context, "AFTER_STEP", startTime, endTime, startMemoryInfo, endMemoryInfo);
    }

    /**
     * 메모리 사용량이 임계값을 초과하는 경우 경고 로그를 남깁니다.
     *
     * @param context 컨텍스트 정보
     * @param memoryInfo 메모리 정보 객체
     * @param phase 단계 정보
     * @param thresholdPercent 임계값 (퍼센트)
     */
    public static void logWithThreshold(
        String context
        , HeapMemoryInfo memoryInfo
        , String phase
        , double thresholdPercent) {

        if (memoryInfo.getUsagePercentage() > thresholdPercent) {
            log.warn(
                "[{}] [MEMORY] [{}] [{}] Heap Memory Usage Exceeded Threshold! Usage: {} % (Threshold: {} %)",
                Thread.currentThread().getName(),
                context,
                phase,
                String.format("%.2f", memoryInfo.getUsagePercentage()),
                thresholdPercent
            );
        }
    }

    /**
     * Step 작업 시작과 종료 시점 사이의 메모리 사용량 차이를 계산하고 로깅합니다.
     *
     * @param context 컨텍스트 정보
     * @param phase 단계 정보 (예: "AFTER_STEP")
     * @param startTime 작업 시작 시간
     * @param endTime 작업 종료 시간
     * @param startMemoryInfo 시작 시점의 메모리 정보
     * @param endMemoryInfo 종료 시점의 메모리 정보
     */
    public static void logMemoryDelta(
            String context,
            String phase,
            LocalDateTime startTime,
            LocalDateTime endTime,
            HeapMemoryInfo startMemoryInfo,
            HeapMemoryInfo endMemoryInfo) {
        
        String threadName = Thread.currentThread().getName();
        
        // 메모리 사용량 차이 계산 (bytes)
        long usedDelta = endMemoryInfo.getUsed() - startMemoryInfo.getUsed();
        long committedDelta = endMemoryInfo.getCommitted() - startMemoryInfo.getCommitted();
        
        // MB 단위로 변환
        double usedDeltaInMB = usedDelta / (1024.0 * 1024.0);
        double committedDeltaInMB = committedDelta / (1024.0 * 1024.0);
        
        // 메모리 사용량 차이 로깅
        log.info(
            "[{}] [MEMORY_DELTA] [{}] [{}] Memory Usage Increase During Step - Used: {} bytes ({} MB), Committed: {} bytes ({} MB)",
            threadName,
            context,
            phase,
            usedDelta,
            String.format("%.2f", usedDeltaInMB),
            committedDelta,
            String.format("%.2f", committedDeltaInMB)
        );

        // 실행 시간 대비 메모리 증가 비율 계산 (선택적)
        if (startTime != null && endTime != null) {
            long executionTimeMillis = Duration.between(startTime, endTime).toMillis();
            if (executionTimeMillis > 0) {
                // 초당 메모리 증가량 계산 (MB/s)
                double memoryIncreaseRate = (usedDeltaInMB / executionTimeMillis) * 1000.0;
                log.info(
                    "[{}] [MEMORY_DELTA] [{}] [{}] Memory Increase Rate: {} MB/s (Used Memory Delta: {} MB, Execution Time: {} ms)",
                    threadName,
                    context,
                    phase,
                    String.format("%.2f", memoryIncreaseRate),
                    String.format("%.2f", usedDeltaInMB),
                    executionTimeMillis
                );
            }
        }

        // 상세 정보는 DEBUG 레벨로
        if (log.isDebugEnabled()) {
            log.debug(
                "[{}] [MEMORY_DELTA] [{}] [{}] Memory Delta Details - Used Delta: {} bytes, Committed Delta: {} bytes, Max: {} bytes (unchanged)",
                threadName,
                context,
                phase,
                usedDelta,
                committedDelta,
                endMemoryInfo.getMax()
            );
        }
    }
}

 

4.5 MemoryLoggingStepListener 클래스

Spring Batch의 StepExecutionListener를 구현하여 Step의 시작과 종료 시점에 메모리 정보를 로깅합니다. Decorator 패턴을 적용하여 기존 코드와의 결합도를 낮췄습니다.

 

package ##;


import ##.HeapMemoryInfo;
import ##.MemoryInfoCollector;
import ##.MemoryInfoCollectorImpl;
import ##.MemoryLogger;
import java.time.LocalDateTime;
import lombok.Builder;
import lombok.Value;
import lombok.extern.slf4j.Slf4j;

import org.springframework.batch.core.BatchStatus;
import org.springframework.batch.core.ExitStatus;
import org.springframework.batch.core.StepExecution;
import org.springframework.batch.core.StepExecutionListener;
import org.springframework.stereotype.Component;


/**
 * 메모리 사용량 로깅 기능이 추가된 Step Listener
 * Decorator Pattern 을 활용하여 기존 StepListener 기능을 확장
 */
@Slf4j
@Component
public class MemoryLoggingStepListener implements StepExecutionListener {

    private static final MemoryInfoCollector MEMORY_INFO_COLLECTOR = MemoryInfoCollectorImpl.getInstance();

    // ThreadLocal 을 사용하여 각 스레드별로 시작 시점의 힙메모리 정보를 저장
    // 멀티스레드 환경에서도 안전하게 동작
    private static final ThreadLocal<MemoryContext> CONTEXT_HOLDER = new ThreadLocal<>();

    // 메모리 사용률 임계값 (퍼센트)
    private static final double MEMORY_WARNING_THRESHOLD = 80.0; // 80% 이상 시 경고

    @Override
    public void beforeStep(StepExecution stepExecution) {
        if (stepExecution.getStatus() == BatchStatus.STARTED) {
            String stepName = stepExecution.getStepName();
            String jobName = stepExecution.getJobExecution().getJobInstance().getJobName();
            String context = String.format("%s.%s", jobName, stepName);

            // 메모리 사용량 로깅
            HeapMemoryInfo startMemoryInfo = MEMORY_INFO_COLLECTOR.collectHeapMemoryInfo();
            MemoryLogger.logBeforeStep(context, startMemoryInfo);

            // 임계값 체크 및 경고
            MemoryLogger.logWithThreshold(context, startMemoryInfo,"BEFORE_STEP", MEMORY_WARNING_THRESHOLD);

            LocalDateTime startTime = stepExecution.getStartTime() != null
                ? stepExecution.getStartTime()
                : LocalDateTime.now();

            // ThreadLocal 에 컨텍스트 저장
            CONTEXT_HOLDER.set(MemoryContext.builder()
                .context(context)
                .startTime(startTime)
                .startMemoryInfo(startMemoryInfo)
                .build());
        }
    }

    @Override
    public ExitStatus afterStep(StepExecution stepExecution) {
        if (stepExecution.getStatus() == BatchStatus.COMPLETED) {
            MemoryContext context = CONTEXT_HOLDER.get();

            if (context != null) {
                // 힙메모리 사용량 로깅
                HeapMemoryInfo endMemoryInfo = MEMORY_INFO_COLLECTOR.collectHeapMemoryInfo();

                LocalDateTime endTime = stepExecution.getEndTime() != null
                    ? stepExecution.getEndTime()
                    : LocalDateTime.now();

                // 메모리 정보와 사용량 차이 로깅
                MemoryLogger.logAfterStep(
                    context.getContext(),
                    context.getStartTime(),
                    endTime,
                    context.getStartMemoryInfo(),
                    endMemoryInfo
                );

                // 임계값 체크 및 경고
                MemoryLogger.logWithThreshold(
                    context.getContext(),
                    endMemoryInfo,
                    "AFTER_STEP",
                    MEMORY_WARNING_THRESHOLD
                );
            } else {
                log.warn(
                    "[{}] [MEMORY] Context not found for step: {}",
                    Thread.currentThread().getName(),
                    stepExecution.getStepName()
                );
            }
        }
        
        // ThreadLocal 정리 (메모리 누수 방지)
        CONTEXT_HOLDER.remove();
        
        return stepExecution.getExitStatus();
    }

    @Value
    @Builder
    private static class MemoryContext {
        String context;
        LocalDateTime startTime;
        HeapMemoryInfo startMemoryInfo;
    }

}

 

이러한 구조를 통해 메모리 정보 수집과 로깅이 분리되어 유지보수성이 향상되며, 각 클래스의 책임이 명확해집니다.

 

5. 응용 예제

이러한 로깅 유틸리티의 구현을 다양한 방식으로 확장하여 활용할 수 있습니다.

 

GC 로거의 경우, GarbageCollectorMXBean을 활용하여 GC 발생 횟수와 소요 시간을 로깅할 수 있습니다. 이를 통해 GC 튜닝에 필요한 정보를 수집할 수 있습니다.

 

CPU 로거의 경우, OperatingSystemMXBean을 활용하여 CPU 사용량을 모니터링할 수 있습니다. 이를 통해 특정 작업이 CPU를 과도하게 사용하는지 파악할 수 있습니다.

 

실제 로깅 결과는 다음과 같은 형식으로 출력됩니다:

INFO  Before Step: dataProcessingStep - Heap Memory | Used: 512 MB, Max: 1024 MB, Committed: 768 MB, Init: 256 MB
INFO  After Step: dataProcessingStep - Heap Memory | Used: 520 MB, Max: 1024 MB, Committed: 768 MB, Init: 256 MB

이를 통해 각 Step의 메모리 사용량 변화를 쉽게 파악할 수 있으며, 메모리 사용 패턴을 분석하여 최적화에 활용할 수 있습니다.

 

 

마무리하며

 

이번 글에서는 JVM 리소스를 로깅하기 위한 유틸리티 클래스를 설계하고 구현하는 과정을 단계적으로 정리해 보았습니다. 단순히 메모리 사용량을 출력하는 것을 넘어서, 어떤 정보를 언제, 어떤 형태로 남길 것인가를 고민하는 과정이 중요하다는 점에 주목했습니다. 

 

특히 MemoryMXBean 기반 접근 방식은 구현 복잡도를 낮추면서도 충분히 신뢰할 수 있는 정보를 제공하며, 로깅 책임을 명확히 분리함으로써 확장성과 유지보수성 또한 확보할 수 있습니다. 이러한 구조는 메모리 로깅뿐 아니라 GC, CPU 등 다른 JVM 리소스 로깅으로도 자연스럽게 확장할 수 있습니다.

 

운영 환경에서 발생하는 성능 이슈는 대부분 사후 분석으로 이어지기 때문에, 이처럼 일관된 형태의 리소스 로그를 미리 남겨두는 것은 문제 원인을 빠르게 좁히는 데 큰 도움이 됩니다. 이번 구현이 JVM 리소스 가시성을 높이고, 안정적인 시스템 운영에 도움이 되리라 생각합니다.