본문 바로가기

Spring

조회수 및 어뷰징 방지 Redis로 구현하기

요구사항

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에 보내서 조회 후 캐싱

  1. 레디스에 상품조회를 할 때마다 조회수를 누적한다 (정각에 초기화)
  2. 사용자가 랭킹조회 api를 누르면 레디스에 저장된 조회수 리스트 상위 10개가져옴(상품 키값)
    1. 10개의 키값들을 가져와서 그 리스트와 매칭하여 엔티티들을 10개 가져와서 순위와 함께 엔티티 리스트를 보여준다 → in 사용하면 됨, pk조회는 빨라서 부담 없음
    2. 해당 리스트를 레디스에 저장하고 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