diff --git a/build.gradle b/build.gradle index 23124cc..f172b5f 100644 --- a/build.gradle +++ b/build.gradle @@ -9,7 +9,7 @@ version = '0.0.1-SNAPSHOT' java { toolchain { - languageVersion = JavaLanguageVersion.of(17) + languageVersion = JavaLanguageVersion.of(21) } } diff --git a/src/main/java/naughty/tuzamate/auth/hantu/scheduling/HantuStockApiRefreshScheduler.java b/src/main/java/naughty/tuzamate/auth/hantu/scheduling/HantuStockApiRefreshScheduler.java index fa63b22..a320d47 100644 --- a/src/main/java/naughty/tuzamate/auth/hantu/scheduling/HantuStockApiRefreshScheduler.java +++ b/src/main/java/naughty/tuzamate/auth/hantu/scheduling/HantuStockApiRefreshScheduler.java @@ -2,8 +2,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import naughty.tuzamate.domain.stock.service.KrxService; -import naughty.tuzamate.domain.stock.service.NasdaqService; +import naughty.tuzamate.domain.stock.service.compare.async.AsyncKrxService; +import naughty.tuzamate.domain.stock.service.compare.async.AsyncNasdaqService; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @@ -19,8 +19,8 @@ @ConditionalOnProperty(name = "hantu.stock.schedule.enabled", havingValue = "true") public class HantuStockApiRefreshScheduler { - private final KrxService krxService; - private final NasdaqService nasdaqService; + private final AsyncKrxService krxService; + private final AsyncNasdaqService asyncNasdaqService; @Scheduled(cron = "0 39 13 * * *") public void refreshStockData() { @@ -32,7 +32,7 @@ public void refreshStockData() { log.info("Hantu Krx API 데이터 갱신 완료"); log.info("Hantu Nasdaq API 데이터 갱신 시작"); - nasdaqService.saveNasdaqStocksInfo(); + asyncNasdaqService.saveNasdaqStocksInfo(); log.info("Hantu Nasdaq API 데이터 갱신 완료"); log.info("Hantu Stock API 데이터 갱신 완료"); diff --git a/src/main/java/naughty/tuzamate/domain/stock/controller/StockController.java b/src/main/java/naughty/tuzamate/domain/stock/controller/StockController.java index 9a548b7..872b738 100644 --- a/src/main/java/naughty/tuzamate/domain/stock/controller/StockController.java +++ b/src/main/java/naughty/tuzamate/domain/stock/controller/StockController.java @@ -8,7 +8,13 @@ import naughty.tuzamate.domain.stock.dto.krx.KrxDto; import naughty.tuzamate.domain.stock.dto.nasdaq.NasdaqDto; import naughty.tuzamate.domain.stock.error.StockErrorCode; -import naughty.tuzamate.domain.stock.service.*; +import naughty.tuzamate.domain.stock.service.common.KrxFinancialService; +import naughty.tuzamate.domain.stock.service.common.KrxInquireService; +import naughty.tuzamate.domain.stock.service.common.StockCodeService; +import naughty.tuzamate.domain.stock.service.common.StockInfoService; +import naughty.tuzamate.domain.stock.service.compare.async.AsyncKrxService; +import naughty.tuzamate.domain.stock.service.compare.async.AsyncNasdaqService; +import naughty.tuzamate.domain.stock.service.compare.async.AsyncNasdaqStockFetcher; import naughty.tuzamate.global.apiPayload.CustomResponse; import naughty.tuzamate.global.success.GeneralSuccessCode; import org.springframework.web.bind.annotation.PathVariable; @@ -24,8 +30,8 @@ public class StockController { private final StockCodeService stockCodeService; - private final NasdaqService nasdaqService; - private final KrxService krxService; + private final AsyncNasdaqService asyncNasdaqService; + private final AsyncKrxService krxService; private final KrxInquireService krxInquireService; private final KrxFinancialService krxFinancialService; private final StockInfoService stockInfoService; @@ -57,7 +63,7 @@ public NasdaqDto.NasdaqInfoDto getNasdaqStockInfo(@PathVariable("nasdaqStockCode description = "나스닥 주식 전체 정보를 가져오고 저장합니다. 주식 코드를 입력하지 않아도 됩니다.") public CustomResponse getAllNasdaqStockInfo() { - nasdaqService.saveNasdaqStocksInfo(); + asyncNasdaqService.saveNasdaqStocksInfo(); return CustomResponse.onSuccess(GeneralSuccessCode.CREATED, "나스닥 주식 전체 정보 저장 완료"); } diff --git a/src/main/java/naughty/tuzamate/domain/stock/service/KrxFinancialService.java b/src/main/java/naughty/tuzamate/domain/stock/service/common/KrxFinancialService.java similarity index 98% rename from src/main/java/naughty/tuzamate/domain/stock/service/KrxFinancialService.java rename to src/main/java/naughty/tuzamate/domain/stock/service/common/KrxFinancialService.java index 96c54bb..6893ce6 100644 --- a/src/main/java/naughty/tuzamate/domain/stock/service/KrxFinancialService.java +++ b/src/main/java/naughty/tuzamate/domain/stock/service/common/KrxFinancialService.java @@ -1,4 +1,4 @@ -package naughty.tuzamate.domain.stock.service; +package naughty.tuzamate.domain.stock.service.common; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/src/main/java/naughty/tuzamate/domain/stock/service/KrxInquireService.java b/src/main/java/naughty/tuzamate/domain/stock/service/common/KrxInquireService.java similarity index 98% rename from src/main/java/naughty/tuzamate/domain/stock/service/KrxInquireService.java rename to src/main/java/naughty/tuzamate/domain/stock/service/common/KrxInquireService.java index 82b096f..c6420cd 100644 --- a/src/main/java/naughty/tuzamate/domain/stock/service/KrxInquireService.java +++ b/src/main/java/naughty/tuzamate/domain/stock/service/common/KrxInquireService.java @@ -1,4 +1,4 @@ -package naughty.tuzamate.domain.stock.service; +package naughty.tuzamate.domain.stock.service.common; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/src/main/java/naughty/tuzamate/domain/stock/service/StockCodeService.java b/src/main/java/naughty/tuzamate/domain/stock/service/common/StockCodeService.java similarity index 99% rename from src/main/java/naughty/tuzamate/domain/stock/service/StockCodeService.java rename to src/main/java/naughty/tuzamate/domain/stock/service/common/StockCodeService.java index 3d74633..cacf263 100644 --- a/src/main/java/naughty/tuzamate/domain/stock/service/StockCodeService.java +++ b/src/main/java/naughty/tuzamate/domain/stock/service/common/StockCodeService.java @@ -1,4 +1,4 @@ -package naughty.tuzamate.domain.stock.service; +package naughty.tuzamate.domain.stock.service.common; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/naughty/tuzamate/domain/stock/service/StockInfoService.java b/src/main/java/naughty/tuzamate/domain/stock/service/common/StockInfoService.java similarity index 98% rename from src/main/java/naughty/tuzamate/domain/stock/service/StockInfoService.java rename to src/main/java/naughty/tuzamate/domain/stock/service/common/StockInfoService.java index 1704dcb..6165a1b 100644 --- a/src/main/java/naughty/tuzamate/domain/stock/service/StockInfoService.java +++ b/src/main/java/naughty/tuzamate/domain/stock/service/common/StockInfoService.java @@ -1,4 +1,4 @@ -package naughty.tuzamate.domain.stock.service; +package naughty.tuzamate.domain.stock.service.common; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/src/main/java/naughty/tuzamate/domain/stock/service/KrxService.java b/src/main/java/naughty/tuzamate/domain/stock/service/compare/async/AsyncKrxService.java similarity index 90% rename from src/main/java/naughty/tuzamate/domain/stock/service/KrxService.java rename to src/main/java/naughty/tuzamate/domain/stock/service/compare/async/AsyncKrxService.java index 642c63d..5742250 100644 --- a/src/main/java/naughty/tuzamate/domain/stock/service/KrxService.java +++ b/src/main/java/naughty/tuzamate/domain/stock/service/compare/async/AsyncKrxService.java @@ -1,26 +1,22 @@ -package naughty.tuzamate.domain.stock.service; +package naughty.tuzamate.domain.stock.service.compare.async; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import naughty.tuzamate.domain.stock.dto.StockInfoDto; -import naughty.tuzamate.domain.stock.dto.krx.KrxDto; import naughty.tuzamate.domain.stock.entity.KrxStockInfo; import naughty.tuzamate.domain.stock.entity.StockCode; import naughty.tuzamate.domain.stock.repository.KrxStockInfoRepository; import naughty.tuzamate.domain.stock.repository.code.StockCodeRepository; -import naughty.tuzamate.domain.stock.strategy.FilterStrategy; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Optional; import java.util.concurrent.CompletableFuture; -import java.util.stream.Collectors; @Service @RequiredArgsConstructor @Slf4j -public class KrxService { +public class AsyncKrxService { private final StockCodeRepository stockCodeRepository; private final KrxStockInfoRepository krxStockInfoRepository; diff --git a/src/main/java/naughty/tuzamate/domain/stock/service/AsyncKrxStockFetcher.java b/src/main/java/naughty/tuzamate/domain/stock/service/compare/async/AsyncKrxStockFetcher.java similarity index 92% rename from src/main/java/naughty/tuzamate/domain/stock/service/AsyncKrxStockFetcher.java rename to src/main/java/naughty/tuzamate/domain/stock/service/compare/async/AsyncKrxStockFetcher.java index 16db732..5d37f6e 100644 --- a/src/main/java/naughty/tuzamate/domain/stock/service/AsyncKrxStockFetcher.java +++ b/src/main/java/naughty/tuzamate/domain/stock/service/compare/async/AsyncKrxStockFetcher.java @@ -1,4 +1,4 @@ -package naughty.tuzamate.domain.stock.service; +package naughty.tuzamate.domain.stock.service.compare.async; import com.google.common.util.concurrent.RateLimiter; import lombok.RequiredArgsConstructor; @@ -6,6 +6,9 @@ import naughty.tuzamate.domain.stock.dto.StockInfoDto; import naughty.tuzamate.domain.stock.dto.krx.KrxDto; import naughty.tuzamate.domain.stock.entity.KrxStockInfo; +import naughty.tuzamate.domain.stock.service.common.KrxFinancialService; +import naughty.tuzamate.domain.stock.service.common.KrxInquireService; +import naughty.tuzamate.domain.stock.service.common.StockInfoService; import naughty.tuzamate.domain.stock.strategy.FilterStrategy; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; diff --git a/src/main/java/naughty/tuzamate/domain/stock/service/NasdaqService.java b/src/main/java/naughty/tuzamate/domain/stock/service/compare/async/AsyncNasdaqService.java similarity index 78% rename from src/main/java/naughty/tuzamate/domain/stock/service/NasdaqService.java rename to src/main/java/naughty/tuzamate/domain/stock/service/compare/async/AsyncNasdaqService.java index 2a92932..c393e7e 100644 --- a/src/main/java/naughty/tuzamate/domain/stock/service/NasdaqService.java +++ b/src/main/java/naughty/tuzamate/domain/stock/service/compare/async/AsyncNasdaqService.java @@ -1,33 +1,22 @@ -package naughty.tuzamate.domain.stock.service; +package naughty.tuzamate.domain.stock.service.compare.async; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import naughty.tuzamate.auth.hantu.service.HantuApiTokenService; -import naughty.tuzamate.domain.stock.dto.StockInfoDto; -import naughty.tuzamate.domain.stock.dto.nasdaq.NasdaqDto; import naughty.tuzamate.domain.stock.entity.NasdaqStockCode; import naughty.tuzamate.domain.stock.entity.NasdaqStockInfo; import naughty.tuzamate.domain.stock.repository.NasdaqStockInfoRepository; import naughty.tuzamate.domain.stock.repository.code.NasdaqCodeRepository; -import naughty.tuzamate.domain.stock.strategy.FilterStrategy; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.*; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.client.RestTemplate; -import org.springframework.web.util.UriComponentsBuilder; import java.util.List; import java.util.Optional; import java.util.concurrent.CompletableFuture; -import java.util.stream.Collectors; @Service @RequiredArgsConstructor @Slf4j -public class NasdaqService { +public class AsyncNasdaqService { private final NasdaqCodeRepository nasdaqCodeRepository; private final NasdaqStockInfoRepository nasdaqStockInfoRepository; diff --git a/src/main/java/naughty/tuzamate/domain/stock/service/AsyncNasdaqStockFetcher.java b/src/main/java/naughty/tuzamate/domain/stock/service/compare/async/AsyncNasdaqStockFetcher.java similarity index 97% rename from src/main/java/naughty/tuzamate/domain/stock/service/AsyncNasdaqStockFetcher.java rename to src/main/java/naughty/tuzamate/domain/stock/service/compare/async/AsyncNasdaqStockFetcher.java index bbc7f30..c25b2b0 100644 --- a/src/main/java/naughty/tuzamate/domain/stock/service/AsyncNasdaqStockFetcher.java +++ b/src/main/java/naughty/tuzamate/domain/stock/service/compare/async/AsyncNasdaqStockFetcher.java @@ -1,4 +1,4 @@ -package naughty.tuzamate.domain.stock.service; +package naughty.tuzamate.domain.stock.service.compare.async; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -9,6 +9,7 @@ import naughty.tuzamate.domain.stock.dto.StockInfoDto; import naughty.tuzamate.domain.stock.dto.nasdaq.NasdaqDto; import naughty.tuzamate.domain.stock.entity.NasdaqStockInfo; +import naughty.tuzamate.domain.stock.service.common.StockInfoService; import naughty.tuzamate.domain.stock.strategy.FilterStrategy; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.*; diff --git a/src/main/java/naughty/tuzamate/domain/stock/service/compare/sync/SyncKrxServiceRefined.java b/src/main/java/naughty/tuzamate/domain/stock/service/compare/sync/SyncKrxServiceRefined.java new file mode 100644 index 0000000..e6d3111 --- /dev/null +++ b/src/main/java/naughty/tuzamate/domain/stock/service/compare/sync/SyncKrxServiceRefined.java @@ -0,0 +1,241 @@ +package naughty.tuzamate.domain.stock.service.compare.sync; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import naughty.tuzamate.domain.stock.dto.StockInfoDto; +import naughty.tuzamate.domain.stock.dto.krx.KrxDto; +import naughty.tuzamate.domain.stock.entity.KrxStockInfo; +import naughty.tuzamate.domain.stock.entity.StockCode; +import naughty.tuzamate.domain.stock.repository.KrxStockInfoRepository; +import naughty.tuzamate.domain.stock.repository.code.StockCodeRepository; +import naughty.tuzamate.domain.stock.service.common.KrxFinancialService; +import naughty.tuzamate.domain.stock.service.common.KrxInquireService; +import naughty.tuzamate.domain.stock.service.common.StockInfoService; +import naughty.tuzamate.domain.stock.service.support.StockRequestRateLimiter; +import naughty.tuzamate.domain.stock.strategy.FilterStrategy; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.util.StopWatch; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +@Slf4j +public class SyncKrxServiceRefined { + + private final StockCodeRepository stockCodeRepository; + private final KrxInquireService krxInquireService; + private final KrxFinancialService krxFinancialService; + private final KrxStockInfoRepository krxStockInfoRepository; + private final StockInfoService stockInfoService; + private final FilterStrategy filterStrategy; + private final StockRequestRateLimiter stockRequestRateLimiter; + + @Value("${stock.collect.batch-size}") + private int batchSize; + + public void saveKrxStocksInfo() { + + List stockCodeList = stockCodeRepository.findAll(); + KrxCollectionMetrics metrics = new KrxCollectionMetrics(stockCodeList.size()); + + StopWatch stopWatch = new StopWatch(); + stopWatch.start(); + log.info("주식 저장 시작"); + + krxStockInfoRepository.deleteAllInBatch(); + List batchBuffer = new ArrayList<>(batchSize); + + for (StockCode stockCode : stockCodeList) { + try { + // sleep 대신 동기 RateLimiter로 요청 간격 제어 + stockRequestRateLimiter.acquire(); + + long inquireStart = System.nanoTime(); + // 주식 코드를 이용해 현재가, PER, PBR, 업종 한글 종목명 조회 + KrxDto.InquireDto currentPerPbrOutputDto = krxInquireService.getCurInquireInfo(stockCode.getCode()); + metrics.addInquireApiNanos(System.nanoTime() - inquireStart); + + long financialStart = System.nanoTime(); + // 주식 코드를 이용해 EPS 값 조회 + KrxDto.FinancialDto currentFinanceOutputDto = krxFinancialService.getCurFinancialInfo(stockCode.getCode()); + metrics.addFinancialApiNanos(System.nanoTime() - financialStart); + + if (filterStrategy.shouldSkipKrx(currentPerPbrOutputDto, currentFinanceOutputDto)) { + metrics.incrementSkippedCount(); + log.info("PER or PBR or EPS is zero: {}", stockCode.getCode()); + continue; + } + + long stockInfoStart = System.nanoTime(); + StockInfoDto.InfoDto currentKrxStockInfoDto = stockInfoService.getStockInfo(stockCode.getCode(), "300"); + metrics.addStockInfoApiNanos(System.nanoTime() - stockInfoStart); + + + KrxDto.KrxStockInfoDto stockInfoDto = new KrxDto.KrxStockInfoDto(); + + KrxStockInfo stockInfo = stockInfoDto.toEntity( + currentPerPbrOutputDto, + currentFinanceOutputDto, + currentKrxStockInfoDto + ); + + batchBuffer.add(stockInfo); + flushBatchIfNeeded(batchBuffer, metrics); + metrics.incrementSavedCount(); + + log.info("Saved stocks is : {}", stockCode.getCode()); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); // 현재 스레드 인터럽트 상태 복구 + log.info("Thread Interrupted : {}", e.getMessage()); + break; + } catch (Exception e) { + metrics.incrementFailedCount(); + log.info("Error stock code is {} : {} and pass!", stockCode.getCode(), e.getMessage()); + } + } + flushBatch(batchBuffer, metrics); + + stopWatch.stop(); + logCollectionMetrics(stopWatch, metrics); + log.info("주식 저장 종료. 총 소요 시간: {} seconds", stopWatch.getTotalTimeSeconds()); + } + + private void flushBatchIfNeeded(List batchBuffer, KrxCollectionMetrics metrics) { + if (batchBuffer.size() >= batchSize) { + flushBatch(batchBuffer, metrics); + } + } + + private void flushBatch(List batchBuffer, KrxCollectionMetrics metrics) { + if (batchBuffer.isEmpty()) { + return; + } + // 단건 save 반복 대신 배치 저장 + long batchSaveStart = System.nanoTime(); + krxStockInfoRepository.saveAll(batchBuffer); + metrics.addBatchSaveNanos(System.nanoTime() - batchSaveStart); + metrics.incrementBatchFlushCount(); + batchBuffer.clear(); + } + + private void logCollectionMetrics(StopWatch stopWatch, KrxCollectionMetrics metrics) { + long totalNanos = stopWatch.getTotalTimeNanos(); + long apiNanos = metrics.getTotalApiNanos(); + long batchSaveNanos = metrics.getBatchSaveNanos(); + + log.info( + "KRX 수집 성능 요약 totalStocks={}, saved={}, skipped={}, failed={}, batchFlushes={}, totalMs={}, apiTotalMs={}, inquireMs={}, financialMs={}, stockInfoMs={}, batchSaveMs={}, nonApiNonDbMs={}", + metrics.getTotalStocks(), + metrics.getSavedCount(), + metrics.getSkippedCount(), + metrics.getFailedCount(), + metrics.getBatchFlushCount(), + nanosToMillis(totalNanos), + nanosToMillis(apiNanos), + nanosToMillis(metrics.getInquireApiNanos()), + nanosToMillis(metrics.getFinancialApiNanos()), + nanosToMillis(metrics.getStockInfoApiNanos()), + nanosToMillis(batchSaveNanos), + nanosToMillis(Math.max(0L, totalNanos - apiNanos - batchSaveNanos)) + ); + } + + private long nanosToMillis(long nanos) { + return TimeUnit.NANOSECONDS.toMillis(nanos); + } + + @Getter + private static class KrxCollectionMetrics { + private final int totalStocks; + private int savedCount; + private int skippedCount; + private int failedCount; + private int batchFlushCount; + private long inquireApiNanos; + private long financialApiNanos; + private long stockInfoApiNanos; + private long batchSaveNanos; + + private KrxCollectionMetrics(int totalStocks) { + this.totalStocks = totalStocks; + } + + private void incrementSavedCount() { + savedCount++; + } + + private void incrementSkippedCount() { + skippedCount++; + } + + private void incrementFailedCount() { + failedCount++; + } + + private void incrementBatchFlushCount() { + batchFlushCount++; + } + + private void addInquireApiNanos(long nanos) { + inquireApiNanos += nanos; + } + + private void addFinancialApiNanos(long nanos) { + financialApiNanos += nanos; + } + + private void addStockInfoApiNanos(long nanos) { + stockInfoApiNanos += nanos; + } + + private void addBatchSaveNanos(long nanos) { + batchSaveNanos += nanos; + } + + private int getTotalStocks() { + return totalStocks; + } + + private int getSavedCount() { + return savedCount; + } + + private int getSkippedCount() { + return skippedCount; + } + + private int getFailedCount() { + return failedCount; + } + + private int getBatchFlushCount() { + return batchFlushCount; + } + + private long getInquireApiNanos() { + return inquireApiNanos; + } + + private long getFinancialApiNanos() { + return financialApiNanos; + } + + private long getStockInfoApiNanos() { + return stockInfoApiNanos; + } + + private long getBatchSaveNanos() { + return batchSaveNanos; + } + + private long getTotalApiNanos() { + return inquireApiNanos + financialApiNanos + stockInfoApiNanos; + } + } +} diff --git a/src/main/java/naughty/tuzamate/domain/stock/service/compare/sync/SyncNasdaqServiceRefined.java b/src/main/java/naughty/tuzamate/domain/stock/service/compare/sync/SyncNasdaqServiceRefined.java new file mode 100644 index 0000000..5120660 --- /dev/null +++ b/src/main/java/naughty/tuzamate/domain/stock/service/compare/sync/SyncNasdaqServiceRefined.java @@ -0,0 +1,173 @@ +package naughty.tuzamate.domain.stock.service.compare.sync; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import naughty.tuzamate.auth.hantu.service.HantuApiTokenService; +import naughty.tuzamate.domain.stock.dto.StockInfoDto; +import naughty.tuzamate.domain.stock.dto.nasdaq.NasdaqDto; +import naughty.tuzamate.domain.stock.entity.NasdaqStockCode; +import naughty.tuzamate.domain.stock.entity.NasdaqStockInfo; +import naughty.tuzamate.domain.stock.repository.NasdaqStockInfoRepository; +import naughty.tuzamate.domain.stock.repository.code.NasdaqCodeRepository; +import naughty.tuzamate.domain.stock.service.common.StockInfoService; +import naughty.tuzamate.domain.stock.service.support.StockApiRetryExecutor; +import naughty.tuzamate.domain.stock.service.support.StockRequestRateLimiter; +import naughty.tuzamate.domain.stock.strategy.FilterStrategy; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class SyncNasdaqServiceRefined { + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + private final NasdaqCodeRepository nasdaqCodeRepository; + private final NasdaqStockInfoRepository nasdaqStockInfoRepository; + private final HantuApiTokenService hantuApiTokenService; + private final StockInfoService stockInfoService; + private final FilterStrategy filterStrategy; + private final StockRequestRateLimiter stockRequestRateLimiter; + private final StockApiRetryExecutor stockApiRetryExecutor; + + @Value("${stock.collect.batch-size:200}") + private int batchSize; + + @Value("${tuza.api.APP_KEY}") + private String appKey; + + @Value("${tuza.api.APP_SECRET_KEY}") + private String appSecret; + + private String accessToken; + + private HttpHeaders createHeaders() { + HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.setContentType(MediaType.APPLICATION_JSON); + accessToken = hantuApiTokenService.getCurrentAccessToken(); + + httpHeaders.setBearerAuth(accessToken); + httpHeaders.set("appkey", appKey); + httpHeaders.set("appsecret", appSecret); + httpHeaders.set("tr_id", "HHDFS76200200"); + httpHeaders.set("custtype", "P"); + + return httpHeaders; + } + + private NasdaqDto.NasdaqInfoDto parsingCurrentNasdaqInfo(String response, String stockCode) { + NasdaqDto.NasdaqInfoDto data = new NasdaqDto.NasdaqInfoDto(); + + try { + JsonNode rootNode = objectMapper.readTree(response); + JsonNode node = rootNode.path("output"); + + if (node != null) { + NasdaqDto.NasdaqInfoDto outputDto = new NasdaqDto.NasdaqInfoDto(); + + outputDto.setCode(stockCode); + outputDto.setPerx(node.path("perx").asText()); + outputDto.setPbrx(node.path("pbrx").asText()); + outputDto.setEpsx(node.path("epsx").asText()); + outputDto.setE_icod(node.path("e_icod").asText()); + outputDto.setLast(node.path("last").asText()); + + + data = outputDto; + } + return data; + } catch (Exception e) { + log.error("error is : {}", e.getMessage()); + throw new RuntimeException(); + } + } + + public NasdaqDto.NasdaqInfoDto getCurrentNasdaqInfo(String stockCode) { + // 일시 오류 구간은 재시도하여 누락을 줄인다. + return stockApiRetryExecutor.execute("NASDAQ price-detail", () -> { + HttpHeaders header = createHeaders(); + + String url = "https://openapi.koreainvestment.com:9443/uapi/overseas-price/v1/quotations/price-detail"; + + HttpEntity httpEntity = new HttpEntity<>(header); + + UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(url) + .queryParam("AUTH", "") + .queryParam("EXCD", "NAS") + .queryParam("SYMB", stockCode); + + ResponseEntity response = restTemplate.exchange( + builder.toUriString(), + HttpMethod.GET, + httpEntity, + String.class + ); + + return parsingCurrentNasdaqInfo(response.getBody(), stockCode); + }); + + } + + public void saveNasdaqStocksInfo() { + List stockCodeList = nasdaqCodeRepository.findAll(); + + nasdaqStockInfoRepository.deleteAllInBatch(); + List batchBuffer = new ArrayList<>(batchSize); + + for (NasdaqStockCode stockCode : stockCodeList) { + try { + + // sleep 대신 동기 RateLimiter로 요청 간격 제어 + stockRequestRateLimiter.acquire(); + + NasdaqDto.NasdaqInfoDto currentNasdaqInfo = getCurrentNasdaqInfo(stockCode.getCode()); + StockInfoDto.InfoDto currentStockInfo = stockInfoService.getStockInfo(stockCode.getCode(), "512"); + + if (filterStrategy.shouldSkipNasdaq(currentNasdaqInfo)) { + log.info("PER or PBR or EPS is zero: {}", stockCode.getCode()); + continue; // 필터 전략에 의해 스킵된 경우 다음 주식 코드로 넘어감 + } + + NasdaqStockInfo entity = currentNasdaqInfo.toEntity(currentNasdaqInfo, currentStockInfo); + + batchBuffer.add(entity); + flushBatchIfNeeded(batchBuffer); + + log.info("Saved stocks is : {}", stockCode.getCode()); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); // 현재 스레드 인터럽트 상태 복구 + log.info("Thread Interrupted : {}", e.getMessage()); + break; + } catch (Exception e) { + log.info("Error stock code is {} : {} and pass!", stockCode.getCode(), e.getMessage()); + } + + } + flushBatch(batchBuffer); + } + + private void flushBatchIfNeeded(List batchBuffer) { + if (batchBuffer.size() >= batchSize) { + flushBatch(batchBuffer); + } + } + + private void flushBatch(List batchBuffer) { + if (batchBuffer.isEmpty()) { + return; + } + // 단건 save 반복 대신 배치 저장 + nasdaqStockInfoRepository.saveAll(batchBuffer); + batchBuffer.clear(); + } +} diff --git a/src/main/java/naughty/tuzamate/domain/stock/service/support/StockApiRetryExecutor.java b/src/main/java/naughty/tuzamate/domain/stock/service/support/StockApiRetryExecutor.java new file mode 100644 index 0000000..047f1c9 --- /dev/null +++ b/src/main/java/naughty/tuzamate/domain/stock/service/support/StockApiRetryExecutor.java @@ -0,0 +1,64 @@ +package naughty.tuzamate.domain.stock.service.support; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatusCode; +import org.springframework.stereotype.Component; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClientResponseException; + +import java.util.function.Supplier; + +/** + * StockApiRetryExecutor는 주식 API 호출 시 일시적인 오류가 발생할 경우 재시도를 수행하는 클래스 + * + */ + +@Component +@Slf4j +public class StockApiRetryExecutor { + + @Value("${stock.api.retry.max-attempts}") + private int maxAttempts; // 최대 재시도 횟수 + + @Value("${stock.api.retry.initial-backoff-ms}") + private long initialBackoffMs; // 초기 백오프 시간 (밀리초) + + // 429, 5xx, timeout 재시도 + public T execute(String apiName, Supplier supplier) { + long backoffMs = initialBackoffMs; + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return supplier.get(); + } catch (ResourceAccessException e) { + if (attempt == maxAttempts) { + throw e; + } + log.warn("[{}] timeout/network error, retry {}/{}", apiName, attempt, maxAttempts); + waitBackoff(backoffMs); + backoffMs *= 2; + } catch (RestClientResponseException e) { + if (!isRetryable(e.getStatusCode()) || attempt == maxAttempts) { + throw e; + } + log.warn("[{}] status={}, retry {}/{}", apiName, e.getStatusCode().value(), attempt, maxAttempts); + waitBackoff(backoffMs); + backoffMs *= 2; + } + } + throw new IllegalStateException("Retry failed unexpectedly"); + } + + private boolean isRetryable(HttpStatusCode statusCode) { + return statusCode.value() == 429 || statusCode.is5xxServerError(); + } + + private void waitBackoff(long backoffMs) { + try { + Thread.sleep(backoffMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Retry interrupted", e); + } + } +} diff --git a/src/main/java/naughty/tuzamate/domain/stock/service/support/StockRequestRateLimiter.java b/src/main/java/naughty/tuzamate/domain/stock/service/support/StockRequestRateLimiter.java new file mode 100644 index 0000000..1913070 --- /dev/null +++ b/src/main/java/naughty/tuzamate/domain/stock/service/support/StockRequestRateLimiter.java @@ -0,0 +1,39 @@ +package naughty.tuzamate.domain.stock.service.support; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; + +@Component +public class StockRequestRateLimiter { + + private final long intervalNanos; + private long nextAllowedTimeNanos; + + public StockRequestRateLimiter(@Value("${stock.api.rate-limit-per-second}") int permitsPerSecond) { + + int safePermitsPerSecond = Math.max(1, permitsPerSecond); + + // 요청 간 최소 간격 + this.intervalNanos = TimeUnit.SECONDS.toNanos(1) / safePermitsPerSecond; + + // 다음 허용되는 시각 + this.nextAllowedTimeNanos = 0L; + + } + + public synchronized void acquire() throws InterruptedException { + long now = System.nanoTime(); + + if (now < nextAllowedTimeNanos) { + // 아직 다음 허용 시각이 되지 않았으므로 대기 + long waitTimeNanos = nextAllowedTimeNanos - now; + TimeUnit.NANOSECONDS.sleep(waitTimeNanos); + } + + // 다음 허용 시각 업데이트 + nextAllowedTimeNanos = Math.max(now, nextAllowedTimeNanos) + intervalNanos; + } + +}