Skip to content

Commit b64eae3

Browse files
dev-antCopilot
andauthored
feat: auction history count 전용 캐시 추가 (#115)
* feat: auction history count 전용 캐시 추가 * chore: p6spy SQL logging enable true로 복구 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 92b9fed commit b64eae3

10 files changed

Lines changed: 142 additions & 59 deletions

File tree

src/main/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryCacheWarmupService.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,14 @@ public void warmupForStartup() {
5656

5757
private void evictCaches() {
5858
clearCache(CacheNames.AUCTION_HISTORY_SEARCH);
59+
clearCache(CacheNames.AUCTION_HISTORY_COUNT);
5960
// auction_history 전체를 직접 쿼리하는 역대 랭킹도 함께 무효화
6061
clearCache(CacheNames.RANKING_ALLTIME_HIGHEST);
6162
clearCache(CacheNames.RANKING_ALLTIME_MONTH_VOLUME);
6263
log.info(
63-
"[Cache Warmup] Evicted: {}, {}, {}",
64+
"[Cache Warmup] Evicted: {}, {}, {}, {}",
6465
CacheNames.AUCTION_HISTORY_SEARCH,
66+
CacheNames.AUCTION_HISTORY_COUNT,
6567
CacheNames.RANKING_ALLTIME_HIGHEST,
6668
CacheNames.RANKING_ALLTIME_MONTH_VOLUME);
6769
}

src/main/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryService.java

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@
44
import java.util.List;
55
import lombok.RequiredArgsConstructor;
66
import lombok.extern.slf4j.Slf4j;
7+
import org.springframework.cache.Cache;
8+
import org.springframework.cache.CacheManager;
79
import org.springframework.cache.annotation.Cacheable;
810
import org.springframework.data.domain.Page;
11+
import org.springframework.data.domain.PageImpl;
12+
import org.springframework.data.domain.Pageable;
913
import org.springframework.stereotype.Service;
1014
import org.springframework.transaction.annotation.Transactional;
1115
import until.the.eternity.auctionhistory.domain.entity.AuctionHistory;
@@ -16,6 +20,7 @@
1620
import until.the.eternity.auctionhistory.interfaces.rest.dto.response.ItemOptionResponse;
1721
import until.the.eternity.common.request.PageRequestDto;
1822
import until.the.eternity.common.response.PageResponseDto;
23+
import until.the.eternity.common.util.CacheKeyBuilder;
1924
import until.the.eternity.config.CacheNames;
2025

2126
@Service
@@ -26,6 +31,7 @@ public class AuctionHistoryService {
2631
private final AuctionHistoryRepositoryPort repository;
2732
private final AuctionHistoryMapper mapper;
2833
private final EntityManager entityManager;
34+
private final CacheManager cacheManager;
2935

3036
/**
3137
* 경매 거래 내역을 검색한다.
@@ -46,11 +52,43 @@ public class AuctionHistoryService {
4652
public PageResponseDto<AuctionHistoryDetailResponse<ItemOptionResponse>> search(
4753
AuctionHistorySearchRequest requestDto, PageRequestDto pageRequestDto) {
4854

49-
Page<AuctionHistory> page = repository.search(requestDto, pageRequestDto.toPageable());
55+
Pageable pageable = pageRequestDto.toPageable();
56+
List<AuctionHistory> content = repository.searchContent(requestDto, pageable);
57+
long total = resolveTotalCount(requestDto);
58+
Page<AuctionHistory> page = new PageImpl<>(content, pageable, total);
5059
Page<AuctionHistoryDetailResponse<ItemOptionResponse>> dtoPage = page.map(mapper::toDto);
5160
return PageResponseDto.of(dtoPage);
5261
}
5362

63+
private long resolveTotalCount(AuctionHistorySearchRequest requestDto) {
64+
if (!isCountCacheEligible(requestDto)) {
65+
return repository.count(requestDto);
66+
}
67+
68+
Cache cache = cacheManager.getCache(CacheNames.AUCTION_HISTORY_COUNT);
69+
if (cache == null) {
70+
return repository.count(requestDto);
71+
}
72+
73+
String cacheKey = CacheKeyBuilder.buildAuctionHistoryCountKey(requestDto);
74+
Long cachedCount = cache.get(cacheKey, Long.class);
75+
if (cachedCount != null) {
76+
return cachedCount;
77+
}
78+
79+
long total = repository.count(requestDto);
80+
cache.put(cacheKey, total);
81+
return total;
82+
}
83+
84+
private boolean isCountCacheEligible(AuctionHistorySearchRequest requestDto) {
85+
return requestDto.itemOptionSearchRequest() == null
86+
&& requestDto.enchantSearchRequest() == null
87+
&& (requestDto.metalwareSearchRequests() == null
88+
|| requestDto.metalwareSearchRequests().isEmpty())
89+
&& requestDto.priceSearchRequest() == null;
90+
}
91+
5492
@Transactional(readOnly = true)
5593
public AuctionHistoryDetailResponse<ItemOptionResponse> findByIdOrElseThrow(String id) {
5694
AuctionHistory auctionHistory =

src/main/java/until/the/eternity/auctionhistory/domain/repository/AuctionHistoryRepositoryPort.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import java.util.List;
55
import java.util.Optional;
66
import java.util.Set;
7-
import org.springframework.data.domain.Page;
87
import org.springframework.data.domain.Pageable;
98
import until.the.eternity.auctionhistory.domain.entity.AuctionHistory;
109
import until.the.eternity.auctionhistory.interfaces.rest.dto.request.AuctionHistorySearchRequest;
@@ -15,7 +14,9 @@ public interface AuctionHistoryRepositoryPort {
1514

1615
record LatestDateWithIds(Instant latestDate, Set<String> existingIds) {}
1716

18-
Page<AuctionHistory> search(AuctionHistorySearchRequest condition, Pageable pageable);
17+
List<AuctionHistory> searchContent(AuctionHistorySearchRequest condition, Pageable pageable);
18+
19+
long count(AuctionHistorySearchRequest condition);
1920

2021
Optional<AuctionHistory> findById(String id);
2122

src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java

Lines changed: 43 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,6 @@
1414
import java.util.ArrayList;
1515
import java.util.List;
1616
import lombok.RequiredArgsConstructor;
17-
import org.springframework.data.domain.Page;
18-
import org.springframework.data.domain.PageImpl;
1917
import org.springframework.data.domain.Pageable;
2018
import org.springframework.data.domain.Sort;
2119
import org.springframework.stereotype.Component;
@@ -34,22 +32,60 @@ class AuctionHistoryQueryDslRepository {
3432
/** 옵션 조건 빌드 결과 (조건 BooleanBuilder + 추가된 조건 개수) */
3533
record OptionConditionResult(BooleanBuilder builder, int count) {}
3634

37-
/** 경매 거래내역 검색 (옵션 조건 포함) */
38-
public Page<AuctionHistory> search(AuctionHistorySearchRequest condition, Pageable pageable) {
35+
/** 경매 거래내역 콘텐츠 조회 (옵션 조건 포함) */
36+
public List<AuctionHistory> searchContent(
37+
AuctionHistorySearchRequest condition, Pageable pageable) {
3938
QAuctionHistory ah = QAuctionHistory.auctionHistory;
4039
QAuctionHistoryItemOption aio = QAuctionHistoryItemOption.auctionHistoryItemOption;
40+
BooleanBuilder historyBuilder = buildSearchPredicate(condition, ah);
4141

42+
List<OrderSpecifier<?>> orderSpecifiers = buildOrderSpecifiers(pageable, ah);
43+
44+
// Deferred Join (Late Row Lookup): 인덱스 친화적으로 ID 먼저 조회
45+
List<String> ids =
46+
queryFactory
47+
.select(ah.auctionBuyId)
48+
.from(ah)
49+
.where(historyBuilder)
50+
.orderBy(orderSpecifiers.toArray(new OrderSpecifier[0]))
51+
.offset(pageable.getOffset())
52+
.limit(pageable.getPageSize())
53+
.fetch();
54+
55+
if (ids.isEmpty()) {
56+
return List.of();
57+
}
58+
59+
return queryFactory
60+
.selectFrom(ah)
61+
.leftJoin(ah.auctionHistoryItemOptions, aio)
62+
.fetchJoin()
63+
.where(ah.auctionBuyId.in(ids))
64+
.orderBy(orderSpecifiers.toArray(new OrderSpecifier[0]))
65+
.distinct()
66+
.fetch();
67+
}
68+
69+
/** 경매 거래내역 조건 기준 전체 건수 조회 */
70+
public long count(AuctionHistorySearchRequest condition) {
71+
QAuctionHistory ah = QAuctionHistory.auctionHistory;
72+
BooleanBuilder historyBuilder = buildSearchPredicate(condition, ah);
73+
Long total = queryFactory.select(ah.count()).from(ah).where(historyBuilder).fetchOne();
74+
return total == null ? 0L : total;
75+
}
76+
77+
/** 검색 where 절 빌드 (옵션/인챈트/세공 포함) */
78+
private BooleanBuilder buildSearchPredicate(
79+
AuctionHistorySearchRequest condition, QAuctionHistory ah) {
4280
OptionConditionResult optionResult = buildOptionConditionResult(condition);
4381
boolean hasItemOptionFilter = hasEffectiveItemOptionFilter(optionResult);
4482
boolean hasEnchantFilter = hasEnchantFilter(condition);
4583
boolean hasMetalwareFilter = hasMetalwareFilter(condition);
4684
boolean isBasicSearchOnly =
4785
!(hasItemOptionFilter || hasEnchantFilter || hasMetalwareFilter);
4886

49-
// 1단계: 거래내역 조건 빌드
5087
BooleanBuilder historyBuilder = buildHistoryPredicate(condition, ah, !isBasicSearchOnly);
5188

52-
// 2단계: 옵션 조건이 있으면 서브쿼리 추가
5389
if (hasItemOptionFilter) {
5490
QAuctionHistoryItemOption subOption = new QAuctionHistoryItemOption("subOption");
5591
var subQuery =
@@ -62,41 +98,7 @@ public Page<AuctionHistory> search(AuctionHistorySearchRequest condition, Pageab
6298
historyBuilder.and(ah.auctionBuyId.in(subQuery));
6399
}
64100

65-
// 3단계: 정렬 조건 빌드
66-
List<OrderSpecifier<?>> orderSpecifiers = buildOrderSpecifiers(pageable, ah);
67-
68-
// 4단계: Deferred Join (Late Row Lookup) 패턴 적용
69-
// 4-1단계: ID만 먼저 조회 (인덱스 활용)
70-
List<String> ids =
71-
queryFactory
72-
.select(ah.auctionBuyId)
73-
.from(ah)
74-
.where(historyBuilder)
75-
.orderBy(orderSpecifiers.toArray(new OrderSpecifier[0]))
76-
.offset(pageable.getOffset())
77-
.limit(pageable.getPageSize())
78-
.fetch();
79-
80-
// 결과가 없으면 빈 페이지 반환
81-
if (ids.isEmpty()) {
82-
return new PageImpl<>(List.of(), pageable, 0L);
83-
}
84-
85-
// 4-2단계: ID로 상세 조회 (LEFT JOIN으로 옵션 포함)
86-
List<AuctionHistory> content =
87-
queryFactory
88-
.selectFrom(ah)
89-
.leftJoin(ah.auctionHistoryItemOptions, aio)
90-
.fetchJoin()
91-
.where(ah.auctionBuyId.in(ids))
92-
.orderBy(orderSpecifiers.toArray(new OrderSpecifier[0]))
93-
.distinct()
94-
.fetch();
95-
96-
// Count 쿼리 (JOIN 없이 실행)
97-
Long total = queryFactory.select(ah.count()).from(ah).where(historyBuilder).fetchOne();
98-
99-
return new PageImpl<>(content, pageable, total == null ? 0L : total);
101+
return historyBuilder;
100102
}
101103

102104
/** 거래내역 기본 조건 빌드 (카테고리, 아이템명, 가격, 거래일자) */

src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryRepositoryPortImpl.java

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
import java.util.Optional;
88
import lombok.RequiredArgsConstructor;
99
import org.springframework.beans.factory.annotation.Value;
10-
import org.springframework.data.domain.Page;
1110
import org.springframework.data.domain.Pageable;
1211
import org.springframework.stereotype.Repository;
1312
import org.springframework.transaction.annotation.Transactional;
@@ -29,8 +28,14 @@ public class AuctionHistoryRepositoryPortImpl implements AuctionHistoryRepositor
2928
private int batchSize;
3029

3130
@Override
32-
public Page<AuctionHistory> search(AuctionHistorySearchRequest condition, Pageable pageable) {
33-
return queryDslRepository.search(condition, pageable);
31+
public List<AuctionHistory> searchContent(
32+
AuctionHistorySearchRequest condition, Pageable pageable) {
33+
return queryDslRepository.searchContent(condition, pageable);
34+
}
35+
36+
@Override
37+
public long count(AuctionHistorySearchRequest condition) {
38+
return queryDslRepository.count(condition);
3439
}
3540

3641
@Override

src/main/java/until/the/eternity/common/util/CacheKeyBuilder.java

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -114,12 +114,9 @@ public static String buildStatisticsTopCategoryKey(
114114
public static String buildAuctionHistorySearchKey(
115115
AuctionHistorySearchRequest requestDto, PageRequestDto pageRequestDto) {
116116
Pageable pageable = pageRequestDto.toPageable();
117-
String dateFrom = "";
118-
String dateTo = "";
119-
if (requestDto.dateAuctionBuyRequest() != null) {
120-
dateFrom = normalize(requestDto.dateAuctionBuyRequest().dateAuctionBuyFrom());
121-
dateTo = normalize(requestDto.dateAuctionBuyRequest().dateAuctionBuyTo());
122-
}
117+
String[] dateRange = resolveAuctionHistoryDateRange(requestDto);
118+
String dateFrom = dateRange[0];
119+
String dateTo = dateRange[1];
123120

124121
return pageable.getPageNumber()
125122
+ ":"
@@ -140,6 +137,24 @@ public static String buildAuctionHistorySearchKey(
140137
+ dateTo;
141138
}
142139

140+
public static String buildAuctionHistoryCountKey(AuctionHistorySearchRequest requestDto) {
141+
String[] dateRange = resolveAuctionHistoryDateRange(requestDto);
142+
String dateFrom = dateRange[0];
143+
String dateTo = dateRange[1];
144+
145+
return normalize(requestDto.itemName())
146+
+ ":"
147+
+ isExactItemName(requestDto.isExactItemName())
148+
+ ":"
149+
+ normalize(requestDto.itemTopCategory())
150+
+ ":"
151+
+ normalize(requestDto.itemSubCategory())
152+
+ ":"
153+
+ dateFrom
154+
+ ":"
155+
+ dateTo;
156+
}
157+
143158
public static String buildAuctionRealtimeSearchKey(
144159
AuctionRealtimeSearchRequest requestDto, Pageable pageable) {
145160
String dateFrom = "";
@@ -176,6 +191,16 @@ private static String normalize(String value) {
176191
return trimmed.isEmpty() ? "" : trimmed;
177192
}
178193

194+
private static String[] resolveAuctionHistoryDateRange(AuctionHistorySearchRequest requestDto) {
195+
String dateFrom = "";
196+
String dateTo = "";
197+
if (requestDto.dateAuctionBuyRequest() != null) {
198+
dateFrom = normalize(requestDto.dateAuctionBuyRequest().dateAuctionBuyFrom());
199+
dateTo = normalize(requestDto.dateAuctionBuyRequest().dateAuctionBuyTo());
200+
}
201+
return new String[] {dateFrom, dateTo};
202+
}
203+
179204
private static boolean isExactItemName(Boolean isExactItemName) {
180205
return Boolean.TRUE.equals(isExactItemName);
181206
}

src/main/java/until/the/eternity/config/CacheNames.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,5 @@ private CacheNames() {}
5252

5353
// ===== 경매 거래 내역 =====
5454
public static final String AUCTION_HISTORY_SEARCH = "auction-history:search";
55+
public static final String AUCTION_HISTORY_COUNT = "auction-history:count";
5556
}

src/main/java/until/the/eternity/config/RedisConfig.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory)
110110

111111
// 경매 거래 내역 - 2시간 TTL (배치 완료 시 evict + warmup)
112112
configs.put(CacheNames.AUCTION_HISTORY_SEARCH, defaultConfig.entryTtl(Duration.ofHours(2)));
113+
configs.put(CacheNames.AUCTION_HISTORY_COUNT, defaultConfig.entryTtl(Duration.ofHours(2)));
113114

114115
return RedisCacheManager.builder(connectionFactory)
115116
.cacheDefaults(defaultConfig)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-- 카테고리 + 거래일자 범위 검색/카운트 최적화
2+
-- 기존 idx_ah_top_sub_name_date는 item_name이 중간 컬럼이라
3+
-- item_name 조건이 없는 top/sub/date 질의에서 비효율이 발생할 수 있음
4+
CREATE INDEX idx_ah_top_sub_date
5+
ON auction_history (item_top_category, item_sub_category, date_auction_buy DESC);

src/test/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryServiceTest.java

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,7 @@
1212
import org.mockito.InjectMocks;
1313
import org.mockito.Mock;
1414
import org.mockito.junit.jupiter.MockitoExtension;
15-
import org.springframework.data.domain.Page;
16-
import org.springframework.data.domain.PageImpl;
15+
import org.springframework.cache.CacheManager;
1716
import org.springframework.data.domain.PageRequest;
1817
import org.springframework.data.domain.Pageable;
1918
import until.the.eternity.auctionhistory.application.service.persister.AuctionHistoryPersister;
@@ -26,6 +25,7 @@
2625
import until.the.eternity.auctionhistory.interfaces.rest.dto.response.ItemOptionResponse;
2726
import until.the.eternity.common.request.PageRequestDto;
2827
import until.the.eternity.common.response.PageResponseDto;
28+
import until.the.eternity.config.CacheNames;
2929

3030
@ExtendWith(MockitoExtension.class)
3131
class AuctionHistoryServiceTest {
@@ -34,6 +34,7 @@ class AuctionHistoryServiceTest {
3434
@Mock private AuctionHistoryFetcherPort fetcherPort;
3535
@Mock private AuctionHistoryPersister persister;
3636
@Mock private AuctionHistoryMapper mapper;
37+
@Mock private CacheManager cacheManager;
3738

3839
@InjectMocks private AuctionHistoryService service;
3940

@@ -51,9 +52,10 @@ void search_should_return_paged_response() {
5152
AuctionHistory entity = new AuctionHistory();
5253
AuctionHistoryDetailResponse<ItemOptionResponse> detailDto =
5354
mock(AuctionHistoryDetailResponse.class);
54-
Page<AuctionHistory> entityPage = new PageImpl<>(List.of(entity), pageable, 1);
5555

56-
when(repositoryPort.search(searchRequest, pageable)).thenReturn(entityPage);
56+
when(cacheManager.getCache(CacheNames.AUCTION_HISTORY_COUNT)).thenReturn(null);
57+
when(repositoryPort.searchContent(searchRequest, pageable)).thenReturn(List.of(entity));
58+
when(repositoryPort.count(searchRequest)).thenReturn(1L);
5759
when(mapper.toDto(entity)).thenReturn(detailDto);
5860

5961
// when
@@ -62,7 +64,8 @@ void search_should_return_paged_response() {
6264

6365
// then
6466
assertThat(result.items()).hasSize(1).contains(detailDto);
65-
verify(repositoryPort).search(searchRequest, pageable);
67+
verify(repositoryPort).searchContent(searchRequest, pageable);
68+
verify(repositoryPort).count(searchRequest);
6669
verify(mapper).toDto(entity);
6770
}
6871

0 commit comments

Comments
 (0)