[Springboot] Open API와 통신하여 Springboot 서버 개발하기 (feat. URLConnection, Jackson)

유아 Yooa·2023년 5월 25일
3

Spring

목록 보기
7/18
post-thumbnail

Overview

최근 데이터 활용 공모전에 참여하게 되면서 Open API를 활용해서 데이터를 수집하고 이를 가공하는 작업을 맡게 되었다. 서버 어플리케이션에서 Open API를 써본 경험이 많지 않아서 미리 연습을 해보자🔥

다양한 기업 및 공공기관에서 API를 제공하고 있다. 필자의 경우, 공공데이터 포털에서 제공하고 있는 Open API로 통신한 데이터를 사용해볼 것이다!

요구사항

  • Open API와 통신하여 데이터 받아오기
  • 요청 날짜에 따른 값 반환하기
  • JSON 데이터를 Java Object에 Mapping하여 반환하기

위 목표를 위해 오늘도 뚝딱여보자.


공공데이터

공공데이터란 공공기관이 만들어내는 모든 자료나 정보, 국민 모두의 소통과 협력을 이끌어내는 공적인 정보를 말한다.

각 공공기관이 공유한 공공데이터 목록과 국민에게 개방할 수 있는 공공데이터를 포털에 등록하면 모두가 공유할 수 있는 양질의 공공데이터로 재탄생하게 된다.

-공공데이터 포털 개요 참고-

공공데이터 포털을 접속해보면 개방 데이터를 파일 형태 혹은 OpenAPI 형태로 사용할 수 있다. 나는 Spring에서 API를 활용해 JSON 형식으로 데이터를 받아올 것이다.

공공데이터 활용 신청하기

우선 자신이 활용할 데이터를 서치한다. 기상청전국 해수욕장 날씨 조회서비스 OpenAPI를 활용하기 위해 '활용 신청'을 했다.

마이페이지에 들어가면 내가 신청한 건에 대한 리스트도 확인할 수 있다.

신청한 API를 누르면 개발계정 상세보기 페이지가 나오는데 여기서 출력되는 일반 인증키를 사용하면 된다.
일일 가능 트래픽 규모도 확인해준다. 내가 사용하는 API는 하루에 10,000번의 트래픽을 사용할 수 있다.

'미리보기'를 통해서 요청에 따른 응답을 테스트해볼 수 있다.
service key와 적절한 request 파라미터를 넣어주면 아래처럼 새로운 탭에 응답값이 찍히는 것을 확인할 수 있다.

참고 문서도 다운로드 받을 수 있다. 참고 문서에 더 친절한 설명이 적혀있다.

Open API 활용가이드

요청 URL과 요청에 필요한 파라미터를 확인해준다.

응답 메시지 명세도 확인해준다. 요청을 하면 이런 응답 파라미터가 온다는 것!

친절하게 어떻게 요청과 응답이 이루어지는지 예제도 나와있다.
XML 형식으로 오는 예제이다.

request에서 dataType을 JSON으로 요청하면 JSON 데이터가 반환된다.

해당 가이드라인 하단에는 참고 문서 별첨이 있었다. response 중 특정 코드로 표시되는 값들이 있어서 이에 대한 해석이 나와있었다. 활용 가이드를 꼼꼼하게 읽어보면 자세하게 안내가 되어 있다.


Spring과 연동하기

Spring 프로젝트를 생성하자
https://start.spring.io/

  • Gradle - Groovy
  • Java 17
  • 3.1.0
  • MySQL
  • Junit4

Datasource 세팅을 마치고 이제 본격적으로 연동을 해보자.

Controller에서 통신하기

우선 controller에서 Open API와 통신하여 데이터를 받아오자.
자세한 코드 설명은 아래에 적어놓겠다.

@RestController
@RequestMapping("/api")
public class ForecastController {
    @Value("${openApi.serviceKey}")
    private String serviceKey;

    @Value("${openApi.callBackUrl}")
    private String callBackUrl;

    @Value("${openApi.dataType}")
    private String dataType;

