본문 바로가기
카테고리 없음

Redis ZSET 기반 실시간 랭킹 시스템 구축하기

by junho7778 2025. 9. 11.

들어가며

현대의 이커머스나 콘텐츠 플랫폼에서 실시간 랭킹 시스템은 필수 기능이 되었습니다. 사용자의 행동(조회, 좋아요, 구매 등)을 즉시 반영하여 인기 상품이나 콘텐츠를 빠르게 노출시키는 것이 사용자 경험과 비즈니스 성과에 직결되기 때문입니다.

이번 글에서는 Redis ZSET(Sorted Set)과 Kafka를 활용하여 대용량 트래픽을 처리할 수 있는 실시간 랭킹 시스템을 어떻게 구축했는지 상세히 공유하겠습니다.

시스템 아키텍처 개요

우리의 랭킹 시스템은 다음과 같은 구조로 설계되었습니다:

┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│   Client API    │    │  Kafka Events   │    │   Redis ZSET    │
│    Requests     │    │  (Real-time)    │    │   (Rankings)    │
└─────────────────┘    └─────────────────┘    └─────────────────┘
         │                       │                       │
         ▼                       ▼                       ▼
┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│RankingController│    │ RankingConsumer │    │ RankingService  │
│   (API Layer)   │    │(Event Processor)│    │ (Domain Logic)  │
└─────────────────┘    └─────────────────┘    └─────────────────┘

핵심 컴포넌트:

  • Commerce-API: 사용자 요청 처리 및 이벤트 발행
  • Kafka: 비동기 이벤트 스트리밍
  • Commerce-Streamer: 이벤트 소비 및 랭킹 집계
  • Redis ZSET: 실시간 랭킹 데이터 저장

1. 도메인 모델 설계

RankingKey: Redis 키 관리

public class RankingKey {
    private static final String RANKING_PREFIX = "ranking";
    private static final String ALL_PRODUCTS = "all";
    private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd");

    private final String keyType; // "all"
    private final String date;    // yyyyMMdd

    public static RankingKey dailyAll(LocalDate date) {
        return new RankingKey(ALL_PRODUCTS, date);
    }

    public String getKey() {
        return String.format("%s:%s:%s", RANKING_PREFIX, keyType, date);
    }
}

Redis 키를 ranking:all:20240909 형식으로 일별 분리하여 TTL 관리와 확장성을 확보했습니다.

RankingWeight: 가중치 시스템

public final class RankingWeight {
    public static final double VIEW_WEIGHT = 0.1;
    public static final double LIKE_WEIGHT = 0.2;
    public static final double ORDER_WEIGHT = 0.7;
    public static final double CARRY_OVER_WEIGHT = 0.1; // 콜드 스타트 방지

    public static double calculateOrderScore(long quantity, double unitPrice) {
        return Math.log1p(quantity * unitPrice) * ORDER_WEIGHT;
    }
}

사용자 행동별로 차등 가중치를 적용하여 의미 있는 랭킹을 생성합니다.

2. 이벤트 기반 아키텍처

Kafka 토픽 구성

  • catalog-events: 상품 관련 이벤트 (PRODUCT_LIKED, PRODUCT_UNLIKED, STOCK_ADJUSTED)
  • order-events: 주문 관련 이벤트 (ORDER_CREATED, PAYMENT_PROCESSED)

이벤트 발행 (Producer)

@Component
public class KafkaEventHandler {
    
    @EventListener
    public void handleProductLikeEvent(ProductLikeEvent event) {
        CatalogEvent catalogEvent = CatalogEvent.builder()
            .eventId(UUID.randomUUID().toString())
            .eventType(event.isLiked() ? "PRODUCT_LIKED" : "PRODUCT_UNLIKED")
            .productId(event.getProductId())
            .build();
            
        kafkaEventPublisher.publish("catalog-events", catalogEvent);
    }
}

도메인 이벤트를 Kafka 메시지로 변환하여 비동기 처리를 가능하게 합니다.

3. 실시간 랭킹 집계 (Consumer)

배치 처리와 멱등성

@Component
public class RankingConsumer {
    
