dev.syw

벤치마킹, 프로파일러, GC 튜닝, 컬렉션/문자열 최적화로 병목을 측정하고 개선하는 방법을 익힌다.

성능 최적화와 프로파일링

"성능이 느리다"는 말은 흔하지만, 그 원인을 정확히 짚는 개발자는 드뭅니다. 추측으로 코드를 최적화하다 보면 실제 병목은 그대로인 채 코드 가독성만 낮아지는 경우가 많습니다. 1강에서 JVM 내부 구조와 GC의 동작 방식을 이해했다면, 이번 레슨에서는 그 지식을 바탕으로 실제 병목을 측정하고 개선하는 실무 기술을 다룹니다.

Java 성능 최적화의 핵심은 단 하나입니다. 추측하지 말고 측정하세요. 그리고 측정 결과가 가리키는 곳만 고치세요. 이 레슨에서는 JMH 마이크로 벤치마킹, JFR/async-profiler를 이용한 핫스팟 탐색, GC 로그 분석, 컬렉션·문자열·객체 생성 최적화, 그리고 스트림과 루프의 실제 트레이드오프를 다룹니다.

학습 목표

  • **JMH(Java Microbenchmark Harness)**를 사용해 마이크로 벤치마크를 올바르게 설계하고 함정을 피할 수 있다.
  • **JFR(Java Flight Recorder)**와 async-profiler로 CPU/메모리 핫스팟을 찾는 방법을 이해한다.
  • GC 로그를 분석하고 힙 크기와 GC 파라미터를 상황에 맞게 조정할 수 있다.
  • 컬렉션 초기 용량 지정, 오토박싱 비용 회피, StringBuilder 활용 등 코드 수준 최적화를 적용할 수 있다.
  • 스트림 API와 전통 루프 사이에서 성능과 가독성을 균형 있게 선택할 수 있다.

성능 측정의 원칙: 추측 대신 측정

코드 성능을 개선하기 전에 반드시 지켜야 할 규칙이 있습니다. 먼저 측정하고, 그다음 최적화합니다. JIT 컴파일러, GC, CPU 분기 예측기 등 JVM 내부 메커니즘이 워낙 복잡하기 때문에 "이 코드가 빠를 것 같다"는 직관이 실제와 다를 때가 많습니다.

잘못된 측정도 문제입니다. 다음은 JMH 없이 직접 시간을 재는 코드의 전형적인 실수 사례입니다.

// ❌ JIT 워밍업 없이 측정하면 첫 번째 실행이 압도적으로 느림
long start = System.nanoTime();
for (int i = 0; i < 1_000_000; i++) {
    String s = Integer.toString(i);
}
long elapsed = System.nanoTime() - start;
System.out.println(elapsed + " ns"); // 신뢰할 수 없는 결과

JVM은 처음 몇 천 번의 메서드 호출 이후 JIT 컴파일을 수행합니다. 워밍업 없이 측정한 결과는 실제 운영 환경의 성능을 전혀 반영하지 않습니다. 또한 JIT가 루프 내부의 죽은 코드를 제거(Dead Code Elimination)해 버리면 측정 자체가 무의미해집니다.

⚠️ 주의 System.currentTimeMillis()는 밀리초 단위이므로 마이크로벤치마크에 사용하면 안 됩니다. System.nanoTime()도 단독으로는 JIT 효과를 배제할 수 없습니다. 마이크로벤치마크에는 반드시 JMH를 사용하세요.

JMH 마이크로 벤치마크와 함정

**JMH(Java Microbenchmark Harness)**는 OpenJDK 팀이 만든 공식 마이크로벤치마크 프레임워크입니다. JIT 워밍업, Dead Code Elimination 방지, 통계 처리 등을 자동으로 처리해 줍니다.

Maven 의존성 추가

<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-core</artifactId>
    <version>1.37</version>
</dependency>
<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-generator-annprocess</artifactId>
    <version>1.37</version>
    <scope>provided</scope>