    @GetMapping("/forecast")
    public ResponseEntity<String> callForecastApi(
            @RequestParam(value="base_time") String baseTime,
            @RequestParam(value="base_date") String baseDate,
            @RequestParam(value="beach_num") String beachNum
    ){
        HttpURLConnection urlConnection = null;
        InputStream stream = null;
        String result = null;

        String urlStr = callBackUrl +
                "serviceKey=" + serviceKey +
                "&dataType=" + dataType +
                "&base_date=" + baseDate +
                "&base_time=" + baseTime +
                "&beach_num=" + beachNum;

        try {
            URL url = new URL(urlStr);

            urlConnection = (HttpURLConnection) url.openConnection();
            stream = getNetworkConnection(urlConnection);
            result = readStreamToString(stream);

            if (stream != null) stream.close();
        } catch(IOException e) {
            e.printStackTrace();
        } finally {
            if (urlConnection != null) {
                urlConnection.disconnect();
            }
        }

        return new ResponseEntity<>(result, HttpStatus.OK);
    }

    /* URLConnection 을 전달받아 연결정보 설정 후 연결, 연결 후 수신한 InputStream 반환 */
    private InputStream getNetworkConnection(HttpURLConnection urlConnection) throws IOException {
        urlConnection.setConnectTimeout(3000);
        urlConnection.setReadTimeout(3000);
        urlConnection.setRequestMethod("GET");
        urlConnection.setDoInput(true);

        if(urlConnection.getResponseCode() != HttpURLConnection.HTTP_OK) {
            throw new IOException("HTTP error code : " + urlConnection.getResponseCode());
        }

        return urlConnection.getInputStream();
    }

    /* InputStream을 전달받아 문자열로 변환 후 반환 */
    private String readStreamToString(InputStream stream) throws IOException{
        StringBuilder result = new StringBuilder();

        BufferedReader br = new BufferedReader(new InputStreamReader(stream, "UTF-8"));

        String readLine;
        while((readLine = br.readLine()) != null) {
            result.append(readLine + "\n\r");
        }

        br.close();

        return result.toString();
    }
}
  • configuration 값

    • serviceKey 같은 경우 외부에 유출되어서 안되기 때문에 property로 관리한다.
    • callBackUrl이나 dataType 같이 변하지 않는 값들도 함께 관리한다.
  • RequestParam

  • 요청 파라미터

    • 활용 가이드를 살펴보고 (필수) 파라미터를 함께 포함시키도록 한다.
    • snake case, camel case 두 개가 섞여 있기도 한다. (like me..) 잘 확인하도록 하자.
    • serviceKey : 공공데이터 포털에서 발급받은 API KEY(Encoding)
    • dataType : JSON/XML
    • base_date : 요청 날짜
    • base_time : 요청 시각
    • beach_num : 요청 해수욕장 number (해수욕장 number 표가 별첨되어 있다.)

HttpURLConnection

  • Java 애플리케이션과 URL 간의 연결에 대한 API를 제공한다.
  • URLConnection 클래스의 서브 클래스로, HTTP 고유 기능에 대한 추가 지원을 제공한다.
  • 해당 클래스는 추상 클래스이므로 새 인스턴스를 직접 만들 수 없다.
    • 그렇기에 URL 객체 연결을 통해 URLConnection 인스턴스를 얻는다.

1. 요청 URL(e.g. urlStr) 주소에 대해 URL 객체를 생성한다.

URL url = new URL(urlStr);

해당 생성자는 URL 형식이 잘못된 경우 MalformedURLException을 throw한다.
해당 예외는 IOException의 하위 클래스이므로 try-catch 문으로 예외처리를 해주었다.

2. URL에서 URL Connection 객체 얻기

URLConnection 인스턴스는 URL 객체의 openConnection() 메소드 호출에 의해 얻어진다.
나는 프로토콜이 http://이므로 반환된 객체를 캐스팅해주었다.

urlConnection = (HttpURLConnection) url.openConnection();

URL의 openConnection() 메서드는 I/O 오류가 발생하면 IOException을 발생시킨다.

openConnection() 메서드는 실제 네트워크 연결을 설정하지 않고, URLConnection 클래스의 인스턴스만 반환한다.
네트워크 연결은 connect() 메서드가 호출 될 때 명시적으로 이루어지거나, 헤더 필드를 읽거나 입력 스트림/출력 스트림을 가져올 때 암시적으로 이루어진다.

3-1. URLConnection 구성

연결을 설정하기 전에 타임아웃, 캐시, HTTP 요청 방법 등과 같이 클라이언트와 서버 간의 옵션을 설정할 수 있다. 연결이 이미 설정된 이후 메서드를 호출하면 일부는IllegalStateException을 발생시킨다.

