PHpullh

JAVA · 심층 가이드

Java 성능/JVM 완전 정리

힙 구조와 GC 선택, JMH 벤치마크, JFR과 덤프 분석을 묶어 Java 애플리케이션의 병목을 추측이 아니라 측정으로 찾는 17개 주제입니다.

주제 17개 · 예제 코드 포함 · 최종 수정 2026-08-30 · 작성 pullh 편집팀

JVM 성능 이야기가 어려운 이유는 코드와 실제 실행 사이에 층이 하나 더 있기 때문입니다. 같은 메서드도 처음 몇천 번은 인터프리터로 돌다가 JIT가 컴파일하고, 인라이닝과 이스케이프 분석을 거치면서 소스에 적힌 객체 할당이 아예 사라지기도 합니다. 그래서 '이 코드가 느려 보인다'는 직관은 자주 틀립니다. 워밍업 없이 System.nanoTime()으로 잰 숫자, 한 번만 돌린 반복문, 컨테이너 밖에서 측정한 메모리는 모두 신뢰하기 어렵습니다.

따라서 측정 도구를 먼저 손에 쥐는 편이 낫습니다. JMH 벤치마크 실전으로 워밍업과 반복을 프레임워크에 맡기는 법을 익히고, 애플리케이션 전체 관점은 JFR과 JMC로 봅니다. 여기서 나온 신호에 따라 갈라집니다. 정지 시간이 문제면 GC 튜닝 기초 — ZGC & G1, 메모리가 계속 늘면 힙 덤프 분석메모리 누수 탐지, 스레드가 멈춰 있으면 스레드 덤프 분석입니다. JIT 최적화와 이스케이프 분석은 이 모든 숫자를 해석할 때 필요한 배경 지식입니다.

JMH를 쓸 때 초보자가 가장 많이 만드는 가짜 결과는 결과값을 쓰지 않는 벤치마크입니다. 계산 결과를 반환하지도, Blackhole에 넣지도 않으면 JIT가 그 계산을 통째로 제거해 버려서 '엄청나게 빠른' 숫자가 나옵니다. GC 쪽에서는 컨테이너 환경이 흔한 함정입니다. 힙 크기를 지정하지 않으면 JVM이 컨테이너 메모리 한도의 일부만 힙으로 잡는데, 여기에 네이티브 메모리와 메타스페이스가 더해져 힙에 여유가 있는데도 OOM Killer에 종료되는 상황이 나옵니다.

01JVM 메모리 모델 & GC 이해

JVM의 Heap 구조, GC 알고리즘, 메모리 튜닝의 핵심을 이해합니다.

Java code

// JVM 메모리 구조 이해를 위한 코드

public class JvmMemory {

    // static 변수 — Method Area (Metaspace)
    private static final int MAX_SIZE = 1000;
    private static int instanceCount = 0;

    // 인스턴스 변수 — Heap
    private final int id;
    private byte[] data;

    public JvmMemory(int size) {
        this.id   = ++instanceCount;
        this.data = new byte[size];  // Heap 할당
    }

    public static void main(String[] args) {
        // JVM 옵션 예시:
        // -Xms512m      : 초기 Heap 크기
        // -Xmx2g        : 최대 Heap 크기
        // -XX:+UseG1GC  : G1 GC 사용
        // -XX:+UseZGC   : ZGC (Java 15+, 저지연)
        // -verbose:gc   : GC 로그 출력

        // Runtime으로 메모리 정보 확인
        Runtime rt = Runtime.getRuntime();
        System.out.println("가용 프로세서: " + rt.availableProcessors());
        System.out.printf("총 메모리: %,d bytes%n", rt.totalMemory());
        System.out.printf("최대 메모리: %,d bytes%n", rt.maxMemory());
        System.out.printf("사용 가능: %,d bytes%n", rt.freeMemory());

        // 메모리 누수 예시 — static 컬렉션에 계속 추가
        // static List<Object> leaky = new ArrayList<>();
        // while(true) leaky.add(new byte[1024]);  // OOM!

        // 약한 참조 — GC가 회수 가능
        var weakRef = new java.lang.ref.WeakReference<>(new byte[1024]);
        System.gc(); // 힌트 (보장 안 됨)
        System.out.println("약한 참조 살아있나: " + (weakRef.get() != null));

        // String intern — String Pool 활용
        String s1 = new String("hello").intern();
        String s2 = "hello";
        System.out.println(s1 == s2); // true (같은 Pool 객체)

        // 효율적인 객체 생성
        // Bad:  for(int i=0;i<1000;i++) new Integer(i);
        // Good: Integer.valueOf(i) — 캐시 활용 (-128~127)
    }
}

// GC 알고리즘 비교 (JVM 옵션):
// Serial GC    : -XX:+UseSerialGC     (단일 스레드, 소형 앱)
// Parallel GC  : -XX:+UseParallelGC   (처리량 우선)
// G1 GC        : -XX:+UseG1GC         (Java 9+ 기본, 균형)
// ZGC          : -XX:+UseZGC          (Java 15+, 초저지연 <1ms)
// Shenandoah   : -XX:+UseShenandoahGC (RedHat, 저지연)
알아두면 좋은 점

