파일 업로드 기능 만들기

왜 ?

지금 만들고 있는 프로젝트는 온라인 동영상 스트리밍 서비스다. 넷플릭스나 왓챠같이 모바일 앱은 지원하지 않지만 이 프로젝트를 하나씩 완성해나가면 좋은 경험이 될 것 같아서 진행중이다. 아무튼 이 프로젝트에서 가장 기본적으로 되어야 하는 부분이라고 생각했던 것이 동영상 업로드 부분이다.

원하는 수준

  • 대용량 동영상을 업로드 되어야 한다.
  • HTTP

기본적인 구현(HTTP)

멀티파트 형식으로 데이터가 전송될 경우, 해당 데이터를 변환해주는 역할로 MultipartResolver가 필요하다.

commons-fileupload를 사용하던가 스프링에서 기본적으로 구현해놓은 StandardServletMultipartResolver를 사용해야하는데 후자를 선택해서 구현해본다.

  1. 메인 작성

    @SpringBootApplication
    @Slf4j
    public class TestApiApplication {
        public static void main(String[] args) {
            SpringApplication.run(TestApiApplication.class, args);
        }
       
        @Bean
        public CommandLineRunner commandLineRunner(){
            File saveFolder = new File("D:/upload");
            if(!saveFolder.exists()) {
                log.info("D:/upload 폴더 생성 중...");
                saveFolder.mkdir();
                log.info("D:/upload 폴더 생성 완료!");
            }
            return null;
        }
    }
       
    
  2. 컨트롤러 작성

    @Controller
    @Slf4j
    public class FileUploadController {
       
        private String SAVE_PATH = "D:/upload";
       
        @GetMapping("/uploadForm")
        public String getUploadForm() {
            return "uploadForm";
        }
       
       
        @PostMapping(value = "/uploadFile", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
        public String upload(@RequestParam("file") MultipartFile multipartFile) throws IOException {
            log.info("contentType : {}", multipartFile.getContentType());
            log.info("original name : {}", multipartFile.getOriginalFilename());
            log.info("file size : {}", multipartFile.getSize());
       
            File uploadFile = new File(SAVE_PATH + "/" + multipartFile.getOriginalFilename());
            uploadFile.createNewFile();
            try (BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(uploadFile));
                 BufferedInputStream bis = new BufferedInputStream(multipartFile.getInputStream());) {
                byte[] buffer = new byte[4096 * 2];
                int read = 0;
                while ((read = bis.read(buffer)) != -1) {
                    bos.write(buffer,0,buffer.length);
                }
                bos.flush();
            } catch (Exception e) {
                log.info("{}", e);
            }
            return "redirect:/uploadForm";
        }
    }
    
  3. 설정파일

    spring:
      servlet:
        multipart:
          max-file-size: -1
          max-request-size: -1
    
  4. 페이지 작성

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <script src="https://code.jquery.com/jquery-3.4.1.min.js"
                integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo="
                crossorigin="anonymous"></script>
        <title>파일 업로드 페이지</title>
    </head>
    <body>
    <h1>파일을 업로드합니다. </h1>
    <form action="/uploadFile" method="post" enctype="multipart/form-data">
        업로드할 파일 : <input type="file" name="file" id="file" multiple><br>
        <input type="submit" value="등록하기">
    </form>
    <br>
    파일 사이즈 : <span id="fileSize"></span><br>
    파일 확장자 : <span id="fileType"></span>
    <script>
        $(document).ready(function () {
            $('#file').change(function () {
                let file = $('#file').prop('files')[0];
                $('#fileSize').html(Math.floor(file.size/(1024*1024))+'MB');
                $('#fileType').html(file.type);
            });
        });
    </script>
    </body>
    </html>
    
  5. 테스트

    집에 있는 4MB, 700MB, 1.6GB 동영상 하나씩 테스트를 하나씩 업로드하고 위에 코드외에 interceptor를 구현해서 측정해보면 아래와 같은 결과가 나온다.

    4MB -> 1초 미만, 900MB ->10초 , 1.6GB -> 16초

개선하기

위에 테스트를 해보면 알겠지만 페이지에서 요청하는 순간부터 응답을 받기까지 시간이 엄청 길다. 1.6GB의 경우 처리하는데에는 16초이지만, 전체적인 시간을 측정했을 때 1분 20초 정도 나왔던 것 같다. 사람의 심리라는게 요청을 했는데 아무 동작도 없이 계속 기다리다 보니 체감상 더 느리게 느껴진다. 그래서 이러한 부분들을 조금 개선해보고자 페이지에 Progressbar를 추가해보려한다. 추가적으로 파일도 여러개 전송할 수 있도록 수정해보겠다.

