From 3ece153a0836bc89bf14fd1e72cba2ea0e9ba41b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Wed, 27 May 2026 13:28:53 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=EB=B3=91=EC=B2=9C=20=EB=82=A0?= =?UTF-8?q?=EC=94=A8=20=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기상청 단기예보 API를 병천면 고정 좌표로 호출해 화면에 필요한 날씨 정보를 제공한다. - 응답을 기온과 날씨 상태로 제한해 클라이언트가 디자인에 필요한 값만 의존하도록 했다. - 발표시각 보정과 SKY/PTY 코드 매핑을 분리해 잘못된 예보시각이나 상태값 노출을 막는다. - 컨트롤러 인수 테스트와 날씨 도메인 단위 테스트로 기본 조회 흐름을 검증했다. --- .../domain/weather/client/WeatherClient.java | 130 ++++++++++++++++++ .../domain/weather/controller/WeatherApi.java | 35 +++++ .../weather/controller/WeatherController.java | 22 +++ .../weather/dto/WeatherApiResponse.java | 51 +++++++ .../domain/weather/dto/WeatherResponse.java | 23 ++++ .../exception/WeatherOpenApiException.java | 20 +++ .../weather/model/WeatherCondition.java | 42 ++++++ .../domain/weather/model/WeatherForecast.java | 17 +++ .../model/WeatherForecastRequestTime.java | 52 +++++++ .../weather/service/WeatherService.java | 38 +++++ .../acceptance/domain/WeatherApiTest.java | 40 ++++++ .../domain/weather/WeatherConditionTest.java | 32 +++++ .../WeatherForecastRequestTimeTest.java | 36 +++++ .../domain/weather/WeatherServiceTest.java | 48 +++++++ 14 files changed, 586 insertions(+) create mode 100644 src/main/java/in/koreatech/koin/domain/weather/client/WeatherClient.java create mode 100644 src/main/java/in/koreatech/koin/domain/weather/controller/WeatherApi.java create mode 100644 src/main/java/in/koreatech/koin/domain/weather/controller/WeatherController.java create mode 100644 src/main/java/in/koreatech/koin/domain/weather/dto/WeatherApiResponse.java create mode 100644 src/main/java/in/koreatech/koin/domain/weather/dto/WeatherResponse.java create mode 100644 src/main/java/in/koreatech/koin/domain/weather/exception/WeatherOpenApiException.java create mode 100644 src/main/java/in/koreatech/koin/domain/weather/model/WeatherCondition.java create mode 100644 src/main/java/in/koreatech/koin/domain/weather/model/WeatherForecast.java create mode 100644 src/main/java/in/koreatech/koin/domain/weather/model/WeatherForecastRequestTime.java create mode 100644 src/main/java/in/koreatech/koin/domain/weather/service/WeatherService.java create mode 100644 src/test/java/in/koreatech/koin/acceptance/domain/WeatherApiTest.java create mode 100644 src/test/java/in/koreatech/koin/unit/domain/weather/WeatherConditionTest.java create mode 100644 src/test/java/in/koreatech/koin/unit/domain/weather/WeatherForecastRequestTimeTest.java create mode 100644 src/test/java/in/koreatech/koin/unit/domain/weather/WeatherServiceTest.java 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 new file mode 100644 index 000000000..e54b13520 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/weather/client/WeatherClient.java @@ -0,0 +1,130 @@ +package in.koreatech.koin.domain.weather.client; + +import static java.net.URLEncoder.encode; + +import java.net.URL; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import in.koreatech.koin.domain.weather.dto.WeatherApiResponse; +import in.koreatech.koin.domain.weather.dto.WeatherApiResponse.WeatherForecastItem; +import in.koreatech.koin.domain.weather.exception.WeatherOpenApiException; +import in.koreatech.koin.domain.weather.model.WeatherForecast; +import in.koreatech.koin.domain.weather.model.WeatherForecastRequestTime; +import in.koreatech.koin.global.exception.custom.KoinIllegalStateException; + +@Component +public class WeatherClient { + + private static final String OPEN_API_URL = + "https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getVilageFcst"; + private static final String ENCODE_TYPE = "UTF-8"; + private static final int BYEONGCHEON_NX = 66; + private static final int BYEONGCHEON_NY = 109; + private static final int ROW_COUNT = 1000; + + private final String openApiKey; + private final RestTemplate restTemplate; + + public WeatherClient( + @Value("${OPEN_API_KEY_PUBLIC}") String openApiKey, + RestTemplate restTemplate + ) { + this.openApiKey = openApiKey; + this.restTemplate = restTemplate; + } + + public WeatherForecast getWeatherForecast(WeatherForecastRequestTime requestTime) { + WeatherApiResponse response = getOpenApiResponse(requestTime); + List items = extractForecastItems(response); + Map forecasts = items.stream() + .filter(item -> item.fcstDate().equals(requestTime.forecastDate())) + .filter(item -> item.fcstTime().equals(requestTime.forecastTime())) + .collect(Collectors.toMap( + WeatherForecastItem::category, + WeatherForecastItem::fcstValue, + (previous, current) -> current + )); + + try { + return new WeatherForecast( + Integer.parseInt(forecasts.get("TMP")), + forecasts.get("SKY"), + forecasts.get("PTY") + ); + } catch (Exception e) { + throw WeatherOpenApiException.withDetail("forecastDateTime: " + + requestTime.forecastDate() + requestTime.forecastTime()); + } + } + + private WeatherApiResponse getOpenApiResponse(WeatherForecastRequestTime requestTime) { + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Accept", "*/*"); + + HttpEntity entity = new HttpEntity<>(headers); + URL url = new URL(getRequestURL(requestTime)); + ResponseEntity response = restTemplate.exchange( + url.toURI(), + HttpMethod.GET, + entity, + WeatherApiResponse.class + ); + return response.getBody(); + } catch (WeatherOpenApiException e) { + throw e; + } catch (Exception e) { + throw WeatherOpenApiException.withDetail("baseDateTime: " + + requestTime.baseDate() + requestTime.baseTime()); + } + } + + private String getRequestURL(WeatherForecastRequestTime requestTime) { + StringBuilder urlBuilder = new StringBuilder(OPEN_API_URL); + try { + urlBuilder.append("?" + encode("serviceKey", ENCODE_TYPE) + "=" + encode(openApiKey, ENCODE_TYPE)); + urlBuilder.append("&" + encode("numOfRows", ENCODE_TYPE) + "=" + + encode(String.valueOf(ROW_COUNT), ENCODE_TYPE)); + urlBuilder.append("&" + encode("pageNo", ENCODE_TYPE) + "=" + encode("1", ENCODE_TYPE)); + urlBuilder.append("&" + encode("dataType", ENCODE_TYPE) + "=" + encode("JSON", ENCODE_TYPE)); + urlBuilder.append("&" + encode("base_date", ENCODE_TYPE) + "=" + + encode(requestTime.baseDate(), ENCODE_TYPE)); + urlBuilder.append("&" + encode("base_time", ENCODE_TYPE) + "=" + + encode(requestTime.baseTime(), ENCODE_TYPE)); + urlBuilder.append("&" + encode("nx", ENCODE_TYPE) + "=" + encode(String.valueOf(BYEONGCHEON_NX), ENCODE_TYPE)); + urlBuilder.append("&" + encode("ny", ENCODE_TYPE) + "=" + encode(String.valueOf(BYEONGCHEON_NY), ENCODE_TYPE)); + return urlBuilder.toString(); + } catch (Exception e) { + throw new KoinIllegalStateException("기상청 단기예보 API URL 생성중 문제가 발생했습니다.", "uri:" + urlBuilder); + } + } + + private List extractForecastItems(WeatherApiResponse response) { + if (response == null + || response.response() == null + || response.response().header() == null + || response.response().body() == null + || response.response().body().items() == null + || response.response().body().items().item() == null) { + throw WeatherOpenApiException.withDetail("response body is empty"); + } + + String resultCode = response.response().header().resultCode(); + if (!resultCode.equals("00") && !resultCode.equals("0")) { + throw WeatherOpenApiException.withDetail("resultCode: " + resultCode); + } + return response.response().body().items().item(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/weather/controller/WeatherApi.java b/src/main/java/in/koreatech/koin/domain/weather/controller/WeatherApi.java new file mode 100644 index 000000000..daa788e52 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/weather/controller/WeatherApi.java @@ -0,0 +1,35 @@ +package in.koreatech.koin.domain.weather.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; + +import in.koreatech.koin.domain.weather.dto.WeatherResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "(Normal) Weather: 날씨", description = "병천 날씨 정보를 조회한다") +public interface WeatherApi { + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200", content = @Content(mediaType = "application/json", examples = + @ExampleObject( + value = """ + { + "temperature": 21, + "weather": "맑음" + } + """ + ))), + @ApiResponse(responseCode = "500", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "병천 날씨 조회") + @GetMapping("/weather") + ResponseEntity getWeather(); +} diff --git a/src/main/java/in/koreatech/koin/domain/weather/controller/WeatherController.java b/src/main/java/in/koreatech/koin/domain/weather/controller/WeatherController.java new file mode 100644 index 000000000..8e7e35216 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/weather/controller/WeatherController.java @@ -0,0 +1,22 @@ +package in.koreatech.koin.domain.weather.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import in.koreatech.koin.domain.weather.dto.WeatherResponse; +import in.koreatech.koin.domain.weather.service.WeatherService; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class WeatherController implements WeatherApi { + + private final WeatherService weatherService; + + @GetMapping("/weather") + public ResponseEntity getWeather() { + WeatherResponse response = weatherService.getWeather(); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/weather/dto/WeatherApiResponse.java b/src/main/java/in/koreatech/koin/domain/weather/dto/WeatherApiResponse.java new file mode 100644 index 000000000..8dee837a5 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/weather/dto/WeatherApiResponse.java @@ -0,0 +1,51 @@ +package in.koreatech.koin.domain.weather.dto; + +import java.util.List; + +public record WeatherApiResponse( + InnerResponse response +) { + + public record InnerResponse( + InnerHeader header, + InnerBody body + ) { + + } + + public record InnerHeader( + String resultCode, + String resultMsg + ) { + + } + + public record InnerBody( + String dataType, + InnerItems items, + Integer numOfRows, + Integer pageNo, + Integer totalCount + ) { + + } + + public record InnerItems( + List item + ) { + + } + + public record WeatherForecastItem( + String baseDate, + String baseTime, + String category, + String fcstDate, + String fcstTime, + String fcstValue, + Integer nx, + Integer ny + ) { + + } +} diff --git a/src/main/java/in/koreatech/koin/domain/weather/dto/WeatherResponse.java b/src/main/java/in/koreatech/koin/domain/weather/dto/WeatherResponse.java new file mode 100644 index 000000000..71eff90f6 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/weather/dto/WeatherResponse.java @@ -0,0 +1,23 @@ +package in.koreatech.koin.domain.weather.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.weather.model.WeatherCondition; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record WeatherResponse( + @Schema(description = "기온(섭씨)", example = "21", requiredMode = REQUIRED) + Integer temperature, + + @Schema(description = "날씨 상태", example = "맑음", requiredMode = REQUIRED) + String weather +) { + + public static WeatherResponse of(Integer temperature, WeatherCondition weatherCondition) { + return new WeatherResponse(temperature, weatherCondition.getValue()); + } +} 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 new file mode 100644 index 000000000..9ce59aef3 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/weather/exception/WeatherOpenApiException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.weather.exception; + +import in.koreatech.koin.global.exception.custom.ExternalServiceException; + +public class WeatherOpenApiException extends ExternalServiceException { + + private static final String DEFAULT_MESSAGE = "기상청 단기예보 API 응답이 정상적이지 않습니다."; + + public WeatherOpenApiException(String message) { + super(message); + } + + public WeatherOpenApiException(String message, String detail) { + super(message, detail); + } + + public static WeatherOpenApiException withDetail(String detail) { + return new WeatherOpenApiException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/weather/model/WeatherCondition.java b/src/main/java/in/koreatech/koin/domain/weather/model/WeatherCondition.java new file mode 100644 index 000000000..89a35cb81 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/weather/model/WeatherCondition.java @@ -0,0 +1,42 @@ +package in.koreatech.koin.domain.weather.model; + +import java.util.Map; + +import lombok.Getter; + +@Getter +public enum WeatherCondition { + SUNNY("맑음"), + CLOUDY("구름많음"), + OVERCAST("흐림"), + RAIN("비"), + RAIN_AND_SNOW("비/눈"), + SNOW("눈"), + SHOWER("소나기"); + + private static final Map SKY_CONDITIONS = Map.of( + "1", SUNNY, + "3", CLOUDY, + "4", OVERCAST + ); + + private static final Map PRECIPITATION_CONDITIONS = Map.of( + "1", RAIN, + "2", RAIN_AND_SNOW, + "3", SNOW, + "4", SHOWER + ); + + private final String value; + + WeatherCondition(String value) { + this.value = value; + } + + public static WeatherCondition from(String sky, String precipitationType) { + if (precipitationType != null && !precipitationType.equals("0")) { + return PRECIPITATION_CONDITIONS.getOrDefault(precipitationType, RAIN); + } + return SKY_CONDITIONS.getOrDefault(sky, OVERCAST); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/weather/model/WeatherForecast.java b/src/main/java/in/koreatech/koin/domain/weather/model/WeatherForecast.java new file mode 100644 index 000000000..f1271ea50 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/weather/model/WeatherForecast.java @@ -0,0 +1,17 @@ +package in.koreatech.koin.domain.weather.model; + +import in.koreatech.koin.domain.weather.dto.WeatherResponse; + +public record WeatherForecast( + Integer temperature, + String sky, + String precipitationType +) { + + public WeatherResponse toResponse() { + return WeatherResponse.of( + temperature, + WeatherCondition.from(sky, precipitationType) + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/weather/model/WeatherForecastRequestTime.java b/src/main/java/in/koreatech/koin/domain/weather/model/WeatherForecastRequestTime.java new file mode 100644 index 000000000..026af14a3 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/weather/model/WeatherForecastRequestTime.java @@ -0,0 +1,52 @@ +package in.koreatech.koin.domain.weather.model; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +public record WeatherForecastRequestTime( + String baseDate, + String baseTime, + String forecastDate, + String forecastTime +) { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HHmm"); + private static final List BASE_TIMES = List.of( + LocalTime.of(2, 0), + LocalTime.of(5, 0), + LocalTime.of(8, 0), + LocalTime.of(11, 0), + LocalTime.of(14, 0), + LocalTime.of(17, 0), + LocalTime.of(20, 0), + LocalTime.of(23, 0) + ); + + public static WeatherForecastRequestTime from(LocalDateTime now) { + LocalDate baseDate = now.toLocalDate(); + LocalTime availableTime = now.toLocalTime().minusMinutes(10); + LocalTime baseTime = null; + for (LocalTime time : BASE_TIMES) { + if (!time.isAfter(availableTime)) { + baseTime = time; + } + } + if (baseTime == null) { + baseDate = baseDate.minusDays(1); + baseTime = BASE_TIMES.get(BASE_TIMES.size() - 1); + } + + LocalDateTime forecastDateTime = now.withMinute(0).withSecond(0).withNano(0); + + return new WeatherForecastRequestTime( + baseDate.format(DATE_FORMATTER), + baseTime.format(TIME_FORMATTER), + forecastDateTime.toLocalDate().format(DATE_FORMATTER), + forecastDateTime.toLocalTime().format(TIME_FORMATTER) + ); + } +} 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 new file mode 100644 index 000000000..45b4b102d --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/weather/service/WeatherService.java @@ -0,0 +1,38 @@ +package in.koreatech.koin.domain.weather.service; + +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDateTime; + +import org.springframework.stereotype.Service; + +import in.koreatech.koin.domain.weather.client.WeatherClient; +import in.koreatech.koin.domain.weather.dto.WeatherResponse; +import in.koreatech.koin.domain.weather.model.WeatherForecastRequestTime; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class WeatherService { + + private static final Duration CACHE_TTL = Duration.ofMinutes(10); + + private final Clock clock; + private final WeatherClient weatherClient; + + private WeatherResponse cachedWeather; + private LocalDateTime cachedAt; + + public synchronized WeatherResponse getWeather() { + LocalDateTime now = LocalDateTime.now(clock); + if (cachedWeather != null && cachedAt.plus(CACHE_TTL).isAfter(now)) { + return cachedWeather; + } + + WeatherForecastRequestTime requestTime = WeatherForecastRequestTime.from(now); + WeatherResponse response = weatherClient.getWeatherForecast(requestTime).toResponse(); + cachedWeather = response; + cachedAt = now; + return response; + } +} diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/WeatherApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/WeatherApiTest.java new file mode 100644 index 000000000..5640094b3 --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/domain/WeatherApiTest.java @@ -0,0 +1,40 @@ +package in.koreatech.koin.acceptance.domain; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; + +import in.koreatech.koin.acceptance.AcceptanceTest; +import in.koreatech.koin.domain.weather.client.WeatherClient; +import in.koreatech.koin.domain.weather.model.WeatherForecast; +import in.koreatech.koin.domain.weather.model.WeatherForecastRequestTime; + +class WeatherApiTest extends AcceptanceTest { + + @MockBean + private WeatherClient weatherClient; + + @Test + void 병천_날씨를_조회한다() throws Exception { + clear(); + when(weatherClient.getWeatherForecast(new WeatherForecastRequestTime("20240115", "1100", "20240115", "1200"))) + .thenReturn(new WeatherForecast(21, "1", "0")); + + mockMvc.perform( + get("/weather") + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andExpect(content().json(""" + { + "temperature": 21, + "weather": "맑음" + } + """)); + } +} diff --git a/src/test/java/in/koreatech/koin/unit/domain/weather/WeatherConditionTest.java b/src/test/java/in/koreatech/koin/unit/domain/weather/WeatherConditionTest.java new file mode 100644 index 000000000..e774f7735 --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/domain/weather/WeatherConditionTest.java @@ -0,0 +1,32 @@ +package in.koreatech.koin.unit.domain.weather; + +import static in.koreatech.koin.domain.weather.model.WeatherCondition.CLOUDY; +import static in.koreatech.koin.domain.weather.model.WeatherCondition.OVERCAST; +import static in.koreatech.koin.domain.weather.model.WeatherCondition.RAIN; +import static in.koreatech.koin.domain.weather.model.WeatherCondition.RAIN_AND_SNOW; +import static in.koreatech.koin.domain.weather.model.WeatherCondition.SHOWER; +import static in.koreatech.koin.domain.weather.model.WeatherCondition.SNOW; +import static in.koreatech.koin.domain.weather.model.WeatherCondition.SUNNY; +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import in.koreatech.koin.domain.weather.model.WeatherCondition; + +class WeatherConditionTest { + + @Test + void 강수형태가_없으면_하늘상태로_날씨를_정한다() { + assertThat(WeatherCondition.from("1", "0")).isEqualTo(SUNNY); + assertThat(WeatherCondition.from("3", "0")).isEqualTo(CLOUDY); + assertThat(WeatherCondition.from("4", "0")).isEqualTo(OVERCAST); + } + + @Test + void 강수형태가_있으면_하늘상태보다_강수형태를_우선한다() { + assertThat(WeatherCondition.from("1", "1")).isEqualTo(RAIN); + assertThat(WeatherCondition.from("1", "2")).isEqualTo(RAIN_AND_SNOW); + assertThat(WeatherCondition.from("1", "3")).isEqualTo(SNOW); + assertThat(WeatherCondition.from("1", "4")).isEqualTo(SHOWER); + } +} diff --git a/src/test/java/in/koreatech/koin/unit/domain/weather/WeatherForecastRequestTimeTest.java b/src/test/java/in/koreatech/koin/unit/domain/weather/WeatherForecastRequestTimeTest.java new file mode 100644 index 000000000..5192cfe22 --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/domain/weather/WeatherForecastRequestTimeTest.java @@ -0,0 +1,36 @@ +package in.koreatech.koin.unit.domain.weather; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.Test; + +import in.koreatech.koin.domain.weather.model.WeatherForecastRequestTime; + +class WeatherForecastRequestTimeTest { + + @Test + void 기상청_단기예보_제공시각이_지난_가장_최근_발표시각을_사용한다() { + WeatherForecastRequestTime requestTime = WeatherForecastRequestTime.from( + LocalDateTime.of(2024, 1, 15, 12, 35) + ); + + assertThat(requestTime.baseDate()).isEqualTo("20240115"); + assertThat(requestTime.baseTime()).isEqualTo("1100"); + assertThat(requestTime.forecastDate()).isEqualTo("20240115"); + assertThat(requestTime.forecastTime()).isEqualTo("1200"); + } + + @Test + void 첫_발표시각_전에는_전날_마지막_발표시각을_사용한다() { + WeatherForecastRequestTime requestTime = WeatherForecastRequestTime.from( + LocalDateTime.of(2024, 1, 15, 2, 5) + ); + + assertThat(requestTime.baseDate()).isEqualTo("20240114"); + assertThat(requestTime.baseTime()).isEqualTo("2300"); + assertThat(requestTime.forecastDate()).isEqualTo("20240115"); + assertThat(requestTime.forecastTime()).isEqualTo("0200"); + } +} 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 new file mode 100644 index 000000000..b0e1906fb --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/domain/weather/WeatherServiceTest.java @@ -0,0 +1,48 @@ +package in.koreatech.koin.unit.domain.weather; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import in.koreatech.koin.domain.weather.client.WeatherClient; +import in.koreatech.koin.domain.weather.dto.WeatherResponse; +import in.koreatech.koin.domain.weather.model.WeatherForecast; +import in.koreatech.koin.domain.weather.model.WeatherForecastRequestTime; +import in.koreatech.koin.domain.weather.service.WeatherService; + +@ExtendWith(MockitoExtension.class) +class WeatherServiceTest { + + @Mock + private WeatherClient weatherClient; + + @Test + void 캐시가_유효하면_기상청_API를_다시_호출하지_않는다() { + Clock clock = Clock.fixed(Instant.parse("2024-01-15T03:35:00Z"), ZoneId.of("Asia/Seoul")); + WeatherService weatherService = new WeatherService(clock, weatherClient); + WeatherForecastRequestTime requestTime = new WeatherForecastRequestTime( + "20240115", + "1100", + "20240115", + "1200" + ); + when(weatherClient.getWeatherForecast(requestTime)) + .thenReturn(new WeatherForecast(21, "1", "0")); + + WeatherResponse firstResponse = weatherService.getWeather(); + WeatherResponse secondResponse = weatherService.getWeather(); + + assertThat(firstResponse).isSameAs(secondResponse); + verify(weatherClient, times(1)).getWeatherForecast(requestTime); + } +} From aeed8a1b0da91f422479a5ca60e40c36b8c1a9fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Wed, 27 May 2026 13:30:29 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=EB=82=A0=EC=94=A8=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20Redis=20=EC=BA=90=EC=8B=9C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 인스턴스가 여러 대여도 같은 예보시각에는 Redis 캐시를 공유하도록 했다. - 캐시 키를 병천 고정 위치로 제한하고 1시간 TTL을 적용해 기상청 API 중복 호출을 줄인다. - 캐시된 예보시각이 현재 계산된 예보시각과 다를 때만 새 데이터를 가져오도록 했다. - Redis 캐시 적중과 미적중 흐름을 단위 테스트로 검증했다. --- .../domain/weather/model/WeatherCache.java | 49 +++++++++++++++++++ .../repository/WeatherCacheRepository.java | 14 ++++++ .../weather/service/WeatherService.java | 23 +++++---- .../domain/weather/WeatherServiceTest.java | 43 +++++++++++++--- 4 files changed, 110 insertions(+), 19 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/domain/weather/model/WeatherCache.java create mode 100644 src/main/java/in/koreatech/koin/domain/weather/repository/WeatherCacheRepository.java 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 new file mode 100644 index 000000000..66965c78b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/weather/model/WeatherCache.java @@ -0,0 +1,49 @@ +package in.koreatech.koin.domain.weather.model; + +import java.util.concurrent.TimeUnit; + +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +import in.koreatech.koin.domain.weather.dto.WeatherResponse; +import jakarta.persistence.Id; +import lombok.Builder; +import lombok.Getter; + +@Getter +@RedisHash("Weather") +public class WeatherCache { + + public static final String BYEONGCHEON_ID = "byeongcheon"; + private static final long CACHE_EXPIRE_HOUR = 1L; + + @Id + private String id; + + private WeatherForecastRequestTime requestTime; + private WeatherResponse weather; + + @TimeToLive(unit = TimeUnit.HOURS) + private final Long expiration; + + @Builder + private WeatherCache( + String id, + WeatherForecastRequestTime requestTime, + WeatherResponse weather, + Long expiration + ) { + this.id = id; + this.requestTime = requestTime; + this.weather = weather; + this.expiration = expiration == null ? CACHE_EXPIRE_HOUR : expiration; + } + + public static WeatherCache of(WeatherForecastRequestTime requestTime, WeatherResponse weather) { + return WeatherCache.builder() + .id(BYEONGCHEON_ID) + .requestTime(requestTime) + .weather(weather) + .build(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/weather/repository/WeatherCacheRepository.java b/src/main/java/in/koreatech/koin/domain/weather/repository/WeatherCacheRepository.java new file mode 100644 index 000000000..7405f43bb --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/weather/repository/WeatherCacheRepository.java @@ -0,0 +1,14 @@ +package in.koreatech.koin.domain.weather.repository; + +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.weather.model.WeatherCache; + +public interface WeatherCacheRepository extends Repository { + + WeatherCache save(WeatherCache weatherCache); + + Optional findById(String id); +} 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 45b4b102d..a1691d01e 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 @@ -1,38 +1,37 @@ package in.koreatech.koin.domain.weather.service; import java.time.Clock; -import java.time.Duration; import java.time.LocalDateTime; import org.springframework.stereotype.Service; import in.koreatech.koin.domain.weather.client.WeatherClient; import in.koreatech.koin.domain.weather.dto.WeatherResponse; +import in.koreatech.koin.domain.weather.model.WeatherCache; import in.koreatech.koin.domain.weather.model.WeatherForecastRequestTime; +import in.koreatech.koin.domain.weather.repository.WeatherCacheRepository; import lombok.RequiredArgsConstructor; @Service @RequiredArgsConstructor public class WeatherService { - private static final Duration CACHE_TTL = Duration.ofMinutes(10); - private final Clock clock; private final WeatherClient weatherClient; - - private WeatherResponse cachedWeather; - private LocalDateTime cachedAt; + private final WeatherCacheRepository weatherCacheRepository; public synchronized WeatherResponse getWeather() { LocalDateTime now = LocalDateTime.now(clock); - if (cachedWeather != null && cachedAt.plus(CACHE_TTL).isAfter(now)) { - return cachedWeather; - } - WeatherForecastRequestTime requestTime = WeatherForecastRequestTime.from(now); + return weatherCacheRepository.findById(WeatherCache.BYEONGCHEON_ID) + .filter(cache -> cache.getRequestTime().equals(requestTime)) + .map(WeatherCache::getWeather) + .orElseGet(() -> refreshWeather(requestTime)); + } + + private WeatherResponse refreshWeather(WeatherForecastRequestTime requestTime) { WeatherResponse response = weatherClient.getWeatherForecast(requestTime).toResponse(); - cachedWeather = response; - cachedAt = now; + weatherCacheRepository.save(WeatherCache.of(requestTime, response)); return response; } } 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 b0e1906fb..9d3e35426 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 @@ -1,13 +1,15 @@ package in.koreatech.koin.unit.domain.weather; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.times; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.time.Clock; import java.time.Instant; import java.time.ZoneId; +import java.util.Optional; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -16,8 +18,10 @@ import in.koreatech.koin.domain.weather.client.WeatherClient; import in.koreatech.koin.domain.weather.dto.WeatherResponse; +import in.koreatech.koin.domain.weather.model.WeatherCache; import in.koreatech.koin.domain.weather.model.WeatherForecast; import in.koreatech.koin.domain.weather.model.WeatherForecastRequestTime; +import in.koreatech.koin.domain.weather.repository.WeatherCacheRepository; import in.koreatech.koin.domain.weather.service.WeatherService; @ExtendWith(MockitoExtension.class) @@ -26,23 +30,48 @@ class WeatherServiceTest { @Mock private WeatherClient weatherClient; + @Mock + private WeatherCacheRepository weatherCacheRepository; + + @Test + void 같은_예보시각의_레디스_캐시가_있으면_기상청_API를_호출하지_않는다() { + Clock clock = Clock.fixed(Instant.parse("2024-01-15T03:35:00Z"), ZoneId.of("Asia/Seoul")); + WeatherService weatherService = new WeatherService(clock, weatherClient, weatherCacheRepository); + WeatherForecastRequestTime requestTime = new WeatherForecastRequestTime( + "20240115", + "1100", + "20240115", + "1200" + ); + WeatherResponse cachedWeather = new WeatherResponse(21, "맑음"); + when(weatherCacheRepository.findById(WeatherCache.BYEONGCHEON_ID)) + .thenReturn(Optional.of(WeatherCache.of(requestTime, cachedWeather))); + + WeatherResponse response = weatherService.getWeather(); + + assertThat(response).isEqualTo(cachedWeather); + verify(weatherClient, never()).getWeatherForecast(requestTime); + } + @Test - void 캐시가_유효하면_기상청_API를_다시_호출하지_않는다() { + void 레디스_캐시가_없으면_기상청_API를_호출하고_캐시를_저장한다() { Clock clock = Clock.fixed(Instant.parse("2024-01-15T03:35:00Z"), ZoneId.of("Asia/Seoul")); - WeatherService weatherService = new WeatherService(clock, weatherClient); + WeatherService weatherService = new WeatherService(clock, weatherClient, weatherCacheRepository); WeatherForecastRequestTime requestTime = new WeatherForecastRequestTime( "20240115", "1100", "20240115", "1200" ); + when(weatherCacheRepository.findById(WeatherCache.BYEONGCHEON_ID)) + .thenReturn(Optional.empty()); when(weatherClient.getWeatherForecast(requestTime)) .thenReturn(new WeatherForecast(21, "1", "0")); - WeatherResponse firstResponse = weatherService.getWeather(); - WeatherResponse secondResponse = weatherService.getWeather(); + WeatherResponse response = weatherService.getWeather(); - assertThat(firstResponse).isSameAs(secondResponse); - verify(weatherClient, times(1)).getWeatherForecast(requestTime); + assertThat(response).isEqualTo(new WeatherResponse(21, "맑음")); + verify(weatherClient).getWeatherForecast(requestTime); + verify(weatherCacheRepository).save(any(WeatherCache.class)); } } From 18f2fa9ad63b83524db9e16e13652d6cc6bf58ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Wed, 27 May 2026 13:35:30 +0900 Subject: [PATCH 3/8] =?UTF-8?q?docs:=20=EB=82=A0=EC=94=A8=20API=20Swagger?= =?UTF-8?q?=20=EA=B7=B8=EB=A3=B9=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 병천 날씨 조회 API가 Swagger UI의 Campus API 그룹에 노출되도록 패키지 스캔 범위에 포함했다. - API 구현과 캐시 동작은 유지하고 문서 탐색 가능성만 보완했다. --- .../in/koreatech/koin/global/config/SwaggerGroupConfig.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/in/koreatech/koin/global/config/SwaggerGroupConfig.java b/src/main/java/in/koreatech/koin/global/config/SwaggerGroupConfig.java index cb6956325..01916dbea 100644 --- a/src/main/java/in/koreatech/koin/global/config/SwaggerGroupConfig.java +++ b/src/main/java/in/koreatech/koin/global/config/SwaggerGroupConfig.java @@ -57,7 +57,8 @@ public GroupedOpenApi campusApi() { "in.koreatech.koin.domain.dining", "in.koreatech.koin.domain.banner", "in.koreatech.koin.domain.club", - "in.koreatech.koin.domain.callvan" + "in.koreatech.koin.domain.callvan", + "in.koreatech.koin.domain.weather" }); } From 9cc418b1c358b91f2d27d9edcc592f062754eb74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Wed, 27 May 2026 13:41:32 +0900 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20=EB=82=A0=EC=94=A8=20API=20=EB=B9=84?= =?UTF-8?q?=EC=A0=95=EC=83=81=20=EC=9D=91=EB=8B=B5=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기상청 응답을 문자열로 먼저 받아 XML 에러나 비정상 JSON 응답의 원인을 메시지에 남기도록 했다. - 최신 발표시각 예보가 아직 없거나 예보 항목이 누락된 경우에만 직전 발표시각으로 한 번 재조회한다. - 인증키 오류 같은 재시도로 해결되지 않는 응답은 그대로 드러내 불필요한 중복 호출을 막는다. - 직전 발표시각 계산 테스트를 추가해 자정 경계 fallback을 검증했다. --- .../domain/weather/client/WeatherClient.java | 77 +++++++++++++++++-- .../model/WeatherForecastRequestTime.java | 19 +++++ .../WeatherForecastRequestTimeTest.java | 34 ++++++++ 3 files changed, 122 insertions(+), 8 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 e54b13520..f660cc517 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 @@ -2,6 +2,7 @@ import static java.net.URLEncoder.encode; +import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URL; import java.util.List; import java.util.Map; @@ -11,7 +12,6 @@ import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; -import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; @@ -35,16 +35,30 @@ public class WeatherClient { private final String openApiKey; private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; public WeatherClient( @Value("${OPEN_API_KEY_PUBLIC}") String openApiKey, - RestTemplate restTemplate + RestTemplate restTemplate, + ObjectMapper objectMapper ) { this.openApiKey = openApiKey; this.restTemplate = restTemplate; + this.objectMapper = objectMapper; } public WeatherForecast getWeatherForecast(WeatherForecastRequestTime requestTime) { + try { + return getWeatherForecastWithoutFallback(requestTime); + } catch (WeatherOpenApiException e) { + if (!canRetryWithPreviousBaseTime(e)) { + throw e; + } + return getWeatherForecastWithoutFallback(requestTime.previousBaseTime()); + } + } + + private WeatherForecast getWeatherForecastWithoutFallback(WeatherForecastRequestTime requestTime) { WeatherApiResponse response = getOpenApiResponse(requestTime); List items = extractForecastItems(response); Map forecasts = items.stream() @@ -68,26 +82,51 @@ public WeatherForecast getWeatherForecast(WeatherForecastRequestTime requestTime } } + private boolean canRetryWithPreviousBaseTime(WeatherOpenApiException e) { + String errorMessage = e.getFullMessage(); + return errorMessage.contains("NO_DATA") + || errorMessage.contains("response body is empty") + || errorMessage.contains("forecastDateTime"); + } + private WeatherApiResponse getOpenApiResponse(WeatherForecastRequestTime requestTime) { try { HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); headers.set("Accept", "*/*"); HttpEntity entity = new HttpEntity<>(headers); URL url = new URL(getRequestURL(requestTime)); - ResponseEntity response = restTemplate.exchange( + ResponseEntity response = restTemplate.exchange( url.toURI(), HttpMethod.GET, entity, - WeatherApiResponse.class + String.class ); - return response.getBody(); + return parseResponse(response.getBody(), requestTime); } catch (WeatherOpenApiException e) { throw e; } catch (Exception e) { throw WeatherOpenApiException.withDetail("baseDateTime: " - + requestTime.baseDate() + requestTime.baseTime()); + + requestTime.baseDate() + requestTime.baseTime() + ", cause: " + e.getClass().getSimpleName()); + } + } + + private WeatherApiResponse parseResponse(String responseBody, WeatherForecastRequestTime requestTime) { + if (responseBody == null || responseBody.isBlank()) { + throw WeatherOpenApiException.withDetail("baseDateTime: " + + requestTime.baseDate() + requestTime.baseTime() + ", response body is empty"); + } + + if (!responseBody.trim().startsWith("{")) { + throw WeatherOpenApiException.withDetail("baseDateTime: " + + requestTime.baseDate() + requestTime.baseTime() + ", " + extractXmlErrorMessage(responseBody)); + } + + try { + return objectMapper.readValue(responseBody, WeatherApiResponse.class); + } catch (Exception e) { + throw WeatherOpenApiException.withDetail("baseDateTime: " + + requestTime.baseDate() + requestTime.baseTime() + ", invalid JSON response"); } } @@ -111,6 +150,26 @@ private String getRequestURL(WeatherForecastRequestTime requestTime) { } } + private String extractXmlErrorMessage(String responseBody) { + String returnAuthMsg = extractTagValue(responseBody, "returnAuthMsg"); + String returnReasonCode = extractTagValue(responseBody, "returnReasonCode"); + if (returnAuthMsg != null || returnReasonCode != null) { + return "returnAuthMsg: " + returnAuthMsg + ", returnReasonCode: " + returnReasonCode; + } + return "non JSON response"; + } + + private String extractTagValue(String responseBody, String tagName) { + String startTag = "<" + tagName + ">"; + String endTag = ""; + int startIndex = responseBody.indexOf(startTag); + int endIndex = responseBody.indexOf(endTag); + if (startIndex == -1 || endIndex == -1 || startIndex > endIndex) { + return null; + } + return responseBody.substring(startIndex + startTag.length(), endIndex); + } + private List extractForecastItems(WeatherApiResponse response) { if (response == null || response.response() == null @@ -123,7 +182,9 @@ private List extractForecastItems(WeatherApiResponse respon String resultCode = response.response().header().resultCode(); if (!resultCode.equals("00") && !resultCode.equals("0")) { - throw WeatherOpenApiException.withDetail("resultCode: " + resultCode); + throw WeatherOpenApiException.withDetail( + "resultCode: " + resultCode + ", resultMsg: " + response.response().header().resultMsg() + ); } return response.response().body().items().item(); } diff --git a/src/main/java/in/koreatech/koin/domain/weather/model/WeatherForecastRequestTime.java b/src/main/java/in/koreatech/koin/domain/weather/model/WeatherForecastRequestTime.java index 026af14a3..6ac2750b4 100644 --- a/src/main/java/in/koreatech/koin/domain/weather/model/WeatherForecastRequestTime.java +++ b/src/main/java/in/koreatech/koin/domain/weather/model/WeatherForecastRequestTime.java @@ -49,4 +49,23 @@ public static WeatherForecastRequestTime from(LocalDateTime now) { forecastDateTime.toLocalTime().format(TIME_FORMATTER) ); } + + public WeatherForecastRequestTime previousBaseTime() { + LocalDate baseLocalDate = LocalDate.parse(baseDate, DATE_FORMATTER); + LocalTime baseLocalTime = LocalTime.parse(baseTime, TIME_FORMATTER); + int baseTimeIndex = BASE_TIMES.indexOf(baseLocalTime); + if (baseTimeIndex <= 0) { + baseLocalDate = baseLocalDate.minusDays(1); + baseLocalTime = BASE_TIMES.get(BASE_TIMES.size() - 1); + } else { + baseLocalTime = BASE_TIMES.get(baseTimeIndex - 1); + } + + return new WeatherForecastRequestTime( + baseLocalDate.format(DATE_FORMATTER), + baseLocalTime.format(TIME_FORMATTER), + forecastDate, + forecastTime + ); + } } diff --git a/src/test/java/in/koreatech/koin/unit/domain/weather/WeatherForecastRequestTimeTest.java b/src/test/java/in/koreatech/koin/unit/domain/weather/WeatherForecastRequestTimeTest.java index 5192cfe22..8ab20d3b1 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/weather/WeatherForecastRequestTimeTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/weather/WeatherForecastRequestTimeTest.java @@ -33,4 +33,38 @@ class WeatherForecastRequestTimeTest { assertThat(requestTime.forecastDate()).isEqualTo("20240115"); assertThat(requestTime.forecastTime()).isEqualTo("0200"); } + + @Test + void 직전_발표시각을_계산한다() { + WeatherForecastRequestTime requestTime = new WeatherForecastRequestTime( + "20240115", + "1100", + "20240115", + "1200" + ); + + WeatherForecastRequestTime previousBaseTime = requestTime.previousBaseTime(); + + assertThat(previousBaseTime.baseDate()).isEqualTo("20240115"); + assertThat(previousBaseTime.baseTime()).isEqualTo("0800"); + assertThat(previousBaseTime.forecastDate()).isEqualTo("20240115"); + assertThat(previousBaseTime.forecastTime()).isEqualTo("1200"); + } + + @Test + void 첫_발표시각의_직전_발표시각은_전날_마지막_발표시각이다() { + WeatherForecastRequestTime requestTime = new WeatherForecastRequestTime( + "20240115", + "0200", + "20240115", + "0300" + ); + + WeatherForecastRequestTime previousBaseTime = requestTime.previousBaseTime(); + + assertThat(previousBaseTime.baseDate()).isEqualTo("20240114"); + assertThat(previousBaseTime.baseTime()).isEqualTo("2300"); + assertThat(previousBaseTime.forecastDate()).isEqualTo("20240115"); + assertThat(previousBaseTime.forecastTime()).isEqualTo("0300"); + } } From 02e314ee42609eef64fca9c5512f50db61485c2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Wed, 27 May 2026 13:44:03 +0900 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20=EB=82=A0=EC=94=A8=20API=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=98=A4=EB=A5=98=20=EC=9D=91=EB=8B=B5=20=EB=B3=B4?= =?UTF-8?q?=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기상청 API가 401/403 같은 HTTP 오류를 반환해도 응답 본문을 파싱해 실제 인증 오류 메시지를 남긴다. - 인증키 오류와 서비스 접근 권한 오류를 구분할 수 있게 해 운영 설정 확인 시간을 줄인다. - 기존 날씨 도메인 테스트를 다시 실행해 응답 처리 변경의 회귀를 확인했다. --- .../koin/domain/weather/client/WeatherClient.java | 8 ++++++++ 1 file changed, 8 insertions(+) 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 f660cc517..fcde047ad 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 @@ -14,6 +14,7 @@ import org.springframework.http.HttpMethod; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpStatusCodeException; import org.springframework.web.client.RestTemplate; import in.koreatech.koin.domain.weather.dto.WeatherApiResponse; @@ -105,6 +106,13 @@ private WeatherApiResponse getOpenApiResponse(WeatherForecastRequestTime request return parseResponse(response.getBody(), requestTime); } catch (WeatherOpenApiException e) { throw e; + } catch (HttpStatusCodeException e) { + String responseBody = e.getResponseBodyAsString(); + if (responseBody != null && !responseBody.isBlank()) { + return parseResponse(responseBody, requestTime); + } + throw WeatherOpenApiException.withDetail("baseDateTime: " + + requestTime.baseDate() + requestTime.baseTime() + ", httpStatus: " + e.getStatusCode()); } catch (Exception e) { throw WeatherOpenApiException.withDetail("baseDateTime: " + requestTime.baseDate() + requestTime.baseTime() + ", cause: " + e.getClass().getSimpleName()); From addb2ebc978a1751fa64113d6ea9d5f2c736ac7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Wed, 27 May 2026 13:47:50 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feat:=20=EB=82=A0=EC=94=A8=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사용자 조회 요청에서 기상청 API를 호출하지 않고 Redis 캐시만 읽도록 책임을 분리했다. - 매시 정각 스케줄러가 병천 날씨를 갱신해 캐시 miss가 사용자 요청 경로에서 외부 호출로 이어지지 않게 했다. - 스케줄 경계와 일시적인 갱신 실패를 버티도록 캐시 TTL을 조회 주기보다 길게 설정했다. - API 조회, 캐시 miss, 스케줄러 갱신 흐름을 테스트로 검증했다. --- .../domain/weather/model/WeatherCache.java | 2 +- .../weather/scheduler/WeatherScheduler.java | 25 +++++++++++++++++++ .../weather/service/WeatherService.java | 13 +++++----- .../acceptance/domain/WeatherApiTest.java | 18 +++++++------ .../domain/weather/WeatherServiceTest.java | 23 ++++++++++++----- 5 files changed, 59 insertions(+), 22 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/domain/weather/scheduler/WeatherScheduler.java 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 66965c78b..deed001ad 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 @@ -15,7 +15,7 @@ public class WeatherCache { public static final String BYEONGCHEON_ID = "byeongcheon"; - private static final long CACHE_EXPIRE_HOUR = 1L; + private static final long CACHE_EXPIRE_HOUR = 2L; @Id private String id; diff --git a/src/main/java/in/koreatech/koin/domain/weather/scheduler/WeatherScheduler.java b/src/main/java/in/koreatech/koin/domain/weather/scheduler/WeatherScheduler.java new file mode 100644 index 000000000..126d77f27 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/weather/scheduler/WeatherScheduler.java @@ -0,0 +1,25 @@ +package in.koreatech.koin.domain.weather.scheduler; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import in.koreatech.koin.domain.weather.service.WeatherService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class WeatherScheduler { + + private final WeatherService weatherService; + + @Scheduled(cron = "0 0 * * * *") + public void refreshWeather() { + try { + weatherService.refreshWeather(); + } catch (Exception e) { + log.warn("날씨 스케줄링 과정에서 오류가 발생했습니다.", e); + } + } +} 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 a1691d01e..f690f79b6 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 @@ -7,6 +7,7 @@ import in.koreatech.koin.domain.weather.client.WeatherClient; import in.koreatech.koin.domain.weather.dto.WeatherResponse; +import in.koreatech.koin.domain.weather.exception.WeatherOpenApiException; import in.koreatech.koin.domain.weather.model.WeatherCache; import in.koreatech.koin.domain.weather.model.WeatherForecastRequestTime; import in.koreatech.koin.domain.weather.repository.WeatherCacheRepository; @@ -20,18 +21,16 @@ public class WeatherService { private final WeatherClient weatherClient; private final WeatherCacheRepository weatherCacheRepository; - public synchronized WeatherResponse getWeather() { - LocalDateTime now = LocalDateTime.now(clock); - WeatherForecastRequestTime requestTime = WeatherForecastRequestTime.from(now); + public WeatherResponse getWeather() { return weatherCacheRepository.findById(WeatherCache.BYEONGCHEON_ID) - .filter(cache -> cache.getRequestTime().equals(requestTime)) .map(WeatherCache::getWeather) - .orElseGet(() -> refreshWeather(requestTime)); + .orElseThrow(() -> WeatherOpenApiException.withDetail("weather cache is empty")); } - private WeatherResponse refreshWeather(WeatherForecastRequestTime requestTime) { + public synchronized void refreshWeather() { + LocalDateTime now = LocalDateTime.now(clock); + WeatherForecastRequestTime requestTime = WeatherForecastRequestTime.from(now); WeatherResponse response = weatherClient.getWeatherForecast(requestTime).toResponse(); weatherCacheRepository.save(WeatherCache.of(requestTime, response)); - return response; } } 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 5640094b3..dc912824c 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/WeatherApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/WeatherApiTest.java @@ -1,29 +1,31 @@ package in.koreatech.koin.acceptance.domain; -import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import org.junit.jupiter.api.Test; -import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import in.koreatech.koin.acceptance.AcceptanceTest; -import in.koreatech.koin.domain.weather.client.WeatherClient; -import in.koreatech.koin.domain.weather.model.WeatherForecast; +import in.koreatech.koin.domain.weather.dto.WeatherResponse; +import in.koreatech.koin.domain.weather.model.WeatherCache; import in.koreatech.koin.domain.weather.model.WeatherForecastRequestTime; +import in.koreatech.koin.domain.weather.repository.WeatherCacheRepository; class WeatherApiTest extends AcceptanceTest { - @MockBean - private WeatherClient weatherClient; + @Autowired + private WeatherCacheRepository weatherCacheRepository; @Test void 병천_날씨를_조회한다() throws Exception { clear(); - when(weatherClient.getWeatherForecast(new WeatherForecastRequestTime("20240115", "1100", "20240115", "1200"))) - .thenReturn(new WeatherForecast(21, "1", "0")); + weatherCacheRepository.save(WeatherCache.of( + new WeatherForecastRequestTime("20240115", "1100", "20240115", "1200"), + new WeatherResponse(21, "맑음") + )); mockMvc.perform( get("/weather") 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 9d3e35426..a88fcf0f1 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 @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -18,6 +19,7 @@ import in.koreatech.koin.domain.weather.client.WeatherClient; import in.koreatech.koin.domain.weather.dto.WeatherResponse; +import in.koreatech.koin.domain.weather.exception.WeatherOpenApiException; import in.koreatech.koin.domain.weather.model.WeatherCache; import in.koreatech.koin.domain.weather.model.WeatherForecast; import in.koreatech.koin.domain.weather.model.WeatherForecastRequestTime; @@ -34,7 +36,7 @@ class WeatherServiceTest { private WeatherCacheRepository weatherCacheRepository; @Test - void 같은_예보시각의_레디스_캐시가_있으면_기상청_API를_호출하지_않는다() { + void 레디스_캐시가_있으면_기상청_API를_호출하지_않고_캐시를_반환한다() { Clock clock = Clock.fixed(Instant.parse("2024-01-15T03:35:00Z"), ZoneId.of("Asia/Seoul")); WeatherService weatherService = new WeatherService(clock, weatherClient, weatherCacheRepository); WeatherForecastRequestTime requestTime = new WeatherForecastRequestTime( @@ -54,7 +56,19 @@ class WeatherServiceTest { } @Test - void 레디스_캐시가_없으면_기상청_API를_호출하고_캐시를_저장한다() { + void 레디스_캐시가_없어도_조회_API에서는_기상청_API를_호출하지_않는다() { + 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.empty()); + + assertThrows(WeatherOpenApiException.class, weatherService::getWeather); + + verify(weatherClient, never()).getWeatherForecast(any()); + } + + @Test + void 스케줄러_갱신에서는_기상청_API를_호출하고_캐시를_저장한다() { Clock clock = Clock.fixed(Instant.parse("2024-01-15T03:35:00Z"), ZoneId.of("Asia/Seoul")); WeatherService weatherService = new WeatherService(clock, weatherClient, weatherCacheRepository); WeatherForecastRequestTime requestTime = new WeatherForecastRequestTime( @@ -63,14 +77,11 @@ class WeatherServiceTest { "20240115", "1200" ); - when(weatherCacheRepository.findById(WeatherCache.BYEONGCHEON_ID)) - .thenReturn(Optional.empty()); when(weatherClient.getWeatherForecast(requestTime)) .thenReturn(new WeatherForecast(21, "1", "0")); - WeatherResponse response = weatherService.getWeather(); + weatherService.refreshWeather(); - assertThat(response).isEqualTo(new WeatherResponse(21, "맑음")); verify(weatherClient).getWeatherForecast(requestTime); verify(weatherCacheRepository).save(any(WeatherCache.class)); } From 6b57660e63ad83692d70bde8d5ef8ca43d502380 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Wed, 27 May 2026 14:26:57 +0900 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20=EB=82=A0=EC=94=A8=20API=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기상청 요청 URL 생성 실패 시 serviceKey가 포함된 URI를 남기지 않도록 민감 정보를 제거 - Redis 캐시 식별자와 기상청 resultCode 검증을 Spring Data 및 null-safe 방식으로 보정 - 자정 직후 발표시각 계산과 애플리케이션 기동 시 날씨 캐시 초기화를 보강 --- .../koin/domain/weather/client/WeatherClient.java | 4 ++-- .../koin/domain/weather/model/WeatherCache.java | 2 +- .../weather/model/WeatherForecastRequestTime.java | 5 +++-- .../domain/weather/scheduler/WeatherScheduler.java | 7 +++++++ .../weather/WeatherForecastRequestTimeTest.java | 12 ++++++++++++ 5 files changed, 25 insertions(+), 5 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 fcde047ad..de4d909c2 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 @@ -154,7 +154,7 @@ private String getRequestURL(WeatherForecastRequestTime requestTime) { urlBuilder.append("&" + encode("ny", ENCODE_TYPE) + "=" + encode(String.valueOf(BYEONGCHEON_NY), ENCODE_TYPE)); return urlBuilder.toString(); } catch (Exception e) { - throw new KoinIllegalStateException("기상청 단기예보 API URL 생성중 문제가 발생했습니다.", "uri:" + urlBuilder); + throw new KoinIllegalStateException("기상청 단기예보 API URL 생성중 문제가 발생했습니다.", "uri build failed"); } } @@ -189,7 +189,7 @@ private List extractForecastItems(WeatherApiResponse respon } String resultCode = response.response().header().resultCode(); - if (!resultCode.equals("00") && !resultCode.equals("0")) { + if (!"00".equals(resultCode) && !"0".equals(resultCode)) { throw WeatherOpenApiException.withDetail( "resultCode: " + resultCode + ", resultMsg: " + response.response().header().resultMsg() ); 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 deed001ad..847d3e985 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 @@ -2,11 +2,11 @@ import java.util.concurrent.TimeUnit; +import org.springframework.data.annotation.Id; import org.springframework.data.redis.core.RedisHash; import org.springframework.data.redis.core.TimeToLive; import in.koreatech.koin.domain.weather.dto.WeatherResponse; -import jakarta.persistence.Id; import lombok.Builder; import lombok.Getter; diff --git a/src/main/java/in/koreatech/koin/domain/weather/model/WeatherForecastRequestTime.java b/src/main/java/in/koreatech/koin/domain/weather/model/WeatherForecastRequestTime.java index 6ac2750b4..dd1e0c57c 100644 --- a/src/main/java/in/koreatech/koin/domain/weather/model/WeatherForecastRequestTime.java +++ b/src/main/java/in/koreatech/koin/domain/weather/model/WeatherForecastRequestTime.java @@ -27,8 +27,9 @@ public record WeatherForecastRequestTime( ); public static WeatherForecastRequestTime from(LocalDateTime now) { - LocalDate baseDate = now.toLocalDate(); - LocalTime availableTime = now.toLocalTime().minusMinutes(10); + LocalDateTime availableDateTime = now.minusMinutes(10); + LocalDate baseDate = availableDateTime.toLocalDate(); + LocalTime availableTime = availableDateTime.toLocalTime(); LocalTime baseTime = null; for (LocalTime time : BASE_TIMES) { if (!time.isAfter(availableTime)) { diff --git a/src/main/java/in/koreatech/koin/domain/weather/scheduler/WeatherScheduler.java b/src/main/java/in/koreatech/koin/domain/weather/scheduler/WeatherScheduler.java index 126d77f27..18d2fb520 100644 --- a/src/main/java/in/koreatech/koin/domain/weather/scheduler/WeatherScheduler.java +++ b/src/main/java/in/koreatech/koin/domain/weather/scheduler/WeatherScheduler.java @@ -1,5 +1,7 @@ package in.koreatech.koin.domain.weather.scheduler; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @@ -14,6 +16,11 @@ public class WeatherScheduler { private final WeatherService weatherService; + @EventListener(ApplicationReadyEvent.class) + public void refreshWeatherOnStartup() { + refreshWeather(); + } + @Scheduled(cron = "0 0 * * * *") public void refreshWeather() { try { diff --git a/src/test/java/in/koreatech/koin/unit/domain/weather/WeatherForecastRequestTimeTest.java b/src/test/java/in/koreatech/koin/unit/domain/weather/WeatherForecastRequestTimeTest.java index 8ab20d3b1..e59247c8d 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/weather/WeatherForecastRequestTimeTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/weather/WeatherForecastRequestTimeTest.java @@ -34,6 +34,18 @@ class WeatherForecastRequestTimeTest { assertThat(requestTime.forecastTime()).isEqualTo("0200"); } + @Test + void 자정_직후에는_전날_마지막_발표시각을_사용한다() { + WeatherForecastRequestTime requestTime = WeatherForecastRequestTime.from( + LocalDateTime.of(2024, 1, 15, 0, 5) + ); + + assertThat(requestTime.baseDate()).isEqualTo("20240114"); + assertThat(requestTime.baseTime()).isEqualTo("2300"); + assertThat(requestTime.forecastDate()).isEqualTo("20240115"); + assertThat(requestTime.forecastTime()).isEqualTo("0000"); + } + @Test void 직전_발표시각을_계산한다() { WeatherForecastRequestTime requestTime = new WeatherForecastRequestTime( From 5ca07f97c3d46673bbf35d300bf274e7d2e5bf91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Wed, 27 May 2026 14:43:09 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20=EB=82=A0=EC=94=A8=20=EC=98=88?= =?UTF-8?q?=EB=B3=B4=20=ED=95=84=EC=88=98=20=ED=95=AD=EB=AA=A9=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기상청 응답에서 TMP, SKY, PTY 중 일부가 누락되면 기본 날씨로 오인하지 않고 비정상 응답으로 처리 - 예보시각 데이터가 아예 없는 경우에만 직전 발표시각 재조회가 동작하도록 실패 원인을 분리 - WeatherClient 단위 테스트로 정상 매핑, 직전 발표시각 fallback, 필수 항목 누락 경로를 고정 --- .../domain/weather/client/WeatherClient.java | 36 ++++-- .../domain/weather/WeatherClientTest.java | 115 ++++++++++++++++++ 2 files changed, 143 insertions(+), 8 deletions(-) create mode 100644 src/test/java/in/koreatech/koin/unit/domain/weather/WeatherClientTest.java 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 de4d909c2..35e8b0847 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 @@ -63,24 +63,44 @@ private WeatherForecast getWeatherForecastWithoutFallback(WeatherForecastRequest WeatherApiResponse response = getOpenApiResponse(requestTime); List items = extractForecastItems(response); Map forecasts = items.stream() - .filter(item -> item.fcstDate().equals(requestTime.forecastDate())) - .filter(item -> item.fcstTime().equals(requestTime.forecastTime())) + .filter(item -> requestTime.forecastDate().equals(item.fcstDate())) + .filter(item -> requestTime.forecastTime().equals(item.fcstTime())) .collect(Collectors.toMap( WeatherForecastItem::category, WeatherForecastItem::fcstValue, (previous, current) -> current )); + if (forecasts.isEmpty()) { + throw WeatherOpenApiException.withDetail("forecastDateTime: " + + requestTime.forecastDate() + requestTime.forecastTime()); + } + + String temperature = requireForecastValue(forecasts, "TMP"); + String sky = requireForecastValue(forecasts, "SKY"); + String precipitationType = requireForecastValue(forecasts, "PTY"); + try { return new WeatherForecast( - Integer.parseInt(forecasts.get("TMP")), - forecasts.get("SKY"), - forecasts.get("PTY") + Integer.parseInt(temperature), + sky, + precipitationType + ); + } catch (NumberFormatException e) { + throw WeatherOpenApiException.withDetail( + "invalid category: TMP, value: " + temperature + ); + } + } + + private String requireForecastValue(Map forecasts, String category) { + String forecastValue = forecasts.get(category); + if (forecastValue == null || forecastValue.isBlank()) { + throw WeatherOpenApiException.withDetail( + "missing category: " + category ); - } catch (Exception e) { - throw WeatherOpenApiException.withDetail("forecastDateTime: " - + requestTime.forecastDate() + requestTime.forecastTime()); } + return forecastValue; } private boolean canRetryWithPreviousBaseTime(WeatherOpenApiException e) { 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 new file mode 100644 index 000000000..39dc7c674 --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/domain/weather/WeatherClientTest.java @@ -0,0 +1,115 @@ +package in.koreatech.koin.unit.domain.weather; + +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.match.MockRestRequestMatchers.anything; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import in.koreatech.koin.domain.weather.client.WeatherClient; +import in.koreatech.koin.domain.weather.exception.WeatherOpenApiException; +import in.koreatech.koin.domain.weather.model.WeatherForecast; +import in.koreatech.koin.domain.weather.model.WeatherForecastRequestTime; + +class WeatherClientTest { + + private static final WeatherForecastRequestTime REQUEST_TIME = new WeatherForecastRequestTime( + "20240115", + "1100", + "20240115", + "1200" + ); + + private MockRestServiceServer server; + private WeatherClient weatherClient; + + @BeforeEach + void setUp() { + RestTemplate restTemplate = new RestTemplate(); + server = MockRestServiceServer.bindTo(restTemplate).build(); + weatherClient = new WeatherClient("test-api-key", restTemplate, new ObjectMapper()); + } + + @Test + 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"} + """), MediaType.APPLICATION_JSON)); + + WeatherForecast forecast = weatherClient.getWeatherForecast(REQUEST_TIME); + + assertThat(forecast.temperature()).isEqualTo(21); + assertThat(forecast.sky()).isEqualTo("1"); + assertThat(forecast.precipitationType()).isEqualTo("0"); + server.verify(); + } + + @Test + void 요청한_예보시각의_데이터가_없으면_직전_발표시각으로_다시_조회한다() { + server.expect(once(), anything()) + .andRespond(withSuccess(weatherApiResponse(""" + {"category":"TMP","fcstDate":"20240115","fcstTime":"1300","fcstValue":"21"}, + {"category":"SKY","fcstDate":"20240115","fcstTime":"1300","fcstValue":"1"}, + {"category":"PTY","fcstDate":"20240115","fcstTime":"1300","fcstValue":"0"} + """), MediaType.APPLICATION_JSON)); + server.expect(once(), anything()) + .andRespond(withSuccess(weatherApiResponse(""" + {"category":"TMP","fcstDate":"20240115","fcstTime":"1200","fcstValue":"20"}, + {"category":"SKY","fcstDate":"20240115","fcstTime":"1200","fcstValue":"3"}, + {"category":"PTY","fcstDate":"20240115","fcstTime":"1200","fcstValue":"0"} + """), MediaType.APPLICATION_JSON)); + + WeatherForecast forecast = weatherClient.getWeatherForecast(REQUEST_TIME); + + assertThat(forecast.temperature()).isEqualTo(20); + assertThat(forecast.sky()).isEqualTo("3"); + assertThat(forecast.precipitationType()).isEqualTo("0"); + server.verify(); + } + + @Test + void 필수_예보_항목이_누락되면_비정상_응답으로_처리한다() { + server.expect(once(), anything()) + .andRespond(withSuccess(weatherApiResponse(""" + {"category":"TMP","fcstDate":"20240115","fcstTime":"1200","fcstValue":"21"}, + {"category":"PTY","fcstDate":"20240115","fcstTime":"1200","fcstValue":"0"} + """), MediaType.APPLICATION_JSON)); + + assertThatThrownBy(() -> weatherClient.getWeatherForecast(REQUEST_TIME)) + .isInstanceOf(WeatherOpenApiException.class) + .hasMessage("기상청 단기예보 API 응답이 정상적이지 않습니다."); + + server.verify(); + } + + private String weatherApiResponse(String items) { + return """ + { + "response": { + "header": { + "resultCode": "00", + "resultMsg": "NORMAL_SERVICE" + }, + "body": { + "items": { + "item": [ + %s + ] + } + } + } + } + """.formatted(items); + } +}