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