이 글을 쓰게된 배경
프로젝트에서 조회수 기능을 사용하고 있는데 조회수를 최신화할 때 레디스를 사용하면 성능 최적화를 할 수 있다는 것을 알게되었습니다. 이미 토큰때문에 레디스가 프로젝트에 적용되어 있었기 때문에 상대적으로 적은 러닝커브로 성능을 향상시킬 수 있을 것 같아 이 방법을 적용하기로 했습니다.
다룰 내용
- 기존 조회수 기능 구현 방법
- 레디스를 이용한 조회수 최신화
- 성능 향상 리포트
기존 조회수 기능 구현 방법
기존에는 장학금 카드를 조회하면 데이터베이스에 저장되어있는 viewCount의 숫자를 1 늘려주는 방법으로 조회수 로직을 구현했습니다.
// 장학금 카드 조회 로직
@Transactional
public ScholarshipResponse getOneScholarship(Long scholarshipId) {
Scholarship scholarship = scholarShipRepository.findById(scholarshipId)
.orElseThrow(ScholarshipNotFoundException::new);
scholarship.addViewCount();
//dto 생성해서 결과 리턴
...
return scholarhipResonse;
}
//addViewCount() 함수
public void addViewCount() {
this.viewCount++;
}
레디스를 이용한 조회수 기능 구현 방법
public ScholarshipResponse getOneScholarshipInRedis(Long scholarshipId) {
Scholarship scholarship = scholarShipRepository.findById(scholarshipId)
.orElseThrow(ScholarshipNotFoundException::new);
incrementViewCount(scholarshipId);
int viewCount = getViewCount(scholarshipId);
//dto 생성해서 결과 리턴
return ScholarshipResponse;
}
public void incrementViewCount(Long scholarshipId) {
String key = VIEW_COUNT_KEY + scholarshipId; // 고유 키 생성
redisTemplate.opsForValue().increment(key);
}
public int getViewCount(Long scholarshipId) {
String key = VIEW_COUNT_KEY + scholarshipId;
String value = redisTemplate.opsForValue().get(key);
return value != null ? Integer.parseInt(value) : 0;
}
결과
Server Software:
Server Hostname: localhost
Server Port: 8080
Document Path: /scholarship/1
Document Length: 61 bytes
Concurrency Level: 100
Time taken for tests: 2.432 seconds
Complete requests: 10000
Failed requests: 0
Non-2xx responses: 10000
Total transferred: 2740000 bytes
HTML transferred: 610000 bytes
Requests per second: 4112.00 [#/sec] (mean)
Time per request: 24.319 [ms] (mean)
Time per request: 0.243 [ms] (mean, across all concurrent requests)
Transfer rate: 1100.28 [Kbytes/sec] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 0 2.0 0 195
Processing: 0 24 30.2 17 215
Waiting: 0 23 30.1 17 215
Total: 0 24 30.3 18 215
Server Software:
Server Hostname: localhost
Server Port: 8080
Document Path: /scholarship/1/redis
Document Length: 61 bytes
Concurrency Level: 100
Time taken for tests: 3.235 seconds
Complete requests: 10000
Failed requests: 0
Non-2xx responses: 10000
Total transferred: 2740000 bytes
HTML transferred: 610000 bytes
Requests per second: 3091.51 [#/sec] (mean)
Time per request: 32.347 [ms] (mean)
Time per request: 0.323 [ms] (mean, across all concurrent requests)
Transfer rate: 827.22 [Kbytes/sec] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 1 3.1 0 98
Processing: 0 31 48.4 13 337
Waiting: 0 31 48.1 13 337
Total: 0 32 48.5 14 337
결과 정리
항목 | 기존 DB 사용 | Redis 사용 | 차이점 분석 |
초당 요청 처리 속도 | 4112 req/sec | 3091 req/sec | Redis가 오히려 느려짐 |
평균 응답 속도 | 24.3ms | 32.3ms | Redis가 평균적으로 8ms 더 느림 |
최대 응답 속도 | 215ms | 337ms | Redis가 지연이 더 많음 |
데이터 전송 속도 | 1100.28 KB/sec | 827.22 KB/sec | Redis가 느림 |
비정상 응답 | 10000 | 10000 | 동일 |
레디스를 사용하면 데이터베이스를 사용할때보다 응답속도가 빠를 것을 예상하고 레디스를 활용한건데 예상외로 데이터베이스를 사용하는 것이 더 성능이 뛰어나다는 결과가 나왔습니다. 도대체 왜 이런 결과가 발생한 건지 이유를 알아보았습니다.
이유
먼저 제가 레디스를 통해서 성능향상이 될 거라고 생각했던 근거는 레디스가 인메모리 방식으로 데이터를 저장하기 때문에 데이터베이스에서 데이터를 가져오는것 보다 레디스에서 정보를 가져오는 속도가 빠른 점이었습니다. 하지만 이 프로젝트에서는 데이터베이스를 h2라는 인메모리 방식의 데이터베이스를 사용하고 있었고 그래서 db를 사용하는것이 더 속도가 빠른 것이었습니다.
레디스 성능 최적화
여기서 더 조회수 성능을 극한까지 할 수 있는 방법이 없을까하고 방법을 찾아보니 역시나 더 최적화 할 수 있는 방법이 있었습니다. 두가지 방법을 찾았는데 하나는 배치를 이용하는것이고 다른 하나는 레디스 파이프를 이용하는 것입니다.
배치
배치를 사용하는 것은 여러개의 요청을 모아서 한번에 전송하는 방식을 사용하는 것입니다. 조회수 증가 요청이 들어오면 바로 처리하는 것이 아니라 10개의 요청이 들어왔을때 이 요청을 한번에 처리하는 것입니다. 배치를 사용하면 레디스의 부하를 줄일 수 있습니다. 배치 사이즈는 성능에따라 조절 가능합니다.
레디스 파이프
레디스가 아무리 I/O 작업이 빠르다고 하지만 결국 tcp기반의 서버입니다. 그 말은 결국 요청이 무수히 많아지면 네트워크 병목이 일어날 수 밖에 없는 구조라는 것입니다. tcp를 사용한다면 결국 하나의 요청에 대한 응답이 들어올때 까지 다음 요청을 보낼 수 없습니다. 10개의 요청이 동시에 들어오게 된다면 한개 요청에 대한 응답을 받을 때까지 나머지 9개의 요청은 대기해야한다는 것입니다. 파이프라인을 구성하면 이 치명적인 문제점을 해결할 수 있습니다. 파이프라인을 만들어놓으면 응답이 오기전에도 요청을 보낼 수가 있습니다. 파이프에서는 요청 순서가 보장되기 때문에 요청과 응답이 섞일 일은 없습니다.
변경한 코드
public void incrementViewCountBatchAndPipe(Long scholarshipId) {
localCounter.putIfAbsent(scholarshipId, new AtomicInteger(0));
int currentCount = localCounter.get(scholarshipId).incrementAndGet();
// 한번에 10개씩 모아서 전송
if (currentCount >= BATCH_SIZE) {
synchronized (this) {
if (localCounter.get(scholarshipId).get() >= BATCH_SIZE) {
String key = VIEW_COUNT_KEY + scholarshipId;
// 🚀 Pipeline 적용: 네트워크 오버헤드 최소화!
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
connection.stringCommands().incrBy(key.getBytes(), currentCount);
return null;
});
localCounter.get(scholarshipId).set(0); // 로컬 카운터 초기화
}
}
}
}
Server Software:
Server Hostname: localhost
Server Port: 8080
Document Path: /scholarship/1/redis
Document Length: 61 bytes
Concurrency Level: 100
Time taken for tests: 2.117 seconds
Complete requests: 10000
Failed requests: 0
Non-2xx responses: 10000
Total transferred: 2740000 bytes
HTML transferred: 610000 bytes
Requests per second: 4723.60 [#/sec] (mean)
Time per request: 21.170 [ms] (mean)
Time per request: 0.212 [ms] (mean, across all concurrent requests)
Transfer rate: 1263.93 [Kbytes/sec] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 0 0.5 0 14
Processing: 0 20 28.2 15 250
Waiting: 0 20 28.2 15 249
Total: 0 20 28.2 15 250
결과 정리
항목 | 레디스 기존 코드 | 레디스 파이프 + 배치 사용 | 개선율 |
초당 요청 처리 속도 | 3091 req/sec | 4723 req/sec | 52.7% 향상 |
평균 응답 속도 | 32.3ms | 21.1ms | 34.6% 단축 |
최대 응답 시간 | 337ms | 250ms | 25.8% 단축 |
네트워크 전송 속도 | 827.22 KB/sec | 1263.93 KB/sec | 52.8% 향상 |
기존 코드와 레디스파이프와 배치를 사용했을 때의 성능 차이 입니다. 위 결과는 배치 사이즈 10일때 결과입니다.
배치 사이즈를 몇으로 하는게 적당한지 궁금해서 1부터 1000까지 변경하면서 비교해 보았습니다. 결과는 배치 사이즈가 너무 커지면 성능이 떨어지는 결과를 보였습니다.배치를 사용하면 레디스의 부하는 줄여주지만 성능에는 악영향을 미쳤습니다. 적절한 배치 사이즈를 조절하면서 사용하는 것이 좋아보입니다.
'Redis' 카테고리의 다른 글
레디스 설정 적용 (0) | 2025.04.18 |
---|---|
레디스 데이터 영속성 방식 (0) | 2025.04.01 |