배치 프로세스 리팩토링

💡이 글은 현재 리팩토링을 진행하고 있는 전시회 기록 서비스의 배치 작업 프로세스를 개선하며 고민한 내용들을 다루고 있습니다. 불필요하게 존재했던 기존 로직들을 없애고, 다루기 쉽게 변경하여 확장성을 높이기 위해 요구사항부터 구현까지 개선했습니다. 배치의 흐름과 선택한 기술들을 사용하며 발생한 문제들도 함께 다루고 있습니다.

 

 프로젝트를 진행하며 가장 중요했던 전시회 및 전시 공간 데이터의 배치 작업에서 시간에 쫓겨 아쉬운 점이 많이 남았었습니다. 이 프로젝트에서 사용하는 배치 작업을 개선하고 그 내용을 기록해보고자 합니다.

 

 당시 짧은 기간 안에 우선 데이터를 채워넣는 것을 목적으로 구현을 하고 방치해 둔 기억이 있습니다.

 

이 글에서는 배치 작업을 소개하고, 작업 플로우를 변경한 내용을 다룹니다.

성능, 외부 API 관련 그리고 아키텍처 관련 개선은 다음 글에서 다루도록 하겠습니다.

Reader 성능 개선 : 외부 API 

Writer 성능 개선 : Bulk Insert/Update

 

0. 요구 사항

1) 배치 작업 요구 사항

  • 전시회 및 전시 공간 데이터를 외부 API에서 가져와 저장해야 함. 현재 날짜 기준 3개월 전부터 1개월 후까지의 데이터를 저장하나, 가능한 최대의 양을 목표로 함
  • 배치 작업을 위해 아래 순서로 외부 API 호출을 해야 함.
    • 전시회 목록 조회 -> 전시회 상세 조회 -> 전시 공간 상세 조회
  • 외부 API 호출 후 데이터 가공이 필요함.
    • 전시회 및 전시 공간의 제목, 이름이 없는 경우 저장하지 않음.
    • 데이터의 공백 처리된 속성은 Null로 통일, 꺽쇠와 같은 HTML 특수문자는 변환 후 저장해야 함.
  • 기존에 저장되어 있던 진시회 및 전시 공간이 응답되는 경우, 새로 저장하지 않고 업데이트해야 함.
  • 배치를 통해 기존 전시회 정보를 삭제하는 경우는 없음.

 

2) 외부 API 특이 사항

  • API 호출에는 일일 호출 제한 존재하여 하루에 일정 횟수 이상 요청할 수 없음. (현재 1,000건 / 최대 10,000건)
  • XML 형식의 응답.
  • 공백 혹은 제목이 없는 등 사용할 수 없는 정도로 부실한 데이터가 존재함.
  • 전시회 상세 조회 시, 전시 공간 seq가 0인 경우는 전시 공간 정보가 없는 경우임.
  • 기간으로 전시회 목록을 조회하는데, 시작일 기준. ex> 2025/02/10 ~ 2025/02/20이면 시작일이 이 사이에 있어야 함 => 문화 포털 문의 응답에서는 앞의 내용과 같이 설명하는데, 실제 응답은 그렇지 않았음. 전시회 기간 동안 하루라도 겹치면 응답함.

 배치 작업 수행에 필요한 사항들을 토대로 기존 배치 작업 플로우를 살펴보고, 목적 플로우로 개선해보겠습니다.

 

 

1. 기존 작업 프로세스

