본문 바로가기
Java & IntelliJ

[Java] WebClient + Reflection 기능 사용해 공공데이터 API 로 정보 받는 공통 모듈 만들기

by 개발자만타 2024. 2. 26.

상황

SSAFY 1학기 최종 프로젝트에서, 다양한 공공데이터를 받아와 DB에 저장해 놓고 사용자에게 정보를 전달하는 기능을 개발해야 했습니다. WebClient 를 활용해 공공데이터를 받아오기로 했지만, API 를 받아오고 처리하는 부분이 너무 중복되기에 이를 개선하고자 했습니다.

 

저는 이전에 JDBC 코드를 공통적으로 분리하여, DTO 에 매핑하는 것까지 Reflection 으로 자동화해 본 경험이 있었기에, WebClient 를 사용하는 부분도 이와 비슷한 방법으로 코드 공통화 처리가 가능할 것이라고 생각했습니다.

 

문제 해결 1. 데이터마다 받아와야 하는 DTO 형식이 모두 다른데, 어떻게 해결할 것인가?

 

저는 여기서 Java Generic 기능을 활용하기로 했습니다. Generic 을 사용할 수 있도록 Method 선언부에 관련된 작업을 처리해 주고, 어떤 Class Type 인지의 정보를 매개 변수에 입력받는다면 동적으로 Return Type 을 결정할 수 있을 것이라고 생각했습니다.

 

아래는 예시 코드입니다.

 

Code

public <T> List<T> getDtoAsList(String apiUrl, T objectMapper){

    // code 가 들어가는 자리
}

 

문제 해결 2. Header, Body 등 다양한 정보를 넣어주어야 하며, WebClient 는 Method Chaining 이 들어가야 하는데, 이를 어떻게 처리할 것인지?

 

Header 가 들어가는 자리가 필요하다면, Method Chaining 도중 중간에 Chaining 을 끊어버리고, Header 관련 내용을 집어넣은 이후 마저 Chaining 을 이어가면 될 것이라고 생각했습니다.

 

그리고, 대부분의 공공데이터가 GET 임을 감안해, Header 에 들어갈 정보만 생각하면 된다고 생각했습니다. 나머지 정보들은 Query String, Path Variable 등 apiUrl 에 미리 정보를 입력해 주고, Method 를 사용하면 되겠다고 생각했습니다.

 

아래는 예시 코드입니다.

 

Code

public <T> List<T> getDtoAsList(String apiUrl, Map<String, String> headers, T objectMapper){

    List<T> resultList = new ArrayList<>();
    WebClient webClient = WebClient.create();

    WebClient.RequestHeadersSpec<?> webClientProcess = webClient.get()
                .uri(apiUrl);

    if(headers != null){
        for(String key : headers.keySet()){
                webClientProcess = webClientProcess.header(key, headers.get(key));
         }
    }

    String result = webClientProcess.retrieve()
           .bodyToMono(String.class)
           .block();

    // .. code
}

문제 해결 3 : 그렇게 정보를 받아왔다고 하면, 어떻게 모두 다른 객체를 가져와 세팅할 것인지?

 

저는 이 부분에서 Java 의 Reflection 기능을 사용했습니다. 대표적으로 Springboot 가 Reflection 기능을 사용해 개발자들이 사용할 수 있는 많은 기능들을 제공하는 것으로 알고 있습니다.

 

저는, 결과를 세팅해 주는 객체를 하나 새로 만들고, Springboot 위에서 사용할 것이다 보니 @Component Annotation 을 통해 Bean 등록을 진행해 주었습니다.

 

그리고 DTO 객체를 하나 새로 만들고, Lombok 의 setter 를 통해 값을 세팅할 것이므로 method 명에 규칙성이 있을 것이라고 쉽게 생각할 수 있습니다. 게다가 data 의 모든 field 에 모두 setter 를 사용하면 되므로, 반복문으로 field 를 받아 처리하면 됩니다.

 

막상, invoke method 를 사용하려고 보니, IllegalArgumentException, IllegalAccessException, InvocationTargetException 을 throw 하는 method 이기 때문에 관련 처리가 필요했습니다.

그래서, try~catch 문으로 감싸주는 것으로 처리를 완료했습니다.

 

문제를 해결한 Code 는 아래와 같습니다.

 

Code

package com.ssafy.enjoytrip.utils;

import org.json.JSONObject;
import org.springframework.stereotype.Component;

import java.lang.reflect.Field;
import java.lang.reflect.Method;

@Component
public class ResultSetter {