</dependency>

기본 벤치마크 작성

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

@BenchmarkMode(Mode.AverageTime)          // 평균 실행 시간 측정
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
@Warmup(iterations = 5, time = 1)        // 워밍업 5회
@Measurement(iterations = 10, time = 1)  // 측정 10회
@Fork(2)                                  // JVM 인스턴스 2개로 실행
public class StringConcatBenchmark {

    private static final int SIZE = 1000;

    // ❌ 문자열 + 연산 반복: 매 반복마다 새 String 객체 생성
    @Benchmark
    public String concatPlus() {
        String result = "";
        for (int i = 0; i < SIZE; i++) {
            result += i;
        }
        return result; // Blackhole 역할: DCE 방지를 위해 반드시 반환
    }

    // ✅ StringBuilder: 단일 가변 버퍼에 추가
    @Benchmark
    public String concatStringBuilder() {
        StringBuilder sb = new StringBuilder(SIZE * 4); // 초기 용량 추정
        for (int i = 0; i < SIZE; i++) {
            sb.append(i);
        }
        return sb.toString();
    }

    public static void main(String[] args) throws Exception {
        new Runner(new OptionsBuilder()
            .include(StringConcatBenchmark.class.getSimpleName())
            .build()).run();
    }
}

JMH 핵심 함정 세 가지

Dead Code Elimination(DCE): JIT는 결과가 사용되지 않는 코드를 제거합니다. 벤치마크 메서드는 반드시 계산 결과를 반환하거나, Blackhole 파라미터에 소비(consume)해야 합니다.

@Benchmark
public void badBenchmark() {
    Math.sqrt(1234.56); // ❌ JIT가 통째로 제거할 수 있음
}

@Benchmark
public double goodBenchmark(Blackhole bh) {
    double result = Math.sqrt(1234.56);
    bh.consume(result); // ✅ DCE 방지
    return result;      // 또는 반환
}

Constant Folding: 상수로만 이루어진 연산은 컴파일 타임에 미리 계산됩니다. 벤치마크 입력값은 @State 필드로 주입하여 런타임에 결정되도록 합니다.

@State(Scope.Benchmark)
public class MyState {
    public int x = 10; // ✅ 런타임 값 — Constant Folding 방지
}

Loop Unrolling: JIT는 루프를 풀어서 최적화합니다. 루프 반복 횟수를 상수로 두면 실제 성능이 과대 측정될 수 있습니다.

💡 TIP JMH 결과를 해석할 때는 Score ± Error를 반드시 확인하세요. 오차 범위가 Score보다 크다면 측정이 불안정한 것입니다. @Fork 값을 늘리거나 시스템 부하를 줄인 후 재측정하세요.

프로파일링 도구: JFR과 async-profiler

마이크로벤치마크는 특정 코드 조각의 성능을 비교하는 데 적합합니다. 반면 프로파일러는 "실행 중인 애플리케이션 전체에서 어느 메서드가 CPU와 메모리를 가장 많이 쓰는가"를 알려줍니다. 두 도구는 목적이 다릅니다.

JFR(Java Flight Recorder)

JFR은 JDK 11부터 무료로 사용할 수 있는 저오버헤드 프로파일링 도구입니다. 프로덕션 환경에서 약 1~3% 오버헤드로 CPU, 힙, GC, 스레드, I/O 이벤트를 동시에 기록할 수 있습니다.

# 실행 중인 JVM에 JFR 연결 (PID는 jps 명령으로 확인)
jcmd <PID> JFR.start duration=60s filename=recording.jfr

# 또는 JVM 시작 시 자동 활성화
java -XX:StartFlightRecording=duration=120s,filename=app.jfr \
     -XX:FlightRecorderOptions=stackdepth=128 \
     -jar myapp.jar

기록된 .jfr 파일은 **JDK Mission Control(JMC)**에서 열어 플레임 그래프, 메모리 할당 프로파일, GC 타임라인을 시각적으로 분석할 수 있습니다.

