-
[Spring Boot][토이 프로젝트] 2. WebClient를 통한 기상청 공공 API 조회 및 @Scheduled를 사용한 자동화Spring/Study 2023. 6. 30. 20:04
https://code-list.tistory.com/75
이전 프로젝트와 이어지는 글입니다.
WebClient
사용한 이유
RestTemplate를 사용하여 외부 API를 조회할 수 있었지만, 나중에는 Deprecated 된다는 글이 심심치 않게 보이길래....
https://velog.io/@dailylifecoding/Spring-RestTemplate-wont-Deprecate
그런데 그냥 유언비어라는 이야기도 있었다.
그래도 RestTemplate 보다 DTO 객체로 변환하기도 쉬운 것 같아서 사용해보게 되었다.
사용법
의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-webflux'
WebClient를 사용하기 위해 webflux 의존성을 추가해준다.
본 글쓴이는 M1 MacBook을 사용중인데, 오류가 발생해서
implementation 'io.netty:netty-resolver-dns-native-macos:4.1.68.Final:osx-aarch_64'
WebFlux를 사용하기 위해 netty 를 사용하는데 Mac OS에서의 호환성 문제로 의존성을 추가해줬다.
간단 예시
WeatherApiResponse weatherApiResponse = WebClient.create("URL") //URL로 .get() //GET 요청 .retrieve() // 응답을 추출하는데 사용 .bodyToMono(WeatherApiResponse.class) // body를 클래스 객체로 변환 .block(); // 비동기 객체를 일반 객체로 변환하는데 사용
여기서 WebClient에 대해 알고 있는 사람이라면, .block()을 사용하는것은 성능상 좋지 않다는 것을 알고 있을 것이다.
목적이 외부 API 요청을 WebClient로 해보는 것이 목적이므로 그냥 block() 처리 해줬다.
안되는 이유에 대해서는 후설하도록 하겠다.
기상청 공공 API 조회 방법
기상청 초단기예보 조회 사용
가장 먼저 기상청 공공 API 문서를 직접 보고 오는 것이 더 빠를 것이다.
거기에 필수 요청 값과 응답값이 전부 정리 되어있다.
public WeatherApiResponse gettingInfoWeather(String dateString, String timeString) { WeatherApiResponse weatherApiResponseMono = WebClient.create("http://apis.data.go.kr/1360000/VilageFcstInfoService_2.0") .get() .uri(uriBuilder -> uriBuilder .path("/getUltraSrtFcst") .queryParam("ServiceKey", WEATHER_SECRET_KEY) .queryParam("pageNo", "1") .queryParam("numOfRows", "1000") .queryParam("dataType", "JSON") .queryParam("base_date", dateString) .queryParam("base_time", timeString) .queryParam("nx", "X좌표") .queryParam("ny", "Y좌표") .build()) .accept(MediaType.APPLICATION_JSON) .retrieve() .bodyToMono(WeatherApiResponse.class) .block(); return weatherApiResponseMono; }
이런식으로 query 값의 이름과 값들을 직접 uriBuilder를 통해 적어 줬다.
서비스 키의 경우 환경 변수로 설정하여 사용했다.
@Value("${WEATHER_SECRET_KEY}") private String WEATHER_SECRET_KEY;
base_date와 base_time은 조회하는 시간에 따라 바뀌어야 하므로 변수로 변경해줬다.
public List<String> gettingTime() { List<String> strings = new ArrayList<>(); LocalDateTime localDateTime = LocalDateTime.now(ZoneId.of("Asia/Seoul")); // 날짜(format: yyyyMMdd) DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyyMMdd"); String dateString = localDateTime.format(dateFormatter); // 시간(format: HH00) DateTimeFormatter timeFormatter = DateTimeFormatter.ofPattern("HH"); String timeString = localDateTime.minusHours(1).format(timeFormatter) + "30"; String nextTimeString = localDateTime.plusHours(1).format(timeFormatter) + "00"; strings.add(dateString); strings.add(timeString); strings.add(nextTimeString); return strings; }
서울 시간을 기준으로 필요한 데이터 형태로 변환해주는 로직을 따로 만들어서 사용했다.
데이터 파싱
실제 데이터를 받기 위해 가장 먼저 API 요청시 응답 DTO를 따로 설정해줘야 했다.
@Getter @Setter public static class WeatherApiResponse { private Response response; } @Getter @Setter public static class Response { private Header header; private Body body; } @Getter @Setter public static class Header { private String resultCode; private String resultMsg; } @Getter @Setter @AllArgsConstructor @NoArgsConstructor public static class Body { private String dataType; private Items items; private int pageNo; private int numOfRows; private int totalCount; } @Getter @Setter @AllArgsConstructor @NoArgsConstructor public static class Items { private List<Item> item; } @Getter @Setter public static class Item { private String baseDate; private String baseTime; private String category; private String fcstDate; private String fcstTime; private String fcstValue; private int nx; private int ny; }
이런식으로 응답받는 값들을 모두 지정해줘야 응답을 받을 때 null 처리되어 받는 불상사가 발생하지 않는다.
여기서 솔직히 고생좀 하긴했다. bodyToMono라길래 Body 값만 처리하는줄 알았는데 알고보니 그냥 응답 값 전체를 사용해야 했다....
다음은 데이터를 응답 받은 뒤 온도값 "T1H"와 기상 형태값 "RN1" 값을 필터링해줬다.
추가적으로 API 요청시를 기준으로 6시간 미래의 예측 값을 전달 해주는데, 여기서 1시간 뒤의 가장 최근 예보를 파싱해서 저장하도록 했다.
public Items parsingWeather(WeatherApiResponse weatherApiResponse, String dateString, String nextTimeString) { List<Item> item = weatherApiResponse.getResponse().getBody().getItems().getItem(); List<Item> result = new ArrayList<>(); for (Item info : item) { if (info.getCategory().equals("T1H") || info.getCategory().equals("RN1")) { if (info.getFcstDate().equals(dateString) && info.getFcstTime().equals(nextTimeString)) { result.add(info); } } } return new Items(result); }
여기서 반환된 Items 값을 DB에 저장하기 위해 Entity를 설정하고 저장 로직을 따로 구현했다.
@Entity @Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder public class WeatherInfo { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "weatherinfo_id") private Long id; private String baseDate; private String baseTime; private String category; private String fcstDate; private String fcstTime; private String fcstValue; private int nx; private int ny; }
다음은 저장 로직이다. Builder() 패턴을 통해 WeatherInfo 엔티티로 변환하여 DB에 저장했다.
public void saveInfo(List<Item> items) { for (Item item : items) { WeatherInfo weatherInfo = WeatherInfo.builder() .baseDate(item.getBaseDate()) .baseTime(item.getBaseTime()) .category(item.getCategory()) .fcstDate(item.getFcstDate()) .fcstTime(item.getFcstTime()) .fcstValue(item.getFcstValue()) .nx(item.getNx()) .ny(item.getNy()) .build(); weatherInfoRepository.save(weatherInfo); } log.info("saveInfo 실행"); }
조회 자동화
이전에 설명한 내용
요청 -> 응답 -> 파싱 -> 저장을
API특성에 맞게 매 시각 47분(약간의 오차를 예상하여 45분에서 여유롭게 2분 뒤에 요청)마다 로직을 수행하도록 @Scheduled를 사용했다.@Scheduled(cron = "0 47 * * * ?", zone = "Asia/Seoul") // 00초 47분 매시 매일 매월 모든요일 서울 시간을 기준을 실행 public void scheduledGetWeather() { List<String> time = weatherAPIService.gettingTime(); String dateString = time.get(0); String timeString = time.get(1); String nextTimeString = time.get(2); log.info("[{}],[{}],[{}] scheduledGetWeather 자동 실행", dateString, timeString, nextTimeString); WeatherApiResponse weatherResult = weatherAPIService.gettingInfoWeather(dateString, timeString); Items items = weatherAPIService.parsingWeather(weatherResult, dateString, nextTimeString); weatherAPIService.saveInfo(items.getItem()); }
@Scheduled 의 cron을 사용하여 특정 시간이 되면 반복해서 수행하도록 설정했다.
여기서 알아둬야 할 점이 void 타입만 @Scheduled 적용이 가능하다.
필수 설정으로 메인 Application 에 @EnableScheduling 애노테이션을 적용해줘야한다.
@EnableScheduling @SpringBootApplication public class KakaoMapApplication { public static void main(String[] args) { SpringApplication.run(KakaoMapApplication.class, args); } }
데이터 조회
자동으로 저장한 데이터를 실제로 요청해서 응답을 받는 API를 따로 만들어 준다.
응답 DTO는 단순하게 기온, 기온형태 값을 전달해준다.
@Getter @Setter @AllArgsConstructor @NoArgsConstructor public static class WeatherInfoResponse { private boolean success; private String rainValue; private String temperatureValue; }
다음은 원하는 데이터를 쿼리를 QueryDsl로 작성한 예시다.
원하는 시간대의 T1H와 RN1을 DTO에 담아 반환하는 로직이다.
public WeatherInfoResponse findRecentWeatherInfo(String date, String time) { QWeatherInfo qWeatherInfo = new QWeatherInfo("weatherinfo"); List<WeatherInfo> weatherInfos = jpaQueryFactory.selectFrom(qWeatherInfo) .where(qWeatherInfo.fcstDate.eq(date)) .where(qWeatherInfo.fcstTime.eq(time)) .fetch(); WeatherInfoResponse weatherInfoResponse = new WeatherInfoResponse(); for (WeatherInfo weatherInfo : weatherInfos) { if (weatherInfo.getCategory().equals("T1H")) weatherInfoResponse.setTemperatureValue(weatherInfo.getFcstValue()); else if (weatherInfo.getCategory().equals("RN1")) weatherInfoResponse.setRainValue(weatherInfo.getFcstValue()); } return weatherInfoResponse; }
.block()의 단점
앞서 말했듯이 .block()을 사용하면 단점이 크다.
단순하게 이해하면 비동기식으로 스레드가 동작하는데 이를 강제로 동기식으로 변경하는 것이기 때문에 효율이 떨어지게 된다는 이유였다.