로스트아크 시세 수집 기능
Updated:
LostArk Developer의 시세 조회 swagger를 보면 다음과 같이 Response를 출력한다.

ItemID는 https://lostarkcodex.com/us/items/에서 조회가 가능하다.
ERD
먼저 다음과 같이 DB를 설계한다.

BackEnd
Entity
@Entity
@Table(name = "market_item")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class MarketItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
@Column(name = "item_id", nullable = false)
private long itemId;
@Column(nullable = false, length = 255)
private String name;
@Column(name = "trade_remain_count")
private Integer tradeRemainCount;
@Column(name = "bundle_count")
private Integer bundleCount;
/*
아이템 등급은 DB에는 VARCHAR로 저장
*/
@Enumerated(EnumType.STRING)
private ItemGrade grade;
/*
ToolTip 원본 JSON 저장
MySQL JSON 타입과 연결
*/
@Column(columnDefinition = "JSON")
private String tooltip;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}
- Integer를 사용하는 이유: JPA에서는 @GeneratedValue는 DB에 값을 자동 생성하는데, 그 전에 id 필드는 null인 상태로 있어야 정상이다. 하지만 int는 null을 가질 수 없으므로 Integer로 사용
- @NoArgsConstructor(access = AccessLevel.PROTECTED): 아무런 매개변수가 없는 생성자를 생성하되 다른 패키지에 소속된 클래스는 접근을 불허한다
- 접근 권한을 Private로 하면 프폭시 객체 생성에 문제가 생기고, Public으로 하면 무분별한 객체 생성 및 setter를 통한 값 주입을 할 수 있기에 접근 권한을 Protected로 작성
- @GeneratedValue(strategy = GenerationType.IDENTITY): JPA에서 데이터베이스의 기본 키 값을 자동으로 생성해 주는 전략을 정의할 때 사용(AUTO_INCREMENT)
- GenerationType.SEQUENCE: 데이터베이스의 sequence 객체를 사용하여 ID를 생성, 대량의 Entity를 삽입할 때 적합
- GenerationType.TABLE: 데이터베이스에 별도의 테이블을 생성하여 ID 값을 관리, 성능이 떨어질 수 있음
- GenerationType.AUTO: JPA가 기본적으로 사용하는 전략, 데이터베이스에 적합한 전략을 자동으로 선택
public enum MarketItemGrade {
NORMAL, // 0, 일반
UNCOMMON, // 1, 고급
RARE, // 2, 희귀
EPIC, // 3, 영웅
LEGENDARY, // 4, 전설
RELIC, // 5, 유물
ANCIENT, // 6, 고대
SIDEREAL // 7, 에스더
}
@Entity
@Table(
name = "market_item_price_history",
uniqueConstraints = {
@UniqueConstraint(
name = "unique_item_price_date",
columnNames = {"item_id", "price_date"}
)
}
)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class MarketItemPriceHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "item_id", nullable = false)
private MarketItem marketItem;
@Column(name = "price_date", nullable = false)
private LocalDateTime priceDate;
@Column(name = "avg_price", nullable = false)
private Integer avgPrice;
@Column(name = "trade_count", nullable = false)
private Integer tradeCount;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
}
- @ManyToOne(fetch = FetchType.LAZY): 가격 히스토리를 조회할 때 굳이 item 전체를 끌어오지 않도록 최적화, 아이템 상세가 필요할 때만 join됨
- uniqueConstraints: 중복된 시세를 여러 번 저장하는 것 방지
Dao
public interface MarketItemRepository extends JpaRepository<Item, Long> {
Optional<Item> findByItemId(Long itemId);
}
- Optional<>: findByItemId가 DB에 이미 존재할 수도 있고, 없을 수도 있어 null이라는 것을 표현하기 위해 Item findByItemId 보다는 Optional
- 으로 사용
public interface MarketItemPriceHistoryRepository extends JpaRepository<ItemPriceHistory, Long> {
boolean existsByMarketItem_IdAndPriceDate(Long itemId, LocalDateTime priceDate);
}
Dto
Swagger Response를 보면 {Name, TradeRemainCount, BundleCount, Stats, Tooltip}을 주고, Stats에는 {Date, AvgPrice, TradeCount}가 포함된 구조이다. 따라서 DTO를 다음과 같이 설계했다.
@Getter
@NoArgsConstructor
public class MarketItemDetailResponse {
@JsonProperty("Name")
private String name;
@JsonProperty("TradeRemainCount")
private Integer tradeRemainCount;
@JsonProperty("BundleCount")
private Integer bundleCount;
@JsonProperty("Stats")
private List<ItemStatsResponse> stats;
@JsonProperty("Tooltip")
private String toolTip;
}
@Getter
@NoArgsConstructor
public class MarketItemStatsResponse {
@JsonProperty("Date")
private String date;
@JsonProperty("AvgPrice")
private Double avgPrice;
@JsonProperty("TradeCount")
private Integer tradeCount;
}
- @JsonProperty(): OpenAPI는 PascalCase로 주고, Java는 camelCase가 표준이기 때문에 @JsonProperty를 사용해 JSON 필드명과 Java 필드명을 매핑해줌
service
이제 service를 작성한다.
@Slf4j
@Service
@RequiredArgsConstructor
public class MarketItemService {
private final MarketItemRepository marketItemRepository;
private final MarketItemPriceHistoryRepository marketItemPriceHistoryRepository;
// MarketItem 시세 저장
@Transactional
public void saveItemMarketInfo(Long itemId, List<MarketItemDetailResponse> responses) {
if (responses == null || responses.isEmpty()) {
log.warn("OpenAPI 응답이 비어있습니다. itemId = {}", itemId);
return;
}
MarketItemDetailResponse dto = responses.get(1); // 1번 응답
MarketItem item = findOrCreateMarketItem(itemId, dto);
savePriceHistory(item, dto.getStats());
log.info("MarketItem 저장 완료 itemId={} / name={}", itemId, item.getName());
}
private MarketItem findOrCreateMarketItem(Long itemId, MarketItemDetailResponse dto) {
return marketItemRepository.findByItemId(itemId)
.orElseGet(() -> saveNewMarketItem(itemId, dto));
}
private MarketItem saveNewMarketItem(Long itemId, MarketItemDetailResponse dto) {
MarketItem item = MarketItem.builder()
.itemId(itemId)
.name(dto.getName())
.tradeRemainCount(dto.getTradeRemainCount())
.bundleCount(dto.getBundleCount())
.tooltip(dto.getToolTip())
.build();
return marketItemRepository.save(item);
}
private void savePriceHistory(MarketItem item, List<MarketItemStatsResponse> stats) {
if (stats == null || stats.isEmpty()) {
log.warn("Stats 데이터 없음 itemId={}", item.getItemId());
return;
}
for (MarketItemStatsResponse stat : stats) {
LocalDate date = LocalDate.parse(stat.getDate());
if (marketItemPriceHistoryRepository.existsByMarketItem_IdAndPriceDate(item.getItemId(), date)) {
continue;
}
MarketItemPriceHistory history = MarketItemPriceHistory.builder()
.marketItem(item)
.priceDate(date)
.avgPrice(stat.getAvgPrice())
.tradeCount(stat.getTradeCount())
.build();
marketItemPriceHistoryRepository.save(history);
}
}
}
- PriceHistory는 매일 저장되고 날짜 중복을 체크함
- 저장 로직 책임 분리(findOrCreate, saveNewItem, saveHistory)
WebClient로 OpenAPI 호출
OpenAPI를 호출하기 위해 “Spring Reactive web” dependency를 추가해준다. OpenAPI의 모든 요청에는 Authorization 헤더가 필요하므로 WebClientConfig를 만들어둔다.
@Configuration
@RequiredArgsConstructor
public class WebClientConfig {
@Value("${lostark.api.key}")
private String apiKey;
@Bean
public WebClient lostArkWebClient() {
return WebClient.builder()
.baseUrl("https://developer-lostark.game.onstove.com")
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.build();
}
}
이제 OpenAPI 호출을 하는 클래스를 작성한다.
@Component
@RequiredArgsConstructor
@Slf4j
public class MarketApiClient {
private final WebClient lostArkWebClient;
// 특정 itemId의 거래소 시세 조회
public List<MarketItemDetailResponse> getMarketItemDetail(Long itemId) {
String uri = "/markets/items/" + itemId;
try {
return lostArkWebClient.get()
.uri(uri)
.retrieve()
.bodyToFlux(MarketItemDetailResponse.class)
.collectList()
.block();
} catch (WebClientResponseException e) {
log.error("Lost Ark API 오류 발생: status={}, body={}",
e.getRawStatusCode(), e.getResponseBodyAsString());
throw e;
} catch (Exception e) {
log.error("Lost Ark API 호출 중 예외 발생", e);
throw e;
}
}
}
itemId로 GET 요청을 보내 market의 item을 조회해야 하므로, 위와 같이 작성한다.
- .retrieve(): 응답 수신 준비
- .bodyToFlux(MarketItemDetailResponse.class): 응답 JSON을 DTO로 변환
- .collectionList(): 비동기를 동기로 변경(Spring MVC 환경에서 필요)
이제 이 WebClient로 OpenAPI를 호출하기 위해 Service에 다음을 추가한다.
@Transactional
public void fetchAndSaveItemMarketInfo(Long itemId) {
List<MarketItemDetailResponse> responses = marketApiClient.getMarketItemDetail(itemId);
if (responses == null || responses.isEmpty()) {
log.warn("Empty response for itemId={}", itemId);
return;
}
saveItemMarketInfo(itemId, responses);
}
controller
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/market")
public class MarketItemController {
private final MarketItemService marketItemService;
@GetMapping("/fetch/{itemId}")
public ResponseEntity<String> fetchItem(@PathVariable Long itemId) {
marketItemService.fetchAndSaveItemMarketInfo(itemId);
return ResponseEntity.ok("Market item saved: " + itemId);
}
}
scheduler
이제 위 과정을 매일 반복하기 위해서 Scheduler를 작성한다.
@Component
@RequiredArgsConstructor
public class MarketItemScheduler {
private final MarketItemService marketItemService;
// 매일 새벽 4시에 실행
@Scheduled(cron = "10 0 0 * * *")
public void updateMarketPrices() {
// 수집 대상 itemId
List<Long> itemIds = List.of(0L);
for (Long id : itemIds) {
marketItemService.fetchAndSaveItemMarketInfo(id);
}
}
}
- corn = “10 0 0 * * *”: 특정 작업을 “초 분 시 일 월 요일” 단위로 주기적으로 실행
이는 메인 클래스에 @EnableScheduling을 선언하면 Cron 설정대로 자동 실행된다.
코드 수정
위 처럼 실행을 해보니, 이미 존재하는 데이터면 오류가 발생하고, 매일 7일치의 데이터를 불러오기 때문에 중복된 데이터를 계속 가져오고 있었다. 그래서 기존에 없는 아이템이면 7일 데이터를 모두 가져오고, 없으면 오늘 데이터만 가져오게 코드를 수정해 주었다. 또한 오늘을 포함한 데이터를 가져오면, 정확하지 않으므로, 어제 데이터까지만 가져오게 구현하였다.
먼저, 신규 여부를 확인하기 위해 record를 생성해 주고 findOrCreateMarketItem을 수정해주었다.
public record MarketItemResult(MarketItem marketItem, boolean isNew) { }
private MarketItemResult findOrCreateMarketItem(Long itemId, MarketItemDetailResponse dto) {
return marketItemRepository.findByItemId(itemId)
.map(existingItem -> new MarketItemResult(existingItem, false))
.orElseGet(() -> {
MarketItem newItem = saveNewMarketItem(itemId, dto);
return new MarketItemResult(newItem, true);
});
}
- record
- java 16부터 도입된 불변 데이터 전용 클래스, private final 필드들, 생성자, getter, equals(), hashCode(), toString()을 자동으로 만들어줌.
- 값이 바뀔 일이 없는 DTO 구조에 매우 적합
다음으로 saveItemMarketInfo()에서 신규 여부를 전달하고, savePriceHistory()에서 신규와 기존 아이템을 분기 처리한다.
@Transactional
public void saveItemMarketInfo(Long itemId, List<MarketItemDetailResponse> responses) {
if (responses == null || responses.isEmpty()) {
log.warn("OpenAPI 응답이 비어있습니다. itemId = {}", itemId);
return;
}
MarketItemDetailResponse dto = responses.get(1); // 1번 응답
MarketItemResult result = findOrCreateMarketItem(itemId, dto);
savePriceHistory(result.marketItem(), dto.getStats(), result.isNew());
log.info("MarketItem 저장 완료 itemId={} / name={} / 신규여부={}", itemId, result.marketItem().getName(), result.isNew());
}
private void savePriceHistory(MarketItem item, List<MarketItemStatsResponse> stats, boolean isNewItem) {
if (stats == null || stats.isEmpty()) {
log.warn("Stats 데이터 없음 itemId={}", item.getItemId());
return;
}
if (isNewItem) {
LocalDate today = LocalDate.now();
// 신규 아이템 >> 14일(오늘 제외) 전체 저장
for (MarketItemStatsResponse stat : stats) {
LocalDate date = LocalDate.parse(stat.getDate());
// 오늘 데이터는 스킵
if (date.equals(today)) {
continue;
}
LocalDate date = LocalDate.parse(stat.getDate());
if (marketItemPriceHistoryRepository.existsByMarketItem_IdAndPriceDate(item.getItemId(), date)) {
continue;
}
MarketItemPriceHistory history = MarketItemPriceHistory.builder()
.marketItem(item)
.priceDate(date)
.avgPrice(stat.getAvgPrice())
.tradeCount(stat.getTradeCount())
.build();
marketItemPriceHistoryRepository.save(history);
}
log.info("신규 아이템 14일 시세(오늘 제외) 저장 완료 itemId={}", item.getItemId());
return;
}
// 기존 아이템 >> 어제 데이터만 저장
MarketItemStatsResponse yesterdayStat = extractYesterdayStat(stats);
if (yesterdayStat == null) {
log.warn("어제 날짜 데이터가 없습니다. itemId={}, yesterday={}", item.getItemId(), LocalDate.now().minusDays(1));
return;
}
LocalDate yesterday = LocalDate.parse(yesterdayStat.getDate());
if (marketItemPriceHistoryRepository.existsByMarketItem_IdAndPriceDate(item.getId(), yesterday)) {
log.info("이미 저장된 어제 데이터 itemId={}, date={}", item.getItemId(), yesterday);
return;
}
MarketItemPriceHistory history = MarketItemPriceHistory.builder()
.marketItem(item)
.priceDate(yesterday)
.avgPrice(yesterdayStat.getAvgPrice())
.tradeCount(yesterdayStat.getTradeCount())
.build();
marketItemPriceHistoryRepository.save(history);
log.info("기존 아이템 어제 데이터 저장 완료 itemId={}", item.getItemId());
}
// 어제 날짜 데이터만 가져오기
private MarketItemStatsResponse extractYesterdayStat(List<MarketItemStatsResponse> stats) {
LocalDate yesterday = LocalDate.now().minusDays(1);
return stats.stream()
.filter(s -> LocalDate.parse(s.getDate()).equals(yesterday))
.findFirst()
.orElse(null);
}
또한 각인서는 1번째 데이터가 실제 데이터고, 재련 재료 등은 0번째 데이터가 실제 데이터인 점이 있었다. 그래서 service에서 실제 데이터를 찾는 메서드를 추가했다.
// 실제 데이터를 찾기
private MarketItemDetailResponse pickRealMarketResponse(List<MarketItemDetailResponse> responses) {
// Case 1. 각인서: responses.get(1)이 실제 데이터 (TradeRemainCount != null)
if (responses.size() > 1 && responses.get(1).getTradeRemainCount() != null) {
return responses.get(1);
}
// Case 2. 재련재료: responses.get(0)이 실제 데이터 (TradeRemainCount == null)
return responses.get(0);
}
test code
@SpringBootTest
@Transactional
class MarketItemServiceTest {
@Autowired
private MarketItemService marketItemService;
@MockitoBean
private MarketItemRepository marketItemRepository;
@MockitoBean
private MarketItemPriceHistoryRepository marketItemPriceHistoryRepository;
@MockitoBean
private MarketApiClient marketApiClient;
// ...
}
- @SpringBootTest: ApplicationContext를 로드해, 테스트환경에서도 스프링 환경과 Bean들을 주입받아서 사용할 수 있게 된다.
- @Transactional: 각각의 테스트 메서드에 대해 트랜잭션을 시작하고, 테스트가 종료되면 롤백
@Test
@DisplayName("신규 아이템이면 14일 데이터 중에서 오늘을 제외한 7일 데이터를 모두 저장")
void save_new_item_13days() {
// given
Long itemId = 6812005L; // 달인용 제작 키트
MarketItemDetailResponse detail = new MarketItemDetailResponse();
detail.setName("달인용 제작 키트");
detail.setTradeRemainCount(null);
detail.setBundleCount(1);
List<MarketItemStatsResponse> stats = List.of(
new MarketItemStatsResponse(LocalDate.now().minusDays(1).toString(), 100.0, 20),
new MarketItemStatsResponse(LocalDate.now().minusDays(2).toString(), 200.0, 30)
);
detail.setStats(stats);
// 각인서는 응답이 [dummy, 실제], 생활재료·재련재료는 [실제] 로 Response가 반환
given(marketApiClient.getMarketItemDetail(itemId))
.willReturn(List.of(detail));
// 신규 아이템
given(marketItemRepository.findByItemId(itemId))
.willReturn(Optional.empty());
// 아이템 저장 시 ID를 부여해서 반환
given(marketItemRepository.save(any(MarketItem.class)))
.willAnswer(inv -> {
MarketItem m = inv.getArgument(0);
m.setId(1L);
return m;
});
// when
marketItemService.fetchAndSaveItemMarketInfo(itemId);
// then
then(marketItemPriceHistoryRepository)
.should(times(2))
.save(any(MarketItemPriceHistory.class));
}
- given
- 달인용 제작 키트의 TradeReaminCount가 null이므로 pickRealMarketResponse()는 responses.get(0)을 선택
- 신규 아이템일 때는 stats 전체 저장하므로, stats 리스트의 모든 데이터, 총 2번 save()가 호출되여야 함.
- given(marketApiClient.getMarketItemDetail(itemId)).willReturn(List.of(detail));
- 생활 재료는 리스트가 1개이므로, detail를 반환 할 것
- given(marketItemRepository.findByItemId(itemId)).willReturn(Optional.empty());
- 9812005번 아이템은 신규 아이템이므로, DB에 존재하지 않음
- 신규 아이템 저장 시 ID = 1을 설정해 주었다.
- when
- 신규 아이템 저장
- then
- 신규 아이템이므로 stats 2개 모두 저장
- save() 가 2번 호출 되었는지 검증
@Test
@DisplayName("기존 아이템이면 어제 데이터만 저장")
void save_existing_item_yesterday_only() {
// given
Long itemId = 95203905L; // 유물 아드레날린 각인서
MarketItem existingItem = MarketItem.builder()
.id(1L)
.itemId(itemId)
.name("유물 아드레날린 각인서")
.build();
given(marketItemRepository.findByItemId(itemId))
.willReturn(Optional.of(existingItem));
LocalDate yesterday = LocalDate.now().minusDays(1);
MarketItemStatsResponse yesterdayStat = new MarketItemStatsResponse(yesterday.toString(), 100.0, 20);
MarketItemDetailResponse detail = new MarketItemDetailResponse();
detail.setName("유물 아드레날린 각인서");
detail.setStats(List.of(yesterdayStat));
given(marketApiClient.getMarketItemDetail(itemId))
.willReturn(List.of(detail));
// 어제 데이터는 DB에 존재하지 않음
given(marketItemPriceHistoryRepository.existsByMarketItem_IdAndPriceDate(existingItem.getId(), yesterday))
.willReturn(false);
// when
marketItemService.fetchAndSaveItemMarketInfo(itemId);
// then
then(marketItemPriceHistoryRepository)
.should(times(1))
.save(any(MarketItemPriceHistory.class));
}
댓글남기기