1) 작업 프로세스

 먼저, 배치 작업의 프로세스를 살펴보겠습니다. 배치 작업을 위해 3가지의 외부 API를 호출합니다.

  • 기간 별 전시회 목록 조회
  • 전시회 상세 조회
  • 전시 공간 상세 조회

 기간 별 전시회 목록은 특정 기간 동안의 전시회 데이터를 페이지 단위로 한 페이지 당 전시회 개수를 파라미터로 요청하여 응답받습니다. 전시회 목록 조회 시 해당 기간에 존재하는 전시회의 총 개수를 응답받습니다. 이를 통해 페이지 개수를 구하고, 페이지 수만큼 loop를 돌며 기간 내 모든 전시회 및 전시 공간의 상세 정보를 조회합니다. 1페이지 당 최대 100개의 전시회를 조회할 수 있습니다.

 

 전시회 목록에는 각 전시회의 고유 번호인 seq가 포함되며, seq로 전시회 상세 정보를 조회합니다. 페이지 수 * 페이지 당 전시회 개수만큼 API를 호출합니다. 전시회 상세 조회 시 해당 전시회가 열리는 전시 공간의 고유 번호인 seq가 포함된 응답을 받습니다.

 

 마찬가지로 seq로 전시 공간 상세 정보를 조회합니다. 상세 조회되는 전시회 개수만큼 API를 호출합니다. 하나의 전시 공간에 여러 전시회가 열릴 수 있습니다.

 

 외부 API로 호출한 전시회 및 전시 공간 데이터는 XML로 응답되며, 이를 JSON으로 바꿔준 후에 전처리와 Update/Insert를 수행합니다.

 

2) 전체 플로우

[현재 플로우]

 외부 API 호출로 받은 전시회 목록 페이지 단위로 빨간 박스의 작업을 처리합니다. 한 페이지에 여러 전시회가 있기 때문에 그 수만큼 파란 박스의 작업을 처리하며, 마지막으로 초록 박스의 작업을 1회 수행합니다.

 간단하게 다음과 같이 정리해볼 수 있습니다.

 

Read : Open API

1) 전시회 목록 조회 API 호출

2) 각 전시회 상세 조회 API 호출

3) 각 전시회 별 전시 공간 상세 조회 API 호출

 

Process & Write : 조건에 맞게 데이터 삽입

4) 전시 공간 이름, 주소 체크 후 Insert

- 둘 중 하나라도 비어 있는 경우 모든 컬럼이 Null인 채로 Insert

5) 전시회 가격, 전시 상태 컬럼 결정 후 Insert

6) 전시회 및 전시 공간 데이터 업데이트

- 이름이 비어 있는 전시회 삭제, HTML 특수 문자 변환 ("&lt"에서 "<"로), 공백 컬럼 Null로 통일

- 전시 공간 전화번호가 여러 개인 경우 처음 하나만 저장, 공백 컬럼 Null로 통일

 

 이렇게 플로우를 정리하고 나니 몇 가지 문제가 보였습니다.

 우선, 작업 흐름 상 순서가 잘못된, 중복된, 불필요한 작업들이 있습니다. 외부 API를 호출하고 받은 데이터를 처리하는 데 특히 문제가 있습니다. 가공을 따로 한다는 점, 삽입하고 전체 수정한다는 점이 가장 큰 문제였습니다.

 

  코드 정리 역시 필요했습니다. 코드가 읽기 힘들 정도로 모여있어, 함수와 클래스를 분리하여 가독성을 높이고 추가 사항을 고려할 때 문제가 없도록 재구성을 하기로 했습니다.

 

 

2. 작업 프로세스 개선

문제 1. 불필요한 작업, 뒤엉킨 작업 순서

개선 전 플로우는 문제들이 몇 가지 있습니다.

  • 데이터 가공 작업이 불필요하게 2회로 나누어져 있음.
  • 새로운 데이터를 삽입한 후 DB 내 전체 데이터를 조회하여 2차 가공을 진행함.
  • 중복 데이터를 업데이트 하지 않고 새로 삽입함.
    • 리뷰와 별점, 개인 캘린더와 같이 기록을 위한 서비스이기 때문에 기존 정보를 활용할 수 있어야 했습니다. 따라서 이미 저장되어 있는 데이터는 업데이트 하도록 변경했습니다.

위 문제들을 해결하기 위해 배치 작업의 명확한 정의를 위해 단계별로 나눈 목적 플로우를 작성했습니다.

 

[목적 플로우]

 Spring Batch에서 아이디어를 얻어, 작업을 Read, Process, Write(update/ insert)로 세분화하여 흐름을 정리했습니다.

 

Read : Open API

1) 전시회 목록 조회 API 호출

2) 각 전시회 상세 조회 API 호출

3) 각 전시회 별 전시 공간 상세 조회 API 호출

 

Process : 데이터 가공

4) 전시회 데이터 가공

