Redis를 이용한 랭킹 조회

profile image 스이연 2025. 9. 10. 21:24

이전에 진행했던 프로젝트에서 여행지 방문수에 따른 여행지 랭킹을 조회하는 API 의 코드를 리펙토링 해보았다.

📋 프로젝트 흐름

여행 기록(Record)을 할 때 기록된 다녀온 여행지와 여행 시작 날짜를 기준으로 많이 기록된 상위 7개의 여행지를 항목별로 조회하게 한다.

 

Ranking 관련 API

  • 월별 랭킹
  • 계절별 랭킹

💬 이전에 진행한 방식

사용자가 Record를 작성해 저장할 경우 Record에 속한 여행지들의 정보가 RecordPlace 테이블에 recordId

와 placeId가 따로 저장된다. 이후 랭킹 조회를 할 때 사용자로부터year , month 혹은 season 정보를 받아와서 RecordPlace에 저장된 데이터를 기반으로 랭킹 정보를 조회해서 PlaceRankGetResponse클래스에 담아 응답했다.

 

PlaceRankGetResponse 클래스

public record PlaceRankGetResponse(
        Integer rank, // 랭킹 정보
        Integer visitCount, // 방문수
        PlaceBasicData placeBasicData // 여행지 정보

) {
    public static PlaceRankGetResponse of(Integer rank, Integer visitCount, Place place){
        return new PlaceRankGetResponse(
                rank,
                visitCount,
                PlaceBasicData.fromPlace(place)
        );
    }
}

 

쿼리문을 이용한 랭킹 조회 방식

PlaceService 클래스

[월별 랭킹 구하는 메소드]

public List<PlaceRankGetResponse> getMonthlyRank(String date) {
        List<PlaceRankGetResponse> placeRankGetResponseList = new ArrayList<>();
        List<Place> placeList = placeRepository.findAll();

        for (Place place : placeList) {
            Integer visitCount = recordPlaceRepository.getCount(place.getPlaceId(), date).orElseGet(() -> 0);
            Integer rank = recordPlaceRepository.getRank(place.getPlaceId(), date).orElseGet(() -> 0);
            if (rank <= 7 && rank != 0) {
                placeRankGetResponseList.add(PlaceRankGetResponse.of(rank, visitCount, place));
            }
        }
        Collections.sort(
                placeRankGetResponseList, Comparator.comparing(PlaceRankGetResponse::visitCount).reversed());

        return placeRankGetResponseList;
    }

 

findAll() 메소드를 통해 Place 목록들을 불러오고 각각의 placeId와 파라미터로 받은 date 와 일치하는 recordPlace들의 개수와 랭킹 정보를 placeList 리스트에 담는다.

 

[계절별 랭킹을 구하는 메소드]

public List<PlaceRankGetResponse> getSeasonRank(String year, String season) {
        List<PlaceRankGetResponse> placeRankGetResponseList = new ArrayList<>();
        List<Place> placeList = placeRepository.findAll();
        List<String> dates = getSeasonMonth(year, season);

        for (Place place : placeList) {
            Integer visitCount = recordPlaceRepository.getCountsBySeason(dates.get(0), dates.get(1), dates.get(2),
                    place.getPlaceId()).orElseGet(() -> 0);
            Integer rank = recordPlaceRepository.getRanksBySeason(dates.get(0), dates.get(1), dates.get(2),
                    place.getPlaceId()).orElseGet(() -> 0);
            if (rank <= 7 && rank != 0) {
                placeRankGetResponseList.add(PlaceRankGetResponse.of(rank, visitCount, place));
            }
        }
        Collections.sort(
                placeRankGetResponseList, Comparator.comparing(PlaceRankGetResponse::rank));

        return placeRankGetResponseList;
    }

    private List<String> getSeasonMonth (String year, String season){

        String startMonth = "";
        String middleMonth = "";
        String endMonth = "";
        List<String> dateList = new ArrayList<>();

        switch (season){
            case "봄":
                startMonth = "03";
                middleMonth = "04";
                endMonth = "05";
                dateList = getDateFormat(year, startMonth, middleMonth, endMonth);

                return dateList;
            case "여름":
                startMonth = "06";
                middleMonth = "07";
                endMonth = "08";
                dateList = getDateFormat(year, startMonth, middleMonth, endMonth);

                return dateList;
            case "가을":
                startMonth = "09";
                middleMonth = "10";
                endMonth = "11";
                dateList = getDateFormat(year, startMonth, middleMonth, endMonth);

                return dateList;
            case "겨울":
                startMonth = "01";
                middleMonth = "02";
                endMonth = "12";
                dateList = getDateFormat(year, startMonth, middleMonth, endMonth);

                return dateList;
        }
        return null;
    }

 

