Skip to content

Commit 5009cee

Browse files
authored
Merge pull request #256 from FunD-StockProject/fix/schedular-external-api
Fix: 스케줄러와 외부 API 호출 관련 안정화
2 parents a465b17 + 16fe705 commit 5009cee

12 files changed

Lines changed: 154 additions & 34 deletions

File tree

src/main/java/com/fund/stockProject/experiment/service/HolidayService.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import reactor.core.publisher.Mono;
99

1010
import java.time.LocalDate;
11+
import java.time.Duration;
1112
import java.time.format.DateTimeFormatter;
1213
import java.util.HashSet;
1314
import java.util.Set;
@@ -24,6 +25,7 @@ public class HolidayService {
2425
private String serviceKey;
2526

2627
private static final String API_BASE_URL = "http://apis.data.go.kr/B090041/openapi/service/SpcdeInfoService";
28+
private static final Duration HOLIDAY_API_TIMEOUT = Duration.ofSeconds(8);
2729

2830
// 간단한 메모리 캐시 (연도별)
2931
private final ConcurrentHashMap<Integer, Set<LocalDate>> holidayCache = new ConcurrentHashMap<>();
@@ -51,6 +53,7 @@ public Mono<Set<LocalDate>> getHolidays(int year) {
5153
.build())
5254
.retrieve()
5355
.bodyToMono(String.class)
56+
.timeout(HOLIDAY_API_TIMEOUT)
5457
.map(xmlResponse -> {
5558
Set<LocalDate> holidays = parseHolidays(xmlResponse);
5659
// 캐시에 저장
@@ -107,12 +110,11 @@ private Set<LocalDate> parseHolidays(String xmlResponse) {
107110
*/
108111
public boolean isHolidaySync(LocalDate date) {
109112
try {
110-
Set<LocalDate> holidays = getHolidays(date.getYear()).block();
113+
Set<LocalDate> holidays = getHolidays(date.getYear()).block(HOLIDAY_API_TIMEOUT.plusSeconds(1));
111114
return holidays != null && holidays.contains(date);
112115
} catch (Exception e) {
113116
log.error("Failed to check holiday synchronously", e);
114117
return false; // 에러 시 공휴일이 아닌 것으로 처리
115118
}
116119
}
117120
}
118-

src/main/java/com/fund/stockProject/global/config/SecurityHttpConfig.java

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,32 @@
11
package com.fund.stockProject.global.config;
22

33
import java.time.LocalDateTime;
4+
import java.time.Duration;
45
import java.time.format.DateTimeFormatter;
56

67
import jakarta.annotation.PostConstruct;
8+
import io.netty.channel.ChannelOption;
79
import org.springframework.beans.factory.annotation.Value;
810
import org.springframework.context.annotation.Bean;
911
import org.springframework.context.annotation.Configuration;
1012
import org.springframework.http.HttpHeaders;
1113
import org.springframework.http.MediaType;
14+
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
1215
import org.springframework.scheduling.annotation.Scheduled;
1316
import org.springframework.web.reactive.function.client.WebClient;
1417

1518
import com.fund.stockProject.global.dto.request.AccessTokenRequest;
1619
import com.fund.stockProject.global.dto.response.AccessTokenResponse;
1720

1821
import lombok.extern.slf4j.Slf4j;
22+
import reactor.netty.http.client.HttpClient;
1923

2024
@Slf4j
2125
@Configuration
2226
public class SecurityHttpConfig {
27+
private static final int CONNECT_TIMEOUT_MILLIS = 5000;
28+
private static final Duration RESPONSE_TIMEOUT = Duration.ofSeconds(10);
29+
private static final String KIS_BASE_URL = "https://openapi.koreainvestment.com:9443";
2330

2431
@Value("${spring.security.appkey}")
2532
private String appkey;
@@ -41,9 +48,7 @@ public void warmUpAccessToken() {
4148

4249
@Bean
4350
public WebClient webClient() {
44-
return WebClient.builder()
45-
.baseUrl("https://openapi.koreainvestment.com:9443")
46-
.build(); // 기본 헤더는 WebClient 호출 시 동적으로 설정
51+
return buildKisWebClient(); // 기본 헤더는 WebClient 호출 시 동적으로 설정
4752
}
4853

4954
public HttpHeaders createSecurityHeaders() {
@@ -59,17 +64,18 @@ public HttpHeaders createSecurityHeaders() {
5964

6065
private String fetchAccessTokenFromApi() {
6166
log.info("Fetching access token from Korea Investment API");
62-
WebClient webClient = WebClient.create();
67+
final WebClient webClient = buildKisWebClient();
6368
AccessTokenRequest request = new AccessTokenRequest("client_credentials", appkey, appSecret);
6469

6570
try {
6671
AccessTokenResponse response = webClient.post()
67-
.uri("https://openapi.koreainvestment.com:9443/oauth2/tokenP")
72+
.uri("/oauth2/tokenP")
6873
.headers(headers -> headers.setContentType(MediaType.APPLICATION_JSON))
6974
.bodyValue(request)
7075
.retrieve()
7176
.bodyToMono(AccessTokenResponse.class)
72-
.block();
77+
.timeout(RESPONSE_TIMEOUT)
78+
.block(RESPONSE_TIMEOUT.plusSeconds(1));
7379

7480
if (response != null) {
7581
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@@ -87,6 +93,17 @@ private String fetchAccessTokenFromApi() {
8793
}
8894
}
8995

96+
private WebClient buildKisWebClient() {
97+
final HttpClient httpClient = HttpClient.create()
98+
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, CONNECT_TIMEOUT_MILLIS)
99+
.responseTimeout(RESPONSE_TIMEOUT);
100+
101+
return WebClient.builder()
102+
.baseUrl(KIS_BASE_URL)
103+
.clientConnector(new ReactorClientHttpConnector(httpClient))
104+
.build();
105+
}
106+
90107
public void refreshTokenIfNeeded() {
91108
if (accessToken == null || LocalDateTime.now().isAfter(expiredDateTime)) {
92109
synchronized (this) {

src/main/java/com/fund/stockProject/global/exception/GlobalExceptionHandler.java

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import org.springframework.http.ResponseEntity;
88
import org.springframework.web.bind.annotation.ExceptionHandler;
99
import org.springframework.web.bind.annotation.RestControllerAdvice;
10+
import org.springframework.web.servlet.resource.NoResourceFoundException;
1011

1112
import java.time.LocalDateTime;
1213
import java.util.HashMap;
@@ -64,6 +65,26 @@ public ResponseEntity<Map<String, Object>> handleIllegalState(
6465
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
6566
}
6667

68+
@ExceptionHandler(NoResourceFoundException.class)
69+
public ResponseEntity<Map<String, Object>> handleNoResourceFound(
70+
NoResourceFoundException ex, HttpServletRequest request) {
71+
if ("/favicon.ico".equals(request.getRequestURI())) {
72+
log.debug("Favicon not found - URI: {}, Method: {}", request.getRequestURI(), request.getMethod());
73+
} else {
74+
log.warn("Resource not found - URI: {}, Method: {}, Error: {}",
75+
request.getRequestURI(), request.getMethod(), ex.getMessage());
76+
}
77+
78+
Map<String, Object> body = new HashMap<>();
79+
body.put("timestamp", LocalDateTime.now());
80+
body.put("status", HttpStatus.NOT_FOUND.value());
81+
body.put("error", "Not Found");
82+
body.put("message", "Resource not found");
83+
body.put("path", request.getRequestURI());
84+
85+
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
86+
}
87+
6788
@ExceptionHandler(RuntimeException.class)
6889
public ResponseEntity<Map<String, Object>> handleRuntimeException(
6990
RuntimeException ex, HttpServletRequest request) {
@@ -99,4 +120,3 @@ public ResponseEntity<Map<String, Object>> handleGenericException(
99120

100121

101122

102-
Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
package com.fund.stockProject.global.scheduler;
22

33
import java.time.LocalDate;
4-
import java.util.List;
54

65
import org.springframework.scheduling.annotation.Scheduled;
76
import org.springframework.stereotype.Component;
87
import org.springframework.transaction.annotation.Transactional;
98

10-
import com.fund.stockProject.keyword.entity.Keyword;
119
import com.fund.stockProject.keyword.repository.KeywordRepository;
1210

1311
import lombok.RequiredArgsConstructor;
12+
import lombok.extern.slf4j.Slf4j;
1413

14+
@Slf4j
1515
@Component
1616
@RequiredArgsConstructor
1717
public class KeywordCleanupScheduler {
@@ -22,10 +22,11 @@ public class KeywordCleanupScheduler {
2222
@Scheduled(cron = "0 0 0 * * MON") // 매주 월요일 0시 실행
2323
@Transactional
2424
public void cleanupUnusedKeywords() {
25-
LocalDate cutoffDate = LocalDate.now().minusDays(30);
26-
List<Keyword> unusedKeywords = keywordRepository.findByLastUsedAtBefore(cutoffDate);
27-
keywordRepository.deleteAll(unusedKeywords);
25+
final LocalDate cutoffDate = LocalDate.now().minusDays(30);
26+
final int agedOrphanDeleted = keywordRepository.deleteOrphanKeywordsByLastUsedAtBefore(cutoffDate);
27+
final int orphanDeleted = keywordRepository.deleteAllOrphanKeywords();
2828

29-
System.out.println("Unused keywords cleanup completed: " + unusedKeywords.size() + " keywords deleted.");
29+
log.info("Unused keywords cleanup completed - agedOrphanDeleted: {}, orphanDeleted: {}",
30+
agedOrphanDeleted, orphanDeleted);
3031
}
31-
}
32+
}

src/main/java/com/fund/stockProject/keyword/repository/KeywordRepository.java

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@
77
import org.springframework.data.domain.PageRequest;
88
import org.springframework.data.domain.Pageable;
99
import org.springframework.data.jpa.repository.JpaRepository;
10+
import org.springframework.data.jpa.repository.Modifying;
1011
import org.springframework.data.jpa.repository.Query;
1112
import org.springframework.data.repository.query.Param;
1213
import org.springframework.stereotype.Repository;
14+
import org.springframework.transaction.annotation.Transactional;
1315

1416
@Repository
15-
public interface KeywordRepository extends JpaRepository<Keyword, Integer> {
17+
public interface KeywordRepository extends JpaRepository<Keyword, Long> {
1618
@Query("SELECT k FROM Keyword k WHERE k.lastUsedAt < :cutoffDate")
1719
List<Keyword> findByLastUsedAtBefore(@Param("cutoffDate") LocalDate cutoffDate);
1820

@@ -51,4 +53,25 @@ public interface KeywordRepository extends JpaRepository<Keyword, Integer> {
5153
List<Keyword> findKeywords(@Param("today") LocalDate today,
5254
@Param("yesterday") LocalDate yesterday,
5355
PageRequest pageRequest);
56+
57+
@Modifying
58+
@Transactional
59+
@Query("""
60+
DELETE FROM Keyword k
61+
WHERE k.lastUsedAt < :cutoffDate
62+
AND NOT EXISTS (
63+
SELECT 1 FROM StockKeyword sk WHERE sk.keyword = k
64+
)
65+
""")
66+
int deleteOrphanKeywordsByLastUsedAtBefore(@Param("cutoffDate") LocalDate cutoffDate);
67+
68+
@Modifying
69+
@Transactional
70+
@Query("""
71+
DELETE FROM Keyword k
72+
WHERE NOT EXISTS (
73+
SELECT 1 FROM StockKeyword sk WHERE sk.keyword = k
74+
)
75+
""")
76+
int deleteAllOrphanKeywords();
5477
}

src/main/java/com/fund/stockProject/keyword/repository/StockKeywordRepository.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import org.springframework.stereotype.Repository;
1111

1212
@Repository
13-
public interface StockKeywordRepository extends JpaRepository<StockKeyword, Integer> {
13+
public interface StockKeywordRepository extends JpaRepository<StockKeyword, Long> {
1414

1515
@Query("SELECT sk FROM StockKeyword sk WHERE sk.keyword.name = :keywordName ORDER BY sk.keyword.frequency DESC")
1616
List<StockKeyword> findByKeywordName(@Param("keywordName") String keywordName);

src/main/java/com/fund/stockProject/notification/service/OutboxDispatcher.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,9 @@ private void handleError(OutboxEvent e, Exception ex) {
147147
int retryCount = e.getRetryCount() + 1;
148148
e.setRetryCount(retryCount);
149149

150-
// 지수 백오프: 1분, 2분, 4분, 8분, 16분, 32분, 64분, 128분, 256분, 300분(최대)
151-
long backoffSec = Math.min(300, 1L << Math.min(10, retryCount));
150+
// 지수 백오프(분 단위): 1분, 2분, 4분 ... 최대 300분
151+
final long backoffMinutes = Math.min(300L, 1L << Math.min(10, Math.max(0, retryCount - 1)));
152+
long backoffSec = backoffMinutes * 60L;
152153
e.setNextAttemptAt(Instant.now().plusSeconds(backoffSec));
153154
e.setStatus("RETRY");
154155
outboxRepo.save(e);

src/main/java/com/fund/stockProject/score/repository/ScoreRepository.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,17 @@
77
import org.springframework.data.jpa.repository.JpaRepository;
88
import org.springframework.data.jpa.repository.Modifying;
99
import org.springframework.data.jpa.repository.Query;
10-
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
1110
import org.springframework.data.repository.query.Param;
1211
import org.springframework.stereotype.Repository;
1312
import org.springframework.transaction.annotation.Transactional;
1413

1514
import com.fund.stockProject.score.entity.Score;
15+
import com.fund.stockProject.score.entity.ScoreId;
1616
import com.fund.stockProject.stock.domain.DomesticSector;
1717
import com.fund.stockProject.stock.domain.OverseasSector;
1818

1919
@Repository
20-
@EnableJpaRepositories
21-
public interface ScoreRepository extends JpaRepository<Score, Integer> {
20+
public interface ScoreRepository extends JpaRepository<Score, ScoreId> {
2221

2322
/**
2423
* stock_id와 date로 특정 데이터가 존재하는지 확인
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.fund.stockProject.score.service;
2+
3+
public class NoCrawlerDataException extends RuntimeException {
4+
5+
public NoCrawlerDataException(String message) {
6+
super(message);
7+
}
8+
}

src/main/java/com/fund/stockProject/score/service/ScoreBatchService.java

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,14 @@ public void runCountryBatch(COUNTRY country) {
3030
int processedCount = 0;
3131
int successCount = 0;
3232
int skippedCount = 0;
33+
int existsSkipCount = 0;
34+
int noDataSkipCount = 0;
3335
int errorCount = 0;
3436

3537
for (Integer stockId : targetStockIds) {
3638
if (scoreRepository.existsByStockIdAndDate(stockId, today)) {
3739
skippedCount++;
40+
existsSkipCount++;
3841
continue;
3942
}
4043

@@ -43,14 +46,18 @@ public void runCountryBatch(COUNTRY country) {
4346
int yesterdayScore = resolveYesterdayScore(stockId, country, yesterday, today);
4447
scoreService.updateScoreAndKeyword(stockId, country, yesterdayScore);
4548
successCount++;
49+
} catch (NoCrawlerDataException e) {
50+
skippedCount++;
51+
noDataSkipCount++;
52+
log.info("Skipping score update due to no crawler data - stockId: {}, country: {}", stockId, country);
4653
} catch (Exception e) {
4754
errorCount++;
4855
log.error("Error processing score for stockId: {}", stockId, e);
4956
}
5057
}
5158

52-
log.info("Score batch completed for {}: processed={}, success={}, skipped={}, errors={}",
53-
country, processedCount, successCount, skippedCount, errorCount);
59+
log.info("Score batch completed for {}: processed={}, success={}, skipped={} (exists={}, noData={}), errors={}",
60+
country, processedCount, successCount, skippedCount, existsSkipCount, noDataSkipCount, errorCount);
5461
}
5562

5663
public void runIndexBatch() {

0 commit comments

Comments
 (0)