Java 21 기준 ZGC가 가장 낮은 Stop-the-World 시간(<1ms)을 제공합니다. G1 GC는 대부분의 서버 애플리케이션에 적합한 기본값입니다.

자주 하는 실수

System.gc()는 GC를 강제하지 않고 JVM에 "힌트"만 줍니다. 프로덕션 코드에서는 사용하지 마세요.

02JMH — Java 마이크로벤치마크

JMH(Java Microbenchmark Harness)로 신뢰할 수 있는 성능 측정을 합니다.

Java code

import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import java.util.*;
import java.util.concurrent.TimeUnit;

@State(Scope.Benchmark)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
@Fork(1)
public class StringConcatBenchmark {

    @Param({"10", "100", "1000"})
    int count;

    private String[] words;

    @Setup
    public void setup() {
        words = new String[count];
        for (int i = 0; i < count; i++) {
            words[i] = "word" + i;
        }
    }

    @Benchmark
    public String stringPlus() {
        String result = "";
        for (String w : words) result += w;
        return result;
    }

    @Benchmark
    public String stringBuilder() {
        var sb = new StringBuilder(count * 6);
        for (String w : words) sb.append(w);
        return sb.toString();
    }

    @Benchmark
    public String stringJoin() {
        return String.join("", words);
    }

    @Benchmark
    public String streamCollect() {
        return Arrays.stream(words)
            .collect(java.util.stream.Collectors.joining());
    }

    // 실행 방법:
    // mvn package
    // java -jar target/benchmarks.jar
    // 또는
    // public static void main(String[] args) throws Exception {
    //     new Runner(new OptionsBuilder()
    //         .include(StringConcatBenchmark.class.getSimpleName())
    //         .build()).run();
    // }
}
알아두면 좋은 점

JVM의 JIT 컴파일러 때문에 단순 루프 타이밍은 신뢰할 수 없습니다. JMH는 워밍업, 데드코드 제거 방지, 통계 처리를 모두 해줍니다.

자주 하는 실수

JMH 없이 System.currentTimeMillis()로 측정하면 JVM JIT 최적화로 결과가 완전히 다를 수 있습니다. 특히 첫 실행과 반복 실행의 차이가 큽니다.

03Project Panama &amp; Foreign Function API (Java 22)

Java 22의 Foreign Function & Memory API로 네이티브 라이브러리를 안전하게 호출합니다.

Java code

// Java 22 — Foreign Function & Memory API (FFM)
import java.lang.foreign.*;
import java.lang.invoke.*;

public class PanamaDemo {
    public static void main(String[] args) throws Throwable {
        // 시스템 라이브러리 링킹
        Linker linker = Linker.nativeLinker();
        SymbolLookup stdlib = linker.defaultLookup();

        // strlen 함수 가져오기
        MethodHandle strlen = linker.downcallHandle(
            stdlib.find("strlen").orElseThrow(),
            FunctionDescriptor.of(
                ValueLayout.JAVA_LONG,   // 반환 타입: long
                ValueLayout.ADDRESS      // 인자 타입: pointer
            )
        );

        // Native 메모리 할당 (Arena로 수명 관리)
        try (Arena arena = Arena.ofConfined()) {
            MemorySegment str =
                arena.allocateFrom("Hello, Panama!");

            long len = (long) strlen.invoke(str);
            System.out.println("strlen = " + len); // 14
        } // Arena 닫히면 자동 메모리 해제

        // StructLayout — C 구조체 매핑
        MemoryLayout pointLayout = MemoryLayout.structLayout(
            ValueLayout.JAVA_INT.withName("x"),
            ValueLayout.JAVA_INT.withName("y")
        );

        VarHandle xHandle = pointLayout.varHandle(
            MemoryLayout.PathElement.groupElement("x"));

        try (Arena arena = Arena.ofConfined()) {
            MemorySegment point = arena.allocate(pointLayout);
            xHandle.set(point, 0L, 42);
            System.out.println("x = " + xHandle.get(point, 0L));
        }

        System.out.println("FFM API — 네이티브 접근 성공!");
    }
}
// 컴파일: javac --enable-preview --release 22 PanamaDemo.java
// 실행:   java  --enable-preview PanamaDemo
알아두면 좋은 점

FFM API는 JNI보다 훨씬 안전하고 직관적입니다. Arena로 메모리 수명을 명시적으로 관리하므로 메모리 누수를 방지합니다.

자주 하는 실수

FFM API는 Java 22에서 정식 기능이 됐습니다. 이전 버전에서는 --enable-preview 플래그가 필요합니다. 버전에 따라 API가 변경될 수 있습니다.

04JEP 445 Unnamed Classes

파일 하나로 실행 가능한 간단한 Java 프로그램

Java code

