auctionRealtimeItemOptions;
+
+ public AuctionRealtimeItem linkItemOptions() {
+ if (this.auctionRealtimeItemOptions != null) {
+ for (AuctionRealtimeItemOption o : this.auctionRealtimeItemOptions) {
+ o.setAuctionRealtimeItem(this);
+ }
+ }
+ return this;
+ }
+}
diff --git a/src/main/java/until/the/eternity/auctionitem/domain/entity/AuctionRealtimeItemOption.java b/src/main/java/until/the/eternity/auctionitem/domain/entity/AuctionRealtimeItemOption.java
new file mode 100644
index 00000000..28ba2905
--- /dev/null
+++ b/src/main/java/until/the/eternity/auctionitem/domain/entity/AuctionRealtimeItemOption.java
@@ -0,0 +1,76 @@
+package until.the.eternity.auctionitem.domain.entity;
+
+import jakarta.persistence.*;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.util.UUID;
+
+/** 실시간 경매장 아이템(auction_realtime_item)에 연결된 아이템 옵션 정보. */
+@Entity
+@Table(name = "auction_realtime_item_option")
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class AuctionRealtimeItemOption {
+
+ @Id
+ @Column(name = "id")
+ private String id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "auction_realtime_item_id", nullable = false)
+ private AuctionRealtimeItem auctionRealtimeItem;
+
+ @Column(name = "option_type")
+ private String optionType;
+
+ @Column(name = "option_sub_type")
+ private String optionSubType;
+
+ @Column(name = "option_value")
+ private String optionValue;
+
+ @Column(name = "option_value2")
+ private String optionValue2;
+
+ @Column(name = "option_desc", columnDefinition = "TEXT")
+ private String optionDesc;
+
+ @PrePersist
+ public void createId() {
+ this.id = UUID.randomUUID().toString();
+ }
+
+ public void setAuctionRealtimeItem(AuctionRealtimeItem auctionRealtimeItem) {
+
+ // 이전 연관관계 정리
+ if (this.auctionRealtimeItem != null) {
+ this.auctionRealtimeItem.getAuctionRealtimeItemOptions().remove(this);
+ }
+
+ // 새 연관관계 설정
+ this.auctionRealtimeItem = auctionRealtimeItem;
+
+ // 반대 쪽 컬렉션 동기화
+ if (auctionRealtimeItem != null
+ && !auctionRealtimeItem.getAuctionRealtimeItemOptions().contains(this)) {
+ auctionRealtimeItem.getAuctionRealtimeItemOptions().add(this);
+ }
+ }
+
+ public void setOptionValue(String optionValue) {
+ this.optionValue = optionValue;
+ }
+
+ public void setOptionValue2(String optionValue2) {
+ this.optionValue2 = optionValue2;
+ }
+
+ public void setOptionDesc(String optionDesc) {
+ this.optionDesc = optionDesc;
+ }
+}
diff --git a/src/main/java/until/the/eternity/auctionitemoption/domain/entity/AuctionItemOption.java b/src/main/java/until/the/eternity/auctionitemoption/domain/entity/AuctionHistoryItemOption.java
similarity index 62%
rename from src/main/java/until/the/eternity/auctionitemoption/domain/entity/AuctionItemOption.java
rename to src/main/java/until/the/eternity/auctionitemoption/domain/entity/AuctionHistoryItemOption.java
index 34a42dea..332605de 100644
--- a/src/main/java/until/the/eternity/auctionitemoption/domain/entity/AuctionItemOption.java
+++ b/src/main/java/until/the/eternity/auctionitemoption/domain/entity/AuctionHistoryItemOption.java
@@ -6,17 +6,20 @@
import lombok.Getter;
import lombok.NoArgsConstructor;
import until.the.eternity.auctionhistory.domain.entity.AuctionHistory;
-import until.the.eternity.auctionitem.domain.entity.AuctionItem;
import java.util.UUID;
+/**
+ * 경매장 거래 내역(auction_history)에 연결된 아이템 옵션 정보. V15 마이그레이션에서 auction_item_option →
+ * auction_history_item_option으로 변경됨.
+ */
@Entity
-@Table(name = "auction_item_option")
+@Table(name = "auction_history_item_option")
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
-public class AuctionItemOption {
+public class AuctionHistoryItemOption {
@Id
@Column(name = "id")
@@ -29,10 +32,6 @@ public class AuctionItemOption {
nullable = false)
private AuctionHistory auctionHistory;
- @ManyToOne(fetch = FetchType.LAZY)
- @JoinColumn(name = "auction_item_id", nullable = true)
- private AuctionItem auctionItem;
-
@Column(name = "option_type")
private String optionType;
@@ -57,15 +56,28 @@ public void setAuctionHistory(AuctionHistory auctionHistory) {
// 이전 연관관계 정리
if (this.auctionHistory != null) {
- this.auctionHistory.getAuctionItemOptions().remove(this);
+ this.auctionHistory.getAuctionHistoryItemOptions().remove(this);
}
// 새 연관관계 설정
this.auctionHistory = auctionHistory;
// 반대 쪽 컬렉션 동기화
- if (auctionHistory != null && !auctionHistory.getAuctionItemOptions().contains(this)) {
- auctionHistory.getAuctionItemOptions().add(this);
+ if (auctionHistory != null
+ && !auctionHistory.getAuctionHistoryItemOptions().contains(this)) {
+ auctionHistory.getAuctionHistoryItemOptions().add(this);
}
}
+
+ public void setOptionValue(String optionValue) {
+ this.optionValue = optionValue;
+ }
+
+ public void setOptionValue2(String optionValue2) {
+ this.optionValue2 = optionValue2;
+ }
+
+ public void setOptionDesc(String optionDesc) {
+ this.optionDesc = optionDesc;
+ }
}
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
new file mode 100644
index 00000000..a854ddd8
--- /dev/null
+++ b/src/main/java/until/the/eternity/auctionrealtime/application/scheduler/AuctionRealtimeScheduler.java
@@ -0,0 +1,148 @@
+package until.the.eternity.auctionrealtime.application.scheduler;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import until.the.eternity.auctionitem.domain.entity.AuctionRealtimeItem;
+import until.the.eternity.auctionrealtime.application.service.AuctionRealtimeService;
+import until.the.eternity.auctionrealtime.application.service.fetcher.AuctionRealtimeFetcher;
+import until.the.eternity.auctionrealtime.application.service.persister.AuctionRealtimePersister;
+import until.the.eternity.auctionrealtime.domain.service.fetcher.AuctionRealtimeFetcherPort.FetchResult;
+import until.the.eternity.common.enums.ItemCategory;
+
+import java.time.Instant;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 실시간 경매장 데이터 수집 스케줄러.
+ *
+ * 10분 간격으로 Nexon Open API /auction/list를 호출하여 현재 판매 중인 아이템 정보를 수집한다.
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class AuctionRealtimeScheduler {
+
+ private final AuctionRealtimeService service;
+ private final AuctionRealtimeFetcher fetcher;
+ private final AuctionRealtimePersister persister;
+
+ @Value("${openapi.auction-realtime.delay-ms:500}")
+ private long delayMs;
+
+ /** 10분 간격으로 실행: 0, 10, 20, 30, 40, 50분. */
+ @Scheduled(cron = "${openapi.auction-realtime.cron:0 0/10 * * * *}", zone = "Asia/Seoul")
+ public void fetchAndSaveAuctionRealtimeAll() {
+ log.info("[REALTIME] Starting Auction Realtime scheduler");
+
+ // ItemCategory를 topCategory별로 그룹화
+ Map> categoriesByTopCategory =
+ Arrays.stream(ItemCategory.values())
+ .collect(
+ Collectors.groupingBy(
+ ItemCategory::getTopCategory,
+ LinkedHashMap::new,
+ Collectors.toList()));
+
+ int totalSavedCount = 0;
+ int totalFailedCount = 0;
+ List topCategories = new ArrayList<>(categoriesByTopCategory.keySet());
+
+ 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);
+
+ for (int subIndex = 0; subIndex < subCategories.size(); subIndex++) {
+ ItemCategory category = subCategories.get(subIndex);
+ try {
+ log.debug("[REALTIME] Processing category [{}]", category.getSubCategory());
+
+ // API 호출 및 데이터 수집
+ FetchResult fetchResult = fetcher.fetch(category);
+
+ if (fetchResult.items().isEmpty()) {
+ log.debug("[REALTIME] [{}] No data fetched", category.getSubCategory());
+ continue;
+ }
+
+ // 엔티티 변환 및 필터링
+ List entities =
+ persister.prepareEntities(
+ fetchResult.items(), category, fetchResult.latestDate());
+
+ if (entities.isEmpty()) {
+ continue;
+ }
+
+ // 동일 날짜 데이터가 있으면 삭제 후 저장
+ if (fetchResult.hasEqualDate() && fetchResult.latestDate() != null) {
+ service.deleteAndSave(category, fetchResult.latestDate(), entities);
+ } else {
+ newEntities.addAll(entities);
+ }
+
+ // 마지막 서브 카테고리가 아닌 경우에만 delay 적용
+ if (subIndex < subCategories.size() - 1) {
+ log.debug(
+ "[REALTIME] Waiting {}ms before processing next category", delayMs);
+ Thread.sleep(delayMs);
+ }
+ } catch (InterruptedException e) {
+ log.error(
+ "[REALTIME] Thread interrupted during delay for category [{}]",
+ category.getSubCategory(),
+ e);
+ Thread.currentThread().interrupt();
+ break;
+ } catch (Exception e) {
+ log.error(
+ "[REALTIME] Error during processing category [{}]",
+ category.getSubCategory(),
+ e);
+ totalFailedCount++;
+ }
+ }
+
+ // 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 {
+ log.debug(
+ "[REALTIME] Waiting {}ms before processing next top category", delayMs);
+ Thread.sleep(delayMs);
+ } catch (InterruptedException e) {
+ log.error(
+ "[REALTIME] Thread interrupted during delay for top category [{}]",
+ topCategory,
+ e);
+ Thread.currentThread().interrupt();
+ break;
+ }
+ }
+ }
+
+ // 만료된 아이템 삭제
+ int deletedExpired = service.deleteExpiredItems(Instant.now());
+
+ log.info(
+ "[REALTIME] Auction Realtime scheduler completed. Total saved: {}, Failed categories: {}, Expired deleted: {}",
+ totalSavedCount,
+ totalFailedCount,
+ deletedExpired);
+ }
+}
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
new file mode 100644
index 00000000..1042d372
--- /dev/null
+++ b/src/main/java/until/the/eternity/auctionrealtime/application/service/AuctionRealtimeService.java
@@ -0,0 +1,119 @@
+package until.the.eternity.auctionrealtime.application.service;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+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.auctionitem.domain.entity.AuctionRealtimeItem;
+import until.the.eternity.auctionrealtime.domain.mapper.AuctionRealtimeMapper;
+import until.the.eternity.auctionrealtime.domain.repository.AuctionRealtimeItemRepositoryPort;
+import until.the.eternity.auctionrealtime.interfaces.rest.dto.request.AuctionRealtimeSearchRequest;
+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.enums.ItemCategory;
+import until.the.eternity.common.response.PageResponseDto;
+
+import java.time.Instant;
+import java.util.List;
+
+/** 실시간 경매장 데이터 Service. */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AuctionRealtimeService {
+
+ private final AuctionRealtimeItemRepositoryPort repository;
+ private final AuctionRealtimeMapper mapper;
+
+ /**
+ * 실시간 경매장 아이템을 검색한다.
+ *
+ * @param requestDto 검색 조건
+ * @param pageable 페이지 정보
+ * @return 검색 결과
+ */
+ @Transactional(readOnly = true)
+ public PageResponseDto> search(
+ AuctionRealtimeSearchRequest requestDto, Pageable pageable) {
+
+ Page page = repository.search(requestDto, pageable);
+ Page> dtoPage =
+ page.map(mapper::toDto);
+ return PageResponseDto.of(dtoPage);
+ }
+
+ /**
+ * ID로 실시간 경매장 아이템을 조회한다.
+ *
+ * @param id 아이템 ID
+ * @return 아이템 상세 정보
+ */
+ @Transactional(readOnly = true)
+ public AuctionRealtimeDetailResponse findByIdOrElseThrow(Long id) {
+ AuctionRealtimeItem item =
+ repository
+ .findById(id)
+ .orElseThrow(
+ () ->
+ new IllegalArgumentException(
+ "AuctionRealtimeItem not found: " + id));
+ return mapper.toDto(item);
+ }
+
+ /**
+ * 해당 카테고리 & 동일 date_auction_expire 레코드 삭제 후 새 엔티티들을 저장한다.
+ *
+ * @param category 아이템 카테고리
+ * @param dateAuctionExpire 삭제할 date_auction_expire
+ * @param entities 저장할 엔티티 리스트
+ */
+ @Transactional
+ public void deleteAndSave(
+ ItemCategory category, Instant dateAuctionExpire, List entities) {
+
+ // 동일 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",
+ category.getSubCategory(),
+ 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());
+ }
+
+ /**
+ * 만료된 아이템을 삭제한다.
+ *
+ * @param now 현재 시각
+ * @return 삭제된 레코드 수
+ */
+ @Transactional
+ public int deleteExpiredItems(Instant now) {
+ int deleted = repository.deleteExpiredItems(now);
+ log.info("[REALTIME] Deleted {} expired auction realtime items", deleted);
+ return deleted;
+ }
+}
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
new file mode 100644
index 00000000..b14b73b3
--- /dev/null
+++ b/src/main/java/until/the/eternity/auctionrealtime/application/service/fetcher/AuctionRealtimeFetcher.java
@@ -0,0 +1,101 @@
+package until.the.eternity.auctionrealtime.application.service.fetcher;
+
+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;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+
+/** 실시간 경매장 데이터 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 =
+ client.fetchAuctionList(category, cursor).block();
+
+ if (response == null || response.auctionItems() == null) {
+ log.warn(
+ "[REALTIME] [{}] response or its items is null, something is wrong with open api call",
+ category.getSubCategory());
+ break;
+ }
+
+ log.debug(
+ "[REALTIME] [{}] fetched '{}' data",
+ category.getSubCategory(),
+ response.auctionItems().size());
+
+ if (response.auctionItems().isEmpty()) {
+ log.debug("[REALTIME] [{}] fetched no data", category.getSubCategory());
+ 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);
+
+ cursor = response.nextCursor();
+
+ if (cursor == null || cursor.isEmpty()) {
+ log.debug(
+ "[REALTIME] [{}] response cursor is null, fetched end",
+ category.getSubCategory());
+ break;
+ }
+ }
+
+ return new FetchResult(result, hasEqualDate, latestDate);
+ }
+}
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
new file mode 100644
index 00000000..41731e35
--- /dev/null
+++ b/src/main/java/until/the/eternity/auctionrealtime/application/service/persister/AuctionRealtimePersister.java
@@ -0,0 +1,54 @@
+package until.the.eternity.auctionrealtime.application.service.persister;
+
+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;
+
+import java.time.Instant;
+import java.util.List;
+
+/** 실시간 경매장 데이터 Persister 구현체. */
+@Slf4j
+@RequiredArgsConstructor
+@Component
+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);
+
+ // Entity 변환
+ List entities = mapper.toEntityList(filtered, category);
+
+ // 아이템 옵션 링크 설정
+ entities.forEach(AuctionRealtimeItem::linkItemOptions);
+
+ if (entities.isEmpty()) {
+ log.info(
+ "[REALTIME] [{}] No new auction realtime items to save",
+ category.getSubCategory());
+ } else {
+ log.info(
+ "[REALTIME] [{}] Prepared {} auction realtime items for save",
+ category.getSubCategory(),
+ entities.size());
+ }
+
+ return entities;
+ }
+}
diff --git a/src/main/java/until/the/eternity/auctionrealtime/domain/mapper/AuctionRealtimeMapper.java b/src/main/java/until/the/eternity/auctionrealtime/domain/mapper/AuctionRealtimeMapper.java
new file mode 100644
index 00000000..f292d803
--- /dev/null
+++ b/src/main/java/until/the/eternity/auctionrealtime/domain/mapper/AuctionRealtimeMapper.java
@@ -0,0 +1,22 @@
+package until.the.eternity.auctionrealtime.domain.mapper;
+
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import until.the.eternity.auctionitem.domain.entity.AuctionRealtimeItem;
+import until.the.eternity.auctionitem.domain.entity.AuctionRealtimeItemOption;
+import until.the.eternity.auctionrealtime.interfaces.rest.dto.response.AuctionRealtimeDetailResponse;
+import until.the.eternity.auctionrealtime.interfaces.rest.dto.response.RealtimeItemOptionResponse;
+
+import java.util.List;
+
+/** AuctionRealtimeItem Entity to DTO mapper class. */
+@Mapper(componentModel = "spring")
+public interface AuctionRealtimeMapper {
+
+ @Mapping(target = "itemOptions", source = "auctionRealtimeItemOptions")
+ AuctionRealtimeDetailResponse toDto(AuctionRealtimeItem entity);
+
+ RealtimeItemOptionResponse toDto(AuctionRealtimeItemOption entity);
+
+ List toDtoList(List entityList);
+}
diff --git a/src/main/java/until/the/eternity/auctionrealtime/domain/mapper/OpenApiAuctionRealtimeMapper.java b/src/main/java/until/the/eternity/auctionrealtime/domain/mapper/OpenApiAuctionRealtimeMapper.java
new file mode 100644
index 00000000..8f65de6b
--- /dev/null
+++ b/src/main/java/until/the/eternity/auctionrealtime/domain/mapper/OpenApiAuctionRealtimeMapper.java
@@ -0,0 +1,49 @@
+package until.the.eternity.auctionrealtime.domain.mapper;
+
+import org.mapstruct.*;
+import until.the.eternity.auctionitem.domain.entity.AuctionRealtimeItem;
+import until.the.eternity.auctionrealtime.interfaces.external.dto.OpenApiAuctionRealtimeResponse;
+import until.the.eternity.common.enums.ItemCategory;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+
+/** OpenApiAuctionRealtimeResponse → AuctionRealtimeItem Entity 변환 Mapper. */
+@Mapper(componentModel = "spring", uses = OpenApiRealtimeItemOptionMapper.class)
+public interface OpenApiAuctionRealtimeMapper {
+
+ String UNKNOWN_ITEM_NAME = "(Unknown)";
+
+ @Named("toEntity(OpenApiAuctionRealtimeResponse, ItemCategory)")
+ @Mapping(
+ source = "dateAuctionExpire",
+ target = "dateAuctionExpire",
+ qualifiedByName = "utcToKst")
+ @Mapping(source = "itemOptions", target = "auctionRealtimeItemOptions")
+ @Mapping(target = "itemSubCategory", expression = "java(itemCategory.getSubCategory())")
+ @Mapping(target = "itemTopCategory", expression = "java(itemCategory.getTopCategory())")
+ @Mapping(target = "id", ignore = true)
+ @Mapping(target = "dateRegister", expression = "java(java.time.Instant.now())")
+ AuctionRealtimeItem toEntity(
+ OpenApiAuctionRealtimeResponse dto, @Context ItemCategory itemCategory);
+
+ @AfterMapping
+ default void afterMapping(
+ OpenApiAuctionRealtimeResponse dto, @MappingTarget AuctionRealtimeItem entity) {
+ // item_name이 "(Unknown)"인 경우 item_display_name으로 대체
+ if (UNKNOWN_ITEM_NAME.equals(entity.getItemName())) {
+ entity.setItemName(dto.itemDisplayName());
+ }
+ }
+
+ @IterableMapping(qualifiedByName = "toEntity(OpenApiAuctionRealtimeResponse, ItemCategory)")
+ List toEntityList(
+ List dtoList, @Context ItemCategory itemCategory);
+
+ @Named("utcToKst")
+ default Instant utcToKst(Instant utcTime) {
+ // API에서 받은 UTC 시간에 9시간을 더하여 KST로 변환
+ return utcTime != null ? utcTime.plus(9, ChronoUnit.HOURS) : null;
+ }
+}
diff --git a/src/main/java/until/the/eternity/auctionrealtime/domain/mapper/OpenApiRealtimeItemOptionMapper.java b/src/main/java/until/the/eternity/auctionrealtime/domain/mapper/OpenApiRealtimeItemOptionMapper.java
new file mode 100644
index 00000000..ae30e87f
--- /dev/null
+++ b/src/main/java/until/the/eternity/auctionrealtime/domain/mapper/OpenApiRealtimeItemOptionMapper.java
@@ -0,0 +1,59 @@
+package until.the.eternity.auctionrealtime.domain.mapper;
+
+import org.mapstruct.AfterMapping;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import org.mapstruct.MappingTarget;
+import until.the.eternity.auctionitem.domain.entity.AuctionRealtimeItemOption;
+import until.the.eternity.auctionitemoption.domain.dto.external.OpenApiAuctionItemOptionResponse;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/** OpenApiAuctionItemOptionResponse → AuctionRealtimeItemOption Entity 변환 Mapper. */
+@Mapper(componentModel = "spring")
+public interface OpenApiRealtimeItemOptionMapper {
+
+ String SEGONG_OPTION_TYPE = "세공 옵션";
+
+ // 패턴 1: "스킬명 숫자 레벨" 또는 "스킬명 숫자레벨" 형식 (예: "천옷만들기 품질 보너스 3 레벨", "지력 2레벨")
+ // 그룹1: 스킬명, 그룹2: "숫자 레벨" 또는 "숫자레벨" (option_desc), 그룹3: 숫자 (option_value2)
+ Pattern PATTERN_LEVEL_SUFFIX = Pattern.compile("^(.+?) ((\\d+) ?레벨)$");
+
+ // 패턴 2: "스킬명(숫자레벨:효과)" 형식 (예: "매그넘 샷 대미지(20레벨:200 % 증가)")
+ // 그룹1: 스킬명, 그룹2: 괄호 전체 (option_desc), 그룹3: 숫자 (option_value2)
+ Pattern PATTERN_LEVEL_PARENTHESIS = Pattern.compile("^(.+?)(\\((\\d+)레벨:.+\\))$");
+
+ @Mapping(target = "id", ignore = true) // PK 자동 생성
+ @Mapping(target = "auctionRealtimeItem", ignore = true)
+ AuctionRealtimeItemOption toEntity(OpenApiAuctionItemOptionResponse itemOption);
+
+ @AfterMapping
+ default void afterMapping(
+ OpenApiAuctionItemOptionResponse dto, @MappingTarget AuctionRealtimeItemOption entity) {
+ // option_type이 "세공 옵션"인 경우에만 파싱 수행
+ if (!SEGONG_OPTION_TYPE.equals(entity.getOptionType()) || entity.getOptionValue() == null) {
+ return;
+ }
+
+ String originalValue = entity.getOptionValue();
+
+ // 패턴 1: "스킬명 숫자 레벨" 또는 "스킬명 숫자레벨" 형식
+ Matcher matcher1 = PATTERN_LEVEL_SUFFIX.matcher(originalValue);
+ if (matcher1.matches()) {
+ entity.setOptionValue(matcher1.group(1));
+ entity.setOptionValue2(matcher1.group(3));
+ entity.setOptionDesc(matcher1.group(2));
+ return;
+ }
+
+ // 패턴 2: "스킬명(숫자레벨:효과)" 형식
+ Matcher matcher2 = PATTERN_LEVEL_PARENTHESIS.matcher(originalValue);
+ if (matcher2.matches()) {
+ entity.setOptionValue(matcher2.group(1));
+ entity.setOptionValue2(matcher2.group(3));
+ entity.setOptionDesc(matcher2.group(2));
+ }
+ // 두 패턴 모두 매칭되지 않으면 원본 값 유지
+ }
+}
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
new file mode 100644
index 00000000..d11c7408
--- /dev/null
+++ b/src/main/java/until/the/eternity/auctionrealtime/domain/repository/AuctionRealtimeItemRepositoryPort.java
@@ -0,0 +1,64 @@
+package until.the.eternity.auctionrealtime.domain.repository;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import until.the.eternity.auctionitem.domain.entity.AuctionRealtimeItem;
+import until.the.eternity.auctionrealtime.interfaces.rest.dto.request.AuctionRealtimeSearchRequest;
+import until.the.eternity.common.enums.ItemCategory;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+
+/** AuctionRealtimeItem Repository Port (Hexagonal Architecture). */
+public interface AuctionRealtimeItemRepositoryPort {
+
+ /**
+ * 실시간 경매장 아이템을 검색한다.
+ *
+ * @param condition 검색 조건
+ * @param pageable 페이지 정보
+ * @return 검색 결과
+ */
+ Page search(AuctionRealtimeSearchRequest condition, Pageable pageable);
+
+ /**
+ * ID로 실시간 경매장 아이템을 조회한다.
+ *
+ * @param id 아이템 ID
+ * @return 아이템 (없으면 Optional.empty())
+ */
+ Optional findById(Long id);
+
+ /**
+ * 해당 subcategory의 최신 date_auction_expire를 조회한다.
+ *
+ * @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);
+
+ /**
+ * date_auction_expire가 현재 시각보다 이전인 모든 레코드를 삭제한다.
+ *
+ * @param now 현재 시각
+ * @return 삭제된 레코드 수
+ */
+ int deleteExpiredItems(Instant now);
+
+ /**
+ * 엔티티들을 일괄 저장한다.
+ *
+ * @param entities 저장할 엔티티 리스트
+ */
+ void saveAll(List entities);
+}
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
new file mode 100644
index 00000000..09637f55
--- /dev/null
+++ b/src/main/java/until/the/eternity/auctionrealtime/domain/service/AuctionRealtimeDuplicateChecker.java
@@ -0,0 +1,132 @@
+package until.the.eternity.auctionrealtime.domain.service;
+
+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;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 실시간 경매장 데이터의 중복 체크 로직.
+ *
+ * 중복 판단 기준:
+ *
+ *
+ * - date_auction_expire < latestDate → 중복 (과거 데이터)
+ *
- date_auction_expire == latestDate → 해당 시간대 데이터 삭제 후 재저장 필요
+ *
- date_auction_expire > latestDate → 신규 데이터
+ *
+ */
+@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
new file mode 100644
index 00000000..da3a9c02
--- /dev/null
+++ b/src/main/java/until/the/eternity/auctionrealtime/domain/service/fetcher/AuctionRealtimeFetcherPort.java
@@ -0,0 +1,34 @@
+package until.the.eternity.auctionrealtime.domain.service.fetcher;
+
+import until.the.eternity.auctionrealtime.interfaces.external.dto.OpenApiAuctionRealtimeResponse;
+import until.the.eternity.common.enums.ItemCategory;
+
+import java.time.Instant;
+import java.util.List;
+
+/** 실시간 경매장 데이터 Fetcher Port. */
+public interface AuctionRealtimeFetcherPort {
+
+ /**
+ * 페칭 결과.
+ *
+ * @param items 수집된 아이템 리스트
+ * @param hasEqualDate 동일 날짜 데이터 존재 여부 (삭제 후 재저장 필요)
+ * @param latestDate DB의 최신 date_auction_expire
+ */
+ record FetchResult(
+ List items, boolean hasEqualDate, Instant latestDate) {
+
+ public static FetchResult empty() {
+ return new FetchResult(List.of(), false, null);
+ }
+ }
+
+ /**
+ * 해당 카테고리의 실시간 경매장 데이터를 수집한다.
+ *
+ * @param category 아이템 카테고리
+ * @return 페칭 결과
+ */
+ FetchResult fetch(ItemCategory category);
+}
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
new file mode 100644
index 00000000..cadc56f1
--- /dev/null
+++ b/src/main/java/until/the/eternity/auctionrealtime/domain/service/persister/AuctionRealtimePersisterPort.java
@@ -0,0 +1,25 @@
+package until.the.eternity.auctionrealtime.domain.service.persister;
+
+import until.the.eternity.auctionitem.domain.entity.AuctionRealtimeItem;
+import until.the.eternity.auctionrealtime.interfaces.external.dto.OpenApiAuctionRealtimeResponse;
+import until.the.eternity.common.enums.ItemCategory;
+
+import java.time.Instant;
+import java.util.List;
+
+/** 실시간 경매장 데이터 Persister Port. */
+public interface AuctionRealtimePersisterPort {
+
+ /**
+ * 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);
+}
diff --git a/src/main/java/until/the/eternity/auctionrealtime/infrastructure/client/AuctionRealtimeClient.java b/src/main/java/until/the/eternity/auctionrealtime/infrastructure/client/AuctionRealtimeClient.java
new file mode 100644
index 00000000..5e92495a
--- /dev/null
+++ b/src/main/java/until/the/eternity/auctionrealtime/infrastructure/client/AuctionRealtimeClient.java
@@ -0,0 +1,59 @@
+package until.the.eternity.auctionrealtime.infrastructure.client;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import org.springframework.web.reactive.function.client.WebClient;
+import reactor.core.publisher.Mono;
+import until.the.eternity.auctionrealtime.interfaces.external.dto.OpenApiAuctionRealtimeListResponse;
+import until.the.eternity.common.enums.ItemCategory;
+
+/** Nexon Open API /auction/list 엔드포인트 호출 클라이언트. */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class AuctionRealtimeClient {
+
+ private final WebClient openApiWebClient;
+
+ /**
+ * 카테고리·커서 기반 경매장 현재 판매 목록 조회.
+ *
+ * @param category 조회할 카테고리
+ * @param cursor 다음 페이지 커서(null 가능)
+ * @return 응답 DTO를 담은 Mono, 호출 실패 시 Mono.empty()
+ */
+ public Mono fetchAuctionList(
+ ItemCategory category, String cursor) {
+
+ log.info(
+ "[REALTIME] [{}] Calling Nexon Open API Auction List API with cursor='{}'",
+ category.getSubCategory(),
+ cursor == null ? "" : cursor);
+
+ return openApiWebClient
+ .get()
+ .uri(
+ uriBuilder -> {
+ uriBuilder
+ .path("/auction/list")
+ .queryParam("auction_item_category", category.getSubCategory());
+ if (cursor != null && !cursor.isEmpty()) {
+ uriBuilder.queryParam("cursor", cursor);
+ }
+ return uriBuilder.build();
+ })
+ .retrieve()
+ .bodyToMono(OpenApiAuctionRealtimeListResponse.class)
+ .onErrorResume(
+ throwable -> {
+ log.warn(
+ "[REALTIME] [{}] Failed to fetch Nexon Open API Auction List API with cursor='{}': error='{}', message='{}'",
+ category.getSubCategory(),
+ cursor,
+ throwable.toString(),
+ throwable.getMessage());
+ return Mono.empty();
+ });
+ }
+}
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
new file mode 100644
index 00000000..d247cd44
--- /dev/null
+++ b/src/main/java/until/the/eternity/auctionrealtime/infrastructure/persistence/AuctionRealtimeItemRepository.java
@@ -0,0 +1,35 @@
+package until.the.eternity.auctionrealtime.infrastructure.persistence;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import until.the.eternity.auctionitem.domain.entity.AuctionRealtimeItem;
+
+import java.time.Instant;
+import java.util.Optional;
+
+/** 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);
+
+ @Modifying
+ @Query("DELETE FROM AuctionRealtimeItem a WHERE a.dateAuctionExpire < :now")
+ int deleteExpiredItems(@Param("now") Instant 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
new file mode 100644
index 00000000..3d88f07b
--- /dev/null
+++ b/src/main/java/until/the/eternity/auctionrealtime/infrastructure/persistence/AuctionRealtimeItemRepositoryPortImpl.java
@@ -0,0 +1,79 @@
+package until.the.eternity.auctionrealtime.infrastructure.persistence;
+
+import jakarta.persistence.EntityManager;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Repository;
+import until.the.eternity.auctionitem.domain.entity.AuctionRealtimeItem;
+import until.the.eternity.auctionrealtime.domain.repository.AuctionRealtimeItemRepositoryPort;
+import until.the.eternity.auctionrealtime.interfaces.rest.dto.request.AuctionRealtimeSearchRequest;
+import until.the.eternity.common.enums.ItemCategory;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+
+/** AuctionRealtimeItemRepositoryPort 구현체. */
+@Slf4j
+@Repository
+@RequiredArgsConstructor
+public class AuctionRealtimeItemRepositoryPortImpl implements AuctionRealtimeItemRepositoryPort {
+
+ private static final int BATCH_SIZE = 500;
+
+ private final AuctionRealtimeItemRepository jpaRepository;
+ private final AuctionRealtimeQueryDslRepository queryDslRepository;
+ private final EntityManager entityManager;
+
+ @Override
+ public Page search(
+ AuctionRealtimeSearchRequest condition, Pageable pageable) {
+ return queryDslRepository.search(condition, pageable);
+ }
+
+ @Override
+ public Optional findById(Long id) {
+ return jpaRepository.findById(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);
+ }
+
+ @Override
+ public int deleteExpiredItems(Instant now) {
+ return jpaRepository.deleteExpiredItems(now);
+ }
+
+ @Override
+ public void saveAll(List entities) {
+ if (entities == null || entities.isEmpty()) {
+ return;
+ }
+
+ for (int i = 0; i < entities.size(); i++) {
+ entityManager.persist(entities.get(i));
+
+ if ((i + 1) % BATCH_SIZE == 0) {
+ entityManager.flush();
+ entityManager.clear();
+ }
+ }
+
+ // 마지막 배치 처리
+ entityManager.flush();
+ entityManager.clear();
+
+ log.debug("[REALTIME] Saved {} auction realtime items", entities.size());
+ }
+}
diff --git a/src/main/java/until/the/eternity/auctionrealtime/infrastructure/persistence/AuctionRealtimeQueryDslRepository.java b/src/main/java/until/the/eternity/auctionrealtime/infrastructure/persistence/AuctionRealtimeQueryDslRepository.java
new file mode 100644
index 00000000..d3e25e59
--- /dev/null
+++ b/src/main/java/until/the/eternity/auctionrealtime/infrastructure/persistence/AuctionRealtimeQueryDslRepository.java
@@ -0,0 +1,423 @@
+package until.the.eternity.auctionrealtime.infrastructure.persistence;
+
+import com.querydsl.core.BooleanBuilder;
+import com.querydsl.core.types.Order;
+import com.querydsl.core.types.OrderSpecifier;
+import com.querydsl.core.types.dsl.BooleanExpression;
+import com.querydsl.core.types.dsl.Expressions;
+import com.querydsl.core.types.dsl.NumberTemplate;
+import com.querydsl.jpa.JPAExpressions;
+import com.querydsl.jpa.impl.JPAQueryFactory;
+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;
+import until.the.eternity.auctionhistory.interfaces.rest.dto.enums.SearchStandard;
+import until.the.eternity.auctionhistory.interfaces.rest.dto.request.ItemOptionSearchRequest;
+import until.the.eternity.auctionhistory.interfaces.rest.dto.request.PriceSearchRequest;
+import until.the.eternity.auctionitem.domain.entity.AuctionRealtimeItem;
+import until.the.eternity.auctionitem.domain.entity.QAuctionRealtimeItem;
+import until.the.eternity.auctionitem.domain.entity.QAuctionRealtimeItemOption;
+import until.the.eternity.auctionrealtime.interfaces.rest.dto.request.AuctionRealtimeSearchRequest;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Component
+@RequiredArgsConstructor
+class AuctionRealtimeQueryDslRepository {
+
+ private final JPAQueryFactory queryFactory;
+
+ /** 옵션 조건 빌드 결과 (조건 BooleanBuilder + 추가된 조건 개수) */
+ record OptionConditionResult(BooleanBuilder builder, int count) {}
+
+ /** 실시간 경매장 검색 (옵션 조건 포함) */
+ public Page search(
+ AuctionRealtimeSearchRequest condition, Pageable pageable) {
+ QAuctionRealtimeItem ar = QAuctionRealtimeItem.auctionRealtimeItem;
+ QAuctionRealtimeItemOption aro = QAuctionRealtimeItemOption.auctionRealtimeItemOption;
+
+ // 1단계: 기본 조건 빌드
+ BooleanBuilder itemBuilder = buildItemPredicate(condition, ar);
+
+ // 2단계: 옵션 조건이 있으면 서브쿼리 추가
+ if (condition.itemOptionSearchRequest() != null) {
+ QAuctionRealtimeItemOption subOption = new QAuctionRealtimeItemOption("subOption");
+ OptionConditionResult optionResult =
+ buildItemOptionConditions(condition.itemOptionSearchRequest(), subOption);
+
+ if (optionResult.builder().hasValue() && optionResult.count() > 0) {
+ var subQuery =
+ JPAExpressions.select(subOption.auctionRealtimeItem.id)
+ .from(subOption)
+ .where(optionResult.builder())
+ .groupBy(subOption.auctionRealtimeItem.id)
+ .having(subOption.count().eq((long) optionResult.count()));
+
+ itemBuilder.and(ar.id.in(subQuery));
+ }
+ }
+
+ // 3단계: 정렬 조건 빌드
+ List> orderSpecifiers = buildOrderSpecifiers(pageable, ar);
+
+ // 4단계: Deferred Join 패턴 적용
+ // 4-1단계: ID만 먼저 조회
+ List ids =
+ queryFactory
+ .select(ar.id)
+ .from(ar)
+ .where(itemBuilder)
+ .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로 상세 조회
+ List content =
+ queryFactory
+ .selectFrom(ar)
+ .leftJoin(ar.auctionRealtimeItemOptions, aro)
+ .fetchJoin()
+ .where(ar.id.in(ids))
+ .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0]))
+ .distinct()
+ .fetch();
+
+ // Count 쿼리
+ Long total = queryFactory.select(ar.countDistinct()).from(ar).where(itemBuilder).fetchOne();
+
+ return new PageImpl<>(content, pageable, total == null ? 0L : total);
+ }
+
+ /** 기본 조건 빌드 (카테고리, 아이템명, 가격) */
+ private BooleanBuilder buildItemPredicate(
+ AuctionRealtimeSearchRequest c, QAuctionRealtimeItem ar) {
+ BooleanBuilder builder = new BooleanBuilder();
+
+ if (c.itemTopCategory() != null && !c.itemTopCategory().isBlank()) {
+ builder.and(ar.itemTopCategory.eq(c.itemTopCategory()));
+ }
+ if (c.itemSubCategory() != null && !c.itemSubCategory().isBlank()) {
+ builder.and(ar.itemSubCategory.eq(c.itemSubCategory()));
+ }
+ if (c.itemName() != null && !c.itemName().isBlank()) {
+ builder.and(ar.itemName.containsIgnoreCase(c.itemName()));
+ }
+
+ if (c.priceSearchRequest() != null) {
+ PriceSearchRequest price = c.priceSearchRequest();
+ if (price.priceFrom() != null) {
+ builder.and(ar.auctionPricePerUnit.goe(price.priceFrom()));
+ }
+ if (price.priceTo() != null) {
+ builder.and(ar.auctionPricePerUnit.loe(price.priceTo()));
+ }
+ }
+
+ return builder;
+ }
+
+ /** 옵션 검색 조건 빌드 (서브쿼리용) */
+ private OptionConditionResult buildItemOptionConditions(
+ ItemOptionSearchRequest opt, QAuctionRealtimeItemOption aro) {
+ BooleanBuilder builder = new BooleanBuilder();
+ int conditionCount = 0;
+ boolean ergConditionAdded = false;
+
+ // 1. Balance (밸런스)
+ if (opt.balanceSearch() != null && opt.balanceSearch().balance() != null) {
+ builder.or(
+ buildOptionCondition(
+ aro,
+ "밸런스",
+ opt.balanceSearch().balance(),
+ opt.balanceSearch().balanceStandard()));
+ conditionCount++;
+ }
+
+ // 2. Critical (크리티컬)
+ if (opt.criticalSearch() != null && opt.criticalSearch().critical() != null) {
+ builder.or(
+ buildOptionCondition(
+ aro,
+ "크리티컬",
+ opt.criticalSearch().critical(),
+ opt.criticalSearch().criticalStandard()));
+ conditionCount++;
+ }
+
+ // 3. Defense (방어력)
+ if (opt.defenseSearch() != null && opt.defenseSearch().defense() != null) {
+ builder.or(
+ buildOptionCondition(
+ aro,
+ "방어력",
+ opt.defenseSearch().defense(),
+ opt.defenseSearch().defenseStandard()));
+ conditionCount++;
+ }
+
+ // 4. Erg (에르그) - 범위 검색
+ if (opt.ergSearch() != null) {
+ BooleanExpression ergTypeCondition = aro.optionType.eq("에르그");
+ BooleanExpression ergValueCondition = null;
+
+ if (opt.ergSearch().ergFrom() != null && opt.ergSearch().ergTo() != null) {
+ ergValueCondition =
+ castOptionValueToInt(aro)
+ .between(opt.ergSearch().ergFrom(), opt.ergSearch().ergTo());
+ } else if (opt.ergSearch().ergFrom() != null) {
+ ergValueCondition = castOptionValueToInt(aro).goe(opt.ergSearch().ergFrom());
+ } else if (opt.ergSearch().ergTo() != null) {
+ ergValueCondition = castOptionValueToInt(aro).loe(opt.ergSearch().ergTo());
+ }
+
+ if (ergValueCondition != null) {
+ BooleanExpression combined = ergTypeCondition.and(ergValueCondition);
+ builder.or(Expressions.booleanTemplate("({0})", combined));
+ if (!ergConditionAdded) {
+ conditionCount++;
+ ergConditionAdded = true;
+ }
+ }
+ }
+
+ // 5. ErgRank (에르그 등급)
+ if (opt.ergRankSearch() != null && opt.ergRankSearch().ergRank() != null) {
+ BooleanExpression combined =
+ aro.optionType.eq("에르그").and(aro.optionValue.eq(opt.ergRankSearch().ergRank()));
+ builder.or(Expressions.booleanTemplate("({0})", combined));
+ if (!ergConditionAdded) {
+ conditionCount++;
+ ergConditionAdded = true;
+ }
+ }
+
+ // 6. MagicDefense (마법 방어력)
+ if (opt.magicDefenseSearch() != null && opt.magicDefenseSearch().magicDefense() != null) {
+ builder.or(
+ buildOptionCondition(
+ aro,
+ "마법 방어력",
+ opt.magicDefenseSearch().magicDefense(),
+ opt.magicDefenseSearch().magicDefenseStandard()));
+ conditionCount++;
+ }
+
+ // 7. MagicProtect (마법 보호)
+ if (opt.magicProtectSearch() != null && opt.magicProtectSearch().magicProtect() != null) {
+ builder.or(
+ buildOptionCondition(
+ aro,
+ "마법 보호",
+ opt.magicProtectSearch().magicProtect(),
+ opt.magicProtectSearch().magicProtectStandard()));
+ conditionCount++;
+ }
+
+ // 8. MaxAttack (공격) - 범위 검색
+ if (opt.maxAttackSearch() != null) {
+ BooleanExpression attackTypeCondition = aro.optionType.eq("공격");
+ BooleanExpression attackValueCondition = null;
+
+ if (opt.maxAttackSearch().maxAttackFrom() != null
+ && opt.maxAttackSearch().maxAttackTo() != null) {
+ attackValueCondition =
+ castOptionValueToInt(aro)
+ .between(
+ opt.maxAttackSearch().maxAttackFrom(),
+ opt.maxAttackSearch().maxAttackTo());
+ } else if (opt.maxAttackSearch().maxAttackFrom() != null) {
+ attackValueCondition =
+ castOptionValueToInt(aro).goe(opt.maxAttackSearch().maxAttackFrom());
+ } else if (opt.maxAttackSearch().maxAttackTo() != null) {
+ attackValueCondition =
+ castOptionValueToInt(aro).loe(opt.maxAttackSearch().maxAttackTo());
+ }
+
+ if (attackValueCondition != null) {
+ BooleanExpression combined = attackTypeCondition.and(attackValueCondition);
+ builder.or(Expressions.booleanTemplate("({0})", combined));
+ conditionCount++;
+ }
+ }
+
+ // 9. MaximumDurability (내구력)
+ if (opt.maximumDurabilitySearch() != null
+ && opt.maximumDurabilitySearch().maximumDurability() != null) {
+ builder.or(
+ buildOptionCondition(
+ aro,
+ "내구력",
+ opt.maximumDurabilitySearch().maximumDurability(),
+ opt.maximumDurabilitySearch().maximumDurabilityStandard()));
+ conditionCount++;
+ }
+
+ // 10. MaxInjuryRate (부상률) - 범위 검색
+ if (opt.maxInjuryRateSearch() != null) {
+ BooleanExpression injuryTypeCondition = aro.optionType.eq("부상률");
+ BooleanExpression injuryValueCondition = null;
+
+ if (opt.maxInjuryRateSearch().maxInjuryRateFrom() != null
+ && opt.maxInjuryRateSearch().maxInjuryRateTo() != null) {
+ injuryValueCondition =
+ castOptionValueToInt(aro)
+ .between(
+ opt.maxInjuryRateSearch().maxInjuryRateFrom(),
+ opt.maxInjuryRateSearch().maxInjuryRateTo());
+ } else if (opt.maxInjuryRateSearch().maxInjuryRateFrom() != null) {
+ injuryValueCondition =
+ castOptionValueToInt(aro)
+ .goe(opt.maxInjuryRateSearch().maxInjuryRateFrom());
+ } else if (opt.maxInjuryRateSearch().maxInjuryRateTo() != null) {
+ injuryValueCondition =
+ castOptionValueToInt(aro).loe(opt.maxInjuryRateSearch().maxInjuryRateTo());
+ }
+
+ if (injuryValueCondition != null) {
+ BooleanExpression combined = injuryTypeCondition.and(injuryValueCondition);
+ builder.or(Expressions.booleanTemplate("({0})", combined));
+ conditionCount++;
+ }
+ }
+
+ // 11. Proficiency (숙련)
+ if (opt.proficiencySearch() != null && opt.proficiencySearch().proficiency() != null) {
+ builder.or(
+ buildOptionCondition(
+ aro,
+ "숙련",
+ opt.proficiencySearch().proficiency(),
+ opt.proficiencySearch().proficiencyStandard()));
+ conditionCount++;
+ }
+
+ // 12. Protect (보호)
+ if (opt.protectSearch() != null && opt.protectSearch().protect() != null) {
+ builder.or(
+ buildOptionCondition(
+ aro,
+ "보호",
+ opt.protectSearch().protect(),
+ opt.protectSearch().protectStandard()));
+ conditionCount++;
+ }
+
+ // 13. RemainingTransactionCount (남은 거래 횟수)
+ if (opt.remainingTransactionCountSearch() != null
+ && opt.remainingTransactionCountSearch().remainingTransactionCount() != null) {
+ builder.or(
+ buildOptionCondition(
+ aro,
+ "남은 거래 횟수",
+ opt.remainingTransactionCountSearch().remainingTransactionCount(),
+ opt.remainingTransactionCountSearch()
+ .remainingTransactionCountStandard()));
+ conditionCount++;
+ }
+
+ // 14. RemainingUnsealCount (남은 전용 해제 가능 횟수)
+ if (opt.remainingUnsealCountSearch() != null
+ && opt.remainingUnsealCountSearch().remainingUnsealCount() != null) {
+ builder.or(
+ buildOptionCondition(
+ aro,
+ "남은 전용 해제 가능 횟수",
+ opt.remainingUnsealCountSearch().remainingUnsealCount(),
+ opt.remainingUnsealCountSearch().remainingUnsealCountStandard()));
+ conditionCount++;
+ }
+
+ // 15. RemainingUseCount (남은 사용 횟수)
+ if (opt.remainingUseCountSearch() != null
+ && opt.remainingUseCountSearch().remainingUseCount() != null) {
+ builder.or(
+ buildOptionCondition(
+ aro,
+ "남은 사용 횟수",
+ opt.remainingUseCountSearch().remainingUseCount(),
+ opt.remainingUseCountSearch().remainingUseCountStandard()));
+ conditionCount++;
+ }
+
+ // 16. WearingRestrictions (착용 제한)
+ if (opt.wearingRestrictionsSearch() != null
+ && opt.wearingRestrictionsSearch().wearingRestrictions() != null) {
+ BooleanExpression condition =
+ aro.optionValue.contains(opt.wearingRestrictionsSearch().wearingRestrictions());
+ builder.or(Expressions.booleanTemplate("({0})", condition));
+ conditionCount++;
+ }
+
+ return new OptionConditionResult(builder, conditionCount);
+ }
+
+ /** 옵션 조건 빌드 헬퍼 */
+ private BooleanExpression buildOptionCondition(
+ QAuctionRealtimeItemOption aro,
+ String optionType,
+ Integer value,
+ SearchStandard standard) {
+ BooleanExpression optionTypeCondition = aro.optionType.eq(optionType);
+ NumberTemplate numValue = castOptionValueToInt(aro);
+
+ BooleanExpression valueCondition;
+ if (standard == null || standard.isEqual()) {
+ valueCondition = numValue.eq(value);
+ } else if (standard.isUp()) {
+ valueCondition = numValue.goe(value);
+ } else if (standard.isDown()) {
+ valueCondition = numValue.loe(value);
+ } else {
+ valueCondition = numValue.eq(value);
+ }
+
+ BooleanExpression combined = optionTypeCondition.and(valueCondition);
+ return Expressions.booleanTemplate("({0})", combined);
+ }
+
+ /** option_value2 또는 option_value를 Integer로 변환 */
+ private NumberTemplate castOptionValueToInt(QAuctionRealtimeItemOption aro) {
+ return Expressions.numberTemplate(
+ Integer.class, "COALESCE({0}, {1}, 0)", aro.optionValue2, aro.optionValue);
+ }
+
+ /** Pageable의 Sort를 QueryDSL OrderSpecifier로 변환 */
+ private List> buildOrderSpecifiers(
+ Pageable pageable, QAuctionRealtimeItem ar) {
+ List> orders = new ArrayList<>();
+
+ if (pageable.getSort().isSorted()) {
+ for (Sort.Order order : pageable.getSort()) {
+ Order direction = order.isAscending() ? Order.ASC : Order.DESC;
+ String property = order.getProperty();
+
+ OrderSpecifier> orderSpecifier =
+ switch (property) {
+ case "dateAuctionExpire" ->
+ new OrderSpecifier<>(direction, ar.dateAuctionExpire);
+ case "dateRegister" -> new OrderSpecifier<>(direction, ar.dateRegister);
+ case "auctionPricePerUnit" ->
+ new OrderSpecifier<>(direction, ar.auctionPricePerUnit);
+ case "itemName" -> new OrderSpecifier<>(direction, ar.itemName);
+ default -> new OrderSpecifier<>(Order.ASC, ar.dateAuctionExpire);
+ };
+
+ orders.add(orderSpecifier);
+ }
+ } else {
+ orders.add(new OrderSpecifier<>(Order.ASC, ar.dateAuctionExpire));
+ }
+
+ return orders;
+ }
+}
diff --git a/src/main/java/until/the/eternity/auctionrealtime/interfaces/external/dto/OpenApiAuctionRealtimeListResponse.java b/src/main/java/until/the/eternity/auctionrealtime/interfaces/external/dto/OpenApiAuctionRealtimeListResponse.java
new file mode 100644
index 00000000..114de80e
--- /dev/null
+++ b/src/main/java/until/the/eternity/auctionrealtime/interfaces/external/dto/OpenApiAuctionRealtimeListResponse.java
@@ -0,0 +1,10 @@
+package until.the.eternity.auctionrealtime.interfaces.external.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.util.List;
+
+/** Nexon Open API /auction/list 응답 리스트 DTO. */
+public record OpenApiAuctionRealtimeListResponse(
+ @JsonProperty("auction_item") List auctionItems,
+ @JsonProperty("next_cursor") String nextCursor) {}
diff --git a/src/main/java/until/the/eternity/auctionrealtime/interfaces/external/dto/OpenApiAuctionRealtimeResponse.java b/src/main/java/until/the/eternity/auctionrealtime/interfaces/external/dto/OpenApiAuctionRealtimeResponse.java
new file mode 100644
index 00000000..2b337bfe
--- /dev/null
+++ b/src/main/java/until/the/eternity/auctionrealtime/interfaces/external/dto/OpenApiAuctionRealtimeResponse.java
@@ -0,0 +1,19 @@
+package until.the.eternity.auctionrealtime.interfaces.external.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import until.the.eternity.auctionitemoption.domain.dto.external.OpenApiAuctionItemOptionResponse;
+
+import java.time.Instant;
+import java.util.List;
+
+/** Nexon Open API /auction/list 응답 DTO. 현재 경매장에서 판매 중인 아이템 정보. */
+public record OpenApiAuctionRealtimeResponse(
+ @JsonProperty("item_name") String itemName,
+ @JsonProperty("item_display_name") String itemDisplayName,
+ @JsonProperty("item_count") long itemCount,
+ @JsonProperty("auction_price_per_unit") long auctionPricePerUnit,
+ @JsonProperty("date_auction_expire")
+ @JsonFormat(shape = JsonFormat.Shape.STRING, timezone = "Asia/Seoul")
+ Instant dateAuctionExpire,
+ @JsonProperty("item_option") List itemOptions) {}
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
new file mode 100644
index 00000000..3742f819
--- /dev/null
+++ b/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/controller/AuctionRealtimeController.java
@@ -0,0 +1,46 @@
+package until.the.eternity.auctionrealtime.interfaces.rest.controller;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springdoc.core.annotations.ParameterObject;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import until.the.eternity.auctionrealtime.application.service.AuctionRealtimeService;
+import until.the.eternity.auctionrealtime.interfaces.rest.dto.request.AuctionRealtimeSearchRequest;
+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.response.PageResponseDto;
+
+@RequestMapping("/auction-realtime")
+@RestController
+@RequiredArgsConstructor
+@Tag(name = "실시간 경매장 API", description = "실시간 경매장 아이템 조회 API")
+public class AuctionRealtimeController {
+
+ private final AuctionRealtimeService service;
+
+ @GetMapping("/search")
+ @Operation(summary = "실시간 경매장 아이템 검색", description = "실시간 경매장에 등록된 아이템 검색")
+ public ResponseEntity<
+ PageResponseDto>>
+ search(
+ @ParameterObject @ModelAttribute @Valid RealtimePageRequestDto pageDto,
+ @ParameterObject @ModelAttribute @Valid
+ AuctionRealtimeSearchRequest requestDto) {
+ PageResponseDto> result =
+ service.search(requestDto, pageDto.toPageable());
+ return ResponseEntity.ok(result);
+ }
+
+ @GetMapping("/{id}")
+ @Operation(summary = "실시간 경매장 아이템 단건 조회", description = "실시간 경매장 아이템 ID로 단건 조회")
+ public ResponseEntity> findById(
+ @PathVariable Long id) {
+ AuctionRealtimeDetailResponse result =
+ service.findByIdOrElseThrow(id);
+ return ResponseEntity.ok(result);
+ }
+}
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
new file mode 100644
index 00000000..19d06834
--- /dev/null
+++ b/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/dto/request/AuctionRealtimeSearchRequest.java
@@ -0,0 +1,14 @@
+package until.the.eternity.auctionrealtime.interfaces.rest.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import until.the.eternity.auctionhistory.interfaces.rest.dto.request.ItemOptionSearchRequest;
+import until.the.eternity.auctionhistory.interfaces.rest.dto.request.PriceSearchRequest;
+
+/** 실시간 경매장 검색 조건 DTO */
+@Schema(description = "실시간 경매장 검색 조건")
+public record AuctionRealtimeSearchRequest(
+ @Schema(description = "아이템 이름 (like 검색)", example = "페러시우스 타이탄 블레이드") String itemName,
+ @Schema(description = "대분류 카테고리", example = "근거리 장비") String itemTopCategory,
+ @Schema(description = "소분류 카테고리", example = "검") String itemSubCategory,
+ @Schema(description = "가격 검색 조건") PriceSearchRequest priceSearchRequest,
+ @Schema(description = "아이템 옵션 검색 조건") ItemOptionSearchRequest itemOptionSearchRequest) {}
diff --git a/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/dto/request/RealtimePageRequestDto.java b/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/dto/request/RealtimePageRequestDto.java
new file mode 100644
index 00000000..9719ba72
--- /dev/null
+++ b/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/dto/request/RealtimePageRequestDto.java
@@ -0,0 +1,39 @@
+package until.the.eternity.auctionrealtime.interfaces.rest.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+import until.the.eternity.common.enums.SortDirection;
+
+@Schema(description = "실시간 경매장 페이지 요청 파라미터")
+public record RealtimePageRequestDto(
+ @Schema(description = "요청할 페이지 번호 (1부터 시작)", example = "1") @Min(1) Integer page,
+ @Schema(description = "페이지당 항목 수", example = "20") @Min(1) @Max(100) Integer size,
+ @Schema(
+ description =
+ "정렬 필드 (dateAuctionExpire, dateRegister, auctionPricePerUnit, itemName)",
+ example = "dateAuctionExpire")
+ RealtimeSortField sortBy,
+ @Schema(description = "정렬 방향 (ASC, DESC)", example = "ASC") SortDirection direction) {
+
+ private static final int DEFAULT_PAGE = 1;
+ private static final int DEFAULT_SIZE = 20;
+ private static final RealtimeSortField DEFAULT_SORT_BY = RealtimeSortField.DATE_AUCTION_EXPIRE;
+ private static final SortDirection DEFAULT_DIRECTION = SortDirection.ASC;
+
+ public Pageable toPageable() {
+ int resolvedPage = this.page != null ? this.page - 1 : DEFAULT_PAGE - 1;
+ int resolvedSize = this.size != null ? this.size : DEFAULT_SIZE;
+ RealtimeSortField resolvedSortBy = this.sortBy != null ? this.sortBy : DEFAULT_SORT_BY;
+ SortDirection resolvedDirection =
+ this.direction != null ? this.direction : DEFAULT_DIRECTION;
+
+ return PageRequest.of(
+ resolvedPage,
+ resolvedSize,
+ Sort.by(resolvedDirection.toSpringDirection(), resolvedSortBy.getFieldName()));
+ }
+}
diff --git a/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/dto/request/RealtimeSortField.java b/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/dto/request/RealtimeSortField.java
new file mode 100644
index 00000000..7d56e88d
--- /dev/null
+++ b/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/dto/request/RealtimeSortField.java
@@ -0,0 +1,44 @@
+package until.the.eternity.auctionrealtime.interfaces.rest.dto.request;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonValue;
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import java.util.Arrays;
+
+/** 실시간 경매장 정렬 필드 */
+@Schema(description = "실시간 경매장 정렬 필드", enumAsRef = true)
+public enum RealtimeSortField {
+ DATE_AUCTION_EXPIRE("dateAuctionExpire", "경매 만료 일시"),
+ DATE_REGISTER("dateRegister", "등록 일시"),
+ AUCTION_PRICE_PER_UNIT("auctionPricePerUnit", "개당 가격"),
+ ITEM_NAME("itemName", "아이템 이름");
+
+ private final String fieldName;
+ private final String description;
+
+ RealtimeSortField(String fieldName, String description) {
+ this.fieldName = fieldName;
+ this.description = description;
+ }
+
+ @JsonValue
+ public String getFieldName() {
+ return fieldName;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ @JsonCreator
+ public static RealtimeSortField from(String fieldName) {
+ if (fieldName == null) {
+ return DATE_AUCTION_EXPIRE;
+ }
+ return Arrays.stream(RealtimeSortField.values())
+ .filter(field -> field.fieldName.equalsIgnoreCase(fieldName))
+ .findFirst()
+ .orElse(DATE_AUCTION_EXPIRE);
+ }
+}
diff --git a/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/dto/response/AuctionRealtimeDetailResponse.java b/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/dto/response/AuctionRealtimeDetailResponse.java
new file mode 100644
index 00000000..a3d5a9e0
--- /dev/null
+++ b/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/dto/response/AuctionRealtimeDetailResponse.java
@@ -0,0 +1,18 @@
+package until.the.eternity.auctionrealtime.interfaces.rest.dto.response;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+
+import java.time.Instant;
+import java.util.List;
+
+public record AuctionRealtimeDetailResponse(
+ Long id,
+ String itemName,
+ String itemDisplayName,
+ Long itemCount,
+ Long auctionPricePerUnit,
+ @JsonFormat(shape = JsonFormat.Shape.STRING) Instant dateAuctionExpire,
+ @JsonFormat(shape = JsonFormat.Shape.STRING) Instant dateRegister,
+ String itemSubCategory,
+ String itemTopCategory,
+ List itemOptions) {}
diff --git a/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/dto/response/RealtimeItemOptionResponse.java b/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/dto/response/RealtimeItemOptionResponse.java
new file mode 100644
index 00000000..c72b8504
--- /dev/null
+++ b/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/dto/response/RealtimeItemOptionResponse.java
@@ -0,0 +1,9 @@
+package until.the.eternity.auctionrealtime.interfaces.rest.dto.response;
+
+public record RealtimeItemOptionResponse(
+ String id,
+ String optionType,
+ String optionSubType,
+ String optionValue,
+ String optionValue2,
+ String optionDesc) {}
diff --git a/src/main/java/until/the/eternity/config/SecurityConfig.java b/src/main/java/until/the/eternity/config/SecurityConfig.java
index 52f6b9b4..13363f0a 100644
--- a/src/main/java/until/the/eternity/config/SecurityConfig.java
+++ b/src/main/java/until/the/eternity/config/SecurityConfig.java
@@ -45,7 +45,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
"/api/**",
"/auction-history/**",
"/statistics/**",
- "/horn-bugle/**")
+ "/horn-bugle/**",
+ "/auction-realtime/**")
.permitAll()
// 나머지 요청은 인증 필요
.anyRequest()
diff --git a/src/main/java/until/the/eternity/config/WebConfig.java b/src/main/java/until/the/eternity/config/WebConfig.java
index b3ebbc84..3f577f0d 100644
--- a/src/main/java/until/the/eternity/config/WebConfig.java
+++ b/src/main/java/until/the/eternity/config/WebConfig.java
@@ -3,6 +3,7 @@
import org.springframework.context.annotation.Configuration;
import org.springframework.format.FormatterRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+import until.the.eternity.auctionrealtime.interfaces.rest.dto.request.RealtimeSortField;
import until.the.eternity.common.enums.SortDirection;
import until.the.eternity.common.enums.SortField;
@@ -13,5 +14,6 @@ public class WebConfig implements WebMvcConfigurer {
public void addFormatters(FormatterRegistry registry) {
registry.addConverter(String.class, SortField.class, SortField::from);
registry.addConverter(String.class, SortDirection.class, SortDirection::from);
+ registry.addConverter(String.class, RealtimeSortField.class, RealtimeSortField::from);
}
}
diff --git a/src/main/java/until/the/eternity/hornBugle/application/runner/HornBugleIndexRunner.java b/src/main/java/until/the/eternity/hornBugle/application/runner/HornBugleIndexRunner.java
new file mode 100644
index 00000000..663122b1
--- /dev/null
+++ b/src/main/java/until/the/eternity/hornBugle/application/runner/HornBugleIndexRunner.java
@@ -0,0 +1,89 @@
+package until.the.eternity.hornBugle.application.runner;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.ApplicationArguments;
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Component;
+import until.the.eternity.hornBugle.domain.entity.HornBugleWorldHistory;
+import until.the.eternity.hornBugle.domain.repository.HornBugleRepositoryPort;
+import until.the.eternity.hornBugle.infrastructure.elasticsearch.HornBugleIndexService;
+
+import java.util.List;
+
+/**
+ * 서버 재기동 시 DB 데이터를 Elasticsearch에 일괄 색인하는 Runner. application.yml에서
+ * elasticsearch.index.enabled=true로 설정 시 활성화됩니다.
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+@ConditionalOnProperty(
+ name = "elasticsearch.index.enabled",
+ havingValue = "true",
+ matchIfMissing = false)
+public class HornBugleIndexRunner implements ApplicationRunner {
+
+ private static final int BATCH_SIZE = 500;
+
+ private final HornBugleRepositoryPort repository;
+ private final HornBugleIndexService indexService;
+
+ @Override
+ public void run(ApplicationArguments args) {
+ log.info("[ES] Starting batch indexing on application startup...");
+
+ try {
+ // 인덱스 삭제 후 재생성
+ indexService.recreateIndex();
+
+ // DB 전체 데이터를 배치로 조회하여 색인
+ long totalIndexed = indexAllFromDatabase();
+
+ log.info("[ES] Batch indexing completed. Total indexed: {} documents", totalIndexed);
+ } catch (Exception e) {
+ log.error("[ES] Batch indexing failed: {}", e.getMessage(), e);
+ }
+ }
+
+ /**
+ * DB의 모든 데이터를 배치로 조회하여 Elasticsearch에 색인한다.
+ *
+ * @return 색인된 총 문서 수
+ */
+ private long indexAllFromDatabase() {
+ long totalIndexed = 0;
+ int pageNumber = 0;
+
+ while (true) {
+ Pageable pageable = PageRequest.of(pageNumber, BATCH_SIZE);
+ Page page = repository.findAll(pageable);
+
+ if (!page.hasContent()) {
+ break;
+ }
+
+ List entities = page.getContent();
+ indexService.indexAll(entities);
+
+ totalIndexed += entities.size();
+ log.debug(
+ "[ES] Indexed page {}: {} documents (total: {})",
+ pageNumber,
+ entities.size(),
+ totalIndexed);
+
+ if (!page.hasNext()) {
+ break;
+ }
+
+ pageNumber++;
+ }
+
+ return totalIndexed;
+ }
+}
diff --git a/src/main/java/until/the/eternity/hornBugle/application/service/HornBugleService.java b/src/main/java/until/the/eternity/hornBugle/application/service/HornBugleService.java
index 70db2e65..c6e3fdc1 100644
--- a/src/main/java/until/the/eternity/hornBugle/application/service/HornBugleService.java
+++ b/src/main/java/until/the/eternity/hornBugle/application/service/HornBugleService.java
@@ -1,6 +1,5 @@
package until.the.eternity.hornBugle.application.service;
-import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.stereotype.Service;
@@ -11,21 +10,35 @@
import until.the.eternity.hornBugle.domain.mapper.HornBugleMapper;
import until.the.eternity.hornBugle.domain.repository.HornBugleRepositoryPort;
import until.the.eternity.hornBugle.domain.service.HornBugleDuplicateChecker;
+import until.the.eternity.hornBugle.infrastructure.elasticsearch.HornBugleDocument;
+import until.the.eternity.hornBugle.infrastructure.elasticsearch.HornBugleIndexService;
import until.the.eternity.hornBugle.interfaces.external.dto.OpenApiHornBugleHistoryResponse;
import until.the.eternity.hornBugle.interfaces.rest.dto.request.HornBuglePageRequestDto;
import until.the.eternity.hornBugle.interfaces.rest.dto.response.HornBugleHistoryResponse;
import java.time.Instant;
import java.util.List;
+import java.util.Optional;
@Slf4j
@Service
-@RequiredArgsConstructor
public class HornBugleService {
private final HornBugleRepositoryPort repository;
private final HornBugleDuplicateChecker duplicateChecker;
private final HornBugleMapper mapper;
+ private final Optional indexService;
+
+ public HornBugleService(
+ HornBugleRepositoryPort repository,
+ HornBugleDuplicateChecker duplicateChecker,
+ HornBugleMapper mapper,
+ Optional indexService) {
+ this.repository = repository;
+ this.duplicateChecker = duplicateChecker;
+ this.mapper = mapper;
+ this.indexService = indexService;
+ }
/**
* API 응답 데이터를 중복 제거 후 저장한다.
@@ -59,20 +72,73 @@ public int saveAll(HornBugleServer server, List
repository.saveAll(entities);
+ // Elasticsearch 실시간 색인 (DB 저장 성공 후, ES가 활성화된 경우에만)
+ indexService.ifPresent(service -> service.indexAll(entities));
+
log.info("[HornBugle] [{}] Saved {} new records.", server.getServerName(), entities.size());
return entities.size();
}
/**
- * 서버별 최신 N건 조회 (페이징)
+ * 서버별 최신 N건 조회 (페이징). keyword가 있고 ES가 활성화되어 있으면 Elasticsearch 검색을 수행한다. ES가 비활성화되어 있거나 사용 불가능한
+ * 경우 MySQL FULLTEXT 검색으로 fallback한다.
*
* @param serverName 서버 이름 (선택 사항, null이면 전체 조회)
+ * @param keyword 검색 키워드 (선택 사항, null이면 DB 검색)
* @param pageRequest 페이지 요청 정보
* @return 페이징 응답
*/
@Transactional(readOnly = true)
public PageResponseDto search(
+ String serverName, String keyword, HornBuglePageRequestDto pageRequest) {
+
+ // keyword가 없으면 단순 DB 조회
+ if (keyword == null || keyword.isBlank()) {
+ return searchByDatabase(serverName, pageRequest);
+ }
+
+ // keyword가 있고 ES가 활성화되어 있고 사용 가능하면 Elasticsearch 검색
+ if (indexService.isPresent() && indexService.get().isAvailable()) {
+ try {
+ return searchByElasticsearch(serverName, keyword, pageRequest);
+ } catch (Exception e) {
+ log.warn(
+ "[HornBugle] Elasticsearch search failed, falling back to MySQL FULLTEXT search. keyword={}, error={}",
+ keyword,
+ e.getMessage());
+ return searchByDatabaseWithKeyword(serverName, keyword, pageRequest);
+ }
+ }
+
+ // ES가 비활성화되어 있거나 사용 불가능한 경우 MySQL FULLTEXT 검색
+ if (indexService.isEmpty()) {
+ log.info(
+ "[HornBugle] Elasticsearch is not enabled. Using MySQL FULLTEXT search. keyword={}",
+ keyword);
+ } else {
+ log.warn(
+ "[HornBugle] Elasticsearch is not available. Falling back to MySQL FULLTEXT search. keyword={}",
+ keyword);
+ }
+
+ return searchByDatabaseWithKeyword(serverName, keyword, pageRequest);
+ }
+
+ /** Elasticsearch로 검색한다. */
+ private PageResponseDto searchByElasticsearch(
+ String serverName, String keyword, HornBuglePageRequestDto pageRequest) {
+
+ Page page =
+ indexService.get().search(keyword, serverName, pageRequest.toPageable());
+
+ Page responsePage = page.map(mapper::toResponse);
+
+ return PageResponseDto.of(responsePage);
+ }
+
+ /** DB로 검색한다 (keyword 없이). */
+ private PageResponseDto searchByDatabase(
String serverName, HornBuglePageRequestDto pageRequest) {
Page page;
@@ -87,4 +153,23 @@ public PageResponseDto search(
return PageResponseDto.of(responsePage);
}
+
+ /** MySQL FULLTEXT 검색으로 keyword 검색을 수행한다. Native Query에서 ORDER BY를 지정하므로 Sort 없이 Pageable을 전달한다. */
+ private PageResponseDto searchByDatabaseWithKeyword(
+ String serverName, String keyword, HornBuglePageRequestDto pageRequest) {
+
+ Page page;
+
+ if (serverName != null && !serverName.isBlank()) {
+ page =
+ repository.searchByKeywordAndServerName(
+ keyword, serverName, pageRequest.toPageableWithoutSort());
+ } else {
+ page = repository.searchByKeyword(keyword, pageRequest.toPageableWithoutSort());
+ }
+
+ Page responsePage = page.map(mapper::toResponse);
+
+ return PageResponseDto.of(responsePage);
+ }
}
diff --git a/src/main/java/until/the/eternity/hornBugle/domain/mapper/HornBugleMapper.java b/src/main/java/until/the/eternity/hornBugle/domain/mapper/HornBugleMapper.java
index c9340bf3..c2bb9e73 100644
--- a/src/main/java/until/the/eternity/hornBugle/domain/mapper/HornBugleMapper.java
+++ b/src/main/java/until/the/eternity/hornBugle/domain/mapper/HornBugleMapper.java
@@ -4,6 +4,7 @@
import org.mapstruct.Mapping;
import until.the.eternity.hornBugle.domain.entity.HornBugleWorldHistory;
import until.the.eternity.hornBugle.domain.enums.HornBugleServer;
+import until.the.eternity.hornBugle.infrastructure.elasticsearch.HornBugleDocument;
import until.the.eternity.hornBugle.interfaces.external.dto.OpenApiHornBugleHistoryResponse;
import until.the.eternity.hornBugle.interfaces.rest.dto.response.HornBugleHistoryResponse;
@@ -20,4 +21,7 @@ HornBugleWorldHistory toEntity(
OpenApiHornBugleHistoryResponse dto, HornBugleServer server, Instant registerTime);
HornBugleHistoryResponse toResponse(HornBugleWorldHistory entity);
+
+ @Mapping(target = "id", expression = "java(Long.parseLong(document.getId()))")
+ HornBugleHistoryResponse toResponse(HornBugleDocument document);
}
diff --git a/src/main/java/until/the/eternity/hornBugle/domain/repository/HornBugleRepositoryPort.java b/src/main/java/until/the/eternity/hornBugle/domain/repository/HornBugleRepositoryPort.java
index 0162f664..75dfd277 100644
--- a/src/main/java/until/the/eternity/hornBugle/domain/repository/HornBugleRepositoryPort.java
+++ b/src/main/java/until/the/eternity/hornBugle/domain/repository/HornBugleRepositoryPort.java
@@ -19,4 +19,11 @@ public interface HornBugleRepositoryPort {
Page findAll(Pageable pageable);
List findByServerNameAndDateSend(String serverName, Instant dateSend);
+
+ /** FULLTEXT 인덱스를 사용한 키워드 검색 (전체 서버) */
+ Page searchByKeyword(String keyword, Pageable pageable);
+
+ /** FULLTEXT 인덱스를 사용한 키워드 검색 (서버 필터 포함) */
+ Page searchByKeywordAndServerName(
+ String keyword, String serverName, Pageable pageable);
}
diff --git a/src/main/java/until/the/eternity/hornBugle/infrastructure/elasticsearch/HornBugleDocument.java b/src/main/java/until/the/eternity/hornBugle/infrastructure/elasticsearch/HornBugleDocument.java
new file mode 100644
index 00000000..98bb7864
--- /dev/null
+++ b/src/main/java/until/the/eternity/hornBugle/infrastructure/elasticsearch/HornBugleDocument.java
@@ -0,0 +1,51 @@
+package until.the.eternity.hornBugle.infrastructure.elasticsearch;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.springframework.data.annotation.Id;
+import org.springframework.data.elasticsearch.annotations.Document;
+import org.springframework.data.elasticsearch.annotations.Field;
+import org.springframework.data.elasticsearch.annotations.FieldType;
+import org.springframework.data.elasticsearch.annotations.Setting;
+import until.the.eternity.hornBugle.domain.entity.HornBugleWorldHistory;
+
+import java.time.Instant;
+
+@Document(indexName = "horn_bugle_world_history")
+@Setting(settingPath = "elasticsearch/horn-bugle-settings.json")
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class HornBugleDocument {
+
+ @Id private String id;
+
+ @Field(type = FieldType.Keyword, name = "server_name")
+ private String serverName;
+
+ @Field(type = FieldType.Text, name = "character_name", analyzer = "nori_analyzer")
+ private String characterName;
+
+ @Field(type = FieldType.Text, name = "message", analyzer = "nori_analyzer")
+ private String message;
+
+ @Field(type = FieldType.Date, name = "date_send")
+ private Instant dateSend;
+
+ @Field(type = FieldType.Date, name = "date_register")
+ private Instant dateRegister;
+
+ public static HornBugleDocument from(HornBugleWorldHistory entity) {
+ return HornBugleDocument.builder()
+ .id(String.valueOf(entity.getId()))
+ .serverName(entity.getServerName())
+ .characterName(entity.getCharacterName())
+ .message(entity.getMessage())
+ .dateSend(entity.getDateSend())
+ .dateRegister(entity.getDateRegister())
+ .build();
+ }
+}
diff --git a/src/main/java/until/the/eternity/hornBugle/infrastructure/elasticsearch/HornBugleElasticsearchConfig.java b/src/main/java/until/the/eternity/hornBugle/infrastructure/elasticsearch/HornBugleElasticsearchConfig.java
new file mode 100644
index 00000000..9df5c5cd
--- /dev/null
+++ b/src/main/java/until/the/eternity/hornBugle/infrastructure/elasticsearch/HornBugleElasticsearchConfig.java
@@ -0,0 +1,12 @@
+package until.the.eternity.hornBugle.infrastructure.elasticsearch;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.elasticsearch.repository.config.EnableElasticsearchRepositories;
+
+/** Elasticsearch 설정 클래스. elasticsearch.enabled=true일 때만 Elasticsearch Repository를 활성화한다. */
+@Configuration
+@ConditionalOnProperty(name = "elasticsearch.enabled", havingValue = "true", matchIfMissing = false)
+@EnableElasticsearchRepositories(
+ basePackages = "until.the.eternity.hornBugle.infrastructure.elasticsearch")
+public class HornBugleElasticsearchConfig {}
diff --git a/src/main/java/until/the/eternity/hornBugle/infrastructure/elasticsearch/HornBugleElasticsearchRepository.java b/src/main/java/until/the/eternity/hornBugle/infrastructure/elasticsearch/HornBugleElasticsearchRepository.java
new file mode 100644
index 00000000..6539c23e
--- /dev/null
+++ b/src/main/java/until/the/eternity/hornBugle/infrastructure/elasticsearch/HornBugleElasticsearchRepository.java
@@ -0,0 +1,6 @@
+package until.the.eternity.hornBugle.infrastructure.elasticsearch;
+
+import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;
+
+public interface HornBugleElasticsearchRepository
+ extends ElasticsearchRepository {}
diff --git a/src/main/java/until/the/eternity/hornBugle/infrastructure/elasticsearch/HornBugleIndexService.java b/src/main/java/until/the/eternity/hornBugle/infrastructure/elasticsearch/HornBugleIndexService.java
new file mode 100644
index 00000000..0b6b8bd9
--- /dev/null
+++ b/src/main/java/until/the/eternity/hornBugle/infrastructure/elasticsearch/HornBugleIndexService.java
@@ -0,0 +1,181 @@
+package until.the.eternity.hornBugle.infrastructure.elasticsearch;
+
+import co.elastic.clients.elasticsearch._types.query_dsl.BoolQuery;
+import co.elastic.clients.elasticsearch._types.query_dsl.Query;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.elasticsearch.client.elc.NativeQuery;
+import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
+import org.springframework.data.elasticsearch.core.IndexOperations;
+import org.springframework.data.elasticsearch.core.SearchHit;
+import org.springframework.data.elasticsearch.core.SearchHits;
+import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates;
+import org.springframework.stereotype.Service;
+import until.the.eternity.hornBugle.domain.entity.HornBugleWorldHistory;
+
+import java.util.List;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@ConditionalOnProperty(name = "elasticsearch.enabled", havingValue = "true", matchIfMissing = false)
+public class HornBugleIndexService {
+
+ private static final String INDEX_NAME = "horn_bugle_world_history";
+ private static final int BATCH_SIZE = 500;
+
+ private final ElasticsearchOperations elasticsearchOperations;
+ private final HornBugleElasticsearchRepository repository;
+
+ /**
+ * 단일 엔티티를 Elasticsearch에 색인한다.
+ *
+ * @param entity 색인할 엔티티
+ */
+ public void index(HornBugleWorldHistory entity) {
+ try {
+ HornBugleDocument document = HornBugleDocument.from(entity);
+ repository.save(document);
+ log.debug("[ES] Indexed document: id={}", entity.getId());
+ } catch (Exception e) {
+ log.error(
+ "[ES] Failed to index document: id={}, error={}",
+ entity.getId(),
+ e.getMessage(),
+ e);
+ }
+ }
+
+ /**
+ * 여러 엔티티를 Elasticsearch에 일괄 색인한다.
+ *
+ * @param entities 색인할 엔티티 목록
+ */
+ public void indexAll(List entities) {
+ if (entities == null || entities.isEmpty()) {
+ return;
+ }
+
+ try {
+ List documents =
+ entities.stream().map(HornBugleDocument::from).toList();
+
+ for (int i = 0; i < documents.size(); i += BATCH_SIZE) {
+ int toIndex = Math.min(i + BATCH_SIZE, documents.size());
+ List batch = documents.subList(i, toIndex);
+ repository.saveAll(batch);
+ log.debug("[ES] Indexed batch: {} documents", batch.size());
+ }
+
+ log.info("[ES] Successfully indexed {} documents", entities.size());
+ } catch (Exception e) {
+ log.error("[ES] Failed to index {} documents: {}", entities.size(), e.getMessage(), e);
+ }
+ }
+
+ /** 인덱스를 삭제하고 재생성한다. */
+ public void recreateIndex() {
+ try {
+ IndexOperations indexOps = elasticsearchOperations.indexOps(HornBugleDocument.class);
+
+ if (indexOps.exists()) {
+ indexOps.delete();
+ log.info("[ES] Deleted existing index: {}", INDEX_NAME);
+ }
+
+ indexOps.createWithMapping();
+ log.info("[ES] Created index with mapping: {}", INDEX_NAME);
+ } catch (Exception e) {
+ log.error("[ES] Failed to recreate index: {}", e.getMessage(), e);
+ throw new RuntimeException("Failed to recreate Elasticsearch index", e);
+ }
+ }
+
+ /**
+ * keyword로 검색한다. serverName이 있으면 필터로 추가한다.
+ *
+ * @param keyword 검색 키워드
+ * @param serverName 서버명 필터 (nullable)
+ * @param pageable 페이징 정보
+ * @return 검색 결과
+ */
+ public Page search(String keyword, String serverName, Pageable pageable) {
+ try {
+ // Multi-match query for keyword search
+ // Note: date_send is Date type, excluded from text search
+ // server_name is Keyword type, included for exact match
+ Query multiMatchQuery =
+ Query.of(
+ q ->
+ q.multiMatch(
+ mm ->
+ mm.query(keyword)
+ .fields("character_name", "message")));
+
+ // Build bool query
+ BoolQuery.Builder boolQueryBuilder = new BoolQuery.Builder().must(multiMatchQuery);
+
+ // Add serverName filter if present
+ if (serverName != null && !serverName.isBlank()) {
+ Query serverFilter =
+ Query.of(q -> q.term(t -> t.field("server_name").value(serverName)));
+ boolQueryBuilder.filter(serverFilter);
+ }
+
+ Query finalQuery = Query.of(q -> q.bool(boolQueryBuilder.build()));
+
+ NativeQuery searchQuery =
+ NativeQuery.builder().withQuery(finalQuery).withPageable(pageable).build();
+
+ SearchHits searchHits =
+ elasticsearchOperations.search(
+ searchQuery, HornBugleDocument.class, IndexCoordinates.of(INDEX_NAME));
+
+ List content =
+ searchHits.getSearchHits().stream().map(SearchHit::getContent).toList();
+
+ return new PageImpl<>(content, pageable, searchHits.getTotalHits());
+ } catch (Exception e) {
+ log.error(
+ "[ES] Search failed: keyword={}, serverName={}, error={}",
+ keyword,
+ serverName,
+ e.getMessage(),
+ e);
+ throw new RuntimeException("Elasticsearch search failed", e);
+ }
+ }
+
+ /**
+ * 인덱스 존재 여부를 확인한다.
+ *
+ * @return 인덱스 존재 여부
+ */
+ public boolean indexExists() {
+ try {
+ return elasticsearchOperations.indexOps(HornBugleDocument.class).exists();
+ } catch (Exception e) {
+ log.error("[ES] Failed to check index existence: {}", e.getMessage(), e);
+ return false;
+ }
+ }
+
+ /**
+ * Elasticsearch 서버가 사용 가능한 상태인지 확인한다.
+ *
+ * @return ES 서버 사용 가능 여부
+ */
+ public boolean isAvailable() {
+ try {
+ return elasticsearchOperations.indexOps(HornBugleDocument.class).exists()
+ || elasticsearchOperations.indexOps(HornBugleDocument.class).create();
+ } catch (Exception e) {
+ log.warn("[ES] Elasticsearch is not available: {}", e.getMessage());
+ return false;
+ }
+ }
+}
diff --git a/src/main/java/until/the/eternity/hornBugle/infrastructure/persistence/HornBugleJpaRepository.java b/src/main/java/until/the/eternity/hornBugle/infrastructure/persistence/HornBugleJpaRepository.java
index 2fd3bb51..e68b01eb 100644
--- a/src/main/java/until/the/eternity/hornBugle/infrastructure/persistence/HornBugleJpaRepository.java
+++ b/src/main/java/until/the/eternity/hornBugle/infrastructure/persistence/HornBugleJpaRepository.java
@@ -26,4 +26,39 @@ public interface HornBugleJpaRepository extends JpaRepository findByServerName(String serverName, Pageable pageable);
List findByServerNameAndDateSend(String serverName, Instant dateSend);
+
+ /** FULLTEXT 인덱스를 사용한 키워드 검색 (전체 서버) */
+ @Query(
+ value =
+ """
+ SELECT * FROM horn_bugle_world_history
+ WHERE MATCH(character_name, message, server_name, date_send_text) AGAINST(:keyword IN NATURAL LANGUAGE MODE)
+ ORDER BY date_send DESC
+ """,
+ countQuery =
+ """
+ SELECT COUNT(*) FROM horn_bugle_world_history
+ WHERE MATCH(character_name, message, server_name, date_send_text) AGAINST(:keyword IN NATURAL LANGUAGE MODE)
+ """,
+ nativeQuery = true)
+ Page searchByKeyword(String keyword, Pageable pageable);
+
+ /** FULLTEXT 인덱스를 사용한 키워드 검색 (서버 필터 포함) */
+ @Query(
+ value =
+ """
+ SELECT * FROM horn_bugle_world_history
+ WHERE MATCH(character_name, message, server_name, date_send_text) AGAINST(:keyword IN NATURAL LANGUAGE MODE)
+ AND server_name = :serverName
+ ORDER BY date_send DESC
+ """,
+ countQuery =
+ """
+ SELECT COUNT(*) FROM horn_bugle_world_history
+ WHERE MATCH(character_name, message, server_name, date_send_text) AGAINST(:keyword IN NATURAL LANGUAGE MODE)
+ AND server_name = :serverName
+ """,
+ nativeQuery = true)
+ Page searchByKeywordAndServerName(
+ String keyword, String serverName, Pageable pageable);
}
diff --git a/src/main/java/until/the/eternity/hornBugle/infrastructure/persistence/HornBugleRepositoryPortImpl.java b/src/main/java/until/the/eternity/hornBugle/infrastructure/persistence/HornBugleRepositoryPortImpl.java
index 469b3760..706bc05b 100644
--- a/src/main/java/until/the/eternity/hornBugle/infrastructure/persistence/HornBugleRepositoryPortImpl.java
+++ b/src/main/java/until/the/eternity/hornBugle/infrastructure/persistence/HornBugleRepositoryPortImpl.java
@@ -60,4 +60,15 @@ public List findByServerNameAndDateSend(
String serverName, Instant dateSend) {
return jpaRepository.findByServerNameAndDateSend(serverName, dateSend);
}
+
+ @Override
+ public Page searchByKeyword(String keyword, Pageable pageable) {
+ return jpaRepository.searchByKeyword(keyword, pageable);
+ }
+
+ @Override
+ public Page searchByKeywordAndServerName(
+ String keyword, String serverName, Pageable pageable) {
+ return jpaRepository.searchByKeywordAndServerName(keyword, serverName, pageable);
+ }
}
diff --git a/src/main/java/until/the/eternity/hornBugle/interfaces/rest/controller/HornBugleController.java b/src/main/java/until/the/eternity/hornBugle/interfaces/rest/controller/HornBugleController.java
index 60d7efdb..e8c1fca4 100644
--- a/src/main/java/until/the/eternity/hornBugle/interfaces/rest/controller/HornBugleController.java
+++ b/src/main/java/until/the/eternity/hornBugle/interfaces/rest/controller/HornBugleController.java
@@ -4,10 +4,12 @@
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
+import jakarta.validation.constraints.Size;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.http.ResponseEntity;
+import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import until.the.eternity.common.response.PageResponseDto;
import until.the.eternity.hornBugle.application.scheduler.HornBugleScheduler;
@@ -19,6 +21,7 @@
@RequestMapping("/horn-bugle")
@RestController
@RequiredArgsConstructor
+@Validated
@Tag(name = "뿔피리 히스토리 API", description = "거대한 외침의 뿔피리 내역 API")
public class HornBugleController {
@@ -26,13 +29,22 @@ public class HornBugleController {
private final HornBugleScheduler scheduler;
@GetMapping
- @Operation(summary = "뿔피리 히스토리 조회", description = "거대한 외침의 뿔피리 내역을 조회합니다. 서버별 또는 전체 조회가 가능합니다.")
+ @Operation(
+ summary = "뿔피리 히스토리 조회",
+ description =
+ "거대한 외침의 뿔피리 내역을 조회합니다. 서버별 또는 전체 조회가 가능합니다. "
+ + "keyword 입력 시 Elasticsearch 기반 전문 검색을 수행합니다.")
public ResponseEntity> search(
@Parameter(description = "서버 이름 (류트, 만돌린, 하프, 울프). 미입력시 전체 조회")
@RequestParam(required = false)
String serverName,
+ @Parameter(description = "검색 키워드 (캐릭터명, 메시지, 서버명, 발화시각 검색). 최대 50자")
+ @RequestParam(required = false)
+ @Size(max = 50, message = "검색 키워드는 최대 50자까지 입력 가능합니다.")
+ String keyword,
@ParameterObject @ModelAttribute @Valid HornBuglePageRequestDto pageRequest) {
- PageResponseDto result = service.search(serverName, pageRequest);
+ PageResponseDto result =
+ service.search(serverName, keyword, pageRequest);
return ResponseEntity.ok(result);
}
diff --git a/src/main/java/until/the/eternity/hornBugle/interfaces/rest/dto/request/HornBuglePageRequestDto.java b/src/main/java/until/the/eternity/hornBugle/interfaces/rest/dto/request/HornBuglePageRequestDto.java
index 06adcf3d..c3648f68 100644
--- a/src/main/java/until/the/eternity/hornBugle/interfaces/rest/dto/request/HornBuglePageRequestDto.java
+++ b/src/main/java/until/the/eternity/hornBugle/interfaces/rest/dto/request/HornBuglePageRequestDto.java
@@ -25,6 +25,16 @@ public Pageable toPageable() {
resolvedPage, resolvedSize, Sort.by(Sort.Direction.DESC, SORT_BY_DATE_SEND));
}
+ /**
+ * Native Query용 Pageable (정렬 없음). Native Query에서 ORDER BY를 직접 지정하므로 Sort를 제외한다.
+ */
+ public Pageable toPageableWithoutSort() {
+ int resolvedPage = this.page != null ? this.page - 1 : DEFAULT_PAGE - 1;
+ int resolvedSize = this.size != null ? this.size : DEFAULT_SIZE;
+
+ return PageRequest.of(resolvedPage, resolvedSize);
+ }
+
public int getResolvedPage() {
return this.page != null ? this.page : DEFAULT_PAGE;
}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 5892cbf8..4d7ac1c9 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -18,6 +18,10 @@ management:
spring:
application:
name: open-api-batch-server
+ elasticsearch:
+ uris: ${ELASTICSEARCH_URIS:http://localhost:9200}
+ username: ${ELASTICSEARCH_USERNAME:}
+ password: ${ELASTICSEARCH_PASSWORD:}
web:
resources:
static-locations: classpath:/static/
@@ -93,10 +97,22 @@ openapi:
cron: ${HORN_BUGLE_CRON:0 */5 * * * *}
max-retries: ${HORN_BUGLE_MAX_RETRIES:3}
retry-delay-ms: ${HORN_BUGLE_RETRY_DELAY_MS:2000}
+ auction-realtime:
+ # 실시간 경매장 데이터 수집 스케줄러
+ # 10분 간격: 0, 10, 20, 30, 40, 50분
+ cron: ${AUCTION_REALTIME_CRON:0 0/10 * * * *}
+ delay-ms: ${AUCTION_REALTIME_DELAY_MS:500}
statistics:
previous-day:
# 전날 통계 최종 확정 스케줄러
# AuctionHistoryScheduler 실행 이후 전날 23시대 거래까지 포함한 통계를 확정
# 기본값: 매일 00:10 (AuctionHistory가 00:05에 실행되므로 그 이후)
- cron: ${STATISTICS_PREVIOUS_DAY_CRON:0 10 0 * * *}
\ No newline at end of file
+ cron: ${STATISTICS_PREVIOUS_DAY_CRON:0 10 0 * * *}
+
+elasticsearch:
+ # Elasticsearch 기능 활성화 여부 (false면 ES 관련 기능 비활성화, DB 검색만 사용)
+ enabled: ${ELASTICSEARCH_ENABLED:true}
+ index:
+ # 서버 재기동 시 DB 데이터 일괄 색인 활성화 여부
+ enabled: ${ELASTICSEARCH_INDEX_ENABLED:true}
\ No newline at end of file
diff --git a/src/main/resources/db/migration/V15__refactor_auction_tables_for_realtime_history_separation.sql b/src/main/resources/db/migration/V15__refactor_auction_tables_for_realtime_history_separation.sql
new file mode 100644
index 00000000..adc9b021
--- /dev/null
+++ b/src/main/resources/db/migration/V15__refactor_auction_tables_for_realtime_history_separation.sql
@@ -0,0 +1,52 @@
+-- V15: Refactor auction tables for realtime/history separation
+-- 1. auction_item_option -> auction_history_item_option (rename, remove auction_item_id)
+-- 2. auction_item -> auction_realtime_item (rename, add category columns)
+-- 3. Create auction_realtime_item_option table
+
+-- ============================================================
+-- Step 1: Modify auction_item_option table
+-- ============================================================
+
+-- 1-1. Drop foreign key constraint for auction_item_id
+ALTER TABLE auction_item_option DROP FOREIGN KEY fk_option_item;
+
+-- 1-2. Drop auction_item_id column
+ALTER TABLE auction_item_option DROP COLUMN auction_item_id;
+
+-- 1-3. Rename table to auction_history_item_option
+RENAME TABLE auction_item_option TO auction_history_item_option;
+
+-- ============================================================
+-- Step 2: Modify auction_item table
+-- ============================================================
+
+-- 2-1. Add category columns
+ALTER TABLE auction_item ADD COLUMN item_sub_category VARCHAR(25) NOT NULL DEFAULT '' COMMENT '아이템 하위 카테고리';
+ALTER TABLE auction_item ADD COLUMN item_top_category VARCHAR(25) NOT NULL DEFAULT '' COMMENT '아이템 상위 카테고리';
+
+-- 2-2. Rename table to auction_realtime_item
+RENAME TABLE auction_item TO auction_realtime_item;
+
+-- 2-3. Create indexes for auction_realtime_item
+CREATE INDEX idx_realtime_top_sub_item
+ ON auction_realtime_item (item_top_category, item_sub_category, item_name);
+
+CREATE INDEX idx_realtime_sub_category_expire
+ ON auction_realtime_item (item_sub_category, date_auction_expire DESC);
+
+-- ============================================================
+-- Step 3: Create auction_realtime_item_option table
+-- ============================================================
+
+CREATE TABLE auction_realtime_item_option (
+ id VARCHAR(36) NOT NULL COMMENT 'ItemOption의 고유 식별자 (UUID)',
+ auction_realtime_item_id BIGINT NOT NULL COMMENT 'auction_realtime_item 테이블의 외래 키',
+ option_type VARCHAR(100) COMMENT '아이템 옵션 유형',
+ option_sub_type VARCHAR(100) COMMENT '아이템 옵션 하위 유형',
+ option_value VARCHAR(255) COMMENT '아이템 옵션 값',
+ option_value2 VARCHAR(255) COMMENT '아이템 옵션 값 2',
+ option_desc TEXT COMMENT '아이템 옵션 부가 정보',
+ PRIMARY KEY (id),
+ CONSTRAINT fk_realtime_option_item FOREIGN KEY (auction_realtime_item_id)
+ REFERENCES auction_realtime_item(id) ON DELETE CASCADE
+) COMMENT='실시간 경매장 아이템 옵션 정보 테이블';
diff --git a/src/main/resources/db/migration/V16__update_unknown_item_name_and_segong_option_level.sql b/src/main/resources/db/migration/V16__update_unknown_item_name_and_segong_option_level.sql
new file mode 100644
index 00000000..c26f1ebc
--- /dev/null
+++ b/src/main/resources/db/migration/V16__update_unknown_item_name_and_segong_option_level.sql
@@ -0,0 +1,46 @@
+-- V16: item_name이 '(Unknown)'인 경우 item_display_name으로 업데이트,
+-- 세공 옵션 파싱 (option_value, option_value2, option_desc)
+
+-- 1. auction_history 테이블: item_name이 '(Unknown)'인 경우 item_display_name으로 업데이트
+UPDATE auction_history
+SET item_name = item_display_name
+WHERE item_name = '(Unknown)';
+
+-- 2. auction_realtime_item 테이블: item_name이 '(Unknown)'인 경우 item_display_name으로 업데이트
+UPDATE auction_realtime_item
+SET item_name = item_display_name
+WHERE item_name = '(Unknown)';
+
+-- 3. auction_history_item_option 테이블: 세공 옵션 파싱
+-- 패턴 1: "스킬명 숫자 레벨" 또는 "스킬명 숫자레벨" 형식 (예: "천옷만들기 품질 보너스 3 레벨", "지력 2레벨")
+UPDATE auction_history_item_option
+SET option_desc = REGEXP_SUBSTR(option_value, '[0-9]+ ?레벨$'),
+ option_value2 = REGEXP_SUBSTR(option_value, '[0-9]+(?= ?레벨$)'),
+ option_value = REGEXP_REPLACE(option_value, ' [0-9]+ ?레벨$', '')
+WHERE option_type = '세공 옵션'
+ AND option_value REGEXP '^.+ [0-9]+ ?레벨$';
+
+-- 패턴 2: "스킬명(숫자레벨:효과)" 형식 (예: "매그넘 샷 대미지(20레벨:200 % 증가)")
+UPDATE auction_history_item_option
+SET option_desc = REGEXP_SUBSTR(option_value, '\\([0-9]+레벨:.+\\)$'),
+ option_value2 = REGEXP_SUBSTR(option_value, '[0-9]+(?=레벨:)'),
+ option_value = REGEXP_REPLACE(option_value, '\\([0-9]+레벨:.+\\)$', '')
+WHERE option_type = '세공 옵션'
+ AND option_value REGEXP '^.+\\([0-9]+레벨:.+\\)$';
+
+-- 4. auction_realtime_item_option 테이블: 세공 옵션 파싱
+-- 패턴 1: "스킬명 숫자 레벨" 또는 "스킬명 숫자레벨" 형식 (예: "천옷만들기 품질 보너스 3 레벨", "지력 2레벨")
+UPDATE auction_realtime_item_option
+SET option_desc = REGEXP_SUBSTR(option_value, '[0-9]+ ?레벨$'),
+ option_value2 = REGEXP_SUBSTR(option_value, '[0-9]+(?= ?레벨$)'),
+ option_value = REGEXP_REPLACE(option_value, ' [0-9]+ ?레벨$', '')
+WHERE option_type = '세공 옵션'
+ AND option_value REGEXP '^.+ [0-9]+ ?레벨$';
+
+-- 패턴 2: "스킬명(숫자레벨:효과)" 형식 (예: "매그넘 샷 대미지(20레벨:200 % 증가)")
+UPDATE auction_realtime_item_option
+SET option_desc = REGEXP_SUBSTR(option_value, '\\([0-9]+레벨:.+\\)$'),
+ option_value2 = REGEXP_SUBSTR(option_value, '[0-9]+(?=레벨:)'),
+ option_value = REGEXP_REPLACE(option_value, '\\([0-9]+레벨:.+\\)$', '')
+WHERE option_type = '세공 옵션'
+ AND option_value REGEXP '^.+\\([0-9]+레벨:.+\\)$';
diff --git a/src/main/resources/db/migration/V17__add_fulltext_index_to_horn_bugle_world_history.sql b/src/main/resources/db/migration/V17__add_fulltext_index_to_horn_bugle_world_history.sql
new file mode 100644
index 00000000..039a1896
--- /dev/null
+++ b/src/main/resources/db/migration/V17__add_fulltext_index_to_horn_bugle_world_history.sql
@@ -0,0 +1,8 @@
+-- FULLTEXT 인덱스를 위한 date_send 문자열 컬럼 추가 (Generated Column)
+ALTER TABLE horn_bugle_world_history
+ ADD COLUMN date_send_text VARCHAR(30) GENERATED ALWAYS AS (DATE_FORMAT(date_send, '%Y-%m-%d %H:%i:%s')) STORED;
+
+-- character_name, message, server_name, date_send_text에 FULLTEXT 인덱스 추가
+-- MySQL 8.0+ 에서 ngram parser를 사용하여 한글 검색 지원
+ALTER TABLE horn_bugle_world_history
+ ADD FULLTEXT INDEX ft_horn_bugle_search (character_name, message, server_name, date_send_text) WITH PARSER ngram;
diff --git a/src/main/resources/elasticsearch/horn-bugle-settings.json b/src/main/resources/elasticsearch/horn-bugle-settings.json
new file mode 100644
index 00000000..f04948ef
--- /dev/null
+++ b/src/main/resources/elasticsearch/horn-bugle-settings.json
@@ -0,0 +1,9 @@
+{
+ "analysis": {
+ "analyzer": {
+ "nori_analyzer": {
+ "type": "standard"
+ }
+ }
+ }
+}
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
new file mode 100644
index 00000000..7f543ac5
--- /dev/null
+++ b/src/test/java/until/the/eternity/auctionrealtime/application/service/fetcher/AuctionRealtimeFetcherTest.java
@@ -0,0 +1,247 @@
+package until.the.eternity.auctionrealtime.application.service.fetcher;
+
+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 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;
+import until.the.eternity.auctionrealtime.interfaces.external.dto.OpenApiAuctionRealtimeResponse;
+import until.the.eternity.common.enums.ItemCategory;
+
+import java.time.Instant;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class AuctionRealtimeFetcherTest {
+
+ @Mock AuctionRealtimeClient client;
+
+ @Mock AuctionRealtimeDuplicateChecker duplicateChecker;
+
+ @InjectMocks AuctionRealtimeFetcher fetcher;
+
+ private OpenApiAuctionRealtimeResponse dummy() {
+ return new OpenApiAuctionRealtimeResponse(
+ "페러시우스 타이탄 블레이드", "신성한 페러시우스 타이탄 블레이드", 1L, 100L, Instant.now(), null);
+ }
+
+ @Nested
+ @DisplayName("OPEN API 끝까지 호출 시나리오")
+ class NormalFlow {
+
+ @Test
+ @DisplayName("모든 페이지를 수집하고 cursor가 null이면 종료한다")
+ void fetchAllPages() {
+ // given
+ var page1 =
+ new OpenApiAuctionRealtimeListResponse(List.of(dummy(), dummy()), "cursor-1");
+ var page2 = new OpenApiAuctionRealtimeListResponse(List.of(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(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() {
+ // 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");
+
+ 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
+ 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());
+ }
+
+ @Test
+ @DisplayName("첫 응답이 null(Mono.empty)이면 빈 리스트를 반환한다")
+ void responseNull() {
+ when(client.fetchAuctionList(ItemCategory.SWORD, "")).thenReturn(Mono.empty());
+
+ FetchResult result = fetcher.fetch(ItemCategory.SWORD);
+
+ assertThat(result.items()).isEmpty();
+ verify(duplicateChecker, never()).checkDuplicateInBatch(any(), any());
+ }
+
+ @Test
+ @DisplayName("auctionItems()가 비어있으면 빈 리스트를 반환한다")
+ void auctionItemsEmpty() {
+ var page = new OpenApiAuctionRealtimeListResponse(List.of(), "ignored");
+
+ when(client.fetchAuctionList(ItemCategory.SWORD, "")).thenReturn(Mono.just(page));
+
+ FetchResult result = fetcher.fetch(ItemCategory.SWORD);
+
+ assertThat(result.items()).isEmpty();
+ verify(duplicateChecker, never()).checkDuplicateInBatch(any(), any());
+ }
+
+ @Test
+ @DisplayName("nextCursor가 빈 문자열이면 수집을 중단한다")
+ 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);
+
+ // then
+ assertThat(result.items()).hasSize(1);
+ verify(client, times(1)).fetchAuctionList(eq(ItemCategory.SWORD), any());
+ verifyNoMoreInteractions(client);
+ }
+
+ @Test
+ @DisplayName("중간 페이지의 auctionItems가 비어있으면 수집을 중단한다")
+ void stopWhenMiddlePageIsEmpty() {
+ // given
+ var page1 = new OpenApiAuctionRealtimeListResponse(List.of(dummy()), "cursor-1");
+ var emptyPage = new OpenApiAuctionRealtimeListResponse(List.of(), "cursor-2");
+
+ 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);
+
+ // then
+ assertThat(result.items()).hasSize(1);
+ verify(client, times(2)).fetchAuctionList(eq(ItemCategory.SWORD), any());
+ verifyNoMoreInteractions(client);
+ }
+
+ @Test
+ @DisplayName("응답 내 auctionItems 리스트가 null이면 수집을 중단한다")
+ void stopWhenAuctionItemsListIsNull() {
+ // given
+ var pageWithNullList = new OpenApiAuctionRealtimeListResponse(null, "cursor-1");
+ when(client.fetchAuctionList(ItemCategory.SWORD, ""))
+ .thenReturn(Mono.just(pageWithNullList));
+
+ // when
+ FetchResult result = fetcher.fetch(ItemCategory.SWORD);
+
+ // then
+ 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
new file mode 100644
index 00000000..a095f3b3
--- /dev/null
+++ b/src/test/java/until/the/eternity/auctionrealtime/domain/service/AuctionRealtimeDuplicateCheckerTest.java
@@ -0,0 +1,238 @@
+package until.the.eternity.auctionrealtime.domain.service;
+
+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;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.when;
+
+@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);
+ }
+ }
+}