async-profiler

async-profiler는 JVM의 safepoint 편향 없이 실제 실행 중인 스레드를 샘플링하는 도구입니다. Safepoint 기반 프로파일러는 JVM이 멈추는 순간만 샘플링하므로 핫스팟을 왜곡할 수 있습니다. async-profiler는 OS 신호를 사용해 이 문제를 피합니다.

# async-profiler 다운로드 후 실행 (Linux/macOS)
./profiler.sh -d 30 -f flamegraph.html <PID>

# Wall-clock 프로파일링 (I/O 대기 포함)
./profiler.sh -e wall -d 30 -f wall_flamegraph.html <PID>

생성된 HTML 파일을 브라우저에서 열면 인터랙티브 플레임 그래프를 볼 수 있습니다. 플레임 그래프에서 넓고 평평한 박스가 CPU를 많이 소비하는 메서드입니다.

도구오버헤드환경주요 분석
JFR~1-3%개발/운영 모두 가능CPU, 힙, GC, I/O 통합
async-profiler~2-5%개발/스테이징 권장CPU 플레임 그래프, 메모리 할당
VisualVM높음(~10%+)개발 전용GUI 편의성, 빠른 탐색

💡 TIP IntelliJ IDEA Ultimate의 내장 프로파일러도 async-profiler 엔진을 사용합니다. 별도 설치 없이 Run 구성에서 프로파일링을 활성화하면 바로 플레임 그래프를 볼 수 있습니다.

GC 로그 분석과 힙 튜닝

1강에서 GC 동작 방식을 배웠다면, 이번에는 실제 로그를 읽고 파라미터를 조정하는 방법에 집중합니다.

GC 로그 활성화

java -Xms512m -Xmx2g \
     -XX:+UseG1GC \
     -Xlog:gc*:file=gc.log:time,uptime,level,tags:filecount=5,filesize=20m \
     -jar myapp.jar

GC 로그 읽기

[2.345s][info][gc] GC(12) Pause Young (Normal) (G1 Evacuation Pause)
                   14M->8M(256M) 4.231ms

이 한 줄에서 읽을 수 있는 정보는 다음과 같습니다.

  • 2.345s: 애플리케이션 시작 후 경과 시간
  • GC(12): 12번째 GC 이벤트
  • Pause Young: Young Gen만 수집한 Minor GC
  • 14M->8M(256M): 수집 전 14MB → 수집 후 8MB, 전체 힙 256MB
  • 4.231ms: STW(Stop-The-World) 시간

힙 크기 튜닝 기준

증상원인 추정조치
Full GC가 잦고 STW가 1초 이상힙이 너무 작음-Xmx 증가
GC 이후에도 힙 사용률이 80% 이상 유지메모리 누수 또는 실제 부족힙 프로파일링 후 판단
Young GC가 초당 10회 이상단명 객체 과다 생성코드 레벨 최적화 필요
GC 시간이 전체의 5% 미만정상 범위튜닝 불필요
# G1GC 권장 파라미터 (지연 시간 목표 200ms)
java -XX:+UseG1GC \
     -XX:MaxGCPauseMillis=200 \
     -XX:G1HeapRegionSize=16m \
     -XX:G1NewSizePercent=20 \
     -XX:G1MaxNewSizePercent=40 \
     -jar myapp.jar

⚠️ 주의 -Xms-Xmx를 동일하게 설정하면 힙 크기 조정 오버헤드를 없앨 수 있지만, 컨테이너 환경에서는 OOM Killer에 의해 프로세스가 종료될 위험이 있습니다. 컨테이너에서는 -XX:MaxRAMPercentage=75.0 방식이 더 안전합니다.

컬렉션 선택, 초기 용량, 오토박싱

컬렉션 성능 특성 비교

컬렉션 API 사용법은 입문편에서 다뤘습니다. 여기서는 성능 관점에서 어떤 컬렉션을 선택해야 하는지에 집중합니다.