<span class="cm">// JEP 445 Unnamed Classes 예제
// data/prompts.js의 생성 프롬프트로 상세 코드 생성 가능</span>
fun main() { println("JEP 445 Unnamed Classes") }
알아두면 좋은 점

JAVA 공식 문서를 함께 참고하세요.

자주 하는 실수

자주 발생하는 실수에 주의하세요.

05GC 튜닝 기초 — ZGC &amp; G1

JVM GC(가비지 컬렉터) 선택과 기본 튜닝. G1 GC(기본)와 ZGC의 특성을 이해하고, 애플리케이션 특성에 맞는 GC를 선택하여 지연시간과 처리량을 최적화합니다.

Java code

// === GC 선택 가이드 ===

// 1. G1 GC (Java 9+ 기본) — 균형 잡힌 범용 GC
// java -XX:+UseG1GC -Xms4g -Xmx4g
//   -XX:MaxGCPauseMillis=200      # 목표 일시 정지 시간
//   -XX:G1HeapRegionSize=16m      # 리전 크기 (1~32MB)
//   -XX:InitiatingHeapOccupancyPercent=45  # 혼합 GC 시작 임계값
//   -Xlog:gc*:gc.log:time,tags    # GC 로그
//   -jar app.jar

// 2. ZGC (Java 15+) — 초저지연 (<1ms 일시 정지)
// java -XX:+UseZGC
//   -Xms4g -Xmx4g
//   -XX:+ZGenerational             # Java 21: 세대별 ZGC (권장)
//   -Xlog:gc*:gc.log:time,tags
//   -jar app.jar

// 3. 프로그래밍으로 GC 정보 조회
import java.lang.management.*;
import java.util.List;

public class GCMonitor {
    public static void printGCInfo() {
        List<GarbageCollectorMXBean> gcBeans =
            ManagementFactory.getGarbageCollectorMXBeans();

        for (GarbageCollectorMXBean gc : gcBeans) {
            System.out.printf("GC: %-20s 횟수: %d, 총 시간: %dms%n",
                gc.getName(), gc.getCollectionCount(),
                gc.getCollectionTime());
        }

        MemoryMXBean mem = ManagementFactory.getMemoryMXBean();
        System.out.printf("힙 사용: %dMB / %dMB%n",
            mem.getHeapMemoryUsage().getUsed() / 1024 / 1024,
            mem.getHeapMemoryUsage().getMax() / 1024 / 1024);
    }

    public static void main(String[] args) {
        printGCInfo();
        // 할당 부하 테스트
        for (int i = 0; i < 100_000; i++) {
            byte[] tmp = new byte[1024];
        }
        printGCInfo();
    }
}
알아두면 좋은 점

일반적으로 G1 GC로 시작하세요. 지연시간이 중요한 서비스(금융, 게임)는 ZGC를, 배치 처리 등 처리량이 중요하면 -XX:+UseParallelGC를 고려하세요.

자주 하는 실수

-Xms-Xmx를 동일하게 설정하면 힙 리사이징 오버헤드를 방지합니다. GC 로그 없이 튜닝하면 추측에 의존하게 되므로 반드시 -Xlog:gc*를 활성화하세요.

06JMH 벤치마크 실전

JMH로 컬렉션 성능을 비교하는 실전 벤치마크입니다.

Java code

// JMH 벤치마크 실전 예시

// @BenchmarkMode(Mode.Throughput)
// @OutputTimeUnit(TimeUnit.MILLISECONDS)
// @State(Scope.Benchmark)
// @Warmup(iterations = 3)
// @Measurement(iterations = 5)
// @Fork(1)
// public class CollectionBenchmark {
//     @Param({"100", "10000", "1000000"})
//     int size;
//
//     List<Integer> arrayList;
//     LinkedList<Integer> linkedList;
//
//     @Setup
//     public void setup() {
//         arrayList = new ArrayList<>(IntStream.range(0, size)
//             .boxed().toList());
//         linkedList = new LinkedList<>(arrayList);
//     }
//
//     @Benchmark
//     public int arrayListGet() { return arrayList.get(size / 2); }
//
//     @Benchmark
//     public int linkedListGet() { return linkedList.get(size / 2); }
//
//     @Benchmark
//     public void arrayListIterate(Blackhole bh) {
//         for (int i : arrayList) bh.consume(i);
//     }
//
//     @Benchmark
//     public void linkedListIterate(Blackhole bh) {
//         for (int i : linkedList) bh.consume(i);
//     }
// }

class Main {
    public static void main(String[] args) {
        System.out.println("JMH 핵심 어노테이션:");
        System.out.println("@Benchmark: 벤치마크 메서드");
        System.out.println("@State: 상태 스코프 (Benchmark, Thread)");
        System.out.println("@Setup/@TearDown: 초기화/정리");
        System.out.println("@Param: 파라미터화된 벤치마크");
        System.out.println("Blackhole: 데드코드 제거 방지");
    }
}
알아두면 좋은 점

