[Spring] Redis 예매율 순위 시스템 구현

오형상·2024년 12월 15일
0

Ficket

목록 보기
18/27

저번에 조회수 기준 랭킹을 구현한 데 이어, 이번에는 예매율 기준 랭킹을 구현했습니다. 구현할 내용은 다음과 같습니다.

  1. Redis Sorted Set 사용: 예매 수를 기반으로 상위 50개의 순위를 기록합니다.

  2. 키 설정: 일간, 주간, 월간 데이터와 장르별로 구분할 수 있도록 Redis 키를 설정합니다.

  3. 주문 성공 시 업데이트: 주문이 성공하면 Redis에 좌석 수만큼 예매 수를 증가시킵니다.

  4. 정합성 유지: 주문 환불이나 이벤트 삭제 시 데이터의 정합성을 유지합니다.

  5. 랭킹 조회: 상위 50개의 이벤트를 가져와 예매율을 계산하고 행사 정보를 조회합니다. (오전에는 예매율이 낮아 충분한 데이터가 쌓이지 않기 때문에, 오전 10시 30분 이전에는 전일 또는 전주 데이터를 보여줍니다.)

  6. 스케줄러 설정: 매일 자정에 전일 및 전주 데이터를 삭제하고 현재 값을 복사하여 저장합니다.


1. Redis Sorted Set을 활용한 랭킹 관리

Redis의 ZSet 자료구조를 사용하여 예매 건수를 기반으로 한 이벤트 순위를 기록합니다. 각 이벤트의 예매 수는 Redis에 이벤트 ID를 키로, 예매 건수를 스코어로 저장됩니다.

1-1. 일간, 주간, 월간 데이터 장르별 분리

  • 랭킹 데이터는 기간(일간, 주간, 월간)과 장르별(뮤지컬, 연극 등)로 저장됩니다.
  • Redis 키는 ficket:reserve:rank:<기간>:<장르> 형식으로 관리됩니다.

1-2. 예매 순위 갱신

  • 주문 성공 이벤트가 발생하면, Redis ZSet의 스코어를 증가시켜 해당 이벤트의 예매 순위를 갱신합니다.
    /**
     * 구매한 좌석 수만큼 예매 순위 업데이트
     *
     * @param eventId   구매한 eventId
     * @param eventGenre 해당 event의 장르
     * @param seatCount 구매한 좌석 수
     */
    private void updateReservationCount(Long eventId, List<Genre> eventGenre, int seatCount) {
        for (Genre genre : eventGenre) {
            String reservationDailyKey = RedisKeyHelper.getReservationKey(DAILY, genre);
            rankingRedisTemplate.opsForZSet().incrementScore(reservationDailyKey, String.valueOf(eventId), seatCount);

            String reservationWeeklyKey = RedisKeyHelper.getReservationKey(WEEKLY, genre);
            rankingRedisTemplate.opsForZSet().incrementScore(reservationWeeklyKey, String.valueOf(eventId), seatCount);

            String reservationMonthlyKey = RedisKeyHelper.getReservationKey(MONTHLY, genre);
            rankingRedisTemplate.opsForZSet().incrementScore(reservationMonthlyKey, String.valueOf(eventId), seatCount);
        }
    }

2. 정합성 유지

2-1. event 삭제

  • 이벤트 삭제 시, Redis에 저장된 해당 이벤트의 모든 랭킹 데이터를 제거합니다.
/**
     * Redis의 모든 랭킹 데이터에서 특정 이벤트를 제거합니다.
     *
     * @param eventId 삭제할 이벤트 ID.
     */
    private void deleteEventFromRankings(Long eventId) {
        Period[] periods = values();
        Genre[] genres = Genre.values();

        for (Period period : periods) {
            for (Genre genre : genres) {
                String rankingKey = RedisKeyHelper.getReservationKey(period, genre);
                rankingRedisTemplate.opsForZSet().remove(rankingKey, eventId.toString());
                log.info("Removed eventId {} from ranking: {}", eventId, rankingKey);
            }
        }
    }

2-2. 환불

  • 환불이 발생하면, 환불된 좌석 수만큼 예매 건수를 감소시켜 Redis의 랭킹 데이터를 갱신합니다.
/**
     * @param ticketInfo 환불 티켓 정보
     */
    @Transactional
    public void refund(TicketInfo ticketInfo) {
        Long ticketId = ticketInfo.getTicketId();
        int seatCount = seatMappingRepository.countSeatMappingByTicketId(ticketId);
        Event event = eventScheduleRepository.findEventByEventScheduleId(ticketInfo.getEventScheduleId())
                .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_NOT_FOUND));

        updateReservationCount(event.getEventId(), event.getGenre(), -seatCount);

    }

3. 랭킹 조회

