From db8621b92814377d0594aad576147f74ecf174de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Tue, 30 Jun 2026 18:39:43 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EA=B8=B0=EC=83=81=EC=B2=AD=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=20=EC=A0=9C=ED=95=9C=20=EC=A0=84=EC=9A=A9=20?= =?UTF-8?q?=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EC=A0=95=EC=B1=85=20=EA=B5=AC?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 동시 호출 제한을 일반 외부 API 오류와 분리해 재시도 대상이 불필요하게 넓어지는 것을 방지 - 최초 호출 이후 5초 간격으로 5회만 추가 시도하도록 날씨 전용 RetryTemplate을 구성 - 전역 @EnableRetry 없이 날씨 클라이언트에만 정책을 주입해 기존 Slack, SMS, 메일 동작에 영향을 주지 않도록 제한 --- .../weather/config/WeatherRetryConfig.java | 24 +++++++++++++++++++ .../exception/WeatherOpenApiException.java | 2 +- .../WeatherOpenApiRateLimitException.java | 12 ++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 src/main/java/in/koreatech/koin/domain/weather/config/WeatherRetryConfig.java create mode 100644 src/main/java/in/koreatech/koin/domain/weather/exception/WeatherOpenApiRateLimitException.java diff --git a/src/main/java/in/koreatech/koin/domain/weather/config/WeatherRetryConfig.java b/src/main/java/in/koreatech/koin/domain/weather/config/WeatherRetryConfig.java new file mode 100644 index 000000000..a5babadc1 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/weather/config/WeatherRetryConfig.java @@ -0,0 +1,24 @@ +package in.koreatech.koin.domain.weather.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.support.RetryTemplate; + +import in.koreatech.koin.domain.weather.exception.WeatherOpenApiRateLimitException; + +@Configuration +public class WeatherRetryConfig { + + private static final int MAX_ATTEMPTS = 6; + private static final long RETRY_INTERVAL_MILLIS = 5_000L; + + @Bean + public RetryTemplate weatherRetryTemplate() { + // 최초 호출 이후 5회 재시도해 기상청 제공 서버의 순간적인 동시 호출 제한을 흡수한다. + return RetryTemplate.builder() + .maxAttempts(MAX_ATTEMPTS) + .fixedBackoff(RETRY_INTERVAL_MILLIS) + .retryOn(WeatherOpenApiRateLimitException.class) + .build(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/weather/exception/WeatherOpenApiException.java b/src/main/java/in/koreatech/koin/domain/weather/exception/WeatherOpenApiException.java index 9ce59aef3..acdff5f20 100644 --- a/src/main/java/in/koreatech/koin/domain/weather/exception/WeatherOpenApiException.java +++ b/src/main/java/in/koreatech/koin/domain/weather/exception/WeatherOpenApiException.java @@ -4,7 +4,7 @@ public class WeatherOpenApiException extends ExternalServiceException { - private static final String DEFAULT_MESSAGE = "기상청 단기예보 API 응답이 정상적이지 않습니다."; + protected static final String DEFAULT_MESSAGE = "기상청 단기예보 API 응답이 정상적이지 않습니다."; public WeatherOpenApiException(String message) { super(message); diff --git a/src/main/java/in/koreatech/koin/domain/weather/exception/WeatherOpenApiRateLimitException.java b/src/main/java/in/koreatech/koin/domain/weather/exception/WeatherOpenApiRateLimitException.java new file mode 100644 index 000000000..a3734cb44 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/weather/exception/WeatherOpenApiRateLimitException.java @@ -0,0 +1,12 @@ +package in.koreatech.koin.domain.weather.exception; + +public class WeatherOpenApiRateLimitException extends WeatherOpenApiException { + + private WeatherOpenApiRateLimitException(String detail) { + super(DEFAULT_MESSAGE, detail); + } + + public static WeatherOpenApiRateLimitException withDetail(String detail) { + return new WeatherOpenApiRateLimitException(detail); + } +} From e050c179a744c0023f7484761defc07c9444e0bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Tue, 30 Jun 2026 18:40:04 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EA=B8=B0=EC=83=81=EC=B2=AD=20?= =?UTF-8?q?=EC=9E=A5=EC=95=A0=EC=97=90=EB=8F=84=20=ED=98=84=EC=9E=AC=20?= =?UTF-8?q?=EB=82=A0=EC=94=A8=EB=A5=BC=20=EC=A0=9C=EA=B3=B5=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=98=88=EB=B3=B4=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 갱신 시점부터 향후 24시간 예보를 yyyyMMddHHmm 기준 Map으로 묶어 날짜 경계를 넘는 시간대도 함께 보관 - Redis 단일 키의 TTL을 24시간으로 늘리고 조회 요청은 현재 정시 예보만 선택해 외부 API와 사용자 요청을 분리 - HTTP 429와 기상청 초당 요청 제한 응답만 전용 예외로 분류해 5초 간격 5회 재시도를 적용 - 모든 갱신 시도가 실패하면 저장을 수행하지 않아 기존 캐시가 삭제되거나 불완전한 데이터로 교체되는 상황을 방지 - 24시간 범위 경계, 재시도 횟수, 현재 시간 조회와 Redis 직렬화를 단위 및 인수 테스트로 검증 --- .../domain/weather/client/WeatherClient.java | 93 +++++++++++++++--- .../domain/weather/model/WeatherCache.java | 13 +-- .../weather/service/WeatherService.java | 29 +++++- .../acceptance/domain/WeatherApiTest.java | 8 +- .../domain/weather/WeatherClientTest.java | 95 ++++++++++++++++--- .../domain/weather/WeatherServiceTest.java | 41 ++++++-- 6 files changed, 234 insertions(+), 45 deletions(-) diff --git a/src/main/java/in/koreatech/koin/domain/weather/client/WeatherClient.java b/src/main/java/in/koreatech/koin/domain/weather/client/WeatherClient.java index 5fa164d26..a3aca39d3 100644 --- a/src/main/java/in/koreatech/koin/domain/weather/client/WeatherClient.java +++ b/src/main/java/in/koreatech/koin/domain/weather/client/WeatherClient.java @@ -4,15 +4,20 @@ import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URL; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.stream.Collectors; import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.ResponseEntity; +import org.springframework.retry.support.RetryTemplate; import org.springframework.stereotype.Component; import org.springframework.web.client.HttpStatusCodeException; import org.springframework.web.client.RestTemplate; @@ -21,6 +26,7 @@ import in.koreatech.koin.domain.weather.client.dto.WeatherApiResponse.WeatherForecastItem; import in.koreatech.koin.domain.weather.client.dto.WeatherForecastRequestTime; import in.koreatech.koin.domain.weather.exception.WeatherOpenApiException; +import in.koreatech.koin.domain.weather.exception.WeatherOpenApiRateLimitException; import in.koreatech.koin.domain.weather.model.WeatherForecast; import in.koreatech.koin.global.exception.custom.KoinIllegalStateException; @@ -33,49 +39,75 @@ public class WeatherClient { private static final int BYEONGCHEON_NX = 66; private static final int BYEONGCHEON_NY = 109; private static final int ROW_COUNT = 1000; + private static final int FORECAST_RANGE_HOURS = 24; + private static final DateTimeFormatter FORECAST_DATE_TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyyMMddHHmm"); private final String openApiKey; private final RestTemplate restTemplate; private final ObjectMapper objectMapper; + private final RetryTemplate retryTemplate; public WeatherClient( @Value("${OPEN_API_KEY_PUBLIC}") String openApiKey, RestTemplate restTemplate, - ObjectMapper objectMapper + ObjectMapper objectMapper, + @Qualifier("weatherRetryTemplate") RetryTemplate retryTemplate ) { this.openApiKey = openApiKey; this.restTemplate = restTemplate; this.objectMapper = objectMapper; + this.retryTemplate = retryTemplate; } - public WeatherForecast getWeatherForecast(WeatherForecastRequestTime requestTime) { + public Map getWeatherForecasts(WeatherForecastRequestTime requestTime) { + return retryTemplate.execute(context -> getWeatherForecastsWithFallback(requestTime)); + } + + private Map getWeatherForecastsWithFallback(WeatherForecastRequestTime requestTime) { try { - return getWeatherForecastWithoutFallback(requestTime); + return getWeatherForecastsWithoutFallback(requestTime); } catch (WeatherOpenApiException e) { if (!canRetryWithPreviousBaseTime(e)) { throw e; } - return getWeatherForecastWithoutFallback(requestTime.previousBaseTime()); + return getWeatherForecastsWithoutFallback(requestTime.previousBaseTime()); } } - private WeatherForecast getWeatherForecastWithoutFallback(WeatherForecastRequestTime requestTime) { + private Map getWeatherForecastsWithoutFallback(WeatherForecastRequestTime requestTime) { WeatherApiResponse response = getOpenApiResponse(requestTime); List items = extractForecastItems(response); - Map forecasts = items.stream() - .filter(item -> requestTime.forecastDate().equals(item.fcstDate())) - .filter(item -> requestTime.forecastTime().equals(item.fcstTime())) - .collect(Collectors.toMap( - WeatherForecastItem::category, - WeatherForecastItem::fcstValue, - (previous, current) -> current + String forecastStartDateTime = forecastDateTime(requestTime); + String forecastEndDateTime = LocalDateTime.parse(forecastStartDateTime, FORECAST_DATE_TIME_FORMATTER) + .plusHours(FORECAST_RANGE_HOURS) + .format(FORECAST_DATE_TIME_FORMATTER); + // 한 번의 API 응답에 포함된 예보를 시간대별로 묶어 Redis에서 현재 시각의 예보를 선택할 수 있게 한다. + Map> forecastsByDateTime = items.stream() + // 달력 날짜가 아닌 갱신 시각을 기준으로 향후 24시간의 예보만 캐시에 저장한다. + .filter(item -> isWithinForecastRange(item, forecastStartDateTime, forecastEndDateTime)) + .collect(Collectors.groupingBy( + item -> item.fcstDate() + item.fcstTime(), + Collectors.toMap( + WeatherForecastItem::category, + WeatherForecastItem::fcstValue, + (previous, current) -> current + ) )); - if (forecasts.isEmpty()) { + if (!forecastsByDateTime.containsKey(forecastDateTime(requestTime))) { throw WeatherOpenApiException.withDetail("forecastDateTime: " + requestTime.forecastDate() + requestTime.forecastTime()); } + return forecastsByDateTime.entrySet().stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + entry -> toWeatherForecast(entry.getValue()) + )); + } + + private WeatherForecast toWeatherForecast(Map forecasts) { String temperature = requireForecastValue(forecasts, "TMP"); String sky = requireForecastValue(forecasts, "SKY"); String precipitationType = requireForecastValue(forecasts, "PTY"); @@ -93,6 +125,20 @@ private WeatherForecast getWeatherForecastWithoutFallback(WeatherForecastRequest } } + private String forecastDateTime(WeatherForecastRequestTime requestTime) { + return requestTime.forecastDate() + requestTime.forecastTime(); + } + + private boolean isWithinForecastRange( + WeatherForecastItem item, + String forecastStartDateTime, + String forecastEndDateTime + ) { + String itemForecastDateTime = item.fcstDate() + item.fcstTime(); + return itemForecastDateTime.compareTo(forecastStartDateTime) >= 0 + && itemForecastDateTime.compareTo(forecastEndDateTime) < 0; + } + private String requireForecastValue(Map forecasts, String category) { String forecastValue = forecasts.get(category); if (forecastValue == null || forecastValue.isBlank()) { @@ -127,6 +173,9 @@ private WeatherApiResponse getOpenApiResponse(WeatherForecastRequestTime request } catch (WeatherOpenApiException e) { throw e; } catch (HttpStatusCodeException e) { + if (e.getStatusCode().value() == 429) { + throw rateLimitException(requestTime, "httpStatus: " + e.getStatusCode()); + } String responseBody = e.getResponseBodyAsString(); if (responseBody != null && !responseBody.isBlank()) { return parseResponse(responseBody, requestTime); @@ -145,6 +194,10 @@ private WeatherApiResponse parseResponse(String responseBody, WeatherForecastReq + requestTime.baseDate() + requestTime.baseTime() + ", response body is empty"); } + if (isRateLimitResponse(responseBody)) { + throw rateLimitException(requestTime, responseBody.trim()); + } + if (!responseBody.trim().startsWith("{")) { throw WeatherOpenApiException.withDetail("baseDateTime: " + requestTime.baseDate() + requestTime.baseTime() + ", " + extractXmlErrorMessage(responseBody)); @@ -158,6 +211,20 @@ private WeatherApiResponse parseResponse(String responseBody, WeatherForecastReq } } + private boolean isRateLimitResponse(String responseBody) { + String normalizedResponse = responseBody.toUpperCase(Locale.ROOT); + return normalizedResponse.contains("API RATE LIMIT EXCEEDED") + || normalizedResponse.contains("LIMITED_NUMBER_OF_SERVICE_REQUESTS_PER_SECOND_EXCEEDS_ERROR"); + } + + private WeatherOpenApiRateLimitException rateLimitException( + WeatherForecastRequestTime requestTime, + String cause + ) { + return WeatherOpenApiRateLimitException.withDetail("baseDateTime: " + + requestTime.baseDate() + requestTime.baseTime() + ", cause: " + cause); + } + private String getRequestURL(WeatherForecastRequestTime requestTime) { StringBuilder urlBuilder = new StringBuilder(OPEN_API_URL); try { diff --git a/src/main/java/in/koreatech/koin/domain/weather/model/WeatherCache.java b/src/main/java/in/koreatech/koin/domain/weather/model/WeatherCache.java index d215696ba..5965aad48 100644 --- a/src/main/java/in/koreatech/koin/domain/weather/model/WeatherCache.java +++ b/src/main/java/in/koreatech/koin/domain/weather/model/WeatherCache.java @@ -1,5 +1,6 @@ package in.koreatech.koin.domain.weather.model; +import java.util.Map; import java.util.concurrent.TimeUnit; import org.springframework.data.annotation.Id; @@ -15,12 +16,12 @@ public class WeatherCache { public static final String BYEONGCHEON_ID = "byeongcheon"; - private static final long CACHE_EXPIRE_HOUR = 2L; + private static final long CACHE_EXPIRE_HOUR = 24L; @Id private String id; - private WeatherResponse weather; + private Map hourlyWeathers; @TimeToLive(unit = TimeUnit.HOURS) private final Long expiration; @@ -28,18 +29,18 @@ public class WeatherCache { @Builder private WeatherCache( String id, - WeatherResponse weather, + Map hourlyWeathers, Long expiration ) { this.id = id; - this.weather = weather; + this.hourlyWeathers = hourlyWeathers; this.expiration = expiration == null ? CACHE_EXPIRE_HOUR : expiration; } - public static WeatherCache of(WeatherResponse weather) { + public static WeatherCache of(Map hourlyWeathers) { return WeatherCache.builder() .id(BYEONGCHEON_ID) - .weather(weather) + .hourlyWeathers(hourlyWeathers) .build(); } } diff --git a/src/main/java/in/koreatech/koin/domain/weather/service/WeatherService.java b/src/main/java/in/koreatech/koin/domain/weather/service/WeatherService.java index fec759e16..4f8a02487 100644 --- a/src/main/java/in/koreatech/koin/domain/weather/service/WeatherService.java +++ b/src/main/java/in/koreatech/koin/domain/weather/service/WeatherService.java @@ -2,6 +2,10 @@ import java.time.Clock; import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; import org.springframework.stereotype.Service; @@ -17,20 +21,37 @@ @RequiredArgsConstructor public class WeatherService { + private static final DateTimeFormatter FORECAST_DATE_TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyyMMddHHmm"); + private final Clock clock; private final WeatherClient weatherClient; private final WeatherCacheRepository weatherCacheRepository; public WeatherResponse getWeather() { + String forecastDateTime = LocalDateTime.now(clock) + .withMinute(0) + .withSecond(0) + .withNano(0) + .format(FORECAST_DATE_TIME_FORMATTER); + return weatherCacheRepository.findById(WeatherCache.BYEONGCHEON_ID) - .map(WeatherCache::getWeather) - .orElseThrow(() -> WeatherOpenApiException.withDetail("weather cache is empty")); + .flatMap(cache -> Optional.ofNullable(cache.getHourlyWeathers())) + .map(hourlyWeathers -> hourlyWeathers.get(forecastDateTime)) + .orElseThrow(() -> WeatherOpenApiException.withDetail( + "weather cache is empty, forecastDateTime: " + forecastDateTime + )); } public synchronized void refreshWeather() { LocalDateTime now = LocalDateTime.now(clock); WeatherForecastRequestTime requestTime = WeatherForecastRequestTime.from(now); - WeatherResponse response = weatherClient.getWeatherForecast(requestTime).toResponse(); - weatherCacheRepository.save(WeatherCache.of(response)); + // 외부 API의 시간대별 도메인 예보를 조회 API가 바로 사용할 수 있는 응답 Map으로 한 번에 변환한다. + Map hourlyWeathers = weatherClient.getWeatherForecasts(requestTime).entrySet().stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + entry -> entry.getValue().toResponse() + )); + weatherCacheRepository.save(WeatherCache.of(hourlyWeathers)); } } diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/WeatherApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/WeatherApiTest.java index ee14bce41..693696e44 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/WeatherApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/WeatherApiTest.java @@ -4,6 +4,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.util.Map; + import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; @@ -21,9 +23,9 @@ class WeatherApiTest extends AcceptanceTest { @Test void 병천_날씨를_조회한다() throws Exception { clear(); - weatherCacheRepository.save(WeatherCache.of( - new WeatherResponse(21, "맑음") - )); + weatherCacheRepository.save(WeatherCache.of(Map.of( + "202401151200", new WeatherResponse(21, "맑음") + ))); mockMvc.perform( get("/weather") diff --git a/src/test/java/in/koreatech/koin/unit/domain/weather/WeatherClientTest.java b/src/test/java/in/koreatech/koin/unit/domain/weather/WeatherClientTest.java index 54de06c84..6a5a2f799 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/weather/WeatherClientTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/weather/WeatherClientTest.java @@ -3,12 +3,18 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.springframework.test.web.client.ExpectedCount.once; +import static org.springframework.test.web.client.ExpectedCount.times; import static org.springframework.test.web.client.match.MockRestRequestMatchers.anything; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; +import java.util.Map; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.retry.support.RetryTemplate; import org.springframework.test.web.client.MockRestServiceServer; import org.springframework.web.client.RestTemplate; @@ -17,6 +23,7 @@ import in.koreatech.koin.domain.weather.client.WeatherClient; import in.koreatech.koin.domain.weather.client.dto.WeatherForecastRequestTime; import in.koreatech.koin.domain.weather.exception.WeatherOpenApiException; +import in.koreatech.koin.domain.weather.exception.WeatherOpenApiRateLimitException; import in.koreatech.koin.domain.weather.model.WeatherForecast; class WeatherClientTest { @@ -35,23 +42,57 @@ class WeatherClientTest { void setUp() { RestTemplate restTemplate = new RestTemplate(); server = MockRestServiceServer.bindTo(restTemplate).build(); - weatherClient = new WeatherClient("test-api-key", restTemplate, new ObjectMapper()); + RetryTemplate retryTemplate = RetryTemplate.builder() + .maxAttempts(6) + .noBackoff() + .retryOn(WeatherOpenApiRateLimitException.class) + .build(); + weatherClient = new WeatherClient("test-api-key", restTemplate, new ObjectMapper(), retryTemplate); } @Test - void 기상청_예보_응답에서_기온_하늘상태_강수형태를_추출한다() { + void 기상청_예보_응답에서_시간대별_기온_하늘상태_강수형태를_추출한다() { server.expect(once(), anything()) .andRespond(withSuccess(weatherApiResponse(""" {"category":"TMP","fcstDate":"20240115","fcstTime":"1200","fcstValue":"21"}, {"category":"SKY","fcstDate":"20240115","fcstTime":"1200","fcstValue":"1"}, - {"category":"PTY","fcstDate":"20240115","fcstTime":"1200","fcstValue":"0"} + {"category":"PTY","fcstDate":"20240115","fcstTime":"1200","fcstValue":"0"}, + {"category":"TMP","fcstDate":"20240115","fcstTime":"1300","fcstValue":"22"}, + {"category":"SKY","fcstDate":"20240115","fcstTime":"1300","fcstValue":"3"}, + {"category":"PTY","fcstDate":"20240115","fcstTime":"1300","fcstValue":"0"} + """), MediaType.APPLICATION_JSON)); + + Map forecasts = weatherClient.getWeatherForecasts(REQUEST_TIME); + + assertThat(forecasts).containsOnlyKeys("202401151200", "202401151300"); + assertThat(forecasts.get("202401151200")) + .isEqualTo(new WeatherForecast(21, "1", "0")); + assertThat(forecasts.get("202401151300")) + .isEqualTo(new WeatherForecast(22, "3", "0")); + server.verify(); + } + + @Test + void 갱신_시각부터_향후_24시간의_예보만_추출한다() { + server.expect(once(), anything()) + .andRespond(withSuccess(weatherApiResponse(""" + {"category":"TMP","fcstDate":"20240115","fcstTime":"1100","fcstValue":"20"}, + {"category":"SKY","fcstDate":"20240115","fcstTime":"1100","fcstValue":"1"}, + {"category":"PTY","fcstDate":"20240115","fcstTime":"1100","fcstValue":"0"}, + {"category":"TMP","fcstDate":"20240115","fcstTime":"1200","fcstValue":"21"}, + {"category":"SKY","fcstDate":"20240115","fcstTime":"1200","fcstValue":"1"}, + {"category":"PTY","fcstDate":"20240115","fcstTime":"1200","fcstValue":"0"}, + {"category":"TMP","fcstDate":"20240116","fcstTime":"1100","fcstValue":"19"}, + {"category":"SKY","fcstDate":"20240116","fcstTime":"1100","fcstValue":"3"}, + {"category":"PTY","fcstDate":"20240116","fcstTime":"1100","fcstValue":"0"}, + {"category":"TMP","fcstDate":"20240116","fcstTime":"1200","fcstValue":"18"}, + {"category":"SKY","fcstDate":"20240116","fcstTime":"1200","fcstValue":"4"}, + {"category":"PTY","fcstDate":"20240116","fcstTime":"1200","fcstValue":"1"} """), MediaType.APPLICATION_JSON)); - WeatherForecast forecast = weatherClient.getWeatherForecast(REQUEST_TIME); + Map forecasts = weatherClient.getWeatherForecasts(REQUEST_TIME); - assertThat(forecast.temperature()).isEqualTo(21); - assertThat(forecast.sky()).isEqualTo("1"); - assertThat(forecast.precipitationType()).isEqualTo("0"); + assertThat(forecasts).containsOnlyKeys("202401151200", "202401161100"); server.verify(); } @@ -70,11 +111,10 @@ void setUp() { {"category":"PTY","fcstDate":"20240115","fcstTime":"1200","fcstValue":"0"} """), MediaType.APPLICATION_JSON)); - WeatherForecast forecast = weatherClient.getWeatherForecast(REQUEST_TIME); + Map forecasts = weatherClient.getWeatherForecasts(REQUEST_TIME); - assertThat(forecast.temperature()).isEqualTo(20); - assertThat(forecast.sky()).isEqualTo("3"); - assertThat(forecast.precipitationType()).isEqualTo("0"); + assertThat(forecasts.get("202401151200")) + .isEqualTo(new WeatherForecast(20, "3", "0")); server.verify(); } @@ -86,7 +126,7 @@ void setUp() { {"category":"PTY","fcstDate":"20240115","fcstTime":"1200","fcstValue":"0"} """), MediaType.APPLICATION_JSON)); - assertThatThrownBy(() -> weatherClient.getWeatherForecast(REQUEST_TIME)) + assertThatThrownBy(() -> weatherClient.getWeatherForecasts(REQUEST_TIME)) .isInstanceOf(WeatherOpenApiException.class) .hasMessage("기상청 단기예보 API 응답이 정상적이지 않습니다.") .satisfies(exception -> assertThat(((WeatherOpenApiException) exception).getFullMessage()) @@ -95,6 +135,37 @@ void setUp() { server.verify(); } + @Test + void 동시_호출_제한이_발생하면_5회까지_재시도한다() { + server.expect(times(5), anything()) + .andRespond(withStatus(HttpStatus.TOO_MANY_REQUESTS) + .body("API rate limit exceeded")); + server.expect(once(), anything()) + .andRespond(withSuccess(weatherApiResponse(""" + {"category":"TMP","fcstDate":"20240115","fcstTime":"1200","fcstValue":"21"}, + {"category":"SKY","fcstDate":"20240115","fcstTime":"1200","fcstValue":"1"}, + {"category":"PTY","fcstDate":"20240115","fcstTime":"1200","fcstValue":"0"} + """), MediaType.APPLICATION_JSON)); + + Map forecasts = weatherClient.getWeatherForecasts(REQUEST_TIME); + + assertThat(forecasts.get("202401151200")) + .isEqualTo(new WeatherForecast(21, "1", "0")); + server.verify(); + } + + @Test + void 동시_호출_제한이_6번_연속_발생하면_최종_예외를_반환한다() { + server.expect(times(6), anything()) + .andRespond(withStatus(HttpStatus.TOO_MANY_REQUESTS) + .body("API rate limit exceeded")); + + assertThatThrownBy(() -> weatherClient.getWeatherForecasts(REQUEST_TIME)) + .isInstanceOf(WeatherOpenApiRateLimitException.class); + + server.verify(); + } + private String weatherApiResponse(String items) { return """ { diff --git a/src/test/java/in/koreatech/koin/unit/domain/weather/WeatherServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/weather/WeatherServiceTest.java index 95c9e2359..6c3c76a34 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/weather/WeatherServiceTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/weather/WeatherServiceTest.java @@ -10,10 +10,12 @@ import java.time.Clock; import java.time.Instant; import java.time.ZoneId; +import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -46,13 +48,17 @@ class WeatherServiceTest { "1200" ); WeatherResponse cachedWeather = new WeatherResponse(21, "맑음"); + WeatherResponse nextWeather = new WeatherResponse(22, "구름많음"); when(weatherCacheRepository.findById(WeatherCache.BYEONGCHEON_ID)) - .thenReturn(Optional.of(WeatherCache.of(cachedWeather))); + .thenReturn(Optional.of(WeatherCache.of(Map.of( + "202401151200", cachedWeather, + "202401151300", nextWeather + )))); WeatherResponse response = weatherService.getWeather(); assertThat(response).isEqualTo(cachedWeather); - verify(weatherClient, never()).getWeatherForecast(requestTime); + verify(weatherClient, never()).getWeatherForecasts(requestTime); } @Test @@ -64,7 +70,19 @@ class WeatherServiceTest { assertThrows(WeatherOpenApiException.class, weatherService::getWeather); - verify(weatherClient, never()).getWeatherForecast(any()); + verify(weatherClient, never()).getWeatherForecasts(any()); + } + + @Test + void 현재_시간대의_예보가_캐시에_없으면_비정상_응답으로_처리한다() { + Clock clock = Clock.fixed(Instant.parse("2024-01-15T03:35:00Z"), ZoneId.of("Asia/Seoul")); + WeatherService weatherService = new WeatherService(clock, weatherClient, weatherCacheRepository); + when(weatherCacheRepository.findById(WeatherCache.BYEONGCHEON_ID)) + .thenReturn(Optional.of(WeatherCache.builder().build())); + + assertThrows(WeatherOpenApiException.class, weatherService::getWeather); + + verify(weatherClient, never()).getWeatherForecasts(any()); } @Test @@ -77,12 +95,21 @@ class WeatherServiceTest { "20240115", "1200" ); - when(weatherClient.getWeatherForecast(requestTime)) - .thenReturn(new WeatherForecast(21, "1", "0")); + when(weatherClient.getWeatherForecasts(requestTime)) + .thenReturn(Map.of( + "202401151200", new WeatherForecast(21, "1", "0"), + "202401151300", new WeatherForecast(22, "3", "0") + )); weatherService.refreshWeather(); - verify(weatherClient).getWeatherForecast(requestTime); - verify(weatherCacheRepository).save(any(WeatherCache.class)); + ArgumentCaptor cacheCaptor = ArgumentCaptor.forClass(WeatherCache.class); + verify(weatherClient).getWeatherForecasts(requestTime); + verify(weatherCacheRepository).save(cacheCaptor.capture()); + assertThat(cacheCaptor.getValue().getHourlyWeathers()).containsOnlyKeys( + "202401151200", + "202401151300" + ); + assertThat(cacheCaptor.getValue().getExpiration()).isEqualTo(24L); } }