From a66daa324489519dfbdd850fb135fa4fff0d90ee Mon Sep 17 00:00:00 2001 From: dev-ant Date: Fri, 6 Mar 2026 08:23:44 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20auction=20history=20count=20?= =?UTF-8?q?=EC=A0=84=EC=9A=A9=20=EC=BA=90=EC=8B=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AuctionHistoryCacheWarmupService.java | 4 +- .../service/AuctionHistoryService.java | 40 ++++++++- .../AuctionHistoryRepositoryPort.java | 5 +- .../AuctionHistoryQueryDslRepository.java | 84 ++++++++++--------- .../AuctionHistoryRepositoryPortImpl.java | 11 ++- .../eternity/common/util/CacheKeyBuilder.java | 37 ++++++-- .../until/the/eternity/config/CacheNames.java | 1 + .../the/eternity/config/RedisConfig.java | 1 + src/main/resources/application.yml | 2 +- ...dd_auction_history_category_date_index.sql | 5 ++ .../service/AuctionHistoryServiceTest.java | 13 +-- 11 files changed, 143 insertions(+), 60 deletions(-) create mode 100644 src/main/resources/db/migration/V27__add_auction_history_category_date_index.sql 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 59dd02d6..477e4995 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 @@ -56,12 +56,14 @@ public void warmupForStartup() { private void evictCaches() { clearCache(CacheNames.AUCTION_HISTORY_SEARCH); + clearCache(CacheNames.AUCTION_HISTORY_COUNT); // auction_history 전체를 직접 쿼리하는 역대 랭킹도 함께 무효화 clearCache(CacheNames.RANKING_ALLTIME_HIGHEST); clearCache(CacheNames.RANKING_ALLTIME_MONTH_VOLUME); log.info( - "[Cache Warmup] Evicted: {}, {}, {}", + "[Cache Warmup] Evicted: {}, {}, {}, {}", CacheNames.AUCTION_HISTORY_SEARCH, + CacheNames.AUCTION_HISTORY_COUNT, CacheNames.RANKING_ALLTIME_HIGHEST, CacheNames.RANKING_ALLTIME_MONTH_VOLUME); } 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 cc896233..b813c015 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,8 +4,12 @@ import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import until.the.eternity.auctionhistory.domain.entity.AuctionHistory; @@ -16,6 +20,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.common.util.CacheKeyBuilder; import until.the.eternity.config.CacheNames; @Service @@ -26,6 +31,7 @@ public class AuctionHistoryService { private final AuctionHistoryRepositoryPort repository; private final AuctionHistoryMapper mapper; private final EntityManager entityManager; + private final CacheManager cacheManager; /** * 경매 거래 내역을 검색한다. @@ -46,11 +52,43 @@ public class AuctionHistoryService { public PageResponseDto> search( AuctionHistorySearchRequest requestDto, PageRequestDto pageRequestDto) { - Page page = repository.search(requestDto, pageRequestDto.toPageable()); + Pageable pageable = pageRequestDto.toPageable(); + List content = repository.searchContent(requestDto, pageable); + long total = resolveTotalCount(requestDto); + Page page = new PageImpl<>(content, pageable, total); Page> dtoPage = page.map(mapper::toDto); return PageResponseDto.of(dtoPage); } + private long resolveTotalCount(AuctionHistorySearchRequest requestDto) { + if (!isCountCacheEligible(requestDto)) { + return repository.count(requestDto); + } + + Cache cache = cacheManager.getCache(CacheNames.AUCTION_HISTORY_COUNT); + if (cache == null) { + return repository.count(requestDto); + } + + String cacheKey = CacheKeyBuilder.buildAuctionHistoryCountKey(requestDto); + Long cachedCount = cache.get(cacheKey, Long.class); + if (cachedCount != null) { + return cachedCount; + } + + long total = repository.count(requestDto); + cache.put(cacheKey, total); + return total; + } + + private boolean isCountCacheEligible(AuctionHistorySearchRequest requestDto) { + return requestDto.itemOptionSearchRequest() == null + && requestDto.enchantSearchRequest() == null + && (requestDto.metalwareSearchRequests() == null + || requestDto.metalwareSearchRequests().isEmpty()) + && requestDto.priceSearchRequest() == null; + } + @Transactional(readOnly = true) public AuctionHistoryDetailResponse findByIdOrElseThrow(String id) { AuctionHistory auctionHistory = diff --git a/src/main/java/until/the/eternity/auctionhistory/domain/repository/AuctionHistoryRepositoryPort.java b/src/main/java/until/the/eternity/auctionhistory/domain/repository/AuctionHistoryRepositoryPort.java index ca490fa1..c0ac03ae 100644 --- a/src/main/java/until/the/eternity/auctionhistory/domain/repository/AuctionHistoryRepositoryPort.java +++ b/src/main/java/until/the/eternity/auctionhistory/domain/repository/AuctionHistoryRepositoryPort.java @@ -4,7 +4,6 @@ import java.util.List; import java.util.Optional; import java.util.Set; -import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import until.the.eternity.auctionhistory.domain.entity.AuctionHistory; import until.the.eternity.auctionhistory.interfaces.rest.dto.request.AuctionHistorySearchRequest; @@ -15,7 +14,9 @@ public interface AuctionHistoryRepositoryPort { record LatestDateWithIds(Instant latestDate, Set existingIds) {} - Page search(AuctionHistorySearchRequest condition, Pageable pageable); + List searchContent(AuctionHistorySearchRequest condition, Pageable pageable); + + long count(AuctionHistorySearchRequest condition); Optional findById(String id); 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 4c27bc49..f59fe8f9 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 @@ -14,8 +14,6 @@ import java.util.ArrayList; import java.util.List; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; @@ -34,11 +32,51 @@ class AuctionHistoryQueryDslRepository { /** 옵션 조건 빌드 결과 (조건 BooleanBuilder + 추가된 조건 개수) */ record OptionConditionResult(BooleanBuilder builder, int count) {} - /** 경매 거래내역 검색 (옵션 조건 포함) */ - public Page search(AuctionHistorySearchRequest condition, Pageable pageable) { + /** 경매 거래내역 콘텐츠 조회 (옵션 조건 포함) */ + public List searchContent( + AuctionHistorySearchRequest condition, Pageable pageable) { QAuctionHistory ah = QAuctionHistory.auctionHistory; QAuctionHistoryItemOption aio = QAuctionHistoryItemOption.auctionHistoryItemOption; + BooleanBuilder historyBuilder = buildSearchPredicate(condition, ah); + List> orderSpecifiers = buildOrderSpecifiers(pageable, ah); + + // Deferred Join (Late Row Lookup): 인덱스 친화적으로 ID 먼저 조회 + List ids = + queryFactory + .select(ah.auctionBuyId) + .from(ah) + .where(historyBuilder) + .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + if (ids.isEmpty()) { + return List.of(); + } + + return queryFactory + .selectFrom(ah) + .leftJoin(ah.auctionHistoryItemOptions, aio) + .fetchJoin() + .where(ah.auctionBuyId.in(ids)) + .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])) + .distinct() + .fetch(); + } + + /** 경매 거래내역 조건 기준 전체 건수 조회 */ + public long count(AuctionHistorySearchRequest condition) { + QAuctionHistory ah = QAuctionHistory.auctionHistory; + BooleanBuilder historyBuilder = buildSearchPredicate(condition, ah); + Long total = queryFactory.select(ah.count()).from(ah).where(historyBuilder).fetchOne(); + return total == null ? 0L : total; + } + + /** 검색 where 절 빌드 (옵션/인챈트/세공 포함) */ + private BooleanBuilder buildSearchPredicate( + AuctionHistorySearchRequest condition, QAuctionHistory ah) { OptionConditionResult optionResult = buildOptionConditionResult(condition); boolean hasItemOptionFilter = hasEffectiveItemOptionFilter(optionResult); boolean hasEnchantFilter = hasEnchantFilter(condition); @@ -46,10 +84,8 @@ public Page search(AuctionHistorySearchRequest condition, Pageab boolean isBasicSearchOnly = !(hasItemOptionFilter || hasEnchantFilter || hasMetalwareFilter); - // 1단계: 거래내역 조건 빌드 BooleanBuilder historyBuilder = buildHistoryPredicate(condition, ah, !isBasicSearchOnly); - // 2단계: 옵션 조건이 있으면 서브쿼리 추가 if (hasItemOptionFilter) { QAuctionHistoryItemOption subOption = new QAuctionHistoryItemOption("subOption"); var subQuery = @@ -62,41 +98,7 @@ public Page search(AuctionHistorySearchRequest condition, Pageab historyBuilder.and(ah.auctionBuyId.in(subQuery)); } - // 3단계: 정렬 조건 빌드 - List> orderSpecifiers = buildOrderSpecifiers(pageable, ah); - - // 4단계: Deferred Join (Late Row Lookup) 패턴 적용 - // 4-1단계: ID만 먼저 조회 (인덱스 활용) - List ids = - queryFactory - .select(ah.auctionBuyId) - .from(ah) - .where(historyBuilder) - .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])) - .offset(pageable.getOffset()) - .limit(pageable.getPageSize()) - .fetch(); - - // 결과가 없으면 빈 페이지 반환 - if (ids.isEmpty()) { - return new PageImpl<>(List.of(), pageable, 0L); - } - - // 4-2단계: ID로 상세 조회 (LEFT JOIN으로 옵션 포함) - List content = - queryFactory - .selectFrom(ah) - .leftJoin(ah.auctionHistoryItemOptions, aio) - .fetchJoin() - .where(ah.auctionBuyId.in(ids)) - .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])) - .distinct() - .fetch(); - - // Count 쿼리 (JOIN 없이 실행) - Long total = queryFactory.select(ah.count()).from(ah).where(historyBuilder).fetchOne(); - - return new PageImpl<>(content, pageable, total == null ? 0L : total); + return historyBuilder; } /** 거래내역 기본 조건 빌드 (카테고리, 아이템명, 가격, 거래일자) */ diff --git a/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryRepositoryPortImpl.java b/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryRepositoryPortImpl.java index 2b0cdf4d..b0d61b18 100644 --- a/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryRepositoryPortImpl.java +++ b/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryRepositoryPortImpl.java @@ -7,7 +7,6 @@ import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; @@ -29,8 +28,14 @@ public class AuctionHistoryRepositoryPortImpl implements AuctionHistoryRepositor private int batchSize; @Override - public Page search(AuctionHistorySearchRequest condition, Pageable pageable) { - return queryDslRepository.search(condition, pageable); + public List searchContent( + AuctionHistorySearchRequest condition, Pageable pageable) { + return queryDslRepository.searchContent(condition, pageable); + } + + @Override + public long count(AuctionHistorySearchRequest condition) { + return queryDslRepository.count(condition); } @Override diff --git a/src/main/java/until/the/eternity/common/util/CacheKeyBuilder.java b/src/main/java/until/the/eternity/common/util/CacheKeyBuilder.java index 12e42cd2..9795e31b 100644 --- a/src/main/java/until/the/eternity/common/util/CacheKeyBuilder.java +++ b/src/main/java/until/the/eternity/common/util/CacheKeyBuilder.java @@ -114,12 +114,9 @@ public static String buildStatisticsTopCategoryKey( public static String buildAuctionHistorySearchKey( AuctionHistorySearchRequest requestDto, PageRequestDto pageRequestDto) { Pageable pageable = pageRequestDto.toPageable(); - String dateFrom = ""; - String dateTo = ""; - if (requestDto.dateAuctionBuyRequest() != null) { - dateFrom = normalize(requestDto.dateAuctionBuyRequest().dateAuctionBuyFrom()); - dateTo = normalize(requestDto.dateAuctionBuyRequest().dateAuctionBuyTo()); - } + String[] dateRange = resolveAuctionHistoryDateRange(requestDto); + String dateFrom = dateRange[0]; + String dateTo = dateRange[1]; return pageable.getPageNumber() + ":" @@ -140,6 +137,24 @@ public static String buildAuctionHistorySearchKey( + dateTo; } + public static String buildAuctionHistoryCountKey(AuctionHistorySearchRequest requestDto) { + String[] dateRange = resolveAuctionHistoryDateRange(requestDto); + String dateFrom = dateRange[0]; + String dateTo = dateRange[1]; + + return normalize(requestDto.itemName()) + + ":" + + isExactItemName(requestDto.isExactItemName()) + + ":" + + normalize(requestDto.itemTopCategory()) + + ":" + + normalize(requestDto.itemSubCategory()) + + ":" + + dateFrom + + ":" + + dateTo; + } + public static String buildAuctionRealtimeSearchKey( AuctionRealtimeSearchRequest requestDto, Pageable pageable) { String dateFrom = ""; @@ -176,6 +191,16 @@ private static String normalize(String value) { return trimmed.isEmpty() ? "" : trimmed; } + private static String[] resolveAuctionHistoryDateRange(AuctionHistorySearchRequest requestDto) { + String dateFrom = ""; + String dateTo = ""; + if (requestDto.dateAuctionBuyRequest() != null) { + dateFrom = normalize(requestDto.dateAuctionBuyRequest().dateAuctionBuyFrom()); + dateTo = normalize(requestDto.dateAuctionBuyRequest().dateAuctionBuyTo()); + } + return new String[] {dateFrom, dateTo}; + } + private static boolean isExactItemName(Boolean isExactItemName) { return Boolean.TRUE.equals(isExactItemName); } diff --git a/src/main/java/until/the/eternity/config/CacheNames.java b/src/main/java/until/the/eternity/config/CacheNames.java index be962568..5b2d8ea7 100644 --- a/src/main/java/until/the/eternity/config/CacheNames.java +++ b/src/main/java/until/the/eternity/config/CacheNames.java @@ -52,4 +52,5 @@ private CacheNames() {} // ===== 경매 거래 내역 ===== public static final String AUCTION_HISTORY_SEARCH = "auction-history:search"; + public static final String AUCTION_HISTORY_COUNT = "auction-history:count"; } diff --git a/src/main/java/until/the/eternity/config/RedisConfig.java b/src/main/java/until/the/eternity/config/RedisConfig.java index 7f7884f8..dbd6abb5 100644 --- a/src/main/java/until/the/eternity/config/RedisConfig.java +++ b/src/main/java/until/the/eternity/config/RedisConfig.java @@ -110,6 +110,7 @@ public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) // 경매 거래 내역 - 2시간 TTL (배치 완료 시 evict + warmup) configs.put(CacheNames.AUCTION_HISTORY_SEARCH, defaultConfig.entryTtl(Duration.ofHours(2))); + configs.put(CacheNames.AUCTION_HISTORY_COUNT, defaultConfig.entryTtl(Duration.ofHours(2))); return RedisCacheManager.builder(connectionFactory) .cacheDefaults(defaultConfig) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 52f07ac6..25bfced6 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -97,7 +97,7 @@ springdoc: decorator: datasource: p6spy: - enable-logging: false + enable-logging: true logging: config: classpath:logback/logback-display.xml diff --git a/src/main/resources/db/migration/V27__add_auction_history_category_date_index.sql b/src/main/resources/db/migration/V27__add_auction_history_category_date_index.sql new file mode 100644 index 00000000..c68769f9 --- /dev/null +++ b/src/main/resources/db/migration/V27__add_auction_history_category_date_index.sql @@ -0,0 +1,5 @@ +-- 카테고리 + 거래일자 범위 검색/카운트 최적화 +-- 기존 idx_ah_top_sub_name_date는 item_name이 중간 컬럼이라 +-- item_name 조건이 없는 top/sub/date 질의에서 비효율이 발생할 수 있음 +CREATE INDEX idx_ah_top_sub_date + ON auction_history (item_top_category, item_sub_category, date_auction_buy DESC); 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 3d60f033..5f12b620 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 @@ -12,8 +12,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; +import org.springframework.cache.CacheManager; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import until.the.eternity.auctionhistory.application.service.persister.AuctionHistoryPersister; @@ -26,6 +25,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; @ExtendWith(MockitoExtension.class) class AuctionHistoryServiceTest { @@ -34,6 +34,7 @@ class AuctionHistoryServiceTest { @Mock private AuctionHistoryFetcherPort fetcherPort; @Mock private AuctionHistoryPersister persister; @Mock private AuctionHistoryMapper mapper; + @Mock private CacheManager cacheManager; @InjectMocks private AuctionHistoryService service; @@ -51,9 +52,10 @@ void search_should_return_paged_response() { AuctionHistory entity = new AuctionHistory(); AuctionHistoryDetailResponse detailDto = mock(AuctionHistoryDetailResponse.class); - Page entityPage = new PageImpl<>(List.of(entity), pageable, 1); - when(repositoryPort.search(searchRequest, pageable)).thenReturn(entityPage); + when(cacheManager.getCache(CacheNames.AUCTION_HISTORY_COUNT)).thenReturn(null); + when(repositoryPort.searchContent(searchRequest, pageable)).thenReturn(List.of(entity)); + when(repositoryPort.count(searchRequest)).thenReturn(1L); when(mapper.toDto(entity)).thenReturn(detailDto); // when @@ -62,7 +64,8 @@ void search_should_return_paged_response() { // then assertThat(result.items()).hasSize(1).contains(detailDto); - verify(repositoryPort).search(searchRequest, pageable); + verify(repositoryPort).searchContent(searchRequest, pageable); + verify(repositoryPort).count(searchRequest); verify(mapper).toDto(entity); } From 85b325cb7c3ea911d3f83b93caf547e97f7c7884 Mon Sep 17 00:00:00 2001 From: Lee Sanghyun <59863112+dev-ant@users.noreply.github.com> Date: Fri, 6 Mar 2026 08:44:21 +0900 Subject: [PATCH 2/2] =?UTF-8?q?chore:=20p6spy=20SQL=20logging=20enable=20t?= =?UTF-8?q?rue=EB=A1=9C=20=EB=B3=B5=EA=B5=AC?= 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> --- src/main/resources/application.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 25bfced6..52f07ac6 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -97,7 +97,7 @@ springdoc: decorator: datasource: p6spy: - enable-logging: true + enable-logging: false logging: config: classpath:logback/logback-display.xml