본문 바로가기
Spring

[파일 수신 API] MultipartFile VS Octet-stream

by 권성호 2022. 2. 9.

사실이 아니라 공부한 내용과 생각을 정리한 글입니다. 언제든 가르침을 주신다면 감사하겠습니다.

 

대용량 파일 수신 서버 개발 업무를 맡아 진행한 내용 중 Spring이 지원하는 MultipartFile의 한계와 이를 극복하기 위해 Octet-stream 기반 파일 수신 API를 개발한 경험에 대해 공유하려 합니다.

 

Spring을 통해 파일 수신 API를 개발하면 가장 많이 사용하는 것이 MultipartFile이라고 생각됩니다. 이 방법은 Spring Controller 앞단에서 content-type이 multipart/form-data인 요청을 파싱 해 임시 파일을 생성 후 MultipartFile 파라미터로 넘겨주기 때문에 파일 수신에 있어서 매우 편리하게 사용할 수 있다는 장점이 있습니다.

대략적인 동작 방식을 도식화하면 아래와 같습니다.

MultipartFile 기반 파일 수신

 

이렇게 임시 파일을 만드는 방식은 몇 가지 한계점이 존재한다고 생각했습니다.

  • 임시 파일을 만드는 IO가 비즈니스 로직과 별개로 무조건 발생한다.
    • 대부분의 경우 임시 파일로 수신된 파일을 뒷단의 비즈니스 로직을 통해 한번 더 원하는 경로에 저장하게 된다.
    • 이는 파일 수신을 위한 IO가 2번 발생하는 것으로 볼 수 있다.
  • 임시 파일에 대한 IO를 애플리케이션 개발자가 재어하기 어렵다.
    • 기본적으로 MultipartFile의 콘셉트 자체가 최초의 multipart/form-data의 요청을 파싱 해 파일을 안정적으로 저장하는 것은 애플리케이션 개발자가 책임지지 않도록 한다는 콘셉트인 것 같다.
  • 임시 파일에 대한 관리가 생각보다 잘 안될 수 있다.
    • multipart/form-data 요청을 수신해 임시 파일을 생성한 워커 스레드가 해당 임시파일의 삭제에 대한 책임까지 지고 있다.
    • @Async와 같은 비동기 방식으로 최초에 임시파일을 생성한 스레드가 아닌 스레드로 비즈니스 로직을 위임한 경우 임시파일에 대한 관리 주체가 애매해진다.(실제로 이 경우 임시파일이 지워지지 않는 것을 확인) 
  • multipart/form-data를 파싱 하는 비용이 발생한다.

따라서 MultipartFile의 도움을 받지 않고 직접 파일을 수신하는 방법을 고민하게 되었고 octet-stream 기반의 파일 수신방법을 선택하게 되었습니다. 

octet-stream 기반 파일 수신

해당 방법의 장점은 아래와 같습니다.(사실상 위에서 언급한 MultipartFile의 단점을 대부분 극복합니다.)

  • 임시 파일을 만드는 IO가 비즈니스 로직과 별개로 발생하지 않는다.
  • 임시 파일에 대한 IO를 어플리케이션 개발자가 재어할 수 있다.
    • 버퍼 사이즈와 같은 부분을 튜닝할 수 있다
  • 임시 파일을 생성하지 않기 때문에 관리 포인트가 감소한다.
  • multipart/form-data를 파싱 하는 비용이 발생하지 않는다.
    • 단순히 request객체에서 inputstream을 꺼내서 읽는 방식

물론 이 방법은 Spring의 지원을 받지 못하기 때문에 파일 수신과 관련한 책임이 모두 애플리케이션 개발자에게 위임된다는 크나큰 단점이 존재합니다. 즉 파일 수신에 대한 안정정을 보장하기 어려울 수 있습니다. (회사에서 개발할 때에는 이 부분을 극복하고자 별도의 파일 수신 프로토콜을 만들었습니다.)

 

해당 글에서는 안정성 보단 성능적인 측면에 초점을 두겠습니다.

 

테스트

제가 분석한 내용이 실제로 유효한지 테스트를 해 봤습니다.

