최초에 구현하고자 했던 기능은 선택한 지역의 일주일간의 중기예보를 출력하는 기능을 구현하고 싶었으나 기상청 API의 단기예보와 중기예보를 섞어서 사용해야하는 부분들이 발생하여 프로젝트 기간상 기능을 축소하기로 하였다. 그래서 절충안을 낸 것이 단기예보(3일)에 대한 결과를 출력하는 것이다.
상단에는 선택한 지역의 위치가 표시된다. 기본값은 보는 것과 같이 강원도 양양군 강현면으로 지정하였다. 최초 로드 시 지역에 대한 정보는 객체(state)로 정보를 부여하였다.
const [spot, setSpot] = useState({
localName: "경상남도"
spotIdx: 237
spotLati: "34.7683"
spotLongi: "127.88893"
spotName: "남면"
townName: "남해군"
});
이러한 형태로 데이터의 초기값을 보유해줬다. 예시에서는 남해군인 점은 감안해주길 바란다.
지역명 옆에 핀 아이콘을 클릭하면 모달창을 띄우고 해당 모달창의 select를 선택할때마다 DB에 저장된 지역이 호출되어지는 형태로 구현을 하였다.
최종적으로 해당 지역을 클릭하면 모달창이 닫히면서 기상청 API 정보로 획득한 단기예보가 UI에 출력되는 형태이다.
이런 형태로 강남동을 클릭했을 때 해당 위치의 날씨정보가 출력되는 것이다. 기능의 흐름을 이해하기 위해 밑의 그림을 보자.
최초에 계획했던 흐름도이다. 단순히 클라이언트 단에서 지역정보를 넘겨주면 해당 지역정보를 가지고 서버 단에서 API로 요청하여 데이터를 가공해서 반환하면 된다고 생각했다.
사실 이렇게만해도 결과는 반환되고 사용은 할 수 있다. 이 상태의 코드를 보면서 한번 이야기해보자.(굉장히 비효율적인 코드라는 생각을 스스로했음.)
..생략
@GetMapping("/weather")
public List getWeather(Spot spot){
//서비스를 통해 API결과를 반환받아 클라이언트에게 응답 데이터로 전송
//응답 객체 타입은 잭슨 라이브러리를 통해 json으로 자동 변환
return spotService.getWeather(spot);
}
컨트롤러에서는 서비스를 호출하여 파라미터만 전달하였다. 대부분의 비즈니스 로직은 서비스에서 구현하기에 위와 같이 전달한 것이다.
..생략
public List<Weather> getWeather(Spot spot){
/* API 요청 URL */
String apiUrl = "http://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getVilageFcst";
LocalDateTime now = LocalDateTime.now(); // 요청 날짜 변수
LocalDateTime minusDay = now.minusDays(1); // 하루 전 날짜 획득
String spotLati = surfingSpot.getSpotLati(); // 지역 x좌표
String spotLongi = surfingSpot.getSpotLongi(); // 지역 y좌표
String serviceKey = "Hj3sHhM%2B7wHKs685goGfKOVwhZFflMyigd1Es7cZCVXi4bP06mZR2OG6B7J1%2BbvIwzCPHQUq0xdv6VyhJs9xtQ%3D%3D";
String pageNo = "1";
String numOfRows = "1000";
String dataType = "JSON";
String base_date = minusDay.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
String base_time = "2300";
String nx = spotLati.substring(0, spotLati.indexOf("."));
log.debug(nx);
String ny = spotLongi.substring(0, spotLongi.indexOf("."));
log.debug(ny);
StringBuilder urlBuilder = new StringBuilder(apiUrl);
urlBuilder.append("?" + URLEncoder.encode("serviceKey", "UTF-8") + "=" + serviceKey);
urlBuilder.append("&" + URLEncoder.encode("pageNo", "UTF-8") + "=" + URLEncoder.encode(pageNo, "UTF-8"));
urlBuilder.append("&" + URLEncoder.encode("numOfRows", "UTF-8") + "=" + URLEncoder.encode(numOfRows, "UTF-8"));
urlBuilder.append("&" + URLEncoder.encode("dataType", "UTF-8") + "=" + URLEncoder.encode(dataType, "UTF-8"));
urlBuilder.append("&" + URLEncoder.encode("base_date", "UTF-8") + "=" + URLEncoder.encode(base_date, "UTF-8"));
urlBuilder.append("&" + URLEncoder.encode("base_time", "UTF-8") + "=" + URLEncoder.encode(base_time, "UTF-8"));
urlBuilder.append("&" + URLEncoder.encode("nx", "UTF-8") + "=" + URLEncoder.encode(nx, "UTF-8"));
urlBuilder.append("&" + URLEncoder.encode("ny", "UTF-8") + "=" + URLEncoder.encode(ny, "UTF-8"));
/*
* 조합완료된 요청 URL 생성 HttpURLConnection 객체 활용 API요청
*/
URL url = new URL(urlBuilder.toString());
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET");
connection.setRequestProperty("Content-Type", "application/json");
log.debug("Response code: " + connection.getResponseCode());
BufferedReader br = null;
if (connection.getResponseCode() >= 200 && connection.getResponseCode() <= 300) {
br = new BufferedReader(new InputStreamReader(connection.getInputStream()));
} else {
br = new BufferedReader(new InputStreamReader(connection.getErrorStream()));
}
// 요청 결과 출력을 위한 준비
StringBuilder sb = new StringBuilder();
String line;
while ((line = br.readLine()) != null) {
sb.append(line);
}
// 시스템 자원 반납 및 연결해제
br.close();
connection.disconnect();
String result = sb.toString();
//weatherManager는 API결과값을 파라미터로 전달받아 weather 객체를 가공하는 클래스
return weatherManager.getVilageFcst(result);
}
위와 같이 서비스에서 URL을 생성하고, URLConnection 클래스 등을 이용해서 로직을 구현하였다. 그나마 다행인건 결과를 반환받아 WeatherManager 클래스에서 데이터를 가공한 것이 코드가 조금이라도 덜 더러워지는 것을 예방한 것 뿐이다. 그럼 weatherManager는 어떻게 선언되어있는지 보자.
..생략
public List getVilageFcst(String result) {
// API 반환결과에서 필요데이터 추출
JSONObject resultObj = new JSONObject(result);
JSONObject response = resultObj.getJSONObject("response");
JSONObject body = response.getJSONObject("body");
JSONObject items = body.getJSONObject("items");
JSONArray itemList = items.getJSONArray("item");
// 조건에 해당하는 item을 담을 새로운 배열 생성
JSONArray selectList = new JSONArray();
for (int i = 0; i < itemList.length(); i++) {
// 하나의 아이템 추출
JSONObject item = itemList.getJSONObject(i);
// 당일 오전 08시 기준으로 조건 분석을 위해 변수로 추출
String fcstTime = item.getString("fcstTime");
// 카테고리별 데이터 추출
String category = item.getString("category");
if (fcstTime.equals("0800")) {
switch (category) {
case "SKY":
selectList.put(item);
break;
case "PTY":
selectList.put(item);
break;
case "TMP":
selectList.put(item);
break;
case "WAV":
selectList.put(item);
break;
case "VEC":
selectList.put(item);
break;
case "WSD":
selectList.put(item);
break;
}
}
}
//단기예보(3일)에 대한 날씨 DTO 생성
Weather today = new Weather();
Weather tomorrow = new Weather();
Weather dayAfter = new Weather();
Calendar calendar = Calendar.getInstance();
LocalDateTime now = LocalDateTime.now();
for(int i = 0; i < selectList.length(); i++) {
JSONObject item = selectList.getJSONObject(i);
String fcstDate = item.getString("fcstDate");
//1~3일 간의 데이터를 추출하기 위해 조건을 통한 분리
if(fcstDate.equals(now.format(DateTimeFormatter.ofPattern("yyyyMMdd")))) {
putCategoryValue(today, item);
today.setDay(Integer.toString(calendar.get(Calendar.DAY_OF_WEEK)));
today.setDayNo(1);
} else if(fcstDate.equals(now.plusDays(1).format(DateTimeFormatter.ofPattern("yyyyMMdd")))) {
putCategoryValue(tomorrow, item);
tomorrow.setDayNo(2);
} else if(fcstDate.equals(now.plusDays(2).format(DateTimeFormatter.ofPattern("yyyyMMdd")))) {
putCategoryValue(dayAfter, item);
dayAfter.setDayNo(3);
}
}
List<Weather> weatherList = new ArrayList();
//데이터가 채워진 weather 객체 List에 주입
weatherList.add(today);
weatherList.add(tomorrow);
weatherList.add(dayAfter);
//해당 리스트 반환
return weatherList;
}
이렇게 반환된 리스트를 컨트롤러로 반환해주면 원하는 지역의 기준 시간인 08시에 대한 단기예보가 전송된다. 전송된 데이터를 클라이언트에서 코드에 맞게 가공해주면 UI구성이 완료되는 것이다.
처음에는 결과가 잘 뜨는 것을 보고 신이났다. 일단 성공했으니....
하지만 몇가지 문제가 식별되니 손을 놓고 볼 수가 없어서 코드를 수정하기로 했다.
일단 중요한 문제가 크게 두가지 였던 것 같다.
이러한 문제점들이 대표적이지만 짜잘하게는 코드부터가 일단 꼴보기 싫었다. 그래서 코드를 수정하기 위해 해결 방안을 모색했다.
다양한 해결방안이 있겠지만 내가 선택한 것은 우선 API 요청을 서버에서 주기적으로 수행하고 데이터를 메모리 또는 DB에 보관하여 사용자가 지역을 선택 시 DB에서 정보를 조회하여 출력하는 방식으로 바꾼 것이다.
그림을 통해 알아보자.
SchedulerService를 이용하여 서버 가동 시 1시간 단위로 API를 요청하여 데이터를 반환받아 가공 후 DB에 등록하도록 구성하였다.
이렇게 구성된 DB에서 클라이언트가 요청한 지역의 PK값과 비교하여 결과를 Controller를 통해 반환해준 것이다.
결론만 이야기하자면 한번 사용자가 클릭할 경우 발생했던 지연시간은 짧게는 1초에서 길게는 8초까지도 걸렸다. 하지만 개선한 결과는 다음과 같다.
코드가 잘 작성되었다고 할 수 없고, 로직이 완벽하다 할 수 없지만 내가 스스로 성능을 개선할 생각을 했고, 성능을 어떻게든 개선해 다음과 같은 결과를 얻었다는 것에 만족한다.
이렇게 개선한 후의 코드는 아래와 같다.
@PostMapping("/weather")
public List getWeather(@RequestBody SurfingSpot surfingSpot) {
long startTime = System.currentTimeMillis();
List<Weather> weatherList = surfingSpotService.getWeather(surfingSpot);
log.debug("------ " + surfingSpot.getSpotName() + " 날씨조회 성공 ------");
long endTime = System.currentTimeMillis();
log.debug("시간걸린 시간은? " + ((endTime-startTime) / 1000.0) + "초");
return weatherList;
}
컨트롤러는 동일하다. 그냥 신나서 시간을 측정하는 코드가 추가된 것 뿐.
@Scheduled(cron = "0 0 0/1 * * *")
public void registSpotWeather() throws IOException {
log.debug("----- 날씨 정보 갱신 요청 ------");
// DB에 저장된 모든 지역정보 조회
List<SurfingSpot> spotList = surfingSpotDAO.selectAll();
log.debug("----- DB에 저장된 지역의 수 : " + spotList.size() + " ------");
// 모든 지역의 수만큼 날씨정보를 갱싱하는 반복문 수행
for (SurfingSpot spot : spotList) {
//APIConnector를 통해 지역 건당 날씨정보를 획득
String apiResult = apiConnector.getWeatherDate(apiConnector.getWeatherURL(spot));
//날씨 정보를 기반으로 weather객체리스트 생성(1개의 지역당 3일의 날씨데이터)
List<Weather> weatherList = weatherManager.getVilageFcst(apiResult);
log.debug(spot.getSpotName() + "의 3일 간 데이터 수는? " + weatherList.size());
log.debug("위도 경도는? " + spot.getSpotLati() + ", " + spot.getSpotLongi());
//3일의 날씨 데이터를 보유한 List를 반복문을 수행하여 DB갱신
for (Weather weather : weatherList) {
log.debug("현재 날씨상태? " + weather.getSky() + weather.getTmp());
//spot 객체 정보를 weather 객체에 저장(idx값등 사용에 필요)
weather.setSurfingSpot(spot);
log.debug("추출한 weather는? " + "spot_idx=" + weather.getSurfingSpot().getSpotIdx() + "dayNo=" + weather.getDayNo());
//날씨객체가 null이 아닌 경우 해당 데이터를 update
//날씨객체가 null인 경우 해당 데이터 insert
Weather result = weatherDAO.selectBySpot(weather);
if(result == null) {
weatherDAO.insert(weather);
} else {
weatherDAO.update(weather);
}
}
}
log.debug("----- 날씨 정보 갱신 완료 ------");
}
순서는 다음과 같다.
이렇게 DB에 저장이 완료되도록 스케줄러 서비스를 구현하였다. 서비스에서 사용된 클래스들의 코드는 아래와 같다. 코드박스를 줄맞춤하는 것이 번거로워 캡쳐로 대신하겠다. 자세한 코드는 여기를 통해 알아보자.
내가 코딩을 시작한뒤로 처음으로 한가지 기능에 상당한 시간을 쓴 것 같다. 우선 원하는 정도의 결과가 나온 것으로도 만족한다. 하지만 프로젝트 시간이 널널했다면 더 효율적으로 만들어보고 싶었다.
우선 문제를 해결하는 방법에서 로컬캐시를 이용하는 방법과 DB를 이용하는 방법에 대해서 고민을 많이했다. DB를 선택한 이유는 다음과 같았다.
두가지의 대표적인 이유를 때문에 DB를 선택한 것이다. 캐시는 갑작스러운 브라우저 종료 등이 발생할 경우 데이터를 다시 요청해야하는 수고스러움이 발생하며, 사용자가 지연시간을 기다려야하는 경우도 생긴다. 그렇기에 DB를 선택한 것이다.
더 공부를 해봐야겠지만 이런식으로 기능이 구현된 코드를 더욱 간결하고 성능이 좋게 개선하는 것이 공부에 도움이 많이 되는 것 같다.
그럼 이만.👊🏽
안녕하세요 선생님 글을 보고 weather 프로젝트를 진행중인데요 궁금한게 있어 여쭤봅니다. db에 기상일보를 저장했다가 보여주는건데 3일 단기 예보를 전부 저장하시나요? 지역을 선택했을때 바로 보여주려면 모든 지역의 3일 단기 예보를 저장하고 보여주는건지 궁금해요