    @KafkaListener(
        topics = {"catalog-events", "order-events"},
        containerFactory = KafkaConfig.BATCH_LISTENER,
        groupId = "ranking-consumer-group"
    )
    @Transactional
    public void handleRankingEvents(List<ConsumerRecord<String, JsonNode>> messages,
                                   Acknowledgment acknowledgment) {
        
        List<ProductScoreUpdate> batchUpdates = new ArrayList<>();
        LocalDate today = LocalDate.now();
        
        for (ConsumerRecord<String, JsonNode> message : messages) {
            JsonNode eventData = message.value();
            String eventId = eventData.get("eventId").asText();
            String eventType = eventData.get("eventType").asText();
            
            // 멱등성 처리
            boolean processed = idempotentEventService.processEventIdempotent(
                eventId, EventHandled.ConsumerType.RANKING, version, () -> {
                    ProductScoreUpdate update = processRankingEvent(eventData, eventType, today);
                    if (update != null) {
                        batchUpdates.add(update);
                    }
                });
        }
        
        // 배치로 랭킹 업데이트
        if (!batchUpdates.isEmpty()) {
            rankingService.batchUpdateRankingScores(batchUpdates, today);
        }
        
        acknowledgment.acknowledge();
    }
}

핵심 특징:

  • 배치 처리: 여러 이벤트를 한 번에 처리하여 성능 최적화
  • 멱등성 보장: 중복 이벤트 처리 방지
  • 수동 ACK: 처리 성공 후에만 메시지 확인

멱등성 처리

@Service
public class IdempotentEventService {
    
    @Transactional
    public boolean processEventIdempotent(String eventId, 
                                        EventHandled.ConsumerType consumerType,
                                        Long version, Runnable processor) {
        
        // 이미 처리된 이벤트인지 확인
        if (isEventAlreadyHandled(eventId, consumerType)) {
            return false;
        }
        
        // 처리 표시
        EventHandled eventHandled = new EventHandled(
            eventId, consumerType, LocalDateTime.now(), version);
        eventHandledRepository.save(eventHandled);
        
        // 실제 비즈니스 로직 실행
        processor.run();
        return true;
    }
}

event_handled 테이블을 통해 이벤트 중복 처리를 방지합니다.

4. Redis ZSET 최적화

기본 랭킹 업데이트

@Service
public class RankingService {
    
    // 개별 점수 업데이트
    public void incrementScore(RankingKey rankingKey, Long productId, double score) {
        ZSetOperations<String, Object> zSetOps = redisTemplate.opsForZSet();
        zSetOps.incrementScore(rankingKey.getKey(), productId.toString(), score);
        setTTLIfNeeded(rankingKey.getKey());
    }
    
    // 배치 업데이트 (Pipeline)
    public void batchUpdateRankingScores(List<ProductScoreUpdate> updates, LocalDate eventDate) {
        String key = RankingKey.dailyAll(eventDate).getKey();
        
        redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
            byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);
            for (ProductScoreUpdate update : updates) {
                byte[] memberBytes = update.productId().toString().getBytes(StandardCharsets.UTF_8);
                connection.zIncrBy(keyBytes, update.score(), memberBytes);
            }
            return null;
        });
        
        setTTLIfNeeded(key);
    }
}

성능 최적화 핵심

1. Pipeline 배치 처리

개별 Redis 호출 대신 Pipeline을 활용하여 네트워크 RTT를 최소화했습니다.

2. ZUNIONSTORE를 활용한 콜드 스타트 방지

// 전날 랭킹의 10%를 오늘 랭킹에 이월
public void carryOverPreviousRanking(LocalDate currentDate) {
    String currentKey = RankingKey.dailyAll(currentDate).getKey();
    String previousKey = RankingKey.dailyAll(currentDate.minusDays(1)).getKey();
    
    redisTemplate.execute((RedisCallback<Object>) connection -> {
        connection.execute("ZUNIONSTORE", 
            currentKey.getBytes(StandardCharsets.UTF_8),
            "1".getBytes(StandardCharsets.UTF_8),
            previousKey.getBytes(StandardCharsets.UTF_8),
            "WEIGHTS".getBytes(StandardCharsets.UTF_8),
            String.valueOf(RankingWeight.CARRY_OVER_WEIGHT).getBytes(StandardCharsets.UTF_8));
        
        connection.expire(currentKey.getBytes(StandardCharsets.UTF_8), TTL.getSeconds());
        return null;
    });
}

