-
Notifications
You must be signed in to change notification settings - Fork 1
feat: 주요 조회 API 레디스 캐싱 설정 추가 #111
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4c6d6fa
3630901
0429378
6044e41
2c08b90
dcfdb29
90a29f3
594f72d
e8c2359
452242d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,7 @@ | |
| import org.springframework.context.ApplicationEventPublisher; | ||
| import org.springframework.scheduling.annotation.Scheduled; | ||
| import org.springframework.stereotype.Component; | ||
| import until.the.eternity.auctionhistory.application.service.AuctionHistoryCacheWarmupService; | ||
| import until.the.eternity.auctionhistory.application.service.AuctionHistoryService; | ||
| import until.the.eternity.auctionhistory.application.service.fetcher.AuctionHistoryFetcher; | ||
| import until.the.eternity.auctionhistory.application.service.persister.AuctionHistoryPersister; | ||
|
|
@@ -27,6 +28,7 @@ public class AuctionHistoryScheduler { | |
| private final AuctionHistoryFetcher fetcher; | ||
| private final AuctionHistoryPersister persister; | ||
| private final ApplicationEventPublisher eventPublisher; | ||
| private final AuctionHistoryCacheWarmupService cacheWarmupService; | ||
|
|
||
| @Value("${openapi.auction-history.delay-ms}") | ||
| private long delayMs; | ||
|
|
@@ -114,12 +116,16 @@ public int fetchAndSaveAuctionHistoryAll() { | |
| "> [SCHEDULE] AuctionHistoryScheduler saved [{}] new auction history records complete", | ||
| totalSavedCount); | ||
|
|
||
| // 통계 업데이트를 위한 이벤트 발행 | ||
| // 통계 업데이트를 위한 이벤트 발행 (DailyStatisticsService가 일간/랭킹 캐시를 함께 무효화) | ||
| log.debug( | ||
| "> [SCHEDULE] Publishing AuctionHistorySavedEvent with {} records", | ||
| totalSavedCount); | ||
| eventPublisher.publishEvent(new AuctionHistorySavedEvent(totalSavedCount)); | ||
|
|
||
| // 경매 거래 내역 캐시 무효화 + 역대 랭킹 캐시 무효화 + 30가지 조합 워밍업 | ||
| log.info("> [SCHEDULE] Starting cache eviction and warmup"); | ||
| cacheWarmupService.evictAndWarm(); | ||
|
Comment on lines
+125
to
+127
|
||
|
|
||
| return totalSavedCount; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,101 @@ | ||||||||||||||||||||||
| package until.the.eternity.auctionhistory.application.service; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| import lombok.RequiredArgsConstructor; | ||||||||||||||||||||||
| import lombok.extern.slf4j.Slf4j; | ||||||||||||||||||||||
| import org.springframework.cache.Cache; | ||||||||||||||||||||||
| import org.springframework.cache.CacheManager; | ||||||||||||||||||||||
| import org.springframework.stereotype.Service; | ||||||||||||||||||||||
| import until.the.eternity.auctionhistory.interfaces.rest.dto.request.AuctionHistorySearchRequest; | ||||||||||||||||||||||
| import until.the.eternity.common.enums.SortDirection; | ||||||||||||||||||||||
| import until.the.eternity.common.enums.SortField; | ||||||||||||||||||||||
| import until.the.eternity.common.request.PageRequestDto; | ||||||||||||||||||||||
| import until.the.eternity.config.CacheNames; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * 경매 거래 내역 캐시 워밍업 서비스. | ||||||||||||||||||||||
| * | ||||||||||||||||||||||
| * <p>AuctionHistoryScheduler 배치 완료 후 호출되어 다음 작업을 수행한다. | ||||||||||||||||||||||
| * | ||||||||||||||||||||||
| * <ol> | ||||||||||||||||||||||
| * <li>auction-history:search 캐시 전체 무효화 | ||||||||||||||||||||||
| * <li>auction_history 기반 역대 랭킹 캐시 무효화 (ALLTIME_HIGHEST, ALLTIME_MONTH_VOLUME) | ||||||||||||||||||||||
| * <li>page 1~2 × size 20 × sortField 3종 × direction 2종 = 12가지 조합 캐시 워밍 | ||||||||||||||||||||||
| * </ol> | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
| @Slf4j | ||||||||||||||||||||||
| @Service | ||||||||||||||||||||||
| @RequiredArgsConstructor | ||||||||||||||||||||||
| public class AuctionHistoryCacheWarmupService { | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| private static final int WARMUP_SIZE = 20; | ||||||||||||||||||||||
| private static final int WARMUP_MAX_PAGE = 2; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| private final AuctionHistoryService auctionHistoryService; | ||||||||||||||||||||||
| private final CacheManager cacheManager; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * 캐시 무효화 후 기본 12가지 조합(page 1~2 × size 20 × sortField 3종 × direction 2종)을 선제적으로 워밍한다. | ||||||||||||||||||||||
| * | ||||||||||||||||||||||
| * <p>빈 검색 조건(필터 없음) 기준으로 워밍하므로, 단순 목록 조회 요청에 즉시 캐시 히트가 발생한다. | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
| public void evictAndWarm() { | ||||||||||||||||||||||
| evictCaches(); | ||||||||||||||||||||||
| warmup(); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| private void evictCaches() { | ||||||||||||||||||||||
| clearCache(CacheNames.AUCTION_HISTORY_SEARCH); | ||||||||||||||||||||||
| // auction_history 전체를 직접 쿼리하는 역대 랭킹도 함께 무효화 | ||||||||||||||||||||||
| clearCache(CacheNames.RANKING_ALLTIME_HIGHEST); | ||||||||||||||||||||||
| clearCache(CacheNames.RANKING_ALLTIME_MONTH_VOLUME); | ||||||||||||||||||||||
| log.info( | ||||||||||||||||||||||
| "[Cache Warmup] Evicted: {}, {}, {}", | ||||||||||||||||||||||
| CacheNames.AUCTION_HISTORY_SEARCH, | ||||||||||||||||||||||
| CacheNames.RANKING_ALLTIME_HIGHEST, | ||||||||||||||||||||||
| CacheNames.RANKING_ALLTIME_MONTH_VOLUME); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| private void warmup() { | ||||||||||||||||||||||
| // 옵션 필터가 모두 null인 빈 검색 조건 (캐싱 condition 충족) | ||||||||||||||||||||||
| AuctionHistorySearchRequest emptyRequest = | ||||||||||||||||||||||
| new AuctionHistorySearchRequest( | ||||||||||||||||||||||
| null, null, null, null, null, null, null, null, null); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| int successCount = 0; | ||||||||||||||||||||||
| int failCount = 0; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| for (int page = 1; page <= WARMUP_MAX_PAGE; page++) { | ||||||||||||||||||||||
| for (SortField sortField : SortField.values()) { | ||||||||||||||||||||||
| for (SortDirection direction : SortDirection.values()) { | ||||||||||||||||||||||
| PageRequestDto pageRequest = | ||||||||||||||||||||||
| new PageRequestDto(page, WARMUP_SIZE, sortField, direction); | ||||||||||||||||||||||
| try { | ||||||||||||||||||||||
| auctionHistoryService.search(emptyRequest, pageRequest); | ||||||||||||||||||||||
| successCount++; | ||||||||||||||||||||||
| } catch (Exception e) { | ||||||||||||||||||||||
| failCount++; | ||||||||||||||||||||||
| log.warn( | ||||||||||||||||||||||
| "[Cache Warmup] Failed: page={}, sortField={}, direction={}", | ||||||||||||||||||||||
| page, | ||||||||||||||||||||||
| sortField, | ||||||||||||||||||||||
| direction, | ||||||||||||||||||||||
| e); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| log.info( | ||||||||||||||||||||||
| "[Cache Warmup] Completed: success={}, fail={} (total={})", | ||||||||||||||||||||||
| successCount, | ||||||||||||||||||||||
| failCount, | ||||||||||||||||||||||
| WARMUP_MAX_PAGE * SortField.values().length * SortDirection.values().length); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| private void clearCache(String cacheName) { | ||||||||||||||||||||||
| Cache cache = cacheManager.getCache(cacheName); | ||||||||||||||||||||||
| if (cache != null) { | ||||||||||||||||||||||
| cache.clear(); | ||||||||||||||||||||||
|
Comment on lines
+97
to
+98
|
||||||||||||||||||||||
| if (cache != null) { | |
| cache.clear(); | |
| if (cache == null) { | |
| log.warn("[Cache Warmup] Cache not found for eviction: {}", cacheName); | |
| return; | |
| } | |
| try { | |
| cache.clear(); | |
| } catch (RuntimeException ex) { | |
| log.warn("[Cache Warmup] Failed to clear cache: {}", cacheName, ex); |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -4,6 +4,7 @@ | |||||||||
| import java.util.List; | ||||||||||
| import lombok.RequiredArgsConstructor; | ||||||||||
| import lombok.extern.slf4j.Slf4j; | ||||||||||
| import org.springframework.cache.annotation.Cacheable; | ||||||||||
| import org.springframework.data.domain.Page; | ||||||||||
| import org.springframework.stereotype.Service; | ||||||||||
| import org.springframework.transaction.annotation.Transactional; | ||||||||||
|
|
@@ -15,6 +16,7 @@ | |||||||||
| import until.the.eternity.auctionhistory.interfaces.rest.dto.response.ItemOptionResponse; | ||||||||||
| import until.the.eternity.common.request.PageRequestDto; | ||||||||||
| import until.the.eternity.common.response.PageResponseDto; | ||||||||||
| import until.the.eternity.config.CacheNames; | ||||||||||
|
|
||||||||||
| @Service | ||||||||||
| @RequiredArgsConstructor | ||||||||||
|
|
@@ -25,6 +27,29 @@ public class AuctionHistoryService { | |||||||||
| private final AuctionHistoryMapper mapper; | ||||||||||
| private final EntityManager entityManager; | ||||||||||
|
|
||||||||||
| /** | ||||||||||
| * 경매 거래 내역을 검색한다. | ||||||||||
| * | ||||||||||
| * <p>단순 검색(가격/옵션/인챈트/세공 필터 없음)에 한해 캐싱을 적용한다. TTL 2시간, 배치 완료 시 전체 무효화 + 워밍업. | ||||||||||
| */ | ||||||||||
| @Cacheable( | ||||||||||
| cacheNames = CacheNames.AUCTION_HISTORY_SEARCH, | ||||||||||
| key = | ||||||||||
| "#pageRequestDto.page() + ':'" | ||||||||||
| + " + #pageRequestDto.size() + ':'" | ||||||||||
|
Comment on lines
+38
to
+39
|
||||||||||
| "#pageRequestDto.page() + ':'" | |
| + " + #pageRequestDto.size() + ':'" | |
| "#pageRequestDto.toPageable().pageNumber + ':'" | |
| + " + #pageRequestDto.toPageable().pageSize + ':'" |
Copilot
AI
Feb 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
캐시 키에서 #pageRequestDto.page()/size()/sortBy()/direction()의 null 값을 그대로 문자열에 포함하면, 실제 조회는 toPageable()에서 기본값으로 정규화되는데도 요청 형태(null vs 기본값)별로 서로 다른 캐시 엔트리가 생겨 히트율이 떨어집니다. 키를 (#pageRequestDto.page ?: 1), (#pageRequestDto.size ?: 20)처럼 기본값으로 정규화(또는 pageRequestDto.toPageable() 기반으로 키 구성)해 주세요.
Copilot
AI
Feb 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@Cacheable key/condition for AuctionHistoryService.search doesn't include requestDto.isExactItemName(), but the repository query changes behavior based on that flag (eq vs contains). A cached response for one mode can be incorrectly reused for the other. Include isExactItemName in the cache key (or exclude caching when it’s non-null/true) so exact/like searches are separated.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -4,6 +4,8 @@ | |||||||||||||||||
| import java.util.List; | ||||||||||||||||||
| import lombok.RequiredArgsConstructor; | ||||||||||||||||||
| import lombok.extern.slf4j.Slf4j; | ||||||||||||||||||
| import org.springframework.cache.annotation.CacheEvict; | ||||||||||||||||||
| import org.springframework.cache.annotation.Cacheable; | ||||||||||||||||||
| import org.springframework.data.domain.Page; | ||||||||||||||||||
| import org.springframework.data.domain.Pageable; | ||||||||||||||||||
| import org.springframework.stereotype.Service; | ||||||||||||||||||
|
|
@@ -16,6 +18,7 @@ | |||||||||||||||||
| import until.the.eternity.auctionrealtime.interfaces.rest.dto.response.RealtimeItemOptionResponse; | ||||||||||||||||||
| import until.the.eternity.common.enums.ItemCategory; | ||||||||||||||||||
| import until.the.eternity.common.response.PageResponseDto; | ||||||||||||||||||
| import until.the.eternity.config.CacheNames; | ||||||||||||||||||
|
|
||||||||||||||||||
| /** 실시간 경매장 데이터 Service. */ | ||||||||||||||||||
| @Slf4j | ||||||||||||||||||
|
|
@@ -29,10 +32,20 @@ public class AuctionRealtimeService { | |||||||||||||||||
| /** | ||||||||||||||||||
| * 실시간 경매장 아이템을 검색한다. | ||||||||||||||||||
| * | ||||||||||||||||||
| * <p>캐시 키: 주요 검색 파라미터 조합. 10분 배치 완료 시 전체 무효화된다. | ||||||||||||||||||
| * | ||||||||||||||||||
| * @param requestDto 검색 조건 | ||||||||||||||||||
| * @param pageable 페이지 정보 | ||||||||||||||||||
| * @return 검색 결과 | ||||||||||||||||||
| */ | ||||||||||||||||||
| @Cacheable( | ||||||||||||||||||
| cacheNames = CacheNames.AUCTION_REALTIME_SEARCH, | ||||||||||||||||||
| key = | ||||||||||||||||||
| "(#requestDto.itemTopCategory() ?: '_') + ':'" | ||||||||||||||||||
| + " + (#requestDto.itemSubCategory() ?: '_') + ':'" | ||||||||||||||||||
| + " + (#requestDto.itemName() ?: '_') + ':'" | ||||||||||||||||||
| + " + (#requestDto.isExactItemName() ?: false) + ':'" | ||||||||||||||||||
| + " + #pageable.pageNumber + ':' + #pageable.pageSize + ':' + #pageable.sort") | ||||||||||||||||||
|
||||||||||||||||||
| + " + #pageable.pageNumber + ':' + #pageable.pageSize + ':' + #pageable.sort") | |
| + " + #pageable.pageNumber + ':' + #pageable.pageSize + ':' + #pageable.sort", | |
| condition = | |
| "#requestDto.priceSearchRequest() == null" | |
| + " && #requestDto.itemOptionSearchRequest() == null" | |
| + " && #requestDto.enchantSearchRequest() == null" | |
| + " && (#requestDto.metalwareSearchRequests() == null" | |
| + " || #requestDto.metalwareSearchRequests().isEmpty())") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
여기서
REDIS_HOST/PORT/PASSWORD를 compose 환경변수로 강제 주입하면, 값이 미정의인 경우 빈 문자열로 컨테이너에 전달되어 application.yml의 기본값(${REDIS_HOST:localhost},${REDIS_PORT:6379})이 적용되지 않습니다. 특히REDIS_PORT가 빈 문자열이면 Spring이 int로 바인딩하다가 실패할 수 있으니, compose 쪽에 기본값(:-6379등)을 주거나 아예 이 환경변수 섹션을 제거하고 Spring 기본값에 맡기는 방식으로 정리해주세요.