개요

Java의 가비지 컬렉터(GC)는 자동 메모리 관리라는 강력한 장점을 제공하지만, 잘못된 설정이나 이해 부족은 심각한 성능 문제로 이어질 수 있습니다. 특히 대규모 프로덕션 환경에서 GC 튜닝은 애플리케이션 성능의 핵심 요소입니다. 이 글에서는 최신 GC 알고리즘의 특성과 실전 튜닝 방법을 다룹니다.

핵심 개념

Java 21 기준으로 선택할 수 있는 주요 GC와 특성입니다.

  • G1 GC (기본): 대부분의 워크로드에 적합한 범용 GC. Region 기반으로 동작하며, 예측 가능한 pause time을 목표로 합니다.
  • ZGC: 초저지연(sub-millisecond pause time) GC. TB 단위의 힙에서도 10ms 이하의 pause를 보장합니다.
  • Shenandoah: Red Hat이 개발한 저지연 GC. ZGC와 유사한 목표를 가지며, OpenJDK에서 사용 가능합니다.
  • Generational ZGC (Java 21+): ZGC에 세대별 수집을 추가하여 처리량까지 개선한 최신 GC입니다.
  • 힙 메모리 구조: Young Generation(Eden + Survivor)과 Old Generation으로 구분되며, 객체의 생존 기간에 따라 이동합니다.

실전 예제

GC 모니터링과 튜닝 설정 예제입니다.

// JVM 옵션 예시: G1 GC 튜닝
// java -Xms4g -Xmx4g
//      -XX:+UseG1GC
//      -XX:MaxGCPauseMillis=200
//      -XX:G1HeapRegionSize=8m
//      -XX:InitiatingHeapOccupancyPercent=45
//      -XX:+ParallelRefProcEnabled
//      -Xlog:gc*:file=gc.log:time,uptime,level,tags
//      -jar myapp.jar

// JVM 옵션 예시: Generational ZGC (Java 21+)
// java -Xms8g -Xmx8g
//      -XX:+UseZGC
//      -XX:+ZGenerational
//      -XX:SoftMaxHeapSize=6g
//      -Xlog:gc*:file=gc.log:time,uptime,level,tags
//      -jar myapp.jar

// 프로그래밍 방식으로 GC 정보 확인
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import java.lang.management.GarbageCollectorMXBean;

public class GCMonitor {
    public static void printGCInfo() {
        MemoryMXBean memoryBean = ManagementFactory.getMemoryMXBean();
        System.out.printf("Heap 사용량: %d MB / %d MB%n",
            memoryBean.getHeapMemoryUsage().getUsed() / 1024 / 1024,
            memoryBean.getHeapMemoryUsage().getMax() / 1024 / 1024);

        for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
            System.out.printf("GC [%s]: 횟수=%d, 시간=%dms%n",
                gc.getName(), gc.getCollectionCount(), gc.getCollectionTime());
        }
    }
}

메모리 누수를 방지하는 코딩 패턴입니다.

// 메모리 누수 패턴과 해결 방법

// 1. 컬렉션에서 참조 해제 누락
public class Cache<K, V> {
    // 나쁜 예: HashMap은 명시적 삭제 없이 계속 증가
    private final Map<K, V> cache = new HashMap<>();

    // 좋은 예: WeakHashMap 또는 크기 제한 캐시
    private final Map<K, V> cache = new LinkedHashMap<>(100, 0.75f, true) {
        @Override
        protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
            return size() > 1000; // 최대 1000개
        }
    };
}

// 2. 리스너/콜백 등록 해제 누락
public class EventManager {
    private final List<WeakReference<EventListener>> listeners = new ArrayList<>();

    public void addEventListener(EventListener listener) {
        listeners.add(new WeakReference<>(listener));
    }

    public void fireEvent(Event event) {
        listeners.removeIf(ref -> ref.get() == null); // 죽은 참조 정리
        listeners.stream()
            .map(WeakReference::get)
            .filter(Objects::nonNull)
            .forEach(l -> l.onEvent(event));
    }
}

// 3. try-with-resources로 리소스 해제 보장
public List<String> readLines(Path path) throws IOException {
    try (var reader = Files.newBufferedReader(path);
         var lines = reader.lines()) {
        return lines.filter(s -> !s.isBlank()).toList();
    }
}

활용 팁

  • Generational ZGC(Java 21+)는 대부분의 시나리오에서 최적의 선택입니다. 저지연과 높은 처리량을 동시에 제공합니다.
  • -Xms-Xmx를 동일하게 설정하면 힙 리사이징 비용을 제거할 수 있습니다.
  • GC 로그는 반드시 활성화하세요. -Xlog:gc* 옵션으로 상세 로그를 수집하고, GCViewer나 GCEasy로 분석합니다.
  • jmap -histojcmd GC.heap_info를 활용해 메모리 사용 현황을 실시간으로 확인하세요.
  • 메모리 누수가 의심되면 Heap Dump(-XX:+HeapDumpOnOutOfMemoryError)를 활성화하고, Eclipse MAT이나 VisualVM으로 분석합니다.

마무리

Java의 GC는 지속적으로 발전하고 있으며, 특히 ZGC의 Generational 모드는 거의 모든 워크로드에서 우수한 성능을 보여줍니다. 하지만 GC 튜닝보다 중요한 것은 메모리 효율적인 코드를 작성하는 것입니다. 불필요한 객체 생성을 줄이고, 적절한 자료구조를 선택하며, 리소스를 확실히 해제하는 습관이 최고의 GC 튜닝입니다.