자료구조랜덤 접근삽입/삭제(중간)검색메모리
ArrayListO(1)O(n)O(n)낮음
LinkedListO(n)O(1) (노드 참조 보유 시)O(n)높음 (노드당 포인터 2개)
HashMapN/AO(1) 평균O(1) 평균중간
TreeMapN/AO(log n)O(log n)중간
ArrayDequeN/AO(1) 양 끝O(n)낮음

LinkedList는 중간 삽입이 빠르다고 알려져 있지만, 실제로는 캐시 지역성(cache locality) 때문에 대부분의 경우 ArrayList보다 느립니다. CPU 캐시는 연속된 메모리를 선호하는데, LinkedList는 노드가 힙 전체에 흩어져 있어 캐시 미스가 많이 발생합니다.

초기 용량 지정으로 리사이징 비용 제거

ArrayListHashMap은 기본 용량이 가득 차면 내부 배열을 복사해 크기를 늘립니다. 원소 수를 미리 알 수 있다면 초기 용량을 지정해 이 비용을 없앨 수 있습니다.

// ❌ 기본 용량(10)으로 시작 — 1만 건 삽입 시 여러 번 리사이징
List<String> list = new ArrayList<>();

// ✅ 예상 크기로 초기화
List<String> list = new ArrayList<>(10_000);

// HashMap의 경우: 예상 크기 / 0.75 + 1 (loadFactor 고려)
// 1만 건 저장 예정이라면
Map<String, Integer> map = new HashMap<>(13_334); // 10000 / 0.75 ≈ 13334

오토박싱 비용

intInteger, longLong 사이의 자동 변환은 코드를 간결하게 만들지만, 빈번한 변환은 성능에 영향을 줍니다. 각 박싱은 힙에 객체를 생성하고, GC 부담을 늘립니다.

// ❌ 반복 오토박싱: 루프마다 Integer 객체 생성
Map<String, Long> counter = new HashMap<>();
for (String key : keys) {
    counter.merge(key, 1L, Long::sum); // merge 내부에서 박싱/언박싱 반복
}

// ✅ 오토박싱을 피하려면 Eclipse Collections의 MutableObjectLongMap 또는
//    직접 AtomicLong 사용
Map<String, long[]> counter = new HashMap<>();
for (String key : keys) {
    counter.computeIfAbsent(key, k -> new long[1])[0]++;
}

수백만 건 이상을 처리하는 핫 경로에서는 Eclipse CollectionsHPPC(High Performance Primitive Collections) 같은 라이브러리의 원시 타입 특화 컬렉션을 사용하면 오토박싱 비용을 완전히 제거할 수 있습니다.

💡 TIP -128에서 127 범위의 Integer는 JVM이 캐싱하므로 박싱 시 새 객체 할당이 없어 GC 부담이 없습니다(메서드 호출과 범위 검사 같은 박싱 동작 자체는 남습니다). 그러나 이 범위를 벗어난 숫자를 자주 박싱한다면 프로파일러로 할당 비율을 확인해 보세요.

문자열 처리, StringBuilder, 불필요한 객체 생성

문자열 연결 최적화

Java 컴파일러(JDK 9 이상)는 + 연산자로 이어진 단순 표현식invokedynamic을 사용한 효율적인 코드로 변환합니다. 그러나 루프 내부의 반복 연결은 여전히 주의가 필요합니다.

// ✅ 단일 표현식 연결 — 컴파일러가 최적화
String msg = "Hello, " + name + "! You are " + age + " years old.";

// ❌ 루프 내 반복 연결 — 매 반복 시 새 String 생성
String result = "";
for (String item : largeList) {
    result += item + ", "; // O(n²) 복잡도
}

// ✅ StringBuilder 사용
StringBuilder sb = new StringBuilder(largeList.size() * 16);
for (String item : largeList) {
    sb.append(item).append(", ");
}
String result = sb.toString();

