벤치마킹, 프로파일러, 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 사용법은 입문편에서 다뤘습니다. 여기서는 성능 관점에서 어떤 컬렉션을 선택해야 하는지에 집중합니다.
| 자료구조 | 랜덤 접근 | 삽입/삭제(중간) | 검색 | 메모리 |
|---|---|---|---|---|
ArrayList | O(1) | O(n) | O(n) | 낮음 |
LinkedList | O(n) | O(1) (노드 참조 보유 시) | O(n) | 높음 (노드당 포인터 2개) |
HashMap | N/A | O(1) 평균 | O(1) 평균 | 중간 |
TreeMap | N/A | O(log n) | O(log n) | 중간 |
ArrayDeque | N/A | O(1) 양 끝 | O(n) | 낮음 |
LinkedList는 중간 삽입이 빠르다고 알려져 있지만, 실제로는 캐시 지역성(cache locality) 때문에 대부분의 경우 ArrayList보다 느립니다. CPU 캐시는 연속된 메모리를 선호하는데, LinkedList는 노드가 힙 전체에 흩어져 있어 캐시 미스가 많이 발생합니다.
초기 용량 지정으로 리사이징 비용 제거
ArrayList와 HashMap은 기본 용량이 가득 차면 내부 배열을 복사해 크기를 늘립니다. 원소 수를 미리 알 수 있다면 초기 용량을 지정해 이 비용을 없앨 수 있습니다.
// ❌ 기본 용량(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
오토박싱 비용
int와 Integer, long과 Long 사이의 자동 변환은 코드를 간결하게 만들지만, 빈번한 변환은 성능에 영향을 줍니다. 각 박싱은 힙에 객체를 생성하고, 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 Collections나 HPPC(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 바운드 병렬 처리 | parallelStream | Fork/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~N까지의 합을 구하는 세 가지 방식입니다. JMH 벤치마크를 설계해 성능을 비교하고, Dead Code Elimination이 발생하지 않도록 올바르게 작성하세요. (N=100_000 기준)
- for-loop
- IntStream.rangeClosed(1, N).sum()
- LongStream.rangeClosed(1, N).sum()
힌트 벤치마크 메서드는 반드시 결과를 반환하거나
Blackhole에 소비해야 합니다. -
아래 코드에는 두 가지 성능 문제가 있습니다. 찾아서 수정하세요.
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; }힌트 루프 내에서 반복 생성되는 비싼 객체와, 결과 리스트의 초기 용량을 생각해 보세요.
-
다음 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 수준인 것에 주목하세요.
-
10만 건의
String키와int값을 저장하는HashMap을 만들고, 이후 모든 값을 합산해 반환하는 메서드를 작성하세요. 오토박싱 비용을 최소화하고, 초기 용량을 올바르게 설정하세요.힌트
Integer대신int[]값 래퍼나computeIfAbsent를 활용하고, 초기 용량은(int)(expectedSize / 0.75) + 1로 계산합니다.
💡 연습문제 풀이
불러오는 중…
댓글 0
“Java 심화” 강좌에 대한 댓글입니다.