    private final StringBuilder builder = new StringBuilder();
    public void callSetterMethod(Object object, JSONObject jsonObject){
        try{
            Field[] fields = object.getClass().getDeclaredFields();

            for(Field field : fields){
                String fieldName = field.getName();

                if(jsonObject.has(fieldName)){
                    Method setter = object.getClass().getMethod("set" + camelCaseToPascalCase(fieldName), field.getType());
                    setter.invoke(object, jsonObject.get(fieldName));
                }
            }
        }catch(Exception e){
            e.printStackTrace();
        }
    }

    private String camelCaseToPascalCase(String input){
        builder.delete(0, builder.length());

        builder.append(Character.toUpperCase(input.charAt(0)));
        builder.append(input.substring(1));
        return builder.toString();
    }
}

문제 해결 4 : 공공데이터에서 핵심이 되는 정보가 어디 들어 있는지 모두 다르다. 

이 부분은, 매개변수로 어떤 route 를 따라 JSONObject 를 받아와야 하는지를 array 로 입력해 주고, 최종적으로 접근해야 하는 JSON 객체 리스트 부분을 따로 받아 처리할 수 있었습니다. 

 

Code

 

public <T> List<T> getDtoAsList(String apiUrl, Map<String, String> headers, T objectMapper){

    List<T> resultList = new ArrayList<>();
    WebClient webClient = WebClient.create();

    WebClient.RequestHeadersSpec<?> webClientProcess = webClient.get()
                .uri(apiUrl);

    if(headers != null){
        for(String key : headers.keySet()){
                webClientProcess = webClientProcess.header(key, headers.get(key));
         }
    }

    String result = webClientProcess.retrieve()
           .bodyToMono(String.class)
           .block();

    JSONObject jsonObject = getJsonObject(new JSONObject(result), jsonFindPath);
    JSONArray jsonArray = (JSONArray)jsonObject.get(arrayPath);
    
    // .. code
}

private JSONObject getJsonObject(JSONObject jsonObject, String[] keyArray){
	if(keyArray != null){
		for(String key : keyArray){
			jsonObject = (JSONObject)jsonObject.get(key);
        	}
	}

	return jsonObject;
}

 

 

문제 해결 5 : 어떤 공공데이터 API 는 XML, 다른 공공데이터 API 는 JSON 으로 받는데, 공통 코드로 처리할 방안은 없을까?

 

이미 JSON 기준으로 생각하여 전체적인 코드를 작성하고 있었고, 어지간한 API 는 JSON 으로 줄 것이라고 간주했었기에 모든 코드를 JSON 기준으로 작성하고 있었습니다. 하지만, 생각보다 XML 로 데이터를 반환하는 공공데이터 API 가 상당히 많았습니다.

 

처음에는 어쩔 수 없이 공통화를 포기하고 XML 을 기준으로 코드를 한 번 더 적어야겠다고 생각했습니다. 하지만, XML 을 JSON 으로 바꿔주는 라이브러리가 있다는 것을 확인했습니다.

 

org.json 라이브러리에 XML 을 JSON 으로 바꿔 주는 라이브러리가 존재한다는 것을 확인했고, XML 을 JSON 으로 바꾸는 코드만 본 코드에 삽입하여 활용한다면 되겠다고 생각했습니다.

 

그래서 Functional Interface 의 Function 객체 를 활용해 JSON 을 JSON 으로 바꾸는 코드, 그리고 XML 을 JSON 으로 바꾸는 기능을 매개변수로 삽입해 최종적으로 반복되는 코드를 모두 제거할 수 있었습니다. 

 

이 부분이 문제 해결 Part 의 마지막이므로, 결과 Code 에 모두 해당 변경사항을 보여 드립니다. 

 

결과

1. API 정보를 받아와, 원하는 Data Type 의 리스트를 반환하는 Code

  • Open API 의 결과가 JSON 타입인지, XML 타입인지에 따라 호출할 Method 가 달라집니다.
package com.ssafy.enjoytrip.utils;

import groovy.util.logging.Slf4j;
import lombok.RequiredArgsConstructor;
import org.json.*;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;

import java.lang.reflect.Constructor;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.Function;

@Slf4j
@Component
@RequiredArgsConstructor
public class ApiInfoGetter {

    private final ResultSetter resultSetter;

    public <T> List<T> getDtoListFromJSON(String apiUrl, Map<String, String> headers, T objectMapper, String[] jsonFindPath, String arrayPath){
        return getDtoAsList(apiUrl, headers, objectMapper, jsonFindPath, arrayPath, JSONObject::new);
    }

    public <T> List<T> getDtoListFromXML(String apiUrl, Map<String, String> headers, T objectMapper, String[] jsonFindPath, String arrayPath){
        return getDtoAsList(apiUrl, headers, objectMapper, jsonFindPath, arrayPath, XML::toJSONObject);
    }

