Jmh
JMH를 통한 벤치마킹 테스트
관련 글
- Websocket
- Websocket + 부가기능
- Websocket (채팅 기록 json 파일 저장하기)
- Sse
- Websocket + jwt
- Websocket test
- Jmh - 채팅 파일 refactoring 👈🏻
성능 테스트(JMH)
JMH는 Java Microbenchmark Harness의 약자로 Java 코드의 성능을 측정하고 비교하기 위한 도구다.
System.nanoTime()으로 시간을 찍는 것보다 훨씬 신뢰도가 높다.
왜 직접 시간을 찍으면 안 되나
System.nanoTime()으로 직접 재는 방식은 다음을 보장하지 못한다.
- JVM 워밍업 — JIT가 최적화하기 전(cold) 실행이 측정에 섞인다.
- DCE(죽은 코드 제거) 방어 — 반환값을 안 쓰면 JIT가 측정 대상 자체를 지워버릴 수 있다.
- fork 격리 — 앞뒤 코드의 JIT/프로파일이 서로 오염된다.
- 통계 — 표본이 적어 편차·신뢰구간을 알 수 없어 절대값은 물론 A/B 우열조차 뒤집힐 수 있다.
JMH는 이 네 가지를 모두 처리한다.
설정
build.gradle에 다음과 같이 설정한다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
plugins {
id "me.champeau.jmh" version "0.6.4"
}
dependencies {
implementation 'org.openjdk.jmh:jmh-core:1.32'
implementation 'org.openjdk.jmh:jmh-generator-annprocess:1.32'
}
jmh {
fork = 2
warmupIterations = 10
iterations = 10
warmup = '1s'
timeOnIteration = '1s'
}
주요 설정값:
fork: 벤치마크를 실행할 자식 JVM 수 (클수록 신뢰도가 올라가고 시간도 늘어남)warmupIterations: 실제 측정 전 JVM 워밍업 반복 횟수iterations: 실제 측정 반복 횟수warmup/timeOnIteration: iteration 1회당 시간
Warmup
JVM warm-up은 JIT 컴파일러가 자주 사용되는 코드를 네이티브 코드로 변환하는 최적화 과정
- 자바 코드가 자바 바이트 코드로 변환
- JIT 컴파일러가 바이트 코드를 네이티브 코드로 변환
- 네이티브 코드로 직접 실행되어 속도 향상
초반 실행은 아직 최적화가 안 돼서 느리기 때문에, 미리 여러 번 호출해 코드 캐시를 채워 두고 측정한다.
JMH 작성 방법
주요 어노테이션
@BenchmarkMode
Mode.Throughput: 시간당 처리량 측정Mode.AverageTime: 평균 실행 시간 측정Mode.SampleTime: 실행 시간 샘플링 측정Mode.SingleShotTime: 단일 실행 시간 측정Mode.All: 모든 시간 측정
@OutputTimeUnit
- NANOSECONDS, MICROSECONDS, MILLISECONDS, SECONDS, MINUTES, HOURS, DAYS
@State
Scope.Benchmark: 벤치마크 전체에서 공유Scope.Thread: 각 스레드마다 별도로 생성Scope.Group: 벤치마크 실행 그룹에서 공유
@Setup / @TearDown
Level.Trial: 벤치마크 전체에서 한 번만 실행Level.Iteration: 벤치마크 반복마다 실행Level.Invocation: 각 메서드 호출마다 실행
작성 순서
@State(Scope.Benchmark)어노테이션 적용@BenchmarkMode(Mode.AverageTime)설정@OutputTimeUnit(TimeUnit.NANOSECONDS)설정- 벤치마크 메서드에
@Benchmark어노테이션 적용 - 필요시
@Setup으로 초기화 작업 수행
벤치마크 메서드는 매 호출이 동일 조건이어야 한다.
측정 대상 메서드가 공유 상태를 바꾸면 2번째 호출부터 다른 코드 경로를 타게 되어, 재려던 것과 다른 것을 측정하게 된다.
JMH 실행 방법
1
./gradlew jmh
결과 확인:
1
cat build/results/jmh/results.txt
기록 삭제:
1
./gradlew clean
파일 마지막 글만 조회하기
1차 수정
변경 전: 파일 포인터로 끝에서부터 개행 7개를 세면서 역방향으로 읽음
변경 후: 파일 포인터를 마지막 줄 앞으로 한 번만 옮겨서 한 줄만 읽음
1
2
3
4
5
6
7
8
9
@Benchmark
public List<String> AtestOriginal() throws IOException { // 7줄 스캔(변경 전)
return lastLineOriginal(chatUploadLocation + "/" + roomId + ".txt");
}
@Benchmark
public List<String> BtestImproved() throws IOException { // 1줄 읽기(변경 후)
return lastLineImproved(chatUploadLocation + "/" + roomId + ".txt");
}
결과:
| Benchmark | Score (ns/op) |
|---|---|
AtestOriginal | 7,227,266 ± 4,740,389 |
BtestImproved | 1,635,889 ± 81,157 |
평균 기준으로 약 4.4배 빨라졌다.
변경 전 방식은 편차(± 4,740,389)가 커서 값이 널뛰는 반면
변경 후 방식은 약 1.6ms로 일정하게 나온다.
2차 수정
채팅창을 열고 닫을 때마다 파일을 다시 읽던 구조를, 마지막 메시지를 ConcurrentHashMap에 캐싱해서 맵에 있으면 파일을 아예 읽지 않도록 바꿨다.
1
2
3
4
5
6
7
public LastMessage lastLine(String roomId) {
if (lastMessageMap.containsKey(roomId)) {
return lastMessageMap.get(roomId); // 메모리 조회
} else {
// 파일에서 마지막 줄을 읽어서 파싱
}
}
파일 조회(ConlyGetFile)와 맵 조회(FonlyGetMap)를 각각 측정한 결과:
| Benchmark | Score |
|---|---|
ConlyGetFile (파일) | 1,756,698 ns (≈ 1.76ms) |
FonlyGetMap (맵) | 4.534 ns |
파일 조회는 밀리초 단위이고 맵 조회는 나노초 단위다.
캐싱으로 디스크 I/O 자체를 없앤 것이라 배수로 말하기보다 “파일 I/O를 제거했다”로 보는 것이 맞다.
파일 조회 코드 성능 비교
ReadFileBenchmark
전체 파일을 읽는 두 방식(Files.readString vs Files.lines)을 비교했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Benchmark)
public class ReadFileBenchmark {
@Benchmark
public Object readFileBefore() throws IOException, ParseException {
String str = Files.readString(Paths.get(chatUploadLocation + "/" + roomId + ".txt"));
JSONParser parser = new JSONParser();
return parser.parse("[" + str + "]");
}
@Benchmark
public Object readFileAfter() throws IOException, ParseException {
List<String> lines;
try (Stream<String> stream = Files.lines(Paths.get(chatUploadLocation, roomId + ".txt"))) {
lines = stream.collect(Collectors.toList()); // 스트림을 닫아 파일 핸들 누수 방지
}
String jsonString = "[" + String.join(",", lines) + "]";
JSONParser parser = new JSONParser();
return parser.parse(jsonString);
}
}
결과:
| Benchmark | Score (ms/op) |
|---|---|
readFileBefore | 0.589 ± 0.012 |
readFileAfter | 0.616 ± 0.026 |
두 방식의 차이는 거의 없었다(오히려 before가 근소하게 빠름)
이 변경은 성능이 아니라 가독성·유지보수를 위한 것으로 본다.
Redis 캐시 vs DB 조회
채팅방 정보를 매번 DB에서 조회하던 것을 Redis 캐시로 대체했을 때의 효과를 측정했다.
실제 RedisService 빈을 거쳐 Redis를 왕복하게 하고, @Setup에서 캐시를 미리 채워 캐시 히트 경로를 측정한다.
두 경로 모두 read-only라 반복 실행에도 상태가 변하지 않는다.
1
2
3
4
5
6
@Benchmark public ChatRoomDto useDb() { return onlyDb(nickname); } // DB 조회
@Benchmark public ChatRoomDto useCache() { return useRedis(nickname); } // Redis 조회
public ChatRoomDto useRedis(String nickname) {
return redisService.getCreateRoom("createRoom::" + nickname);
}
결과:
| Benchmark | Throughput (ops/s) |
|---|---|
useCache (Redis) | 1312.6 ± 97.2 |
useDb (DB) | 516.6 ± 89.0 |
캐시가 DB보다 처리량이 약 2.5배(1312.6 / 516.6) 높다.
무엇보다 조회를 캐시가 받아내므로 DB 부하가 줄어든다.
변경 전후 코드
파일 저장하기
FileWriter→PrintWriter(자동 개행 처리)toString()대신 enum 상수 비교setPrettyPrinting()제거- try-with-resources 구문 사용
파일 조회하기
Files.readString()→Files.lines()사용 (try-with-resources로 스트림을 닫아 파일 핸들 누수 방지)- JSON 객체 직접 파싱
파일 마지막 JSON만 조회
- try-with-resources 구문
- 파일 포인터를 마지막 줄 앞으로 이동
- JSON 객체로 직접 파싱하여 값 추출
- DTO의 getter 직접 호출로 리스트 순서 의존성 제거