Blackhole.consume()으로 JIT가 결과를 사용하지 않는 코드를 제거하는 것을 방지합니다. 벤치마크 결과의 정확성에 필수입니다.

자주 하는 실수

@Setup(Level.Invocation)은 매 호출마다 실행되어 측정에 포함될 수 있습니다. 대부분 Level.Trial이 적합합니다.

07JFR과 JMC

Java Flight Recorder로 운영 환경의 성능 데이터를 수집하고 분석합니다.

Java code

// JFR — Java Flight Recorder (JDK 11+ 무료)

// 1. 명령줄로 JFR 시작
// java -XX:StartFlightRecording=duration=60s,filename=app.jfr MyApp

// 2. 실행 중 JFR 제어
// jcmd <pid> JFR.start name=profile duration=30s
// jcmd <pid> JFR.dump name=profile filename=dump.jfr
// jcmd <pid> JFR.stop name=profile

// 3. 프로그래밍 방식
import jdk.jfr.*;

@Label("사용자 요청")
@Category({"Application", "Request"})
@StackTrace(false)
class RequestEvent extends Event {
    @Label("경로") String path;
    @Label("응답시간(ms)") long responseTime;
    @Label("상태코드") int statusCode;
}

public class JFRDemo {
    static void handleRequest(String path) {
        RequestEvent event = new RequestEvent();
        event.begin();

        // 요청 처리 시뮬레이션
        long start = System.currentTimeMillis();
        try { Thread.sleep((long)(Math.random() * 100)); }
        catch (InterruptedException e) {}

        event.path = path;
        event.responseTime = System.currentTimeMillis() - start;
        event.statusCode = 200;
        event.commit(); // JFR에 기록
    }

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            handleRequest("/api/users/" + i);
        }
        System.out.println("JFR 이벤트 기록 완료");
        System.out.println("JMC(JDK Mission Control)로 .jfr 파일 분석");
    }
}
알아두면 좋은 점

JFR은 오버헤드가 1% 미만이라 운영 환경에서도 안전하게 사용할 수 있습니다. 성능 문제 진단의 첫 번째 도구입니다.

자주 하는 실수

JFR 파일이 커지면 분석이 어렵습니다. duration이나 maxsize를 적절히 설정하세요.

08GC 분석과 튜닝

GC 로그를 분석하고 적절한 가비지 컬렉터를 선택합니다.

Java code

// GC 옵션 비교
// -XX:+UseG1GC          — G1 (기본, Java 9+)
// -XX:+UseZGC            — ZGC (초저지연, Java 15+)
// -XX:+UseShenandoahGC   — Shenandoah (저지연)

// GC 로그 활성화
// java -Xlog:gc*:file=gc.log:time,level,tags MyApp

// 주요 GC 튜닝 파라미터
// -Xms4g -Xmx4g          — 힙 크기 (최소=최대 권장)
// -XX:MaxGCPauseMillis=200 — G1 목표 일시정지
// -XX:G1HeapRegionSize=16m — G1 리전 크기

public class GCAnalysis {
    public static void main(String[] args) {
        Runtime rt = Runtime.getRuntime();

        System.out.println("=== JVM 메모리 정보 ===");
        System.out.printf("최대 힙: %dMB%n", rt.maxMemory() / 1024 / 1024);
        System.out.printf("전체 힙: %dMB%n", rt.totalMemory() / 1024 / 1024);
        System.out.printf("사용 가능: %dMB%n", rt.freeMemory() / 1024 / 1024);

        // GC 정보
        java.lang.management.ManagementFactory.getGarbageCollectorMXBeans()
            .forEach(gc -> System.out.printf("GC: %s, 횟수: %d, 시간: %dms%n",
                gc.getName(), gc.getCollectionCount(), gc.getCollectionTime()));

        // 메모리 풀
        java.lang.management.ManagementFactory.getMemoryPoolMXBeans()
            .forEach(pool -> {
                var usage = pool.getUsage();
                if (usage != null && usage.getMax() > 0) {
                    System.out.printf("풀: %s, 사용: %dMB/%dMB%n",
                        pool.getName(),
                        usage.getUsed() / 1024 / 1024,
                        usage.getMax() / 1024 / 1024);
                }
            });
    }
}
알아두면 좋은 점

ZGC는 힙 크기와 관계없이 10ms 이하의 일시정지를 보장합니다. 지연 시간이 중요한 서비스에서는 ZGC를 고려하세요.

자주 하는 실수

-Xms-Xmx를 다르게 설정하면 힙 확장 시 GC가 추가 발생합니다. 운영 환경에서는 같은 값으로 설정하세요.

09힙 덤프 분석

힙 덤프를 생성하고 메모리 사용을 분석하는 방법입니다.

Java code

// 힙 덤프 생성 방법

// 1. OOM 시 자동 생성
// java -XX:+HeapDumpOnOutOfMemoryError
//      -XX:HeapDumpPath=/tmp/heap.hprof MyApp