ZUNIONSTORE 명령어로 전날 랭킹을 한 번에 복사하여 O(1) 시간복잡도를 달성했습니다.

5. 랭킹 조회 API

Top-N 랭킹 조회

public List<RankingItem> getTopRanking(RankingKey rankingKey, int size, int page) {
    long start = (long) page * size;
    long end = start + size - 1;
    
    ZSetOperations<String, Object> zSetOps = redisTemplate.opsForZSet();
    Set<ZSetOperations.TypedTuple<Object>> tuples = 
        zSetOps.reverseRangeWithScores(rankingKey.getKey(), start, end);
    
    List<RankingItem> items = new ArrayList<>();
    long rank = start + 1;
    for (ZSetOperations.TypedTuple<Object> tuple : tuples) {
        Long productId = Long.valueOf(tuple.getValue().toString());
        items.add(new RankingItem(productId, tuple.getScore(), rank++));
    }
    return items;
}

개별 상품 랭킹 조회

public RankingItem getProductRanking(RankingKey rankingKey, Long productId) {
    ZSetOperations<String, Object> zSetOps = redisTemplate.opsForZSet();
    String key = rankingKey.getKey();
    String member = productId.toString();
    
    Double score = zSetOps.score(key, member);
    Long rank = zSetOps.reverseRank(key, member);
    
    if (score == null || rank == null) {
        return null;
    }
    
    return new RankingItem(productId, score, rank + 1); // 0-based → 1-based
}

6. 전체 데이터 플로우

실시간 랭킹 업데이트

1. 사용자 행동 발생 (상품 조회/좋아요/주문)
   ↓
2. 이벤트 발행 → Kafka Topics (catalog-events, order-events)
   ↓
3. RankingConsumer가 배치로 이벤트 수신
   ↓
4. IdempotentEventService로 중복 처리 방지
   ↓
5. 이벤트 타입별 가중치 적용 (VIEW:0.1, LIKE:0.2, ORDER:0.7)
   ↓
6. Redis ZSET에 점수 업데이트 (ZINCRBY ranking:all:20240909 0.7 "123")
   ↓
7. TTL 설정 (2일 후 자동 삭제)

API 조회 플로우

1. 클라이언트 API 요청 (GET /api/v1/rankings)
   ↓
2. RankingController에서 요청 파라미터 검증
   ↓
3. RankingFacade에서 비즈니스 로직 처리
   ↓
4. RankingService에서 Redis ZSET 조회 (ZREVRANGE with SCORES)
   ↓
5. ProductService에서 상품 정보 배치 조회
   ↓
6. 랭킹 + 상품 정보 결합 → RankingResponse
   ↓
7. 페이징 처리 후 ApiResponse로 반환

 

7. 운영 환경 고려사항

TTL 관리 전략

private void setTTLIfNeeded(String key) {
    Boolean hasKey = redisTemplate.hasKey(key);
    if (hasKey != null && hasKey) {
        Long ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
        if (ttl != null && ttl == -1) { // TTL이 설정되지 않은 경우에만
            redisTemplate.expire(key, Duration.ofDays(2));
        }
    }
}

각 랭킹 키는 2일 TTL로 자동 관리되어 메모리 사용량을 제어합니다.

 

마무리

Redis ZSET을 활용한 실시간 랭킹 시스템 구축을 통해 다음을 달성했습니다:

  1. 높은 성능: Pipeline과 ZUNIONSTORE 최적화
  2. 안정성: 멱등성 처리와 TTL 관리로 데이터 일관성 확보
  3. 확장성: 이벤트 기반 아키텍처로 기능 확장 용이
  4. 운영 효율성: 멀티 컨슈머 구조로 각각의 역할 분리

특히 Redis ZSET의 강력한 정렬 기능과 Kafka의 실시간 스트리밍 처리 능력을 결합하여 실용적이고 확장 가능한 랭킹 시스템을 구현할 수 있었습니다.

이 시스템은 현재 프로덕션 환경에서 안정적으로 운영되고 있으며, 향후 카테고리별 랭킹이나 개인화 랭킹 등으로 확장할 예정입니다.