MultipartFile
MultipartFile는 Spring Framework에서 제공하는 인터페이스로써, 파일 업로드 기능을 위해 사용되며, 사용자가 업로드한 파일의 내용, 이름, 원래 이름, 내용 유형 등의 정보를 이 인터페이스를 통해 접근할 수 있다.
멀티파트 사용 옵션
- 업로드 사이즈 제한
- spring.servlet.multipart.max-file-size=1MB
- spring.servlet.multipart.max-request-size=10MB
- 사이즈를 넘으면 예외(
SizeLimitExceededException
)가 발생한다. max-file-size
: 파일 하나의 최대 사이즈, 기본 1MBmax-request-size
: 멀티파트 요청 하나에 여러 파일을 업로드 할 수 있는데, 그 전체 합이다. 기본 10MB
파일 경로 설정
- application.properties에 file.dir=/path.../ 를 입력해주면 해당 경로에 파일을 저장 할 수 있다.
- 그 전에 먼저 해당 경로에 실제 폴더를 미리 만들어 둬야 한다.
@PostMapping("/upload")
public String saveFileV2(HttpServletRequest request) throws ServletException, IOException {
log.info("request={}", request);
String itemName = request.getParameter("itemName");
log.info("itemName={}", itemName);
Collection<Part> parts = request.getParts();
for (Part part : parts) {
log.info("===============");
log.info("name={}", part.getName());
Collection<String> headerNames = part.getHeaderNames();
for (String headerName : headerNames) {
log.info("header{} : {}", headerName, part.getHeader(headerName));
}
log.info("submitted={}", part.getSubmittedFileName());
log.info("size={}", part.getSize());
// read data
InputStream inputStream = part.getInputStream(); // 바이너리가 들어옴
String body = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
log.info("body={}", body);
}
log.info("parts={}", parts);
return "upload-form";
}
코드 흐름
코드는 HTTP 요청에서의 멀티파트 폼 데이터를 처리하기 위한 코드이다. 멀티파트 폼 데이터는 주로 파일 업로드와 같은 바이너리 데이터를 포함하여 여러 부분으로 나뉘어진 데이터를 서버로 전송할 때 사용된다.
MultipartFile을 프로젝트에 적용시켜보자
쇼핑몰 페이지의 관리자 페이지를 개발중에 이미지 업로드 기능을 개발해보기로했다. 어떠한 파일 업로드를 하기 위해서는 MultiparfFile 인터페이스를 이용해야하는데, 여기서 썸네일 이미지처럼 대표이미지 처럼 사용될 이미지 1장과, 추가 이미지 여러장을 업로드 하고 다뤄볼 수 있도록 CRUD 메서드를 만들어보겠다. 그 전에 이미지 파일이 저장될 실제 경로를 지정해주는 작업을 수행한다.
Properties를 활용하여 이미지 경로 설정하기
#Image File Directory
file.main-img = ...
file.sub-img = ...
#Image File Max Size
spring.servlet.multipart.max-file-size=nMB // 단일 파일 업로드시
spring.servlet.multipart.max-request-size=nMB // 여러장의 파일 업로드시
이미지 파일 저장 경로와, MultipartFile 업로드시 파일 최대 사이즈를 지정해 줄 수 있다.
@Slf4j
@Component
@Getter
@Setter
@ConfigurationProperties("file")
public class ImgStore {
private String mainImg;
private String subImg;
public String getStorageMainImgName(MultipartFile imgFile) throws IOException {
if (imgFile.isEmpty())
return "Non-Img";
String fullMainPath = getFullMainPath(imgFile.getOriginalFilename());
makeFileDir(fullMainPath);
imgFile.transferTo(new File(fullMainPath));
return imgFile.getOriginalFilename();
}
public List<String> getStorageSubImgName(List<MultipartFile> subImgs) throws IOException {
if (subImgs.stream().allMatch(MultipartFile::isEmpty))
return List.of("Non-Img");
String fullSubPath = getFullSubPath(subImgs.get(0).getOriginalFilename());
makeFileDir(fullSubPath);
for (MultipartFile img : subImgs) {
img.transferTo(new File(getFullSubPath(img.getOriginalFilename())));
}
return subImgs.stream()
.map(MultipartFile::getOriginalFilename)
.toList();
}
public String updateMainImgFile(Product foundProduct, MultipartFile updateImg) throws IOException {
if (updateImg.isEmpty())
return foundProduct.getCurrentImg(foundProduct.getMainImgPath());
// 메인 이미지 업데이트
String updateMainImgPath = getFullMainPath(updateImg.getOriginalFilename());
updateImg(updateImg, updateMainImgPath);
// 기존 이미지 삭제
String pastImgName = getFullMainPath(foundProduct.getMainImgPath());
deleteImg(pastImgName);
return updateImg.getOriginalFilename();
}
public List<String> updateSubImgFiles(List<ProductSubImg> foundProducts, List<MultipartFile> updateImgs) throws IOException {
if (updateImgs.stream().allMatch(MultipartFile::isEmpty))
return ProductSubImg.getCurrentImgs(foundProducts);
// 서브 이미지 업데이트
List<String> updateImgNames = new ArrayList<>();
for (MultipartFile updateImg : updateImgs) {
String updatedSubImgName = getFullSubPath(updateImg.getOriginalFilename());
updateImg(updateImg, updatedSubImgName);
updateImgNames.add(updateImg.getOriginalFilename());
}
//서브 이미지 삭제
for (ProductSubImg foundImg : foundProducts) {
String pastImgName = getFullSubPath(foundImg.getPath());
deleteImg(pastImgName);
}
return updateImgNames;
}
private void updateImg(MultipartFile updateImg, String updatedSubImgName) throws IOException {
Path updatedSubImgPath = Paths.get(updatedSubImgName);
Files.write(updatedSubImgPath, updateImg.getBytes());
}
private void deleteImg(String pastImgName) throws IOException {
Path oldImgPath = Paths.get(pastImgName);
if (Files.exists(oldImgPath)) {
Files.delete(oldImgPath);
}
}
private void makeFileDir(String fullPath) throws IOException {
Path path = Paths.get(fullPath);
if (!Files.exists(path)) {
Files.createFile(path);
}
}
private String getFullMainPath(String fileName) {
return System.getProperty("user.dir") + mainImg + fileName;
}
private String getFullSubPath(String fileName) {
return System.getProperty("user.dir") + subImg + fileName;
}
}
이미지 파일 저장 및 경로를 생성하고 관리할 도메인 객체이다. 여기에서 @ConfigurationProperties("file")
을 적용시켜주면 스프링 부트가 application.properteis
파일을 읽어서 file
로 시작하는 경로를 읽어들이기 시작한다. 그리고 file
뒤에 시작하는 main-img
,sub-img
를 필드의 변수로 선언된 String mainImg, String subImg
로 매핑을 해준다.
@ConfigurationProperties
는 유연함을 제공한는데 예를 들어 application.properteis
에 file.main_img
여도 String mainImg
에 매핑이 된다. 즉 핵심은 properties 파일에 카멜케이스로 적용을 하던, -
로 적용을 하던 _
로 적용을 하던 매핑을 시켜준다는 것!!
주요 코드 설명
private String getFullMainPath(String fileName) {
return System.getProperty("user.dir") + mainImg + fileName;
}
private String getFullSubPath(String fileName) {
return System.getProperty("user.dir") + subImg + fileName;
}
System.getProperty("user.dir")을 통해 프로젝트 루트 디렉토리부터 시작해서 경로를 설정해준다. 해당 메서드를 거치면 /Users/someone/Java/project/....
이런식의 String을 반환한다.
public String getStorageMainImgName(MultipartFile imgFile) throws IOException {
if (imgFile.isEmpty())
return "Non-Img";
String fullMainPath = getFullMainPath(imgFile.getOriginalFilename());
makeFileDir(fullMainPath);
imgFile.transferTo(new File(fullMainPath));
return imgFile.getOriginalFilename();
}
public List<String> getStorageSubImgName(List<MultipartFile> subImgs) throws IOException {
if (subImgs.stream().allMatch(MultipartFile::isEmpty))
return List.of("Non-Img");
String fullSubPath = getFullSubPath(subImgs.get(0).getOriginalFilename());
makeFileDir(fullSubPath);
for (MultipartFile img : subImgs) {
img.transferTo(new File(getFullSubPath(img.getOriginalFilename())));
}
return subImgs.stream()
.map(MultipartFile::getOriginalFilename)
.toList();
}
그 뒤 외부로부터 MultiparFile을 받아서 파일이 저장될 경로를 설정해주고, 파일 객체로 변환시켜준뒤 설정해둔 디렉토리에 파일을 저장시켜준다.
Update
public String updateMainImgFile(Product foundProduct, MultipartFile updateImg) throws IOException {
if (updateImg.isEmpty())
return foundProduct.getCurrentImg(foundProduct.getMainImgPath());
// 메인 이미지 업데이트
String updateMainImgPath = getFullMainPath(updateImg.getOriginalFilename());
updateImg(updateImg, updateMainImgPath);
// 기존 이미지 삭제
String pastImgName = getFullMainPath(foundProduct.getMainImgPath());
deleteImg(pastImgName);
return updateImg.getOriginalFilename();
}
public List<String> updateSubImgFiles(List<ProductSubImg> foundProducts, List<MultipartFile> updateImgs) throws IOException {
if (updateImgs.stream().allMatch(MultipartFile::isEmpty))
return ProductSubImg.getCurrentImgs(foundProducts);
// 서브 이미지 업데이트
List<String> updateImgNames = new ArrayList<>();
for (MultipartFile updateImg : updateImgs) {
String updatedSubImgName = getFullSubPath(updateImg.getOriginalFilename());
updateImg(updateImg, updatedSubImgName);
updateImgNames.add(updateImg.getOriginalFilename());
}
//서브 이미지 삭제
for (ProductSubImg foundImg : foundProducts) {
String pastImgName = getFullSubPath(foundImg.getPath());
deleteImg(pastImgName);
}
return updateImgNames;
}
수정 시, 다른 속성은 수정했지만 이미지를 수정하지 않고 수정 폼을 제출하게 될 경우 Empty
상태로 들어간다. (객체는 있지만 데이터가 없는 상태)
기존의 코드에서는 뷰단에서 <img>
태그를 통해 해당 상세페이지를 들어갔을 경우 이미지가 렌더링 될 수 있도록 해놨었다.
그리고 th:value
속성을 통해 기존값을 그대로 두게 해놨었는데, 예상한 바로는 기존 이미지를 수정하지 않고, 수정 폼을 제출할 경우 value 속성으로 인해 값이 전달되는 줄 알았는데, 자꾸 NPE
에러가 발생했었다.
파일 업로드 필드인 <input type="file">
에서는 보안상의 이유로 브라우저에서 파일의 경로나 내용을 읽을 수 없. 사용자가 파일을 선택하지 않은 경우, th:value
로 값을 박아놨다해도, 이 필드는 서버로 아무런 값도 전송하지 않는다. 즉, 파일을 선택하지 않았기 때문에 MultipartFile 객체가 null로 전송되는 것그래서 수정 시 이미지 파일을 수정하지 않았다면 DB에서 기존의 파일을 경로를 불러오고 다시 수정DTO의 필드에 넣어주고 save해줘야 한다.
// 단일 파일 업로드시 수정을 하지 않은 경우
if (updateImg.isEmpty())
return foundProduct.getCurrentImg(foundProduct.getMainImgPath());
// 여러장의 파일 업로드시 수정을 하지 않은 경우
if (updateImgs.stream().allMatch(MultipartFile::isEmpty))
return ProductSubImg.getCurrentImgs(foundProducts);
그리고 만약 기존의 파일을 3장을 업로드한 상태에서 수정시 3장보다 많은 4장 or 3장보다 적은 2장으로 수정 시
private void updateSubImgs(Product updateProduct, List<MultipartFile> updateSubImgs, List<ProductSubImg> foundAll, List<String> storageSubImgName) {
//기존의 파일보다 요청한 파일이 더 많을 경우
if (updateSubImgs.size() > foundAll.size()) {
List<String> extraSubImgNames = storageSubImgName.subList(foundAll.size(), updateSubImgs.size());
List<ProductSubImg> productSubImgs = ProductSubImg.saveSubImgs(extraSubImgNames, updateProduct);
subImgRepository.reg(productSubImgs);
List<String> overWriteImgNames = storageSubImgName.subList(0, foundAll.size());
List<ProductSubImg> overWriteSubImgList = ProductSubImg.updateSubImgs(overWriteImgNames, foundAll);
subImgRepository.updatedImgs(overWriteSubImgList);
return;
}
//기존의 파일보다 요청한 파일이 더 적을 경우
if (updateSubImgs.size() < foundAll.size()) {
List<ProductSubImg> extraSubImgsToDelete = foundAll.subList(updateSubImgs.size(), foundAll.size());
subImgRepository.deleteAll(extraSubImgsToDelete);
List<ProductSubImg> remainingSubImgs = foundAll.subList(0, updateSubImgs.size());
List<ProductSubImg> productSubImgs = ProductSubImg.updateSubImgs(storageSubImgName, remainingSubImgs);
subImgRepository.updatedImgs(productSubImgs);
return;
}
//같을 경우
List<ProductSubImg> productSubImgs = ProductSubImg.updateSubImgs(storageSubImgName, foundAll);
subImgRepository.updatedImgs(productSubImgs);
}
해당 로직을 실행 시켜서 경우의 수에 대해서 기존의 파일보다 요청이 많은 경우 엔티티를 생성하고, 적은 경우는 엔티티를 삭제해주는 코드이다.그리고 수정시의 로직을 생각해보면, 파일이 업로드 된 후, 해당 파일이 기존의 파일에 덮어씌워지거나 새로운 파일이 설정해둔 경로에 생성되어야 한다.
private void updateImg(MultipartFile updateImg, String updatedSubImgName) throws IOException {
Path updatedSubImgPath = Paths.get(updatedSubImgName);
Files.write(updatedSubImgPath, updateImg.getBytes());
}
Delete
삭제 부분은 현재 프로젝트에서 MultipartFile은 DTO의 한 속성으로 가지고 있어서 만약 등록한 Item 엔티티를 삭제해주는 코드로 짜여져있다. 허나 이미지 부분만 삭제하는 부분은
private void deleteImg(String pastImgName) throws IOException {
Path oldImgPath = Paths.get(pastImgName);
if (Files.exists(oldImgPath)) {
Files.delete(oldImgPath);
}
}
해당 코드가 담당한다. 인자로 파일이름을 받아서, 경로로 만들어 준다. 그 뒤 해당 경로의 파일이 존재한다면 지워준다.
주의
<form
method="post"
th:action="@{/admin/products}"
class="n-form n-form-type:outline-box px:10 w:100p"
enctype="multipart/form-data"
th:object="${product}"
>
MultipartFile을 이용하여 파일 데이터를 받을 때는 form
태그에 enctype="multipart/form-data"
를 명시해줘야 한다.
느낀점
MultipartFile을 이용하여 파일을 업로드하는 것 자체는 쉬웠다. 그러나 여러 이미지를 업로드 하거나, 수정 시에 대한 경우의 수와, DB에 어떻게 반영될 것인지, 그리고 th:value
를 통해 수정하지 않더라도 기존의 값이 그대로 반영될 줄 알았는데 NPE가 발생해서 많이 당황했고 구글링을 엄청했던거 같다. 기록해두고 MultipartFile을 다뤄야할 프로젝트가 또 있다면 참고해야겠다.
'스프링' 카테고리의 다른 글
[Spring] 서블릿의 예외 처리와 ExceptionHandler, ControllerAdvice 알아보기 (0) | 2024.05.17 |
---|---|
[Spring] 서블릿의 Filter, 스프링의 Interceptor 예제 코드로 알아보기 (0) | 2024.05.16 |
[Spring] 로그인 기능 구현으로 알아보는 쿠키 및 세션 (2) | 2024.05.13 |
[Spring] 애노테이션 파라미터를 처리하는 ArgumentResolver (1) | 2024.05.13 |
[Spring] Bean Validation, BindingResult 적용하기 (0) | 2024.04.29 |