서블릿 파일 업로드 -> 스프링 파일 업로드
1. 파일 업로드
HTML 폼 전송 방식 2가지
- application/x-www-form-urlencoded (문자만 전송)
- multipart/form-data (문자 + 바이너리 데이터 함께 전송)
1) application/x-www-form-urlencoded 방식
- 폼 데이터를 서버로 전송하는 가장 기본적인 방법 (문자만 전송할 때)
- Form 태그에 별도의 enctype 옵션 X
-> 웹 브라우저는 요청 HTTP 메시지의 헤더에 기본적으로
Content-Type : application/x-www-form-urlencoded 추가
-> 폼에 입력한 전송할 데이터를 HTTP Body에 문자로 username=kim&age=20 같이 &로 구분해서 전송
2) multipart/form-data 방식
- Form 태그에 별도의 enctype = "multipart/form-data" 지정 (다른 종류의 여러 파일과 폼의 내용 함께 전송 = multipart)
- 웹 브라우저가 생성한 요청 HTTP 메시지
- Content-Type : multipart/form-data
- 각각의 전송 항목 구분 (boundary)
- Content-Disposition (항목별 헤더) : username / age / file1 (부가정보) 로 분리
- 일반 데이터 -> 각 항목별로 문자 전송
파일 데이터 -> 파일 이름(filename), Content-Type(자동) 추가 -> 바이너리 데이터 전송 - 각각의 항목을 구분해서(Part) 한번에 전송
2. 서블릿 파일 업로드
2-1. 서블릿 파일 업로드 알아보기
1) 서블릿 파일 업로드 실행
(1) 컨트롤러 - ServletUploadControllerV1
- 서블릿 request에서 파일 정보 확인
- request.getParts() : multipart/form-data 전송 방식에서 각각 분리된 항목을 받아서 확인
package hello.upload.controller;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.Part;
import java.io.IOException;
import java.util.Collection;
@Slf4j
@Controller
@RequestMapping("/servlet/v1")
public class ServletUploadControllerV1 {
@GetMapping("/upload")
public String newFile() {
return "upload-form";
}
@PostMapping("/upload")
public String saveFileV1(HttpServletRequest request) throws ServletException, IOException {
log.info("request={}", request);
String itemName = request.getParameter("itemName");
log.info("itemName={}", itemName);
Collection<Part> parts = request.getParts();
log.info("parts={}", parts);
return "upload-form";
}
}
(2) 뷰 - upload-form.html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
</head>
<body>
<div class="container">
<div class="py-5 text-center">
<h2>상품 등록 폼</h2>
</div>
<h4 class="mb-3">상품 입력</h4>
<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>
</div> <!-- /container -->
</body>
</html>
(3) HTTP 요청 메시지 확인하는 로그 설정 - application.properties
logging.level.org.apache.coyote.http11=debug
(4) 실행
HTTP 요청 메시지 로그
- Content-Type : multipart/form-data
- boundary로 구분된 part 확인
컨트롤러에서 찍은 로그
- request 객체 정보
- itemName 파라미터 정보
- parts 구분된 두 파트
2) 멀티파트 사용 옵션
(1) 업로드 사이즈 제한
- 사이즈를 넘으면 SizeLimitExceededException 예외 발생
- max-file-size : 파일 하나의 최대 사이즈, 기본 1MB
- max-request-size : 멀티파트 요청 하나에 업로드 할 수 있는 여러 파일의 전체 사이즈, 기본 10MB
spring.servlet.multipart.max-file-size=1MB
spring.servlet.multipart.max-request-size=10MB
(2) 멀티파트 처리 여부
- 옵션 끄기 (false)
- 멀티파트 관련 처리 X
- request = RequestFacade - HttpServletRequest의 기본객체
itemName = null
parts = [ ]
request=org.apache.catalina.connector.RequestFacade@xxx
itemName=null
parts=[]
- 옵션 켜기 (true) - 기본값
- 멀티파트 처리 O
- request = RequestFacade -> StandardMultipartHttpServletRequest
💡 참고
spring.servlet.multipart.enabled = true -> 스프링의 DispatcherServlet에서 멀티파트 리졸버(MultipartResolver) 실행
멀티파트 리졸버
: HttpServletRequest -> MultipartHttpServletRequest 객체로 변환해서 반환 (HttpServletRequest 의 자식 인터페이스)
-> 멀티파트 관련 추가 기능 제공
근데 스프링의 MultipartFile을 주로 사용해서 MultipartHttpServletRequest 는 잘 사용 X
2-2. 서블릿으로 실제 파일을 서버에 업로드
1) 파일 저장 경로 - application.properties
- 주의: 마지막 슬래시(/) 포함 !
file.dir=/C:/study/file/
2) 컨트롤러 - ServletUploadControllerV2
package hello.upload.controller;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.util.StreamUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.Part;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
@Slf4j
@Controller
@RequestMapping("/servlet/v2")
public class ServletUploadControllerV2 {
@Value("${file.dir}")
private String fileDir;
@GetMapping("/upload")
public String newFile() {
return "upload-form";
}
@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();
log.info("parts={}", parts);
for (Part part : parts) {
log.info("==== PART ====");
log.info("name={}", part.getName());
// parts의 헤더
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());
// parts 바디의 데이터 읽기
InputStream inputStream = part.getInputStream();
String body = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
log.info("body={}", body);
// 파일에 저장하기
if (StringUtils.hasText(part.getSubmittedFileName())) {
String fullPath = fileDir + part.getSubmittedFileName();
log.info("파일 저장 fullPath={}", fullPath);
part.write(fullPath);
}
}
return "upload-form";
}
}
(1) 파일 저장 경로 주입
@Value("${file.dir}")
private String fileDir;
- application.properties에서 설정한 file.dir의 값 주입
(2) Part
// Part 1
------WebKitFormBoundaryYpfJu8ZzzymRMXhv
// part의 Header
Content-Disposition: form-data; name="itemName"
// part의 Body
itemC
// Part 2
------WebKitFormBoundaryYpfJu8ZzzymRMXhv
// part의 Header
Content-Disposition: form-data; name="file"; filename="ë미리.jpg"
Content-Type: image/jpeg
// part의 Body (파일 데이터)
ÿØÿà JFIF ,, ÿÛ C
@PostMapping("/upload")
public String saveFileV2(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());
// parts의 헤더
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());
// parts 바디의 데이터 읽기
InputStream inputStream = part.getInputStream();
String body = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
log.info("body={}", body);
// 파일에 저장하기
if (StringUtils.hasText(part.getSubmittedFileName())) {
String fullPath = fileDir + part.getSubmittedFileName();
log.info("파일 저장 fullPath={}", fullPath);
part.write(fullPath);
}
}
멀티파트 형식은 전송 데이터를 각 항목(Part)으로 나누어 전송
parts에는 이렇게 나누어진 Part 데이터가 각각 담김
서블릿이 제공하는 Part는 멀티파트 형식을 편리하게 읽을 수 있는 다양항 메소드 제공
- Part 주요 메소드
- part.getSubmittedFileName() : 클라이언트가 전달한 파일명
- part.getInputStream() : Part의 전송 데이터를 읽을 수 있음 (문자 <-> 바이너리 데이터 : Charsets 지정 필수)
- part.write() : Part를 통해 전송된 데이터를 저장할 수 있음
결과 로그
파일 저장 경로에 실제 파일 저장
---> 서블릿 파일 업로드 Part 기능 -> 쫌 복잡함
3. 스프링 파일 업로드 - MultipartFile
1) 컨트롤러 -SpringUploadController
package hello.upload.controller;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletRequest;
import java.io.File;
import java.io.IOException;
@Slf4j
@Controller
@RequestMapping("/spring")
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 {
log.info("request={}", request);
log.info("itemName={}", itemName);
log.info("multipartFile={}", file);
if (!file.isEmpty()) {
String fullPath = fileDir + file.getOriginalFilename();
log.info("파일 저장 fullPath={}", fullPath);
file.transferTo(new File(fullPath));
}
return "upload-form";
}
}
- @RequestParam MultipartFile file
업로드하는 HTML Form의 name에 맞춰 @RequestParam 적용
+) @ModelAttribute 에서도 MultipartFile을 동일하게 사용 가능 - MultipartFile 주요 메소드
- file.getOriginalFilename() : 업로드 파일 명
- file.transferTo( ... ) : 파일 저장
(파일명을 받아서 경로 생성 -> 경로에다가 파일 객체를 만들어서 저장)
2) 실행
실행 로그
파일 경로에 실제 파일 저장
4. 파일 업로드, 다운로드 구현
1) 요구사항
- 상품명
- 첨부파일 1개 -> 업로드, 다운로드
- 이미지 파일 여러개 -> 웹 브라우저에서 확인
* 패키지 구조
2) Item - 상품 도메인
package hello.upload.domain;
import lombok.Data;
import java.util.List;
@Data
public class Item {
private Long id;
private String itemName;
private UploadFile attachFile; // 첨부파일 1개
private List<UploadFile> imageFiles; // 이미지파일 여러개
}
3) ItemRepository - 상품 저장소
package hello.upload.domain;
import org.springframework.stereotype.Repository;
import java.util.HashMap;
import java.util.Map;
@Repository
public class ItemRepository {
private final Map<Long, Item> store = new HashMap<>();
private long sequence = 0L; // Item Id 생성
public Item save(Item item) {
item.setId(++sequence);
store.put(item.getId(), item);
return item;
}
public Item findById(Long id) {
return store.get(id);
}
}
4) UploadFile - 업로드 파일 정보 보관
package hello.upload.domain;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class UploadFile {
private String uploadFileName; // 고객이 업로드한 파일명
private String storeFileName; // 서버에서 관리되는 파일명
}
- 주의) 고객이 업로드한 파일명과 서버에서 관리되는 파일명이 달라야 함 !
-> 서로 다른 고객이 같은 파일명으로 업로드하면 충돌날 수 있음
5) FileStore - 서버에 파일 저장하는 로직
package hello.upload.file;
import hello.upload.domain.UploadFile;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Component
public class FileStore {
@Value("${file.dir}")
private String fileDir;
// 파일 저장 경로(fullPath) 생성
public String getFullPath(String filename) {
return fileDir + filename;
}
// 파일 여러개 저장
public List<UploadFile> storeFiles(List<MultipartFile> multipartFiles) throws IOException {
List<UploadFile> storeFileResult = new ArrayList<>();
for (MultipartFile multipartFile : multipartFiles) {
if (!multipartFile.isEmpty()) {
// 서버에 파일 저장하는 메소드 (밑에 생성)
storeFileResult.add(storeFile(multipartFile));
}
}
return storeFileResult;
}
// 서버에 파일 저장 ( 파일 정보 반환 )
public UploadFile storeFile(MultipartFile multipartFile) throws IOException {
if (multipartFile.isEmpty()) {
return null;
}
String originalFilename = multipartFile.getOriginalFilename(); // image.png
String storeFileName = createStoreFileName(originalFilename); // dfw-dwf-dwf.png
multipartFile.transferTo(new File(getFullPath(storeFileName))); // 서버에 저장된 파일명으로 실제 파일 생성
return new UploadFile(originalFilename, storeFileName);
}
// 서버에 저장되는 파일명 생성 메소드
private String createStoreFileName(String originalFilename) {
// 확장자 추출
String ext = extractExt(originalFilename);
// UUID로 서버에 저장되는 파일명 생성 + 확장자
String uuid = UUID.randomUUID().toString();
// 서버에 저장되는 파일명 반환 (dfw-dwf-dwf.png)
return uuid + "." + ext;
}
// 확장자 추출 메소드
private String extractExt(String originalFilename) {
// "."의 마지막 인덱스 위치
int pos = originalFilename.lastIndexOf(".");
// 그 "."의 다음 글자부터 출력 (=확장자)
return originalFilename.substring(pos + 1);
}
}
6) ItemForm - 상품 저장용 폼
package hello.upload.controller;
import lombok.Data;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
@Data
public class ItemForm {
private Long itemId;
private String itemName;
private MultipartFile attachFile;
private List<MultipartFile> imageFiles; // 이미지 다중 업로드
}
📌 Item vs ItemForm
Item : DB에 저장할 때 파일 경로, 이름 등만 저장하기 위한 객체
ItemForm : MultipartFile같은 파일 객체를 저장해서 서버 <-> 뷰로 왔다갔다하기 위한 객체
7) ItemController
package hello.upload.controller;
import hello.upload.domain.Item;
import hello.upload.domain.ItemRepository;
import hello.upload.domain.UploadFile;
import hello.upload.file.FileStore;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.tomcat.util.buf.UriUtil;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import org.springframework.web.util.UriUtils;
import java.io.IOException;
import java.net.MalformedURLException;
import java.nio.charset.StandardCharsets;
import java.util.List;
@Slf4j
@Controller
@RequiredArgsConstructor
public class ItemController {
private final ItemRepository itemRepository;
private final FileStore fileStore;
// 파일 등록 폼 조회
@GetMapping("/items/new")
public String newItem(@ModelAttribute ItemForm itemForm) {
return "item-form";
}
// 파일 업로드
@PostMapping("/items/new")
public String saveItem(@ModelAttribute ItemForm form, RedirectAttributes redirectAttributes) throws IOException {
// 파일 경로에 파일 1개 저장
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}";
}
// 상품 조회
@GetMapping("/items/{id}")
public String items(@PathVariable Long id, Model model) {
Item item = itemRepository.findById(id);
model.addAttribute("item", item);
return "item-view";
}
// 이미지 조회
@ResponseBody
@GetMapping("/images/{filename}")
public Resource downloadImage(@PathVariable String filename) throws MalformedURLException {
// UrlResource를 이용해서 파일에 직접 접근해서 바이트 정보(이미지 바이너리)로 반환
return new UrlResource("file:" + fileStore.getFullPath(filename));
}
// 첨부파일 다운로드
@GetMapping("/attach/{itemId}")
public ResponseEntity<Resource> downloadAttach(@PathVariable Long itemId) throws MalformedURLException {
Item item = itemRepository.findById(itemId);
String uploadFileName = item.getAttachFile().getUploadFileName(); // 다운로드 받을 때 고객에 업로드한 파일명으로 보이기 위함
String storeFileName = item.getAttachFile().getStoreFileName();
// UrlResource를 이용해서 파일에 직접 접근해서 바이트 정보(이미지 바이너리)로 반환
UrlResource resource = new UrlResource("file:" + fileStore.getFullPath(storeFileName));
log.info("uploadFileName={}", uploadFileName);
// 한글 깨짐 방지
String encodedUploadFileName = UriUtils.encode(uploadFileName, StandardCharsets.UTF_8);
// 다운로드 위한 규약
String contentDisposition = "attachment; filename=\"" + encodedUploadFileName + "\"";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition) // 헤더를 안넣으면 링크로 이동해버림 (다운로드 X)
.body(resource);
}
}
- 이미지 조회
- @ResponseBody + byte[] 또는 Resource 반환
-> UrlResource를 사용해서 파일 경로에 직접 접근해서 이미지 파일을 읽어서 바이트 정보(이미지 바이너리) 반환
-> <img> 에서는 이 바이트 정보를 읽어서 이미지로 반환
- @ResponseBody + byte[] 또는 Resource 반환
- 첨부파일 다운로드
- 파일 다운로드 시 권한 체크 -> 요청 파라미터에 넣어줌 (ex. 로그인한 회원 아이디)
- 상품 아이디로 상품 조회하기 위해 itemId를 요청 파라미터에 넣어줌
- Content-Disposition 헤더에 attachment; filename="업로드 파일명"
- ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition) .body(resource);
8) 등록 폼 뷰 - item-form.html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
</head>
<body>
<div class="container">
<div class="py-5 text-center">
<h2>상품 등록</h2>
</div>
<form th:action method="post" enctype="multipart/form-data">
<ul>
<li>상품명 <input type="text" name="itemName"></li>
<li>첨부파일<input type="file" name="attachFile" ></li>
<li>이미지 파일들<input type="file" multiple="multiple" name="imageFiles" ></li>
</ul>
<input type="submit"/>
</form>
</div> <!-- /container -->
</body>
</html>
- multiple = "multiple" : 다중 파일 업로드
- ItemForm의 private List<MultipartFile> imageFiles 에서 다중 이미지 파일을 받음
9) 상품 조회 뷰 - item-view.html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
</head>
<body>
<div class="container">
<div class="py-5 text-center">
<h2>상품 조회</h2>
</div>
상품명: <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"/>
</div> <!-- /container -->
</body>
</html>
- 첨부파일 : 링크 -> 클릭 시 다운로드 - 업로드 파일명
- 다중 이미지파일 : each로 <img> 태그 반복 출력 - 서버 저장 파일명
10) 실행
'Spring' 카테고리의 다른 글
[인프런/스프링 DB 1편] 1. JDBC (0) | 2023.06.17 |
---|---|
[Spring] 파일저장 transferTo, InputStream OutputStream (0) | 2023.06.06 |
[인프런/스프링 MVC 2편] 10. 스프링 타입 컨버터 (0) | 2023.05.31 |
[인프런/스프링 MVC 2편] 9. API 예외 처리 (1) | 2023.05.31 |
[인프런/스프링 MVC 2편] 8. 예외 처리와 오류 페이지 (0) | 2023.05.27 |