성능 최적화 요약
성능 최적화 요약
Chat Project
Chat Project를 진행하면서 성능 개선을 위해 여러 가지 최적화 작업을 진행했다.
이를 통해 로딩 시간, 조회 속도 및 응답 시간을 크게 개선했다.
아래는 각 주요 최적화 작업과 성능 테스트 결과의 요약이다.
더 자세한 내용은 관련 링크에서 확인할 수 있다.
1. 채팅 기록 파일 조회 속도 개선
2. 채팅 기록 파일 조회 횟수 감소
3. Broker 적용 : STOMP 프로토콜을 지원하는 RabbitMQ Broker 적용
4. db 조회 횟수 감소 : 채팅을 하는데 필요한 정보들을 Redis Cache를 활용해 db 조회 횟수 감소
*이 글의 수치는 전부 JMH(fork=2, warmup 10회 × 2초, 측정 10회 × 3초)로 다시 측정한 값이다.
1. 채팅 기록 파일 조회 속도 개선
처음에는 줄바꿈을 포함해 사람이 읽기 쉬운 형태로 채팅 기록 파일을 저장했다.
그래서 마지막 메시지를 읽으려면 파일 끝에서 개행을 세면서 역방향으로 스캔해야 했다.
데이터가 쌓이면서 조회 속도가 점차 느려졌고 사용자가 불편할 정도로 로딩 시간이 길어졌다.
이를 개선하기 위해 채팅 기록을 한 줄로 저장하는 방식으로 변경했다.
이제 파일 포인터를 마지막 줄 앞으로 한 번만 옮겨서 한 줄만 읽으면 된다.
| 방식 | 조회 시간 |
|---|---|
| 7줄 스캔 (기존) | 6.062 ± 0.218 ms |
| 1줄 읽기 (개선) | 2.184 ± 0.059 ms |
평균 기준으로 약 2.8배 빨라졌다.
기존 방식은 마지막 줄이 길수록 더 많이 읽어야 해서 실행마다 시간이 달라지는데
개선한 방식은 읽는 양이 거의 고정이라 값이 일정하게 나온다.
관련 글 : Jmh
2. 채팅 기록 파일 조회 횟수 감소
초기에는 채팅 버튼을 반복해서 열고 닫을 때마다 마지막 메시지를 매번 조회하는 비효율적인 방식이었다.
이를 개선하기 위해 가장 최근 메시지를 ConcurrentHashMap에 저장해 두고
채팅창을 열 때 파일 조회 대신 Map에 저장된 값을 참조하도록 변경했다.
마지막 글을 조회할 때 Map에 해당 기록을 저장하고 채팅을 할 때마다 해당 내용을 update했다.
여러 사용자의 메시지가 동시에 이 Map을 읽고 쓰기 때문에 HashMap이 아니라 ConcurrentHashMap을 썼다.
1
2
3
4
5
6
7
public LastMessage lastLine(String roomId) {
if (lastMessageMap.containsKey(roomId)) {
return lastMessageMap.get(roomId); // 메모리 조회
} else {
// 파일에서 마지막 줄을 읽어서 파싱
}
}
그 결과 파일을 다시 읽는 일 자체가 없어졌다.
| 방식 | 조회 시간 |
|---|---|
| 파일 조회 | 2.380 ± 0.054 ms |
| 맵 조회 | 6.214 ± 0.207 ns |
맵 조회는 get 한 번이라 나노초 단위고
파일 조회는 파일 열기 + 역방향 탐색 + JSON 파싱이 들어가서 밀리초 단위다.
단위 자체가 달라서 몇 배라고 말하기보다 디스크 I/O를 제거했다고 보는 게 맞다.
3. Broker 적용 : STOMP 프로토콜을 지원하는 RabbitMQ Broker 적용
Stomp 프로토콜을 사용할 때 기본적으로 In-Memory Message Broker를 사용했는데
In-Memory Message Broker는 용량 제한, 메시지 유실 가능성, 모니터링 어려움 등의 문제점이 있었다.
이를 개선하고자 외부 메시지 브로커인 RabbitMQ를 도입했다.
RabbitMQ를 고른 이유는 STOMP 프로토콜을 그대로 지원해서
클라이언트 코드는 건드리지 않고 브로커만 바깥으로 뺄 수 있었기 때문이다.
1
2
3
4
5
// 변경 전
config.enableSimpleBroker("/topic/ping", "/every-chat");
// 변경 후
config.enableStompBrokerRelay("/exchange");
관련 글 : RabbitMQ
4. db 조회 횟수 감소
Redis Cache를 활용해 자주 참조되는 데이터를 메모리에 저장하여 DB 조회 횟수를 줄였다.
전체 값을 캐싱할 때는 @Cacheable과 같은 어노테이션을 사용했으며
일부 값만 필요할 때는 RedisTemplate을 활용했다.
이를 통해 최소한의 DB 조회만 하도록 코드를 작성했다.
방 정보를 매번 DB에서 조회하던 것을 Redis 캐시로 대체하고 처리량을 측정했다.
| 방식 | 처리량 |
|---|---|
| DB 조회 | 624.8 ± 50.7 ops/s |
| Redis 조회 | 1,265.3 ± 31.5 ops/s |
처리량이 약 2배 올라갔고 그만큼 DB 부하도 줄었다.
로컬에서 같은 머신으로 잰 값이라 네트워크나 DB 부하가 큰 환경일수록 차이는 더 커질 것이다.
여기서 핵심은 배수보다 조회를 캐시가 받아내면서 DB 트래픽이 줄었다는 점이다.
관련 글 : Redis Cache
종합 : 단계별 조회 시간
위 세 가지는 각각 다른 대상을 재기 때문에 나란히 비교하기 어렵다.
1번은 파일을 읽는 방식끼리 비교한 것이고
2번은 파일과 메모리를 비교한 것이라 단위부터 ms와 ns로 다르다.
4번은 조회 시간이 아니라 처리량(ops/s)으로 쟀다.
그래서 각 항목이 얼마나 개선됐는지는 알 수 있어도
채팅창을 여는 동작 자체가 실제로 얼마나 빨라졌는지는 이 숫자들만으로 말할 수 없었다.
그래서 사용자가 실제로 하는 동작 하나를 기준으로 다시 측정했다.
“채팅창 열기 1회 = 마지막 메시지 조회 + 방 정보 조회”
이 동일한 작업을 캐시를 한 단계씩 누적 적용한 세 가지 설정으로 쟀다.
- 캐시 없음 : 파일에서 마지막 메시지 + DB에서 방 정보
- Map 적용 : Map에서 마지막 메시지 + DB에서 방 정보
- Map + Redis : Map에서 마지막 메시지 + Redis에서 방 정보
세 경로 모두 조회만 하기 때문에 반복 실행해도 상태가 변하지 않는다.
같은 작업을 재는 것이라 하나의 그래프로 비교할 수 있다.
| 설정 | 조회 시간 | 캐시 없음 대비 |
|---|---|---|
| 캐시 없음 (파일 + DB) | 4.610 ± 0.292 ms | — |
| Map 적용 (맵 + DB) | 2.032 ± 0.398 ms | 2.3배 |
| Map + Redis (맵 + Redis) | 1.198 ± 0.181 ms | 3.8배 |
Map만 적용하면 마지막 메시지의 파일 I/O만 제거되고 방 정보는 여전히 DB에서 읽어서 병목이 남는다 (4.61 → 2.03ms)
Redis까지 적용하면 방 정보 조회도 캐시로 대체되면서 4.61 → 1.20ms까지 줄어든다
표의 3.8배는 이 실행에서 나온 값이다.
같은 벤치마크를 여러 번 돌려보니 배수가 3.2 ~ 3.85배 사이에서 움직였다.