// 2. 수동 생성
// jcmd <pid> GC.heap_dump /tmp/heap.hprof
// jmap -dump:format=b,file=/tmp/heap.hprof <pid>

// 3. 프로그래밍 방식
import com.sun.management.HotSpotDiagnosticMXBean;
import java.lang.management.ManagementFactory;

public class HeapDumpAnalysis {
    static void dumpHeap(String path, boolean live) throws Exception {
        HotSpotDiagnosticMXBean bean = ManagementFactory
            .getPlatformMXBean(HotSpotDiagnosticMXBean.class);
        bean.dumpHeap(path, live); // live=true: GC 후 덤프
    }

    public static void main(String[] args) throws Exception {
        // 의도적 메모리 사용
        java.util.List<byte[]> leak = new java.util.ArrayList<>();
        for (int i = 0; i < 100; i++) {
            leak.add(new byte[1024 * 1024]); // 1MB씩
        }

        System.out.println("메모리 사용: " +
            (Runtime.getRuntime().totalMemory() -
             Runtime.getRuntime().freeMemory()) / 1024 / 1024 + "MB");

        // 분석 도구:
        // - Eclipse MAT (Memory Analyzer Tool)
        // - VisualVM
        // - JDK Mission Control
        System.out.println("분석 도구: Eclipse MAT, VisualVM, JMC");
        System.out.println("MAT의 Leak Suspects 리포트가 가장 유용합니다.");
    }
}
알아두면 좋은 점

-XX:+HeapDumpOnOutOfMemoryError는 운영 환경에서 필수 옵션입니다. OOM 발생 시 원인 분석의 유일한 단서가 됩니다.

자주 하는 실수

힙 덤프는 힙 크기만큼 디스크를 사용합니다. 4GB 힙이면 4GB+ 파일이 생성되므로 디스크 여유 공간을 확인하세요.

10스레드 덤프 분석

스레드 덤프로 데드락, 병목, 스레드 상태를 진단합니다.

Java code

// 스레드 덤프 생성
// jcmd <pid> Thread.print
// jstack <pid>
// kill -3 <pid> (Unix)

import java.lang.management.*;

public class ThreadDumpAnalysis {
    public static void main(String[] args) throws Exception {
        // 데드락 시뮬레이션용 잠금 객체
        Object lock1 = new Object();
        Object lock2 = new Object();

        Thread t1 = new Thread(() -> {
            synchronized (lock1) {
                try { Thread.sleep(100); } catch (InterruptedException e) {}
                synchronized (lock2) { System.out.println("T1 완료"); }
            }
        }, "Thread-A");

        Thread t2 = new Thread(() -> {
            synchronized (lock2) {
                try { Thread.sleep(100); } catch (InterruptedException e) {}
                synchronized (lock1) { System.out.println("T2 완료"); }
            }
        }, "Thread-B");

        t1.start();
        t2.start();

        Thread.sleep(500);

        // 프로그래밍 방식으로 스레드 정보 확인
        ThreadMXBean tmx = ManagementFactory.getThreadMXBean();

        // 데드락 감지
        long[] deadlocked = tmx.findDeadlockedThreads();
        if (deadlocked != null) {
            System.out.println("데드락 감지!");
            for (ThreadInfo info : tmx.getThreadInfo(deadlocked, true, true)) {
                System.out.println(info);
            }
        }

        // 전체 스레드 상태
        for (ThreadInfo info : tmx.dumpAllThreads(true, true)) {
            System.out.printf("%s [%s]%n", info.getThreadName(), info.getThreadState());
        }
    }
}
알아두면 좋은 점

스레드 덤프를 3~5초 간격으로 3회 이상 수집하면 동일 위치에서 멈춘 스레드를 찾아 병목을 진단할 수 있습니다.

자주 하는 실수

BLOCKED 상태 스레드가 많으면 잠금 경합이 심한 것입니다. synchronizedReentrantLock이나 ConcurrentHashMap으로 대체를 고려하세요.

11캐싱 (Caffeine)

Caffeine 라이브러리로 고성능 로컬 캐시를 구현합니다.

Java code

// Caffeine 의존성: com.github.ben-manes.caffeine:caffeine:3.1+

// import com.github.ben-manes.caffeine.cache.*;

// Caffeine 캐시 설정 (의사코드)
// Cache<String, User> cache = Caffeine.newBuilder()
//     .maximumSize(10_000)              // 최대 항목 수
//     .expireAfterWrite(Duration.ofMinutes(5))  // 쓰기 후 만료
//     .expireAfterAccess(Duration.ofMinutes(1)) // 접근 후 만료
//     .recordStats()                    // 통계 기록
//     .build();

// 동기 로딩 캐시
// LoadingCache<String, User> loadingCache = Caffeine.newBuilder()
//     .maximumSize(10_000)
//     .expireAfterWrite(Duration.ofMinutes(5))
//     .build(key -> userRepository.findById(key)); // 캐시 미스 시 로딩