// ✅ 또는 String.join / Collectors.joining 사용 (가독성 우선 시)
String result = String.join(", ", largeList);

불필요한 객체 생성 줄이기

// ❌ 루프마다 Pattern 컴파일 — 매우 비쌈
for (String s : data) {
    if (s.matches("\\d+")) { // 내부에서 Pattern.compile 호출
        process(s);
    }
}

// ✅ Pattern을 상수로 미리 컴파일
private static final Pattern DIGIT_PATTERN = Pattern.compile("\\d+");

for (String s : data) {
    if (DIGIT_PATTERN.matcher(s).matches()) { // ✅ 재사용
        process(s);
    }
}
// ❌ DateTimeFormatter도 매번 생성하면 비용이 큼
for (LocalDate date : dates) {
    String formatted = DateTimeFormatter.ofPattern("yyyy-MM-dd").format(date);
}

// ✅ 상수로 캐싱 (DateTimeFormatter는 스레드 안전)
private static final DateTimeFormatter DATE_FMT =
    DateTimeFormatter.ofPattern("yyyy-MM-dd");

for (LocalDate date : dates) {
    String formatted = DATE_FMT.format(date);
}

💡 TIP String.intern()으로 동일한 내용의 문자열을 공유할 수 있지만, 남용하면 String Pool이 과부하될 수 있습니다. 대신 반복적으로 생성되는 키 문자열은 정적 상수로 정의하는 것이 더 간단하고 안전합니다.

스트림 vs 루프: 실제 성능 트레이드오프

스트림 API는 가독성과 함수형 스타일을 제공하지만, 성능 측면에서는 무조건 루프보다 빠르거나 느리다고 단정할 수 없습니다. JMH로 측정한 실제 결과를 기반으로 판단해야 합니다.

스트림이 루프보다 느릴 수 있는 경우

// 간단한 합산: 루프가 보통 더 빠름
int[] arr = new int[1_000_000];

// ✅ for-loop (캐시 친화적, 오버헤드 없음)
long sum = 0;
for (int v : arr) sum += v;

// 스트림 (람다 호출 오버헤드, 박싱 가능성)
long sumStream = Arrays.stream(arr).sum(); // IntStream이므로 박싱 없음, 비슷한 성능
// ❌ Stream<Integer>로 박싱 합산: 람다 박스 비교·Integer 캐스팅 오버헤드
List<Integer> list = ...;
int sum = list.stream()
              .reduce(0, Integer::sum); // Integer 박싱/언박싱 반복

// ✅ IntStream으로 언박싱 후 기본형 합산: 박싱 비용 없음
int sumPrimitive = list.stream()
                       .mapToInt(Integer::intValue) // int로 한 번만 언박싱
                       .sum();

// 💡 더 나은 방법: 애초에 int[]/IntStream으로 데이터를 보유해 List<Integer> 자체를 피함
int[] arr = ...;
int sumBest = Arrays.stream(arr).sum(); // 박싱·언박싱 모두 불필요

스트림이 실용적인 경우

// 복잡한 파이프라인 — 스트림이 훨씬 읽기 쉬움
Map<String, Long> countByDept = employees.stream()
    .filter(e -> e.getSalary() > 50_000)
    .collect(Collectors.groupingBy(Employee::getDept, Collectors.counting()));

// 병렬 스트림 — I/O 없는 CPU 바운드 작업에서 코어 수 비례 향상
long count = largeList.parallelStream()
    .filter(this::isExpensiveCheck)
    .count();

스트림과 루프 선택 기준

상황권장 선택이유
단순 합산·최댓값 등 집계루프 또는 IntStream오버헤드 최소
복잡한 변환·그룹핑 파이프라인스트림가독성, 유지보수
CPU 바운드 병렬 처리parallelStreamFork/Join 자동 활용
I/O 포함 파이프라인스트림 지양 (CompletableFuture 검토)블로킹으로 스레드 낭비
수백만 건 이상 + 성능 임계JMH로 직접 측정 후 결정추측 금지

