From ea50fab796f10779a11b2b05d005fe7d71d6b241 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Tue, 13 Oct 2026 02:43:39 +0900 Subject: [PATCH 01/11] =?UTF-8?q?chore:=20=EA=B3=B5=ED=9C=B4=EC=9D=BC=20AP?= =?UTF-8?q?I=20=ED=82=A4=20=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-local.yml | 3 +++ src/main/resources/application-prod.yml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 6e431aa5..cdbb004e 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -55,3 +55,6 @@ ai: user-rpd: ${AI_USER_RPD} global-rpd: ${AI_GLOBAL_RPD} global-tpm: ${AI_GLOBAL_TPM} + +holiday: + api-key: ${HOLIDAY_API_KEY:} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index e2f8be12..ca3546d4 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -65,6 +65,9 @@ ai: global-rpd: ${AI_GLOBAL_RPD} global-tpm: ${AI_GLOBAL_TPM} +holiday: + api-key: ${HOLIDAY_API_KEY:} + logging: level: root: INFO From 783a6be95077b26c5c4d6a33069b63069e412835 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Tue, 13 Oct 2026 02:43:39 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat(home):=20=EC=B2=9C=EB=AC=B8=EC=97=B0?= =?UTF-8?q?=EA=B5=AC=EC=9B=90=20=ED=8A=B9=EC=9D=BC=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EA=B3=B5=ED=9C=B4=EC=9D=BC=20API=20=ED=81=B4=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EC=96=B8=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/home/client/HolidayApiClient.java | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/home/client/HolidayApiClient.java diff --git a/src/main/java/com/Timo/Timo/domain/home/client/HolidayApiClient.java b/src/main/java/com/Timo/Timo/domain/home/client/HolidayApiClient.java new file mode 100644 index 00000000..923df536 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/home/client/HolidayApiClient.java @@ -0,0 +1,91 @@ +package com.Timo.Timo.domain.home.client; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.HashSet; +import java.util.Set; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class HolidayApiClient { + + private static final String REST_DE_INFO_URL = + "https://apis.data.go.kr/B090041/openapi/service/SpcdeInfoService/getRestDeInfo" + + "?ServiceKey={serviceKey}&solYear={year}&numOfRows=100&_type=json"; + private static final String SUCCESS_CODE = "00"; + private static final String HOLIDAY_FLAG = "Y"; + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(3); + private static final Duration READ_TIMEOUT = Duration.ofSeconds(5); + + private final ObjectMapper objectMapper; + private final RestClient restClient = createRestClient(); + + @Value("${holiday.api-key:}") + private String apiKey; + + public boolean isConfigured() { + return apiKey != null && !apiKey.isBlank(); + } + + public Set fetchHolidays(int year) { + String response = restClient.get() + .uri(REST_DE_INFO_URL, apiKey, year) + .retrieve() + .body(String.class); + + return extractHolidays(response); + } + + private RestClient createRestClient() { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(CONNECT_TIMEOUT); + requestFactory.setReadTimeout(READ_TIMEOUT); + + return RestClient.builder() + .requestFactory(requestFactory) + .build(); + } + + private Set extractHolidays(String response) { + JsonNode responseNode = readTree(response).path("response"); + String resultCode = responseNode.path("header").path("resultCode").asText(); + if (!SUCCESS_CODE.equals(resultCode)) { + throw new IllegalStateException("공휴일 API 응답 오류. resultCode=" + resultCode); + } + + JsonNode itemNode = responseNode.path("body").path("items").path("item"); + Set holidays = new HashSet<>(); + if (itemNode.isArray()) { + itemNode.forEach(item -> addHoliday(holidays, item)); + } else if (itemNode.isObject()) { + addHoliday(holidays, itemNode); + } + return holidays; + } + + private JsonNode readTree(String response) { + try { + return objectMapper.readTree(response); + } catch (JsonProcessingException e) { + throw new IllegalStateException("공휴일 API 응답 파싱 실패", e); + } + } + + private void addHoliday(Set holidays, JsonNode item) { + if (HOLIDAY_FLAG.equals(item.path("isHoliday").asText())) { + holidays.add(LocalDate.parse(item.path("locdate").asText(), DateTimeFormatter.BASIC_ISO_DATE)); + } + } +} From eea24979af07ebd1cd25e6140969a51ea8d54e8a Mon Sep 17 00:00:00 2001 From: aneykrap Date: Tue, 13 Oct 2026 02:43:39 +0900 Subject: [PATCH 03/11] =?UTF-8?q?chore(config):=20=EA=B3=B5=ED=9C=B4?= =?UTF-8?q?=EC=9D=BC=20=EB=B9=84=EB=8F=99=EA=B8=B0=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EC=9A=A9=20=EC=8B=A4=ED=96=89=EA=B8=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/global/config/AsyncConfig.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/java/com/Timo/Timo/global/config/AsyncConfig.java b/src/main/java/com/Timo/Timo/global/config/AsyncConfig.java index 60180aa1..0c8e69f1 100644 --- a/src/main/java/com/Timo/Timo/global/config/AsyncConfig.java +++ b/src/main/java/com/Timo/Timo/global/config/AsyncConfig.java @@ -26,4 +26,16 @@ public Executor aiHistoryExecutor() { executor.initialize(); return executor; } + + @Bean(name = "holidayExecutor") + public Executor holidayExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setThreadNamePrefix("holiday-"); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(2); + executor.setQueueCapacity(30); + executor.setTaskDecorator(new MdcTaskDecorator()); + executor.initialize(); + return executor; + } } \ No newline at end of file From eb7f618ef9edd9d63bccffd5cc3cf71ebc9802c4 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Tue, 13 Oct 2026 02:43:39 +0900 Subject: [PATCH 04/11] =?UTF-8?q?feat(home):=20=EA=B3=B5=ED=9C=B4=EC=9D=BC?= =?UTF-8?q?=EC=9D=84=20API=20=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=ED=8C=90=EB=8B=A8=ED=95=98=EA=B3=A0=20=EC=97=B0=EB=8F=84?= =?UTF-8?q?=EB=B3=84=EB=A1=9C=20=EB=A9=94=EB=AA=A8=EB=A6=AC=EC=97=90=20?= =?UTF-8?q?=EC=BA=90=EC=8B=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/home/service/HolidayChecker.java | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/src/main/java/com/Timo/Timo/domain/home/service/HolidayChecker.java b/src/main/java/com/Timo/Timo/domain/home/service/HolidayChecker.java index 508e2478..af014e33 100644 --- a/src/main/java/com/Timo/Timo/domain/home/service/HolidayChecker.java +++ b/src/main/java/com/Timo/Timo/domain/home/service/HolidayChecker.java @@ -1,18 +1,41 @@ package com.Timo.Timo.domain.home.service; import java.time.DayOfWeek; +import java.time.Duration; +import java.time.Instant; import java.time.LocalDate; import java.time.MonthDay; +import java.time.ZoneId; import java.util.Map; import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import com.Timo.Timo.domain.home.client.HolidayApiClient; import com.Timo.Timo.domain.user.enums.Language; +import lombok.extern.slf4j.Slf4j; + +@Slf4j @Component public class HolidayChecker { + private static final ZoneId KOREA_ZONE = ZoneId.of("Asia/Seoul"); + private static final int MIN_SUPPORTED_YEAR = 2004; + private static final int MAX_FUTURE_YEARS = 2; + private static final Duration LOAD_WAIT = Duration.ofSeconds(2); + private static final Duration RETRY_DELAY = Duration.ofMinutes(10); + private static final Set KOREAN_FIXED_HOLIDAYS = Set.of( MonthDay.of(1, 1), MonthDay.of(3, 1), @@ -33,12 +56,121 @@ public class HolidayChecker { Language.EN, Set.of() ); + private final HolidayApiClient holidayApiClient; + private final Executor holidayExecutor; + private final Map> koreanHolidaysByYear = new ConcurrentHashMap<>(); + private final Map loadingYears = new ConcurrentHashMap<>(); + private final Map failedAtByYear = new ConcurrentHashMap<>(); + + public HolidayChecker( + HolidayApiClient holidayApiClient, + @Qualifier("holidayExecutor") Executor holidayExecutor + ) { + this.holidayApiClient = holidayApiClient; + this.holidayExecutor = holidayExecutor; + } + + @EventListener(ApplicationReadyEvent.class) + public void loadHolidaysOnStartup() { + if (!holidayApiClient.isConfigured()) { + log.warn("공휴일 API 키가 설정되지 않아 고정 공휴일로 판단합니다."); + return; + } + + int currentYear = LocalDate.now(KOREA_ZONE).getYear(); + startLoading(currentYear - 1); + startLoading(currentYear); + startLoading(currentYear + 1); + } + + @Scheduled(cron = "0 0 4 * * *", zone = "Asia/Seoul") + public void refreshUpcomingHolidays() { + if (!holidayApiClient.isConfigured()) { + return; + } + + int currentYear = LocalDate.now(KOREA_ZONE).getYear(); + startLoading(currentYear); + startLoading(currentYear + 1); + } + public boolean isHoliday(LocalDate date, Language language) { if (date.getDayOfWeek() == DayOfWeek.SUNDAY) { return true; } + if (language == Language.KO) { + Set koreanHolidays = findKoreanHolidays(date.getYear()); + if (koreanHolidays != null) { + return koreanHolidays.contains(date); + } + } + Set fixedHolidays = FIXED_HOLIDAYS_BY_LANGUAGE.getOrDefault(language, Set.of()); return fixedHolidays.contains(MonthDay.from(date)); } + + private Set findKoreanHolidays(int year) { + Set cached = koreanHolidaysByYear.get(year); + if (cached != null || !isLoadable(year)) { + return cached; + } + + LoadingYear loading = startLoading(year); + long waitMillis = Duration.between(Instant.now(), loading.waitDeadline()).toMillis(); + if (waitMillis <= 0) { + return null; + } + + try { + return loading.future().get(waitMillis, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return null; + } catch (ExecutionException | TimeoutException e) { + return null; + } + } + + private boolean isLoadable(int year) { + int currentYear = LocalDate.now(KOREA_ZONE).getYear(); + if (!holidayApiClient.isConfigured() || year < MIN_SUPPORTED_YEAR || year > currentYear + MAX_FUTURE_YEARS) { + return false; + } + + Instant failedAt = failedAtByYear.get(year); + return failedAt == null || failedAt.plus(RETRY_DELAY).isBefore(Instant.now()); + } + + private LoadingYear startLoading(int year) { + return loadingYears.computeIfAbsent(year, key -> new LoadingYear( + CompletableFuture.supplyAsync(() -> loadHolidays(key), holidayExecutor), + Instant.now().plus(LOAD_WAIT) + )); + } + + private Set loadHolidays(int year) { + try { + Set holidays = Set.copyOf(holidayApiClient.fetchHolidays(year)); + if (holidays.isEmpty()) { + failedAtByYear.put(year, Instant.now()); + return null; + } + koreanHolidaysByYear.put(year, holidays); + failedAtByYear.remove(year); + return holidays; + } catch (Exception e) { + failedAtByYear.put(year, Instant.now()); + log.warn("공휴일 API 조회 실패. year={}", year, e); + return null; + } finally { + loadingYears.remove(year); + } + } + + private record LoadingYear( + CompletableFuture> future, + Instant waitDeadline + ) { + } } From d8a570283cf337f91e72b0e1a53e7f73fef2e6f2 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Tue, 13 Oct 2026 23:17:26 +0900 Subject: [PATCH 05/11] =?UTF-8?q?refactor(home):=20=EA=B3=B5=ED=9C=B4?= =?UTF-8?q?=EC=9D=BC=20=ED=8C=90=EB=8B=A8=EC=97=90=EC=84=9C=20=ED=95=98?= =?UTF-8?q?=EB=93=9C=EC=BD=94=EB=94=A9=ED=95=9C=20=EC=96=91=EB=A0=A5=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=20=EA=B3=B5=ED=9C=B4=EC=9D=BC=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/home/service/HolidayChecker.java | 34 +++---------------- 1 file changed, 5 insertions(+), 29 deletions(-) diff --git a/src/main/java/com/Timo/Timo/domain/home/service/HolidayChecker.java b/src/main/java/com/Timo/Timo/domain/home/service/HolidayChecker.java index af014e33..1cc33bc3 100644 --- a/src/main/java/com/Timo/Timo/domain/home/service/HolidayChecker.java +++ b/src/main/java/com/Timo/Timo/domain/home/service/HolidayChecker.java @@ -4,7 +4,6 @@ import java.time.Duration; import java.time.Instant; import java.time.LocalDate; -import java.time.MonthDay; import java.time.ZoneId; import java.util.Map; import java.util.Set; @@ -36,26 +35,6 @@ public class HolidayChecker { private static final Duration LOAD_WAIT = Duration.ofSeconds(2); private static final Duration RETRY_DELAY = Duration.ofMinutes(10); - private static final Set KOREAN_FIXED_HOLIDAYS = Set.of( - MonthDay.of(1, 1), - MonthDay.of(3, 1), - MonthDay.of(5, 5), - MonthDay.of(6, 6), - MonthDay.of(8, 15), - MonthDay.of(10, 3), - MonthDay.of(10, 9), - MonthDay.of(12, 25) - ); - - /** - * 언어(지역)별 양력 고정 공휴일. 음력 공휴일(설날·추석 등)은 별도 계산이 필요해 아직 포함하지 않는다. - * 신뢰할 만한 데이터가 없는 언어는 빈 집합으로 두고 일요일만 공휴일로 처리한다. - */ - private static final Map> FIXED_HOLIDAYS_BY_LANGUAGE = Map.of( - Language.KO, KOREAN_FIXED_HOLIDAYS, - Language.EN, Set.of() - ); - private final HolidayApiClient holidayApiClient; private final Executor holidayExecutor; private final Map> koreanHolidaysByYear = new ConcurrentHashMap<>(); @@ -73,7 +52,7 @@ public HolidayChecker( @EventListener(ApplicationReadyEvent.class) public void loadHolidaysOnStartup() { if (!holidayApiClient.isConfigured()) { - log.warn("공휴일 API 키가 설정되지 않아 고정 공휴일로 판단합니다."); + log.warn("공휴일 API 키가 설정되지 않아 일요일만 공휴일로 판단합니다."); return; } @@ -99,15 +78,12 @@ public boolean isHoliday(LocalDate date, Language language) { return true; } - if (language == Language.KO) { - Set koreanHolidays = findKoreanHolidays(date.getYear()); - if (koreanHolidays != null) { - return koreanHolidays.contains(date); - } + if (language != Language.KO) { + return false; } - Set fixedHolidays = FIXED_HOLIDAYS_BY_LANGUAGE.getOrDefault(language, Set.of()); - return fixedHolidays.contains(MonthDay.from(date)); + Set koreanHolidays = findKoreanHolidays(date.getYear()); + return koreanHolidays != null && koreanHolidays.contains(date); } private Set findKoreanHolidays(int year) { From 8de4611ec9ddfb2c6d30c51da17460e301086949 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Tue, 13 Oct 2026 23:17:26 +0900 Subject: [PATCH 06/11] =?UTF-8?q?refactor(home):=20=EA=B3=B5=ED=9C=B4?= =?UTF-8?q?=EC=9D=BC=20=EB=A1=9C=EA=B7=B8=20=EB=A9=94=EC=8B=9C=EC=A7=80?= =?UTF-8?q?=EB=A5=BC=20=EC=98=81=EC=96=B4=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/home/service/HolidayChecker.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/Timo/Timo/domain/home/service/HolidayChecker.java b/src/main/java/com/Timo/Timo/domain/home/service/HolidayChecker.java index 1cc33bc3..c5a15855 100644 --- a/src/main/java/com/Timo/Timo/domain/home/service/HolidayChecker.java +++ b/src/main/java/com/Timo/Timo/domain/home/service/HolidayChecker.java @@ -52,7 +52,7 @@ public HolidayChecker( @EventListener(ApplicationReadyEvent.class) public void loadHolidaysOnStartup() { if (!holidayApiClient.isConfigured()) { - log.warn("공휴일 API 키가 설정되지 않아 일요일만 공휴일로 판단합니다."); + log.warn("Holiday API key is not configured."); return; } @@ -137,7 +137,7 @@ private Set loadHolidays(int year) { return holidays; } catch (Exception e) { failedAtByYear.put(year, Instant.now()); - log.warn("공휴일 API 조회 실패. year={}", year, e); + log.warn("Failed to fetch holidays. year={}", year, e); return null; } finally { loadingYears.remove(year); From 46c0d8f2213f1da2a1a8579e8a90aadd082fa230 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Tue, 13 Oct 2026 23:50:33 +0900 Subject: [PATCH 07/11] =?UTF-8?q?fix(home):=20=EA=B3=B5=ED=9C=B4=EC=9D=BC?= =?UTF-8?q?=20API=20=EC=98=A4=EB=A5=98=20=EC=8B=9C=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?URL=EA=B3=BC=20=EC=9D=91=EB=8B=B5=20=EB=B3=B8=EB=AC=B8=EC=9D=B4?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=EC=97=90=20=EB=82=A8=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=98=88=EC=99=B8=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/home/client/HolidayApiClient.java | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/Timo/Timo/domain/home/client/HolidayApiClient.java b/src/main/java/com/Timo/Timo/domain/home/client/HolidayApiClient.java index 923df536..1f8d8e56 100644 --- a/src/main/java/com/Timo/Timo/domain/home/client/HolidayApiClient.java +++ b/src/main/java/com/Timo/Timo/domain/home/client/HolidayApiClient.java @@ -7,9 +7,11 @@ import java.util.Set; import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.NestedExceptionUtils; import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; @@ -40,10 +42,18 @@ public boolean isConfigured() { } public Set fetchHolidays(int year) { - String response = restClient.get() - .uri(REST_DE_INFO_URL, apiKey, year) - .retrieve() - .body(String.class); + String response; + try { + response = restClient.get() + .uri(REST_DE_INFO_URL, apiKey, year) + .retrieve() + .body(String.class); + } catch (RestClientException e) { + throw new IllegalStateException( + "Holiday API request failed. year=" + year + + ", cause=" + NestedExceptionUtils.getMostSpecificCause(e).getClass().getSimpleName() + ); + } return extractHolidays(response); } From aa3192d3eb9677aa7b6869cbc40f818d84e8dc53 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 14 Oct 2026 00:13:42 +0900 Subject: [PATCH 08/11] =?UTF-8?q?fix(home):=20=EA=B3=B5=ED=9C=B4=EC=9D=BC?= =?UTF-8?q?=20API=20=EC=9D=91=EB=8B=B5=20=EC=B2=98=EB=A6=AC=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=EC=97=90=EC=84=9C=20=EC=99=B8=EB=B6=80=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EB=82=B4=EC=9A=A9=EC=9D=B4=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=97=90=20=EB=82=A8=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/home/client/HolidayApiClient.java | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/Timo/Timo/domain/home/client/HolidayApiClient.java b/src/main/java/com/Timo/Timo/domain/home/client/HolidayApiClient.java index 1f8d8e56..5ae20337 100644 --- a/src/main/java/com/Timo/Timo/domain/home/client/HolidayApiClient.java +++ b/src/main/java/com/Timo/Timo/domain/home/client/HolidayApiClient.java @@ -3,6 +3,7 @@ import java.time.Duration; import java.time.LocalDate; import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; import java.util.HashSet; import java.util.Set; @@ -72,7 +73,7 @@ private Set extractHolidays(String response) { JsonNode responseNode = readTree(response).path("response"); String resultCode = responseNode.path("header").path("resultCode").asText(); if (!SUCCESS_CODE.equals(resultCode)) { - throw new IllegalStateException("공휴일 API 응답 오류. resultCode=" + resultCode); + throw new IllegalStateException("Holiday API returned an error result."); } JsonNode itemNode = responseNode.path("body").path("items").path("item"); @@ -89,13 +90,19 @@ private JsonNode readTree(String response) { try { return objectMapper.readTree(response); } catch (JsonProcessingException e) { - throw new IllegalStateException("공휴일 API 응답 파싱 실패", e); + throw new IllegalStateException("Failed to parse holiday API response."); } } private void addHoliday(Set holidays, JsonNode item) { - if (HOLIDAY_FLAG.equals(item.path("isHoliday").asText())) { + if (!HOLIDAY_FLAG.equals(item.path("isHoliday").asText())) { + return; + } + + try { holidays.add(LocalDate.parse(item.path("locdate").asText(), DateTimeFormatter.BASIC_ISO_DATE)); + } catch (DateTimeParseException e) { + throw new IllegalStateException("Invalid holiday date in API response."); } } } From 31cf9d95da15beb65acd18bb8f3b075f3099d421 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 14 Oct 2026 19:35:24 +0900 Subject: [PATCH 09/11] =?UTF-8?q?refactor(home):=20=EA=B3=B5=ED=9C=B4?= =?UTF-8?q?=EC=9D=BC=20API=EA=B0=80=20=EB=B9=88=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=EC=9D=84=20=EB=B0=98=ED=99=98=ED=95=A0=20=EB=95=8C=20=EC=97=B0?= =?UTF-8?q?=EB=8F=84=EC=99=80=20=ED=95=A8=EA=BB=98=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/Timo/Timo/domain/home/service/HolidayChecker.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/Timo/Timo/domain/home/service/HolidayChecker.java b/src/main/java/com/Timo/Timo/domain/home/service/HolidayChecker.java index c5a15855..76881284 100644 --- a/src/main/java/com/Timo/Timo/domain/home/service/HolidayChecker.java +++ b/src/main/java/com/Timo/Timo/domain/home/service/HolidayChecker.java @@ -129,6 +129,7 @@ private Set loadHolidays(int year) { try { Set holidays = Set.copyOf(holidayApiClient.fetchHolidays(year)); if (holidays.isEmpty()) { + log.info("Holiday list is empty. year={}", year); failedAtByYear.put(year, Instant.now()); return null; } From 282798c9628f8618f5c76a8828fe4dca530edce9 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Mon, 19 Oct 2026 15:30:19 +0900 Subject: [PATCH 10/11] =?UTF-8?q?fix(home):=20=EA=B3=B5=ED=9C=B4=EC=9D=BC?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EC=9E=91=EC=97=85=EC=9D=B4=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=EA=B8=B0=EC=97=90=EC=84=9C=20=EA=B1=B0=EC=A0=88?= =?UTF-8?q?=EB=90=98=EC=96=B4=EB=8F=84=20=ED=99=88=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EA=B0=80=20=EC=8B=A4=ED=8C=A8=ED=95=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/home/service/HolidayChecker.java | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/Timo/Timo/domain/home/service/HolidayChecker.java b/src/main/java/com/Timo/Timo/domain/home/service/HolidayChecker.java index 76881284..fab63855 100644 --- a/src/main/java/com/Timo/Timo/domain/home/service/HolidayChecker.java +++ b/src/main/java/com/Timo/Timo/domain/home/service/HolidayChecker.java @@ -11,6 +11,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.Executor; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -93,6 +94,10 @@ private Set findKoreanHolidays(int year) { } LoadingYear loading = startLoading(year); + if (loading == null) { + return null; + } + long waitMillis = Duration.between(Instant.now(), loading.waitDeadline()).toMillis(); if (waitMillis <= 0) { return null; @@ -119,10 +124,15 @@ private boolean isLoadable(int year) { } private LoadingYear startLoading(int year) { - return loadingYears.computeIfAbsent(year, key -> new LoadingYear( - CompletableFuture.supplyAsync(() -> loadHolidays(key), holidayExecutor), - Instant.now().plus(LOAD_WAIT) - )); + try { + return loadingYears.computeIfAbsent(year, key -> new LoadingYear( + CompletableFuture.supplyAsync(() -> loadHolidays(key), holidayExecutor), + Instant.now().plus(LOAD_WAIT) + )); + } catch (RejectedExecutionException e) { + log.warn("Holiday loading task was rejected. year={}", year); + return null; + } } private Set loadHolidays(int year) { From 3955758e3334508df517c84fe471121c26bbaf31 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Mon, 19 Oct 2026 15:30:19 +0900 Subject: [PATCH 11/11] =?UTF-8?q?refactor(home):=20=EA=B3=B5=ED=9C=B4?= =?UTF-8?q?=EC=9D=BC=20API=20=EC=9D=91=EB=8B=B5=20=EB=B3=B8=EB=AC=B8?= =?UTF-8?q?=EC=9D=B4=20=EB=B9=84=EC=96=B4=20=EC=9E=88=EC=9D=84=20=EB=95=8C?= =?UTF-8?q?=20=EA=B3=A0=EC=A0=95=20=EB=A9=94=EC=8B=9C=EC=A7=80=EB=A1=9C=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/home/client/HolidayApiClient.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/com/Timo/Timo/domain/home/client/HolidayApiClient.java b/src/main/java/com/Timo/Timo/domain/home/client/HolidayApiClient.java index 5ae20337..c40307b1 100644 --- a/src/main/java/com/Timo/Timo/domain/home/client/HolidayApiClient.java +++ b/src/main/java/com/Timo/Timo/domain/home/client/HolidayApiClient.java @@ -70,6 +70,10 @@ private RestClient createRestClient() { } private Set extractHolidays(String response) { + if (response == null) { + throw new IllegalStateException("Holiday API returned an empty response."); + } + JsonNode responseNode = readTree(response).path("response"); String resultCode = responseNode.path("header").path("resultCode").asText(); if (!SUCCESS_CODE.equals(resultCode)) {