[프로젝트]기상청 API 연동하기

Inung_92·2023년 3월 13일
1

프로젝트

목록 보기
3/9
post-thumbnail

구현기능 소개

최초에 구현하고자 했던 기능은 선택한 지역의 일주일간의 중기예보를 출력하는 기능을 구현하고 싶었으나 기상청 API의 단기예보와 중기예보를 섞어서 사용해야하는 부분들이 발생하여 프로젝트 기간상 기능을 축소하기로 하였다. 그래서 절충안을 낸 것이 단기예보(3일)에 대한 결과를 출력하는 것이다.

⚡️ 기능 UI(React)

상단에는 선택한 지역의 위치가 표시된다. 기본값은 보는 것과 같이 강원도 양양군 강현면으로 지정하였다. 최초 로드 시 지역에 대한 정보는 객체(state)로 정보를 부여하였다.

const [spot, setSpot] = useState({
	localName: "경상남도"
	spotIdx: 237
	spotLati: "34.7683"
	spotLongi: "127.88893"
	spotName: "남면"
	townName: "남해군"
});

이러한 형태로 데이터의 초기값을 보유해줬다. 예시에서는 남해군인 점은 감안해주길 바란다.

⚡️ 기능 설명

지역명 옆에 핀 아이콘을 클릭하면 모달창을 띄우고 해당 모달창의 select를 선택할때마다 DB에 저장된 지역이 호출되어지는 형태로 구현을 하였다.

최종적으로 해당 지역을 클릭하면 모달창이 닫히면서 기상청 API 정보로 획득한 단기예보가 UI에 출력되는 형태이다.

이런 형태로 강남동을 클릭했을 때 해당 위치의 날씨정보가 출력되는 것이다. 기능의 흐름을 이해하기 위해 밑의 그림을 보자.

최초에 계획했던 흐름도이다. 단순히 클라이언트 단에서 지역정보를 넘겨주면 해당 지역정보를 가지고 서버 단에서 API로 요청하여 데이터를 가공해서 반환하면 된다고 생각했다.
사실 이렇게만해도 결과는 반환되고 사용은 할 수 있다. 이 상태의 코드를 보면서 한번 이야기해보자.(굉장히 비효율적인 코드라는 생각을 스스로했음.)

🖥️ controller

..생략
@GetMapping("/weather")
public List getWeather(Spot spot){
	//서비스를 통해 API결과를 반환받아 클라이언트에게 응답 데이터로 전송
    //응답 객체 타입은 잭슨 라이브러리를 통해 json으로 자동 변환
	return spotService.getWeather(spot);
}

컨트롤러에서는 서비스를 호출하여 파라미터만 전달하였다. 대부분의 비즈니스 로직은 서비스에서 구현하기에 위와 같이 전달한 것이다.

🖥️ service

..생략
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는 어떻게 선언되어있는지 보자.

🖥️ 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구성이 완료되는 것이다.

처음에는 결과가 잘 뜨는 것을 보고 신이났다. 일단 성공했으니....
하지만 몇가지 문제가 식별되니 손을 놓고 볼 수가 없어서 코드를 수정하기로 했다.


개선내용

⚡️ 문제점

일단 중요한 문제가 크게 두가지 였던 것 같다.

  1. 사용자가 지역을 클릭할 때마다 API가 요청되어지고 처리되고 응답을 받는데까지 로딩시간이 발생함.(대략 1~5초 사이)
  2. API 통신에 문제가 생기면 기능 자체가 먹통이 생겨서 사용자 입장에서는 해당 기능을 사용할 수 없게됨.
  3. 사용자가 많아질 경우 API 요청 횟수가 늘어나면서 부하가 심해지는 현상이 발생함.

이러한 문제점들이 대표적이지만 짜잘하게는 코드부터가 일단 꼴보기 싫었다. 그래서 코드를 수정하기 위해 해결 방안을 모색했다.

⚡️ 해결방안

다양한 해결방안이 있겠지만 내가 선택한 것은 우선 API 요청을 서버에서 주기적으로 수행하고 데이터를 메모리 또는 DB에 보관하여 사용자가 지역을 선택 시 DB에서 정보를 조회하여 출력하는 방식으로 바꾼 것이다.

그림을 통해 알아보자.

SchedulerService를 이용하여 서버 가동 시 1시간 단위로 API를 요청하여 데이터를 반환받아 가공 후 DB에 등록하도록 구성하였다.

이렇게 구성된 DB에서 클라이언트가 요청한 지역의 PK값과 비교하여 결과를 Controller를 통해 반환해준 것이다.

⚡️ 개선결과

결론만 이야기하자면 한번 사용자가 클릭할 경우 발생했던 지연시간은 짧게는 1초에서 길게는 8초까지도 걸렸다. 하지만 개선한 결과는 다음과 같다.

코드가 잘 작성되었다고 할 수 없고, 로직이 완벽하다 할 수 없지만 내가 스스로 성능을 개선할 생각을 했고, 성능을 어떻게든 개선해 다음과 같은 결과를 얻었다는 것에 만족한다.

이렇게 개선한 후의 코드는 아래와 같다.

🖥️ controller

@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;
}

컨트롤러는 동일하다. 그냥 신나서 시간을 측정하는 코드가 추가된 것 뿐.

🖥️ schedulerService

@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("----- 날씨 정보 갱신 완료 ------");
}

순서는 다음과 같다.

  1. DB에 저장된 지역정보를 모두 불러온다.
  2. 불러들인 지역정보만큼 반복문을 수행하여 API 요청을 한다.
  3. 지역 당 3일치의 데이터를 출력해야하기에 List로 반환받고, 해당 List만큼 이중 반복문을 통해 DB에 저장한다.
  4. 이때 기존에 생성되어있는 지역의 정보일 경우 최신 데이터로 update를 수행하고, 그렇지 않을 경우는 insert를 해준다.

이렇게 DB에 저장이 완료되도록 스케줄러 서비스를 구현하였다. 서비스에서 사용된 클래스들의 코드는 아래와 같다. 코드박스를 줄맞춤하는 것이 번거로워 캡쳐로 대신하겠다. 자세한 코드는 여기를 통해 알아보자.

🖥️ APIConnector

🖥️ weatherManager



마무리

내가 코딩을 시작한뒤로 처음으로 한가지 기능에 상당한 시간을 쓴 것 같다. 우선 원하는 정도의 결과가 나온 것으로도 만족한다. 하지만 프로젝트 시간이 널널했다면 더 효율적으로 만들어보고 싶었다.

⚡️ 느낀점

우선 문제를 해결하는 방법에서 로컬캐시를 이용하는 방법과 DB를 이용하는 방법에 대해서 고민을 많이했다. DB를 선택한 이유는 다음과 같았다.

  • 데이터가 일관성이 보장되어야하며, 웹 브라우저가 로드 되자마자 데이터를 호출할 수 있어야했다.
  • 서비스를 이용하는 동안 데이터의 갑작스런 삭제 및 변동이 있어서는 안됐음.

두가지의 대표적인 이유를 때문에 DB를 선택한 것이다. 캐시는 갑작스러운 브라우저 종료 등이 발생할 경우 데이터를 다시 요청해야하는 수고스러움이 발생하며, 사용자가 지연시간을 기다려야하는 경우도 생긴다. 그렇기에 DB를 선택한 것이다.

더 공부를 해봐야겠지만 이런식으로 기능이 구현된 코드를 더욱 간결하고 성능이 좋게 개선하는 것이 공부에 도움이 많이 되는 것 같다.

그럼 이만.👊🏽

profile
서핑하는 개발자🏄🏽

0개의 댓글