Spring

Spring RestTemplate 대용량 json 파싱/처리( GC 피하기 )

구티맨 2022. 4. 7. 23:31

Spring에서 HTTP 요청을 하기 위해 RestTemplate를 많이 사용합니다.

( Spring 5.0부터 RestTemplate대신 WebClient을 사용을 추천하므로 legacy 프로젝트를 하시는 것이 아니라면

WebClient로 개발하시는 것을 추천드립니다. RestTemplate는 5.0부터 유지보수만 이루어집니다. 관련 링크 )

 

RestTemplate의 exchange, getForEntity, postForEntity 등의 API를 이용하여 응답을 ResponseEntity로 한 번에 메모리에 받아옵니다.

일반적인 경우에는 문제가 되지 않지만 응답의 크기가 수백MB이상이 되면 GC가 발생하게 됩니다.

 

저도 프로젝트를 진행하는 와중에, GC가 발생하여 확인해보니 응답 사이즈가 600MB 이상이 되니 HTTP를 호출하는 코드에서 문제가 발생했습니다.

GC를 피하기 위해서는 응답을 한 번에 메모리에 올리는 것이 아닌 Stream 방식으로 적당한 버퍼에 응답을 받아 처리를 해야 합니다.

이렇게 구현을 하기 위해서는 execute 함수를 사용하여 HTTP 요청을 하되, responseExtractor에서 응답을 처리할 로직을 구현하면 됩니다.

<T> T execute(URI url, HttpMethod method, RequestCallback requestCallback, ResponseExtractor<T> responseExtractor)

: Execute the HTTP method to the given URL, preparing the request with the RequestCallback, and reading the response with a ResponseExtractor.

 

아래 예제에서는 responseExtractor에서 응답을 파일에 작성하도록 구현을 하였습니다.

( Files.copy에서는 8k 버퍼에 응답 데이터를 가져와 파일을 작성하기 때문에 GC 걱정은 하지 않으셔도 됩니다. )

responseExtractor의 함수가 종료되면 stream이 닫혀 응답 데이터를 접근이 불가하기 때문에 파일에 작성을 해두고

해당 파일을 일정 버퍼로 읽어 처리를 해주면 됩니다.

ResponseExtractor<Void> responseExtractor = response -> {
      Path path = Paths.get("response.json");
      Files.copy(response.getBody(), path);
      return null;
};

restTemplate.execute("localhost:8680/api/download", HttpMethod.GET, null, responseExtractor);

 

이번에는 responseExtractor 클래스를 별도 구현하여, json을 파싱하여 csv로 변환하는 로직을 구현해보겠습니다.

 

response inputstream을 통해 JsonParser를 만들어 nextToken으로 토큰을 하나씩 읽어가며 처리를 하고 있습니다.

( json의 키가 column인 경우, column 키의 값을 csv형태로 작성하고 있습니다. )

 

( ObjectMapper의 readTree로 응답 파일을 읽어서 json 파싱을 하려고 했더니 readTree에서 응답 파일을 읽어 전체 jsonNode Tree를 생성하여 GC가 발생하니 참조하세요. )

import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.opencsv.CSVWriter;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.web.client.HttpMessageConverterExtractor;

import java.io.FileWriter;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.stream.Stream;

public class ExportCsvExtractor extends HttpMessageConverterExtractor {
    private Path path;

    final String column = "column";

    public ExportCsvExtractor(Class responseType, List list, String filePath) {
        super(responseType, list);
        this.path = Paths.get(filePath);
    }

    @Override
    public Object extractData(ClientHttpResponse clientHttpResponse) throws IOException {
        CSVWriter writer = new CSVWriter(new FileWriter(path.toString()));

        JsonFactory jsonFactory = new JsonFactory();
        JsonParser jsonParser = jsonFactory.createParser(clientHttpResponse.getBody());

        while (jsonParser.nextToken() != JsonToken.END_ARRAY){
            String fieldName = jsonParser.getCurrentName();
            if(column.equals(fieldName)){
                while(jsonParser.nextToken() != JsonToken.END_OBJECT){
                    writer.writeNext(Stream.of(jsonParser.getText()).toArray(String[]::new), false);
                }
            }
        }

        writer.close();
        return null;
    }
}
ResponseExtractor<Void> responseExtractor = new ExportCsvExtractor(String.class, restTemplate.getMessageConverters(), filePath);

restTemplate.execute("localhost:8680/api/download", HttpMethod.GET, null, responseExtractor);