(전체 예제 코드는 [ https://github.com/rnjstjdgh/FileUploadTest ]를 참고해 주세요. ) 

 

우선 일반적으로 사용하는 MultipartFile을 통한 파일 수신 서비스 메서드를 보겠습니다.

public void saveMultipartFile(String fileUuid, MultipartFile mpFiles) throws IOException {
    mpWatch.start();
    Path fileDirectoryPath = Paths.get(ROOT_PATH).toAbsolutePath().normalize();
    Path filePath = fileDirectoryPath.resolve(fileUuid).normalize();
    if(Files.notExists(fileDirectoryPath))
        Files.createDirectories(fileDirectoryPath);

    //file uuid가 고유하기 때문에 사실상 덮어쓸 일이 없음(파일은 수정의 개념이 없고 추가 삭제에 대한 개념만 있음)
    Files.copy(mpFiles.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING);

    mpWatch.stop();
    System.out.println(mpWatch.prettyPrint());
}

fileUuid와 파일의 실제 정보를 담고 있는 mpFiles를 파라미터로 받아 원하는 경로에 저장하는 서비스 메서드입니다.

위에서 언급했던 것처럼, 서비스 로직에 들어왔다는 것은 이미 WAS Level에서 임시파일에 대한 저장이 모두 끝났다는 것을 의미합니다. 코드만 보면 mpFiles로 넘어온 파일을 한 번만 저장하는 것처럼 보이나 실제로는 파일 저장을 2번 한 꼴입니다.

 

다음으로 Octet-stream을 통한 파일 수신 서비스 메서드를 보겠습니다.

public void saveStreamFile(String fileUuid, HttpServletRequest request) throws IOException{
    streamWatch.start();

    Path fileDirectoryPath = Paths.get(ROOT_PATH).toAbsolutePath().normalize();
    Path filePath = fileDirectoryPath.resolve(fileUuid).normalize();
    if(Files.notExists(fileDirectoryPath))
        Files.createDirectories(fileDirectoryPath);

    FileOutputStream outStream = new FileOutputStream(filePath.toFile(), true);
    ServletInputStream inStream = request.getInputStream();   //request로 넘어온 body를 읽기 위해 socket에 대한 open을 한다고 생각
    byte[] buffer = new byte[BUFFER_SIZE];
    int bytesRead = -1;
    while((bytesRead = inStream.read(buffer)) != -1){	//inStream.read=> 클라가 천천히 보내서 서버가 더 빨리 읽을 경우 서버는 기다린다.(time out 설정 값으로 지정한 만큼만 기다린다.)
        outStream.write(buffer, 0, bytesRead);
    }
    outStream.close();

    streamWatch.stop();
    System.out.println(streamWatch.prettyPrint());
}

마찬가지로 fileUuid 정보를 받지만 이번에는 MultipartFile이 아니라 HttpServletRequest를 직접 받고 있습니다. 

내부 코드를 보면 request.getInputStream()을 통해 요청 객체에서 직접 스트림을 꺼내서 원하는 경로에 바로 파일을 저장하는 것을 알 수 있습니다.

파일 수신을 직접 하기 때문에 BUFFER_SIZE를 튜닝할 수 있습니다. 이번 테스트 예제에서는 BUFFER_SIZE를 TCP Socket의 기본 버퍼 크기인 8KB로 잡았습니다.

private static final Integer BUFFER_SIZE = 8000;	//TCP Socket의 기본 버퍼 크기: 8KB

또한 각 서비스 메서드의 시작과 끝에는 stopWatch를 걸어 메소드의 수행 시간을 측정하도록 했습니다.

private final StopWatch mpWatch = new StopWatch("mpWatch");	// MultipartFile 수신 성능 측정
private final StopWatch streamWatch = new StopWatch("streamWatch"); // Octet-stream 수신 성능 측정

 

그러고 나서 postman으로 각 API에 185MB 크기의 파일을 전송해 보았습니다. 그 결과....

StopWatch 'streamWatch': running time = 881329300 ns
---------------------------------------------
ns         %     Task name
---------------------------------------------
881329300  100%  

StopWatch 'mpWatch': running time = 576992800 ns
---------------------------------------------
ns         %     Task name
---------------------------------------------
576992800  100%

오잉? octet-stream 방식이 MultipartFile 방식보다 더 느리게 나왔습니다.(처음에는 예상과 달라 조금 당황했습니다...)

조금 더 생각해 보니 당연한 결과였습니다.

제가 StopWatch를 통해 측정한 시간은 단순히 서비스 메서드가 실행된 시간입니다. 

그리고 이 시간에는 WAS가 앞단에서 임시파일을 수신하는 시간이 제외되어 있습니다. 따라서 octect-stream의 경우 요청 데이터를 직접 수신하는 반면, mpFile의 경우 이미 서버에 올라온 파일을 다른 곳으로 옮기는 작업에 대한 시간만 측정된 것입니다. 

 

파일 수신에 소요되는 총시간을 측정하기 위해 postman의 API 응답 시간 측정 방법을 사용했습니다.

postman의 test 텝에 아래 코드를 넣습니다.

console.log(`mpTest: ${pm.response.responseTime}`)
console.log(`streamTest: ${pm.response.responseTime}`)

그러고 나서 다시 API를 호출하고 응답 시간을 확인해 보면?

streamTest: 1112
mpTest: 2270

octet-stream 방식이 2배 정도 더 빠른 것을 확인할 수 있습니다!

 

 

마치며

octet-stream 방식은 파일 수신에 대한 책임이 개발자에게 위임된다는 단점이 있습니다. 하지만 반대로 말하면, 개발자가 파일 수신을 최적화할 수 있는 기회가 열린다고 할 수도 있습니다.

추후에는 Spring WebFlux를 도입해 비동기 방식으로 파일을 수신할 수 있는 서버를 구성해 보고 싶습니다.(파일 수신은 파일 io 작업이라는 면에서 비 동기 처리가 스레드 풀 방식보다 유리할 것이라고 생각했으나, 파일을 수신하는 스레드는 하나의 파일에 종속되는 것이 오히려 더 성능이 좋을 수 도 있다는 생각도 들어 일단 보류하였습니다.)

 

 

댓글