Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,13 @@ public TodayQuestionResponse getTodayQuestion(Long userId) {
log.info("오늘의 질문 조회 - questionId: {}, userId: {}, hasVoted: {}, canVote: {}",
questionId, userId, hasVoted, canVote);

// 통계 정보 조회 (실시간)
VoteStats voteStats = voteStatsRepository.findByQuestionId(questionId).orElse(null);
// 통계 정보 조회 (캐시 적용 - TTL 5초)
VoteStats voteStats = todayQuestionCacheService.getVoteStatsCached(questionId).orElse(null);
VoteStatsResponse voteStatsResponse = VoteStatsResponse.from(voteStats);
int participants = voteStats != null ? voteStats.getTotalCount() : 0;

// 댓글 수 조회 (실시간)
int commentCount = (int) commentRepository.countByQuestionId(questionId);
// 댓글 수 조회 (캐시 적용 - TTL 10초)
int commentCount = todayQuestionCacheService.getCommentCountCached(questionId);

return QuestionMapper.toTodayQuestionResponseFromCache(
cacheDto,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package com.ifu.ifu_server.domain.question.service;

import com.ifu.ifu_server.domain.comment.repository.CommentRepository;
import com.ifu.ifu_server.domain.question.dto.TodayQuestionBaseCacheDto;
import com.ifu.ifu_server.domain.question.entity.Question;
import com.ifu.ifu_server.domain.question.entity.QuestionStatus;
import com.ifu.ifu_server.domain.question.repository.QuestionRepository;
import com.ifu.ifu_server.domain.vote.entity.VoteStats;
import com.ifu.ifu_server.domain.vote.repository.VoteStatsRepository;
import com.ifu.ifu_server.global.config.LocalCacheConfig;
import com.ifu.ifu_server.global.exception.BusinessException;
import com.ifu.ifu_server.global.exception.ErrorCode;
Expand All @@ -21,6 +24,7 @@
* 오늘의 질문 캐시 서비스
* - Cache Aside 패턴 구현
* - Spring Cache Abstraction 사용
* - sync=true로 캐시 스탬피드 방지
*/
@Slf4j
@Service
Expand All @@ -29,15 +33,18 @@
public class TodayQuestionCacheService {

private final QuestionRepository questionRepository;
private final VoteStatsRepository voteStatsRepository;
private final CommentRepository commentRepository;

/**
* 오늘의 질문 기본 정보 조회 (캐시 적용)
* - Cache Aside: 캐시 hit 시 DB 조회 없이 반환
* - Cache miss 시 DB 조회 후 캐시에 저장
* - sync=true: 동시 요청 시 단일 스레드만 DB 조회 (캐시 스탬피드 방지)
*
* @return 오늘의 질문 기본 정보 캐시 DTO
*/
@Cacheable(value = LocalCacheConfig.TODAY_QUESTION_CACHE, key = "'today'")
@Cacheable(value = LocalCacheConfig.TODAY_QUESTION_CACHE, key = "'today'", sync = true)
public TodayQuestionBaseCacheDto getTodayQuestionBase() {
log.info("[Cache Miss] 오늘의 질문 DB 조회 시작");

Expand Down Expand Up @@ -79,4 +86,32 @@ public Optional<TodayQuestionBaseCacheDto> getTodayQuestionBaseFromDb() {
public void evictTodayQuestionCache() {
log.info("[Cache Evict] 오늘의 질문 캐시 삭제");
}

/**
* 투표 통계 조회 (캐시 적용)
* - TTL: 5초 (스케줄러에서 주기적 evict)
* - sync=true: 캐시 스탬피드 방지
*
* @param questionId 질문 ID
* @return 투표 통계 (Optional)
*/
@Cacheable(value = LocalCacheConfig.QUESTION_VOTE_STATS_CACHE, key = "#questionId", sync = true)
public Optional<VoteStats> getVoteStatsCached(Long questionId) {
log.info("[Cache Miss] VoteStats DB 조회 - questionId: {}", questionId);
return voteStatsRepository.findByQuestionId(questionId);
}

/**
* 댓글 수 조회 (캐시 적용)
* - TTL: 10초 (스케줄러에서 주기적 evict)
* - sync=true: 캐시 스탬피드 방지
*
* @param questionId 질문 ID
* @return 댓글 수
*/
@Cacheable(value = LocalCacheConfig.QUESTION_COMMENT_COUNT_CACHE, key = "#questionId", sync = true)
public int getCommentCountCached(Long questionId) {
log.info("[Cache Miss] CommentCount DB 조회 - questionId: {}", questionId);
return (int) commentRepository.countByQuestionId(questionId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.ifu.ifu_server.global.config;

import com.ifu.ifu_server.domain.question.service.TodayQuestionCacheService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;

/**
* 애플리케이션 시작 시 캐시 pre-warm
* - 캐시 스탬피드 방지: 서버 구동 직후 캐시가 비어있는 상태에서
* 동시 요청으로 인한 DB 폭주를 막기 위해 미리 캐시를 채움
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class CacheWarmupRunner implements ApplicationRunner {

private final TodayQuestionCacheService cacheService;

@Override
public void run(ApplicationArguments args) {
try {
cacheService.getTodayQuestionBase();
log.info("[Startup] 캐시 pre-warm 완료");
} catch (Exception e) {
log.warn("[Startup] 캐시 pre-warm 실패: {}", e.getMessage());
}
}
}
Original file line number Diff line number Diff line change
@@ -1,24 +1,70 @@
package com.ifu.ifu_server.global.config;

import java.util.Objects;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;

/**
* 로컬 캐시 설정
* - Spring Cache Abstraction 사용
* - ConcurrentMapCacheManager로 로컬 캐시 구현
* - 집계 데이터 캐시는 스케줄러로 주기적 evict (TTL 대체)
*/
@Slf4j
@EnableCaching
@EnableScheduling
@Configuration
public class LocalCacheConfig {

public static final String TODAY_QUESTION_CACHE = "todayQuestion";
public static final String QUESTION_VOTE_STATS_CACHE = "questionVoteStats";
public static final String QUESTION_COMMENT_COUNT_CACHE = "questionCommentCount";

private final CacheManager cacheManager;

public LocalCacheConfig() {
this.cacheManager = new ConcurrentMapCacheManager(
TODAY_QUESTION_CACHE,
QUESTION_VOTE_STATS_CACHE,
QUESTION_COMMENT_COUNT_CACHE
);
}

@Bean
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager(TODAY_QUESTION_CACHE);
return cacheManager;
}

/**
* VoteStats 캐시 주기적 evict (5초마다)
* - ConcurrentMapCacheManager는 TTL 미지원이므로 스케줄러로 대체
*/
@Scheduled(fixedRate = 5000)
public void evictVoteStatsCache() {
Cache cache = cacheManager.getCache(QUESTION_VOTE_STATS_CACHE);
if (cache != null) {
cache.clear();
log.debug("[Cache Evict] VoteStats 캐시 만료 - 5초 TTL");
}
}

/**
* CommentCount 캐시 주기적 evict (10초마다)
* - ConcurrentMapCacheManager는 TTL 미지원이므로 스케줄러로 대체
*/
@Scheduled(fixedRate = 10000)
public void evictCommentCountCache() {
Cache cache = cacheManager.getCache(QUESTION_COMMENT_COUNT_CACHE);
if (cache != null) {
cache.clear();
log.debug("[Cache Evict] CommentCount 캐시 만료 - 10초 TTL");
}
}
}
Loading