// 비동기 로딩 캐시
// AsyncLoadingCache<String, User> asyncCache = Caffeine.newBuilder()
//     .maximumSize(10_000)
//     .buildAsync(key -> userRepository.findByIdAsync(key));

// 간단한 캐시 구현
import java.util.*;
import java.util.concurrent.*;

class SimpleCache<K, V> {
    private final Map<K, V> store = new ConcurrentHashMap<>();
    private final Map<K, Long> expiry = new ConcurrentHashMap<>();
    private final long ttlMs;

    SimpleCache(long ttlMs) { this.ttlMs = ttlMs; }

    void put(K key, V value) {
        store.put(key, value);
        expiry.put(key, System.currentTimeMillis() + ttlMs);
    }

    Optional<V> get(K key) {
        Long exp = expiry.get(key);
        if (exp == null || System.currentTimeMillis() > exp) {
            store.remove(key);
            expiry.remove(key);
            return Optional.empty();
        }
        return Optional.ofNullable(store.get(key));
    }
}

class Main {
    public static void main(String[] args) {
        SimpleCache<String, String> cache = new SimpleCache<>(5000);
        cache.put("key", "value");
        System.out.println(cache.get("key")); // Optional[value]
    }
}
알아두면 좋은 점

Caffeine은 W-TinyLFU 알고리즘으로 높은 적중률을 제공합니다. 로컬 캐시로는 Caffeine이 최고 성능입니다.

자주 하는 실수

캐시 크기를 너무 크게 설정하면 GC 부담이 늘어납니다. 힙의 10-20% 이내로 유지하고 모니터링하세요.

12커넥션 풀 튜닝

데이터베이스 커넥션 풀의 최적 크기와 모니터링 전략입니다.

Java code

// 커넥션 풀 최적화 전략

public class ConnectionPoolTuning {
    public static void main(String[] args) {
        int cpuCores = Runtime.getRuntime().availableProcessors();

        // 최적 풀 크기 공식 (PostgreSQL 권장)
        // connections = (core_count * 2) + effective_spindle_count
        int optimalSize = cpuCores * 2 + 1;
        System.out.printf("CPU 코어: %d, 최적 풀 크기: %d%n", cpuCores, optimalSize);

        // HikariCP 권장 설정
        System.out.println("\n=== HikariCP 권장 설정 ===");
        System.out.println("maximumPoolSize: " + optimalSize);
        System.out.println("minimumIdle: " + optimalSize); // 고정 크기 권장
        System.out.println("connectionTimeout: 30000ms");
        System.out.println("idleTimeout: 600000ms (10분)");
        System.out.println("maxLifetime: 1800000ms (30분)");
        System.out.println("leakDetectionThreshold: 60000ms (1분)");

        // 모니터링 지표
        System.out.println("\n=== 모니터링 지표 ===");
        System.out.println("활성 커넥션 수 (active)");
        System.out.println("유휴 커넥션 수 (idle)");
        System.out.println("대기 스레드 수 (pending)");
        System.out.println("커넥션 획득 시간 (acquisition time)");
        System.out.println("커넥션 사용 시간 (usage time)");
        System.out.println("타임아웃 발생 횟수 (timeout count)");

        // 리틀의 법칙
        double avgResponseTime = 0.1; // 100ms
        double requestRate = 50; // 초당 50 요청
        double requiredConnections = avgResponseTime * requestRate;
        System.out.printf("\n리틀의 법칙: %.0f req/s * %.1fs = %.0f 커넥션 필요%n",
            requestRate, avgResponseTime, requiredConnections);
    }
}
알아두면 좋은 점

leakDetectionThreshold를 설정하면 반환하지 않은 커넥션을 감지합니다. 개발 환경에서 1분, 운영에서 5분이 적당합니다.

자주 하는 실수

커넥션 풀을 100으로 설정하는 것은 거의 항상 잘못입니다. 데이터베이스 측 최대 연결 수와 맞춰야 하며, 보통 10-20이 최적입니다.

13JIT 최적화와 이스케이프 분석

JIT 컴파일러의 최적화 기법과 이스케이프 분석을 이해합니다.

Java code

// JIT 최적화 확인 옵션
// -XX:+PrintCompilation        — 컴파일 로그
// -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining — 인라인 로그

public class JITOptimization {
    // 1. 인라이닝 — 작은 메서드 호출을 제거
    static int add(int a, int b) { return a + b; }

    // 2. 이스케이프 분석 — 힙 할당을 스택으로
    static long sumPoints() {
        long sum = 0;
        for (int i = 0; i < 1_000_000; i++) {
            // JIT가 Point가 메서드 밖으로 나가지 않음을 감지
            // -> 힙 대신 스택에 할당 (스칼라 치환)
            var p = new int[]{i, i * 2}; // 이스케이프하지 않음
            sum += p[0] + p[1];
        }
        return sum;
    }

    // 3. 루프 최적화 — 언롤링, 벡터화
    static int arraySum(int[] arr) {
        int sum = 0;
        for (int v : arr) sum += v; // JIT가 SIMD 벡터화 가능
        return sum;
    }

