From 4c6d6fa03372a7365973bac6be194f88f54b4512 Mon Sep 17 00:00:00 2001 From: dev-ant Date: Sat, 28 Feb 2026 10:00:44 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20redis=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EB=B0=B0=EC=B9=98=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=BA=90=EC=8B=B1=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 3 + .../scheduler/AuctionHistoryScheduler.java | 8 +- .../AuctionHistoryCacheWarmupService.java | 100 +++++++++++++++++ .../service/AuctionHistoryService.java | 22 ++++ .../scheduler/AuctionRealtimeScheduler.java | 5 + .../service/AuctionRealtimeService.java | 23 ++++ .../service/AuctionSearchOptionService.java | 3 + .../until/the/eternity/config/CacheNames.java | 55 +++++++++ .../the/eternity/config/RedisConfig.java | 104 ++++++++++++++++++ .../service/EnchantInfoService.java | 15 +++ .../application/service/ItemInfoService.java | 30 ++++- .../MetalwareAttributeInfoService.java | 7 ++ .../service/MetalwareInfoService.java | 12 ++ .../service/AllTimeRankingService.java | 6 +- .../service/CategoryRankingService.java | 8 ++ .../service/PriceChangeRankingService.java | 5 + .../service/PriceRankingService.java | 5 + .../service/VolumeRankingService.java | 4 + .../service/ItemDailyStatisticsService.java | 25 +++-- .../service/ItemWeeklyStatisticsService.java | 25 +++-- .../SubcategoryDailyStatisticsService.java | 23 ++-- .../SubcategoryWeeklyStatisticsService.java | 23 ++-- .../TopCategoryDailyStatisticsService.java | 19 ++-- .../TopCategoryWeeklyStatisticsService.java | 19 ++-- .../service/DailyStatisticsService.java | 77 +++++++++++++ .../service/WeeklyStatisticsService.java | 23 ++++ src/main/resources/application.yml | 14 +++ 27 files changed, 612 insertions(+), 51 deletions(-) create mode 100644 src/main/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryCacheWarmupService.java create mode 100644 src/main/java/until/the/eternity/config/CacheNames.java create mode 100644 src/main/java/until/the/eternity/config/RedisConfig.java diff --git a/build.gradle.kts b/build.gradle.kts index 68ef752b..a8950869 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -36,6 +36,9 @@ dependencies { // Spring Boot implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-aop") + + // Redis + implementation("org.springframework.boot:spring-boot-starter-data-redis") implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-data-jpa") diff --git a/src/main/java/until/the/eternity/auctionhistory/application/scheduler/AuctionHistoryScheduler.java b/src/main/java/until/the/eternity/auctionhistory/application/scheduler/AuctionHistoryScheduler.java index 141e6b1a..b4c857c0 100644 --- a/src/main/java/until/the/eternity/auctionhistory/application/scheduler/AuctionHistoryScheduler.java +++ b/src/main/java/until/the/eternity/auctionhistory/application/scheduler/AuctionHistoryScheduler.java @@ -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(); + return totalSavedCount; } } diff --git a/src/main/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryCacheWarmupService.java b/src/main/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryCacheWarmupService.java new file mode 100644 index 00000000..bafd0a49 --- /dev/null +++ b/src/main/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryCacheWarmupService.java @@ -0,0 +1,100 @@ +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; + +/** + * 경매 거래 내역 캐시 워밍업 서비스. + * + *

AuctionHistoryScheduler 배치 완료 후 호출되어 다음 작업을 수행한다. + * + *

    + *
  1. auction-history:search 캐시 전체 무효화 + *
  2. auction_history 기반 역대 랭킹 캐시 무효화 (ALLTIME_HIGHEST, ALLTIME_MONTH_VOLUME) + *
  3. page 1~5 × size 20 × sortField 3종 × direction 2종 = 30가지 조합 캐시 워밍 + *
+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AuctionHistoryCacheWarmupService { + + private static final int WARMUP_SIZE = 20; + private static final int WARMUP_MAX_PAGE = 5; + + private final AuctionHistoryService auctionHistoryService; + private final CacheManager cacheManager; + + /** + * 캐시 무효화 후 기본 30가지 조합을 선제적으로 워밍한다. + * + *

빈 검색 조건(필터 없음) 기준으로 워밍하므로, 단순 목록 조회 요청에 즉시 캐시 히트가 발생한다. + */ + 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(); + } + } +} diff --git a/src/main/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryService.java b/src/main/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryService.java index cc15bfd1..c6adf6da 100644 --- a/src/main/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryService.java +++ b/src/main/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryService.java @@ -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,26 @@ public class AuctionHistoryService { private final AuctionHistoryMapper mapper; private final EntityManager entityManager; + /** + * 경매 거래 내역을 검색한다. + * + *

단순 검색(가격/옵션/인챈트/세공 필터 없음)에 한해 캐싱을 적용한다. TTL 2시간, 배치 완료 시 전체 무효화 + 워밍업. + */ + @Cacheable( + cacheNames = CacheNames.AUCTION_HISTORY_SEARCH, + key = + "#pageRequestDto.page() + ':'" + + " + #pageRequestDto.size() + ':'" + + " + (#pageRequestDto.sortBy() != null ? #pageRequestDto.sortBy().fieldName : 'dateAuctionBuy') + ':'" + + " + (#pageRequestDto.direction() != null ? #pageRequestDto.direction().code : 'DESC') + ':'" + + " + (#requestDto.itemName() ?: '') + ':'" + + " + (#requestDto.itemTopCategory() ?: '') + ':'" + + " + (#requestDto.itemSubCategory() ?: '')", + condition = + "#requestDto.itemOptionSearchRequest() == null" + + " and #requestDto.enchantSearchRequest() == null" + + " and (#requestDto.metalwareSearchRequests() == null or #requestDto.metalwareSearchRequests().isEmpty())" + + " and #requestDto.priceSearchRequest() == null") @Transactional(readOnly = true) public PageResponseDto> search( AuctionHistorySearchRequest requestDto, PageRequestDto pageRequestDto) { diff --git a/src/main/java/until/the/eternity/auctionrealtime/application/scheduler/AuctionRealtimeScheduler.java b/src/main/java/until/the/eternity/auctionrealtime/application/scheduler/AuctionRealtimeScheduler.java index 1adc8e93..74296a41 100644 --- a/src/main/java/until/the/eternity/auctionrealtime/application/scheduler/AuctionRealtimeScheduler.java +++ b/src/main/java/until/the/eternity/auctionrealtime/application/scheduler/AuctionRealtimeScheduler.java @@ -21,6 +21,8 @@ *

10분 간격으로 Nexon Open API /auction/list를 호출하여 현재 판매 중인 아이템 정보를 수집한다. * *

각 서브 카테고리별로 전체 데이터를 수집한 뒤, 기존 데이터를 삭제하고 새 데이터로 교체한다. (Full Refresh) + * + *

Full Refresh 완료 후 auction-realtime:search 캐시 전체를 무효화한다. */ @Slf4j @Component @@ -130,5 +132,8 @@ public void fetchAndSaveAuctionRealtimeAll() { totalSavedCount, totalFailedCount, deletedExpired); + + // Full Refresh 완료 후 실시간 경매 검색 캐시 전체 무효화 + service.evictSearchCache(); } } diff --git a/src/main/java/until/the/eternity/auctionrealtime/application/service/AuctionRealtimeService.java b/src/main/java/until/the/eternity/auctionrealtime/application/service/AuctionRealtimeService.java index c34da333..5de388b0 100644 --- a/src/main/java/until/the/eternity/auctionrealtime/application/service/AuctionRealtimeService.java +++ b/src/main/java/until/the/eternity/auctionrealtime/application/service/AuctionRealtimeService.java @@ -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 { /** * 실시간 경매장 아이템을 검색한다. * + *

캐시 키: 주요 검색 파라미터 조합. 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") @Transactional(readOnly = true) public PageResponseDto> search( AuctionRealtimeSearchRequest requestDto, Pageable pageable) { @@ -91,4 +104,14 @@ public int deleteExpiredItems(Instant now) { log.info("[REALTIME] Deleted {} expired auction realtime items", deleted); return deleted; } + + /** + * 실시간 경매 검색 캐시 전체를 무효화한다. + * + *

AuctionRealtimeScheduler에서 Full Refresh 완료 후 호출한다. + */ + @CacheEvict(cacheNames = CacheNames.AUCTION_REALTIME_SEARCH, allEntries = true) + public void evictSearchCache() { + log.info("[REALTIME] Cache evicted: {}", CacheNames.AUCTION_REALTIME_SEARCH); + } } diff --git a/src/main/java/until/the/eternity/auctionsearchoption/application/service/AuctionSearchOptionService.java b/src/main/java/until/the/eternity/auctionsearchoption/application/service/AuctionSearchOptionService.java index aec4e464..95a7e73f 100644 --- a/src/main/java/until/the/eternity/auctionsearchoption/application/service/AuctionSearchOptionService.java +++ b/src/main/java/until/the/eternity/auctionsearchoption/application/service/AuctionSearchOptionService.java @@ -6,8 +6,10 @@ import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import until.the.eternity.config.CacheNames; import until.the.eternity.auctionsearchoption.domain.entity.AuctionSearchOptionMetadata; import until.the.eternity.auctionsearchoption.domain.repository.AuctionSearchOptionRepositoryPort; import until.the.eternity.auctionsearchoption.interfaces.rest.dto.response.FieldMetadata; @@ -26,6 +28,7 @@ public class AuctionSearchOptionService { * * @return 검색 옵션 메타데이터 리스트 */ + @Cacheable(cacheNames = CacheNames.SEARCH_OPTION_ALL_ACTIVE, key = "'all'") @Transactional(readOnly = true) public List getAllActiveSearchOptions() { List entities = repositoryPort.findAllActive(); diff --git a/src/main/java/until/the/eternity/config/CacheNames.java b/src/main/java/until/the/eternity/config/CacheNames.java new file mode 100644 index 00000000..be962568 --- /dev/null +++ b/src/main/java/until/the/eternity/config/CacheNames.java @@ -0,0 +1,55 @@ +package until.the.eternity.config; + +/** Redis 캐시 이름 상수 클래스. */ +public final class CacheNames { + + private CacheNames() {} + + // ===== 랭킹 ===== + public static final String RANKING_PRICE_TODAY_HIGHEST = "ranking:price:today:highest"; + public static final String RANKING_PRICE_WEEK_HIGHEST = "ranking:price:week:highest"; + public static final String RANKING_PRICE_TODAY_VOLUME = "ranking:price:today:volume"; + public static final String RANKING_VOLUME_TODAY_POPULAR = "ranking:volume:today:popular"; + public static final String RANKING_VOLUME_WEEK_POPULAR = "ranking:volume:week:popular"; + public static final String RANKING_CHANGE_PRICE_SURGE = "ranking:change:price:surge"; + public static final String RANKING_CHANGE_PRICE_DROP = "ranking:change:price:drop"; + public static final String RANKING_CHANGE_VOLUME_SURGE = "ranking:change:volume:surge"; + public static final String RANKING_CATEGORY_HIGHEST = "ranking:category:highest"; + public static final String RANKING_CATEGORY_POPULAR = "ranking:category:popular"; + public static final String RANKING_ALLTIME_HIGHEST = "ranking:alltime:highest"; + public static final String RANKING_ALLTIME_MONTH_VOLUME = "ranking:alltime:month:volume"; + + // ===== 검색 옵션 메타데이터 ===== + public static final String SEARCH_OPTION_ALL_ACTIVE = "search-option:all-active"; + + // ===== 아이템 마스터 ===== + public static final String ITEM_INFO_ALL = "item-info:all"; + public static final String ITEM_INFO_BY_TOP_CATEGORY = "item-info:by-top-category"; + public static final String ITEM_INFO_BY_SUB_CATEGORY = "item-info:by-sub-category"; + public static final String ITEM_INFO_DETAIL = "item-info:detail"; + public static final String ITEM_INFO_SUMMARY = "item-info:summary"; + + // ===== 인챈트 마스터 ===== + public static final String ENCHANT_INFO_ALL = "enchant-info:all"; + public static final String ENCHANT_INFO_FULLNAMES = "enchant-info:fullnames"; + + // ===== 세공 마스터 ===== + public static final String METALWARE_INFO_ALL = "metalware-info:all"; + public static final String METALWARE_ATTRIBUTE_INFO_SEARCH = "metalware-attribute-info:search"; + + // ===== 통계 (일간) ===== + public static final String STATISTICS_ITEM_DAILY = "statistics:item:daily"; + public static final String STATISTICS_SUBCATEGORY_DAILY = "statistics:subcategory:daily"; + public static final String STATISTICS_TOPCATEGORY_DAILY = "statistics:topcategory:daily"; + + // ===== 통계 (주간) ===== + public static final String STATISTICS_ITEM_WEEKLY = "statistics:item:weekly"; + public static final String STATISTICS_SUBCATEGORY_WEEKLY = "statistics:subcategory:weekly"; + public static final String STATISTICS_TOPCATEGORY_WEEKLY = "statistics:topcategory:weekly"; + + // ===== 실시간 경매 ===== + public static final String AUCTION_REALTIME_SEARCH = "auction-realtime:search"; + + // ===== 경매 거래 내역 ===== + public static final String AUCTION_HISTORY_SEARCH = "auction-history:search"; +} diff --git a/src/main/java/until/the/eternity/config/RedisConfig.java b/src/main/java/until/the/eternity/config/RedisConfig.java new file mode 100644 index 00000000..c7c473fc --- /dev/null +++ b/src/main/java/until/the/eternity/config/RedisConfig.java @@ -0,0 +1,104 @@ +package until.the.eternity.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +@EnableCaching +public class RedisConfig { + + @Bean + public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) { + ObjectMapper objectMapper = + new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + GenericJackson2JsonRedisSerializer jsonSerializer = + new GenericJackson2JsonRedisSerializer(objectMapper); + + RedisCacheConfiguration defaultConfig = + RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofMinutes(10)) + .serializeKeysWith( + RedisSerializationContext.SerializationPair.fromSerializer( + new StringRedisSerializer())) + .serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer( + jsonSerializer)) + .disableCachingNullValues(); + + Map configs = new HashMap<>(); + + // 랭킹 - 10분 TTL (이벤트 기반 eviction으로 실시간 반영) + Duration rankingTtl = Duration.ofMinutes(10); + configs.put(CacheNames.RANKING_PRICE_TODAY_HIGHEST, defaultConfig.entryTtl(rankingTtl)); + configs.put(CacheNames.RANKING_PRICE_WEEK_HIGHEST, defaultConfig.entryTtl(rankingTtl)); + configs.put(CacheNames.RANKING_PRICE_TODAY_VOLUME, defaultConfig.entryTtl(rankingTtl)); + configs.put(CacheNames.RANKING_VOLUME_TODAY_POPULAR, defaultConfig.entryTtl(rankingTtl)); + configs.put(CacheNames.RANKING_VOLUME_WEEK_POPULAR, defaultConfig.entryTtl(rankingTtl)); + configs.put(CacheNames.RANKING_CHANGE_PRICE_SURGE, defaultConfig.entryTtl(rankingTtl)); + configs.put(CacheNames.RANKING_CHANGE_PRICE_DROP, defaultConfig.entryTtl(rankingTtl)); + configs.put(CacheNames.RANKING_CHANGE_VOLUME_SURGE, defaultConfig.entryTtl(rankingTtl)); + configs.put(CacheNames.RANKING_CATEGORY_HIGHEST, defaultConfig.entryTtl(rankingTtl)); + configs.put(CacheNames.RANKING_CATEGORY_POPULAR, defaultConfig.entryTtl(rankingTtl)); + // 역대 최고가 - 1시간 TTL (auction_history 전체 스캔, 잘 바뀌지 않음) + configs.put( + CacheNames.RANKING_ALLTIME_HIGHEST, defaultConfig.entryTtl(Duration.ofHours(1))); + configs.put(CacheNames.RANKING_ALLTIME_MONTH_VOLUME, defaultConfig.entryTtl(rankingTtl)); + + // 검색 옵션 메타데이터 - 24시간 TTL (사실상 정적 데이터) + configs.put( + CacheNames.SEARCH_OPTION_ALL_ACTIVE, + defaultConfig.entryTtl(Duration.ofHours(24))); + + // 마스터 데이터 - 1시간 TTL (sync API 호출 시 evict) + Duration masterTtl = Duration.ofHours(1); + configs.put(CacheNames.ITEM_INFO_ALL, defaultConfig.entryTtl(masterTtl)); + configs.put(CacheNames.ITEM_INFO_BY_TOP_CATEGORY, defaultConfig.entryTtl(masterTtl)); + configs.put(CacheNames.ITEM_INFO_BY_SUB_CATEGORY, defaultConfig.entryTtl(masterTtl)); + configs.put(CacheNames.ITEM_INFO_DETAIL, defaultConfig.entryTtl(masterTtl)); + configs.put(CacheNames.ITEM_INFO_SUMMARY, defaultConfig.entryTtl(masterTtl)); + configs.put(CacheNames.ENCHANT_INFO_ALL, defaultConfig.entryTtl(masterTtl)); + configs.put(CacheNames.ENCHANT_INFO_FULLNAMES, defaultConfig.entryTtl(masterTtl)); + configs.put(CacheNames.METALWARE_INFO_ALL, defaultConfig.entryTtl(masterTtl)); + configs.put( + CacheNames.METALWARE_ATTRIBUTE_INFO_SEARCH, defaultConfig.entryTtl(masterTtl)); + + // 통계 - 30분 TTL (이벤트 기반 eviction으로 실시간 반영) + Duration statsTtl = Duration.ofMinutes(30); + configs.put(CacheNames.STATISTICS_ITEM_DAILY, defaultConfig.entryTtl(statsTtl)); + configs.put(CacheNames.STATISTICS_SUBCATEGORY_DAILY, defaultConfig.entryTtl(statsTtl)); + configs.put(CacheNames.STATISTICS_TOPCATEGORY_DAILY, defaultConfig.entryTtl(statsTtl)); + configs.put(CacheNames.STATISTICS_ITEM_WEEKLY, defaultConfig.entryTtl(statsTtl)); + configs.put(CacheNames.STATISTICS_SUBCATEGORY_WEEKLY, defaultConfig.entryTtl(statsTtl)); + configs.put(CacheNames.STATISTICS_TOPCATEGORY_WEEKLY, defaultConfig.entryTtl(statsTtl)); + + // 실시간 경매 - 12분 TTL (10분 배치 + 여유 2분) + configs.put( + CacheNames.AUCTION_REALTIME_SEARCH, + defaultConfig.entryTtl(Duration.ofMinutes(12))); + + // 경매 거래 내역 - 2시간 TTL (배치 완료 시 evict + warmup) + configs.put( + CacheNames.AUCTION_HISTORY_SEARCH, defaultConfig.entryTtl(Duration.ofHours(2))); + + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(defaultConfig) + .withInitialCacheConfigurations(configs) + .build(); + } +} diff --git a/src/main/java/until/the/eternity/enchantinfo/application/service/EnchantInfoService.java b/src/main/java/until/the/eternity/enchantinfo/application/service/EnchantInfoService.java index 396b8811..74dbe94f 100644 --- a/src/main/java/until/the/eternity/enchantinfo/application/service/EnchantInfoService.java +++ b/src/main/java/until/the/eternity/enchantinfo/application/service/EnchantInfoService.java @@ -3,11 +3,15 @@ import java.util.List; import java.util.Set; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cache.annotation.Caching; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import until.the.eternity.common.exception.CustomException; +import until.the.eternity.config.CacheNames; import until.the.eternity.enchantinfo.domain.exception.EnchantInfoExceptionCode; import until.the.eternity.enchantinfo.domain.repository.EnchantInfoRepositoryPort; import until.the.eternity.enchantinfo.interfaces.rest.dto.response.EnchantInfoResponse; @@ -22,10 +26,16 @@ public class EnchantInfoService { private final EnchantInfoRepositoryPort enchantInfoRepository; + @Cacheable( + cacheNames = CacheNames.ENCHANT_INFO_ALL, + key = "#pageable.pageNumber + ':' + #pageable.pageSize") public Page findAll(Pageable pageable) { return enchantInfoRepository.findAll(pageable).map(EnchantInfoResponse::from); } + @Cacheable( + cacheNames = CacheNames.ENCHANT_INFO_FULLNAMES, + key = "#affixPosition ?: 'all'") public List findAllFullnames(String affixPosition) { if (affixPosition != null) { if (!ALLOWED_AFFIX_POSITIONS.contains(affixPosition)) { @@ -36,6 +46,11 @@ public List findAllFullnames(String affixPosition) { return enchantInfoRepository.findAllFullnames(); } + @Caching( + evict = { + @CacheEvict(cacheNames = CacheNames.ENCHANT_INFO_ALL, allEntries = true), + @CacheEvict(cacheNames = CacheNames.ENCHANT_INFO_FULLNAMES, allEntries = true) + }) @Transactional public EnchantInfoSyncResponse syncFromAuctionHistory() { int upserted = enchantInfoRepository.upsertFromAuctionHistory(); diff --git a/src/main/java/until/the/eternity/iteminfo/application/service/ItemInfoService.java b/src/main/java/until/the/eternity/iteminfo/application/service/ItemInfoService.java index f1022c51..ab7c5bb4 100644 --- a/src/main/java/until/the/eternity/iteminfo/application/service/ItemInfoService.java +++ b/src/main/java/until/the/eternity/iteminfo/application/service/ItemInfoService.java @@ -6,6 +6,9 @@ import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cache.annotation.Caching; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -13,6 +16,7 @@ import until.the.eternity.auctionhistory.domain.repository.AuctionHistoryRepositoryPort; import until.the.eternity.batchlog.domain.enums.BatchType; import until.the.eternity.common.annotation.BatchLog; +import until.the.eternity.config.CacheNames; import until.the.eternity.iteminfo.domain.entity.ItemInfo; import until.the.eternity.iteminfo.domain.entity.ItemInfoId; import until.the.eternity.iteminfo.domain.repository.ItemInfoRepositoryPort; @@ -35,21 +39,31 @@ public List findItemCategories() { return ItemCategoryResponse.from(); } + @Cacheable(cacheNames = CacheNames.ITEM_INFO_ALL, key = "'all'") public List findAll() { List itemInfos = itemInfoRepository.findAll(); return ItemInfoResponse.from(itemInfos); } + @Cacheable(cacheNames = CacheNames.ITEM_INFO_BY_TOP_CATEGORY, key = "#topCategory") public List findByTopCategory(String topCategory) { List itemInfos = itemInfoRepository.findByTopCategory(topCategory); return ItemInfoResponse.from(itemInfos); } + @Cacheable(cacheNames = CacheNames.ITEM_INFO_BY_SUB_CATEGORY, key = "#subCategory") public List findBySubCategory(String subCategory) { List itemInfos = itemInfoRepository.findBySubCategory(subCategory); return ItemInfoResponse.from(itemInfos); } + @Cacheable( + cacheNames = CacheNames.ITEM_INFO_DETAIL, + key = + "(#searchRequest.itemName() ?: '') + ':'" + + " + (#searchRequest.itemTopCategory() ?: '') + ':'" + + " + (#searchRequest.itemSubCategory() ?: '') + ':'" + + " + #pageable.pageNumber + ':' + #pageable.pageSize") public Page findAllDetail( ItemInfoSearchRequest searchRequest, Pageable pageable) { Page itemInfoPage = @@ -57,10 +71,16 @@ public Page findAllDetail( return itemInfoPage.map(ItemInfoResponse::from); } + @Cacheable( + cacheNames = CacheNames.ITEM_INFO_SUMMARY, + key = + "(#searchRequest.itemName() ?: '') + ':'" + + " + (#searchRequest.itemTopCategory() ?: '') + ':'" + + " + (#searchRequest.itemSubCategory() ?: '') + ':'" + + " + #direction.name()") public List findAllSummary( ItemInfoSearchRequest searchRequest, org.springframework.data.domain.Sort.Direction direction) { - // direction을 Pageable로 변환 Pageable pageable = org.springframework.data.domain.PageRequest.of( 0, @@ -70,6 +90,14 @@ public List findAllSummary( return ItemInfoSummaryResponse.from(itemInfos); } + @Caching( + evict = { + @CacheEvict(cacheNames = CacheNames.ITEM_INFO_ALL, allEntries = true), + @CacheEvict(cacheNames = CacheNames.ITEM_INFO_BY_TOP_CATEGORY, allEntries = true), + @CacheEvict(cacheNames = CacheNames.ITEM_INFO_BY_SUB_CATEGORY, allEntries = true), + @CacheEvict(cacheNames = CacheNames.ITEM_INFO_DETAIL, allEntries = true), + @CacheEvict(cacheNames = CacheNames.ITEM_INFO_SUMMARY, allEntries = true) + }) @BatchLog(type = BatchType.ITEM_INFO_SYNC) @Transactional public ItemInfoSyncResponse syncItemInfoFromAuctionHistory() { diff --git a/src/main/java/until/the/eternity/metalwareinfo/application/service/MetalwareAttributeInfoService.java b/src/main/java/until/the/eternity/metalwareinfo/application/service/MetalwareAttributeInfoService.java index 2816d4fa..8e8adf7a 100644 --- a/src/main/java/until/the/eternity/metalwareinfo/application/service/MetalwareAttributeInfoService.java +++ b/src/main/java/until/the/eternity/metalwareinfo/application/service/MetalwareAttributeInfoService.java @@ -1,11 +1,14 @@ package until.the.eternity.metalwareinfo.application.service; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import until.the.eternity.batchlog.domain.enums.BatchType; import until.the.eternity.common.annotation.BatchLog; +import until.the.eternity.config.CacheNames; import until.the.eternity.metalwareinfo.domain.repository.MetalwareAttributeInfoRepositoryPort; import until.the.eternity.metalwareinfo.interfaces.rest.dto.request.MetalwareAttributeInfoSearchRequest; import until.the.eternity.metalwareinfo.interfaces.rest.dto.response.MetalwareAttributeInfoResponse; @@ -16,6 +19,7 @@ public class MetalwareAttributeInfoService { private final MetalwareAttributeInfoRepositoryPort metalwareAttributeInfoRepository; + @CacheEvict(cacheNames = CacheNames.METALWARE_ATTRIBUTE_INFO_SEARCH, allEntries = true) @BatchLog(type = BatchType.METALWARE_ATTRIBUTE_SYNC) @Transactional public int sync() { @@ -27,6 +31,9 @@ public int sync() { return inserted + updated; } + @Cacheable( + cacheNames = CacheNames.METALWARE_ATTRIBUTE_INFO_SEARCH, + key = "#request.metalware() + ':' + #request.toPageable().pageNumber + ':' + #request.toPageable().pageSize") @Transactional(readOnly = true) public Page search( MetalwareAttributeInfoSearchRequest request) { diff --git a/src/main/java/until/the/eternity/metalwareinfo/application/service/MetalwareInfoService.java b/src/main/java/until/the/eternity/metalwareinfo/application/service/MetalwareInfoService.java index fdc99fbd..aa909d6f 100644 --- a/src/main/java/until/the/eternity/metalwareinfo/application/service/MetalwareInfoService.java +++ b/src/main/java/until/the/eternity/metalwareinfo/application/service/MetalwareInfoService.java @@ -2,8 +2,12 @@ import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cache.annotation.Caching; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import until.the.eternity.config.CacheNames; import until.the.eternity.metalwareinfo.domain.repository.MetalwareInfoRepositoryPort; import until.the.eternity.metalwareinfo.interfaces.rest.dto.response.MetalwareInfoResponse; import until.the.eternity.metalwareinfo.interfaces.rest.dto.response.MetalwareInfoSyncResponse; @@ -15,11 +19,19 @@ public class MetalwareInfoService { private final MetalwareInfoRepositoryPort metalwareInfoRepository; + @Cacheable(cacheNames = CacheNames.METALWARE_INFO_ALL, key = "'all'") public List findAll() { List metalwares = metalwareInfoRepository.findAllMetalwares(); return MetalwareInfoResponse.from(metalwares); } + @Caching( + evict = { + @CacheEvict(cacheNames = CacheNames.METALWARE_INFO_ALL, allEntries = true), + @CacheEvict( + cacheNames = CacheNames.METALWARE_ATTRIBUTE_INFO_SEARCH, + allEntries = true) + }) @Transactional public MetalwareInfoSyncResponse syncFromAttributeInfo() { int levelAttributeUpserted = diff --git a/src/main/java/until/the/eternity/ranking/application/service/AllTimeRankingService.java b/src/main/java/until/the/eternity/ranking/application/service/AllTimeRankingService.java index 1fb59d6d..ac95269f 100644 --- a/src/main/java/until/the/eternity/ranking/application/service/AllTimeRankingService.java +++ b/src/main/java/until/the/eternity/ranking/application/service/AllTimeRankingService.java @@ -2,8 +2,10 @@ import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import until.the.eternity.config.CacheNames; import until.the.eternity.ranking.domain.mapper.RankingMapper; import until.the.eternity.ranking.interfaces.rest.dto.response.AllTimeRankingResponse; import until.the.eternity.ranking.repository.RankingRepository; @@ -16,13 +18,15 @@ public class AllTimeRankingService { private final RankingRepository rankingRepository; private final RankingMapper rankingMapper; - /** 역대 최고가 거래 TOP N (API 11) */ + /** 역대 최고가 거래 TOP N (API 11) - auction_history 전체 스캔, 1시간 TTL */ + @Cacheable(cacheNames = CacheNames.RANKING_ALLTIME_HIGHEST, key = "#limit") public List getAllTimeHighestPrice(int limit) { List results = rankingRepository.findAllTimeHighestPrice(limit); return rankingMapper.toAllTimeRankingResponses(results); } /** 이번 달 최대 거래액 TOP N (API 12) */ + @Cacheable(cacheNames = CacheNames.RANKING_ALLTIME_MONTH_VOLUME, key = "#limit") public List getMonthLargestVolume(int limit) { List results = rankingRepository.findMonthLargestVolume(limit); return rankingMapper.toAllTimeRankingResponses(results); diff --git a/src/main/java/until/the/eternity/ranking/application/service/CategoryRankingService.java b/src/main/java/until/the/eternity/ranking/application/service/CategoryRankingService.java index f70c98e0..194e2a30 100644 --- a/src/main/java/until/the/eternity/ranking/application/service/CategoryRankingService.java +++ b/src/main/java/until/the/eternity/ranking/application/service/CategoryRankingService.java @@ -2,8 +2,10 @@ import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import until.the.eternity.config.CacheNames; import until.the.eternity.ranking.domain.mapper.RankingMapper; import until.the.eternity.ranking.interfaces.rest.dto.response.PriceRankingResponse; import until.the.eternity.ranking.interfaces.rest.dto.response.VolumeRankingResponse; @@ -18,6 +20,9 @@ public class CategoryRankingService { private final RankingMapper rankingMapper; /** 카테고리별 최고가 TOP N (API 9) */ + @Cacheable( + cacheNames = CacheNames.RANKING_CATEGORY_HIGHEST, + key = "#topCategory + ':' + #subCategory + ':' + #limit") public List getCategoryTopPriced( String topCategory, String subCategory, int limit) { List results = @@ -26,6 +31,9 @@ public List getCategoryTopPriced( } /** 카테고리별 인기 아이템 TOP N (API 10) */ + @Cacheable( + cacheNames = CacheNames.RANKING_CATEGORY_POPULAR, + key = "#topCategory + ':' + #subCategory + ':' + #limit") public List getCategoryPopular( String topCategory, String subCategory, int limit) { List results = diff --git a/src/main/java/until/the/eternity/ranking/application/service/PriceChangeRankingService.java b/src/main/java/until/the/eternity/ranking/application/service/PriceChangeRankingService.java index be0cd184..1296d9b5 100644 --- a/src/main/java/until/the/eternity/ranking/application/service/PriceChangeRankingService.java +++ b/src/main/java/until/the/eternity/ranking/application/service/PriceChangeRankingService.java @@ -2,8 +2,10 @@ import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import until.the.eternity.config.CacheNames; import until.the.eternity.ranking.domain.mapper.RankingMapper; import until.the.eternity.ranking.interfaces.rest.dto.response.PriceChangeRankingResponse; import until.the.eternity.ranking.interfaces.rest.dto.response.VolumeChangeRankingResponse; @@ -18,18 +20,21 @@ public class PriceChangeRankingService { private final RankingMapper rankingMapper; /** 가격 급등 TOP N (API 6) */ + @Cacheable(cacheNames = CacheNames.RANKING_CHANGE_PRICE_SURGE, key = "#limit") public List getPriceSurge(int limit) { List results = rankingRepository.findPriceSurge(limit); return rankingMapper.toPriceChangeRankingResponses(results); } /** 가격 급락 TOP N (API 7) */ + @Cacheable(cacheNames = CacheNames.RANKING_CHANGE_PRICE_DROP, key = "#limit") public List getPriceDrop(int limit) { List results = rankingRepository.findPriceDrop(limit); return rankingMapper.toPriceChangeRankingResponses(results); } /** 거래량 급증 TOP N (API 8) */ + @Cacheable(cacheNames = CacheNames.RANKING_CHANGE_VOLUME_SURGE, key = "#limit") public List getVolumeSurge(int limit) { List results = rankingRepository.findVolumeSurge(limit); return rankingMapper.toVolumeChangeRankingResponses(results); diff --git a/src/main/java/until/the/eternity/ranking/application/service/PriceRankingService.java b/src/main/java/until/the/eternity/ranking/application/service/PriceRankingService.java index 757d9cf7..fa81b45e 100644 --- a/src/main/java/until/the/eternity/ranking/application/service/PriceRankingService.java +++ b/src/main/java/until/the/eternity/ranking/application/service/PriceRankingService.java @@ -2,8 +2,10 @@ import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import until.the.eternity.config.CacheNames; import until.the.eternity.ranking.domain.mapper.RankingMapper; import until.the.eternity.ranking.interfaces.rest.dto.response.PriceRankingResponse; import until.the.eternity.ranking.repository.RankingRepository; @@ -17,18 +19,21 @@ public class PriceRankingService { private final RankingMapper rankingMapper; /** 오늘의 최고가 거래 TOP N (API 1) */ + @Cacheable(cacheNames = CacheNames.RANKING_PRICE_TODAY_HIGHEST, key = "#limit") public List getTodayHighestPrice(int limit) { List results = rankingRepository.findTodayHighestPrice(limit); return rankingMapper.toPriceRankingResponses(results); } /** 이번 주 최고가 아이템 TOP N (API 2) */ + @Cacheable(cacheNames = CacheNames.RANKING_PRICE_WEEK_HIGHEST, key = "#limit") public List getWeekHighestPrice(int limit) { List results = rankingRepository.findWeekHighestPrice(limit); return rankingMapper.toPriceRankingResponses(results); } /** 오늘의 최대 거래액 TOP N (API 3) */ + @Cacheable(cacheNames = CacheNames.RANKING_PRICE_TODAY_VOLUME, key = "#limit") public List getTodayLargestVolume(int limit) { List results = rankingRepository.findTodayLargestVolume(limit); return rankingMapper.toPriceRankingResponses(results); diff --git a/src/main/java/until/the/eternity/ranking/application/service/VolumeRankingService.java b/src/main/java/until/the/eternity/ranking/application/service/VolumeRankingService.java index 033a61bb..9d593054 100644 --- a/src/main/java/until/the/eternity/ranking/application/service/VolumeRankingService.java +++ b/src/main/java/until/the/eternity/ranking/application/service/VolumeRankingService.java @@ -2,8 +2,10 @@ import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import until.the.eternity.config.CacheNames; import until.the.eternity.ranking.domain.mapper.RankingMapper; import until.the.eternity.ranking.interfaces.rest.dto.response.VolumeRankingResponse; import until.the.eternity.ranking.repository.RankingRepository; @@ -17,12 +19,14 @@ public class VolumeRankingService { private final RankingMapper rankingMapper; /** 오늘의 인기 아이템 TOP N (API 4) */ + @Cacheable(cacheNames = CacheNames.RANKING_VOLUME_TODAY_POPULAR, key = "#limit") public List getTodayPopular(int limit) { List results = rankingRepository.findTodayPopular(limit); return rankingMapper.toVolumeRankingResponses(results); } /** 이번 주 인기 아이템 TOP N (API 5) */ + @Cacheable(cacheNames = CacheNames.RANKING_VOLUME_WEEK_POPULAR, key = "#limit") public List getWeekPopular(int limit) { List results = rankingRepository.findWeekPopular(limit); return rankingMapper.toVolumeRankingResponses(results); diff --git a/src/main/java/until/the/eternity/statistics/application/service/ItemDailyStatisticsService.java b/src/main/java/until/the/eternity/statistics/application/service/ItemDailyStatisticsService.java index a2d7e8ec..c765967d 100644 --- a/src/main/java/until/the/eternity/statistics/application/service/ItemDailyStatisticsService.java +++ b/src/main/java/until/the/eternity/statistics/application/service/ItemDailyStatisticsService.java @@ -1,9 +1,14 @@ package until.the.eternity.statistics.application.service; +import java.time.LocalDate; +import java.util.List; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import until.the.eternity.config.CacheNames; import until.the.eternity.statistics.domain.entity.daily.ItemDailyStatistics; import until.the.eternity.statistics.domain.mapper.ItemDailyStatisticsMapper; import until.the.eternity.statistics.interfaces.rest.dto.response.ItemDailyStatisticsResponse; @@ -18,23 +23,27 @@ public class ItemDailyStatisticsService { private final ItemDailyStatisticsMapper mapper; /** 아이템별 일간 통계 조회 (itemName, subCategory, topCategory, 날짜 범위) */ + @Cacheable( + cacheNames = CacheNames.STATISTICS_ITEM_DAILY, + key = + "(#itemName ?: '') + ':'" + + " + (#subCategory ?: '') + ':'" + + " + (#topCategory ?: '') + ':'" + + " + #startDate + ':' + #endDate") @Transactional(readOnly = true) - public java.util.List search( + public List search( String itemName, String subCategory, String topCategory, - java.time.LocalDate startDate, - java.time.LocalDate endDate) { - // 날짜 범위 검증 (최대 30일) + LocalDate startDate, + LocalDate endDate) { until.the.eternity.statistics.util.DateRangeValidator.validateDailyDateRange( startDate, endDate); - // 조회 - java.util.List results = + List results = repository.findByItemAndDateRange( itemName, subCategory, topCategory, startDate, endDate); - // DTO 변환 - return results.stream().map(mapper::toDto).collect(java.util.stream.Collectors.toList()); + return results.stream().map(mapper::toDto).collect(Collectors.toList()); } } diff --git a/src/main/java/until/the/eternity/statistics/application/service/ItemWeeklyStatisticsService.java b/src/main/java/until/the/eternity/statistics/application/service/ItemWeeklyStatisticsService.java index 8cc8677d..d40850c4 100644 --- a/src/main/java/until/the/eternity/statistics/application/service/ItemWeeklyStatisticsService.java +++ b/src/main/java/until/the/eternity/statistics/application/service/ItemWeeklyStatisticsService.java @@ -1,9 +1,14 @@ package until.the.eternity.statistics.application.service; +import java.time.LocalDate; +import java.util.List; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import until.the.eternity.config.CacheNames; import until.the.eternity.statistics.domain.entity.weekly.ItemWeeklyStatistics; import until.the.eternity.statistics.domain.mapper.ItemWeeklyStatisticsMapper; import until.the.eternity.statistics.interfaces.rest.dto.response.ItemWeeklyStatisticsResponse; @@ -18,23 +23,27 @@ public class ItemWeeklyStatisticsService { private final ItemWeeklyStatisticsMapper mapper; /** 아이템별 주간 통계 조회 (itemName, subCategory, topCategory, 날짜 범위) */ + @Cacheable( + cacheNames = CacheNames.STATISTICS_ITEM_WEEKLY, + key = + "(#itemName ?: '') + ':'" + + " + (#subCategory ?: '') + ':'" + + " + (#topCategory ?: '') + ':'" + + " + #startDate + ':' + #endDate") @Transactional(readOnly = true) - public java.util.List search( + public List search( String itemName, String subCategory, String topCategory, - java.time.LocalDate startDate, - java.time.LocalDate endDate) { - // 날짜 범위 검증 (최대 4개월) + LocalDate startDate, + LocalDate endDate) { until.the.eternity.statistics.util.DateRangeValidator.validateWeeklyDateRange( startDate, endDate); - // 조회 - java.util.List results = + List results = repository.findByItemAndDateRange( itemName, subCategory, topCategory, startDate, endDate); - // DTO 변환 - return results.stream().map(mapper::toDto).collect(java.util.stream.Collectors.toList()); + return results.stream().map(mapper::toDto).collect(Collectors.toList()); } } diff --git a/src/main/java/until/the/eternity/statistics/application/service/SubcategoryDailyStatisticsService.java b/src/main/java/until/the/eternity/statistics/application/service/SubcategoryDailyStatisticsService.java index 1038bcd5..93135629 100644 --- a/src/main/java/until/the/eternity/statistics/application/service/SubcategoryDailyStatisticsService.java +++ b/src/main/java/until/the/eternity/statistics/application/service/SubcategoryDailyStatisticsService.java @@ -1,9 +1,14 @@ package until.the.eternity.statistics.application.service; +import java.time.LocalDate; +import java.util.List; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import until.the.eternity.config.CacheNames; import until.the.eternity.statistics.domain.entity.daily.SubcategoryDailyStatistics; import until.the.eternity.statistics.domain.mapper.SubcategoryDailyStatisticsMapper; import until.the.eternity.statistics.interfaces.rest.dto.response.SubcategoryDailyStatisticsResponse; @@ -18,21 +23,21 @@ public class SubcategoryDailyStatisticsService { private final SubcategoryDailyStatisticsMapper mapper; /** 서브카테고리별 일간 통계 조회 (subCategory, 날짜 범위) */ + @Cacheable( + cacheNames = CacheNames.STATISTICS_SUBCATEGORY_DAILY, + key = "(#topCategory ?: '') + ':' + (#subCategory ?: '') + ':' + #startDate + ':' + #endDate") @Transactional(readOnly = true) - public java.util.List search( - String topCategory, // topCategory는 파라미터로 받지만 조회에는 사용하지 않음 (DB 구조상) + public List search( + String topCategory, String subCategory, - java.time.LocalDate startDate, - java.time.LocalDate endDate) { - // 날짜 범위 검증 (최대 30일) + LocalDate startDate, + LocalDate endDate) { until.the.eternity.statistics.util.DateRangeValidator.validateDailyDateRange( startDate, endDate); - // 조회 - java.util.List results = + List results = repository.findBySubcategoryAndDateRange(subCategory, startDate, endDate); - // DTO 변환 - return results.stream().map(mapper::toDto).collect(java.util.stream.Collectors.toList()); + return results.stream().map(mapper::toDto).collect(Collectors.toList()); } } diff --git a/src/main/java/until/the/eternity/statistics/application/service/SubcategoryWeeklyStatisticsService.java b/src/main/java/until/the/eternity/statistics/application/service/SubcategoryWeeklyStatisticsService.java index e4d0b5ed..84391115 100644 --- a/src/main/java/until/the/eternity/statistics/application/service/SubcategoryWeeklyStatisticsService.java +++ b/src/main/java/until/the/eternity/statistics/application/service/SubcategoryWeeklyStatisticsService.java @@ -1,9 +1,14 @@ package until.the.eternity.statistics.application.service; +import java.time.LocalDate; +import java.util.List; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import until.the.eternity.config.CacheNames; import until.the.eternity.statistics.domain.entity.weekly.SubcategoryWeeklyStatistics; import until.the.eternity.statistics.domain.mapper.SubcategoryWeeklyStatisticsMapper; import until.the.eternity.statistics.interfaces.rest.dto.response.SubcategoryWeeklyStatisticsResponse; @@ -18,21 +23,21 @@ public class SubcategoryWeeklyStatisticsService { private final SubcategoryWeeklyStatisticsMapper mapper; /** 서브카테고리별 주간 통계 조회 (subCategory, 날짜 범위) */ + @Cacheable( + cacheNames = CacheNames.STATISTICS_SUBCATEGORY_WEEKLY, + key = "(#topCategory ?: '') + ':' + (#subCategory ?: '') + ':' + #startDate + ':' + #endDate") @Transactional(readOnly = true) - public java.util.List search( - String topCategory, // topCategory는 파라미터로 받지만 조회에는 사용하지 않음 (DB 구조상) + public List search( + String topCategory, String subCategory, - java.time.LocalDate startDate, - java.time.LocalDate endDate) { - // 날짜 범위 검증 (최대 4개월) + LocalDate startDate, + LocalDate endDate) { until.the.eternity.statistics.util.DateRangeValidator.validateWeeklyDateRange( startDate, endDate); - // 조회 - java.util.List results = + List results = repository.findBySubcategoryAndDateRange(subCategory, startDate, endDate); - // DTO 변환 - return results.stream().map(mapper::toDto).collect(java.util.stream.Collectors.toList()); + return results.stream().map(mapper::toDto).collect(Collectors.toList()); } } diff --git a/src/main/java/until/the/eternity/statistics/application/service/TopCategoryDailyStatisticsService.java b/src/main/java/until/the/eternity/statistics/application/service/TopCategoryDailyStatisticsService.java index d150b7a6..3aabdee4 100644 --- a/src/main/java/until/the/eternity/statistics/application/service/TopCategoryDailyStatisticsService.java +++ b/src/main/java/until/the/eternity/statistics/application/service/TopCategoryDailyStatisticsService.java @@ -1,9 +1,14 @@ package until.the.eternity.statistics.application.service; +import java.time.LocalDate; +import java.util.List; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import until.the.eternity.config.CacheNames; import until.the.eternity.statistics.domain.entity.daily.TopCategoryDailyStatistics; import until.the.eternity.statistics.domain.mapper.TopCategoryDailyStatisticsMapper; import until.the.eternity.statistics.interfaces.rest.dto.response.TopCategoryDailyStatisticsResponse; @@ -18,18 +23,18 @@ public class TopCategoryDailyStatisticsService { private final TopCategoryDailyStatisticsMapper mapper; /** 탑카테고리별 일간 통계 조회 (topCategory, 날짜 범위) */ + @Cacheable( + cacheNames = CacheNames.STATISTICS_TOPCATEGORY_DAILY, + key = "(#topCategory ?: '') + ':' + #startDate + ':' + #endDate") @Transactional(readOnly = true) - public java.util.List search( - String topCategory, java.time.LocalDate startDate, java.time.LocalDate endDate) { - // 날짜 범위 검증 (최대 30일) + public List search( + String topCategory, LocalDate startDate, LocalDate endDate) { until.the.eternity.statistics.util.DateRangeValidator.validateDailyDateRange( startDate, endDate); - // 조회 - java.util.List results = + List results = repository.findByTopCategoryAndDateRange(topCategory, startDate, endDate); - // DTO 변환 - return results.stream().map(mapper::toDto).collect(java.util.stream.Collectors.toList()); + return results.stream().map(mapper::toDto).collect(Collectors.toList()); } } diff --git a/src/main/java/until/the/eternity/statistics/application/service/TopCategoryWeeklyStatisticsService.java b/src/main/java/until/the/eternity/statistics/application/service/TopCategoryWeeklyStatisticsService.java index 5d1e7b3c..9717d7ba 100644 --- a/src/main/java/until/the/eternity/statistics/application/service/TopCategoryWeeklyStatisticsService.java +++ b/src/main/java/until/the/eternity/statistics/application/service/TopCategoryWeeklyStatisticsService.java @@ -1,9 +1,14 @@ package until.the.eternity.statistics.application.service; +import java.time.LocalDate; +import java.util.List; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import until.the.eternity.config.CacheNames; import until.the.eternity.statistics.domain.entity.weekly.TopCategoryWeeklyStatistics; import until.the.eternity.statistics.domain.mapper.TopCategoryWeeklyStatisticsMapper; import until.the.eternity.statistics.interfaces.rest.dto.response.TopCategoryWeeklyStatisticsResponse; @@ -18,18 +23,18 @@ public class TopCategoryWeeklyStatisticsService { private final TopCategoryWeeklyStatisticsMapper mapper; /** 탑카테고리별 주간 통계 조회 (topCategory, 날짜 범위) */ + @Cacheable( + cacheNames = CacheNames.STATISTICS_TOPCATEGORY_WEEKLY, + key = "(#topCategory ?: '') + ':' + #startDate + ':' + #endDate") @Transactional(readOnly = true) - public java.util.List search( - String topCategory, java.time.LocalDate startDate, java.time.LocalDate endDate) { - // 날짜 범위 검증 (최대 4개월) + public List search( + String topCategory, LocalDate startDate, LocalDate endDate) { until.the.eternity.statistics.util.DateRangeValidator.validateWeeklyDateRange( startDate, endDate); - // 조회 - java.util.List results = + List results = repository.findByTopCategoryAndDateRange(topCategory, startDate, endDate); - // DTO 변환 - return results.stream().map(mapper::toDto).collect(java.util.stream.Collectors.toList()); + return results.stream().map(mapper::toDto).collect(Collectors.toList()); } } diff --git a/src/main/java/until/the/eternity/statistics/service/DailyStatisticsService.java b/src/main/java/until/the/eternity/statistics/service/DailyStatisticsService.java index 6f09dffc..94fd0c9c 100644 --- a/src/main/java/until/the/eternity/statistics/service/DailyStatisticsService.java +++ b/src/main/java/until/the/eternity/statistics/service/DailyStatisticsService.java @@ -2,8 +2,11 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Caching; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import until.the.eternity.config.CacheNames; import until.the.eternity.statistics.repository.daily.ItemDailyStatisticsRepository; import until.the.eternity.statistics.repository.daily.SubcategoryDailyStatisticsRepository; import until.the.eternity.statistics.repository.daily.TopCategoryDailyStatisticsRepository; @@ -20,7 +23,45 @@ public class DailyStatisticsService { /** * 당일의 경매 거래 내역을 기반으로 일간 통계를 업데이트 AuctionHistoryScheduler가 실행될 때마다 호출되어 당일 통계만 갱신 순서: * auction_history → ItemDaily → SubcategoryDaily → TopCategoryDaily + * + *

통계 계산 완료 후 일간 통계 캐시 + 오늘/카테고리 기반 랭킹 캐시를 무효화한다. */ + @Caching( + evict = { + // 일간 통계 캐시 + @CacheEvict(cacheNames = CacheNames.STATISTICS_ITEM_DAILY, allEntries = true), + @CacheEvict( + cacheNames = CacheNames.STATISTICS_SUBCATEGORY_DAILY, + allEntries = true), + @CacheEvict( + cacheNames = CacheNames.STATISTICS_TOPCATEGORY_DAILY, + allEntries = true), + // 오늘 기준 랭킹 캐시 (item_daily_statistics 기반) + @CacheEvict( + cacheNames = CacheNames.RANKING_PRICE_TODAY_HIGHEST, + allEntries = true), + @CacheEvict( + cacheNames = CacheNames.RANKING_PRICE_TODAY_VOLUME, + allEntries = true), + @CacheEvict( + cacheNames = CacheNames.RANKING_VOLUME_TODAY_POPULAR, + allEntries = true), + @CacheEvict( + cacheNames = CacheNames.RANKING_CHANGE_PRICE_SURGE, + allEntries = true), + @CacheEvict( + cacheNames = CacheNames.RANKING_CHANGE_PRICE_DROP, + allEntries = true), + @CacheEvict( + cacheNames = CacheNames.RANKING_CHANGE_VOLUME_SURGE, + allEntries = true), + @CacheEvict( + cacheNames = CacheNames.RANKING_CATEGORY_HIGHEST, + allEntries = true), + @CacheEvict( + cacheNames = CacheNames.RANKING_CATEGORY_POPULAR, + allEntries = true), + }) @Transactional public void calculateAndSaveCurrentDayStatistics() { log.info("[Current Day Statistics] Starting current day statistics calculation..."); @@ -58,7 +99,43 @@ public void calculateAndSaveCurrentDayStatistics() { /** * 전날의 경매 거래 내역을 기반으로 일간 통계를 최종 확정 매일 새벽 한 번 실행되어 전날 23시대 거래까지 포함한 통계를 완성 순서: auction_history → * ItemDaily → SubcategoryDaily → TopCategoryDaily + * + *

전날 통계 확정 후 변동률 랭킹(어제 대비 오늘)도 함께 무효화한다. */ + @Caching( + evict = { + @CacheEvict(cacheNames = CacheNames.STATISTICS_ITEM_DAILY, allEntries = true), + @CacheEvict( + cacheNames = CacheNames.STATISTICS_SUBCATEGORY_DAILY, + allEntries = true), + @CacheEvict( + cacheNames = CacheNames.STATISTICS_TOPCATEGORY_DAILY, + allEntries = true), + @CacheEvict( + cacheNames = CacheNames.RANKING_PRICE_TODAY_HIGHEST, + allEntries = true), + @CacheEvict( + cacheNames = CacheNames.RANKING_PRICE_TODAY_VOLUME, + allEntries = true), + @CacheEvict( + cacheNames = CacheNames.RANKING_VOLUME_TODAY_POPULAR, + allEntries = true), + @CacheEvict( + cacheNames = CacheNames.RANKING_CHANGE_PRICE_SURGE, + allEntries = true), + @CacheEvict( + cacheNames = CacheNames.RANKING_CHANGE_PRICE_DROP, + allEntries = true), + @CacheEvict( + cacheNames = CacheNames.RANKING_CHANGE_VOLUME_SURGE, + allEntries = true), + @CacheEvict( + cacheNames = CacheNames.RANKING_CATEGORY_HIGHEST, + allEntries = true), + @CacheEvict( + cacheNames = CacheNames.RANKING_CATEGORY_POPULAR, + allEntries = true), + }) @Transactional public void calculateAndSavePreviousDayStatistics() { log.info("[Previous Day Statistics] Starting previous day statistics finalization..."); diff --git a/src/main/java/until/the/eternity/statistics/service/WeeklyStatisticsService.java b/src/main/java/until/the/eternity/statistics/service/WeeklyStatisticsService.java index 5c5ca2b7..5c5d6a47 100644 --- a/src/main/java/until/the/eternity/statistics/service/WeeklyStatisticsService.java +++ b/src/main/java/until/the/eternity/statistics/service/WeeklyStatisticsService.java @@ -2,8 +2,11 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Caching; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import until.the.eternity.config.CacheNames; import until.the.eternity.statistics.repository.weekly.ItemWeeklyStatisticsRepository; import until.the.eternity.statistics.repository.weekly.SubcategoryWeeklyStatisticsRepository; import until.the.eternity.statistics.repository.weekly.TopCategoryWeeklyStatisticsRepository; @@ -20,7 +23,27 @@ public class WeeklyStatisticsService { /** * 전주(지난 주 월~일)의 일간 통계를 기반으로 주간 통계를 계산하여 저장 순서: ItemDaily → ItemWeekly → SubcategoryWeekly → * TopCategoryWeekly + * + *

주간 통계 계산 완료 후 주간 통계 캐시 + 주간 랭킹 캐시를 무효화한다. */ + @Caching( + evict = { + // 주간 통계 캐시 + @CacheEvict(cacheNames = CacheNames.STATISTICS_ITEM_WEEKLY, allEntries = true), + @CacheEvict( + cacheNames = CacheNames.STATISTICS_SUBCATEGORY_WEEKLY, + allEntries = true), + @CacheEvict( + cacheNames = CacheNames.STATISTICS_TOPCATEGORY_WEEKLY, + allEntries = true), + // 주간 기준 랭킹 캐시 (item_weekly_statistics 기반) + @CacheEvict( + cacheNames = CacheNames.RANKING_PRICE_WEEK_HIGHEST, + allEntries = true), + @CacheEvict( + cacheNames = CacheNames.RANKING_VOLUME_WEEK_POPULAR, + allEntries = true), + }) @Transactional public void calculateAndSaveWeeklyStatistics() { log.info("[Weekly Statistics] Starting weekly statistics calculation..."); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c8fbd4d8..67bf6a9e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -18,6 +18,20 @@ management: spring: application: name: open-api-batch-server + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + timeout: 3000ms + lettuce: + pool: + max-active: 10 + max-idle: 5 + min-idle: 2 + max-wait: 1000ms + cache: + type: redis elasticsearch: uris: ${ELASTICSEARCH_URIS:http://localhost:9200} username: ${ELASTICSEARCH_USERNAME:} From 363090103f679d8098e565b5fab9ace4753b0043 Mon Sep 17 00:00:00 2001 From: dev-ant Date: Sat, 28 Feb 2026 10:24:20 +0900 Subject: [PATCH 02/10] =?UTF-8?q?chore:=20docker-compose=20redis=20?= =?UTF-8?q?=ED=86=B5=EC=8B=A0=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose-prod.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docker-compose-prod.yml b/docker-compose-prod.yml index d0917839..97a316c5 100644 --- a/docker-compose-prod.yml +++ b/docker-compose-prod.yml @@ -32,6 +32,11 @@ services: DB_USER: ${DB_USER} DB_PASSWORD: ${DB_PASSWORD} + # === Redis Configuration === + REDIS_HOST: ${REDIS_HOST} + REDIS_PORT: ${REDIS_PORT} + REDIS_PASSWORD: ${REDIS_PASSWORD} + # === Security Configuration === JWT_SECRET_KEY: ${JWT_SECRET_KEY} JWT_ACCESS_TOKEN_VALIDITY: ${JWT_ACCESS_TOKEN_VALIDITY} From 04293786883a4c8a2e301e1ab470ce3898e58a68 Mon Sep 17 00:00:00 2001 From: dev-ant Date: Sat, 28 Feb 2026 11:16:41 +0900 Subject: [PATCH 03/10] =?UTF-8?q?fix:=20=EC=9D=B8=EC=B1=88=ED=8A=B8,=20?= =?UTF-8?q?=EC=95=84=EC=9D=B4=ED=85=9C,=20=EC=84=B8=EA=B3=B5=20=EC=86=8D?= =?UTF-8?q?=EC=84=B1=20=EC=A0=95=EB=B3=B4=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20redis=20key=20=ED=8C=8C=EB=9D=BC?= =?UTF-8?q?=EB=AF=B8=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/service/EnchantInfoService.java | 2 +- .../application/service/ItemInfoService.java | 15 ++++++++------- .../service/MetalwareAttributeInfoService.java | 2 +- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/main/java/until/the/eternity/enchantinfo/application/service/EnchantInfoService.java b/src/main/java/until/the/eternity/enchantinfo/application/service/EnchantInfoService.java index 74dbe94f..2d3f7127 100644 --- a/src/main/java/until/the/eternity/enchantinfo/application/service/EnchantInfoService.java +++ b/src/main/java/until/the/eternity/enchantinfo/application/service/EnchantInfoService.java @@ -28,7 +28,7 @@ public class EnchantInfoService { @Cacheable( cacheNames = CacheNames.ENCHANT_INFO_ALL, - key = "#pageable.pageNumber + ':' + #pageable.pageSize") + key = "#pageable.pageNumber + ':' + #pageable.pageSize + ':' + #pageable.sort") public Page findAll(Pageable pageable) { return enchantInfoRepository.findAll(pageable).map(EnchantInfoResponse::from); } diff --git a/src/main/java/until/the/eternity/iteminfo/application/service/ItemInfoService.java b/src/main/java/until/the/eternity/iteminfo/application/service/ItemInfoService.java index ab7c5bb4..5e7491e4 100644 --- a/src/main/java/until/the/eternity/iteminfo/application/service/ItemInfoService.java +++ b/src/main/java/until/the/eternity/iteminfo/application/service/ItemInfoService.java @@ -60,10 +60,11 @@ public List findBySubCategory(String subCategory) { @Cacheable( cacheNames = CacheNames.ITEM_INFO_DETAIL, key = - "(#searchRequest.itemName() ?: '') + ':'" - + " + (#searchRequest.itemTopCategory() ?: '') + ':'" - + " + (#searchRequest.itemSubCategory() ?: '') + ':'" - + " + #pageable.pageNumber + ':' + #pageable.pageSize") + "(#searchRequest.name() ?: '') + ':'" + + " + (#searchRequest.topCategory() ?: '') + ':'" + + " + (#searchRequest.subCategory() ?: '') + ':'" + + " + #pageable.pageNumber + ':' + #pageable.pageSize" + + " + ':' + #pageable.sort") public Page findAllDetail( ItemInfoSearchRequest searchRequest, Pageable pageable) { Page itemInfoPage = @@ -74,9 +75,9 @@ public Page findAllDetail( @Cacheable( cacheNames = CacheNames.ITEM_INFO_SUMMARY, key = - "(#searchRequest.itemName() ?: '') + ':'" - + " + (#searchRequest.itemTopCategory() ?: '') + ':'" - + " + (#searchRequest.itemSubCategory() ?: '') + ':'" + "(#searchRequest.name() ?: '') + ':'" + + " + (#searchRequest.topCategory() ?: '') + ':'" + + " + (#searchRequest.subCategory() ?: '') + ':'" + " + #direction.name()") public List findAllSummary( ItemInfoSearchRequest searchRequest, diff --git a/src/main/java/until/the/eternity/metalwareinfo/application/service/MetalwareAttributeInfoService.java b/src/main/java/until/the/eternity/metalwareinfo/application/service/MetalwareAttributeInfoService.java index 8e8adf7a..eb421905 100644 --- a/src/main/java/until/the/eternity/metalwareinfo/application/service/MetalwareAttributeInfoService.java +++ b/src/main/java/until/the/eternity/metalwareinfo/application/service/MetalwareAttributeInfoService.java @@ -33,7 +33,7 @@ public int sync() { @Cacheable( cacheNames = CacheNames.METALWARE_ATTRIBUTE_INFO_SEARCH, - key = "#request.metalware() + ':' + #request.toPageable().pageNumber + ':' + #request.toPageable().pageSize") + key = "#request.metalware() + ':' + (#request.page ?: 1) + ':' + (#request.size ?: 25) + ':' + (#request.direction?.name() ?: 'ASC')") @Transactional(readOnly = true) public Page search( MetalwareAttributeInfoSearchRequest request) { From 6044e4142777659997c97436b2b1eeb592e76e54 Mon Sep 17 00:00:00 2001 From: dev-ant Date: Sat, 28 Feb 2026 11:25:01 +0900 Subject: [PATCH 04/10] =?UTF-8?q?fix:=20auction=20history=20search=20redis?= =?UTF-8?q?=20key=20=EC=83=9D=EC=84=B1=20=ED=8C=8C=EB=9D=BC=EB=AF=B8?= =?UTF-8?q?=ED=84=B0=EC=97=90=20dataTo,=20dateFrom=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/service/AuctionHistoryService.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryService.java b/src/main/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryService.java index c6adf6da..30a9460e 100644 --- a/src/main/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryService.java +++ b/src/main/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryService.java @@ -40,8 +40,11 @@ public class AuctionHistoryService { + " + (#pageRequestDto.sortBy() != null ? #pageRequestDto.sortBy().fieldName : 'dateAuctionBuy') + ':'" + " + (#pageRequestDto.direction() != null ? #pageRequestDto.direction().code : 'DESC') + ':'" + " + (#requestDto.itemName() ?: '') + ':'" + + " + (#requestDto.isExactItemName() ?: false) + ':'" + " + (#requestDto.itemTopCategory() ?: '') + ':'" - + " + (#requestDto.itemSubCategory() ?: '')", + + " + (#requestDto.itemSubCategory() ?: '') + ':'" + + " + (#requestDto.dateAuctionBuyRequest()?.dateAuctionBuyFrom() ?: '') + ':'" + + " + (#requestDto.dateAuctionBuyRequest()?.dateAuctionBuyTo() ?: '')", condition = "#requestDto.itemOptionSearchRequest() == null" + " and #requestDto.enchantSearchRequest() == null" From 2c08b90eab6adb44b10146849d538af7768fdd8f Mon Sep 17 00:00:00 2001 From: dev-ant Date: Sat, 28 Feb 2026 14:15:51 +0900 Subject: [PATCH 05/10] =?UTF-8?q?feat:=20redis=20=EC=9E=A5=EC=95=A0=20?= =?UTF-8?q?=EC=8B=9C=20db=20=EC=A7=81=EC=A0=91=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=ED=95=98=EA=B2=8C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/RedisCacheErrorHandler.java | 48 +++++++++++++++++++ .../the/eternity/config/RedisConfig.java | 9 +++- 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 src/main/java/until/the/eternity/config/RedisCacheErrorHandler.java diff --git a/src/main/java/until/the/eternity/config/RedisCacheErrorHandler.java b/src/main/java/until/the/eternity/config/RedisCacheErrorHandler.java new file mode 100644 index 00000000..77186301 --- /dev/null +++ b/src/main/java/until/the/eternity/config/RedisCacheErrorHandler.java @@ -0,0 +1,48 @@ +package until.the.eternity.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.Cache; +import org.springframework.cache.interceptor.CacheErrorHandler; + +/** + * Redis 장애 시 캐시 오류를 로깅만 하고 예외를 전파하지 않는 핸들러. + * + *

Redis가 다운되어도 캐시를 건너뛰고 실제 메서드(DB 조회 등)를 실행해 서비스를 유지한다. + */ +@Slf4j +public class RedisCacheErrorHandler implements CacheErrorHandler { + + @Override + public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) { + log.warn( + "[Cache] GET 실패 - cache={}, key={}, error={}", + cache.getName(), + key, + exception.getMessage()); + } + + @Override + public void handleCachePutError( + RuntimeException exception, Cache cache, Object key, Object value) { + log.warn( + "[Cache] PUT 실패 - cache={}, key={}, error={}", + cache.getName(), + key, + exception.getMessage()); + } + + @Override + public void handleCacheEvictError(RuntimeException exception, Cache cache, Object key) { + log.warn( + "[Cache] EVICT 실패 - cache={}, key={}, error={}", + cache.getName(), + key, + exception.getMessage()); + } + + @Override + public void handleCacheClearError(RuntimeException exception, Cache cache) { + log.warn( + "[Cache] CLEAR 실패 - cache={}, error={}", cache.getName(), exception.getMessage()); + } +} diff --git a/src/main/java/until/the/eternity/config/RedisConfig.java b/src/main/java/until/the/eternity/config/RedisConfig.java index c7c473fc..05f1a8b9 100644 --- a/src/main/java/until/the/eternity/config/RedisConfig.java +++ b/src/main/java/until/the/eternity/config/RedisConfig.java @@ -7,6 +7,8 @@ import java.util.HashMap; import java.util.Map; import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.interceptor.CacheErrorHandler; +import org.springframework.cache.interceptor.CachingConfigurer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.cache.RedisCacheConfiguration; @@ -18,7 +20,12 @@ @Configuration @EnableCaching -public class RedisConfig { +public class RedisConfig implements CachingConfigurer { + + @Override + public CacheErrorHandler errorHandler() { + return new RedisCacheErrorHandler(); + } @Bean public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) { From dcfdb290696275f4aa132fbc3e22c49ddab5bc68 Mon Sep 17 00:00:00 2001 From: dev-ant Date: Sat, 28 Feb 2026 14:21:35 +0900 Subject: [PATCH 06/10] =?UTF-8?q?feat:=20auction=20history=20redis=20warm?= =?UTF-8?q?=20up=20page=20=EB=B2=94=EC=9C=84=201~2=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/service/AuctionHistoryCacheWarmupService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryCacheWarmupService.java b/src/main/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryCacheWarmupService.java index bafd0a49..4d56d246 100644 --- a/src/main/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryCacheWarmupService.java +++ b/src/main/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryCacheWarmupService.java @@ -19,7 +19,7 @@ *

    *
  1. auction-history:search 캐시 전체 무효화 *
  2. auction_history 기반 역대 랭킹 캐시 무효화 (ALLTIME_HIGHEST, ALLTIME_MONTH_VOLUME) - *
  3. page 1~5 × size 20 × sortField 3종 × direction 2종 = 30가지 조합 캐시 워밍 + *
  4. page 1~2 × size 20 × sortField 3종 × direction 2종 = 12가지 조합 캐시 워밍 *
*/ @Slf4j @@ -28,7 +28,7 @@ public class AuctionHistoryCacheWarmupService { private static final int WARMUP_SIZE = 20; - private static final int WARMUP_MAX_PAGE = 5; + private static final int WARMUP_MAX_PAGE = 2; private final AuctionHistoryService auctionHistoryService; private final CacheManager cacheManager; From 90a29f353617293a2db4bcad36ea9ebd95b07fb9 Mon Sep 17 00:00:00 2001 From: dev-ant Date: Sat, 28 Feb 2026 14:22:10 +0900 Subject: [PATCH 07/10] =?UTF-8?q?fix:=20=ED=86=B5=EA=B3=84=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=ED=95=A8=EC=88=98=20=EC=BA=90=EC=8B=B1=20?= =?UTF-8?q?=ED=82=A4=EC=97=90=EC=84=9C=20topcategory=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/service/SubcategoryDailyStatisticsService.java | 2 +- .../application/service/SubcategoryWeeklyStatisticsService.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/until/the/eternity/statistics/application/service/SubcategoryDailyStatisticsService.java b/src/main/java/until/the/eternity/statistics/application/service/SubcategoryDailyStatisticsService.java index 93135629..7845aecf 100644 --- a/src/main/java/until/the/eternity/statistics/application/service/SubcategoryDailyStatisticsService.java +++ b/src/main/java/until/the/eternity/statistics/application/service/SubcategoryDailyStatisticsService.java @@ -25,7 +25,7 @@ public class SubcategoryDailyStatisticsService { /** 서브카테고리별 일간 통계 조회 (subCategory, 날짜 범위) */ @Cacheable( cacheNames = CacheNames.STATISTICS_SUBCATEGORY_DAILY, - key = "(#topCategory ?: '') + ':' + (#subCategory ?: '') + ':' + #startDate + ':' + #endDate") + key = "(#subCategory ?: '') + ':' + #startDate + ':' + #endDate") @Transactional(readOnly = true) public List search( String topCategory, diff --git a/src/main/java/until/the/eternity/statistics/application/service/SubcategoryWeeklyStatisticsService.java b/src/main/java/until/the/eternity/statistics/application/service/SubcategoryWeeklyStatisticsService.java index 84391115..95616c4e 100644 --- a/src/main/java/until/the/eternity/statistics/application/service/SubcategoryWeeklyStatisticsService.java +++ b/src/main/java/until/the/eternity/statistics/application/service/SubcategoryWeeklyStatisticsService.java @@ -25,7 +25,7 @@ public class SubcategoryWeeklyStatisticsService { /** 서브카테고리별 주간 통계 조회 (subCategory, 날짜 범위) */ @Cacheable( cacheNames = CacheNames.STATISTICS_SUBCATEGORY_WEEKLY, - key = "(#topCategory ?: '') + ':' + (#subCategory ?: '') + ':' + #startDate + ':' + #endDate") + key = "(#subCategory ?: '') + ':' + #startDate + ':' + #endDate") @Transactional(readOnly = true) public List search( String topCategory, From 594f72d3ee7f717a1ca29a0fa19a4e1cc1ea4545 Mon Sep 17 00:00:00 2001 From: dev-ant Date: Tue, 3 Mar 2026 12:44:12 +0900 Subject: [PATCH 08/10] =?UTF-8?q?fix:=20CachingConfigurer=20Import=20?= =?UTF-8?q?=ED=8C=A8=ED=82=A4=EC=A7=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AuctionHistoryCacheWarmupService.java | 3 +- .../AuctionHistoryQueryDslRepository.java | 4 +- .../controller/AuctionHistoryController.java | 2 +- .../request/AuctionHistorySearchRequest.java | 6 +- .../dto/request/MetalwareSearchRequest.java | 3 +- .../controller/AuctionRealtimeController.java | 2 +- .../request/AuctionRealtimeSearchRequest.java | 6 +- .../service/AuctionSearchOptionService.java | 2 +- .../config/RedisCacheErrorHandler.java | 3 +- .../service/EnchantInfoService.java | 4 +- .../MetalwareAttributeInfoService.java | 3 +- .../SubcategoryDailyStatisticsService.java | 5 +- .../SubcategoryWeeklyStatisticsService.java | 5 +- .../service/DailyStatisticsService.java | 56 +++++-------------- .../service/WeeklyStatisticsService.java | 8 +-- .../service/AuctionHistoryServiceTest.java | 3 +- 16 files changed, 41 insertions(+), 74 deletions(-) diff --git a/src/main/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryCacheWarmupService.java b/src/main/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryCacheWarmupService.java index 4d56d246..24ad80f7 100644 --- a/src/main/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryCacheWarmupService.java +++ b/src/main/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryCacheWarmupService.java @@ -48,7 +48,8 @@ private void evictCaches() { // auction_history 전체를 직접 쿼리하는 역대 랭킹도 함께 무효화 clearCache(CacheNames.RANKING_ALLTIME_HIGHEST); clearCache(CacheNames.RANKING_ALLTIME_MONTH_VOLUME); - log.info("[Cache Warmup] Evicted: {}, {}, {}", + log.info( + "[Cache Warmup] Evicted: {}, {}, {}", CacheNames.AUCTION_HISTORY_SEARCH, CacheNames.RANKING_ALLTIME_HIGHEST, CacheNames.RANKING_ALLTIME_MONTH_VOLUME); diff --git a/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java b/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java index 17850b42..0deb6dfc 100644 --- a/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java +++ b/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java @@ -203,7 +203,9 @@ private BooleanBuilder buildHistoryPredicate( QAuctionHistoryItemOption mwOpt = new QAuctionHistoryItemOption("mw" + i); NumberTemplate mwLevel = Expressions.numberTemplate( - Integer.class, "CAST(NULLIF({0}, '') AS integer)", mwOpt.optionValue2); + Integer.class, + "CAST(NULLIF({0}, '') AS integer)", + mwOpt.optionValue2); var mwSubQuery = JPAExpressions.select(mwOpt.auctionHistory.auctionBuyId) diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/controller/AuctionHistoryController.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/controller/AuctionHistoryController.java index 67ee1885..e741c7ce 100644 --- a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/controller/AuctionHistoryController.java +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/controller/AuctionHistoryController.java @@ -3,7 +3,6 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; -import until.the.eternity.common.annotation.MetalwareParameters; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springdoc.core.annotations.ParameterObject; @@ -15,6 +14,7 @@ import until.the.eternity.auctionhistory.interfaces.rest.dto.request.AuctionHistorySearchRequest; import until.the.eternity.auctionhistory.interfaces.rest.dto.response.AuctionHistoryDetailResponse; import until.the.eternity.auctionhistory.interfaces.rest.dto.response.ItemOptionResponse; +import until.the.eternity.common.annotation.MetalwareParameters; import until.the.eternity.common.request.PageRequestDto; import until.the.eternity.common.response.PageResponseDto; diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/AuctionHistorySearchRequest.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/AuctionHistorySearchRequest.java index 87c01cf1..80b12c29 100644 --- a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/AuctionHistorySearchRequest.java +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/AuctionHistorySearchRequest.java @@ -7,7 +7,8 @@ @Schema(description = "경매 거래내역 검색 조건") public record AuctionHistorySearchRequest( @Schema(description = "아이템 이름 (like 검색)", example = "페러시우스 타이탄 블레이드") String itemName, - @Schema(description = "아이템 이름 완전 일치 검색 여부 (true: eq, false: like)", + @Schema( + description = "아이템 이름 완전 일치 검색 여부 (true: eq, false: like)", requiredMode = Schema.RequiredMode.NOT_REQUIRED, defaultValue = "false", example = "false") @@ -18,7 +19,8 @@ public record AuctionHistorySearchRequest( @Schema(description = "가격 검색 조건") PriceSearchRequest priceSearchRequest, @Schema(description = "아이템 옵션 검색 조건") ItemOptionSearchRequest itemOptionSearchRequest, @Schema(description = "인챈트 검색 조건") EnchantSearchRequest enchantSearchRequest, - @Schema(description = "세공 검색 조건 목록 (최대 3개, AND 조건으로 검색)", + @Schema( + description = "세공 검색 조건 목록 (최대 3개, AND 조건으로 검색)", requiredMode = Schema.RequiredMode.NOT_REQUIRED, hidden = true) List metalwareSearchRequests) {} diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/MetalwareSearchRequest.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/MetalwareSearchRequest.java index d0b3c64d..37f7e63a 100644 --- a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/MetalwareSearchRequest.java +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/MetalwareSearchRequest.java @@ -6,8 +6,7 @@ public record MetalwareSearchRequest( @Schema(description = "세공 이름 (완전 일치)", example = "불의 연성술") String metalware, @Schema(description = "세공 레벨 시작값 (null이면 1로 처리)", example = "1") Integer levelFrom, - @Schema(description = "세공 레벨 종료값 (null이면 30으로 처리)", example = "30") - Integer levelTo) { + @Schema(description = "세공 레벨 종료값 (null이면 30으로 처리)", example = "30") Integer levelTo) { public int resolvedLevelFrom() { return levelFrom != null ? levelFrom : 1; diff --git a/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/controller/AuctionRealtimeController.java b/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/controller/AuctionRealtimeController.java index e13725d3..bc025406 100644 --- a/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/controller/AuctionRealtimeController.java +++ b/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/controller/AuctionRealtimeController.java @@ -2,7 +2,6 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -import until.the.eternity.common.annotation.MetalwareParameters; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springdoc.core.annotations.ParameterObject; @@ -13,6 +12,7 @@ import until.the.eternity.auctionrealtime.interfaces.rest.dto.request.RealtimePageRequestDto; import until.the.eternity.auctionrealtime.interfaces.rest.dto.response.AuctionRealtimeDetailResponse; import until.the.eternity.auctionrealtime.interfaces.rest.dto.response.RealtimeItemOptionResponse; +import until.the.eternity.common.annotation.MetalwareParameters; import until.the.eternity.common.response.PageResponseDto; @RequestMapping("/auction-realtime") diff --git a/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/dto/request/AuctionRealtimeSearchRequest.java b/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/dto/request/AuctionRealtimeSearchRequest.java index df2d1f0b..3c66e56b 100644 --- a/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/dto/request/AuctionRealtimeSearchRequest.java +++ b/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/dto/request/AuctionRealtimeSearchRequest.java @@ -11,7 +11,8 @@ @Schema(description = "실시간 경매장 검색 조건") public record AuctionRealtimeSearchRequest( @Schema(description = "아이템 이름 (like 검색)", example = "페러시우스 타이탄 블레이드") String itemName, - @Schema(description = "아이템 이름 완전 일치 검색 여부 (true: eq, false: like)", + @Schema( + description = "아이템 이름 완전 일치 검색 여부 (true: eq, false: like)", requiredMode = Schema.RequiredMode.NOT_REQUIRED, defaultValue = "false", example = "false") @@ -21,7 +22,8 @@ public record AuctionRealtimeSearchRequest( @Schema(description = "가격 검색 조건") PriceSearchRequest priceSearchRequest, @Schema(description = "아이템 옵션 검색 조건") ItemOptionSearchRequest itemOptionSearchRequest, @Schema(description = "인챈트 검색 조건") EnchantSearchRequest enchantSearchRequest, - @Schema(description = "세공 검색 조건 목록 (최대 3개, AND 조건으로 검색)", + @Schema( + description = "세공 검색 조건 목록 (최대 3개, AND 조건으로 검색)", requiredMode = Schema.RequiredMode.NOT_REQUIRED, hidden = true) List metalwareSearchRequests) {} diff --git a/src/main/java/until/the/eternity/auctionsearchoption/application/service/AuctionSearchOptionService.java b/src/main/java/until/the/eternity/auctionsearchoption/application/service/AuctionSearchOptionService.java index 95a7e73f..0c3dca98 100644 --- a/src/main/java/until/the/eternity/auctionsearchoption/application/service/AuctionSearchOptionService.java +++ b/src/main/java/until/the/eternity/auctionsearchoption/application/service/AuctionSearchOptionService.java @@ -9,11 +9,11 @@ import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import until.the.eternity.config.CacheNames; import until.the.eternity.auctionsearchoption.domain.entity.AuctionSearchOptionMetadata; import until.the.eternity.auctionsearchoption.domain.repository.AuctionSearchOptionRepositoryPort; import until.the.eternity.auctionsearchoption.interfaces.rest.dto.response.FieldMetadata; import until.the.eternity.auctionsearchoption.interfaces.rest.dto.response.SearchOptionMetadataResponse; +import until.the.eternity.config.CacheNames; @Slf4j @Service diff --git a/src/main/java/until/the/eternity/config/RedisCacheErrorHandler.java b/src/main/java/until/the/eternity/config/RedisCacheErrorHandler.java index 77186301..8ce10832 100644 --- a/src/main/java/until/the/eternity/config/RedisCacheErrorHandler.java +++ b/src/main/java/until/the/eternity/config/RedisCacheErrorHandler.java @@ -42,7 +42,6 @@ public void handleCacheEvictError(RuntimeException exception, Cache cache, Objec @Override public void handleCacheClearError(RuntimeException exception, Cache cache) { - log.warn( - "[Cache] CLEAR 실패 - cache={}, error={}", cache.getName(), exception.getMessage()); + log.warn("[Cache] CLEAR 실패 - cache={}, error={}", cache.getName(), exception.getMessage()); } } diff --git a/src/main/java/until/the/eternity/enchantinfo/application/service/EnchantInfoService.java b/src/main/java/until/the/eternity/enchantinfo/application/service/EnchantInfoService.java index 2d3f7127..edf03138 100644 --- a/src/main/java/until/the/eternity/enchantinfo/application/service/EnchantInfoService.java +++ b/src/main/java/until/the/eternity/enchantinfo/application/service/EnchantInfoService.java @@ -33,9 +33,7 @@ public Page findAll(Pageable pageable) { return enchantInfoRepository.findAll(pageable).map(EnchantInfoResponse::from); } - @Cacheable( - cacheNames = CacheNames.ENCHANT_INFO_FULLNAMES, - key = "#affixPosition ?: 'all'") + @Cacheable(cacheNames = CacheNames.ENCHANT_INFO_FULLNAMES, key = "#affixPosition ?: 'all'") public List findAllFullnames(String affixPosition) { if (affixPosition != null) { if (!ALLOWED_AFFIX_POSITIONS.contains(affixPosition)) { diff --git a/src/main/java/until/the/eternity/metalwareinfo/application/service/MetalwareAttributeInfoService.java b/src/main/java/until/the/eternity/metalwareinfo/application/service/MetalwareAttributeInfoService.java index eb421905..aed51db2 100644 --- a/src/main/java/until/the/eternity/metalwareinfo/application/service/MetalwareAttributeInfoService.java +++ b/src/main/java/until/the/eternity/metalwareinfo/application/service/MetalwareAttributeInfoService.java @@ -33,7 +33,8 @@ public int sync() { @Cacheable( cacheNames = CacheNames.METALWARE_ATTRIBUTE_INFO_SEARCH, - key = "#request.metalware() + ':' + (#request.page ?: 1) + ':' + (#request.size ?: 25) + ':' + (#request.direction?.name() ?: 'ASC')") + key = + "#request.metalware() + ':' + (#request.page ?: 1) + ':' + (#request.size ?: 25) + ':' + (#request.direction?.name() ?: 'ASC')") @Transactional(readOnly = true) public Page search( MetalwareAttributeInfoSearchRequest request) { diff --git a/src/main/java/until/the/eternity/statistics/application/service/SubcategoryDailyStatisticsService.java b/src/main/java/until/the/eternity/statistics/application/service/SubcategoryDailyStatisticsService.java index 7845aecf..02b3d4ca 100644 --- a/src/main/java/until/the/eternity/statistics/application/service/SubcategoryDailyStatisticsService.java +++ b/src/main/java/until/the/eternity/statistics/application/service/SubcategoryDailyStatisticsService.java @@ -28,10 +28,7 @@ public class SubcategoryDailyStatisticsService { key = "(#subCategory ?: '') + ':' + #startDate + ':' + #endDate") @Transactional(readOnly = true) public List search( - String topCategory, - String subCategory, - LocalDate startDate, - LocalDate endDate) { + String topCategory, String subCategory, LocalDate startDate, LocalDate endDate) { until.the.eternity.statistics.util.DateRangeValidator.validateDailyDateRange( startDate, endDate); diff --git a/src/main/java/until/the/eternity/statistics/application/service/SubcategoryWeeklyStatisticsService.java b/src/main/java/until/the/eternity/statistics/application/service/SubcategoryWeeklyStatisticsService.java index 95616c4e..62c7bf00 100644 --- a/src/main/java/until/the/eternity/statistics/application/service/SubcategoryWeeklyStatisticsService.java +++ b/src/main/java/until/the/eternity/statistics/application/service/SubcategoryWeeklyStatisticsService.java @@ -28,10 +28,7 @@ public class SubcategoryWeeklyStatisticsService { key = "(#subCategory ?: '') + ':' + #startDate + ':' + #endDate") @Transactional(readOnly = true) public List search( - String topCategory, - String subCategory, - LocalDate startDate, - LocalDate endDate) { + String topCategory, String subCategory, LocalDate startDate, LocalDate endDate) { until.the.eternity.statistics.util.DateRangeValidator.validateWeeklyDateRange( startDate, endDate); diff --git a/src/main/java/until/the/eternity/statistics/service/DailyStatisticsService.java b/src/main/java/until/the/eternity/statistics/service/DailyStatisticsService.java index 94fd0c9c..2769ed4e 100644 --- a/src/main/java/until/the/eternity/statistics/service/DailyStatisticsService.java +++ b/src/main/java/until/the/eternity/statistics/service/DailyStatisticsService.java @@ -37,30 +37,16 @@ public class DailyStatisticsService { cacheNames = CacheNames.STATISTICS_TOPCATEGORY_DAILY, allEntries = true), // 오늘 기준 랭킹 캐시 (item_daily_statistics 기반) - @CacheEvict( - cacheNames = CacheNames.RANKING_PRICE_TODAY_HIGHEST, - allEntries = true), - @CacheEvict( - cacheNames = CacheNames.RANKING_PRICE_TODAY_VOLUME, - allEntries = true), + @CacheEvict(cacheNames = CacheNames.RANKING_PRICE_TODAY_HIGHEST, allEntries = true), + @CacheEvict(cacheNames = CacheNames.RANKING_PRICE_TODAY_VOLUME, allEntries = true), @CacheEvict( cacheNames = CacheNames.RANKING_VOLUME_TODAY_POPULAR, allEntries = true), - @CacheEvict( - cacheNames = CacheNames.RANKING_CHANGE_PRICE_SURGE, - allEntries = true), - @CacheEvict( - cacheNames = CacheNames.RANKING_CHANGE_PRICE_DROP, - allEntries = true), - @CacheEvict( - cacheNames = CacheNames.RANKING_CHANGE_VOLUME_SURGE, - allEntries = true), - @CacheEvict( - cacheNames = CacheNames.RANKING_CATEGORY_HIGHEST, - allEntries = true), - @CacheEvict( - cacheNames = CacheNames.RANKING_CATEGORY_POPULAR, - allEntries = true), + @CacheEvict(cacheNames = CacheNames.RANKING_CHANGE_PRICE_SURGE, allEntries = true), + @CacheEvict(cacheNames = CacheNames.RANKING_CHANGE_PRICE_DROP, allEntries = true), + @CacheEvict(cacheNames = CacheNames.RANKING_CHANGE_VOLUME_SURGE, allEntries = true), + @CacheEvict(cacheNames = CacheNames.RANKING_CATEGORY_HIGHEST, allEntries = true), + @CacheEvict(cacheNames = CacheNames.RANKING_CATEGORY_POPULAR, allEntries = true), }) @Transactional public void calculateAndSaveCurrentDayStatistics() { @@ -111,30 +97,16 @@ public void calculateAndSaveCurrentDayStatistics() { @CacheEvict( cacheNames = CacheNames.STATISTICS_TOPCATEGORY_DAILY, allEntries = true), - @CacheEvict( - cacheNames = CacheNames.RANKING_PRICE_TODAY_HIGHEST, - allEntries = true), - @CacheEvict( - cacheNames = CacheNames.RANKING_PRICE_TODAY_VOLUME, - allEntries = true), + @CacheEvict(cacheNames = CacheNames.RANKING_PRICE_TODAY_HIGHEST, allEntries = true), + @CacheEvict(cacheNames = CacheNames.RANKING_PRICE_TODAY_VOLUME, allEntries = true), @CacheEvict( cacheNames = CacheNames.RANKING_VOLUME_TODAY_POPULAR, allEntries = true), - @CacheEvict( - cacheNames = CacheNames.RANKING_CHANGE_PRICE_SURGE, - allEntries = true), - @CacheEvict( - cacheNames = CacheNames.RANKING_CHANGE_PRICE_DROP, - allEntries = true), - @CacheEvict( - cacheNames = CacheNames.RANKING_CHANGE_VOLUME_SURGE, - allEntries = true), - @CacheEvict( - cacheNames = CacheNames.RANKING_CATEGORY_HIGHEST, - allEntries = true), - @CacheEvict( - cacheNames = CacheNames.RANKING_CATEGORY_POPULAR, - allEntries = true), + @CacheEvict(cacheNames = CacheNames.RANKING_CHANGE_PRICE_SURGE, allEntries = true), + @CacheEvict(cacheNames = CacheNames.RANKING_CHANGE_PRICE_DROP, allEntries = true), + @CacheEvict(cacheNames = CacheNames.RANKING_CHANGE_VOLUME_SURGE, allEntries = true), + @CacheEvict(cacheNames = CacheNames.RANKING_CATEGORY_HIGHEST, allEntries = true), + @CacheEvict(cacheNames = CacheNames.RANKING_CATEGORY_POPULAR, allEntries = true), }) @Transactional public void calculateAndSavePreviousDayStatistics() { diff --git a/src/main/java/until/the/eternity/statistics/service/WeeklyStatisticsService.java b/src/main/java/until/the/eternity/statistics/service/WeeklyStatisticsService.java index 5c5d6a47..ca191803 100644 --- a/src/main/java/until/the/eternity/statistics/service/WeeklyStatisticsService.java +++ b/src/main/java/until/the/eternity/statistics/service/WeeklyStatisticsService.java @@ -37,12 +37,8 @@ public class WeeklyStatisticsService { cacheNames = CacheNames.STATISTICS_TOPCATEGORY_WEEKLY, allEntries = true), // 주간 기준 랭킹 캐시 (item_weekly_statistics 기반) - @CacheEvict( - cacheNames = CacheNames.RANKING_PRICE_WEEK_HIGHEST, - allEntries = true), - @CacheEvict( - cacheNames = CacheNames.RANKING_VOLUME_WEEK_POPULAR, - allEntries = true), + @CacheEvict(cacheNames = CacheNames.RANKING_PRICE_WEEK_HIGHEST, allEntries = true), + @CacheEvict(cacheNames = CacheNames.RANKING_VOLUME_WEEK_POPULAR, allEntries = true), }) @Transactional public void calculateAndSaveWeeklyStatistics() { diff --git a/src/test/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryServiceTest.java b/src/test/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryServiceTest.java index b9ea3a5f..3d60f033 100644 --- a/src/test/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryServiceTest.java +++ b/src/test/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryServiceTest.java @@ -42,7 +42,8 @@ class AuctionHistoryServiceTest { void search_should_return_paged_response() { // given AuctionHistorySearchRequest searchRequest = - new AuctionHistorySearchRequest(null, null, null, null, null, null, null, null, null); + new AuctionHistorySearchRequest( + null, null, null, null, null, null, null, null, null); PageRequestDto pageRequestDto = mock(PageRequestDto.class); Pageable pageable = PageRequest.of(0, 10); when(pageRequestDto.toPageable()).thenReturn(pageable); From e8c2359f114dbd13992f89bc922b683403512a7b Mon Sep 17 00:00:00 2001 From: dev-ant Date: Tue, 3 Mar 2026 12:59:09 +0900 Subject: [PATCH 09/10] =?UTF-8?q?fix:=20RedisConfig=20Import=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../until/the/eternity/config/RedisConfig.java | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/main/java/until/the/eternity/config/RedisConfig.java b/src/main/java/until/the/eternity/config/RedisConfig.java index 05f1a8b9..965b9506 100644 --- a/src/main/java/until/the/eternity/config/RedisConfig.java +++ b/src/main/java/until/the/eternity/config/RedisConfig.java @@ -6,9 +6,9 @@ import java.time.Duration; import java.util.HashMap; import java.util.Map; +import org.springframework.cache.annotation.CachingConfigurer; import org.springframework.cache.annotation.EnableCaching; import org.springframework.cache.interceptor.CacheErrorHandler; -import org.springframework.cache.interceptor.CachingConfigurer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.cache.RedisCacheConfiguration; @@ -69,8 +69,7 @@ public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) // 검색 옵션 메타데이터 - 24시간 TTL (사실상 정적 데이터) configs.put( - CacheNames.SEARCH_OPTION_ALL_ACTIVE, - defaultConfig.entryTtl(Duration.ofHours(24))); + CacheNames.SEARCH_OPTION_ALL_ACTIVE, defaultConfig.entryTtl(Duration.ofHours(24))); // 마스터 데이터 - 1시간 TTL (sync API 호출 시 evict) Duration masterTtl = Duration.ofHours(1); @@ -82,8 +81,7 @@ public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) configs.put(CacheNames.ENCHANT_INFO_ALL, defaultConfig.entryTtl(masterTtl)); configs.put(CacheNames.ENCHANT_INFO_FULLNAMES, defaultConfig.entryTtl(masterTtl)); configs.put(CacheNames.METALWARE_INFO_ALL, defaultConfig.entryTtl(masterTtl)); - configs.put( - CacheNames.METALWARE_ATTRIBUTE_INFO_SEARCH, defaultConfig.entryTtl(masterTtl)); + configs.put(CacheNames.METALWARE_ATTRIBUTE_INFO_SEARCH, defaultConfig.entryTtl(masterTtl)); // 통계 - 30분 TTL (이벤트 기반 eviction으로 실시간 반영) Duration statsTtl = Duration.ofMinutes(30); @@ -96,12 +94,10 @@ public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) // 실시간 경매 - 12분 TTL (10분 배치 + 여유 2분) configs.put( - CacheNames.AUCTION_REALTIME_SEARCH, - defaultConfig.entryTtl(Duration.ofMinutes(12))); + CacheNames.AUCTION_REALTIME_SEARCH, defaultConfig.entryTtl(Duration.ofMinutes(12))); // 경매 거래 내역 - 2시간 TTL (배치 완료 시 evict + warmup) - configs.put( - CacheNames.AUCTION_HISTORY_SEARCH, defaultConfig.entryTtl(Duration.ofHours(2))); + configs.put(CacheNames.AUCTION_HISTORY_SEARCH, defaultConfig.entryTtl(Duration.ofHours(2))); return RedisCacheManager.builder(connectionFactory) .cacheDefaults(defaultConfig) From 452242d3c767abf3718964194cca447ce4c76450 Mon Sep 17 00:00:00 2001 From: Lee Sanghyun <59863112+dev-ant@users.noreply.github.com> Date: Tue, 3 Mar 2026 20:43:19 +0900 Subject: [PATCH 10/10] =?UTF-8?q?fix:=20AuctionHistoryCacheWarmupService?= =?UTF-8?q?=20=EC=A3=BC=EC=84=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../application/service/AuctionHistoryCacheWarmupService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryCacheWarmupService.java b/src/main/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryCacheWarmupService.java index 24ad80f7..1784ca76 100644 --- a/src/main/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryCacheWarmupService.java +++ b/src/main/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryCacheWarmupService.java @@ -34,7 +34,7 @@ public class AuctionHistoryCacheWarmupService { private final CacheManager cacheManager; /** - * 캐시 무효화 후 기본 30가지 조합을 선제적으로 워밍한다. + * 캐시 무효화 후 기본 12가지 조합(page 1~2 × size 20 × sortField 3종 × direction 2종)을 선제적으로 워밍한다. * *

빈 검색 조건(필터 없음) 기준으로 워밍하므로, 단순 목록 조회 요청에 즉시 캐시 히트가 발생한다. */