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..c40307b1 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/home/client/HolidayApiClient.java @@ -0,0 +1,112 @@ +package com.Timo.Timo.domain.home.client; + +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; + +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; +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; + 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); + } + + 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) { + 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)) { + throw new IllegalStateException("Holiday API returned an error result."); + } + + 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("Failed to parse holiday API response."); + } + } + + private void addHoliday(Set holidays, JsonNode item) { + 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."); + } + } +} 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..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 @@ -1,44 +1,163 @@ 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.RejectedExecutionException; +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 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 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 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("Holiday API key is not configured."); + 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; } - Set fixedHolidays = FIXED_HOLIDAYS_BY_LANGUAGE.getOrDefault(language, Set.of()); - return fixedHolidays.contains(MonthDay.from(date)); + if (language != Language.KO) { + return false; + } + + Set koreanHolidays = findKoreanHolidays(date.getYear()); + return koreanHolidays != null && koreanHolidays.contains(date); + } + + private Set findKoreanHolidays(int year) { + Set cached = koreanHolidaysByYear.get(year); + if (cached != null || !isLoadable(year)) { + return cached; + } + + LoadingYear loading = startLoading(year); + if (loading == null) { + return null; + } + + 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) { + 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) { + 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; + } + koreanHolidaysByYear.put(year, holidays); + failedAtByYear.remove(year); + return holidays; + } catch (Exception e) { + failedAtByYear.put(year, Instant.now()); + log.warn("Failed to fetch holidays. year={}", year, e); + return null; + } finally { + loadingYears.remove(year); + } + } + + private record LoadingYear( + CompletableFuture> future, + Instant waitDeadline + ) { } } 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 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