[코드로 배우는 스프링부트 웹 프로젝트] - 파일 업로드

Jongwon·2023년 1월 15일
0

Servlet 3버전부터는 파일 업로드 라이브러리가 자체적으로 탑재되어 있다고 합니다. 따라서 SpringBoot 구동 시 Tomcat을 이용한다면 별도의 라이브러리 설치 없이 설정만 해주면 됩니다.

application.properties

spring.servlet.multipart.enabled=true
spring.servlet.multipart.location=C:\\Users\\tank3\\Desktop\\mreview\\upload
spring.servlet.multipart.max-request-size=30MB
spring.servlet.multipart.max-file-size=10MB

location에는 임시파일을 저장하고 싶은 경로를 적으시면 됩니다.(\\는 '\' 문자 인식위해 2번 작성)



파일이 정상적으로 올라가는지 확인하기 위해 테스트 Controller를 생성하겠습니다.

UploadTestController

package org.zerock.mreview.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class UploadTestController {

    @GetMapping("/uploadEx")
    public void uploadEx() {

    }
}

이제 예제 페이지를 만들어 파일이 여러개 선택되는지 확인해보겠습니다.

uploadEx.html(templates 아래에 추가)

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <!--jQuery-->
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
</head>
<body>
    <input name="uploadFiles" type="file" multiple>
    <button class="uploadBtn">Upload</button>
    <script>
        $('.uploadBtn').click(function() {
            var formData = new FormData();

            var inputFile = $("input[type='file']");

            var files = inputFile[0].files;

            for(var i = 0; i < files.length; i++) {
                console.log(files[i]);
                formData.append("uploadFiles", files[i]);
            }
        });
    </script>
</body>
</html>

파일 선택 버튼을 누르면 여러개의 파일을 선택할 수 있습니다. 그리고 Upload 버튼을 누르면 콘솔창에 파일이 보여집니다.

이제 업로드시 서버와 비동기 처리를 하기 위해 ajax를 사용합니다. 기존 html script를 아래와 같이 수정합니다.

uploadEx.html

    <script>
        $('.uploadBtn').click(function() {
            var formData = new FormData();

            var inputFile = $("input[type='file']");

            var files = inputFile[0].files;

            for(var i = 0; i < files.length; i++) {
                console.log(files[i]);
                formData.append("uploadFiles", files[i]);
            }

            $.ajax({
                url: '/uploadAjax',
                processData: false,
                contentType: false,
                data: formData,
                type: 'POST',
                dataType: 'json',
                success: function(result) {
                    console.log(result);
                },
                error: function(jqXHR, textStatus, errorThrown) {
                    console.log(textStatus);
                }
            });
        });
    </script>

그리고 ajax를 통해 지정된 URL 경로를 받는 별도의 Controller를 생성해줍니다. 이 Controller는 이후에도 사용할 것이기 때문에 UploadController라는 이름으로 생성하겠습니다.

UploadController

package org.zerock.mreview.controller;

import lombok.extern.log4j.Log4j2;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

@RestController
@Log4j2
public class UploadController {

    @PostMapping("/uploadAjax")
    public void uploadFile(MultipartFile[] uploadFiles) {
        for(MultipartFile uploadFile : uploadFiles) {
            String originalName = uploadFile.getOriginalFilename();
            String fileName = originalName.substring(originalName.lastIndexOf("\\") + 1);

            log.info("fileName " +fileName);
        }
    }
}

Post에서는 파일명을 받아서 log로 출력하는 간단한 기능이 적혀있습니다. 파일을 선택 후 Upload 버튼을 누르면 서버에는 선택된 파일의 이름이 출력되는 것을 확인할 수 있습니다.

이제 들어온 파일을 저장해야 합니다. 먼저 application.properties에 파일을 저장할 경로를 설정하고, 이를 Controller에 가져옵니다.

application.properties

//추가
org.zerock.upload.path=C:\\Users\\tank3\\Desktop\\mreview\\upload

UploadController

//추가
    @Value("${org.zerock.upload.path}")
    private String uploadPath;

이제 파일 업로드 처리를 해야하는데, 영화 리뷰 웹사이트를 만든다고 했을 때 3가지 고려사항이 있습니다.

  • 파일의 확장자가 이미지 파일인가
    getContentType()으로 해결
  • 하나의 폴더에 너무 많은 파일이 쌓이는가
    년/월/일로 폴더를 구분하여 적절히 쌓이도록 함
  • 중복된 이름을 어떻게 처리하는가
    UUID + 파일명으로 중복 제거


위의 3가지 고려사항을 고려하여 UploadController를 다음과 같이 수정합니다.
UploadController

package org.zerock.mreview.controller;

import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.UUID;

@RestController
@Log4j2
public class UploadController {

    @Value("${org.zerock.upload.path}")
    private String uploadPath;

    @PostMapping("/uploadAjax")
    public void uploadFile(MultipartFile[] uploadFiles) {
        for(MultipartFile uploadFile : uploadFiles) {

            //파일 확장자 체크
            if(uploadFile.getContentType().startsWith("image") == false) {
                log.warn("this is not image type");
                return;
            }

            String originalName = uploadFile.getOriginalFilename();
            String fileName = originalName.substring(originalName.lastIndexOf("\\") + 1);

            log.info("fileName " +fileName);

            //폴더 구분
            String folderPath = makeFolder();
            String uuid = UUID.randomUUID().toString();

            //파일명 구분
            String saveName = uploadPath + File.separator + folderPath + File.separator + uuid + "_" + fileName;
            Path savePath = Paths.get(saveName);
            try {
                uploadFile.transferTo(savePath);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    private String makeFolder(){
        String str = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));

        String folderPath = str.replace("//", File.separator);

        File uploadPathFolder = new File(uploadPath, folderPath);

        if(uploadPathFolder.exists() == false) {
            uploadPathFolder.mkdirs();
        }

        return folderPath;
    }
}

이후 SpringBoot를 실행하여 파일을 저장해보면 아래 사진과 같이 2023, 01, 15폴더가 생성되었고, 그 안에 UUID와 파일명으로 저장된 파일을 확인할 수 있습니다.


이제 클라이언트에 새로운 파일에 대한 정보(UUID값, 저장경로, 파일명)을 반환해주어야 합니다. 이를 DTO를 통해 전달하겠습니다.

UploadResultDTO

package org.zerock.mreview.dto;

import lombok.AllArgsConstructor;
import lombok.Data;

import java.io.Serializable;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;

@Data
@AllArgsConstructor
public class UploadResultDTO implements Serializable {

    private String fileName;
    private String uuid;
    private String folderPath;

    public String getImageURL() {
        try {
            return URLEncoder.encode(folderPath+"/"+uuid+"_"+fileName, "UTF-8");
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }

        return "";
    }
}

그리고 Controller가 ResponseEntity를 반환하도록 수정하여 UploadResultDTO를 전송할 수 있도록 합니다.

UploadController

    @PostMapping("/uploadAjax")
    public ResponseEntity<List<UploadResultDTO>> uploadFile(MultipartFile[] uploadFiles) {

        List<UploadResultDTO> resultDTOList = new ArrayList<>();
        for(MultipartFile uploadFile : uploadFiles) {

            //파일 확장자 체크
            if(uploadFile.getContentType().startsWith("image") == false) {
                log.warn("this is not image type");
                return new ResponseEntity<>(HttpStatus.FORBIDDEN);
            }

            String originalName = uploadFile.getOriginalFilename();
            String fileName = originalName.substring(originalName.lastIndexOf("\\") + 1);

            log.info("fileName " +fileName);

            //폴더 구분
            String folderPath = makeFolder();
            String uuid = UUID.randomUUID().toString();

            //파일명 구분
            String saveName = uploadPath + File.separator + folderPath + File.separator + uuid + "_" + fileName;
            Path savePath = Paths.get(saveName);
            try {
                uploadFile.transferTo(savePath);
                resultDTOList.add(new UploadResultDTO(fileName, uuid, folderPath));
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

        return new ResponseEntity<>(resultDTOList, HttpStatus.OK);
    }

이제 서버는 파일을 저장하고, 클라이언트는 저장된 파일의 정보를 JSON으로 확인할 수 있습니다.


저장된 이미지를 화면에 출력하기 위해 html에 링크를 넣어주어야 하고, 서버는 그 링크가 호출되었을 때 이미지를 전송해주면 됩니다.

UploadController

    @GetMapping("/display")
    public ResponseEntity<byte[]> getFile(String fileName) {
        ResponseEntity<byte[]> result = null;
        
        try {
            String srcFileName = URLDecoder.decode(fileName, "UTF-8");
            
            log.info("fileName: " + srcFileName);
            
            File file = new File(uploadPath + File.separator + srcFileName);
            
            log.info("file: " + file);

            HttpHeaders header = new HttpHeaders();
            
            //MIME 타입 지정
            header.add("Content-Type", Files.probeContentType(file.toPath()));
            //Byte Array로 변환
            result = new ResponseEntity<>(FileCopyUtils.copyToByteArray(file), header, HttpStatus.OK);
        } catch (Exception e) {
            log.error(e.getMessage());
            return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
        }
        
        return  result;
    }

화면에서는 img를 통해 가져온 이미지를 출력할 수 있도록 html을 수정합니다.
uploadEx.html

    <button class="uploadBtn">Upload</button>
    
    <div class="uploadResult"></div>
    <script>
        $('.uploadBtn').click(function() {
            var formData = new FormData();

            var inputFile = $("input[type='file']");

            var files = inputFile[0].files;

            for(var i = 0; i < files.length; i++) {
                console.log(files[i]);
                formData.append("uploadFiles", files[i]);
            }

            $.ajax({
                url: '/uploadAjax',
                processData: false,
                contentType: false,
                data: formData,
                type: 'POST',
                dataType: 'json',
                //수정
                success: function(result) {
                    showUploadedImages(result);
                },
                error: function(jqXHR, textStatus, errorThrown) {
                    console.log(textStatus);
                }
            });
            //추가
            function showUploadedImages(arr) {
                console.log(arr);
                
                var divArea = $(".uploadResult");
                
                for(var i = 0; i < arr.length; i++) {
                    divArea.append("<img src='/display?fileName="+arr[i].imageURL+"'>");
                }
            }
        });
    </script>

이제 파일을 업로드하면 이미지가 화면에 보여집니다.



하지만 이렇게 원본 이미지를 바로 보여주면 용량이 너무 큽니다. 따라서 썸네일을 먼저 보여주고, 원본을 보고자 클릭할 때만 원본 이미지를 보여주는 방식이 좋습니다.

https://github.com/coobird/thumbnailator
Thumbnailator라는 라이브러리를 통해 손쉽게 썸네일을 만들 수 있습니다.

build.gradle의 dependencies에 아래의 코드를 넣고 build합니다. 이때 DB에 중복 저장이 되는것을 방지하기 위해 insert테스트는 주석처리합니다.
implementation "net.coobird:thumbnailator:[0.4, 0.5)"

썸네일은 upload시 s_파일명으로 별도의 이름으로 저장하게 됩니다. Controller에서 Upload하는 부분의 try~catch 구문을 아래와 같이 수정합니다.

UploadController

try {
    uploadFile.transferTo(savePath);

    //thumbnail 생성
    String thumbnailSaveName = uploadPath + File.separator + folderPath + File.separator + "s_" + uuid + "_" + fileName;
    File thumbnailFile = new File(thumbnailSaveName);
    Thumbnailator.createThumbnail(savePath.toFile(), thumbnailFile, 100, 100);

    resultDTOList.add(new UploadResultDTO(fileName, uuid, folderPath));
} catch (IOException e) {
    e.printStackTrace();
}

파일을 저장할 때 s_가 붙은 동일한 파일이 하나 생성되는 것을 확인할 수 있습니다. 생성된 파일을 확인해보면 크기도 줄어들고, 용량도 많이 압축되었습니다.

DTO에서도 thumbnailURL을 반환하는 메서드를 만들어 썸네일의 링크를 처리할 수 있도록 합니다.

UploadResultDTO

public String getThumbnailURL() {
    try {
        return URLEncoder.encode(folderPath+"/s_" + uuid + "_" + fileName, "UTF-8");
    } catch (UnsupportedEncodingException e) {
        e.printStackTrace();
    }

    return "";
}

화면에서도 원본 이미지가 아닌 썸네일이 보여질 수 있도록 script에서 이미지를 불러오는 부분에서 imageURL -> thumbnailURL로 변경합니다.

function showUploadedImages(arr) {
    console.log(arr);

    var divArea = $(".uploadResult");

    for(var i = 0; i < arr.length; i++) {
        divArea.append("<img src='/display?fileName="+arr[i].thumbnailURL+"'>");
    }
}


파일을 삭제하는 기능도 추가하겠습니다.
UploadController

@PostMapping("/removeFile")
public ResponseEntity<Boolean> removeFile(String fileName) {
    String srcFileName = null;
    
    try {
        srcFileName = URLDecoder.decode(fileName, "UTF-8");
        File file = new File(uploadPath + File.separator + srcFileName);
        boolean result = file.delete();
        
        File thumbnail = new File(file.getParent(), "s_" + file.getName());
        
        result =thumbnail.delete();
        
        return new ResponseEntity<>(result, HttpStatus.OK);
    } catch (UnsupportedEncodingException e) {
        e.printStackTrace();
        return new ResponseEntity<>(false, HttpStatus.INTERNAL_SERVER_ERROR);
    }
}

그리고 이미지를 추가할 때 REMOVE 버튼도 같이 추가하고 하나의 div로 묶이도록 script를 수정합니다.

uploadEx.html

function showUploadedImages(arr) {
    console.log(arr);

    var divArea = $(".uploadResult");
    var str = "";

    for(var i = 0; i < arr.length; i++) {
        str += "<div>";
        str += "<img src='/display?fileName="+arr[i].thumbnailURL+"'>";
        str += "<button class='removeBtn' data-name='" + arr[i].imageURL + "'>REMOVE</button>";
        str += "</div>";
    }
    
    divArea.append(str);
}

REMOVE버튼을 눌렀을 때 삭제 event가 발생할 수 있도록 script에 추가합니다.(이때 uploadBtn 밖에 설치해야 합니다.)

$(".uploadResult").on("click", ".removeBtn", function(e) {
    var target = $(this);
    var fileName = target.data("name");
    var targetDiv = $(this).closest("div");

    console.log(fileName);

    $.post('/removeFile', {fileName: fileName}, function(result) {
        console.log(result);
        if(result === true) {
            targetDiv.remove();
        }
    })
});

SpringBoot를 실행한 후, 파일을 업로드 한 뒤에 삭제버튼을 누르면 저장된 위치에서 삭제되는 것을 확인할 수 있습니다.

profile
Backend Engineer

0개의 댓글