5) 전시 공간 데이터 가공

 

Write : 데이터 Update

6) 2에서 불러온 전시회 데이터가 DB에 있는지 확인, 존재하면 Update

7) 3에서 불러온 전시 공간 데이터가 DB에 있는지 확인, 존재하면 Update

 

Write : 데이터 삽입

8) 6에서 Update하지 않은 전시회 데이터 Insert

9) 7에서 Update하지 않은 전시 공간 데이터 Insert

 

 우선, 외부 API 호출과 데이터 가공 및 저장을 분리했습니다.

 작업은 전시회 및 전시 공간 데이터를 한 번에 모아서 처리하는 방법한 페이지씩 끊어서 처리하는 방식이 있습니다. 후자는 청크 지향 처리라고 볼 수 있는데, 현재는 흐름 개선을 우선으로 하고 있기 때문에 비교적 간결하게 진행될 수 있도록 한 번에 모아서 처리하는 방식으로 선택했습니다.

 

아래 코드처럼 step() 함수에서 Reader, Processor, Writer의 함수를 호출하여 위 플로우처럼 진행되도록 변경했습니다.

    public void step() throws IOException {
        List<Integer> performanceSeqList = batchReader.readExhibitionList();
        List<ExhibitionDetailDTO.ExhibitionDetailResponseDTO.ExhibitionDetailMsgBodyDTO.PerformanceInfo> performanceInfoList = batchReader.readExhibitionDetail(performanceSeqList);        
        List<PlaceDetailDTO.PlaceDetailResponseDTO.PlaceDetailMsgBodyDTO.PlaceInfo> placeInfoList = batchReader.readPlaceDetail(performanceInfoList);
        
        List<PlaceDtoToWrite> placeDtoListToWrite = batchProcessor.processPlace(placeInfoList);               
        List<ExhibitionDtoToWrite> exhibitionDtoListToWrite = batchProcessor.processExhibition(performanceInfoList);
              
        batchWriter.writePlace(placeDtoListToWrite);                
        batchWriter.writeExhibition(exhibitionDtoListToWrite);        
    }

 

BatchReader는 외부 API를 호출합니다.

  1. 전시회 전체 목록을 조회하여 모든 전시회 번호(seq)를 담은 List를 반환.
  2. 각 전시회 번호로 전시회 상세 정보를 조회하고, DTO에 담아 List를 반환.
  3. 각 전시회의 전시 공간 번호로 전시 공간 상세 정보를 조회하고, DTO에 담아 List를 반환.

 일일 호출 제한이 전시회 1000건으로 제한되므로, 최대 1페이지 당 100개의 전시회를 조회하여 10회의 리스트 조회, 1000회의 전시회 상세 조회, 1000회의 전시회 상세 공간 조회 API를 호출합니다.

 

BatchProcessor는 호출된 데이터 가공 작업을 수행합니다.

 전시회 제목과 같이 반드시 필요한 속성이 비어 있는 경우 저장하지 않기 위한 작업과 가격 키워드 및 운영 정보 키워드를 DB 컬럼과 맞추기 위한 작업을 함. 이 과정에서는 외부 API와 DB에 접근하지 않음.

 

BatchWriter는 DB에 데이터를 Update/Insert하는 작업을 수행합니다.

  1. Place와 Exhibition은 OneToMany 관계이므로, Place 먼저 Write 작업을 진행.
  2. Seq(고유 번호)로 DB에서 조회하여 이미 저장되어 있는 데이터인지 확인.
  3. 이미 있는 데이터는 Update 하고 없는 데이터는 Insert 함.

 이렇게 작업 플로우를 구성하게 되면 한 번에 모두 호출하고, 가공, write를 하게 됩니다. 한 번에 작업을 모두 하는만큼 속도는 더 빨라지지만, 일시적으로 메모리에 데이터가 많이 올라가게 됩니다. 이 프로젝트의 배치 데이터는 일일 제한이 있고, 그 수가 문제가 될만큼 많지 않아 일괄 처리 방식으로 진행한 후 성능 개선 시 여러 케이스를 다뤄보기로 했습니다.

 

 이제 코드를 읽고 변경하는 데 부담이 덜해졌고, 중복되는 작업 수행이 줄어들었습니다. 이렇게 흐름을 개선한 것만으로도 속도가 조금이지만 빨라졌습니다.

 