⚠️ 주의 parallelStream()은 공용 ForkJoinPool을 사용합니다. 웹 서버처럼 이미 스레드 풀이 포화된 환경에서 무분별하게 사용하면 스레드 기아(starvation)가 발생할 수 있습니다. 병렬 스트림은 독립적인 CPU 바운드 배치 작업에서만 신중하게 사용하세요.

요약

  • 추측 대신 측정: 성능 문제는 반드시 프로파일러나 JMH로 먼저 확인한 후 최적화합니다.
  • JMH: 마이크로벤치마크에서 Dead Code Elimination, Constant Folding, 워밍업 부재 등의 함정을 피하려면 JMH를 사용해야 합니다.
  • JFR/async-profiler: 운영 환경 프로파일링은 JFR(저오버헤드), 개발 환경 플레임 그래프는 async-profiler를 활용합니다.
  • GC 튜닝: GC 로그를 읽어 STW 시간과 GC 빈도를 파악하고, 증상에 맞는 힙 파라미터를 조정합니다.
  • 컬렉션/문자열 최적화: 초기 용량 지정, 오토박싱 회피, Pattern·DateTimeFormatter 사전 컴파일, StringBuilder 활용으로 핫 경로의 객체 생성 비용을 줄입니다.
  • 스트림 vs 루프: 스트림이 항상 빠르거나 느린 것이 아니라, 용도와 데이터 크기에 따라 JMH로 측정한 결과를 바탕으로 선택합니다.

연습문제

  1. 다음 코드는 1~N까지의 합을 구하는 세 가지 방식입니다. JMH 벤치마크를 설계해 성능을 비교하고, Dead Code Elimination이 발생하지 않도록 올바르게 작성하세요. (N=100_000 기준)

    • for-loop
    • IntStream.rangeClosed(1, N).sum()
    • LongStream.rangeClosed(1, N).sum()

    힌트 벤치마크 메서드는 반드시 결과를 반환하거나 Blackhole에 소비해야 합니다.

  2. 아래 코드에는 두 가지 성능 문제가 있습니다. 찾아서 수정하세요.

    public List<String> processItems(List<String> items) {
        List<String> result = new ArrayList<>();
        for (String item : items) {
            if (item.matches("[A-Z][a-z]+")) {
                result.add(item.toUpperCase());
            }
        }
        return result;
    }
    

    힌트 루프 내에서 반복 생성되는 비싼 객체와, 결과 리스트의 초기 용량을 생각해 보세요.

  3. 다음 GC 로그 조각을 보고 (a) GC 종류, (b) 힙 사용량 변화, (c) STW 시간을 읽어 내고, 이 애플리케이션에서 GC 관련 성능 문제가 있는지 판단하세요.

    [5.123s][info][gc] GC(47) Pause Young (Normal) (G1 Evacuation Pause) 1800M->1200M(2048M) 320.451ms
    [5.445s][info][gc] GC(48) Pause Young (Normal) (G1 Evacuation Pause) 1600M->1100M(2048M) 298.771ms
    [5.745s][info][gc] GC(49) Pause Full (System.gc()) 1500M->900M(2048M) 2100.332ms
    

    힌트 STW 320ms, Full GC 2100ms, 그리고 GC 간격이 300ms 수준인 것에 주목하세요.

  4. 10만 건의 String 키와 int 값을 저장하는 HashMap을 만들고, 이후 모든 값을 합산해 반환하는 메서드를 작성하세요. 오토박싱 비용을 최소화하고, 초기 용량을 올바르게 설정하세요.

    힌트 Integer 대신 int[] 값 래퍼나 computeIfAbsent를 활용하고, 초기 용량은 (int)(expectedSize / 0.75) + 1로 계산합니다.

💡 연습문제 풀이

불러오는 중…

함께 보면 좋은 자료

댓글 0

Java 심화” 강좌에 대한 댓글입니다.

댓글을 작성하려면 로그인이 필요합니다.