저번에 조회수 기준 랭킹을 구현한 데 이어, 이번에는 예매율 기준 랭킹을 구현했습니다. 구현할 내용은 다음과 같습니다.
Redis Sorted Set 사용: 예매 수를 기반으로 상위 50개의 순위를 기록합니다.
키 설정: 일간, 주간, 월간 데이터와 장르별로 구분할 수 있도록 Redis 키를 설정합니다.
주문 성공 시 업데이트: 주문이 성공하면 Redis에 좌석 수만큼 예매 수를 증가시킵니다.
정합성 유지: 주문 환불이나 이벤트 삭제 시 데이터의 정합성을 유지합니다.
랭킹 조회: 상위 50개의 이벤트를 가져와 예매율을 계산하고 행사 정보를 조회합니다. (오전에는 예매율이 낮아 충분한 데이터가 쌓이지 않기 때문에, 오전 10시 30분 이전에는 전일 또는 전주 데이터를 보여줍니다.)
스케줄러 설정: 매일 자정에 전일 및 전주 데이터를 삭제하고 현재 값을 복사하여 저장합니다.
Redis의 ZSet
자료구조를 사용하여 예매 건수를 기반으로 한 이벤트 순위를 기록합니다. 각 이벤트의 예매 수는 Redis에 이벤트 ID를 키로, 예매 건수를 스코어로 저장됩니다.
일간
, 주간
, 월간
)과 장르별(뮤지컬
, 연극
등)로 저장됩니다.ficket:reserve:rank:<기간>:<장르>
형식으로 관리됩니다.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);
}
}
/**
* 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);
}
}
}
/**
* @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);
}
이 메서드는 특정 장르와 기간에 대한 상위 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);
}
/**
* 오전 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분 이전인지 확인하여, 요청된 기간을 전일 또는 전주로 조정합니다./**
* 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
형태로 변환하여 반환합니다./**
* 이벤트 세부 정보를 조회하고 예매율 정보를 생성합니다.
*
* @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
객체로 변환하여 리스트로 반환합니다./**
* 특정 기간 동안의 전체 좌석 수를 조회합니다.
*
* @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
메서드는 주어진 기간 동안의 전체 좌석 수를 조회합니다./**
* 특정 기간에 대한 시작일과 종료일을 계산합니다.
*
* @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
메서드는 주어진 기간에 따라 시작일과 종료일을 계산합니다.일간/주간
데이터의 정합성을 유지하기 위해 전일 및 전주 데이터 삭제 및 백업 작업을 수행합니다.@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