문제 2. 읽기 힘든 코드

  기존 코드는 Save, Modify, Add로 구성되어 있습니다. Save는 초기 데이터 세팅, Add는 매일 데이터 갱신을 위한 새로운 데이터 삽입, Modify는 꺽쇠, 공백 등 포맷 변경을 위한 코드를 담고 있습니다.

 Save(혹은 Add)로 데이터를 1차 가공 및 삽입하고 Modify로 2차 가공 후 수정합니다. 

  • 외부 API 호출 코드가 작업 코드와 함께 존재하며, 중복하여 존재.
  • Save와 Add가 동일한 작업을 무의미하게 나누어 가지고 있음.
  • 읽기, 처리, 쓰기 등 여러 작업이 하나의 함수에 몰려 있음.

 외부 API를 호출하기 위한 긴 코드들은 Caller 클래스를 만들어 분리하고, 요청 생성과 응답 변환 등은 Util 클래스로 분리했습니다.

// 외부 API 호출 클래스
public class OpenApiCaller {
	// 전시회 목록 조회 API 호출
    public String callExhibitionListApi(String from, String to, Integer cPage, Integer rows) throws IOException {
        StringBuilder urlBuilder = new StringBuilder(CALL_EXHIBITION_LIST_URL);
        urlBuilder.append("?" + URLEncoder.encode(SERVICE_KEY_PARAMETER_NAME, ENC) + SERVICE_KEY_1);
        urlBuilder.append("&" + URLEncoder.encode(FROM_PARAMETER_NAME, ENC) + "=" + URLEncoder.encode(from, ENC));
        urlBuilder.append("&" + URLEncoder.encode(TO_PARAMETER_NAME, ENC) + "=" + URLEncoder.encode(to, ENC));
        urlBuilder.append("&" + URLEncoder.encode(C_PAGE_PARAMETER_NAME, ENC) + "=" + URLEncoder.encode(cPage.toString(), ENC));
        urlBuilder.append("&" + URLEncoder.encode(ROWS_PARAMETER_NAME, ENC) + "=" + URLEncoder.encode(rows.toString(), ENC));

        HttpURLConnection connection = OpenApiUtil.createHttpURLConnectionGet(urlBuilder);
        StringBuilder stringBuilder = OpenApiUtil.writeResponse(connection);
        connection.disconnect();
        return stringBuilder.toString();
    }
    
    // 상세 조회 API 호출 함수들 ...

// Util 클래스
public class OpenApiUtil {

    public static HttpURLConnection createHttpURLConnectionGet(StringBuilder urlBuilder) throws IOException {
        URL url = new URL(urlBuilder.toString());
        HttpURLConnection connection = (HttpURLConnection) url.openConnection();
        connection.setRequestMethod("GET");
        connection.setRequestProperty("Content-type", "application/json");
        return connection;
    }

    public static StringBuilder writeResponse(HttpURLConnection connection) throws IOException{
        BufferedReader br;
        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();
        return sb;
    }
}

 