    // 4. 데드 코드 제거
    static void deadCode() {
        int result = 0;
        for (int i = 0; i < 1000; i++) {
            result += i; // result를 사용하지 않으면 전체 루프 제거
        }
        // JIT가 결과가 사용되지 않음을 감지 -> 루프 제거
    }

    public static void main(String[] args) {
        // 워밍업 — JIT 컴파일 유도
        for (int i = 0; i < 10_000; i++) {
            sumPoints();
        }
        System.out.println("JIT 최적화 완료");
        System.out.println("합계: " + sumPoints());
    }
}
알아두면 좋은 점

이스케이프 분석은 객체가 메서드 밖으로 나가지 않으면 힙 할당을 생략합니다. 짧은 수명의 객체 생성을 두려워하지 마세요.

자주 하는 실수

벤치마크에서 결과를 사용하지 않으면 JIT가 코드를 제거합니다. JMH의 Blackhole로 이를 방지해야 합니다.

14문자열 최적화

문자열 처리의 성능을 개선하는 다양한 기법입니다.

Java code

public class StringOptimization {
    public static void main(String[] args) {
        // 1. 문자열 연결: + vs StringBuilder
        // Java 9+에서 +는 invokedynamic으로 최적화
        // 루프에서는 여전히 StringBuilder가 유리
        StringBuilder sb = new StringBuilder(1000);
        for (int i = 0; i < 1000; i++) {
            sb.append("item").append(i).append(", ");
        }

        // 2. String.intern() — 중복 문자열 제거
        // 주의: intern() 테이블은 네이티브 메모리 사용
        String s1 = new String("hello").intern();
        String s2 = "hello";
        System.out.println(s1 == s2); // true

        // 3. compact strings (Java 9+)
        // 내부적으로 Latin1은 byte[], 유니코드는 UTF-16
        // 메모리 자동 절약 (별도 설정 불필요)

        // 4. 정규식 캐싱
        // Pattern은 한 번만 컴파일
        var pattern = java.util.regex.Pattern.compile("\\d+");
        for (int i = 0; i < 1000; i++) {
            pattern.matcher("abc123").find(); // 재사용
        }

        // 5. String.format vs StringBuilder
        // format()은 느림, 성능 중요 시 StringBuilder
        String fast = new StringBuilder()
            .append("이름: ").append("홍길동")
            .append(", 나이: ").append(30)
            .toString();

        System.out.println("최적화 완료: " + fast);
    }
}
알아두면 좋은 점

Java 9의 Compact Strings 덕분에 ASCII 문자열은 메모리를 절반만 사용합니다. 별도 설정 없이 자동 적용됩니다.

자주 하는 실수

String.intern()을 과도하게 사용하면 네이티브 메모리가 증가하고 GC 성능이 저하됩니다. 중복이 매우 많은 경우에만 사용하세요.

15컬렉션 사이징 최적화

컬렉션 초기 용량 설정으로 불필요한 재할당을 방지합니다.

Java code

import java.util.*;

public class CollectionSizing {
    public static void main(String[] args) {
        int expectedSize = 1000;

        // ArrayList — 기본 용량 10, 1.5배씩 증가
        // 1000개: 10->15->22->33->...->1076 (약 17번 재할당)
        List<String> badList = new ArrayList<>();       // 기본 10
        List<String> goodList = new ArrayList<>(expectedSize); // 1000

        // HashMap — 기본 용량 16, 로드팩터 0.75
        // 1000개 저장: 1000/0.75 = 1334 이상 필요
        Map<String, String> badMap = new HashMap<>();
        Map<String, String> goodMap = new HashMap<>(
            (int)(expectedSize / 0.75) + 1); // 1334

        // HashSet — HashMap 기반
        Set<String> goodSet = new HashSet<>(
            (int)(expectedSize / 0.75) + 1);

        // 성능 비교
        long start = System.nanoTime();
        for (int i = 0; i < expectedSize; i++) {
            badList.add("item" + i);
        }
        long badTime = System.nanoTime() - start;

        start = System.nanoTime();
        for (int i = 0; i < expectedSize; i++) {
            goodList.add("item" + i);
        }
        long goodTime = System.nanoTime() - start;

        System.out.printf("기본 용량: %dns%n", badTime);
        System.out.printf("최적 용량: %dns%n", goodTime);
        System.out.printf("개선: %.1f%%%n",
            (1.0 - (double)goodTime / badTime) * 100);
    }
}
알아두면 좋은 점

GuavaMaps.newHashMapWithExpectedSize(n)은 로드 팩터를 고려한 최적 초기 용량을 계산해줍니다.

자주 하는 실수

new HashMap(1000)은 1000개를 저장할 수 있다는 의미가 아닙니다. 로드 팩터(0.75)를 고려하면 750개에서 리해싱됩니다.

16메모리 누수 탐지

일반적인 Java 메모리 누수 패턴과 탐지 방법입니다.

Java code

import java.util.*;

