김태오

zset 사용 본문

Redis

zset 사용

ystc1247 2023. 10. 18. 22:39

리더보드를 생성하는 데 그냥 JPA repository 에서 query를 날리는 방식이 있다.

@Query("SELECT new com.swm.cbz.dto.LeaderboardDTO(u.users.userId, COUNT(u.video.videoId)) " +
            "FROM UserVideo u " +
            "GROUP BY u.users.userId " +
            "ORDER BY COUNT(u.video.videoId) DESC")
    List<LeaderboardDTO> findLeaderboardData();

그냥 간단히 이런 식으로 쿼리를 생성하고 controller 랑 service layer에서 사용하면 된다.

기능상 전혀 문제가 없으며, RDBMS에서 데이터를 뽑아옴으로써 persistence가 강화되긴 한다.

그러나 많은 사용자가 리더보드 get 요청을 하고, 리더보드에 들어가는 유저 수가 늘어날수록 성능이 악화될 여지가 많다. 또한 데이터베이스에 동시적 write 요청에 대해 더 정교한 handling logic이 필요할 수도 있다.

 

이럴 때 Redis의 zset 자료구조를 사용할 수 있다. ZSET은 sorted set으로써, Z는 Ziplet을 뜻하는데, 이는 Redis 에서 메모리를 효율적으로 관리하기 위한 인코딩 포맷 중 하나이다. 일반적인 Redis Set 과 같이 멤버들은 모두 unique하며, 더불어 Sorted Set이니만큼 정렬되어 있다. 멤버의 score 기준으로 정렬되는데, 이 score의 data type 은 float이다. 

 

다음은 간단한 ZSET configuration이다. 

implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'io.lettuce:lettuce-core:6.1.8.RELEASE'

일단 build.gradle에 추가해준다. lettuce-core 는 Java Redis Client인데, Jedis와 더불어 가장 많이 쓰인다. 모두 clustering을 지원하지만, Jedis는 사용이 조금 용이한 대신 오직 synchronous 작업만 가능하다. 

@Service
public class DatabaseInitializer {

    @Autowired
    private UserVideoRepository userVideoRepository;

    @Autowired
    private UserRepository userRepository;
    @Autowired
    private StringRedisTemplate redisTemplate;

    @EventListener(ApplicationReadyEvent.class)
    public void initializeLeaderboard() {
        List<LeaderboardDTO> leaderboardData = userVideoRepository.findLeaderboardData();
        List<LeaderboardDTO> evaluationLeaderboard = userRepository.findLeaderboardByTotalScore();
        leaderboardData.forEach(entry -> {
            redisTemplate.opsForZSet().add("video:leaderboard", entry.getUserId().toString(), entry.getPoint());
        });
        evaluationLeaderboard.forEach(entry -> {
            redisTemplate.opsForZSet().add("evaluation:leaderboard", entry.getUserId().toString(), entry.getPoint());
        });
    }
}

이건 RDBMS에서 데이터를 Redis 로 로딩하는 작업인데, Application이 run될 시에 하게 구현되어 있다. 현재 Leaderboard를 불러오는 것 외의 write 작업들은 모두 RDBMS가 관리하기 때문에, RDBMS에 변경이 있을 시 Redis에 즉각적으로 반영되진 않는다. 위의 설정만으로 이것이 해결되진 않지만, Application 구동 시 Redis Database를 함께 initialize해준다고 생각하면 되겠다.

@Service
public class LeaderboardService {
    @Autowired
    private StringRedisTemplate redisTemplate;

    public void incrementUserScore(String userId) {
        redisTemplate.opsForZSet().incrementScore("video:leaderboard", userId, 1);
    }

    public List<LeaderboardDTO> getLeaderboardData() {
        Set<ZSetOperations.TypedTuple<String>> range = redisTemplate.opsForZSet()
                .reverseRangeWithScores("video:leaderboard", 0, 9); // top 10

        assert range != null;
        return range.stream()
                .map(tuple -> new LeaderboardDTO(
                        Long.parseLong(tuple.getValue()),
                        Math.round(tuple.getScore())
                ))
                .collect(Collectors.toList());
    }

    public List<LeaderboardDTO> getEvaluationLeaderboardData() {
        Set<ZSetOperations.TypedTuple<String>> range = redisTemplate.opsForZSet()
                .reverseRangeWithScores("evaluation:leaderboard", 0, 9); // top 10

        assert range != null;
        return range.stream()
                .map(tuple -> new LeaderboardDTO(
                        Long.parseLong(tuple.getValue()),
                        Math.round(tuple.getScore())
                ))
                .collect(Collectors.toList());
    }
}

이후 Service layer에서 구현을 해주는데, StringRedisTemplate을 사용한다. 자료가 <string, float> 형식으로 저장된다. 일단 위에서는 상위 10개의 데이터를 leaderboard로 넘기는데, 9 대신 -1을 사용하면 데이터 전체가 날라간다.

 

이제 RDBMS의 변경시 즉각적인 Redis ZSET에 반영이 되어야 하는데, 그냥 간단하게 변경이 생기는 Service layer에서 SpringRedisTemplate을 생성하여 함께 반영해주면 된다.

private final StringRedisTemplate redisTemplate;
/*
change in RDBMS logic
*/
redisTemplate.opsForZSet().incrementScore("evaluation:leaderboard", userIdStr, apiResponse.getOverall());