Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,14 @@ public void warmupForStartup() {

private void evictCaches() {
clearCache(CacheNames.AUCTION_HISTORY_SEARCH);
clearCache(CacheNames.AUCTION_HISTORY_COUNT);
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The class Javadoc on line 20 lists only auction-history:search as being evicted, but the evictCaches() method now also evicts auction-history:count. The documentation should be updated to mention both caches to avoid confusion.

Copilot uses AI. Check for mistakes.
// 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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -26,6 +31,7 @@ public class AuctionHistoryService {
private final AuctionHistoryRepositoryPort repository;
private final AuctionHistoryMapper mapper;
private final EntityManager entityManager;
private final CacheManager cacheManager;

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

Page<AuctionHistory> page = repository.search(requestDto, pageRequestDto.toPageable());
Pageable pageable = pageRequestDto.toPageable();
List<AuctionHistory> content = repository.searchContent(requestDto, pageable);
long total = resolveTotalCount(requestDto);
Page<AuctionHistory> page = new PageImpl<>(content, pageable, total);
Page<AuctionHistoryDetailResponse<ItemOptionResponse>> 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<ItemOptionResponse> findByIdOrElseThrow(String id) {
AuctionHistory auctionHistory =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -15,7 +14,9 @@ public interface AuctionHistoryRepositoryPort {

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

Page<AuctionHistory> search(AuctionHistorySearchRequest condition, Pageable pageable);
List<AuctionHistory> searchContent(AuctionHistorySearchRequest condition, Pageable pageable);

long count(AuctionHistorySearchRequest condition);

Optional<AuctionHistory> findById(String id);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -34,22 +32,60 @@ class AuctionHistoryQueryDslRepository {
/** 옵션 조건 빌드 결과 (조건 BooleanBuilder + 추가된 조건 개수) */
record OptionConditionResult(BooleanBuilder builder, int count) {}

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

List<OrderSpecifier<?>> orderSpecifiers = buildOrderSpecifiers(pageable, ah);

// Deferred Join (Late Row Lookup): 인덱스 친화적으로 ID 먼저 조회
List<String> 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);
boolean hasMetalwareFilter = hasMetalwareFilter(condition);
boolean isBasicSearchOnly =
!(hasItemOptionFilter || hasEnchantFilter || hasMetalwareFilter);

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

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

// 3단계: 정렬 조건 빌드
List<OrderSpecifier<?>> orderSpecifiers = buildOrderSpecifiers(pageable, ah);

// 4단계: Deferred Join (Late Row Lookup) 패턴 적용
// 4-1단계: ID만 먼저 조회 (인덱스 활용)
List<String> 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<AuctionHistory> 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;
}

/** 거래내역 기본 조건 빌드 (카테고리, 아이템명, 가격, 거래일자) */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,8 +28,14 @@ public class AuctionHistoryRepositoryPortImpl implements AuctionHistoryRepositor
private int batchSize;

@Override
public Page<AuctionHistory> search(AuctionHistorySearchRequest condition, Pageable pageable) {
return queryDslRepository.search(condition, pageable);
public List<AuctionHistory> searchContent(
AuctionHistorySearchRequest condition, Pageable pageable) {
return queryDslRepository.searchContent(condition, pageable);
}

@Override
public long count(AuctionHistorySearchRequest condition) {
return queryDslRepository.count(condition);
}

@Override
Expand Down
37 changes: 31 additions & 6 deletions src/main/java/until/the/eternity/common/util/CacheKeyBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -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()
+ ":"
Expand All @@ -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 = "";
Expand Down Expand Up @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions src/main/java/until/the/eternity/config/CacheNames.java
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
1 change: 1 addition & 0 deletions src/main/java/until/the/eternity/config/RedisConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand All @@ -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;

Expand All @@ -51,9 +52,10 @@ void search_should_return_paged_response() {
AuctionHistory entity = new AuctionHistory();
AuctionHistoryDetailResponse<ItemOptionResponse> detailDto =
mock(AuctionHistoryDetailResponse.class);
Page<AuctionHistory> 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
Expand All @@ -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);
}

Expand Down