urlConnection.setConnectTimeout(3000);
urlConnection.setReadTimeout(3000);
urlConnection.setRequestMethod("GET");
urlConnection.setDoInput(true);
  • setConnectTimeout (int timeout) : 연결 타임아웃 값을 설정한다. (단위:ms)
    • 연결이 설정되기 전에 제한시간이 만료되면 java.net.SocketTimeoutException이 발생한다.
    • 시간 초과가 0이면, 무한대 타입아웃(기본값)을 의미한다.
  • setReadTimeout (int timeout) : 읽기 타임아웃 값을 설정한다. (단위:ms)
    • 제한 시간이 만료되고 연결의 입력 스트림에서 읽을 수 있는 데이터가 없으면 SocketTimeoutException이 발생한다.
    • 시간 초과가 0이면, 무한대 타임아웃을 의미한다.
  • setDefaultUserCaches (boolean default) : URLConnection이 기본적으로 캐시를 사용하는지 여부를 사용한다.(기본값은 true)
  • setUseCaches (boolean useCaches) : 연결이 캐시를 사용하는지 여부를 설정한다. (기본값은 true)
  • setDoInput (boolean doInput) : URLConnetion을 서버에서 콘텐츠를 읽는 데 사용할 수 있는지 여부를 설정한다. (기본값은 true)
  • setDoOutput (boolean doOutput) : URLConnection이 서버에 데이터를 보내는 데 사용할 수 있는 여부를 설정한다. (기본값은 false)
  • setIfModifiedSince (long time) : 주로 HTTP 프로토콜에 대해 클라이언트가 검색한 콘텐츠의 마지막 수정 시간을 새로 설정한다.
    • e.g. 서버가 지정된 시간 이후에 정적 콘텐츠(이미지,HTML 등)가 변경되지 않았으면 콘텐츠를 가져오지 않고 상태 코드 304(수정되지 않음)을 반환한다.
    • 클라이언트는 지정된 시간보다 최근에 수정된 경우 새로운 콘텐츠를 받게 된다.
  • setAllowUserInteraction (boolean allow) : 사용자 상호 작용을 활성화 또는 비활성화환다.
    • e.g. 필요한 경우 인증 대화 상자를 표시한다.
    • 기본값은 false
  • setDefaultAllowUserInteraction (boolean default) : 이후의 모든 URLConnection 객체에 대한 사용자 상호 작용의 기본값을 설정한다.
  • setRequestProperty (String key, String value) : key=value 쌍으로 지정된 일반 요청 속성을 설정한다.
    • 키가 있는 속성이 이미 있는 경우 이전 값을 새 값으로 적용한다.

3-2. 하위 클래스 HttpURLConnetion 연결 구성

  • setRequestMethod (String method) : HTTP 메서드 GET, POST, HEAD, OPTIONS, PUT, DELETE, TRACE 중 하나를 URL 요청에 대한 메소드로 설정한다. (기본값은 GET)
  • setChunkedStreamingMode (int chunkLength) : 콘텐츠 길이를 미리 알 수 없는 경우 내부 버퍼링 없이 HTTP 요청 본문을 스트리밍할 수 있다.
  • setFixedLengthStreamingMode (long contentLength) : 콘텐츠 길이를 미리 알고 있는 경우 내부 버퍼링 없이 HTTP 요청 본문을 스트리밍할 수 있다.
  • setFollowRedirects (boolean follow) : 클래스의 미래 개체가 자동으로 따라야 하는지 여부를 설정한다. (기본값은 true)
    • 보안 관리자가 변경을 허가하지 않을 경우 SecurityException을 발생시킨다.
  • setInstanceFollowRedirects (boolean follow) : HttpURLConnection 객체가 리다이렉트를 따라가게 만든다. (기본값은 true)

3-3. getter도 제공하고 있다.

4. 헤더 필드

  • 연결이 이루어지면 서버는 URL 요청을 처리하고 메타데이터와 실제 콘텐츠로 구성된 response를 보낸다.
  • 메타데이터는 헤더 필드라고 하는 key, value 값 쌍의 모입이다.
  • 헤더 필드는 서버에 대한 정보, 상태 코드, 프로토콜 정보 등을 나타낸다.
  • 헤더 필드를 읽기 위한 다양한 메소드가 있는데 필자는 getResponseCode()를 이용해 서버에서 보낸 HTTP 상태 코드를 체크했다.
        if(urlConnection.getResponseCode() != HttpURLConnection.HTTP_OK) {
            throw new IOException("HTTP error code : " + urlConnection.getResponseCode());
        }