placeList 에 모든 place 정보들을 담고 파라미터로 받은 연도와 계절을 getSeasonsMonth() 메소드를 통해 계절의 시작, 중간, 끝 월로 세분화 시켜 dates에 담았다.

모든 place 값들을 for문으로 돌면서 dates 안에 있고 placeId와 동일한 방문 횟수와 랭킹 정보를 받아 placeRankGetResponseList 에 넣었다.

 

🤔 여기서 들었던 의문

사실 recordPlaceRepository 에서 1위에서 7위 안에 드는 장소만 반환하도록 조건문을 걸어두었는데, 왜인지 모르게 service 계층에서 if문으로 한 번 더 rank 범위를 확인하는 로직이 들어가야 제대로된 상위 7위까지의 데이터들이 나왔다. 왜 쿼리문의 조건식이 안먹는지는 알아봐야할 것 같다..

 

RecordPlaceRepository 클래스

visitCount를 구하기 위해서는 jpql을 이용해서 쿼리문을 작성했다.

// 월별 recordPlace 개수 조회
@Query(value = "select count(rp.recordPlace) from RecordPlace rp"
        + " where rp.recordPlace.placeId = :pid and function('date_format',rp.linkedRecord.tripStartDate,'%Y-%m') = :date "
        + "group by rp.recordPlace")
Optional<Integer> getCount(@Param("pid") Long placeId, @Param("date") String date);

// 계절별 recordPlace 개수 조회
 @Query("SELECT COUNT(rp.recordPlace) " +
            "FROM RecordPlace rp " +
            "WHERE ((function('date_format',rp.linkedRecord.tripStartDate,'%Y-%m') between :startDate and :middleDate or function('date_format',rp.linkedRecord.tripStartDate,'%Y-%m') = :endDate)) and rp.recordPlace.placeId = :pid " +
            "GROUP BY rp.recordPlace")
    Optional<Integer> getCountsBySeason(@Param("startDate") String startDate, @Param("middleDate") String middleDate, @Param("endDate") String endDate, @Param("pid") Long placeId);

RecordPlace에 저장된 데이터들 중에 placeId 가 일치하고 date가 일치하는 데이터들을 조회해서 개수를 반환하는 쿼리문이다.

이때 계절별 RecordPlace 개수를 불러오기 위해서 4계절 각각의 시작, 중간, 끝 달을 받아와서 그 달에 해당하는 date 들을 조회하게 했다.

 

rank를 구하기 위해서 nativeQuery를 이용해 쿼리문을 작성했다.

// 월별 recordPlace 랭킹 조회
@Query(value = "select r.ranking from"
            + "(select rank() over (order by count(rp.place_id) desc ) as ranking, rp.place_id "
            + "from record_place rp LEFT JOIN record re ON rp.record_id = re.record_id where DATE_FORMAT(trip_start_date, '%Y-%m') = :date "
            + "group by rp.place_id) r where r.place_id = :pid and r.ranking <= 7 ", nativeQuery = true)
Optional<Integer> getRank(@Param("pid") Long placeId, @Param("date") String date);
    
 // 계절별 recordPlace 랭킹 조회
 @Query(value = "SELECT r.ranking FROM (" +
            "    SELECT rank() over (order by count(rp.place_id) desc ) as ranking, rp.place_id "
            + "FROM record_place rp LEFT JOIN record re ON rp.record_id = re.record_id WHERE DATE_FORMAT(trip_start_date, '%Y-%m') between :startDate and :middleDate or DATE_FORMAT(trip_start_date, '%Y-%m') = :endDate " +
            " GROUP BY rp.place_id) r WHERE r.place_id = :pid ", nativeQuery = true)
	Optional<Integer> getRanksBySeason(@Param("startDate") String startDate, @Param("middleDate") String middleDate, @Param("endDate") String endDate, @Param("pid") Long placeId);

 