이 메서드는 특정 장르와 기간에 대한 상위 50개의 예매율 순위를 조회하는 기능을 담당합니다.

/**
 * 특정 장르와 기간에 대한 상위 50개의 예매율 순위를 조회합니다.
 *
 * @param genre  조회할 이벤트의 장르 (Genre Enum).
 * @param period 조회할 기간 (Period Enum).
 * @return 이벤트 세부 정보와 예매율 정보를 포함한 ReservationRateEventInfoResponse 목록.
 */
public List<ReservationRateEventInfoResponse> getTopFiftyReservationRateRank(Genre genre, Period period) {
    period = adjustPeriodByCutoffTime(period);

    List<Pair<Long, BigDecimal>> eventIdWithScores = getTopEventsFromRedis(genre, period);
    if (eventIdWithScores.isEmpty()) {
        return Collections.emptyList();
    }

    BigDecimal totalSeatCount = getTotalSeatCountForPeriod(period);
    return fetchEventDetails(eventIdWithScores, totalSeatCount);
}

3.1 기간 조정

/**
 * 오전 10시 30분 이전에 요청된 경우, 기간을 전일 또는 전주로 변경합니다.
 *
 * @param period 입력된 기간 (Period Enum).
 * @return 조정된 기간 (Period Enum).
 */
private Period adjustPeriodByCutoffTime(Period period) {
    LocalTime now = LocalTime.now();
    LocalTime cutoffTime = LocalTime.of(10, 30);

    if (now.isBefore(cutoffTime)) {
        if (period == Period.DAILY) return Period.PREVIOUS_DAILY;
        if (period == Period.WEEKLY) return Period.PREVIOUS_WEEKLY;
    }
    return period;
}
  • adjustPeriodByCutoffTime 메서드는 현재 시간이 오전 10시 30분 이전인지 확인하여, 요청된 기간을 전일 또는 전주로 조정합니다.

3-2. Redis에서 이벤트 조회

/**
 * Redis에서 특정 장르와 기간의 상위 50개의 이벤트 ID 및 점수를 조회합니다.
 *
 * @param genre  조회할 이벤트의 장르 (Genre Enum).
 * @param period 조회할 기간 (Period Enum).
 * @return 이벤트 ID와 점수를 포함한 Pair 목록.
 */
private List<Pair<Long, BigDecimal>> getTopEventsFromRedis(Genre genre, Period period) {
    Set<ZSetOperations.TypedTuple<Object>> rankedEvents = rankingRedisTemplate.opsForZSet()
            .reverseRangeWithScores(RedisKeyHelper.getReservationKey(period, genre), 0, 49);

    if (rankedEvents == null || rankedEvents.isEmpty()) {
        return Collections.emptyList();
    }

    return rankedEvents.stream()
            .map(tuple -> Pair.of(
                    Long.parseLong(tuple.getValue().toString()),  // 이벤트 ID
                    BigDecimal.valueOf(tuple.getScore())          // 점수
            ))
            .toList();
}
  • getTopEventsFromRedis 메서드는 Redis에서 특정 장르와 기간에 대한 상위 50개의 이벤트를 가져옵니다.
  • reverseRangeWithScores 메서드를 사용하여 해당 키에 대한 순위를 조회하고, 결과를 Pair 형태로 변환하여 반환합니다.

3-3. 이벤트 세부 정보 조회

/**
 * 이벤트 세부 정보를 조회하고 예매율 정보를 생성합니다.
 *
 * @param eventIdWithScores 이벤트 ID와 점수를 포함한 Pair 목록.
 * @param totalSeatCount    기간 동안의 전체 좌석 수.
 * @return ReservationRateEventInfoResponse 목록.
 */
private List<ReservationRateEventInfoResponse> fetchEventDetails(List<Pair<Long, BigDecimal>> eventIdWithScores, BigDecimal totalSeatCount) {
    return eventIdWithScores.stream()
            .map(eventWithScore -> {
                Long eventId = eventWithScore.getKey();
                BigDecimal score = eventWithScore.getValue();

                EventDetailRes eventDetail = getEventDetailFromCacheOrDB(eventId);
                if (eventDetail == null) {
                	throw new BusinessException(ErrorCode.EVENT_DETAIL_NOT_FOUND)
                }

                return ReservationRateEventInfoResponse.toReservationRateEventInfoResponse(eventId, eventDetail, score, totalSeatCount);
            })
            .filter(Objects::nonNull)
            .toList();
}
  • fetchEventDetails 메서드는 이벤트 ID와 점수를 기반으로 세부 정보를 조회하여 예매율 정보를 생성합니다.
  • 각 이벤트에 대해 getEventDetailFromCacheOrDB 메서드를 호출하여 캐시 또는 DB에서 이벤트 세부 정보를 가져오고,ReservationRateEventInfoResponse 객체로 변환하여 리스트로 반환합니다.

