WebClient를 사용한 커리어넷 학교 정보 API가져오기
프로젝트 진행중 프론트 개발자 분이 학교 정보 API를 가져올 수 있는 정보를 달라고 했다.
이에 나는 커리어넷에서 WebClient를 사용하여 학교 정보 API를 가져오기로 했다.
추가적으로 인터넷 글을 보면 RestTemplate가 사라진다는 정보가 많은데 그것은 사실이 아니다.
그러니 해당 정보에 속지 말자 실제로 몇 버전에서 사라진다고 했었는데 이후에 철회를 한 거 같다.
최근 Docs를 보면 공식 페이지에서 @Deprecated 는 내용은 찾아 볼 수 없다.
그럼 어떤게 더 좋을까? WebClient vs RestTempalte 간단히 비교 후 작성 코드를 보겠습니다.
🔥 RestTemplate
Java Servelet API를 활용하고 있기 때문에 기본적으로 하나의 스레드당 하나의 요청방식으로 되어 있으며 이는 클라이언트가 응답을 받을 때까지 스레드는 Block 상태이기에 서버 자원과 응답 시간에 영향을 미친다.
많은 요청이 들어오면 이에 비례하여 스레드가 생성되어 메모리와 스레드 풀에 영향을 미침.
✨ Web Client
Reactive Framework는 Event-Driven 구조를 사용하기 때문에 비동기 로직을 Reactive Stream API를 통해 제공하게 된다.
이는 RestTemplate와 비교하여 적은 양의 스레드와 시스템 자원만으로 많은 요청을 처리할 수 있음을 의미하기 때문에 많은 양의 요청을 병렬로 처리하는 경우 적합하다.
위에와 같은 이유로 적은 양의 스레드와 시스템 자원 많은로 많은 요청을 처리할 수 있는 Web Client를 사용하게 되었다.
환경 변수에 저장한 값이다.
SCHOOL_API_URL=커리어넷URL
SCHOOL_API_KEY=키값
우선 나는 학교 정보는 많이 바뀔 이유가 없어서 3달에 한 번씩 1일날 마다 학교 정보를 밀고 가져와주는 Scheduled를 사용했습니다.
가장 우선적으로 나는 도메인이라는 객체를 만들어서 해당 부분 도메인에서 작업을 해주는 메서드를 만들었다.
😜 getTotalCount
/**
* 학교 정보 총 개수를 api에서 가져와준다.
* @param apiUrl url 정보
* @param apiKey key 정보
* @param gubun_list 학교 구분 리스트
* @return 총 개수 리스트 정보 반환
*/
public static List<String> getTotalCount(String apiUrl, String apiKey, List<String> gubun_list) {
// 학교정보 url 리스트 만들기
List<String> makeUrl_list = new ArrayList<>();
// makeUrl_list에 넣어준다.
for(String gubun: gubun_list) makeUrl_list.add(apiUrl + "?apiKey=" + apiKey + "&svcType=api&svcCode=SCHOOL&contentType=json&gubun=" + gubun);
// convertToList에 넣어준다.
List<String> convertToList = new ArrayList<>();
for(String makeUrl : makeUrl_list) convertToList.add(webClientWrapper.get().uri(makeUrl).retrieve().bodyToMono(String.class).block());
// total_list 정보
ArrayList<String> totalPages = new ArrayList<>();
convertToList.forEach(json -> totalPages.add(SchoolJsonUtil.getTotalCount(json)));
return totalPages;
}
해당 메서드는 totalCount를 JSON에서 가져오기 위하여 작성한 메서드입니다. 일단 api를 호출해야 총 개수를 가지고 올 수 있기에 구현하였습니다.
/**
* JSON 데이터에서 총 개수를 추출해준다.
* @param json JSON 데이터 문자열
* @return 총 개수
*/
public static String getTotalCount(String json){
JSONParser parser = new JSONParser();
JSONObject targetObject = null;
try {
targetObject = (JSONObject) parser.parse(json);
JSONObject dataSearch = (JSONObject) targetObject.get("dataSearch");
JSONArray obj = (JSONArray) dataSearch.get("content");
JSONObject data = (JSONObject) obj.getFirst();
return (String) data.get("totalCount");
} catch (ParseException e) {
throw new RestApiException(CommonExceptionCode.INTERNAL_SERVER_ERROR);
}
}
해당 부분은 Util 코드이며 JSONObject에서 데이터 totalCount라는 데이터를 추출하였습니다.
😜 getSchoolInfoData
/**
* 모든 학교 정보를 가져온다
* @param apiUrl api url 정보
* @param apiKey api key 정보
* @param page 페이지 개수
* @param perPage 페이지당 호출할 데이터
* @param gubun 학교 구분 정보
* @return School 정보 엔티티 리스트
*/
public static List<SchoolEntity> getSchoolInfoData(String apiUrl, String apiKey, int page, int perPage, String gubun) {
final WebClientWrapper webClientWrapper = new WebClientWrapper(WebClient.builder());
String url = apiUrl + "?apiKey=" + apiKey + "&svcType=api&svcCode=SCHOOL&contentType=json&gubun=" + gubun + "&thisPage="+ page + "&perPage=" + perPage;
String schools = webClientWrapper.get().uri(url).retrieve().bodyToMono(String.class).block();
SchoolType schoolType = null;
switch(gubun) {
case "elem_list"-> schoolType = SchoolType.ELEMENTARY_SCHOOL;
case "midd_list"-> schoolType = SchoolType.MIDDLE_SCHOOL;
case "high_list"-> schoolType = SchoolType.HIGH_SCHOOL;
case "univ_list"-> schoolType = SchoolType.UNIVERSITY;
case "seet_list" -> schoolType = SchoolType.SPECIAL_SCHOOL;
}
return getSchoolInfo(schools, schoolType);
}
각각의 기능에 대해서 설명하겠습니다.
String url = apiUrl + "?apiKey=" + apiKey + "&svcType=api&svcCode=SCHOOL&contentType=json&gubun=" + gubun + "&thisPage="+ page + "&perPage=" + perPage;
해당 코드는 데이터를 가져올 url코드를 가공해주는 부분입니다.
String schools = webClientWrapper.get().uri(url).retrieve().bodyToMono(String.class).block();
해당 부분은 webClientWrapper라는 객체를 통해서 WebClient를 사용하여 가져오는 부분입니다.
해당 부분 처럼 작성한 이유는 추후 테스트를 쉽게 하기 위해 작성하였지만.. 사실 사용을 안하였습니다.
🙏 WebClientWrapper
package darkoverload.itzip.feature.school.domain;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
@Component
public class WebClientWrapper implements WebClient {
private final WebClient webClient;
public WebClientWrapper(Builder builder) {
webClient = builder.build();
}
@Override
public RequestHeadersUriSpec<?> get() {
return webClient.get();
}
@Override
public RequestHeadersUriSpec<?> head() {
return webClient.head();
}
@Override
public RequestBodyUriSpec post() {
return webClient.post();
}
@Override
public RequestBodyUriSpec put() {
return webClient.put();
}
@Override
public RequestBodyUriSpec patch() {
return webClient.patch();
}
@Override
public RequestHeadersUriSpec<?> delete() {
return webClient.delete();
}
@Override
public RequestHeadersUriSpec<?> options() {
return webClient.options();
}
@Override
public RequestBodyUriSpec method(HttpMethod method) {
return webClient.method(method);
}
@Override
public Builder mutate() {
return webClient.mutate();
}
}
WebClient코드는 위에와 같이 작성하였습니다.
특별한 부분은 딱히 없습니다. 사실 해당 코드는 지워도 될 거 같습니다. 테스트를 편하게 하기위하여 작성한 코드입니다.
😎 GetSchoolInfo
/**
* 각 구분에 대한 학교 정보 데이터를 가져와준다.
* @param json 학교 정보 JSON 데이터
* @param schoolType 학교 구분 타입
* @return 학교정보 엔티티 리스트
*/
public static List<SchoolEntity> getSchoolInfo(String json, SchoolType schoolType){
JSONParser parser = new JSONParser();
List<SchoolEntity> list = new ArrayList<>();
try {
JSONObject targetObject = (JSONObject) parser.parse(json);
JSONObject dataSearch = (JSONObject) targetObject.get("dataSearch");
JSONArray array = (JSONArray) dataSearch.get("content");
for(int i=0;i<array.size();i++){
JSONObject object = (JSONObject) array.get(i);
String schoolGubun = object.containsKey("schoolGubun") ? (String) object.get("schoolGubun") : "";
String adres = object.containsKey("adres") ? (String) object.get("adres") : "";
String schoolName = object.containsKey("schoolName") ? (String) object.get("schoolName") : "";
String region = object.containsKey("region") ? (String) object.get("region") : "";
String estType = object.containsKey("estType") ? (String) object.get("estType") : "";
String campusName = object.containsKey("campusName") ? (String) object.get("campusName") : "";
switch (schoolName) {
case "공군항공과학고등학교" -> {
estType = "국립";
adres = "경상남도 진주시 금산면 송백로 46(속사리, 공군교육사령부 내)";
}
case "반디기독학교" -> {
estType = "사립";
adres = "부산광역시 해운대구 해운대로 191번길 9 반디기독학교";
}
}
School school = School.builder()
.gubun(schoolGubun)
.address(adres)
.schoolType(schoolType)
.schoolName(schoolName).region(fromRegionName(region))
.estType(fromEstTypeName(estType))
.campusName(campusName)
.build();
log.info("school insert data : {} ", school);
list.add(school.convertToEntity());
}
} catch (ParseException e) {
throw new RestApiException(CommonExceptionCode.INTERNAL_SERVER_ERROR);
}
return list;
}
해당 부분은 마지막에 SchoolEntity로 반환 해주었던 리스트이며 사실 Entity를 사용하는 것은 바람직한 부분은 아니지만 해당 부분은 반환을 하지 않으며 단순히 데이터를 넣어주는 용도로만 Entity가 사용되기에 해당 방향으로 개발을 진행하여도 된다고 생각하여 진행하였습니다.
이건 개인적인 생각이라 의견의 차이가 있을 수 있고 제가 맞는 방법은 아닌거 같기도 합니다.
천천히 읽어보시면서 작성하시면 될 것 같습니다.
😎 connectSchoolAPI
@Override
public void connectSchoolApi() {
// 실제 호출할 url_list가 담겨 있다. 순서는 gubun_list 동일하다.
List<String> totalCountList = School.getTotalCount(apiUrl, apiKey, gubun_list);
Map<String, Integer> calculateMap = calculatePageCount(totalCountList);
for(String gubun:gubun_list) {
int pages = calculateMap.get(gubun);
for(int i=1; i<=pages; i++){
schoolRepository.saveAll(getSchoolInfoData(apiUrl, apiKey, i, 500, gubun));
}
}
}
해당 메서드는 totalCount를 구한 것을 총 몇페이지인지 calculatePageCount메서드를 통해서 맵 자료형으로 받아오며 각각을 구현해주는 부분입니다.
추가적으로 gubun_list는 이런식으로 선언해주었습니다.
private final List<String> gubun_list = List.of("elem_list", "midd_list", "high_list", "univ_list", "seet_list");
😎 calculatePageCount
/**
* 총 페이지 개수를 구해준다
* @param totalCountList 모든 개수 리스트
* @return 학교 데이터 구분에 해당하는 부분을 업데이트 한다.
*/
public Map<String,Integer> calculatePageCount(List<String> totalCountList){
Map<String, Integer> calcualteMap = new HashMap<>();
for(int i=0; i<totalCountList.size(); i++) {
int count = Integer.parseInt(totalCountList.get(i));
int totalPage = count / 500 + 1;
calcualteMap.put(gubun_list.get(i), totalPage);
}
return calcualteMap;
}
해당 함수는 각각의 리스트에 있는 것들을 500개를 기준으로 나누어 구해주었으며 해당 방식으로 해주는 이유는 WebClient가 너무 많은 개수로 api를 호출하면 에러를 뱉어버리기에 해당 방식으로 구현하였습니다.
😎 saveAll
schoolRepository.saveAll(getSchoolInfoData(apiUrl, apiKey, i, 500, gubun));
해당 메서드는 save를 통해서 작성하였다가 수정한 코드입니다.
왜 수정 하였는가?
테스트 코드를 통하여 확인한 결과 5분이라는 결과를 보았습니다. 어? 너무 오래 걸리는데라는 생각이 들어 최적화를 해보자라는 생각을 가지게 되었습니다.
5분이라는 결과가 나오는 이유는 쿼리가 insert 후, select로 반환 해주는 부분이 있었으며 이에 따라 문제가 된다고 판단하였습니다.
이때, bulkInsert를 통하여 해결 할 수 있다는 생각이 들어 bulkInsert로 해당 문제를 해결하였습니다.
하지만, bulkInsert를 하려면 엔티티에 @GenerationType.INDENTITY 를 사용하지 못합니다.
그래서 저는 SEQUENCE를 통해 Id값을 잡아주자고 판단하여 Sequence로 잡아주게 되었습니다.
JDBC Template을 사용하면 해당 부분이 가능하긴 합니다. << 추가적인 TMI
@Id
@SequenceGenerator(
name="SCHOOL_INFOS_SEQ_GENERATOR",
sequenceName = "SCHOOL_SEQ",
initialValue = 1,
allocationSize = 50
)
@GeneratedValue(strategy = GenerationType.SEQUENCE,
generator = "SCHOOL_INFOS_SEQ_GENERATOR")
private Long id;
엔티티에 이런식으로 id값을 넣어주었습니다.
spring:
properties:
hibernate:
jdbc:
batch_size: 500
order_inserts: true
order_updates: true
이런식으로 batch를 통해 한번에 insert할 부분을 작성하였습니다.
이렇게 해서 저장을 한 결과 5분이라는 시간에서 아래와 같은 시간으로 바뀌었습니다.
배치코드
/**
* 학교 정보 배치 스케줄러
* 1,4,7,10월 1일날 배치 작업
*/
@Scheduled(cron = "0 0 0 1 1,4,7,10 ?")
public void schoolInfoInsert(){
log.info("==== 작업완료 ====");
// 테이블에 총 개수 가져온다.
Long count = connectService.getTotalCount();
// 총 개수가 0보다 크면 테이블 정보를 모두 deleteAll
if(count > 0) connectService.deleteAll();
// 학교 정보 api 데이터 실질적으로 저장
connectService.connectSchoolApi();
log.info("==== 작업 종료 ====");
}
배치코드는 따로 설명 할 부분은 없습니다.
다음은 자동완성을 위해 사용한 ELK 세팅으로 찾아오겠습니다. 이만 춍춍 사라지도록 하겠습니다.