Redis Sorted Set으로 실시간 급상승 검색어 랭킹 구현하기

ABL·2024년 4월 2일
1
post-thumbnail
post-custom-banner

보통 Redis를 사용하여 인기 검색어 랭킹을 구현할 때, Sorted Set을 사용하곤 한다.
Redis Sorted Set은 key 하나에 여러개의 score와 member로 구성되는데, 이때 member값은 중복되지 않으며 score를 기준으로 정렬된다.

보통 member에 키워드를 지정해서 검색할 때마다 score을 1씩 증가시키는 로직을 작성한 뒤, score이 가장 높은 순으로 n개의 member를 가져오는 방식으로 사용한다.

하지만 내가 구현한 기능의 경우 요구사항은 실시간성을 반영하기 위해서 검색일자 기준 3일까지만 랭킹에 반영되도록 해야 했다. 검색 데이터별 TTL을 통해 관리하기에는, Sorted Set을 사용한 로직이 모든 검색 데이터를 저장하는 것이 아닌, 키워드별 score (count) 수를 1씩 증가하는 것이기 때문에 적용할 수 없었다.

따라서 "ranking_240401"과 같이, key값 자체를 랭킹이 보여질 날짜별로 여러개 생성한 뒤, key 자체의 TTL을 통해 정리하는 방식을 사용했다.


Sorted Set 기본 구조

key : [member, score]

적용 → "ranking:{ranking_date}" : [{keyword} : {keyword_score}]

  • 각 키워드의 검색 빈도를 저장하기 위해 Redis의 Sorted Set을 사용 -> 이 구조에서 각 키워드(member)는 고유하며, 각각의 검색 횟수(score)에 따라 자동으로 정렬

  • 검색일을 기준으로 일자별로 별도의 key를 생성 (ex> "ranking:240401") -> 특정 일자별로 검색 빈도를 관리 가능

  • 각 key는 생성된 날짜로부터 3일 동안만 유효함(TTL 설정) -> 랭킹 실시간성을 유지하면서도, 오래된 데이터 제거

  • 사용자가 특정 키워드를 검색할 때, 그날을 포함해 이후 2일간 해당하는 key들에서 해당 키워드의 score를 1 증가, 미래의 날짜에도 미리 검색 빈도를 반영

  • 랭킹을 조회할 때는 해당 일자의 key를 기준으로, 가장 높은 score를 가진 상위 n개의 키워드를 가져옴, 이를 통해 최근 3일간 가장 인기 있는 검색어를 파악할 수 있음

Example

  1. 240401에 "뽀삐" 라는 키워드 검색
  2. key값이 "ranking:240401", "ranking:240402", "ranking:240403" 인 Set에서 member이 "뽀삐"인 score를 1씩 increase
  3. 새로 생성되는 key는 TTL 3일 (ranking:240403은 240401에 생성되어, 240403에 조회되며, 240404에 삭제된다)
  4. 240401, 240402, 240403 3일간 240401에 뽀삐 검색 1회가 누적된 랭킹을 보여줄 수 있음


코드 구현


	// 검색어 랭킹 반영
    public void increaseKeywordFrequency(KeywordRequest keywordRequest) {

        if (keywordRequest.getKeyword().trim().isEmpty()) {
            throw new CustomException(BLANK_REQUEST_VALUE, "키워드 검색은 공백일 수 없습니다.");
        }

        LocalDate today = LocalDate.now();

        for (int i = 0; i < 3; i++) {
            String key = "ranking:" + today.plusDays(i).format(DateTimeFormatter.ISO_DATE);

            redisTemplate.opsForZSet().incrementScore(key, keywordRequest.getKeyword(), 1);
            redisTemplate.expire(key, 3, TimeUnit.DAYS);

        }

    }
    
    // 키워드 랭킹 가져오기 (score 5 미만은 제외)
    public List<SearchRankResponse> getKeywordRank() {
        LocalDate today = LocalDate.now();
        String key = "ranking:" + today.format(DateTimeFormatter.ISO_DATE);
        Set<ZSetOperations.TypedTuple<String>> typedTuples = redisTemplate.opsForZSet().reverseRangeWithScores(key, 0, 4);

        List<SearchRankResponse> keywordRankResponses = extractResponses(typedTuples);
        double threshold = 5.0;
        removeBelowThreshold(keywordRankResponses, threshold);

        return keywordRankResponses;
    }

    private List<SearchRankResponse> extractResponses(Set<ZSetOperations.TypedTuple<String>> typedTuples) {
        return Optional.ofNullable(typedTuples)
                .map(tuples -> tuples.stream()
                        .map(SearchRankResponse::of)
                        .collect(Collectors.toList()))
                .orElse(Collections.emptyList());
    }

    public static void removeBelowThreshold(List<SearchRankResponse> responses, double threshold) {
        responses.removeIf(response -> response.getScore() < threshold);
    }
profile
💻
post-custom-banner

0개의 댓글