Skip to content

레디스 Fallback #105

@sunwon12

Description

@sunwon12

아래와 같이 레디스 FallBack 설정 추후에 해야 합니다

@Service
@Slf4j
@RequiredArgsConstructor
public class ReadingDiaryStatisticService {

    private static final String DIARY_KEY_PREFIX = "diaries:";
    private static final String DIRTY_DIARIES_KEY = "diaries:dirty";
    private final StringRedisTemplate redisTemplate;
    private final ReadingDiaryStatisticsRepository statisticsRepository;

    public void increaseViewCount(Long diaryId, Long memberId) {
        String userViewLogKey = DIARY_KEY_PREFIX + diaryId + ":views:log";
        Long addResult = redisTemplate.opsForSet().add(userViewLogKey, String.valueOf(memberId));
        redisTemplate.expire(userViewLogKey, 1, java.util.concurrent.TimeUnit.DAYS);

        boolean isFirstViewIn24Hours = addResult != null && addResult > 0;
        if (isFirstViewIn24Hours) {
            incrementCount(diaryId, CountType.VIEW);
        }
    }

    public void incrementCount(Long diaryId, CountType type) {
        String counterKey = DIARY_KEY_PREFIX + diaryId + ":" + type.name().toLowerCase();
        redisTemplate.opsForValue().increment(counterKey);
        redisTemplate.opsForSet().add(DIRTY_DIARIES_KEY, String.valueOf(diaryId));
    }

    public void decrementCount(Long diaryId, CountType type, long amount) {
        String counterKey = DIARY_KEY_PREFIX + diaryId + ":" + type.name().toLowerCase();
        redisTemplate.opsForValue().decrement(counterKey, amount);
        redisTemplate.opsForSet().add(DIRTY_DIARIES_KEY, String.valueOf(diaryId));
    }

    public Map<Long, Map<CountType, Long>> getCounts(List<Long> diaryIds) {
        if (diaryIds == null || diaryIds.isEmpty()) {
            return Collections.emptyMap();
        }

        // 1. Redis에서 데이터 조회
        List<String> keys = new ArrayList<>();
        for (Long diaryId : diaryIds) {
            for (CountType type : CountType.values()) {
                keys.add(DIARY_KEY_PREFIX + diaryId + ":" + type.name().toLowerCase());
            }
        }

        List<String> values = redisTemplate.opsForValue().multiGet(keys);
        Map<Long, Map<CountType, Long>> result = new HashMap<>();
        List<Long> missingDiaryIds = new ArrayList<>();

        // 2. Redis 결과 처리 및 누락된 데이터 식별
        for (int i = 0; i < keys.size(); i++) {
            String key = keys.get(i);
            String value = (values != null && values.get(i) != null) ? values.get(i) : null;

            String[] parts = key.split(":");
            if (parts.length >= 3) {
                Long diaryId = Long.parseLong(parts[1]);
                CountType type = CountType.valueOf(parts[2].toUpperCase());
                
                if (value != null) {
                    // Redis에 데이터가 있는 경우
                    long count = Long.parseLong(value);
                    result.computeIfAbsent(diaryId, k -> new EnumMap<>(CountType.class)).put(type, count);
                } else {
                    // Redis에 데이터가 없는 경우, DB fallback 대상으로 추가
                    if (!missingDiaryIds.contains(diaryId)) {
                        missingDiaryIds.add(diaryId);
                    }
                }
            }
        }

        // 3. DB Fallback: Redis에 없는 데이터를 DB에서 조회
        if (!missingDiaryIds.isEmpty()) {
            log.warn("Redis에서 누락된 독서일지 통계 데이터를 DB에서 조회합니다. diaryIds: {}", missingDiaryIds);
            
            List<ReadingDiaryStatistic> dbStatistics = statisticsRepository.findAllByReadingDiaryIdIn(missingDiaryIds);
            
            for (ReadingDiaryStatistic stat : dbStatistics) {
                Long diaryId = stat.getReadingDiary().getId();
                Map<CountType, Long> counts = result.computeIfAbsent(diaryId, k -> new EnumMap<>(CountType.class));
                
                // DB 값을 Redis에 다시 저장 (캐시 워밍업)
                this.syncStatisticToRedis(diaryId, stat);
                
                // 결과에 추가
                counts.put(CountType.VIEW, (long) stat.getViewCount());
                counts.put(CountType.LIKE, (long) stat.getLikeCount());
                counts.put(CountType.COMMENT, (long) stat.getCommentCount());
            }
            
            // DB에도 없는 독서일지는 기본값(0) 설정
            for (Long diaryId : missingDiaryIds) {
                if (!result.containsKey(diaryId)) {
                    Map<CountType, Long> defaultCounts = new EnumMap<>(CountType.class);
                    for (CountType type : CountType.values()) {
                        defaultCounts.put(type, 0L);
                    }
                    result.put(diaryId, defaultCounts);
                }
            }
        }
        
        return result;
    }

    /**
     * DB 통계 데이터를 Redis에 동기화 (캐시 워밍업)
     */
    private void syncStatisticToRedis(Long diaryId, ReadingDiaryStatistic statistic) {
        try {
            String viewKey = DIARY_KEY_PREFIX + diaryId + ":view";
            String likeKey = DIARY_KEY_PREFIX + diaryId + ":like";
            String commentKey = DIARY_KEY_PREFIX + diaryId + ":comment";
            
            redisTemplate.opsForValue().set(viewKey, String.valueOf(statistic.getViewCount()));
            redisTemplate.opsForValue().set(likeKey, String.valueOf(statistic.getLikeCount())); 
            redisTemplate.opsForValue().set(commentKey, String.valueOf(statistic.getCommentCount()));
            
        } catch (Exception e) {
            log.error("Redis 캐시 워밍업 중 오류 발생. diaryId: {}", diaryId, e);
        }
    }

    public void deleteCounts(List<Long> diaryIds) {
        if (diaryIds == null || diaryIds.isEmpty()) {
            return;
        }

        List<String> keysToDelete = new ArrayList<>();
        for (Long diaryId : diaryIds) {
            for (CountType type : CountType.values()) {
                keysToDelete.add(DIARY_KEY_PREFIX + diaryId + ":" + type.name().toLowerCase());
            }
            // 24시간 조회 기록 로그 Set도 함께 삭제
            keysToDelete.add(DIARY_KEY_PREFIX + diaryId + ":views:log");
        }

        redisTemplate.delete(keysToDelete);

        String[] dirtyIdsToRemove = diaryIds.stream()
                .map(String::valueOf)
                .toArray(String[]::new);

        redisTemplate.opsForSet().remove(DIRTY_DIARIES_KEY, (Object[]) dirtyIdsToRemove);
    }
} 

Metadata

Metadata

Assignees

Labels

Someday TODO언젠간 해야 할 TODO

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions