요구사항
1. 상품 조회 시 해당 게시글의 조회수를 카운팅 + 어뷰징을 방지할 것
2. 상품을 조회할 때 상품의 상세정보와 함께 조회수도 같이 볼 수 있어야 함
3. 조회수를 기준으로 상위 10개의 상품정보를 랭킹을 포함하여 조회할 수 있어야 함
조회수 카운팅 구현
Global Cache 방식 사용
- Local Cache 전략을 사용하게 되면 속도가 빠르지만 다중 서버 환경에서는 각 서버마다 중복된 데이터를 보관해야 하고, 동기화되지 않기 때문에 데이터의 정합성 문제가 발생하기 때문에 속도가 Local Cache 보다는 느리지만 정합성과 중복성 문제를 해결하기 위한 Global Cache 방식을 선택!
Redis 선택 이유
- 다양한 캐시 데이터베이스 중에서도 Redis는 다양한 자료구조를 지원함 -> 이번에 구현해야 할 상위 10개 검색 상품을 저장할 때 ZSet 자료구조를 이용하여 key-value임에도 불구하고 빠르게 정렬해서 조회가 가능
- 다양한 라이브러리를 지원하기 때문에 spring boot에서도 간단하게 사용할 수 있음
- 조회수 같은 숫자 데이터는 메모리 부담이 적기 때문에 사용하기 적절하다고 판단!
ZSet (Sorted Set) 자료구조 사용
- Key-Value 구조인 Redis에서 정렬이 가능한 자료구조
- 각 요소에 점수(Score)를 부여하고 이를 오름차순이나 내림차순으로 정렬할 수 있음
- O(log N)의 빠른 성능을 가짐
조회수 데이터 저장 : Redis에 조회수 데이터를 ZSet을 이용하여 구현
// 조회수가 없는 경우엔 1로 초기화, 존재하는 경우엔 조회수를 1만큼 증가
public Long addReadCount(Long productId) {
if (redisUtils.notExistsReadCount(productId)){
redisUtils.setReadCount(productId);
return 1L;
}
return redisUtils.addReadCount(productId);
}
- 조회수같이 자주 조회되고, 정각마다 초기화되는 데이터는 RDB에 저장하는 것보다 캐싱하여 빠르게 조회되고 갱신하는 것이 더 낫다고 판단함
- 해당 상품의 조회수가 존재하지 않는다면 product:readCount라는 ZSet의 키 이름을 저장하고"product: + id"라는 이름의 값에Score를 1로 세팅
- 존재한다면 "product: + id"라는 이름의 값에Score를 1만큼 증가
어뷰징 방지 : Redis에 방문기록을 저장하여 어뷰징 검증
public Boolean isNotViewed (Long productId, Long userId) {
Boolean isNotViewed = redisTemplate.opsForValue().setIfAbsent("product:" + productId + ":user:" + userId, "viewed");
return isNotViewed;
}
- "product:" + productId + ":user:" + userId 키가 존재하지 않는다면 방문기록을 저장하고 true 반환
- "product:" + productId + ":user:" + userId 키가 존재한다면 false 반환
// 어뷰징 검증, 24시간 이내에 방문했다면 기존 조회수 조회, 아니라면 조회수 증가
public Long findReadCount(Long productId, Long userId) {
Boolean isNotViewed = redisUtils.isNotViewed(productId, userId);
if (Boolean.TRUE.equals(isNotViewed)) {
return addReadCount(productId);
}
return redisUtils.getReadCount(productId);
}
- isNotViewed 메서드를 통해서 사용자가 정각 이전에 1번 이상 해당 상품을 조회했는지 검증한 후에 조회한 적이 있다면 기존 조회수 조회
- 조회한 적이 없다면 조회수를 1만큼 증가시킴
조회수 상위 10개 상품 랭킹 조회 : Redis에 저장된 상위 10개 상품 id를 RDB에 보내서 조회 후 캐싱
- 레디스에 상품조회를 할 때마다 조회수를 누적한다 (정각에 초기화)
- 사용자가 랭킹조회 api를 누르면 레디스에 저장된 조회수 리스트 상위 10개가져옴(상품 키값)
- 10개의 키값들을 가져와서 그 리스트와 매칭하여 엔티티들을 10개 가져와서 순위와 함께 엔티티 리스트를 보여준다 → in 사용하면 됨, pk조회는 빨라서 부담 없음
- 해당 리스트를 레디스에 저장하고 ttl이 만료되기 전까지는 레디스에서 조회
// 조회수 상위 10개의 상품 id 리스트 조회
public List<Long> findProductIds() {
Set<Object> objectSet = redisTemplate.opsForZSet().reverseRange("product:readCount",0, 9);
if (objectSet == null) {
return Collections.emptyList();
}
//"product:7" -> ["product", "7"] -> ["7"]로 변환
List<Long> productIdList = new ArrayList<>();
for (Object o : objectSet) {
String value = (String) o;
String[] parts = value.split(":");
productIdList.add(Long.parseLong(parts[1]));
}
return productIdList;
}
- ZSet을 이용하여 상위 10개의 조회수 id값을 순서대로 정렬하여 조회
@Transactional(readOnly = true)
@Cacheable(cacheNames = "rank", key = "'products:rank'", cacheManager = "productCacheManager")
public List<ProductRankResponse> findProductsByReadCount() {
List<Long> idList = redisUtils.findProductIds();
List<Product> products = productRepository.findProductsByRank(idList);
// 순위별로 정렬
products.sort(Comparator.comparingInt(p -> idList.indexOf(p.getId())));
List<ProductRankResponse> readCountResponses = ProductRankResponse.toProductRankResponseList(products);
return readCountResponses;
}
- 조회된 상위 10개의 상품 id리스트를 in절을 사용해서 RBD에서 상품데이터를 조회
- -> 기본키(PK)를 이용하여 조회하기 때문에 성능상 RDB에서 조회한다고 해도 큰 부담이 없음
- in절을 이용하여 상품을 조회하는 경우 데이터의 반환 순서를 보장하지 않기 때문에 products.sort를 이용하여 원래 id리스트 순서(랭킹)별로 다시 정렬
- @Cacheable을 이용하여 조회수별 인기 순위 상품 결과를 캐시에 저장
자정에 조회수가 자동으로 리셋되도록 설정한 방법
// 자정에 캐시 리셋
@Scheduled(cron = "0 0 0 * * ?")
public void clearCache() {
Set<String> visitorKeys = redisTemplate.keys("product*");
if (visitorKeys != null && !visitorKeys.isEmpty()) {
redisTemplate.delete(visitorKeys);
}
if (Boolean.TRUE.equals(redisTemplate.hasKey("product:readCount"))) {
redisTemplate.opsForZSet().removeRange("product:readCount", 0, -1);
}
}
- Spring Scheduler를 이용하여 정각마다 방문자수와 인기 검색 상품리스트를 초기화
캐싱조회 전 / 후 성능 비교
테스트조건
- 1초동안 1000번의 요청을 30번 반복
데이터 100만건 중에서 상품 조회수 조회 성능비교
1. 데이터베이스에 조회수를 저장하고 조회
평균 응답시간(ms) : 569
Throughput (초당 응답할 수 있는 요청의 처리 수) : 1671
2. Redis에 조회수를 저장해서 캐싱으로 조회
평균 응답시간(ms) : 293
Throughput (초당 응답할 수 있는 요청의 처리 수) : 3035
- 응답 시간: 48.5% 개선
- 처리량 (Throughput): 81.7% 개선
데이터 100만건 중에서 랭킹 10위 상품 정보 조회 성능 비교하기
1. 조회수 랭킹 10위 리스트 캐싱 적용 안한 경우 조회
평균 응답시간(ms) : 605
Throughput (초당 응답할 수 있는 요청의 처리 수) : 1541
2. 조회수 랭킹 10위 리스트 캐싱 적용 한 경우 조회
평균 응답시간(ms) : 387
Throughput (초당 응답할 수 있는 요청의 처리 수) : 2405
개선점
- 응답 시간: 36.0% 개선
- 처리량 (Throughput): 56.0% 개선
정리
평균 응답시간(ms) | Throughput | |
상품 조회수 조회 (캐싱 x) | 569 | 1671 |
상품 조회수 조회 (캐싱 o) | 293 | 3035 |
상품 랭킹 정보 조회 (캐싱 x) | 605 | 1541 |
상품 랭킹 정보 조회 (캐싱 o) | 387 | 2405 |
응답시간은 대략 48.5% 감소했고, Throughput은 56.0% 증가했다!
'Spring' 카테고리의 다른 글
QueryDSL을 이용하여 동적 쿼리 구현하기 (0) | 2025.03.31 |
---|---|
failed to create jar file 문제 해결하기 (1) | 2025.03.25 |
테스트 코드 작성하기 (1) | 2025.03.23 |
JPA와 Transaction (1) | 2025.03.21 |
Spring Security의 JWT 적용 (1) | 2025.03.21 |