    public <T> List<T> getDtoAsList(String apiUrl, Map<String, String> headers, T objectMapper,
                                    String[] jsonFindPath, String arrayPath, Function<String, JSONObject> converter){
        List<T> resultList = new ArrayList<>();
        try{
            WebClient webClient = WebClient.create();

            WebClient.RequestHeadersSpec<?> webClientProcess = webClient.get()
                    .uri(apiUrl);

            if(headers != null){
                for(String key : headers.keySet()){
                    webClientProcess = webClientProcess.header(key, headers.get(key));
                }
            }

            String result = webClientProcess.retrieve()
                    .bodyToMono(String.class)
                    .block();

            JSONObject jsonObject = getJsonObject(converter.apply(result), jsonFindPath);
            JSONArray jsonArray = (JSONArray)jsonObject.get(arrayPath);

            for(int index = 0; index < jsonArray.length(); index++){
                Constructor<?> constructor = objectMapper.getClass().getConstructor();
                JSONObject oneJsonObject = (JSONObject)jsonArray.get(index);

                T instance = (T) constructor.newInstance();
                resultSetter.callSetterMethod(instance, oneJsonObject);
                resultList.add(instance);
            }
        }catch(Exception e){
            e.printStackTrace();
            throw new RuntimeException(e.getMessage());
        }

        return resultList;
    }
    private JSONObject getJsonObject(JSONObject jsonObject, String[] keyArray){
        if(keyArray != null){
            for(String key : keyArray){
                jsonObject = (JSONObject)jsonObject.get(key);
            }
        }

        return jsonObject;
    }
}

 

 

2. 위의 Code 에서 사용된 ResultSetter Code

package com.ssafy.enjoytrip.utils;

import org.json.JSONObject;
import org.springframework.stereotype.Component;

import java.lang.reflect.Field;
import java.lang.reflect.Method;

@Component
public class ResultSetter {

    private final StringBuilder builder = new StringBuilder();
    public void callSetterMethod(Object object, JSONObject jsonObject){
        try{
            Field[] fields = object.getClass().getDeclaredFields();

            for(Field field : fields){
                String fieldName = field.getName();

                if(jsonObject.has(fieldName)){
                    Method setter = object.getClass().getMethod("set" + camelCaseToPascalCase(fieldName), field.getType());
                    setter.invoke(object, jsonObject.get(fieldName));
                }
            }
        }catch(Exception e){
            e.printStackTrace();
        }
    }

    private String camelCaseToPascalCase(String input){
        builder.delete(0, builder.length());

        builder.append(Character.toUpperCase(input.charAt(0)));
        builder.append(input.substring(1));
        return builder.toString();
    }
}

 

 

3. 사용 예시(Service Code)

package com.ssafy.enjoytrip.service;

import com.ssafy.enjoytrip.dto.AreaCode;
import com.ssafy.enjoytrip.dto.TourInfo;
import com.ssafy.enjoytrip.dto.TourPhoto;
import com.ssafy.enjoytrip.type.urlBuilder.BasicTourUrlBuilder;
import com.ssafy.enjoytrip.type.urlBuilder.TourPhotoUrlBuilder;
import com.ssafy.enjoytrip.utils.ApiInfoGetter;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
@RequiredArgsConstructor
public class TourInfoService {

    private final BasicTourUrlBuilder tourUrlBuilder;
    private final TourPhotoUrlBuilder photoUrlBuilder;
    private final ApiInfoGetter apiInfoGetter;

    private String[] jsonFindPath = new String[]{"response", "body", "items"};
    private String arrayPath = "item";

    public List<AreaCode> getAreaInfo(){
        String areaApiUrl = tourUrlBuilder.areaCodeApiUrl();
        return apiInfoGetter.getDtoListFromJSON(areaApiUrl, null, new AreaCode(), jsonFindPath, arrayPath);
    }

    public List<TourInfo> getTourInfo(Integer areaCode, Integer contentType, String keyword){
        String tourApiUrl = tourUrlBuilder.infoApiUrl(areaCode, contentType, keyword);
        return apiInfoGetter.getDtoListFromJSON(tourApiUrl, null, new TourInfo(), jsonFindPath, arrayPath);
    }

    public List<TourPhoto> getTourPhoto(String keyword){
        String photoApiUrl = photoUrlBuilder.tourPhotoUrl(keyword);
        return apiInfoGetter.getDtoListFromJSON(photoApiUrl, null, new TourPhoto(), jsonFindPath, arrayPath);
    }
}

 

 

모두 다른 DTO 임에도 불구하고, 성공적으로 Code 가 동작하여 객체를 받아올 수 있음을 확인해 보았습니다. 공통적으로 Code 를 만듬으로써, 중복되는 코드를 최소화 하는 데 성공했습니다.