5. 입력 스트림 가져오기 및 데이터 읽기

  • 실제 내용을 읽기 위해 InputStream 인스턴스를 얻어온다.
    -getInputStream() 메서드는 다음과 같은 예외가 발생한다.
    • IOException : 입력 스트림을 생성하는 동안 I/O 오류 발생
    • SocketTimeoutException : 데이터를 읽을 수 있기 전 timeout 발생
    • UnknownServiceException : 프로토콜이 입력을 지원하지 않는 경우
return urlConnection.getInputStream();
  • 문자 데이터를 읽기 위해서 InputStreamInputStreamReader로 wrapping한다.
  • 데이터를 문자열로 읽기 위해 InputStreamBufferedReader로 wrapping한다.
  • UTF-8로 인코딩하는 옵션도 추가해주었다.
BufferedReader br = new BufferedReader(new InputStreamReader(stream, "UTF-8"));
  • BufferedReader의 문자열을 한줄씩 읽으며 result에 붙여준다.
 String readLine;
while((readLine = br.readLine()) != null) {
	result.append(readLine + "\n\r");
}

6.연결 종료하기

  • 데이터를 무사히 받아왔다면 네트워크 연결을 끊어준다.
if (urlConnection != null) {
                urlConnection.disconnect();
}

Controller test 해보기

@RunWith(SpringRunner.class)
@WebMvcTest(ForecastController.class)
@AutoConfigureMockMvc
public class ForecastControllerTest {

    @Autowired
    private MockMvc mvc;

    @Test
    @DisplayName("Open API 통신 테스트")
    public void callOpenApi() throws Exception {
        String baseTime = "1100";
        String baseDate = "20230525";
        String beachNum = "1";

        MultiValueMap<String, String> param = new LinkedMultiValueMap<>();

        param.add("base_time", baseTime);
        param.add("base_date", baseDate);
        param.add("beach_num", beachNum);

        this.mvc.perform(get("/api/forecast").params(param))
                .andExpect(status().isOk())
                .andDo(print());
    }
}

test 코드를 작성하고 결과값을 확인해보니

{
  "response": {
    "header": {
      "resultCode": "00",
      "resultMsg": "NORMAL_SERVICE"
    },
    "body": {
      "dataType": "JSON",
      "items": {
        "item": [
          {
            "beachNum": "1",
            "baseDate": "20230525",
            "baseTime": "1100",
            "category": "TMP",
            "fcstDate": "20230525",
            "fcstTime": "1200",
            "fcstValue": "20",
            "nx": 49,
            "ny": 124
          },
          {
            "beachNum": "1",
            "baseDate": "20230525",
            "baseTime": "1100",
            "category": "UUU",
            "fcstDate": "20230525",
            "fcstTime": "1200",
            "fcstValue": "-0.1",
            "nx": 49,
            "ny": 124
          },
          {
            "beachNum": "1",
            "baseDate": "20230525",
            "baseTime": "1100",
            "category": "VVV",
            "fcstDate": "20230525",
            "fcstTime": "1200",
            "fcstValue": "2.7",
            "nx": 49,
            "ny": 124
          },
          {
            "beachNum": "1",
            "baseDate": "20230525",
            "baseTime": "1100",
            "category": "VEC",
            "fcstDate": "20230525",
            "fcstTime": "1200",
            "fcstValue": "176",
            "nx": 49,
            "ny": 124
          },
          {
            "beachNum": "1",
            "baseDate": "20230525",
            "baseTime": "1100",
            "category": "WSD",
            "fcstDate": "20230525",
            "fcstTime": "1200",
            "fcstValue": "2.7",
            "nx": 49,
            "ny": 124
          },
          {
            "beachNum": "1",
            "baseDate": "20230525",
            "baseTime": "1100",
            "category": "SKY",
            "fcstDate": "20230525",
            "fcstTime": "1200",
            "fcstValue": "1",
            "nx": 49,
            "ny": 124
          },
          {
            "beachNum": "1",
            "baseDate": "20230525",
            "baseTime": "1100",
            "category": "PTY",
            "fcstDate": "20230525",
            "fcstTime": "1200",
            "fcstValue": "0",
            "nx": 49,
            "ny": 124
          },
          {
            "beachNum": "1",
            "baseDate": "20230525",
            "baseTime": "1100",
            "category": "POP",
            "fcstDate": "20230525",
            "fcstTime": "1200",
            "fcstValue": "0",
            "nx": 49,
            "ny": 124
          },
          {
            "beachNum": "1",
            "baseDate": "20230525",
            "baseTime": "1100",
            "category": "WAV",
            "fcstDate": "20230525",
            "fcstTime": "1200",
            "fcstValue": "0.5",
            "nx": 49,
            "ny": 124
          },
          {
            "beachNum": "1",
            "baseDate": "20230525",
            "baseTime": "1100",
            "category": "PCP",
            "fcstDate": "20230525",
            "fcstTime": "1200",
            "fcstValue": "강수없음",
            "nx": 49,
            "ny": 124
          }
        ]
      },
      "pageNo": 1,
      "numOfRows": 10,
      "totalCount": 737
    }
  }
}

