Redis Sorted Set을 이용한 리더보드 구현하기

이진일·2026년 5월 12일
post-thumbnail

🏆 실시간 리더보드, 왜 Sorted Set을 사용해야 할까?

쇼핑몰의 '인기 상품 TOP 10'이나 게임의 '실시간 랭킹'처럼 순위를 매겨 보여주는 기능은 사용자 경험에 매우 중요합니다. 하지만 이를 RDB(관계형 데이터베이스)만으로 구현하려면 성능 고민이 깊어질 수밖에 없습니다. 왜 리더보드에서 Redis의 Sorted Set이 좋은지 알아봅니다.


1. 관계형 데이터베이스(RDB)의 한계

이커머스 서비스에서 '가장 많이 팔린 물품'을 조회한다고 가정해 봅시다. 보통은 아래와 같은 과정을 거칩니다.

  • 복잡한 쿼리: Item 테이블과 Order 테이블을 JOIN하고, SUM이나 COUNT 같은 집계 함수를 사용해야 합니다.
  • 성능 저하: 데이터가 수십만 건 이상 쌓이면 매번 집계하고 정렬(ORDER BY)하는 과정에서 DB 부하가 급증합니다.
  • 해결책의 딜레마: 물품 테이블에 sales_count 컬럼을 추가해도, 주문이 들어올 때마다 빈번하게 UPDATE가 발생하므로 쓰기 성능과 데이터 정합성 문제가 생길 수 있습니다.

2. Redis Sorted Set의 등장

Redis의 Sorted Set은 값(Member)과 점수(Score)를 쌍으로 저장하며, 저장과 동시에 점수 순으로 데이터를 정렬합니다.

  • 효율적인 연산:
    • ZADD(데이터 추가): O(log(N))O(log(N))
    • ZRANGE(범위 조회): O(log(N)+M)O(log(N) + M)
  • 실시간성: 데이터가 들어오는 즉시 순위가 결정되므로 별도의 정렬 쿼리가 필요 없습니다.

3. Spring Boot로 인기 상품 리더보드 구현하기

🔹 1. RedisTemplate 설정

객체 데이터(ItemDto)를 JSON 형태로 저장하기 위해 Serializer를 설정한 Bean을 등록합니다.

@Bean
public RedisTemplate<String, ItemDto> rankTemplate(RedisConnectionFactory factory) {
    RedisTemplate<String, ItemDto> template = new RedisTemplate<>();
    template.setConnectionFactory(factory);
    template.setKeySerializer(RedisSerializer.string());
    template.setValueSerializer(RedisSerializer.json()); // JSON 직렬화
    return template;
}

🔹 2. Service 레이어 구성

RedisTemplate에서 Sorted Set을 조작하기 위해서는 ZSetOperations 인터페이스가 필요합니다. 생성자를 통해 이를 주입받아 사용합니다.

@Slf4j
@Service
public class ItemService {
    private final ItemRepository itemRepository;
    private final OrderRepository orderRepository;
    private final ZSetOperations<String, ItemDto> rankOps;
    
    public ItemService(
            ItemRepository itemRepository,
            OrderRepository orderRepository,
            RedisTemplate<String, ItemDto> rankTemplate
    ) {
        this.itemRepository = itemRepository;
        this.orderRepository = orderRepository;
        this.rankOps = rankTemplate.opsForZSet(); // ZSetOperations 초기화
    }

    public void purchase(Long id) {
        Item item = itemRepository.findById(id)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
        
        orderRepository.save(ItemOrder.builder()
                .item(item)
                .count(1)
                .build());

        // ZINCRBY 명령과 동일: 해당 아이템의 점수를 1 증가시킴
        rankOps.incrementScore("soldRanks", ItemDto.fromEntity(item), 1);
    }

    public List<ItemDto> getMostSold() {
        // 점수가 높은 순서대로 10개 조회 (ZREVRANGE)
        Set<ItemDto> ranks = rankOps.reverseRange("soldRanks", 0, 9);
        if (ranks == null) return Collections.emptyList();
        return ranks.stream().toList();
    }
}

🔹 3. 점수 업데이트 (구매 로직)

사용자가 상품을 구매할 때마다 incrementScore(ZINCRBY)를 호출하여 해당 아이템의 점수를 1점씩 올립니다.

public void purchase(Long id) {
    Item item = itemRepository.findById(id).orElseThrow();
    
    // 주문 내역 저장 (RDB)
    orderRepository.save(ItemOrder.builder().item(item).count(1).build());
    
    // 리더보드 점수 업데이트 (Redis)
    // 데이터가 없으면 새로 추가하고, 있으면 기존 점수에 누적합니다.
    rankOps.incrementScore("soldRanks", ItemDto.fromEntity(item), 1);
}

🔹 4. 리더보드 조회 (TOP 10)

Sorted Set은 기본적으로 오름차순 정렬이므로, 내림차순으로 가져오기 위해 reverseRange를 사용합니다.

public List<ItemDto> getMostSold() {
    // 0위부터 9위까지 내림차순 조회
    Set<ItemDto> ranks = rankOps.reverseRange("soldRanks", 0, 9);
    
    if (ranks == null) return Collections.emptyList();
    return ranks.stream().toList();
}

💡 결론

Redis의 Sorted Set을 활용하면 성능 최적화는 물론, 구현 난이도까지 획기적으로 낮출 수 있습니다. 단순히 랭킹 기능뿐만 아니라 실시간 검색어, 대기열 시스템 등 다양한 분야에서 활용할 수 있습니다!

0개의 댓글