문제의 시작

정렬된 데이터를 매번 DB에서 계산하는 것은 간단하지만 비싸다. 특히 ranking, score, range query가 반복되는 기능에서는 자료구조 선택이 곧 성능 설계가 된다. 이 글은 Redis ZSET을 Spring Boot에서 사용하며 score 기반 정렬 데이터를 어떻게 다룰 수 있는지 확인한 기록이다.

리더보드를 생성하는 가장 단순한 방식은 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();

예를 들어 이런 식으로 query를 만들고 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.gradlelettuce-core를 추가한다. Lettuce는 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에서 StringRedisTemplate을 사용해 함께 반영하면 된다.

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

설계 기준

ZSET은 단순 cache보다 훨씬 명확한 목적을 가진 도구다. score가 business meaning을 가질 때, Redis는 단순 저장소가 아니라 query pattern을 바꾸는 자료구조가 된다. 대신 score update, 동시성, DB fallback 기준까지 함께 설계해야 운영에서 안전하다.