잘 받아와진다!
참고로 지금은 콘솔에 출력된 String으로 반환된 미친 json 데이터를 json formatter를 이용해서 예쁘게 만들어보았다.


JSON deserialize

이제 데이터를 사용하려면 적절하게 가공을 해주어야 한다. JSON deserialize 작업을 해주어 데이터를 가공해 Java Object에 mapping하여 값을 반환하자.

참고로 추후에는 Service 단에서 DB에 가공 값을 저장해줄 예정이라 JSON 가공 작업은 Service 단에서 해줄 것이다.

JSON 라이브러리는 정말 많은데 나는 그중 Jackson 라이브러리를 사용해볼 것이다.

스프링부트는 spring-boot-starter-webJackson 라이브러리를 제공하고 있어서 Json의 직렬/역직렬화에는 Jackson을 사용한다.

deserialize(Json > VO)는 아래와 같은 순서를 따른다.

  1. 기본 생성자로 객체를 생성한다.
  2. public 필드 또는 public의 getter/setter로 필드를 찾아 binding한다.

response 구조 파악하기

response JSON 응답을 적절하게 binding 해주어야 하는데 이 과정이 몹시 몹 시 귀찮다!

우선 response JSON의 구조를 파악해서 depth를 구분한다.

{
  "response": {
    "header": {
      "resultCode": "00",
      "resultMsg": "NORMAL_SERVICE"
    },
    "body": {
      "dataType": "JSON",
      "items": {
        "item": [
          {
            "beachNum": "1",
            "baseDate": "20230525",
            "baseTime": "1100",
            "category": "TMP",
            "fcstDate": "20230525",
            "fcstTime": "1200",
            "fcstValue": "20",
            "nx": 49,
            "ny": 124
          },
          (...)
          {
            "beachNum": "1",
            "baseDate": "20230525",
            "baseTime": "1100",
            "category": "PCP",
            "fcstDate": "20230525",
            "fcstTime": "1200",
            "fcstValue": "강수없음",
            "nx": 49,
            "ny": 124
          }
        ]
      },
      "pageNo": 1,
      "numOfRows": 10,
      "totalCount": 737
    }
  }
}

depth 1 : response
depth 2 : header, body
depth 3(header) : resultCode, resultMsg
depth 3(body) : dataType, items, pageNo, numOfRows, totalCount
depth 4(items) : item(array)
depth 5(item) : beachNum, baseDate, baseTime, category, fcstDate, fcstTime, fcstValue, nx, ny

미친 중첩을 보여준다.
우리는 이 중 depth 4에 있는 item array를 가져올 것이기 때문에 직접 mapping을 해주는 작업이 필요하다.

VO 구성하기

Map과 VO의 논쟁이 뜨거운데
담겨있는 필드가 많아지는 경우 대체로 VO 객체를 사용하는 추세라고..

우선 응답 데이터를 mapping 시킬 VO를 생성한다.

FcstItems

@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class FcstItems {
    @JsonProperty("item")
    private List<FcstItem> fcstItems;
}

FcstItem

@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class FcstItem {
    // 해변코드
    @JsonProperty("beachNum")
    private int beachNum;

    // 발표일자
    @JsonProperty("baseDate")
    private String baseDate;

    // 발표시각
    @JsonProperty("baseTime")
    private String baseTime;

    // 자료구분코드
    @JsonProperty("category")
    private String category;

    // 예보일자
    @JsonProperty("fcstDate")
    private String fcstDate;

    // 예보시간
    @JsonProperty("fcstTime")
    private String fcstTime;

    // 예보 값
    @JsonProperty("fcstValue")
    private String fcstValue;

    // X좌표
    @JsonProperty("nx")
    private int nx;

    // Y좌표
    @JsonProperty("ny")
    private int ny;
}

