Skip to content

Commit 886a8e6

Browse files
committed
feat: 캐시 스탬피드 방지 및 집계 데이터 캐싱
적용
1 parent dfbf6fc commit 886a8e6

5 files changed

Lines changed: 271 additions & 154 deletions

File tree

src/main/java/com/ifu/ifu_server/domain/question/service/QuestionService.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,13 @@ public TodayQuestionResponse getTodayQuestion(Long userId) {
7575
log.info("오늘의 질문 조회 - questionId: {}, userId: {}, hasVoted: {}, canVote: {}",
7676
questionId, userId, hasVoted, canVote);
7777

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

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

8686
return QuestionMapper.toTodayQuestionResponseFromCache(
8787
cacheDto,

src/main/java/com/ifu/ifu_server/domain/question/service/TodayQuestionCacheService.java

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package com.ifu.ifu_server.domain.question.service;
22

3+
import com.ifu.ifu_server.domain.comment.repository.CommentRepository;
34
import com.ifu.ifu_server.domain.question.dto.TodayQuestionBaseCacheDto;
45
import com.ifu.ifu_server.domain.question.entity.Question;
56
import com.ifu.ifu_server.domain.question.entity.QuestionStatus;
67
import com.ifu.ifu_server.domain.question.repository.QuestionRepository;
8+
import com.ifu.ifu_server.domain.vote.entity.VoteStats;
9+
import com.ifu.ifu_server.domain.vote.repository.VoteStatsRepository;
710
import com.ifu.ifu_server.global.config.LocalCacheConfig;
811
import com.ifu.ifu_server.global.exception.BusinessException;
912
import com.ifu.ifu_server.global.exception.ErrorCode;
@@ -21,6 +24,7 @@
2124
* 오늘의 질문 캐시 서비스
2225
* - Cache Aside 패턴 구현
2326
* - Spring Cache Abstraction 사용
27+
* - sync=true로 캐시 스탬피드 방지
2428
*/
2529
@Slf4j
2630
@Service
@@ -29,15 +33,18 @@
2933
public class TodayQuestionCacheService {
3034

3135
private final QuestionRepository questionRepository;
36+
private final VoteStatsRepository voteStatsRepository;
37+
private final CommentRepository commentRepository;
3238

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

@@ -79,4 +86,32 @@ public Optional<TodayQuestionBaseCacheDto> getTodayQuestionBaseFromDb() {
7986
public void evictTodayQuestionCache() {
8087
log.info("[Cache Evict] 오늘의 질문 캐시 삭제");
8188
}
89+
90+
/**
91+
* 투표 통계 조회 (캐시 적용)
92+
* - TTL: 5초 (스케줄러에서 주기적 evict)
93+
* - sync=true: 캐시 스탬피드 방지
94+
*
95+
* @param questionId 질문 ID
96+
* @return 투표 통계 (Optional)
97+
*/
98+
@Cacheable(value = LocalCacheConfig.QUESTION_VOTE_STATS_CACHE, key = "#questionId", sync = true)
99+
public Optional<VoteStats> getVoteStatsCached(Long questionId) {
100+
log.info("[Cache Miss] VoteStats DB 조회 - questionId: {}", questionId);
101+
return voteStatsRepository.findByQuestionId(questionId);
102+
}
103+
104+
/**
105+
* 댓글 수 조회 (캐시 적용)
106+
* - TTL: 10초 (스케줄러에서 주기적 evict)
107+
* - sync=true: 캐시 스탬피드 방지
108+
*
109+
* @param questionId 질문 ID
110+
* @return 댓글 수
111+
*/
112+
@Cacheable(value = LocalCacheConfig.QUESTION_COMMENT_COUNT_CACHE, key = "#questionId", sync = true)
113+
public int getCommentCountCached(Long questionId) {
114+
log.info("[Cache Miss] CommentCount DB 조회 - questionId: {}", questionId);
115+
return (int) commentRepository.countByQuestionId(questionId);
116+
}
82117
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package com.ifu.ifu_server.global.config;
2+
3+
import com.ifu.ifu_server.domain.question.service.TodayQuestionCacheService;
4+
import lombok.RequiredArgsConstructor;
5+
import lombok.extern.slf4j.Slf4j;
6+
import org.springframework.boot.ApplicationArguments;
7+
import org.springframework.boot.ApplicationRunner;
8+
import org.springframework.stereotype.Component;
9+
10+
/**
11+
* 애플리케이션 시작 시 캐시 pre-warm
12+
* - 캐시 스탬피드 방지: 서버 구동 직후 캐시가 비어있는 상태에서
13+
* 동시 요청으로 인한 DB 폭주를 막기 위해 미리 캐시를 채움
14+
*/
15+
@Slf4j
16+
@Component
17+
@RequiredArgsConstructor
18+
public class CacheWarmupRunner implements ApplicationRunner {
19+
20+
private final TodayQuestionCacheService cacheService;
21+
22+
@Override
23+
public void run(ApplicationArguments args) {
24+
try {
25+
cacheService.getTodayQuestionBase();
26+
log.info("[Startup] 캐시 pre-warm 완료");
27+
} catch (Exception e) {
28+
log.warn("[Startup] 캐시 pre-warm 실패: {}", e.getMessage());
29+
}
30+
}
31+
}
Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,70 @@
11
package com.ifu.ifu_server.global.config;
22

3+
import java.util.Objects;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.springframework.cache.Cache;
36
import org.springframework.cache.CacheManager;
47
import org.springframework.cache.annotation.EnableCaching;
58
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
69
import org.springframework.context.annotation.Bean;
710
import org.springframework.context.annotation.Configuration;
11+
import org.springframework.scheduling.annotation.EnableScheduling;
12+
import org.springframework.scheduling.annotation.Scheduled;
813

914
/**
1015
* 로컬 캐시 설정
1116
* - Spring Cache Abstraction 사용
1217
* - ConcurrentMapCacheManager로 로컬 캐시 구현
18+
* - 집계 데이터 캐시는 스케줄러로 주기적 evict (TTL 대체)
1319
*/
20+
@Slf4j
1421
@EnableCaching
22+
@EnableScheduling
1523
@Configuration
1624
public class LocalCacheConfig {
1725

1826
public static final String TODAY_QUESTION_CACHE = "todayQuestion";
27+
public static final String QUESTION_VOTE_STATS_CACHE = "questionVoteStats";
28+
public static final String QUESTION_COMMENT_COUNT_CACHE = "questionCommentCount";
29+
30+
private final CacheManager cacheManager;
31+
32+
public LocalCacheConfig() {
33+
this.cacheManager = new ConcurrentMapCacheManager(
34+
TODAY_QUESTION_CACHE,
35+
QUESTION_VOTE_STATS_CACHE,
36+
QUESTION_COMMENT_COUNT_CACHE
37+
);
38+
}
1939

2040
@Bean
2141
public CacheManager cacheManager() {
22-
return new ConcurrentMapCacheManager(TODAY_QUESTION_CACHE);
42+
return cacheManager;
43+
}
44+
45+
/**
46+
* VoteStats 캐시 주기적 evict (5초마다)
47+
* - ConcurrentMapCacheManager는 TTL 미지원이므로 스케줄러로 대체
48+
*/
49+
@Scheduled(fixedRate = 5000)
50+
public void evictVoteStatsCache() {
51+
Cache cache = cacheManager.getCache(QUESTION_VOTE_STATS_CACHE);
52+
if (cache != null) {
53+
cache.clear();
54+
log.debug("[Cache Evict] VoteStats 캐시 만료 - 5초 TTL");
55+
}
56+
}
57+
58+
/**
59+
* CommentCount 캐시 주기적 evict (10초마다)
60+
* - ConcurrentMapCacheManager는 TTL 미지원이므로 스케줄러로 대체
61+
*/
62+
@Scheduled(fixedRate = 10000)
63+
public void evictCommentCountCache() {
64+
Cache cache = cacheManager.getCache(QUESTION_COMMENT_COUNT_CACHE);
65+
if (cache != null) {
66+
cache.clear();
67+
log.debug("[Cache Evict] CommentCount 캐시 만료 - 10초 TTL");
68+
}
2369
}
2470
}

0 commit comments

Comments
 (0)