Progressbar 추가

   <!DOCTYPE html>
   <html lang="en">
   <head>
       <meta charset="UTF-8">
       <script src="https://code.jquery.com/jquery-3.4.1.min.js"
               integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo="
               crossorigin="anonymous"></script>
       <!-- ##########################추가###############################-->
       <script src="http://malsup.github.com/jquery.form.js"></script>
       <title>파일 업로드 페이지</title>
        <style>
            .progress {
                position: relative;
                width: 400px;
                border: 1px solid #ddd;
                padding: 1px;
                border-radius: 3px;
            }
            .bar {
                background-color: #B4F5B4;
                width: 0%;
                height: 20px;
                border-radius: 3px;
            }
            .percent {
                position: absolute;
                display: inline-block;
                top: 3px;
                left: 48%;
            }
        </style>
   </head>
   <body>
   <h1>파일을 업로드합니다. </h1>
   <form action="/uploadFile" method="post" enctype="multipart/form-data">
       업로드할 파일 : <input type="file" name="file" id="file"><br>
       <input type="submit" value="등록하기">
   </form>
   <br>
   파일 사이즈 : <span id="fileSize"></span><br>
   파일 확장자 : <span id="fileType"></span>
   <!-- ##########################추가###############################-->
   <div class="progress">
       <div class="bar"></div >
       <div class="percent">0%</div >
   </div>
   <div id="status"></div>
   <script>
       $(document).ready(function () {
           $('#file').change(function () {
               let file = $('#file').prop('files')[0];
               $('#fileSize').html(Math.floor(file.size/(1024*1024))+'MB');
               $('#fileType').html(file.type);
           });
           /*####################추가#######################*/
           var bar = $('.bar');
           var percent = $('.percent');
           var status = $('#status');
   
           $('form').ajaxForm({
               // 전송 전
               beforeSend: function() {
                   var percentVal = '0%';
                   bar.width(percentVal);
                   percent.html(percentVal);
               },
               // 업로드 시
               uploadProgress: function(event, position, total, percentComplete) {
                   var percentVal = percentComplete + '%';
                   bar.width(percentVal);
                   percent.html(percentVal);
               },
               // // 완료 시
               complete: function(xhr) {
                   alert(xhr.responseText);
                   window.location.reload();
               }
           });
       });
   </script>
   </body>
   </html>

컨트롤러의 return을 “Success”;로 변경해준다.

@PostMapping(value = "/uploadFile", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public String upload(@RequestParam("file") MultipartFile multipartFile) throws IOException {
        log.info("contentType : {}", multipartFile.getContentType());
        log.info("original name : {}", multipartFile.getOriginalFilename());
		...
        ...
        
        return "Success";
}

멀티 파일 업로드

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <script src="https://code.jquery.com/jquery-3.4.1.min.js"
            integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo="
            crossorigin="anonymous"></script>
    <script src="http://malsup.github.com/jquery.form.js"></script>
    <title>파일 업로드 페이지</title>
    <style>
        .progress {
            position: relative;
            width: 400px;
            border: 1px solid #ddd;
            padding: 1px;
            border-radius: 3px;
        }
        .bar {
            background-color: #B4F5B4;
            width: 0%;
            height: 20px;
            border-radius: 3px;
        }
        .percent {
            position: absolute;
            display: inline-block;
            top: 3px;
            left: 48%;
        }
    </style>
</head>
<body>
<h1>파일을 업로드합니다. </h1>
<form id="fm" action="/uploadFile" method="post" enctype="multipart/form-data">
    <!--#######################multiple 속성 추가#############################-->
    업로드할 파일 : <input type="file" name="file" id="file" multiple><br>
    <input type="submit" value="등록하기">
</form>
<br>
파일 사이즈 : <span id="fileSize"></span><br>
파일 확장자 : <span id="fileType"></span>


<div class="progress">
    <div class="bar"></div >
    <div class="percent">0%</div >
</div>
<div id="status"></div>
<script>

    $(document).ready(function () {
		/*################# 변경 ############################*/
        $('#file').change(function () {
            let files = $('#file').prop('files');
            let len = files.length;
            let totalSize = 0;
            for(let i=0;i<len;i++){
                totalSize += Math.floor(files[i].size/(1024*1024))
            }
            $('#fileSize').html(totalSize+'MB');
        });
		/*####################################################*/
        var bar = $('.bar');
        var percent = $('.percent');
        var status = $('#status');

        $('form').ajaxForm({
            // 전송 전
            beforeSend: function() {
                var percentVal = '0%';
                bar.width(percentVal);
                percent.html(percentVal);
            },
            // 업로드 시
            uploadProgress: function(event, position, total, percentComplete) {
                var percentVal = percentComplete + '%';
                bar.width(percentVal);
                percent.html(percentVal);
            },
            // // 완료 시
            complete: function(xhr) {
                alert(xhr.responseText);
                window.location.reload();
            }
        });
    });
</script>
</body>
</html>
@Controller
@Slf4j
public class FileUploadController {

    private String SAVE_PATH = "D:/upload";

    @GetMapping("/uploadForm")
    public String getUploadForm() {
        return "uploadForm";
    }


    @PostMapping(value = "/uploadFile", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    @ResponseBody
    public String upload(@RequestParam("file") List<MultipartFile> multipartFile) throws IOException {

        StringBuilder success = new StringBuilder("-----------upload success list ---------------");
        StringBuilder fail = new StringBuilder("--------------upload fail list------------------");
        multipartFile.forEach(file -> {
            log.info("{}, {}, {}", file.getContentType(),file.getOriginalFilename(),file.getSize());
            try {
                fileUpload(file);
                success.append("\n"+file.getOriginalFilename());
            } catch (IOException e) {
                e.printStackTrace();
                fail.append("\n"+file.getOriginalFilename());
            }
        });
        String ret = success.toString()+"\n"+fail.toString();
        return ret;
    }

    private void fileUpload(MultipartFile multipartFile) throws IOException {
        File uploadFile = new File(SAVE_PATH + "/" + multipartFile.getOriginalFilename());
        uploadFile.createNewFile();
        try (BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(uploadFile));
             BufferedInputStream bis = new BufferedInputStream(multipartFile.getInputStream());) {
            byte[] buffer = new byte[4096 * 2];
            int read = 0;
            while ((read = bis.read(buffer)) != -1) {
                bos.write(buffer,0,buffer.length);
            }
            bos.flush();
        } catch (Exception e) {
            log.info("{}", e);
        }
    }
}