김영한 님의 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의를 보고 작성한 내용입니다.
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard
form 태그에 enctype
속성이 없으면 웹 브라우저는 요청 메세지의 Content-Type에 application/x-www-form-urlencoded
추가
input 태그의 name 속성을 key로 해서 message body에 key=value 형식으로 담긴다
각 input 태그( 전달되는 내용 )는 & 로 구분한다
즉, Form에 입력한 내용을 message body에 담는다
파일은 문자가 아닌 바이너리 데이터를 전송해야하고 파일과 문자가 함께 있는 경우 문자와 바이너리를 동시에 전송해야한다
다른 종류의 여러 파일과 Form의 내용을 한 번에 전송할 수 있는 multipart/form-data
를 사용한다
enctype
속성에 multipart/form-data
를 지정해서 사용
웹 브라우저가 생성한 HTTP 요청 메세지를 보면 각각의 전송 항목이 boundary
로 구분
Content-Disposition
이라는 항목별 헤더가 추가
Form의 일반 데이터는 각 항목 별로( input 태그 별로 ) 문자가 전송
파일의 경우 filename과 Content-Type이 추가되고 바이너리 데이터가 전송
public class ServletUploadControllerV1 {
@PostMapping("upload")
public String saveFileV1(HttpServletRequest request) throws ServletException, IOException {
Collection<Part> parts = request.getParts();
log.info("parts={}", parts);
return "upload-form";
}
}
<form th:action method="post" enctype="multipart/form-data">
<ul>
<li>상품명 <input type="text" name="itemName"></li>
<li>파일<input type="file" name="file" ></li>
</ul>
<input type="submit"/>
</form>
getParts()
: multipart/form-data 전송 방식에서 나누어진 부분들을 받아서 확인할 수 있다
HTML을 보면 input 태그가 문자와 파일 두 개이기 때문에 parts를 로그로 찍어보면 아래처럼 두 부분으로 나뉘어서 출력
parts=[org.apache.catalina.core.ApplicationPart@4b4d23fd, org.apache.catalina.core.ApplicationPart@352057c]
나뉘어서 데이터가 들어왔기 때문에 parts에서 원하는 데이터를 꺼낼 수 있다
참고> HTTP 요청 메세지를 서버에서 로그로 남기고 싶은 경우 application.properties에 아래 코드 작성
logging.level.org.apache.coyote.http11=debug
spring.servlet.multipart.max-file-size=1MB
spring.servlet.multipart.max-request-size=10MB
spring.servlet.multipart.enabled=false
서블릿 컨테이너가 멀티파트 요청과 관련된 처리를 하지 않는다
request를 로그로 찍어보면 RequestFacade
getParameter()나 getParts()의 결과를 로그로 찍어보면 비어있다
spring.servlet.multipart.enabled=true
멀티파트 요청과 관련된 처리를 한다
request를 로그로 찍어보면 StandardMultipartHttpServletRequest
spring.servlet.multipart.enabled=true
이면 스프링의 DispatcherServlet
에서 멀티파트리졸버(MultipartResolver
)를 실행
MultipartResolver
는 멀티파트 요청인 경우, 서블릿 컨테이너가 전달하는 일반적인 HttpServletRequest
(RequestFacade)를 MultiPartHttpServletRequest
로 변환해서 반환
MultiPartHttpServletRequest
는 HttpServletRequest
의 자식 인터페이스스프링이 제공하는 기본 MultipartResolver
는 MultiPartHttpServletRequest
를 구현한 StandardMultipartHttpServletRequest
를 반환
이제 컨트롤러에서 HttpServletRequest
대신 MultiPartHttpServletRequest
를 주입받을 수 있는데 이것을 사용하면 멀티파트와 관련된 처리를 편리하게 할 수 있다
but> MultipartFile
을 사용하는 것이 더 편하기 때문에 잘 사용하진 않는다
// Controller
@PostMapping("/upload")
public String saveFileV1(HttpServletRequest request) throws ServletException, IOException {
Collection<Part> parts = request.getParts();
log.info("parts={}", parts);
for (Part part : parts) {
log.info("==== PART ====");
log.info("name={}", part.getName());
Collection<String> headerNames = part.getHeaderNames();
for (String headerName : headerNames) {
log.info("header {}: {}", headerName, part.getHeader(headerName));
}
// content-disposition; filename
log.info("submittedFilename={}", part.getSubmittedFileName());
log.info("size={}", part.getSize()); // part body size
// 데이터 읽기 ( body에 있는 데이터 읽기 )
InputStream inputStream = part.getInputStream();
String body = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
log.info("body={}", body);
}
return "upload-form";
}
// 결과
==== PART ====
name=itemName
header content-disposition: form-data; name="itemName"
submittedFileName=null
size=4
body=test
==== PART ====
name=file
header content-disposition: form-data; name="file"; filename="conversionService.JPG"
header content-type: image/jpeg
submittedFileName=conversionService.JPG
size=28661
body=���� JFIF x x ���Exif MM * ;
getParts()
를 통해 문자와 파일 전송 두 개로 나뉘어져 있음
parts.getName()
: HTML의 input 태그의 name 속성에 지정한 이름을 반환
parts.getHeaderNames()
: parts도 헤더와 바디로 구분, parts의 모든 헤더 이름을 반환
문자라면 content-disposition 헤더가 존재
파일이라면 content-disposition, content-type 헤더가 존재
part.getHeader(헤더이름)
: parts의 헤더이름으로 헤더 가져오기
문자라면 content-disposition에 name이 있음
파일이라면 content-disposition에 name, filename이 있음
part.getSubmittedFileName()
: content-disposition에 있는 filename을 반환 ( 클라이언트가 전달한 파일명 )
part.getInputStream()
: parts의 body에 있는 데이터를 읽기
StreamUtils.copyToString()
: 읽은 바이너리 데이터를 문자로 변환, 인코딩 방식 지정 필수
public class ServletUploadControllerV2 {
@Value("${file.dir}")
private String fileDir;
@PostMapping("/upload")
public String saveFileV1(HttpServletRequest request) throws ServletException, IOException {
...
// 파일 저장하기
if (StringUtils.hasText(part.getSubmittedFileName())) {
String fullPath = fileDir + part.getSubmittedFileName();
log.info("파일 저장 fullPath = {}", fullPath);
part.write(fullPath);
}
}
return "upload-form";
}
fild.dir=경로/
: application.propertoes에 파일 저장 경로 지정, 마지막에 /
붙여야함
@Value("${file.dir}")
: application.properties에 있는 file.dir이라는 속성을 그대로 가져온다
StringUtils.hasText(part.getSubmittedFileName())
: 전송된 파일이 있는지 확인
part.write(경로)
: part를 통해 전송된 데이터를 해당 경로에 저장
public class SpringUploadController {
@Value("${file.dir}")
private String fileDir;
@GetMapping("/upload")
public String newFile() {
return "upload-form";
}
@PostMapping("upload")
public String saveFile(@RequestParam String itemName,
@RequestParam MultipartFile file, HttpServletRequest request) throws IOException {
if (!file.isEmpty()) {
String fullPath = fileDir + file.getOriginalFilename();
log.info("파일 저장 fullPath={}", fullPath);
file.transferTo(new File(fullPath));
}
return "upload-form";
}
}
스프링은 MultipartFile
이라는 인터페이스로 멀티파트 파일을 편리하게 지원
HTML Form 의 name 에 맞춰 @RequestParam
을 적용하면 된다
file은 MultipartFile, 문자는 String 을 사용
input 태그의 name 속성과 파라미터 이름이 동일하면 @RequestParam
에 이름 생략 가능
@ModelAttribute
에서도 MultipartFile 사용 가능
file.getOriginalFilename()
: 사용자가 업로드한 파일명
file.transferTo()
: 파일 저장
상품 = 상품 이름, 첨부 파일 1개, 이미지 파일 여러 개
첨부파일 업로드 및 다운로드 가능
업로드한 이미지를 웹 브라우저에서 확인 가능
Item : id / 상품 이름 / UploadFile
(첨부파일) / List<UploadFile>
(이미지 파일)
UploadFile : 사용자가 업로드한 이름 / 서버에 저장되는 이름
ItemRepository : Item을 Map 객체에 저장 및 조회
ItemForm : id / 상품 이름 / MultipartFile
(첨부파일) / List<MultipartFile>
(이미지 파일)
FileStore
getFullPath()
: 파일 이름을 받아서 전체 경로 + 파일명을 반환
storeFile()
: MultipartFile 을 받아서 UploadFile 을 반환
MultipartFile 에서 사용자가 업로드한 파일명과 확장자를 추출
UUID 를 이용해 서버에 저장하는 파일명 생성하고, 추출한 확장자를 붙여서 저장
서버에 저장한 이름과 사용자가 업로드한 이름을 가진 UploadFile 반환
storeFiles()
List<MultipartFile>
을 받아 List<UploadFile>
을 반환
storeFile()
을 이용
ItemController
@ModelAttribute ItemForm
을 통해 데이터를 받는다
FileStore.storeFile()
을 통해 UploadFile 을 반환 ( 첨부파일 )
FileStore.storeFiles()
를 통해 List<UploadFile>
을 반환
Item 객체를 만들어 ItemRepository에 저장
@Data
public class UploadFile {
private String uploadFileName; // 고객이 업로드한 파일명
private String storeFileName; // 서버 내부에서 관리하는 파일명
}
위처럼 사용자가 업로드 하는 파일명과 실제로 서버에 저장되는 파일명을 다르게 해야한다
여러 명의 사용자가 동일한 이름의 파일을 업로드하면 파일이 덮어씌워질 수 있기 때문
이를 방지하기 위해 서버에 저장되는 파일명은 UUID 같은 것을 통해 겹치지 않도록 설정해야한다
경로를 저장할 때 전체 경로를 저장하지는 않고, 공통부분을 제외한 일부 상대경로만 저장
<input type="file" multiple="multiple" name="imageFiles" >
input 태그에 multiple 속성을 "mlultiple"로 하면 여러 개의 파일을 업로드 할 수 있다
Java 에서 List<MultipartFile>
로 받을 수 있다
public class ItemController {
@PostMapping("/items/new")
public String saveItem(@ModelAttribute ItemForm form, RedirectAttributes redirectAttributes) throws IOException {
UploadFile attachFile = fileStore.storeFile(form.getAttachFile()); // 첨부파일 ( 하나만 가능 )
List<UploadFile> storeImageFiles = fileStore.storeFiles(form.getImageFiles()); // 이미지 ( 여러 개 가능 )
}
Item item = new Item();
item.setItemName(form.getItemName());
item.setAttachFile(attachFile);
item.setImageFiles(storeImageFiles);
itemRepository.save(item);
redirectAttributes.addAttribute("itemId", item.getId());
return "redirect:/items/{itemId}";
}
DB에 저장할 때 실제로 파일 자체를 저장하지 않고( MultipartFile
) 파일이 저장된 경로 같은 것만 저장( UploadFile
)
파일들은 실제 스토리지 같은 곳에 저장
그래서 form에서 받은 ItemForm의 첨부파일과, 이미지 파일을 FileStore의 메서드를 통해 스토리지에 저장하고 파일 이름을 가진 UploadFile 객체를 만들어 반환
변환한 것을 가지고 Item 객체를 만들어 ItemRepository를 통해 저장 ( DB 저장 )
<!-- Thymeleaf -->
상품명: <span th:text="${item.itemName}">상품명</span><br/>
첨부파일: <a th:if="${item.attachFile}" th:href="|/attach/${item.id}|"
th:text="${item.getAttachFile().getUploadFileName()}" /><br/>
<img th:each="imageFile : ${item.imageFiles}"
th:src="|/images/${imageFile.getStoreFileName()}|" width="300" height="300"/>
첨부파일은 저장한 파일 이름이 아니라 고객이 업로드한 이름이 보여지도록
item.getAttachFile() ➜ 반환형이 UploadFile
item.getAttachFile().getUploadFileName() ➜ UploadFile.getUploadFileName()
/attach/${item.id}
: 첨부파일의 다운로드 링크, 이 URL을 처리하는 메서드나 컨트롤러가 필요
이미지는 여러 개가 올 수 있기 때문에 th:each
로 작성
/images/${imageFile.getStoreFileName()}
: 이미지를 보여주기 위한 경로, 이 URL을 처리하는 메서드나 컨트롤러 필요
public class ItemController {
@ResponseBody
@GetMapping("/images/{filename}")
public Resource downloadImage(@PathVariable String filename) throws MalformedURLException {
return new UrlResource("file:" + fileStore.getFullPath(filename));
}
}
getFullPath()
를 통해 파일이 저장된 위치 + 파일명의 형태로 만든다
UrlResource를 통해 경로에 있는 파일에 접근해서 파일을 스트림으로 반환
즉, UrlResource로 이미지 파일을 읽어서 @ResponseBody 로 이미지 바이너리를 반환
but> 보안에는 취약하기 때문에 체크로직 같은 것을 넣으면 좋다
public class ItemController {
@GetMapping("/attach/{itemId}")
public ResponseEntity<Resource> downloadAttach(@PathVariable Long itemId) throws MalformedURLException {
Item item = itemRepository.findById(itemId);
String storeFileName = item.getAttachFile().getStoreFileName();
String uploadFileName = item.getAttachFile().getUploadFileName();
UrlResource resource = new UrlResource("file:" + fileStore.getFullPath(storeFileName));
log.info("uploadFileName={}", uploadFileName);
String encodeUploadFileName = UriUtils.encode(uploadFileName, StandardCharsets.UTF_8);
String contentDisposition = "attachment; filename=\"" + encodeUploadFileName + "\"";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
.body(resource);
}
}
DB에 저장되는 id가 아니라 itemID로 한 이유
특정 상품에 접근할 수 있는지 확인을 위한 정보 및 로직이 있다고 가정했을 때
item에 접근할 수 있는 사용자만 첨부파일을 다운로드 할 수 있는 기능을 넣으려면 itemId로 하는게 좋다
다운로드 받을 때 사용자가 업로드한 파일명이 나와야하기 때문에 업로드 이름 필요
attachment; filename="업로드 파일명"
형태로 작성UriUtils.encode()
: 다운로드 받을 때 한글로 된 파일명이 깨질 수도 있기 때문에 추가ResponseEntity를 반환할 때 다운로드를 받으려면 Content-Disposition
헤더가 필요하기 때문에 추가
헤더가 없는 경우 첨부파일이나 이미지가 열려서 내용이 보여지기만 한다
헤더를 추가하면 웹 브라우저가 첨부파일이라고 인식해서 파일을 다운로드 받는다