카카오맵 토이 프로젝트 2: WebClient로 날씨 수집 자동화하기
장소 저장과 컨트롤러 테스트에 이어, 기상청 공공 API를 조회하고 저장하는 과정을 정리했습니다.
WebClient를 선택한 이유
처음에는 RestTemplate도 생각했습니다. 곧 deprecated된다는 글을 보았지만 그렇지 않다는 설명도 있었습니다. 여기서는 그 논쟁을 결론 내리기보다 DTO로 응답을 받는 방식이 편해 보여 WebClient를 사용했습니다.
의존성과 첫 요청
WebFlux 의존성을 추가했습니다.
implementation 'org.springframework.boot:spring-boot-starter-webflux'M1 MacBook에서 오류가 발생해 macOS용 Netty DNS 의존성도 추가했습니다. 다음 버전은 당시 사용한 기록이며 현재 설치 권장 버전은 아닙니다.
implementation 'io.netty:netty-resolver-dns-native-macos:4.1.68.Final:osx-aarch_64'먼저 GET 요청을 보내고 응답을 DTO로 받았습니다. 외부 API 호출을 익혀 보려는 목적이어서 마지막에는 block()으로 결과를 기다렸습니다.
block()은 호출 스레드가 결과를 기다리게 합니다. 무조건 잘못된 호출은 아닙니다. 다만 WebFlux에서 요청을 비차단으로 처리하는 흐름 안에 넣을 때와 동기식 작업 끝에서 기다릴 때는 구분해야 합니다. WebClient의 동기식 사용 문서도 이 차이를 설명합니다.
WeatherApiResponse weatherApiResponse = WebClient.create("URL") //URL로
.get() //GET 요청
.retrieve() // 응답을 추출하는데 사용
.bodyToMono(WeatherApiResponse.class) // body를 클래스 객체로 변환
.block(); // 비동기 객체를 일반 객체로 변환하는데 사용초단기예보 요청 만들기
요청값과 응답 구조를 확인한 뒤 uriBuilder에 쿼리 파라미터를 넣었습니다. 아래 좌표는 설명용 값입니다.
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;
}서비스 키는 환경 변수로 분리했습니다.
@Value("${WEATHER_SECRET_KEY}")
private String WEATHER_SECRET_KEY;날짜와 시간은 서울 시간으로 만들었습니다. 요청 기준 시각과 다음 예보 시각을 문자열 목록으로 반환했습니다.
다만 gettingTime()은 날짜는 현재 날짜로 두고 시간만 더하거나 뺍니다. 자정을 넘으면 요청 기준 날짜나 예보 날짜가 맞지 않을 수 있습니다. 당시 코드에는 이 경계 처리가 없습니다.
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;
}응답 구조에 맞춰 DTO 만들기
여기서 시간을 썼습니다. 처음에는 bodyToMono가 JSON 안의 body 필드만 읽는다고 생각했습니다. 실제로는 HTTP 응답 본문 전체를 DTO로 읽으므로 최상위 response부터 중첩 구조를 맞춰야 했습니다.
JSON의 모든 필드를 반드시 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;
}다음으로 T1H와 RN1 항목을 고르고 예보 날짜와 시간이 원하는 값과 일치하는 데이터만 남겼습니다. 예보는 여러 시간대로 오는데 그중 한 시간 뒤 값을 저장하려 했습니다.
T1H는 기온, RN1은 1시간 강수량입니다. 그때는 RN1을 기상 형태로 잘못 이해했습니다. 항목 정의와 제공 시각은 기상청 단기예보 조회서비스를 기준으로 확인해야 합니다.
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);
}필요한 값을 데이터베이스에 저장하기
저장할 엔티티를 정의했습니다.
@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로 엔티티로 바꿔 저장했습니다.
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 실행");
}매시간 47분에 수집하기
요청, 응답 변환, 필터링, 저장을 매시간 반복하도록 만들었습니다. 실행 시각은 45분에서 2분 여유를 두어 서울 시간 기준 매시 47분으로 정했습니다.
일반적인 @Scheduled 메서드는 인자가 없어야 하며 반환값은 있어도 스케줄러가 무시합니다. 그때는 “void만 가능하다”고 생각했지만 반환값이 무시될 뿐 반환형이 제한되지는 않습니다. 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());
}애플리케이션에 @EnableScheduling도 추가했습니다.
@EnableScheduling
@SpringBootApplication
public class KakaoMapApplication {
public static void main(String[] args) {
SpringApplication.run(KakaoMapApplication.class, args);
}
}저장한 예보 조회하기
응답에는 성공 여부, 강수량과 기온을 담을 필드를 두었습니다.
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public static class WeatherInfoResponse {
private boolean success;
private String rainValue;
private String temperatureValue;
}QueryDSL로 예보 날짜와 시간이 같은 데이터를 조회한 뒤 항목 코드에 따라 DTO에 값을 넣었습니다.
이 조회 예제는 success를 설정하지 않습니다. 그래서 성공 응답의 필드를 모두 채우려면 success 값을 따로 설정해야 합니다.
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;
}