Ficket 프로젝트를 진행하며 조회수를 기준으로 실시간 랭킹 구현을 맡았습니다.
프로젝트에서는 기존에 Spring의 @Cacheable
을 사용해 Redis 캐시를 통해 성능 최적화를 구현한 이벤트 상세 조회 API가 있었는데, 이를 활용하여 랭킹 시스템을 구현하려 했습니다. 하지만 캐시에 데이터가 있는 경우 조회수 증가 로직이 실행되지 않는 문제를 발견하였고, 이를 해결하기 위해 코드를 수정했습니다.
이벤트 상세 데이터를 캐싱하여 데이터베이스 접근을 최소화하고, API 응답 속도를 최적화하는 것이 주요 목표였습니다.
Spring의 @Cacheable
어노테이션을 사용하여 Redis에 캐시를 저장하는 방식으로 설계되었습니다.
이벤트 상세 조회 API는 다음과 같이 작성되어 있었습니다:
@Cacheable(value = "eventDetails", key = "#eventId", unless = "#result == null")
public EventDetailRes getEventDetail(Long eventId) {
// 1. 데이터베이스에서 이벤트 조회
Event event = eventRepository.findById(eventId)
.orElseThrow(() -> new BusinessException(ErrorCode.EVENT_NOT_FOUND));
// 2. Event 데이터를 EventDetailRes로 변환 후 반환
return EventDetailRes.toEventDetailRes(event);
}
랭킹 기능을 구현하기 위해 조회수 증가 로직을 추가하려 했습니다. Redis의 Sorted Set (ZSet)을 활용해 조회수 데이터를 관리하고, 이를 기반으로 인기 이벤트를 정렬하려는 구조였습니다.
하지만 기존 @Cacheable
로직과 조회수 증가 로직이 의도한 대로 동작하지 않는 문제가 있었습니다.
랭킹 기능에서 조회수는 항상 증가해야 하므로 기존 코드를 다음과 같이 수정하였습니다:
1. @Cacheable
을 제거하고, RedisTemplate을 사용해 캐싱을 수동으로 관리하도록 변경했습니다.
2. 캐시에서 데이터를 반환하더라도, 조회수 증가 로직이 항상 실행되도록 수정했습니다.
public EventDetailRes getEventDetail(HttpServletRequest request, HttpServletResponse response, Long eventId) {
// 1. Redis 캐시 키 생성
String cacheKey = RedisKeyHelper.getEventDetailCacheKey(eventId);
// 2. 캐시에서 데이터 확인
EventDetailRes cachedEvent = (EventDetailRes) redisTemplate.opsForValue().get(cacheKey);
// 3. 조회수 증가 (항상 실행)
if (!isDuplicateView(request, response, eventId)) {
incrementViewCount(eventId); // 조회수 증가
}
// 4. 캐시 데이터가 존재하면 반환
if (cachedEvent != null) {
return cachedEvent;
}
// 5. 캐시 데이터가 없으면 DB에서 조회
Event event = eventRepository.findById(eventId)
.orElseThrow(() -> new BusinessException(ErrorCode.EVENT_NOT_FOUND));
// 6. 조회 결과를 Redis 캐시에 저장
EventDetailRes eventDetailRes = EventDetailRes.toEventDetailRes(event);
redisTemplate.opsForValue().set(cacheKey, eventDetailRes, Duration.ofHours(24)); // TTL 설정
return eventDetailRes;
}
조회수는 Redis의 ZSet에 저장되며, Redis의 incrementScore
를 사용하여 실시간으로 업데이트됩니다.
중복 조회를 방지하기 위해 쿠키 기반 로직을 추가하였습니다.
/**
* 조회수 증가
*/
private void incrementViewCount(Long eventId) {
String rankingKey = RedisKeyHelper.getViewRankingKey(); // 랭킹 키 생성
rankingRedisTemplate.opsForZSet().incrementScore(rankingKey, String.valueOf(eventId), 1);
}
/**
* 쿠키를 통해 중복 조회 방지
*/
private boolean isDuplicateView(HttpServletRequest request, HttpServletResponse response, Long eventId) {
Cookie[] cookies = request.getCookies();
Cookie viewCountCookie = findCookie(cookies, "Event_View_Count");
if (viewCountCookie != null) {
if (viewCountCookie.getValue().contains("[" + eventId + "]")) {
return true; // 중복 조회
}
// 쿠키에 eventId 추가
viewCountCookie.setValue(viewCountCookie.getValue() + "[" + eventId + "]");
viewCountCookie.setPath("/");
viewCountCookie.setHttpOnly(true); // 보안 강화
viewCountCookie.setSecure(request.isSecure()); // HTTPS 요청만 Secure 설정
response.addCookie(viewCountCookie);
} else {
// 쿠키가 없는 경우 새로 생성
Cookie newCookie = new Cookie("Event_View_Count", "[" + eventId + "]");
newCookie.setPath("/");
newCookie.setHttpOnly(true); // 보안 강화
newCookie.setSecure(request.isSecure()); // HTTPS 요청만 Secure 설정
response.addCookie(newCookie);
}
return false; // 중복이 아님
}
Redis 키 관리는 RedisKeyHelper
와 KeyType
을 통해 체계적으로 관리하였습니다.
public class RedisKeyHelper {
private RedisKeyHelper() {
// Prevent instantiation
}
public static String getEventDetailCacheKey(Long eventId) {
return KeyType.EVENT_DETAIL_CACHE.format(eventId);
}
public static String getViewRankingKey() {
return KeyType.EVENT_VIEW_RANKING.format();
}
}
public enum KeyType {
EVENT_DETAIL_CACHE("ficket:event:detail:%d"),
EVENT_VIEW_RANKING("ficket:event:ranking:view");
private final String keyPattern;
KeyType(String keyPattern) {
this.keyPattern = keyPattern;
}
public String format(Object... args) {
return String.format(keyPattern, args);
}
}
매일 오전 10시에 랭킹 데이터를 초기화하여 실시간성을 유지합니다.
@Slf4j
@Component
@RequiredArgsConstructor
public class RankingResetScheduler {
@Qualifier("rankingRedisTemplate")
private final RedisTemplate<String, Object> redisTemplate;
@Scheduled(cron = "0 0 10 * * ?") // 매일 오전 10시
public void resetRanking() {
redisTemplate.delete(RedisKeyHelper.getViewRankingKey());
log.info("Ranking scores reset at 10:00 AM");
}
}
기존에 @Cacheable
을 활용해 성능을 최적화하는 코드가 작성되어 있었지만, 캐시에 데이터가 있을 경우 조회수 증가 로직이 실행되지 않는 문제가 있었습니다. 이를 해결하기 위해:
1. @Cacheable
을 제거하고 수동 캐싱으로 전환하였습니다.
2. 캐시와 조회수 증가 로직을 분리하여 항상 조회수가 업데이트되도록 보장하였습니다.
Reference