 또한, 앞에서 살펴본 것처럼 Reader, Processor, Writer로 작업을 역할에 따라 분리했습니다.

// read 함수
public List<Integer> readExhibitionList() throws IOException {
        String fromDate = LocalDate.now().minusMonths(3).format(formatter);
        String toDate = LocalDate.now().plusMonths(1).format(formatter);
        List<Integer> performanceSeqList = new ArrayList<>();
        Integer page = 1;
        int rows = 100;
        while (true) {
            String exhibitionListXml = openApiCaller.callExhibitionListApi(fromDate, toDate, page, rows);
            String exhibitionListJsonStr = DataTypeTransferUtil.xmlStrToJsonStr(exhibitionListXml);
            ExhibitionListDTO exhibitionList = objectMapper.readValue(exhibitionListJsonStr, ExhibitionListDTO.class);
            // 마지막 페이지 지난 경우
            if (exhibitionList.getResponse().getMsgBody().getPerforList().isEmpty())
                break;
            List<ExhibitionListDTO.ExhibitionListResponseDTO.ExhibitionListMsgBodyDTO.PerformElement> performanceList = exhibitionList.getResponse().getMsgBody().getPerforList();
            for (ExhibitionListDTO.ExhibitionListResponseDTO.ExhibitionListMsgBodyDTO.PerformElement performElement : performanceList) {
                if (!performanceSeqList.contains(performElement.getSeq()))
                    performanceSeqList.add(performElement.getSeq());
            }
            page++;
        }
        return performanceSeqList;
    }

// writer 함수
public void writePlace(List<PlaceDtoToWrite> placeDtoListToWrite) {
        for (PlaceDtoToWrite placeDtoToWrite : placeDtoListToWrite) {
            Optional<Place> existingPlace = placeRepository.findBySeq(placeDtoToWrite.getSeq());
            placeRepository.findBySeq(placeDtoToWrite.getSeq())
                    .ifPresentOrElse(
                            place -> updatePlace(place, placeDtoToWrite),
                            () -> insertPlace(placeDtoToWrite)
                    );
        }
    }

 

 

 

3. 기술 선택

 배치 작업 플로우 변경 후, 현재 방식과 기술 선택에 관한 고민을 통해 더 적합한 기술을 사용하고자 했습니다. 하지만, 추가로 개선할 점들에 의해 사용될 기술이 변경될 수 있기 때문에 처음부터 모든 상황을 고려한 선택을 하기보단 우선 기초적인 구현이 가능한 정도로만 사용하는 것이 좋을 것 같았습니다. 따라서 현재로써 선택할 수 있는 합리적인 기술을 선택하여 적용하고, 필요하다면 그에 맞게 변경하는 방향으로 선택했습니다.

1) XML To JSON 

전시회 및 전시 공간 데이터를 받는 Open API에서는 데이터를 XML 형식으로 응답합니다. XML 데이터를 그대로 사용하기엔 어려움이 있었습니다.

잘 읽히지 않는 XML 데이터 구조

  • 가독성이 좋지 않으며, 익숙하지 않음
  • 배치 처리에 불필요한 데이터가 너무 많음
  • 모든 값이 문자열로, 특정 타입을 사용할 수 없음

위와 같은 이유들로 XML 형식의 데이터를 JSON 형식으로 변환하고, 궁극적으로는 필요한 컬럼만 골라서 저장하고 싶었습니다.

 

문제 1. 동일 태그는 마지막 하나만 파싱하는 문제

같은 문제의 Stack Over Flow 글 

 XML Mapper로 XML을 직접 사용하게 되면 동일 태그에 대해 마지막 값만 저장하는 문제가 발생할 수 있습니다. 전시회 목록 조회 시 페이지 당 요소 개수만큼 전시회가 동일한 태그로 반환됩니다. 반환되는 모든 전시회를 사용해야되므로 XML을 String으로 변환한 후, ObjectMapper로 읽어주었습니다.

    // 외부 API 호출 후 응답(XML)을 String으로 변환
    public static StringBuilder writeResponse(HttpURLConnection connection) throws IOException{
        BufferedReader br;
        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();
        return sb;
    }
    
    // String으로 변환된 XML을 JSONObject로 변환
    public static String xmlStrToJsonStr(String xml) {
        JSONObject jsonObject = XML.toJSONObject(xml);
        return jsonObject.toString();
    }

 