binding 하기

binding하는 방법은 두 가지가 있다고
1. Custom deserializer 작성하기
2. 어노테이션 사용하기

1. Custom deserializer

Deserializer를 별도의 클래스에 코드로 작성하면 DTO 내부 코드가 깔끔하고 재사용면에서 장점을 가진다. 그러나 dto마다 deserializer를 작성해주어야 하는 경우와 같이 클래스의 수가 많아지는 일이 발생할 수 있다.

FcstItemDeserializer

public class FcstItemDeserializer extends JsonDeserializer<FcstItems> {

    private final ObjectMapper objectMapper;

    public FcstItemDeserializer() {
        this.objectMapper = new ObjectMapper();
    }

    @Override
    public FcstItems deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JacksonException {
        JsonNode node = p.getCodec().readTree(p);
        JsonNode itemNode = node.findValue("item");

        List<FcstItem> items = Arrays.stream(objectMapper.treeToValue(itemNode, FcstItem[].class)).toList();

        return new FcstItems(items);
    }
}

com.fasterxml.jackson.databind.JsonDeserializer를 구현하는 Deserializer 객체를 생성한다.

JsonNode를 통해 mapping 해주었다.

  • get() : 노드의 필드를 찾고 없으면 null을 반환한다.
    • e.g. node.get("body").get("totalCount").asInt();
  • path() : 노드의 필드를 찾고 없으면 MissingNode를 반환한다.
  • findValue() : 노드와 자식 노드들에서 필드를 찾고 없으면 null을 반환한다.
  • 순차적인 접근은 get(), path()
  • 노드 하위 전체에서 필드를 찾고 싶으면 findValue()
    • 이때 동일 필드명이 있는 경우 잘못된 필드를 찾을 수도 있다.
    • item은 동일 필드명이 없기 때문에 findValue() 방식으로 접근했다.

List 값을 받으려면 objectMapper.treetoValue()를 활용하여 배열로 받아 toList() 해주어야 한다.

FcstItems

@Data
@JsonDeserialize(using = FcstItemDeserializer.class)
public class FcstItems {
    @JsonProperty("item")
    private List<FcstItem> fcstItems;

    public FcstItems(List<FcstItem> fcstItems) {
        this.fcstItems = fcstItems;
    }
}

FcstItems에 Deserialize할 때, 어떤 Deserializer를 사용할지 명시해주어야 한다.
@JsonDeserializer 어노테이션을 추가하여 class를 설정해주었다.

2. annotation

Deserializer를 재사용할 일이 많이 없거나, DTO마다 별도의 Deserializer가 필요한 경우 annotation을 사용해주는 방법도 있다.

FcstItems

@Data
@AllArgsConstructor
public class FcstItems {
    @JsonProperty("item")
    private List<FcstItem> fcstItems;

    @JsonCreator
    public FcstItems(@JsonProperty("response")JsonNode node) throws JsonProcessingException {
        ObjectMapper objectMapper = new ObjectMapper();

        JsonNode itemNode = node.findValue("item");
        this.fcstItems = Arrays.stream(objectMapper.treeToValue(itemNode, FcstItem[].class)).toList();
    }
}

@JsonCreator@JsonProperty 어노테이션을 VO 안에서 사용해주었다.

@JsonCreator은 기본생성자, setter 조합을 대체 하기때문에 @NoArgsConstructor가 필요없다.
객체를 생성하고 필드를 생성과 동시에 채워 setter없이 immutable한 객체를 얻을 수 있다는 장점이 있다.

@JsonProperty로 depth 1의 response를 가져와 주었다.

Service 코드 짜기

@Service
public class ForecastService {

    public FcstItems parsingJsonObject(String json) {
        FcstItems items = null;
        try {
            ObjectMapper mapper = new ObjectMapper();
            items = mapper.readValue(json, FcstItems.class);
        } catch(Exception e) {
            e.printStackTrace();
        }
        return items;
    }
}

결과값 확인하기

원하는 값이 mapping된 것을 확인할 수 있다.

