[Spring] Redis로 조회수 랭킹 시스템 구현하기

오형상·2024년 12월 14일
0

Ficket

목록 보기
17/27
post-thumbnail

Ficket 프로젝트를 진행하며 조회수를 기준으로 실시간 랭킹 구현을 맡았습니다.
프로젝트에서는 기존에 Spring의 @Cacheable을 사용해 Redis 캐시를 통해 성능 최적화를 구현한 이벤트 상세 조회 API가 있었는데, 이를 활용하여 랭킹 시스템을 구현하려 했습니다. 하지만 캐시에 데이터가 있는 경우 조회수 증가 로직이 실행되지 않는 문제를 발견하였고, 이를 해결하기 위해 코드를 수정했습니다.


기존 코드: @Cacheable을 활용한 이벤트 상세 조회

1. 기존 구현 목표

이벤트 상세 데이터를 캐싱하여 데이터베이스 접근을 최소화하고, API 응답 속도를 최적화하는 것이 주요 목표였습니다.
Spring의 @Cacheable 어노테이션을 사용하여 Redis에 캐시를 저장하는 방식으로 설계되었습니다.

2. 기존 코드

이벤트 상세 조회 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);
}

문제: 캐시와 조회수 증가의 비동작

1. 랭킹 시스템 구현 중 문제 발견

랭킹 기능을 구현하기 위해 조회수 증가 로직을 추가하려 했습니다. Redis의 Sorted Set (ZSet)을 활용해 조회수 데이터를 관리하고, 이를 기반으로 인기 이벤트를 정렬하려는 구조였습니다.

하지만 기존 @Cacheable 로직과 조회수 증가 로직이 의도한 대로 동작하지 않는 문제가 있었습니다.

2. 문제 상황

  • 캐시에 데이터가 있는 경우, Redis 캐시에서 데이터를 가져오기 때문에 조회수 증가 로직이 실행되지 않는 문제가 발생했습니다.
  • 조회수 증가 로직은 데이터베이스(DB)나 별도의 업데이트 로직을 통해 실행되도록 되어 있었으나, 캐시에서 데이터를 바로 반환하면서 이 로직이 건너뛰어졌습니다.
  • 그 결과, 조회수가 업데이트되지 않아 랭킹 데이터가 정확하지 않은 문제가 발생했습니다.

솔루션: 캐시와 조회수 증가 로직 분리

랭킹 기능에서 조회수는 항상 증가해야 하므로 기존 코드를 다음과 같이 수정하였습니다:
1. @Cacheable을 제거하고, RedisTemplate을 사용해 캐싱을 수동으로 관리하도록 변경했습니다.
2. 캐시에서 데이터를 반환하더라도, 조회수 증가 로직이 항상 실행되도록 수정했습니다.

수정된 코드

1. 캐시와 조회수 증가 로직 분리

  • 캐시를 확인한 뒤에도 조회수는 별도로 증가하도록 구현하였습니다.
  • 캐시에 데이터가 존재하지 않을 경우 데이터베이스에서 조회하고, 캐시를 갱신합니다.

수정된 코드

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;
}

2. 조회수 증가 로직

조회수는 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; // 중복이 아님
}

3. Redis 키 관리

Redis 키 관리는 RedisKeyHelperKeyType을 통해 체계적으로 관리하였습니다.

RedisKeyHelper

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();
    }
}

KeyType Enum

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);
    }
}

4. 스케줄링으로 랭킹 초기화

매일 오전 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

0개의 댓글