문제 2. 복잡한 구조의 JSON 데이터 역직렬화

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<response>
    <comMsgHeader>
        <RequestMsgID></RequestMsgID>
        <ResponseTime>2025-03-05 21:11:28.1128</ResponseTime>
        <ResponseMsgID></ResponseMsgID>
        <SuccessYN>Y</SuccessYN>
        <ReturnCode>00</ReturnCode>
        <ErrMsg>NORMAL SERVICE.</ErrMsg>
    </comMsgHeader>
    <msgBody>
        <seq>230936</seq>
        <perforInfo>
            <seq>230936</seq>
            <title>봄을 기다리는 사람들</title>
            <startDate>20210623</startDate>
            <endDate>20231231</endDate>
            <place>양구군립 박수근미술관</place>
            <realmName>미술</realmName>
            <area>강원</area>
            <subTitle></subTitle>
            <price>성인 6,000원 / 학생(7세 이상 만 18세 이하) 3,000원 / 양구군민, 관내군용사, 호수문화관광권역, 접경지역시장군수협의회주민, 병역명문가 및 가족 50% 할인 / 다자녀가정(19세미만 자녀2인이상) 30% 할인</price>
            <contents1></contents1>
            <contents2></contents2>
            <url></url>
            <phone>033-480-7226</phone>
            <imgUrl>http://www.culture.go.kr/upload/rdf/22/11/show_2022111116294121574.PNG</imgUrl>
            <gpsX>127.98169771414179</gpsX>
            <gpsY>38.0963564079577</gpsY>
            <placeUrl>http://www.parksookeun.or.kr/</placeUrl>
            <placeAddr>강원특별자치도 양구군 양구읍 박수근로 265-15 박수근 미술관</placeAddr>
            <placeSeq>2838</placeSeq>
        </perforInfo>
    </msgBody>
</response>

 위처럼 응답되는 XML 데이터를 JSON으로 변환했으나, 복잡한 계층 구조를 가지고 있었고, 사용할 데이터를 골라낼 필요가 있었습니다.

 

 JSON 데이터 역직렬화를 위해 Object Mapper를 사용했고, 계층 구조와 데이터 선별을 위해 DTO 클래스를 만들어 데이터를 담았습니다. 

# Object Mapper를 사용하여 DTO 클래스 타입으로 저장
{
String exhibitionDetailXml = openApiCaller.callExhibitionDetailApi(exhibitionSeq);
String exhibitionDetailJsonStr = DataTypeTransferUtil.xmlStrToJsonStr(exhibitionDetailXml);
ExhibitionDetailDTO exhibitionDetail = objectMapper.readValue(exhibitionDetailJsonStr, ExhibitionDetailDTO.class);
}
# ...

@ToString
@Getter
@NoArgsConstructor
public class ExhibitionDetailDTO {
    @Nullable
    private ExhibitionDetailResponseDTO response;

    @Getter
    @NoArgsConstructor
    public static class ExhibitionDetailResponseDTO {
        @Nullable
        private ExhibitionDetailMsgBodyDTO msgBody;

        @Getter
        @NoArgsConstructor
        public static class ExhibitionDetailMsgBodyDTO {
            private Integer seq;
            @Nullable
            private PerformanceInfo perforInfo;

            @Getter
            @Setter
            @NoArgsConstructor
            public static class PerformanceInfo {
                private Integer seq;
                private String title;
                private String startDate;
                private String endDate;
                private String price;
                private Integer placeSeq;
                private String imgUrl;
            }
        }
    }
}

수월한 클래스 관리를 위해 내부 클래스로 계층 구조를 구성했습니다.

 

문제 3. 전시회 목록에 단일 컬럼이 들어오는 경우

 페이지 당 전시회 목록 조회 시 마지막 페이지에 전시회가 하나만 있는 경우가 있습니다. 예를 들면 페이지 당 5개씩 조회하는데 총 전시회 개수가 6개인 경우 2페이지에서는 전시회가 1개 조회됩니다. 이 경우 Object Mapper의 readValue 함수 사용 시 에러가 발생했습니다.

 

 위에서 DTO로 파싱하는 과정을 살펴봤는데, 전시회 목록의 경우 List 타입으로 전시회 목록을 파싱했습니다. 하지만, 단일 컬럼의 경우 List 아닌 단일 Object로 제공되어 타입 불일치로 에러가 발생한 것입니다. 따라서 단일 컬럼의 경우에도 배열로 읽을 수 있도록 아래와 같이 설정을 추가해주었습니다.

	@Getter
        @NoArgsConstructor
        public static class ExhibitionListMsgBodyDTO {
            private Integer totalCount;
            private Integer cPage;
            private Integer rows;
            @Nullable
            private List<PerformElement> perforList = new ArrayList<>();

            @Getter
            @NoArgsConstructor
            public static class PerformElement {
                private Integer seq;
            }
        }
    @Bean
    public ObjectMapper objectMapper() {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        // 추가
        objectMapper.enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY);
        return objectMapper;
    }

 

