성능 문제는 항상 4단계로 접근합니다. 순서를 건너뛰면 엉뚱한 곳을 고치게 됩니다.
| 단계 | 질문 | 도구 |
|---|---|---|
| 1. 측정 | 실제로 느린가, 얼마나 느린가 | actuator, JFR, 로그 |
| 2. 위치 | 어느 구간이 느린가 | 구간 타이머, SQL 로그 |
| 3. 원인 | 왜 그 구간이 느린가 | 실행 계획, JFR 스택 |
| 4. 검증 | 고친 뒤 정말 나아졌는가 | 같은 측정 재실행 |
"체감상 느려진 것 같다"는 출발점일 뿐, 결론이 아닙니다. 반드시 숫자로 확인한 뒤에 코드를 고칩니다.
응답 시간을 평균 하나로 보면 위험합니다. 대부분 빠르고 일부만 아주 느려도 평균은 낮게 나와 문제를 감춥니다.
민원은 보통 p95·p99 구간에서 발생합니다. 평균만 보고 "정상"이라 답하면 민원인의 경험과 어긋납니다.
병목은 SQL 부터 OS 까지 여러 층에 있을 수 있습니다. 위층부터 순서대로 내려가며 확인합니다.
EXPLAIN PLAN)과 느린 쿼리 로그로 특정 쿼리를 찾습니다.top·vmstat 으로 CPU·메모리·스왑 등 자원 고갈 여부를 확인합니다.대부분의 실무 문제는 12단계에서 끝납니다. 34단계까지 내려가는 경우는 GC 튜닝이나 스레드 경합처럼 애플리케이션 코드만으로 설명 안 되는 상황입니다.
JFR(Java Flight Recorder)은 JDK 에 내장된 프로파일러입니다. 별도 에이전트나 jar 설치 없이 바로 씁니다. 폐쇄망에서도 추가 반입 없이 쓸 수 있어 실무에 적합합니다.
# 애플리케이션 시작 시 60초간 기록
java -XX:StartFlightRecording=filename=app.jfr,duration=60s -jar app.jar
# 이미 떠 있는 프로세스에 원격으로 시작·덤프
jcmd <PID> JFR.start name=app duration=60s filename=app.jfr
jcmd <PID> JFR.dump name=app filename=app_snapshot.jfr오버헤드는 보통 1~2% 로 작아 운영 중에도 켤 수 있습니다. 장애가 재현될 때까지 계속 기록해 두고, 문제가 생긴 시점의 스냅샷만 덤프하는 방식이 실무에서 흔합니다.
JMC(JDK Mission Control) GUI 없이도 jfr print 명령으로 텍스트만으로 분석할 수 있습니다. 폐쇄망 서버에 GUI 가 없어도 결과 파일만 반출하면 됩니다.
jfr print --events jdk.CPULoad app.jfrjdk.CPULoad {
startTime = 12:03:41.201
jvmUser = 34.2%
jvmSystem = 3.1%
machineTotal = 41.0%
}CPU 외에 jdk.ObjectAllocationSample(할당 핫스팟), jdk.JavaMonitorEnter(락 경합), jdk.GCPhasePause(GC 정지 시간) 이벤트를 함께 보면 CPU·메모리·락·GC 중 어느 쪽이 원인인지 좁힐 수 있습니다.
System.nanoTime 으로 코드 앞뒤를 감싸 재는 방식은 간단하지만 세 가지 함정이 있습니다.
nanoTime 남용: 아주 짧은 코드(수 나노초)를 반복 측정하면 타이머 호출 자체의 오버헤드가 결과를 왜곡합니다.이 레슨의 Bench 클래스는 워밍업 반복과 결과를 더하는 sink 필드로 앞의 두 함정을 최소한으로 막습니다. 다만 실제 운영 판단에는 JMH 같은 검증된 도구를 권장합니다.
JMH(Java Microbenchmark Harness)는 워밍업·죽은 코드 제거·통계 처리를 표준화한 벤치마크 도구입니다. Maven Central 접속이 막힌 폐쇄망에서는 jmh-core·jmh-generator-annprocess jar 를 사전에 반입해야 합니다.
@State(Scope.Thread)
public class StringBench {
private String base = "x".repeat(100);
@Benchmark
public String concat() {
return base + base; // 반환값이 있어야 죽은 코드 제거를 피한다
}
@Benchmark
public void concatVoid(Blackhole bh) {
bh.consume(base + base); // 반환 없는 메서드는 Blackhole 로 소비
}
}@Fork(2) // 별도 JVM 프로세스로 2회 반복 측정
@Warmup(iterations = 5) // 워밍업 5회
@Measurement(iterations = 5)
public class BenchRunner {
// Runner.run() 으로 실행하거나 커맨드라인 jar 로 실행
}@State 는 벤치마크가 공유할 필드를 담고, Blackhole 은 반환값 없는 코드의 죽은 코드 제거를 막습니다. @Fork 는 JVM 을 통째로 새로 띄워 이전 측정의 JIT 상태가 섞이는 것을 막습니다. jar 없이는 이 문법을 실행할 수 없어, 이 레슨의 실행 데모는 Bench 로 대신합니다.
ab·wrk·JMeter 가 폐쇄망 서버에 없다면 두 가지 대안이 있습니다.
for 문으로 curl 을 여러 번 실행합니다. 간단하지만 동시성 제어가 어렵습니다.부하 테스트를 설계할 때 확인할 세 가지가 있습니다.
20 / 평균 응답 초 를 넘기 어렵습니다.실무에서 마주치는 병목은 대략 다음 순서로 자주 나타납니다. 위쪽일수록 흔하고 고치기도 쉽습니다.
WHERE 절 컬럼에 인덱스가 없어 풀스캔이 도는 경우입니다.GC 를 먼저 의심하고 힙을 늘리는 것은 흔한 실수입니다. 대개는 1~5번이 원인이고, GC 튜닝은 고급 10 레슨에서 다루는 마지막 수단입니다.
병목을 고친 뒤에는 전·후 수치를 표로 남겨야 "느낌"이 아니라 근거로 보고할 수 있습니다.
| 지표 | 개선 전 | 개선 후 |
|---|---|---|
| p50 | 320ms | 45ms |
| p95 | 1,800ms | 180ms |
| TPS | 12 | 68 |
| 에러율 | 2.1% | 0.0% |
| 원인 | N+1 쿼리 120회 | fetch join 1회 |
이 표 하나가 "체감상 빨라졌다"보다 훨씬 설득력 있는 보고 자료가 됩니다. 개선 전 수치를 남겨두지 않으면 나중에 비교할 기준이 사라집니다.
Spring Boot 는 actuator 로 위 지표 상당수를 코드 추가 없이 노출합니다.
| 기능 | 설정·엔드포인트 |
|---|---|
| 요청 분위수 | /actuator/metrics/http.server.requests |
| 분포 히스토그램 | 아래 설정 키 참고 |
| SQL 로그 | p6spy 또는 MyBatis logging.level.mapper 패키지=DEBUG |
| JVM 지표 | /actuator/metrics/jvm.memory.used 등 |
management.metrics.distribution.percentiles-histogram.http.server.requests=truespring.jpa.show-sql=true 는 바인딩 파라미터를 보여주지 않아 실무 디버깅에 부족합니다. p6spy 나 MyBatis 로그 설정으로 실제 바인딩 값과 실행 시간을 함께 남기는 편이 낫습니다. JVM 내부 원리는 고급 10 레슨을, 서버 자원 관점의 장애 대응은 리눅스 운영 09 레슨을 함께 참고하세요.