파일 업로드 소개
일반적으로 사용하는 HTML Form을 통한 파일 업로드를 이해하려면
먼저 폼을 전송하는 다음 두 가지 방식의 차이를 이해해야 한다.
application/x-www-form-urlencoded
- HTML 폼 데이터를 서버로 전송하는 가장 기본적인 방법이다.
- Form 태그에 별도의
enctype
옵션이 없으면 웹 브라우저는 요청 HTTP 메시지의 헤더에 다음 내용을 추가한다.Content-Type: application/x-www-form-urlencoded
- 그리고 폼에 입력한 전송할 항목을 HTTP Body에 문자로
username=kim&age=20
와 같이&
로 구분해서 전송한다. - 다만 문제점은 파일은 문자가 아니라 바이너리 데이터를 전송해야 한다.
- 바이너리 데이터만 전송한다면 모를까 실무에서는 문자와 바이너리를 동시에 전송해야 하는 상황이 대부분이다.
- 이 문제를 해결하기 위해 HTTP는
multipart/form-data
라는 전송 방식을 제공한다.
multipart/form-data
multipart/form-data
전송 방식은application/x-www-form-urlencoded
전송 방식과 메시지를 생성하는 방법부터 다르다.application/x-www-form-urlencoded
- HTTP Body에 문자로
username=kim&age=20
와 같이&
로 구분해서 전송한다.
- HTTP Body에 문자로
multipart/form-data
- HTTP Body에 각각의 전송 항목이 구분되어 있다.
application/x-www-form-urlencoded
에서는username=kim&age=20
와 같이 전송 항목들이 연결되어 있다.multipart/form-data
에서는username
과age
가 구분되어 있다.Content-Disposition
이라는 항목별 헤더가 추가되어 있고 여기에 부가 정보가 있다.- 파일의 경우
intro.png
처럼 파일 이름과image/png
처럼Content-Type
이 추가되고 바이너리 데이터가 전송된다. - 구분이 되어있다고 따로따로 보내는 게 아니라 구분이 되어 있는 상태로 한 번에 전송한다.
프로젝트 생성
- 스프링 이니셜라이저를 통해 프로젝트를 생성하자.
- 프로젝트 선택
- Project
- Gradle - Groovy Project
- Language
- Java
- Spring Boot
- 3.x.x
- Project
- Project Metadata
- Group
- hello
- Artifact
- typeconverter
- Name
- typeconverter
- Package name
- hello.typeconverter
- Packaging
- Jar (주의!)
- Java
- 17 또는 21
- Group
- Dependencies
- Spring Web
- Thymeleaf
- Lombok
- 프로젝트 선택
서블릿과 파일 업로드1
HTML 파일 만들기
- 우선은 파일을 업로드하기 위해 HTML을 생성하자.
<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>
컨트롤러 만들기
- 파일을 업로드하기 위해 간단한 컨트롤러를 만들자.
@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";
}
테스트
application.properties
에logging.level.org.apache.coyote.http11=trace
를 추가해주자.- 스프링 부트 3.2 이전이면 trace 대신에 debug를 사용해도 된다.
- 상품명으로 “테스트”를 입력하고, 적당한 파일과 함께 제출 버튼을 눌러보자.
- 그럼 아래와 같은 결과가 나온다.
request=org.springframework.web.multipart.support.StandardMultipartHttpServletRequest@a467fff
itemName=테스트
parts=[org.apache.catalina.core.ApplicationPart@48180c7, org.apache.catalina.core.ApplicationPart@be148f]
- 콘솔을 확인해보면
multipart/form-data
방식으로 전송된 것을 확인할 수 있다.
멀티파트 사용 옵션
- 업로드 사이즈 제한
spring.servlet.multipart.max-file-size=1MB
- 파일 하나의 최대 사이즈
- 기본값 : 1MB
spring.servlet.multipart.max-request-size=10MB
- 멀티파트 요청 하나에 업로드한 여러 파일들 크기의 전체 합이다.
- 기본값 : 10MB
- 큰 파일을 무제한 업로드하게 둘 수는 없으므로 업로드 사이즈를 제한할 수 있다.
- 사이즈를 넘으면 예외( SizeLimitExceededException )가 발생한다.
- 멀티파트 금지
spring.servlet.multipart.enabled=false
multipart/form-data
방식 자체를 금지시키는 방법이다.- 그래서 해당 옵션을 false로 바꾸면 itemName도 null로 넘어오는 것을 볼 수 있다.
- 기본값 : true
서블릿과 파일 업로드2
- 이번에는 파일을 실제로 업로드해보자.
경로 만들기
application.properties
에 실제 파일을 업로드할 경로를 정의해두자.- 해당 경로에 실제 폴더를 미리 만들어둬야 한다.
- 안 그러면 파일 업로드할 때 예외가 발생한다.
application.properties
에서 설정할 때 마지막에/ (슬래시)
가 포함된 것을 꼭 확인하자.- 윈도우에서는 경로를 못 찾을 수도 있다.
- IOS에서는 잘 기억이 안 나는데 Linux의 경우에는
/files
라고 하면 절대 경로로 찾으려고 한다. - 그런데 윈도우에서는 스프링 부트의 내장 톰캣때문에 그런지는 모르곘지만 톰캣 경로도 같이 들어가버린다.
- 예시 :
C:\Users\leews\AppData\Local\Temp\tomcat.8080.11212911520451062349\work\Tomcat\localhost\ROOT\file\test.txt
- 예시 :
- 그래서 윈도우에서는
file.dir=C:/files/
처럼 명시해줘야 한다.
- IOS에서는 잘 기억이 안 나는데 Linux의 경우에는
경로 가져오기
- 방금
application.properties
에서 설정한 경로 변수의 이름으로 경로를 불러오자.
@Value("${file.dir}")
private String fileDir;
파일 저장하기
- 이번에는 실제로 파일을 저장하는 기능을 만들어보자.
@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);
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
//데이터 읽기
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";
}
- 실제로 실행해보면 파일이 잘 올라가는 것을 확인할 수 있다.
- 다만 코드가 매우 길고 번거롭다.
- 그래서 스프링에서는
MultipartFile
이라는 인터페이스로 멀티파트 파일을 매우 편리하게 지원해준다.
스프링과 파일 업로드
- 이번에는 스프링이 제공하는
MultipartFile
인터페이스를 통해 파일 업로드를 구현해보자.
@PostMapping("/upload")
@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";
}
- 서블릿으로 업로드했을 때와 비교하면 코드 길이는 뒤로 미뤄도 사용 방식이 매우 직관적으로 바뀌었다.
- 사용하는 메소드명들이 눈에 띄게 직관적이다.
- 파일이 비어있는가?
- 파일의 원본명을 달라.
- 파일을 변환해달라.
- 다만 실제 폴더가 없으면 예외가 발생하는 건 서블릿과 동일하니 그건 참고하자.
예제로 구현하는 파일 업로드, 다운로드
- 실제 파일이나 이미지를 업로드, 다운로드 할 때는 실제 서비스 특성에 따라 몇 가지 고려할 점이 있다.
- 예시
- 첨부파일은 1개만 등록 가능하다.
- 이미지 파일은 여러 개 등록 가능하다.
- 첨부파일을 업로드 및 다운로드할 수 있다.
- 업로드한 이미지를 웹 브라우저에서 확인할 수 있다.
- 위 사항들을 고려해서 기능을 만들어보자.
도메인 만들기
- 파일 도메인
package hello.upload.domain;
import lombok.Data;
@Data
public class UploadFile {
private String uploadFileName; //사용자가 업로드한 실제 파일명
private String storeFileName; //서버 내부에서 관리하는 파일명
public UploadFile(String uploadFileName, String storeFileName) {
this.uploadFileName = uploadFileName;
this.storeFileName = storeFileName;
}
}
- 상품 도메인
package hello.upload.domain;
import lombok.Data;
import java.util.List;
@Data
public class Item {
private Long id;
private String itemName;
private UploadFile attachFile;
private List<UploadFile> imageFiles;
}
폼 객체 만들기
- 상품명, 1개의 첨부파일, 여러 개의 이미지 파일을 저장할 수 있는 폼 객체를 만들자.
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 List<MultipartFile> imageFiles;
private MultipartFile attachFile;
}
파일 관리 서비스 만들기
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;
/**
* 서버에 저장할 전체 파일 경로 구하기
* @param filename 서버에 저장할 파일명
* @return
*/
public String getFullPath(String filename) {
return fileDir + filename;
}
/**
* 복수 파일 저장하기
* @param multipartFiles 파일 목록
* @return
* @throws IOException
*/
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;
}
/**
* 단일 파일 저장하기
* @param multipartFile 단일 파일
* @return
* @throws IOException
*/
public UploadFile storeFile(MultipartFile multipartFile) throws IOException
{
if (multipartFile.isEmpty()) {
return null;
}
String originalFilename = multipartFile.getOriginalFilename();
String storeFileName = createStoreFileName(originalFilename);
multipartFile.transferTo(new File(getFullPath(storeFileName)));
return new UploadFile(originalFilename, storeFileName);
}
/**
* 서버 내부에서 관리하는 파일명은 유일한 이름을 생성하는 UUID를 사용해서 충돌하지 않도록 한다.
* - 예를 들어서 고객이 a.png 라는 이름으로 업로드 하면 51041c62-86e4-4274-801d-614a7d994edb.png 와 같이 저장한다.
* @param originalFilename
* @return
*/
private String createStoreFileName(String originalFilename) {
String ext = extractExt(originalFilename);
String uuid = UUID.randomUUID().toString();
return uuid + "." + ext;
}
/**
* 확장자를 별도로 추출해서 반환한다.
* @param originalFilename 원본 파일명
* @return
*/
private String extractExt(String originalFilename) {
int pos = originalFilename.lastIndexOf(".");
return originalFilename.substring(pos + 1);
}
}
상품 관리 서비스 만들기
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;
//상품 정보 저장
public Item save(Item item) {
item.setId(++sequence);
store.put(item.getId(), item);
return item;
}
//상품 정보 조회
public Item findById(Long id) {
return store.get(id);
}
}
업로드 컨트롤러 만들기
@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}";
}
상품 정보 조회 컨트롤러 만들기
@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로 이미지 파일을 읽어서 @ResponseBody로 이미지 바이너리를 반환한다.
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 storeFileName = item.getAttachFile().getStoreFileName(); //서버 내부의 파일명을 가져온다.
String uploadFileName = item.getAttachFile().getUploadFileName(); //사용자가 올린 시점의 원본 파일명을 가져온다.
//UrlResource로 이미지 파일을 읽는다.
UrlResource resource = new UrlResource("file:" + fileStore.getFullPath(storeFileName));
log.info("uploadFileName={}", uploadFileName);
String encodedUploadFileName = UriUtils.encode(uploadFileName, StandardCharsets.UTF_8); //UTF-8로 원본 파일명을 인코딩한다.
String contentDisposition = "attachment; filename=\"" + encodedUploadFileName + "\""; //다운받을 때의 파일명을 지정한다.
//요청 성공 메시지와 함께 HTTP 응답 메시지를 반환한다.
//HTTP 응답 헤더에는 파일 정보가 포함되어 있다.
//HTTP 응답 바디에는 파일 바이너리 데이터가 포함되어 있다.
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
.body(resource);
}
Base64 인코딩
- Base64 인코딩 방식을 통해서 바이너리 데이터를 문자열로 바꾸는 방법이 있긴하다.
- 다만 Base64는 바이너리 데이터를 아스키 코드 일부와 일대일로 매칭되는 문자열로 단순 치환하는 인코딩 방식이다.
- 그냥 어거지로 Base64를 사용하여 바이너리 데이터를 문자열로 만들어주는 뜻이다.
- Base64로 인코딩하면 UTF-8과 호환 가능한 문자열을 얻을 수 있다.
- 이미지 파일을 Base64로 인코딩하여 문자열로 바꿀 수 있다는 뜻이다.
- 그러면 json 형식으로 다른 문자열 데이터와 같이 보낼 수 있다는 장점이 있긴하다.
- 또한 문자열이기 때문에 전송이 안정적이며 데이터 호환성이 좋다는 장점도 있습니다.
- 다만 데이터를 실시간으로 주고 받는 환경에서는 보통은 Base64 인코딩 방식을 사용하지는 않는다.
- 왜냐하면 Base64로 인코딩된 데이터는 원래의 바이너리 데이터보다 용량이 더 커지기 때문이다.
- 그래서 실시간으로 대용량 데이터를 전송 및 저장할 때는 적합하지 않다.