From 6d2e2c75e3b4f4ab6a29ebc9e347397141ebbfa6 Mon Sep 17 00:00:00 2001 From: dev-ant Date: Wed, 4 Feb 2026 19:25:29 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20ranking=20api=20permit=20all=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/until/the/eternity/config/SecurityConfig.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/until/the/eternity/config/SecurityConfig.java b/src/main/java/until/the/eternity/config/SecurityConfig.java index 13363f0a..d346532b 100644 --- a/src/main/java/until/the/eternity/config/SecurityConfig.java +++ b/src/main/java/until/the/eternity/config/SecurityConfig.java @@ -46,7 +46,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/auction-history/**", "/statistics/**", "/horn-bugle/**", - "/auction-realtime/**") + "/auction-realtime/**", + "/rankings/**") .permitAll() // 나머지 요청은 인증 필요 .anyRequest() From 86ec7dccf5f766e4f37b935c35ab79059f37e506 Mon Sep 17 00:00:00 2001 From: dev-ant Date: Sun, 8 Feb 2026 18:42:24 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EA=B2=BD=EB=A7=A4=EC=9E=A5=20=EC=A0=95=EB=B3=B4=20=EC=A0=81?= =?UTF-8?q?=EC=9E=AC=20=EB=B0=A9=EC=8B=9D=EC=9D=84=20full-refresh=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scheduler/AuctionRealtimeScheduler.java | 29 +-- .../service/AuctionRealtimeService.java | 32 +-- .../fetcher/AuctionRealtimeFetcher.java | 48 +--- .../persister/AuctionRealtimePersister.java | 13 +- .../AuctionRealtimeItemRepositoryPort.java | 13 +- .../AuctionRealtimeDuplicateChecker.java | 131 ---------- .../fetcher/AuctionRealtimeFetcherPort.java | 10 +- .../AuctionRealtimePersisterPort.java | 8 +- .../AuctionRealtimeItemRepository.java | 18 +- ...AuctionRealtimeItemRepositoryPortImpl.java | 11 +- .../fetcher/AuctionRealtimeFetcherTest.java | 113 ++------- .../AuctionRealtimeDuplicateCheckerTest.java | 237 ------------------ 12 files changed, 47 insertions(+), 616 deletions(-) delete mode 100644 src/main/java/until/the/eternity/auctionrealtime/domain/service/AuctionRealtimeDuplicateChecker.java delete mode 100644 src/test/java/until/the/eternity/auctionrealtime/domain/service/AuctionRealtimeDuplicateCheckerTest.java 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 1e9c1586..1adc8e93 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 @@ -19,6 +19,8 @@ * 실시간 경매장 데이터 수집 스케줄러. * *

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

각 서브 카테고리별로 전체 데이터를 수집한 뒤, 기존 데이터를 삭제하고 새 데이터로 교체한다. (Full Refresh) */ @Slf4j @Component @@ -53,7 +55,6 @@ public void fetchAndSaveAuctionRealtimeAll() { for (int topIndex = 0; topIndex < topCategories.size(); topIndex++) { String topCategory = topCategories.get(topIndex); List subCategories = categoriesByTopCategory.get(topCategory); - List newEntities = new ArrayList<>(); log.debug("[REALTIME] Processing top category [{}]", topCategory); @@ -62,7 +63,7 @@ public void fetchAndSaveAuctionRealtimeAll() { try { log.debug("[REALTIME] Processing category [{}]", category.getSubCategory()); - // API 호출 및 데이터 수집 + // API 호출 및 전체 데이터 수집 FetchResult fetchResult = fetcher.fetch(category); if (fetchResult.items().isEmpty()) { @@ -70,21 +71,17 @@ public void fetchAndSaveAuctionRealtimeAll() { continue; } - // 엔티티 변환 및 필터링 + // 엔티티 변환 List entities = - persister.prepareEntities( - fetchResult.items(), category, fetchResult.latestDate()); + persister.prepareEntities(fetchResult.items(), category); if (entities.isEmpty()) { continue; } - // 동일 날짜 데이터가 있으면 삭제 후 저장 - if (fetchResult.hasEqualDate() && fetchResult.latestDate() != null) { - service.deleteAndSave(category, fetchResult.latestDate(), entities); - } else { - newEntities.addAll(entities); - } + // 기존 데이터 삭제 후 새 데이터 저장 (Full Refresh) + service.replaceBySubCategory(category, entities); + totalSavedCount += entities.size(); // 마지막 서브 카테고리가 아닌 경우에만 delay 적용 if (subIndex < subCategories.size() - 1) { @@ -108,16 +105,6 @@ public void fetchAndSaveAuctionRealtimeAll() { } } - // Top Category별로 저장 (동일 날짜가 아닌 신규 데이터) - if (!newEntities.isEmpty()) { - service.saveAll(newEntities); - totalSavedCount += newEntities.size(); - log.info( - "[REALTIME] Saved [{}] new auction realtime items for top category [{}]", - newEntities.size(), - topCategory); - } - // 마지막 탑 카테고리가 아닌 경우에만 delay 적용 if (topIndex < topCategories.size() - 1) { try { 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 8aacbe22..c34da333 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 @@ -62,26 +62,16 @@ public AuctionRealtimeDetailResponse findByIdOrElseT } /** - * 해당 카테고리 & 동일 date_auction_expire 레코드 삭제 후 새 엔티티들을 저장한다. + * 해당 카테고리의 기존 데이터를 모두 삭제하고 새 데이터를 저장한다. (Full Refresh) * * @param category 아이템 카테고리 - * @param dateAuctionExpire 삭제할 date_auction_expire * @param entities 저장할 엔티티 리스트 */ @Transactional - public void deleteAndSave( - ItemCategory category, Instant dateAuctionExpire, List entities) { + public void replaceBySubCategory(ItemCategory category, List entities) { + int deleted = repository.deleteBySubCategory(category); + log.info("[REALTIME] [{}] Deleted {} existing records", category.getSubCategory(), deleted); - // 동일 date_auction_expire 레코드 삭제 - int deleted = - repository.deleteBySubCategoryAndDateAuctionExpire(category, dateAuctionExpire); - log.info( - "[REALTIME] [{}] Deleted {} records with date_auction_expire={}", - category.getSubCategory(), - deleted, - dateAuctionExpire); - - // 새 엔티티 저장 repository.saveAll(entities); log.info( "[REALTIME] [{}] Saved {} new auction realtime items", @@ -89,20 +79,6 @@ public void deleteAndSave( entities.size()); } - /** - * 엔티티들을 저장한다. (삭제 없이) - * - * @param entities 저장할 엔티티 리스트 - */ - @Transactional - public void saveAll(List entities) { - if (entities == null || entities.isEmpty()) { - return; - } - repository.saveAll(entities); - log.debug("[REALTIME] Saved {} auction realtime items", entities.size()); - } - /** * 만료된 아이템을 삭제한다. * diff --git a/src/main/java/until/the/eternity/auctionrealtime/application/service/fetcher/AuctionRealtimeFetcher.java b/src/main/java/until/the/eternity/auctionrealtime/application/service/fetcher/AuctionRealtimeFetcher.java index 1bd26535..b2da4f82 100644 --- a/src/main/java/until/the/eternity/auctionrealtime/application/service/fetcher/AuctionRealtimeFetcher.java +++ b/src/main/java/until/the/eternity/auctionrealtime/application/service/fetcher/AuctionRealtimeFetcher.java @@ -1,34 +1,32 @@ package until.the.eternity.auctionrealtime.application.service.fetcher; -import java.time.Instant; import java.util.ArrayList; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -import until.the.eternity.auctionrealtime.domain.service.AuctionRealtimeDuplicateChecker; -import until.the.eternity.auctionrealtime.domain.service.AuctionRealtimeDuplicateChecker.DuplicateCheckResult; import until.the.eternity.auctionrealtime.domain.service.fetcher.AuctionRealtimeFetcherPort; import until.the.eternity.auctionrealtime.infrastructure.client.AuctionRealtimeClient; import until.the.eternity.auctionrealtime.interfaces.external.dto.OpenApiAuctionRealtimeListResponse; import until.the.eternity.auctionrealtime.interfaces.external.dto.OpenApiAuctionRealtimeResponse; import until.the.eternity.common.enums.ItemCategory; -/** 실시간 경매장 데이터 Fetcher 구현체. Cursor 기반 페이징으로 API를 호출하고, 중복 감지 시 호출을 중단한다. */ +/** + * 실시간 경매장 데이터 Fetcher 구현체. + * + *

Cursor 기반 페이징으로 API를 호출하여 해당 카테고리의 전체 데이터를 수집한다. + */ @Slf4j @Component @RequiredArgsConstructor public class AuctionRealtimeFetcher implements AuctionRealtimeFetcherPort { private final AuctionRealtimeClient client; - private final AuctionRealtimeDuplicateChecker duplicateChecker; @Override public FetchResult fetch(ItemCategory category) { List result = new ArrayList<>(); String cursor = ""; - boolean hasEqualDate = false; - Instant latestDate = null; while (true) { OpenApiAuctionRealtimeListResponse response = @@ -51,39 +49,7 @@ public FetchResult fetch(ItemCategory category) { break; } - List batch = response.auctionItems(); - - // 중복 체크 - DuplicateCheckResult checkResult = - duplicateChecker.checkDuplicateInBatch(batch, category); - - if (checkResult.isDuplicate()) { - int index = checkResult.duplicateIndex(); - latestDate = checkResult.latestDate(); - hasEqualDate = checkResult.hasEqualDate(); - - if (index > 0) { - result.addAll(batch.subList(0, index)); - } - - if (hasEqualDate) { - log.debug( - "[REALTIME] [{}] equal date found at index {}, need to delete and re-save, added {} items", - category.getSubCategory(), - index, - index); - } else { - log.debug( - "[REALTIME] [{}] duplicate found at index {}, added {} items before duplicate", - category.getSubCategory(), - index, - index); - } - break; - } - - latestDate = checkResult.latestDate(); - result.addAll(batch); + result.addAll(response.auctionItems()); cursor = response.nextCursor(); @@ -95,6 +61,6 @@ public FetchResult fetch(ItemCategory category) { } } - return new FetchResult(result, hasEqualDate, latestDate); + return new FetchResult(result); } } diff --git a/src/main/java/until/the/eternity/auctionrealtime/application/service/persister/AuctionRealtimePersister.java b/src/main/java/until/the/eternity/auctionrealtime/application/service/persister/AuctionRealtimePersister.java index 8132c675..10e0a9fc 100644 --- a/src/main/java/until/the/eternity/auctionrealtime/application/service/persister/AuctionRealtimePersister.java +++ b/src/main/java/until/the/eternity/auctionrealtime/application/service/persister/AuctionRealtimePersister.java @@ -1,13 +1,11 @@ package until.the.eternity.auctionrealtime.application.service.persister; -import java.time.Instant; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import until.the.eternity.auctionitem.domain.entity.AuctionRealtimeItem; import until.the.eternity.auctionrealtime.domain.mapper.OpenApiAuctionRealtimeMapper; -import until.the.eternity.auctionrealtime.domain.service.AuctionRealtimeDuplicateChecker; import until.the.eternity.auctionrealtime.domain.service.persister.AuctionRealtimePersisterPort; import until.the.eternity.auctionrealtime.interfaces.external.dto.OpenApiAuctionRealtimeResponse; import until.the.eternity.common.enums.ItemCategory; @@ -19,20 +17,13 @@ public class AuctionRealtimePersister implements AuctionRealtimePersisterPort { private final OpenApiAuctionRealtimeMapper mapper; - private final AuctionRealtimeDuplicateChecker duplicateChecker; @Override public List prepareEntities( - List dtoList, - ItemCategory category, - Instant latestDate) { - - // 저장 가능한 데이터만 필터링 - List filtered = - duplicateChecker.filterForSave(dtoList, latestDate); + List dtoList, ItemCategory category) { // Entity 변환 - List entities = mapper.toEntityList(filtered, category); + List entities = mapper.toEntityList(dtoList, category); // 아이템 옵션 링크 설정 entities.forEach(AuctionRealtimeItem::linkItemOptions); diff --git a/src/main/java/until/the/eternity/auctionrealtime/domain/repository/AuctionRealtimeItemRepositoryPort.java b/src/main/java/until/the/eternity/auctionrealtime/domain/repository/AuctionRealtimeItemRepositoryPort.java index 56513424..1ff1e35a 100644 --- a/src/main/java/until/the/eternity/auctionrealtime/domain/repository/AuctionRealtimeItemRepositoryPort.java +++ b/src/main/java/until/the/eternity/auctionrealtime/domain/repository/AuctionRealtimeItemRepositoryPort.java @@ -30,21 +30,12 @@ public interface AuctionRealtimeItemRepositoryPort { Optional findById(Long id); /** - * 해당 subcategory의 최신 date_auction_expire를 조회한다. + * 해당 subcategory의 모든 레코드를 삭제한다. * * @param category 아이템 카테고리 - * @return 최신 date_auction_expire (없으면 Optional.empty()) - */ - Optional findLatestDateAuctionExpireBySubCategory(ItemCategory category); - - /** - * 해당 subcategory & date_auction_expire에 해당하는 모든 레코드를 삭제한다. - * - * @param category 아이템 카테고리 - * @param dateAuctionExpire 만료 시각 * @return 삭제된 레코드 수 */ - int deleteBySubCategoryAndDateAuctionExpire(ItemCategory category, Instant dateAuctionExpire); + int deleteBySubCategory(ItemCategory category); /** * date_auction_expire가 현재 시각보다 이전인 모든 레코드를 삭제한다. diff --git a/src/main/java/until/the/eternity/auctionrealtime/domain/service/AuctionRealtimeDuplicateChecker.java b/src/main/java/until/the/eternity/auctionrealtime/domain/service/AuctionRealtimeDuplicateChecker.java deleted file mode 100644 index 54c24f6d..00000000 --- a/src/main/java/until/the/eternity/auctionrealtime/domain/service/AuctionRealtimeDuplicateChecker.java +++ /dev/null @@ -1,131 +0,0 @@ -package until.the.eternity.auctionrealtime.domain.service; - -import java.time.Instant; -import java.util.List; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; -import until.the.eternity.auctionrealtime.domain.repository.AuctionRealtimeItemRepositoryPort; -import until.the.eternity.auctionrealtime.interfaces.external.dto.OpenApiAuctionRealtimeResponse; -import until.the.eternity.common.enums.ItemCategory; - -/** - * 실시간 경매장 데이터의 중복 체크 로직. - * - *

중복 판단 기준: - * - *

- */ -@Slf4j -@Component -@RequiredArgsConstructor -public class AuctionRealtimeDuplicateChecker { - - private final AuctionRealtimeItemRepositoryPort repository; - - /** - * 중복 체크 결과. - * - * @param isDuplicate 중복 여부 - * @param hasEqualDate 동일 날짜 데이터 존재 여부 (삭제 후 재저장 필요) - * @param duplicateIndex 중복이 발생한 첫 번째 인덱스 (-1이면 중복 없음) - * @param latestDate DB의 최신 date_auction_expire - */ - public record DuplicateCheckResult( - boolean isDuplicate, boolean hasEqualDate, int duplicateIndex, Instant latestDate) { - - public static DuplicateCheckResult noDuplicate() { - return new DuplicateCheckResult(false, false, -1, null); - } - - public static DuplicateCheckResult noDuplicateWithLatestDate(Instant latestDate) { - return new DuplicateCheckResult(false, false, -1, latestDate); - } - - public static DuplicateCheckResult duplicateFound(int index, Instant latestDate) { - return new DuplicateCheckResult(true, false, index, latestDate); - } - - public static DuplicateCheckResult equalDateFound(int index, Instant latestDate) { - return new DuplicateCheckResult(true, true, index, latestDate); - } - } - - /** - * 배치에서 중복 또는 동일 날짜 데이터를 체크한다. - * - * @param batch 검사할 배치 데이터 - * @param category 아이템 카테고리 - * @return 중복 체크 결과 - */ - public DuplicateCheckResult checkDuplicateInBatch( - List batch, ItemCategory category) { - if (batch == null || batch.isEmpty()) { - return DuplicateCheckResult.noDuplicate(); - } - - Optional latestDateOpt = - repository.findLatestDateAuctionExpireBySubCategory(category); - - if (latestDateOpt.isEmpty()) { - // DB에 데이터가 없으면 모두 신규 - return DuplicateCheckResult.noDuplicate(); - } - - Instant latestDate = latestDateOpt.get(); - - for (int i = 0; i < batch.size(); i++) { - OpenApiAuctionRealtimeResponse dto = batch.get(i); - Instant dtoDate = dto.dateAuctionExpire(); - - if (dtoDate == null) { - continue; - } - - if (dtoDate.isBefore(latestDate)) { - // 과거 데이터 → 중복 - return DuplicateCheckResult.duplicateFound(i, latestDate); - } - - if (dtoDate.equals(latestDate)) { - // 동일 날짜 → 삭제 후 재저장 필요 - return DuplicateCheckResult.equalDateFound(i, latestDate); - } - } - - // 모든 데이터가 latestDate보다 이후 - return DuplicateCheckResult.noDuplicateWithLatestDate(latestDate); - } - - /** - * API 응답 데이터를 필터링하여 저장 가능한 데이터만 반환한다. - * - * @param dtos 필터링할 DTO 리스트 - * @param latestDate DB의 최신 date_auction_expire (null이면 모두 저장) - * @return 저장할 데이터 리스트 - */ - public List filterForSave( - List dtos, Instant latestDate) { - if (dtos == null || dtos.isEmpty()) { - return List.of(); - } - - if (latestDate == null) { - return dtos; - } - - return dtos.stream() - .filter( - dto -> { - Instant dtoDate = dto.dateAuctionExpire(); - // latestDate 이후 또는 동일한 날짜만 저장 - return dtoDate != null && !dtoDate.isBefore(latestDate); - }) - .toList(); - } -} diff --git a/src/main/java/until/the/eternity/auctionrealtime/domain/service/fetcher/AuctionRealtimeFetcherPort.java b/src/main/java/until/the/eternity/auctionrealtime/domain/service/fetcher/AuctionRealtimeFetcherPort.java index 8f31c645..31220874 100644 --- a/src/main/java/until/the/eternity/auctionrealtime/domain/service/fetcher/AuctionRealtimeFetcherPort.java +++ b/src/main/java/until/the/eternity/auctionrealtime/domain/service/fetcher/AuctionRealtimeFetcherPort.java @@ -1,6 +1,5 @@ package until.the.eternity.auctionrealtime.domain.service.fetcher; -import java.time.Instant; import java.util.List; import until.the.eternity.auctionrealtime.interfaces.external.dto.OpenApiAuctionRealtimeResponse; import until.the.eternity.common.enums.ItemCategory; @@ -12,19 +11,16 @@ public interface AuctionRealtimeFetcherPort { * 페칭 결과. * * @param items 수집된 아이템 리스트 - * @param hasEqualDate 동일 날짜 데이터 존재 여부 (삭제 후 재저장 필요) - * @param latestDate DB의 최신 date_auction_expire */ - record FetchResult( - List items, boolean hasEqualDate, Instant latestDate) { + record FetchResult(List items) { public static FetchResult empty() { - return new FetchResult(List.of(), false, null); + return new FetchResult(List.of()); } } /** - * 해당 카테고리의 실시간 경매장 데이터를 수집한다. + * 해당 카테고리의 실시간 경매장 데이터를 전체 수집한다. * * @param category 아이템 카테고리 * @return 페칭 결과 diff --git a/src/main/java/until/the/eternity/auctionrealtime/domain/service/persister/AuctionRealtimePersisterPort.java b/src/main/java/until/the/eternity/auctionrealtime/domain/service/persister/AuctionRealtimePersisterPort.java index 265327f7..41357a28 100644 --- a/src/main/java/until/the/eternity/auctionrealtime/domain/service/persister/AuctionRealtimePersisterPort.java +++ b/src/main/java/until/the/eternity/auctionrealtime/domain/service/persister/AuctionRealtimePersisterPort.java @@ -1,6 +1,5 @@ package until.the.eternity.auctionrealtime.domain.service.persister; -import java.time.Instant; import java.util.List; import until.the.eternity.auctionitem.domain.entity.AuctionRealtimeItem; import until.the.eternity.auctionrealtime.interfaces.external.dto.OpenApiAuctionRealtimeResponse; @@ -10,15 +9,12 @@ public interface AuctionRealtimePersisterPort { /** - * API 응답 데이터를 Entity로 변환하고 필터링한다. + * API 응답 데이터를 Entity로 변환한다. * * @param dtoList API 응답 DTO 리스트 * @param category 아이템 카테고리 - * @param latestDate DB의 최신 date_auction_expire (null이면 모두 저장) * @return 저장할 Entity 리스트 */ List prepareEntities( - List dtoList, - ItemCategory category, - Instant latestDate); + List dtoList, ItemCategory category); } diff --git a/src/main/java/until/the/eternity/auctionrealtime/infrastructure/persistence/AuctionRealtimeItemRepository.java b/src/main/java/until/the/eternity/auctionrealtime/infrastructure/persistence/AuctionRealtimeItemRepository.java index 399678c1..53cbdbf4 100644 --- a/src/main/java/until/the/eternity/auctionrealtime/infrastructure/persistence/AuctionRealtimeItemRepository.java +++ b/src/main/java/until/the/eternity/auctionrealtime/infrastructure/persistence/AuctionRealtimeItemRepository.java @@ -1,7 +1,6 @@ package until.the.eternity.auctionrealtime.infrastructure.persistence; import java.time.Instant; -import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -11,22 +10,9 @@ /** AuctionRealtimeItem JPA Repository. */ public interface AuctionRealtimeItemRepository extends JpaRepository { - @Query( - value = - "SELECT date_auction_expire FROM auction_realtime_item " - + "WHERE item_sub_category = :subCategory " - + "ORDER BY date_auction_expire DESC LIMIT 1", - nativeQuery = true) - Optional findLatestDateAuctionExpireBySubCategory( - @Param("subCategory") String subCategory); - @Modifying - @Query( - "DELETE FROM AuctionRealtimeItem a " - + "WHERE a.itemSubCategory = :subCategory AND a.dateAuctionExpire = :dateAuctionExpire") - int deleteBySubCategoryAndDateAuctionExpire( - @Param("subCategory") String subCategory, - @Param("dateAuctionExpire") Instant dateAuctionExpire); + @Query("DELETE FROM AuctionRealtimeItem a " + "WHERE a.itemSubCategory = :subCategory") + int deleteBySubCategory(@Param("subCategory") String subCategory); @Modifying @Query("DELETE FROM AuctionRealtimeItem a WHERE a.dateAuctionExpire < :now") diff --git a/src/main/java/until/the/eternity/auctionrealtime/infrastructure/persistence/AuctionRealtimeItemRepositoryPortImpl.java b/src/main/java/until/the/eternity/auctionrealtime/infrastructure/persistence/AuctionRealtimeItemRepositoryPortImpl.java index 4e9fca60..6da0da26 100644 --- a/src/main/java/until/the/eternity/auctionrealtime/infrastructure/persistence/AuctionRealtimeItemRepositoryPortImpl.java +++ b/src/main/java/until/the/eternity/auctionrealtime/infrastructure/persistence/AuctionRealtimeItemRepositoryPortImpl.java @@ -38,15 +38,8 @@ public Optional findById(Long id) { } @Override - public Optional findLatestDateAuctionExpireBySubCategory(ItemCategory category) { - return jpaRepository.findLatestDateAuctionExpireBySubCategory(category.getSubCategory()); - } - - @Override - public int deleteBySubCategoryAndDateAuctionExpire( - ItemCategory category, Instant dateAuctionExpire) { - return jpaRepository.deleteBySubCategoryAndDateAuctionExpire( - category.getSubCategory(), dateAuctionExpire); + public int deleteBySubCategory(ItemCategory category) { + return jpaRepository.deleteBySubCategory(category.getSubCategory()); } @Override diff --git a/src/test/java/until/the/eternity/auctionrealtime/application/service/fetcher/AuctionRealtimeFetcherTest.java b/src/test/java/until/the/eternity/auctionrealtime/application/service/fetcher/AuctionRealtimeFetcherTest.java index 3c2c7bb3..cb22f7b3 100644 --- a/src/test/java/until/the/eternity/auctionrealtime/application/service/fetcher/AuctionRealtimeFetcherTest.java +++ b/src/test/java/until/the/eternity/auctionrealtime/application/service/fetcher/AuctionRealtimeFetcherTest.java @@ -15,8 +15,6 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import reactor.core.publisher.Mono; -import until.the.eternity.auctionrealtime.domain.service.AuctionRealtimeDuplicateChecker; -import until.the.eternity.auctionrealtime.domain.service.AuctionRealtimeDuplicateChecker.DuplicateCheckResult; import until.the.eternity.auctionrealtime.domain.service.fetcher.AuctionRealtimeFetcherPort.FetchResult; import until.the.eternity.auctionrealtime.infrastructure.client.AuctionRealtimeClient; import until.the.eternity.auctionrealtime.interfaces.external.dto.OpenApiAuctionRealtimeListResponse; @@ -28,8 +26,6 @@ class AuctionRealtimeFetcherTest { @Mock AuctionRealtimeClient client; - @Mock AuctionRealtimeDuplicateChecker duplicateChecker; - @InjectMocks AuctionRealtimeFetcher fetcher; private OpenApiAuctionRealtimeResponse dummy() { @@ -38,8 +34,8 @@ private OpenApiAuctionRealtimeResponse dummy() { } @Nested - @DisplayName("OPEN API 끝까지 호출 시나리오") - class NormalFlow { + @DisplayName("전체 페이지 수집 시나리오") + class FullFetchFlow { @Test @DisplayName("모든 페이지를 수집하고 cursor가 null이면 종료한다") @@ -52,114 +48,42 @@ void fetchAllPages() { when(client.fetchAuctionList(ItemCategory.SWORD, "")).thenReturn(Mono.just(page1)); when(client.fetchAuctionList(ItemCategory.SWORD, "cursor-1")) .thenReturn(Mono.just(page2)); - when(duplicateChecker.checkDuplicateInBatch(any(), eq(ItemCategory.SWORD))) - .thenReturn(DuplicateCheckResult.noDuplicate()); // when FetchResult result = fetcher.fetch(ItemCategory.SWORD); // then assertThat(result.items()).hasSize(3); - assertThat(result.hasEqualDate()).isFalse(); - verify(client, times(2)).fetchAuctionList(eq(ItemCategory.SWORD), any()); - verify(duplicateChecker, times(2)).checkDuplicateInBatch(any(), eq(ItemCategory.SWORD)); } - } - - @Nested - @DisplayName("OPEN API 호출 중단 시나리오") - class EarlyBreakFlow { @Test - @DisplayName("첫 배치 첫 항목에서 중복이면 빈 리스트를 반환한다") - void stopOnDuplicateAtFirstItem() { + @DisplayName("3페이지 이상도 끝까지 수집한다") + void fetchMultiplePages() { // given var page1 = new OpenApiAuctionRealtimeListResponse(List.of(dummy(), dummy()), "cursor-1"); - Instant latestDate = Instant.parse("2024-01-01T00:00:00Z"); - - when(client.fetchAuctionList(ItemCategory.SWORD, "")).thenReturn(Mono.just(page1)); - when(duplicateChecker.checkDuplicateInBatch(page1.auctionItems(), ItemCategory.SWORD)) - .thenReturn(DuplicateCheckResult.duplicateFound(0, latestDate)); - - // when - FetchResult result = fetcher.fetch(ItemCategory.SWORD); - - // then - assertThat(result.items()).isEmpty(); - assertThat(result.latestDate()).isEqualTo(latestDate); - verify(client, times(1)).fetchAuctionList(ItemCategory.SWORD, ""); - verifyNoMoreInteractions(client); - } - - @Test - @DisplayName("첫 배치 중간에서 중복이면 중복 전까지만 반환한다") - void stopOnDuplicateAtMiddle() { - // given - var batch = List.of(dummy(), dummy(), dummy()); - var page1 = new OpenApiAuctionRealtimeListResponse(batch, "cursor-1"); - Instant latestDate = Instant.parse("2024-01-01T00:00:00Z"); - - when(client.fetchAuctionList(ItemCategory.SWORD, "")).thenReturn(Mono.just(page1)); - when(duplicateChecker.checkDuplicateInBatch(batch, ItemCategory.SWORD)) - .thenReturn(DuplicateCheckResult.duplicateFound(2, latestDate)); - - // when - FetchResult result = fetcher.fetch(ItemCategory.SWORD); - - // then - assertThat(result.items()).hasSize(2); - verify(client, times(1)).fetchAuctionList(ItemCategory.SWORD, ""); - verifyNoMoreInteractions(client); - } - - @Test - @DisplayName("동일 날짜 데이터가 감지되면 hasEqualDate가 true로 반환된다") - void stopOnEqualDate() { - // given - var batch = List.of(dummy(), dummy()); - var page1 = new OpenApiAuctionRealtimeListResponse(batch, "cursor-1"); - Instant latestDate = Instant.parse("2024-01-01T00:00:00Z"); - - when(client.fetchAuctionList(ItemCategory.SWORD, "")).thenReturn(Mono.just(page1)); - when(duplicateChecker.checkDuplicateInBatch(batch, ItemCategory.SWORD)) - .thenReturn(DuplicateCheckResult.equalDateFound(1, latestDate)); - - // when - FetchResult result = fetcher.fetch(ItemCategory.SWORD); - - // then - assertThat(result.items()).hasSize(1); - assertThat(result.hasEqualDate()).isTrue(); - assertThat(result.latestDate()).isEqualTo(latestDate); - } - - @Test - @DisplayName("두 번째 배치에서 중복이면 첫 배치 전체 + 중복 전까지만 반환한다") - void stopOnDuplicateAtSecondBatch() { - // given - var batch1 = List.of(dummy(), dummy()); - var batch2 = List.of(dummy(), dummy(), dummy()); - var page1 = new OpenApiAuctionRealtimeListResponse(batch1, "cursor-1"); - var page2 = new OpenApiAuctionRealtimeListResponse(batch2, "cursor-2"); - Instant latestDate = Instant.parse("2024-01-01T00:00:00Z"); + var page2 = new OpenApiAuctionRealtimeListResponse(List.of(dummy()), "cursor-2"); + var page3 = new OpenApiAuctionRealtimeListResponse(List.of(dummy(), dummy()), null); when(client.fetchAuctionList(ItemCategory.SWORD, "")).thenReturn(Mono.just(page1)); when(client.fetchAuctionList(ItemCategory.SWORD, "cursor-1")) .thenReturn(Mono.just(page2)); - when(duplicateChecker.checkDuplicateInBatch(batch1, ItemCategory.SWORD)) - .thenReturn(DuplicateCheckResult.noDuplicate()); - when(duplicateChecker.checkDuplicateInBatch(batch2, ItemCategory.SWORD)) - .thenReturn(DuplicateCheckResult.duplicateFound(1, latestDate)); + when(client.fetchAuctionList(ItemCategory.SWORD, "cursor-2")) + .thenReturn(Mono.just(page3)); // when FetchResult result = fetcher.fetch(ItemCategory.SWORD); // then - assertThat(result.items()).hasSize(3); // 2 from batch1 + 1 from batch2 - verify(client, times(2)).fetchAuctionList(eq(ItemCategory.SWORD), any()); + assertThat(result.items()).hasSize(5); + verify(client, times(3)).fetchAuctionList(eq(ItemCategory.SWORD), any()); } + } + + @Nested + @DisplayName("수집 중단 시나리오") + class EarlyBreakFlow { @Test @DisplayName("첫 응답이 null(Mono.empty)이면 빈 리스트를 반환한다") @@ -169,7 +93,6 @@ void responseNull() { FetchResult result = fetcher.fetch(ItemCategory.SWORD); assertThat(result.items()).isEmpty(); - verify(duplicateChecker, never()).checkDuplicateInBatch(any(), any()); } @Test @@ -182,7 +105,6 @@ void auctionItemsEmpty() { FetchResult result = fetcher.fetch(ItemCategory.SWORD); assertThat(result.items()).isEmpty(); - verify(duplicateChecker, never()).checkDuplicateInBatch(any(), any()); } @Test @@ -191,8 +113,6 @@ void stopWhenNextCursorIsEmptyString() { // given var page1 = new OpenApiAuctionRealtimeListResponse(List.of(dummy()), ""); when(client.fetchAuctionList(ItemCategory.SWORD, "")).thenReturn(Mono.just(page1)); - when(duplicateChecker.checkDuplicateInBatch(any(), eq(ItemCategory.SWORD))) - .thenReturn(DuplicateCheckResult.noDuplicate()); // when FetchResult result = fetcher.fetch(ItemCategory.SWORD); @@ -213,8 +133,6 @@ void stopWhenMiddlePageIsEmpty() { when(client.fetchAuctionList(ItemCategory.SWORD, "")).thenReturn(Mono.just(page1)); when(client.fetchAuctionList(ItemCategory.SWORD, "cursor-1")) .thenReturn(Mono.just(emptyPage)); - when(duplicateChecker.checkDuplicateInBatch(any(), eq(ItemCategory.SWORD))) - .thenReturn(DuplicateCheckResult.noDuplicate()); // when FetchResult result = fetcher.fetch(ItemCategory.SWORD); @@ -240,7 +158,6 @@ void stopWhenAuctionItemsListIsNull() { assertThat(result.items()).isEmpty(); verify(client, times(1)).fetchAuctionList(eq(ItemCategory.SWORD), any()); verifyNoMoreInteractions(client); - verify(duplicateChecker, never()).checkDuplicateInBatch(any(), any()); } } } diff --git a/src/test/java/until/the/eternity/auctionrealtime/domain/service/AuctionRealtimeDuplicateCheckerTest.java b/src/test/java/until/the/eternity/auctionrealtime/domain/service/AuctionRealtimeDuplicateCheckerTest.java deleted file mode 100644 index b731130b..00000000 --- a/src/test/java/until/the/eternity/auctionrealtime/domain/service/AuctionRealtimeDuplicateCheckerTest.java +++ /dev/null @@ -1,237 +0,0 @@ -package until.the.eternity.auctionrealtime.domain.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.when; - -import java.time.Instant; -import java.util.List; -import java.util.Optional; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import until.the.eternity.auctionrealtime.domain.repository.AuctionRealtimeItemRepositoryPort; -import until.the.eternity.auctionrealtime.domain.service.AuctionRealtimeDuplicateChecker.DuplicateCheckResult; -import until.the.eternity.auctionrealtime.interfaces.external.dto.OpenApiAuctionRealtimeResponse; -import until.the.eternity.common.enums.ItemCategory; - -@ExtendWith(MockitoExtension.class) -class AuctionRealtimeDuplicateCheckerTest { - - @Mock AuctionRealtimeItemRepositoryPort repository; - - @InjectMocks AuctionRealtimeDuplicateChecker checker; - - private static final ItemCategory CATEGORY = ItemCategory.SWORD; - - private OpenApiAuctionRealtimeResponse dto(Instant dateAuctionExpire) { - return new OpenApiAuctionRealtimeResponse( - "페러시우스 타이탄 블레이드", "신성한 페러시우스 타이탄 블레이드", 1L, 100L, dateAuctionExpire, null); - } - - @Nested - @DisplayName("checkDuplicateInBatch 테스트") - class CheckDuplicateInBatchTest { - - @Test - @DisplayName("DB에 데이터가 없으면 중복 없음으로 판정") - void noDuplicateWhenNoDataInDb() { - // given - Instant now = Instant.now(); - var batch = List.of(dto(now), dto(now.minusSeconds(10))); - when(repository.findLatestDateAuctionExpireBySubCategory(CATEGORY)) - .thenReturn(Optional.empty()); - - // when - DuplicateCheckResult result = checker.checkDuplicateInBatch(batch, CATEGORY); - - // then - assertThat(result.isDuplicate()).isFalse(); - assertThat(result.hasEqualDate()).isFalse(); - assertThat(result.latestDate()).isNull(); - } - - @Test - @DisplayName("빈 배치이면 중복 없음으로 판정") - void noDuplicateWhenEmptyBatch() { - // given - List batch = List.of(); - - // when - DuplicateCheckResult result = checker.checkDuplicateInBatch(batch, CATEGORY); - - // then - assertThat(result.isDuplicate()).isFalse(); - assertThat(result.hasEqualDate()).isFalse(); - } - - @Test - @DisplayName("모든 데이터가 latestDate 이후면 중복 없음") - void noDuplicateWhenAllDataAfterLatestDate() { - // given - Instant latestDate = Instant.parse("2024-01-01T00:00:00Z"); - Instant afterLatest = latestDate.plusSeconds(100); - var batch = List.of(dto(afterLatest), dto(afterLatest.plusSeconds(10))); - - when(repository.findLatestDateAuctionExpireBySubCategory(CATEGORY)) - .thenReturn(Optional.of(latestDate)); - - // when - DuplicateCheckResult result = checker.checkDuplicateInBatch(batch, CATEGORY); - - // then - assertThat(result.isDuplicate()).isFalse(); - assertThat(result.latestDate()).isEqualTo(latestDate); - } - - @Test - @DisplayName("중간에 latestDate 이전 데이터가 있으면 해당 인덱스와 중복 반환") - void duplicateFoundWhenDataBeforeLatestDate() { - // given - Instant latestDate = Instant.parse("2024-01-01T00:00:00Z"); - Instant afterLatest = latestDate.plusSeconds(100); - Instant beforeLatest = latestDate.minusSeconds(100); - var batch = List.of(dto(afterLatest), dto(afterLatest), dto(beforeLatest)); - - when(repository.findLatestDateAuctionExpireBySubCategory(CATEGORY)) - .thenReturn(Optional.of(latestDate)); - - // when - DuplicateCheckResult result = checker.checkDuplicateInBatch(batch, CATEGORY); - - // then - assertThat(result.isDuplicate()).isTrue(); - assertThat(result.hasEqualDate()).isFalse(); - assertThat(result.duplicateIndex()).isEqualTo(2); - assertThat(result.latestDate()).isEqualTo(latestDate); - } - - @Test - @DisplayName("동일 날짜 데이터가 있으면 equalDateFound 반환") - void equalDateFoundWhenSameDate() { - // given - Instant latestDate = Instant.parse("2024-01-01T00:00:00Z"); - Instant afterLatest = latestDate.plusSeconds(100); - var batch = List.of(dto(afterLatest), dto(latestDate)); - - when(repository.findLatestDateAuctionExpireBySubCategory(CATEGORY)) - .thenReturn(Optional.of(latestDate)); - - // when - DuplicateCheckResult result = checker.checkDuplicateInBatch(batch, CATEGORY); - - // then - assertThat(result.isDuplicate()).isTrue(); - assertThat(result.hasEqualDate()).isTrue(); - assertThat(result.duplicateIndex()).isEqualTo(1); - assertThat(result.latestDate()).isEqualTo(latestDate); - } - - @Test - @DisplayName("첫 번째 항목이 과거면 인덱스 0 반환") - void duplicateAtFirstIndex() { - // given - Instant latestDate = Instant.parse("2024-01-01T00:00:00Z"); - Instant beforeLatest = latestDate.minusSeconds(100); - var batch = List.of(dto(beforeLatest), dto(latestDate)); - - when(repository.findLatestDateAuctionExpireBySubCategory(CATEGORY)) - .thenReturn(Optional.of(latestDate)); - - // when - DuplicateCheckResult result = checker.checkDuplicateInBatch(batch, CATEGORY); - - // then - assertThat(result.isDuplicate()).isTrue(); - assertThat(result.duplicateIndex()).isEqualTo(0); - } - } - - @Nested - @DisplayName("filterForSave 테스트") - class FilterForSaveTest { - - @Test - @DisplayName("latestDate가 null이면 모든 데이터 반환") - void returnAllWhenLatestDateNull() { - // given - Instant now = Instant.now(); - var dtos = List.of(dto(now), dto(now.minusSeconds(10))); - - // when - var result = checker.filterForSave(dtos, null); - - // then - assertThat(result).hasSize(2); - } - - @Test - @DisplayName("빈 리스트이면 빈 리스트 반환") - void returnEmptyWhenEmptyList() { - // given - List dtos = List.of(); - - // when - var result = checker.filterForSave(dtos, Instant.now()); - - // then - assertThat(result).isEmpty(); - } - - @Test - @DisplayName("latestDate 이전 데이터는 필터링됨") - void filterOutDataBeforeLatestDate() { - // given - Instant latestDate = Instant.parse("2024-01-01T00:00:00Z"); - Instant afterLatest = latestDate.plusSeconds(100); - Instant beforeLatest = latestDate.minusSeconds(100); - var dtos = List.of(dto(afterLatest), dto(beforeLatest), dto(afterLatest)); - - // when - var result = checker.filterForSave(dtos, latestDate); - - // then - assertThat(result).hasSize(2); - } - - @Test - @DisplayName("동일 날짜 데이터는 포함됨") - void includeSameDateData() { - // given - Instant latestDate = Instant.parse("2024-01-01T00:00:00Z"); - var dtos = List.of(dto(latestDate), dto(latestDate)); - - // when - var result = checker.filterForSave(dtos, latestDate); - - // then - assertThat(result).hasSize(2); - } - - @Test - @DisplayName("복합 시나리오: 이전/동일/이후 데이터") - void complexScenario() { - // given - Instant latestDate = Instant.parse("2024-01-01T00:00:00Z"); - Instant afterLatest = latestDate.plusSeconds(100); - Instant beforeLatest = latestDate.minusSeconds(100); - - var dtos = - List.of( - dto(afterLatest), // 신규: 포함 - dto(beforeLatest), // 과거: 제외 - dto(latestDate), // 동일 날짜: 포함 - dto(afterLatest) // 신규: 포함 - ); - - // when - var result = checker.filterForSave(dtos, latestDate); - - // then - assertThat(result).hasSize(3); - } - } -}