월별, 계절별 모두 인라인뷰를 이용해서 랭킹 정보를 불러왔는데, 이때 월별 랭킹의 경우 인라인 뷰 안에서 record_place 테이블과 record 테이블을 조인해서 저장된 여행 시작 날짜가 파라미터로 받은 date와 일치한 7개의 recordPlace 정보들을 불러왔다. 계절별 랭킹은 여행 시작 날짜가 startDate와 middleDate 사이에 있거나 endDate와 일치한 7개의 recordPlace 정보들을 불러오게했다.

 

🤔 between :startDate and :endDate로 하지 않았던 이유

처음에는 그냥 계절 시작 날짜와 종료 날짜만 파라미터로 받아서 그 사이의 데이터들을 불러오려고 했는데, 겨울은 연도가 바뀌는 문제가 있어 조회가 제대로 되지 않는 문제가 발생해서 시작-중간-끝 날짜로 세분화해서 조회하게 되었다.

 

nativeQuery와 jpql의 차이
▪️ nativeQuery
말그대로 자연적 쿼리문 그자체로 mysql 같은 곳에서 작성하는 쿼리문 그대로를 사용하는 방법 테이블명을 db에 저장된 테이블명 그대로 작성해야한다.

▪️ jpql
객체지향적인 쿼리로 엔티티를 쿼리하도록 설계되어 있으며 직접적인 쿼리문을 사용하지 않는다. 테이블명을 엔티티명으로 작성해서 사용한다.

 

이렇게 진행할 경우

쿼리문이 길어져서 가독성이 떨어지고 랭킹을 조회할 때마다 place를 다 불러오고 조건에 맞는 recordPlace의 개수와 랭킹을 불러오며 정렬작업을 해줘야한다.

→ 가독성 저하, 성능이 떨어진다는 문제점 발생

 

Redis를 이용한 랭킹 조회

랭킹을 조회할때마다 쿼리문을 불러와 랭킹을 구하는 방법 대신 여행 기록을 할 때 redisTemplate의 opsForZSet을 이용해서 key를 만들어 해당 키에 방문 횟수를 늘려가며 미리 저장해두고 이후 랭킹 조회할 때 저장해둔 값에서 상위 7개의 값만 불러오는 방식을 적용해보기로 했다.

 

[방문 횟수 업데이트 로직]

private final RedisTemplate<String, String> redisTemplate;
private final PlaceService placeService;

private static final String MONTHLY_RANK_KEY_PREFIX = "ranking:monthly";
private static final String SEASON_RANK_KEY_PREFIX = "ranking:season";

@Transactional
public void updateRanking(List<Long> placeIds, LocalDate tripDate) {
    String monthKey = getMonthKey(tripDate);
    String seasonKey = getSeasonKey(tripDate);

    for(Long placeId : placeIds) {
        // 월별 랭킹 update
        redisTemplate.opsForZSet().incrementScore(monthKey, String.valueOf(placeId), 1);

        // 계절별 랭킹 update
        redisTemplate.opsForZSet().incrementScore(seasonKey, String.valueOf(placeId), 1);
    }
}

// 월별 랭킹 관련 key 생성
private String getMonthKey(LocalDate date) {
    return MONTHLY_RANK_KEY_PREFIX + date.format(DateTimeFormatter.ofPattern("yyyy-MM"));
}

  // 계절별 랭킹 관련 key 생성
private String getSeasonKey(LocalDate date) {
    String season = getSeason(date.getMonthValue());
    return SEASON_RANK_KEY_PREFIX + date.getYear() + ":" + season;
}

    // 입력 받은 달에 따른 계절 반환
private String getSeason(int month) {
    return switch (month) {
        case 3, 4, 5 -> "봄";
        case 6, 7, 8 -> "여름";
        case 9, 10, 11 -> "가을";
        default -> "겨울";
    };
}

사용자가 여행 기록을 했을 때 입력 받은 여행 장소들의 id 값과 여행 시작 날짜를 파라미터로 받는다.

여행 시작 날짜는 getMonthKey()getSeasonKey() 메소드를 통해 월별, 계절별 key 값이 생성된다.

  • 월별 방문 횟수 저장하는 set의 key 생성 (getMonthKey)
    MONTHLY_RANK_KEY_PREFIX 로 월별인지 계절별인지 구별하는 접두어를 앞에 붙이고 date 정보를 뒤에 붙인다.
  • 계절별 방문 횟수를 저장하는 set의 key 생성 (getSeasonKey())
    SEASON_RANK_KEY_PREFIX 로 계절별 조회 횟수임을 구별하는 접두어를 앞에 붙이고 date 를  getSeason() 에 넣어 계절 정보를 반환 받아 년도 + 계절을 접두어 뒤에 붙인다.