public class MemoryLeakDetection {
    // 누수 패턴 1: static 컬렉션에 계속 추가
    static final List<byte[]> leakyCache = new ArrayList<>();

    // 누수 패턴 2: 리스너 미해제
    static final List<Runnable> listeners = new ArrayList<>();

    // 누수 패턴 3: 내부 클래스의 외부 참조
    class InnerClass {
        // 암묵적으로 OuterClass 참조 유지
        void doWork() { }
    }

    // 해결: WeakReference 사용
    static class SafeCache<K, V> {
        private final Map<K, java.lang.ref.WeakReference<V>> cache =
            new HashMap<>();

        void put(K key, V value) {
            cache.put(key, new java.lang.ref.WeakReference<>(value));
        }

        Optional<V> get(K key) {
            var ref = cache.get(key);
            if (ref == null) return Optional.empty();
            V value = ref.get();
            if (value == null) {
                cache.remove(key); // 정리
                return Optional.empty();
            }
            return Optional.of(value);
        }
    }

    public static void main(String[] args) {
        System.out.println("메모리 누수 탐지 방법:");
        System.out.println("1. -XX:+HeapDumpOnOutOfMemoryError");
        System.out.println("2. JFR로 객체 할당 추적");
        System.out.println("3. Eclipse MAT의 Leak Suspects");
        System.out.println("4. VisualVM 실시간 모니터링");

        System.out.println("\n주요 누수 패턴:");
        System.out.println("- static 컬렉션 무한 증가");
        System.out.println("- 이벤트 리스너 미해제");
        System.out.println("- 내부 클래스의 외부 참조");
        System.out.println("- 커넥션/스트림 미닫기");
        System.out.println("- ThreadLocal 미정리");
    }
}
알아두면 좋은 점

WeakHashMap은 키가 GC되면 자동으로 엔트리가 제거됩니다. 캐시에서 메모리 누수를 방지하는 데 유용합니다.

자주 하는 실수

ThreadLocal 값을 remove()하지 않으면 스레드 풀에서 메모리 누수가 발생합니다. try-finally로 반드시 정리하세요.

17성능 최적화 체크리스트

Java 애플리케이션 성능 최적화의 종합 체크리스트입니다.

Java code

public class PerformanceChecklist {
    public static void main(String[] args) {
        System.out.println("=== Java 성능 최적화 체크리스트 ===");

        System.out.println("\n[JVM 설정]");
        System.out.println("- Xms = Xmx (힙 크기 고정)");
        System.out.println("- 적절한 GC 선택 (G1/ZGC)");
        System.out.println("- HeapDumpOnOutOfMemoryError 활성화");
        System.out.println("- JFR 상시 활성화 (1% 미만 오버헤드)");

        System.out.println("\n[코드 레벨]");
        System.out.println("- 컬렉션 초기 용량 지정");
        System.out.println("- 루프에서 오토박싱 방지");
        System.out.println("- 불필요한 객체 생성 최소화");
        System.out.println("- Stream은 간결성 우선, 핫 경로는 for-loop");
        System.out.println("- Pattern 컴파일 캐싱");

        System.out.println("\n[동시성]");
        System.out.println("- 스레드 풀 크기: CPU*2+1 (CPU 바운드)");
        System.out.println("- I/O 바운드: Virtual Thread 사용");
        System.out.println("- ConcurrentHashMap > synchronizedMap");
        System.out.println("- Lock 경합 최소화");

        System.out.println("\n[I/O]");
        System.out.println("- 커넥션 풀 적정 크기");
        System.out.println("- 배치 처리 (JDBC batch)");
        System.out.println("- 로컬 캐시 (Caffeine)");
        System.out.println("- 비동기 I/O 활용");

        System.out.println("\n[측정]");
        System.out.println("- JMH로 마이크로벤치마크");
        System.out.println("- JFR/JMC로 프로파일링");
        System.out.println("- Micrometer + Prometheus 모니터링");
        System.out.println("- 변경 전후 반드시 측정");
    }
}
알아두면 좋은 점

최적화의 첫 번째 규칙: 측정하기 전에 최적화하지 마세요. 두 번째 규칙: 아직 측정하지 마세요. 진짜 병목을 찾은 후에 최적화하세요.

자주 하는 실수

성급한 최적화는 가독성을 해칩니다. 프로파일러로 확인된 핫스팟(상위 20%)에만 최적화를 적용하세요. 80/20 법칙을 기억하세요.

정리하며

  • 마이크로 성능 비교는 JMH로만 하고 결과값은 반환하거나 Blackhole에 넘깁니다
  • 튜닝 전에 JFR로 프로파일을 남겨 병목이 GC인지 락인지 I/O인지부터 가릅니다
  • 지연시간이 목표면 ZGC, 처리량 위주면 G1을 기준선으로 두고 비교 측정합니다
  • 컨테이너 배포에서는 힙 비율과 네이티브 메모리를 함께 계산해 OOM 종료를 막습니다

더 깊이 들어가고 싶다면 Java 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.