2) 외부 API 호출 방식

 제가 선택한 방식은 HttpURLConnection입니다. 플로우 개선을 우선으로 했기 때문에 더 많은 요소들을 고려하고 선택하기보단 지금은 문제없이 작동을 확인할 수 있는 정도의 방식으로 선택하고, 필요할 때 변경하기로 했습니다.

 

 외부 API 호출 횟수가 정해져 있고(그리고 많지 않음), 매일 호출 시 이 횟수에 대해 큰 변동이 없기 때문에 HttpURLConnection의 기본적인 기능만 있어도 충분했습니다.

 또한, 흐름 개선 - 구조 정리 - 성능 개선 순서로 진행할 예정이기에, 멀티 스레드 및 병렬 처리와 같이 외부 API 호출에서도 변화가 생기게 된다면 그때 다른 방식 도입을 고려하기로 했습니다.

 

요청을 만들어서 외부 API를 호출하기 위해 대표적으로 아래 4가지 방법이 있었습니다. 이 글에서는 알아본 내용을 간단히 정리하고 넘어가겠습니다.

 

1. RestTemplate

  • Spring에서 외부 API 호출을 위해 가장 많이 사용되던 동기(Blocking) 방식의 HTTP 클라이언트
  • RestTemplate은 간단한 요청을 보내기 편리하지만, 비동기 지원 부족확장성이 떨어진다는 평가
  • Spring 5 이후부터 WebClient를 권장

2. WebClient

  • Spring 5부터 등장한 비동기(Non-blocking) HTTP 클라이언트
  • RestTemplate보다 유연하고, Reactor 기반 비동기 처리를 지원함
  • Flux, Mono (Reactive Streams)와 함께 사용할 수 있어, 성능 최적화가 가능

3. HttpURLConnection

  • Java에서 기본 제공하는 HTTP 요청 방식 (java.net.HttpURLConnection)
  • 별도의 라이브러리 없이 사용할 수 있지만, 사용법이 불편하고 확장성이 낮음

4. Apache HttpClient

  • RestTemplate 내부에서도 사용되는 성능 최적화된 HTTP 클라이언트
  • 커넥션 풀 지원, 비동기 처리 가능, 다양한 HTTP 요청 커스터마이징 가능

5. Open Feign

  • 선언형으로, 인터페이스 기반 REST API 호출 가능
  • @FeignClient로 선언하여 쉽게 사용 가능 

 

3) 배치 수행 방법

 매일 특정 시간에 배치 작업을 수행하기 위해 @Scheduled를 사용하여 배치 작업을 실행시켰습니다.

배치 수행 방법 역시 다양한데, 대표적으로 아래 3가지 방법을 찾아볼 수 있었습니다.

 

1. @Scheduled

2. Spring Batch

3. Quertz

 

 현재 작업은 사용자가 호출하는 것이 아닌, 우리 서버가 외부 API를 호출하여 저장/업데이트 합니다. 정산과 같이 DB에서 대량의 데이터를 Read하고 집계 등 여러 작업을 거치는 것이 아닌 간단한 작업이며, 데이터의 양이 많지 않기 때문에 기존에 사용하던 @Scheduled로 주기적 배치 작업을 수행하기로 했습니다.

 

 페이지 당 처리, 병렬 처리와 같이 쪼갤 필요가 생기고, 요청 실패 시 재시도 등 추가로 생각할 점들이 많습니다. 이 경우에는 @Scheduled보다 더 효과적인 방안이 많은데, 이는 개선과 함께 추가로 고려하도록 하겠습니다. 우선 현재 배치 작업은 청크 지향보다 한 번에 처리하는 방향이기 때문에, 특정 시간에 호출되는 정도의 기능만으로 충분하므로 @Scheduled를 선택했습니다.

 

'Project' 카테고리의 다른 글

Bulk 연산으로 Write 작업 개선하기  (0) 2025.03.13
외부 API 호출 시 고려할 것  (0) 2025.03.10
Swagger 코드 분리, Http Status  (0) 2025.02.28
효과적인 파일 업로드 방법  (0) 2025.01.19
파일 업로드 로직 변경  (0) 2024.11.20