이후 파라미터로 받은 여행 장소 id와 앞에서 만든 key 값을 redisTemplate.opsForZSet().incrementScore() 에 넣어 방문 횟수를 늘려준다.

 

ex)
placeIds: [1, 2, 3]
tripDate: 2025-11

key가 ranking:monthly:202511 이고 값이 1~3 인 set에 방문 횟수 1씩 늘려줌.

key가 ranking:season:2025:가을 이고 값이 1~3 인 set에 방문 횟수 1씩 늘려줌.

 

[랭킹 조회하는 로직]

// 월별 랭킹 정보 조회
public List<PlaceRankGetResponse> getMonthlyRanking(String date, int top) {
    String monthlyKey = MONTHLY_RANK_KEY_PREFIX + date;
    Set<ZSetOperations.TypedTuple<String>> topPlaces =
            redisTemplate.opsForZSet().reverseRangeWithScores(monthlyKey, 0, top - 1);
    return convertToResponse(topPlaces);
}

// 계절별 랭킹 정보 조회
public List<PlaceRankGetResponse> getSeasonRanking(String year, String season, int top) {
    String seasonKey = SEASON_RANK_KEY_PREFIX + year + ":" + season;
    Set<ZSetOperations.TypedTuple<String>> topPlaces =
            redisTemplate.opsForZSet().reverseRangeWithScores(seasonKey, 0, top - 1);
    return convertToResponse(topPlaces);
}

// Response 클래스 형태로 변환해주는 메소드
private List<PlaceRankGetResponse> convertToResponse(Set<ZSetOperations.TypedTuple<String>> topPlaces) {
    List<PlaceRankGetResponse> result = new ArrayList<>();
    if(topPlaces == null) return result;

    int rank = 1;
    for(ZSetOperations.TypedTuple<String> tuple : topPlaces) {
        Place place = placeService.getPlaceOrException(Long.parseLong(tuple.getValue()));
        double score = tuple.getScore();
        result.add(PlaceRankGetResponse.of(rank++, (int) score, place));
    }

    return result;
}

 

monthlyKey와 seasonKey를 각각 만들고 opsForZSet().reverseRageWithScores() 에 key 값과 범위를 나타내는 시작과 끝 인덱스 정보를 넣어 범위 안의 set 을 topPlaces에 넣는다.

이후 topPlaces 안의 set 값들을 PlaceRankGetResponse에 넣기 위해 convertToResponse 메소드로 형태를 변환해준다.

 

이렇게 할 경우 redis 메모리에 미리 여행 기록이 될때마다 방문 횟수를 ZSetOperations 인터페이스를 이용해서 key, value 값 으로 저장되는 set 에 저장해두고 랭킹 조회할 때 reverseRageWithScores()를 이용해서 상위 7개의 정보를 미리 저장된 방문수를 기준으로 반환할 수 있다.

 

 

위와 같이 개선한 결과 복잡한 JOIN, COUNT 쿼리문을 작성하지 않아도 직관적이고 빠르게 데이터를 불러올 수 있다는 장점이 있다.

 

🖥️ 성능 테스트

70000 개의 여행 기록 정보의 계절별 랭킹 조회 테스트

약 7.5배 정도 시간을 단축 할 수 있었다.

 

💡 느낀점
당시 프로젝트를 한참 진행하고 있을 때는 사실 월별 랭킹은 크게 구현하는데 문제가 없었지만 계절별 랭킹을 구현할 때 날짜에 따라 계절 정보를 판별하고, rank() 함수를 쿼리문에 적용해야해서 불가피하게 nativeQuery를 사용해버려 쿼리문이 매우 복잡해졌었기 때문에 골치가 아팠던 기억이 있다. 너무 긴 쿼리문에 비효율적인 코드라고 생각은 들었지만 당시에는 해결방법이 생각나지 않아 그대로 방치해두었는데, 이번 기회에 redis를 공부하게 되면서 해당 프로젝트에 적용해보고 성능 완화를 시켜보니 뿌듯했다. 이번에 redis를 공부하고 처음으로 실습해본 것이기 때문에 이후 redis를 더 공부해서 다양한 방면으로 활용해보고 싶다는 생각이 들었다.