요구 사항

진행 중이던 프로젝트에서 실시간 인기 공연목록을 제공하기로 했다.
인기 공연을 선정하는 방식은 최근 조회수를 기반으로 하여
가장 높은 순으로 최대 10개의 공연을 선정했다.
서비스가 작고 이용자가 많지 않았기 때문에
완전한 실시간성을 보장하는 방식이 아닌 최근 조회수가 많은 공연을 제공하기로 했다.
설계 과정
조회수 증가
우선 사용자가 공연을 누르면 해당 공연의 조회수를 증가시켜야 했다.
당시 프로젝트에서 DB는 MySQL만 사용 중이었는데 이러한 단순한 조회수 증가를
MySQL로 실행하기에는 DB의 부하가 많을 것이라 판단했다.
@Override
public void incrementViewCount(Long performanceId) {
longRedisTemplate.opsForList().leftPush(RECENT_VIEW_COUNT_KEY, performanceId);
longRedisTemplate.opsForZSet().incrementScore(PERFORMANCE_VIEW_COUNT_KEY, performanceId, 1);
Long listSize = longRedisTemplate.opsForList().size(RECENT_VIEW_COUNT_KEY);
if (listSize != null && listSize > MAX_RECENT_VIEWS) {
Long oldestPerformanceId = (Long) longRedisTemplate.opsForList().rightPop(RECENT_VIEW_COUNT_KEY);
if (oldestPerformanceId != null) {
longRedisTemplate.opsForZSet().incrementScore(PERFORMANCE_VIEW_COUNT_KEY,
oldestPerformanceId, -1);
}
}
}
따라서 이런 실시간 처리에 적합한 Redis를 도입하여 조회수를 수집하도록 했다.
하지만 제한 없이 조회수를 증가시키는 것은 의미가 없었다.
실시간 인기공연의 제공목적은 최근 조회수가 가장 많은 공연이 무엇인지를 알려줘야 하기 때문이다.
따라서
1. Redis에 특정한 길이의 리스트를 만들고 (예를 들어 200)
2. 사용자가 특정 공연을 조회하면 해당 공연의 id를 리스트에 넣는다. 만약 이때 리스트의 길이가 특정한 값을 넘어가면 가장 이전에 들어온 id값을 제거한다.
상위 인기공연 id값 반환
private List<Long> getPopularPerformanceIds() {
Set<ZSetOperations.TypedTuple<Long>> topPerformanceIds = longRedisTemplate.opsForZSet()
.reverseRangeWithScores(PERFORMANCE_VIEW_COUNT_KEY, 0, 9);
if (topPerformanceIds != null && !topPerformanceIds.isEmpty()) {
return topPerformanceIds.stream().map(ZSetOperations.TypedTuple::getValue)
.toList();
}
log.info("조회된 공연이 없습니다.");
return Collections.emptyList();
}
3. 이후 해당 배열에서 SortedSet을 이용하여 현재 조회수가 높은 공연 상위 10개를 추려 공연의 id값을 반환하도록 했다.
캐싱 적용 & 인기공연 정보 제공

4. 반환된 공연들의 id 값을 가지고 MySQL에서 해당 공연들의 데이터를 가져왔다.
하지만 실시간 인기공연의 경우 사용자별 맞춤공연처럼 사용자마다
다른 공연정보를 제공하는 것이 아니기 때문에 사용자가 요청할 때마다
해당 로직이 실행된다면 부하가 있을 것이라 생각했고, 이에 캐싱을 적용하기로 했다.
간략히 표현한 캐싱 전략은 위의 플로우 차트와 같다.
@Scheduled(fixedRate = SCHEDULER_TIME)
public void cacheTopPerformance() {
log.info("실시간 인기공연 스케줄러 동작");
List<Long> performanceIds = getPopularPerformanceIds();
if(!performanceIds.isEmpty()){
PerformanceListResponse popularPerformances = performanceService.getPopularPerformances(performanceIds);
performanceListResponseRedisTemplate.opsForValue().set(PERFORMANCE_VIEW_CACHE_KEY, popularPerformances);
}
}
먼저 5분마다 한 번씩 스케줄러를 통해 실시간 인기 공연들의 id를 Redis에서 추출하고,
이를 이용해서 MySQL에서 공연들의 데이터를 가져와 Redis에 캐싱했다.
@Override
public PerformanceListResponse getPopularPerformances() {
PerformanceListResponse performances = (PerformanceListResponse)
performanceListResponseRedisTemplate.opsForValue().get(PERFORMANCE_VIEW_CACHE_KEY);
if (performances != null) {
return performances;
}
List<Long> performanceIds = getPopularPerformanceIds();
if(!performanceIds.isEmpty()){
PerformanceListResponse popularPerformances = performanceService.getPopularPerformances(performanceIds);
performanceListResponseRedisTemplate.opsForValue().set(PERFORMANCE_VIEW_CACHE_KEY, popularPerformances);
return popularPerformances;
}
return new PerformanceListResponse(0, Collections.emptyList());
}
이후 사용자가 실시간 인기공연 데이터를 요청하면 먼저 Redis에 캐싱되어 있는 데이터가 있는지 확인 후
있다면 곧바로 캐싱된 데이터를 제공하고, 없다면 직접 Redis에서 인기공연 id를 찾아 MySQL에서 해당 공연들의 데이터를
가져와 캐싱하고 사용자에게 응답했다.
이번에는 프로젝트 규모가 작고 이용자가 많이 없다는 것을 가정하여 단순히 Redis를 이용하여 설계하였지만
이런 방식은 실제 완전한 실시간성이 아닌 최근 조회수를 기반으로 한 순위였다.
만약 실제로 실시간성을 보장하고 트래픽의 규모가 컸을때는 Kafka와 ElasticSearch 기반의 실시간 분석을
이용하여 설계하는 것이 좋지않을까 생각한다.
'Project' 카테고리의 다른 글
Mockito를 이용해서 단위 테스트 작성하기 (0) | 2025.04.06 |
---|---|
WebSocket HTTP Connection 문제 해결하기 (0) | 2025.03.05 |
Quartz 스케줄러 이용하여 경매 마감 관리하기 (1) | 2025.01.10 |
Embeddis Redis 적용하기(feat. M1) (0) | 2024.11.05 |