{
    "item": [
        {
            "beachNum": 1,
            "baseDate": "20230524",
            "baseTime": "1100",
            "category": "TMP",
            "fcstDate": "20230524",
            "fcstTime": "1200",
            "fcstValue": "20",
            "nx": 49,
            "ny": 124
        },
        (...)
        {
            "beachNum": 1,
            "baseDate": "20230524",
            "baseTime": "1100",
            "category": "PCP",
            "fcstDate": "20230524",
            "fcstTime": "1200",
            "fcstValue": "강수없음",
            "nx": 49,
            "ny": 124
        }
    ]
}

추가 가공하기

이제 json > object로의 mapping 작업은 모두 종료됐다. 추가적으로 Open API로 가져온 코드 값들을 해석해줄 일이 남았다.

category의 경우 별첨 자료에 코드 해석에 대한 내용이 나와있다. 클라이언트측에 값을 줄 때 해석한 값을 주는 것이 좋을 것 같아서 enum 클래스를 생성했다.

CategoryCode

public enum CategoryCode {
    POP("강수확률", "%"),
    PTY("강수형태", ""),
    PCP("1시간 강수량", "mm"),
    REH("습도", "%"),
    SNO("1시간 신적설", "cm"),
    SKY("하늘상태", ""),
    TMP("1시간 기온", "℃"),
    TMN("아침 최저기온", "℃"),
    TMX("낮 최고기운", "℃"),
    UUU("풍속(동서성분)", "m/s"),
    VVV("풍속(남북성분)", "m/s"),
    WAV("파고", "M"),
    VEC("풍향", "deg"),
    WSD("풍속", "m/s");
    >
    private final String name;
    private final String unit;
    CategoryCode(String name, String unit) {
        this.name = name;
        this.unit = unit;
    }
    public String getName() { return name; }
    public String getUnit() { return unit; }
>
    public static String getCodeInfo(String name, String value) {
        CategoryCode c = CategoryCode.valueOf(name);
        if(c == CategoryCode.PTY) {
            switch (value) {
                case "0":
                    return "없음";
                case "1":
                    return "비";
                case "2":
                    return "비/눈";
                case "3":
                    return "눈";
                case "4":
                    return "소나기";
            }
        } else if(c == CategoryCode.SKY) {
            switch(value) {
                case "1":
                    return "맑음";
                case "3":
                    return "구름많음";
                case "4":
                    return "흐림";
            }
        }
        return value;
    }
}

FcstItem

category 코드를 해석하면 담아줄 필드를 하나 추가했다.
json과 mapping 될 때 json에는 없는 값이므로 @jsonIgnoreProperties(ignoreUnknown = true)를 추가해주었다.

@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class FcstItem {
    // 해변코드
    @JsonProperty("beachNum")
    private int beachNum;

    (...)
    // 예보 값
    @JsonProperty("fcstValue")
    private String fcstValue;
    
    private String categoryName;
}

ForecastService

CategoryCode를 활용하는 코드를 추가한다.

@Service
public class ForecastService {

    public FcstItems parsingJsonObject(String json) {
        FcstItems result = new FcstItems(new ArrayList<>());
        try {
            ObjectMapper mapper = new ObjectMapper();
            FcstItems items = mapper.readValue(json, FcstItems.class);

            for(FcstItem item : items.getFcstItems()) {
                result.getFcstItems().add(decodeCategory(item));
            }
        } catch(Exception e) {
            e.printStackTrace();
        }
        return result;
    }

    private FcstItem decodeCategory(FcstItem item) {
        String name = CategoryCode.valueOf(item.getCategory()).getName();
        String value = CategoryCode.getCodeValue(item.getCategory(), item.getFcstValue());
        String unit = CategoryCode.valueOf(item.getCategory()).getUnit();

        item.setCategoryName(name);
        item.setFcstValue(value + unit);
        return item;
    }
}

최종 결과

