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/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} 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..1784ca76 --- /dev/null +++ b/src/main/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryCacheWarmupService.java @@ -0,0 +1,101 @@ +package until.the.eternity.auctionhistory.application.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Service; +import until.the.eternity.auctionhistory.interfaces.rest.dto.request.AuctionHistorySearchRequest; +import until.the.eternity.common.enums.SortDirection; +import until.the.eternity.common.enums.SortField; +import until.the.eternity.common.request.PageRequestDto; +import until.the.eternity.config.CacheNames; + +/** + * 경매 거래 내역 캐시 워밍업 서비스. + * + *

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

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

빈 검색 조건(필터 없음) 기준으로 워밍하므로, 단순 목록 조회 요청에 즉시 캐시 히트가 발생한다. + */ + 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..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 @@ -4,6 +4,7 @@ import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -15,6 +16,7 @@ import until.the.eternity.auctionhistory.interfaces.rest.dto.response.ItemOptionResponse; import until.the.eternity.common.request.PageRequestDto; import until.the.eternity.common.response.PageResponseDto; +import until.the.eternity.config.CacheNames; @Service @RequiredArgsConstructor @@ -25,6 +27,29 @@ public class AuctionHistoryService { private final AuctionHistoryMapper mapper; private final EntityManager entityManager; + /** + * 경매 거래 내역을 검색한다. + * + *

단순 검색(가격/옵션/인챈트/세공 필터 없음)에 한해 캐싱을 적용한다. 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.isExactItemName() ?: false) + ':'" + + " + (#requestDto.itemTopCategory() ?: '') + ':'" + + " + (#requestDto.itemSubCategory() ?: '') + ':'" + + " + (#requestDto.dateAuctionBuyRequest()?.dateAuctionBuyFrom() ?: '') + ':'" + + " + (#requestDto.dateAuctionBuyRequest()?.dateAuctionBuyTo() ?: '')", + 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/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/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/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 aec4e464..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 @@ -6,12 +6,14 @@ 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.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 @@ -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/RedisCacheErrorHandler.java b/src/main/java/until/the/eternity/config/RedisCacheErrorHandler.java new file mode 100644 index 00000000..8ce10832 --- /dev/null +++ b/src/main/java/until/the/eternity/config/RedisCacheErrorHandler.java @@ -0,0 +1,47 @@ +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 new file mode 100644 index 00000000..965b9506 --- /dev/null +++ b/src/main/java/until/the/eternity/config/RedisConfig.java @@ -0,0 +1,107 @@ +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.CachingConfigurer; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.interceptor.CacheErrorHandler; +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 implements CachingConfigurer { + + @Override + public CacheErrorHandler errorHandler() { + return new RedisCacheErrorHandler(); + } + + @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..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 @@ -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,14 @@ public class EnchantInfoService { private final EnchantInfoRepositoryPort enchantInfoRepository; + @Cacheable( + cacheNames = CacheNames.ENCHANT_INFO_ALL, + key = "#pageable.pageNumber + ':' + #pageable.pageSize + ':' + #pageable.sort") 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 +44,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..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 @@ -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,32 @@ 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.name() ?: '') + ':'" + + " + (#searchRequest.topCategory() ?: '') + ':'" + + " + (#searchRequest.subCategory() ?: '') + ':'" + + " + #pageable.pageNumber + ':' + #pageable.pageSize" + + " + ':' + #pageable.sort") public Page findAllDetail( ItemInfoSearchRequest searchRequest, Pageable pageable) { Page itemInfoPage = @@ -57,10 +72,16 @@ public Page findAllDetail( return itemInfoPage.map(ItemInfoResponse::from); } + @Cacheable( + cacheNames = CacheNames.ITEM_INFO_SUMMARY, + key = + "(#searchRequest.name() ?: '') + ':'" + + " + (#searchRequest.topCategory() ?: '') + ':'" + + " + (#searchRequest.subCategory() ?: '') + ':'" + + " + #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 +91,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..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 @@ -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,10 @@ public int sync() { return inserted + updated; } + @Cacheable( + cacheNames = CacheNames.METALWARE_ATTRIBUTE_INFO_SEARCH, + 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/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..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 @@ -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,18 @@ public class SubcategoryDailyStatisticsService { private final SubcategoryDailyStatisticsMapper mapper; /** 서브카테고리별 일간 통계 조회 (subCategory, 날짜 범위) */ + @Cacheable( + cacheNames = CacheNames.STATISTICS_SUBCATEGORY_DAILY, + key = "(#subCategory ?: '') + ':' + #startDate + ':' + #endDate") @Transactional(readOnly = true) - public java.util.List search( - String topCategory, // topCategory는 파라미터로 받지만 조회에는 사용하지 않음 (DB 구조상) - String subCategory, - java.time.LocalDate startDate, - java.time.LocalDate endDate) { - // 날짜 범위 검증 (최대 30일) + public List search( + String topCategory, String subCategory, 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..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 @@ -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,18 @@ public class SubcategoryWeeklyStatisticsService { private final SubcategoryWeeklyStatisticsMapper mapper; /** 서브카테고리별 주간 통계 조회 (subCategory, 날짜 범위) */ + @Cacheable( + cacheNames = CacheNames.STATISTICS_SUBCATEGORY_WEEKLY, + key = "(#subCategory ?: '') + ':' + #startDate + ':' + #endDate") @Transactional(readOnly = true) - public java.util.List search( - String topCategory, // topCategory는 파라미터로 받지만 조회에는 사용하지 않음 (DB 구조상) - String subCategory, - java.time.LocalDate startDate, - java.time.LocalDate endDate) { - // 날짜 범위 검증 (최대 4개월) + public List search( + String topCategory, String subCategory, 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..2769ed4e 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,31 @@ 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 +85,29 @@ 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..ca191803 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,23 @@ 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:} 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);