3-4. 전체 좌석 수 조회

/**
 * 특정 기간 동안의 전체 좌석 수를 조회합니다.
 *
 * @param period 조회할 기간 (Period Enum).
 * @return 해당 기간의 전체 좌석 수 (BigDecimal).
 */
public BigDecimal getTotalSeatCountForPeriod(Period period) {
    String cacheKey = RedisKeyHelper.getTotalSeatCount(period);
    BigDecimal totalSeatCount = (BigDecimal) redisTemplate.opsForValue().get(cacheKey);

    if (totalSeatCount == null) {
        LocalDateTime[] range = getPeriodRange(period);
        totalSeatCount = stageSeatRepository.findTotalSeatsForPeriod(range[0], range[1]);
        redisTemplate.opsForValue().set(cacheKey, totalSeatCount, Duration.ofHours(1));
    }
    return totalSeatCount;
}
  • getTotalSeatCountForPeriod 메서드는 주어진 기간 동안의 전체 좌석 수를 조회합니다.
  • Redis 캐시에서 값을 가져오고, 캐시가 없다면 데이터베이스에서 직접 조회하여 캐시에 저장합니다.

3-5. 기간 범위 계산

/**
 * 특정 기간에 대한 시작일과 종료일을 계산합니다.
 *
 * @param period 조회할 기간 (Period Enum).
 * @return 시작일과 종료일을 포함한 LocalDateTime 배열.
 */
private LocalDateTime[] getPeriodRange(Period period) {
    LocalDate date = LocalDate.now();
    return switch (period) {
        case DAILY -> getStartAndNowOfDay(date);
        case PREVIOUS_DAILY -> getStartAndEndOfDay(date.minusDays(1));
        case WEEKLY -> getStartAndEndOfWeek(date);
        case PREVIOUS_WEEKLY -> getStartAndEndOfWeek(date.minusWeeks(1));
        case MONTHLY -> getStartAndEndOfMonth(date);
        default -> throw new BusinessException(ErrorCode.INPUT_VALUE_INVALID);
    };
}
  • getPeriodRange 메서드는 주어진 기간에 따라 시작일과 종료일을 계산합니다.

4. 스케줄러를 통한 데이터 관리

  • 매일 자정에 일간/주간 데이터의 정합성을 유지하기 위해 전일 및 전주 데이터 삭제 및 백업 작업을 수행합니다.
@Slf4j
@Component
@RequiredArgsConstructor
public class RankingResetScheduler {

    @Qualifier("rankingRedisTemplate")
    private final RedisTemplate<String, Object> redisTemplate;

    /**
     * 매일 자정에 전주/전일 데이터를 삭제하고 복사
     */
    @Scheduled(cron = "0 0 0 * * ?") // 매일 자정
    public void archiveAndResetPreviousData() {

        Genre[] genres = Genre.values();

        for (Genre genre : genres) {
            // 전일 데이터 삭제 및 복사
            String dailyKey = RedisKeyHelper.getReservationKey(Period.DAILY, genre);
            String previousDailyKey = RedisKeyHelper.getReservationKey(Period.PREVIOUS_DAILY, genre);
            archiveAndReset(dailyKey, previousDailyKey);

            // 주간 데이터 삭제 및 복사
            String weeklyKey = RedisKeyHelper.getReservationKey(Period.WEEKLY, genre);
            String previousWeeklyKey = RedisKeyHelper.getReservationKey(Period.PREVIOUS_WEEKLY, genre);
            archiveAndReset(weeklyKey, previousWeeklyKey);
        }

        log.info("Archived and reset previous daily and weekly rankings at midnight");
    }

    /**
     * 데이터 복사 및 초기화
     *
     * @param sourceKey      복사할 원본 키
     * @param destinationKey 복사 대상 키
     */
    private void archiveAndReset(String sourceKey, String destinationKey) {
        // 원본 데이터 가져오기
        Set<ZSetOperations.TypedTuple<Object>> sourceData = redisTemplate.opsForZSet().rangeWithScores(sourceKey, 0, -1);

        // 원본 데이터를 대상 키로 복사
        if (sourceData != null && !sourceData.isEmpty()) {
            sourceData.forEach(tuple -> {
                redisTemplate.opsForZSet().add(destinationKey, tuple.getValue(), tuple.getScore());
            });
            log.info("Data copied from {} to {}", sourceKey, destinationKey);
        } else {
            log.warn("No data found for key: {}", sourceKey);
        }

        // 원본 데이터 초기화
        redisTemplate.delete(sourceKey);
        log.info("Data reset for key: {}", sourceKey);
    }
}

Reference

0개의 댓글