{
    "item": [
        {
            "categoryName": "1시간 기온",
            "beachNum": 1,
            "baseDate": "20230524",
            "baseTime": "1100",
            "category": "TMP",
            "fcstDate": "20230524",
            "fcstTime": "1200",
            "fcstValue": "20℃",
            "nx": 49,
            "ny": 124
        },
        {
            "categoryName": "풍속(동서성분)",
            "beachNum": 1,
            "baseDate": "20230524",
            "baseTime": "1100",
            "category": "UUU",
            "fcstDate": "20230524",
            "fcstTime": "1200",
            "fcstValue": "0.6m/s",
            "nx": 49,
            "ny": 124
        },
        {
            "categoryName": "풍속(남북성분)",
            "beachNum": 1,
            "baseDate": "20230524",
            "baseTime": "1100",
            "category": "VVV",
            "fcstDate": "20230524",
            "fcstTime": "1200",
            "fcstValue": "2.3m/s",
            "nx": 49,
            "ny": 124
        },
        {
            "categoryName": "풍향",
            "beachNum": 1,
            "baseDate": "20230524",
            "baseTime": "1100",
            "category": "VEC",
            "fcstDate": "20230524",
            "fcstTime": "1200",
            "fcstValue": "195deg",
            "nx": 49,
            "ny": 124
        },
        {
            "categoryName": "풍속",
            "beachNum": 1,
            "baseDate": "20230524",
            "baseTime": "1100",
            "category": "WSD",
            "fcstDate": "20230524",
            "fcstTime": "1200",
            "fcstValue": "2.4m/s",
            "nx": 49,
            "ny": 124
        },
        {
            "categoryName": "하늘상태",
            "beachNum": 1,
            "baseDate": "20230524",
            "baseTime": "1100",
            "category": "SKY",
            "fcstDate": "20230524",
            "fcstTime": "1200",
            "fcstValue": "구름많음",
            "nx": 49,
            "ny": 124
        },
        {
            "categoryName": "강수형태",
            "beachNum": 1,
            "baseDate": "20230524",
            "baseTime": "1100",
            "category": "PTY",
            "fcstDate": "20230524",
            "fcstTime": "1200",
            "fcstValue": "없음",
            "nx": 49,
            "ny": 124
        },
        {
            "categoryName": "강수확률",
            "beachNum": 1,
            "baseDate": "20230524",
            "baseTime": "1100",
            "category": "POP",
            "fcstDate": "20230524",
            "fcstTime": "1200",
            "fcstValue": "20%",
            "nx": 49,
            "ny": 124
        },
        {
            "categoryName": "파고",
            "beachNum": 1,
            "baseDate": "20230524",
            "baseTime": "1100",
            "category": "WAV",
            "fcstDate": "20230524",
            "fcstTime": "1200",
            "fcstValue": "0.5M",
            "nx": 49,
            "ny": 124
        },
        {
            "categoryName": "1시간 강수량",
            "beachNum": 1,
            "baseDate": "20230524",
            "baseTime": "1100",
            "category": "PCP",
            "fcstDate": "20230524",
            "fcstTime": "1200",
            "fcstValue": "강수없음mm",
            "nx": 49,
            "ny": 124
        }
    ]
}

Github repo

전체 코드가 궁금하다면?
https://github.com/Jeongminyooa/open-api


자신의 목적에 맞게 Open API를 활용한다면 분명 강력한 도구가 될 것 같다. 오늘은 간단하게 통신해와서 반환하는 코드만 작성했지만 필요에 따라 Database에 저장하는 작업도 추가해주어도 된다. (어쩌면 다음 포스팅이 될지도 모르는..)

Open API와 연동하여 통신하는 작업보다 Json parsing 작업이 시간이 더 소요된 것 같다. 그래도 json mapping 하는 여러가지 방법이나 URLConnection 클래스에 대해 정확하게 공부해볼 수 있어서 유익했다.


ref
https://www.codejava.net/java-se/networking/how-to-use-java-urlconnection-and-httpurlconnection
https://tjdans.tistory.com/6
https://thalals.tistory.com/273
https://shinsunyoung.tistory.com/52
https://hianna.tistory.com/631
https://blog.naver.com/PostView.naver?blogId=occidere&logNo=222512549848&redirect=Dlog&widgetTypeCall=true&directAccess=false
https://homoefficio.github.io/2016/11/19/%EC%A1%B0%EA%B8%88%EC%9D%80-%EC%8B%A0%EA%B2%BD%EC%8D%A8%EC%A4%98%EC%95%BC-%ED%95%98%EB%8A%94-Jackson-Custom-Deserialization/
https://velog.io/@yevini118/SpringBoot-Json-Deserialize-%ED%95%98%EA%B8%B0

profile
기록이 주는 즐거움

1개의 댓글

comment-user-thumbnail
2023년 9월 18일

잘 봤습니다. 감사합니다!

답글 달기