multipart/form-data
는 form
데이터를 전송할 때 사용되는 인코딩 방식 중 하나입니다. 주로 파일 업로드와 같이 바이너리 데이터를 전송할 때 사용됩니다.
...
<form method="post" enctype="multipart/form-data">
<input type="file" name="file">
<input type="text" name="itemName">
<input type="submit"/>
</form>
...
multipart/form-data
를 바디에 담아 보낼 때는 다음과 같은 규칙을 따른다.Content-Type : multipart/form-data; boundary =
boundary
값을 기준으로 타입이 뭔지, 그 안에 값은 뭔지를 담아서 전송한다. 그렇기 때문에 서버에서 처리할 때 해당 타입에 알맞게 처리할 수 있게 된다.
boudary
뒤에 --
값은 종료를 알리는 값이다.implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
multipart/form-data
를 처리하기 위해서StandardServletMultipartResolver
가 동작한다. resolveMulitipart()
메소드에서 각 part를 resolve한다.return new StandardMultipartHttpServletRequest(request, this.resolveLazily)
이 부분을 따라가 보면
parseRequest(request)
가 등장하고 이 부분을 다시 따라가 보면 각 part를 일일이 resolve 하고 있다.
다음 코드는 multipart/form-data를 직접 컨트롤러에서 resolve해주는 것이다. StandardServletMultipartResolver
구현을 단순화 한 것으로 보면된다.
여기서 form에서 file type 같은 경우는 헤더에 업로드한 파일 이름이 filename에 저장되어 있다. 그렇기 때문에 해당 part가 file type인지 구분하는 방법은 StringUtils.hasText(part.getSubmittedFileName()))
를 이용해서 할 수 있다. 파일이라면 part.write(fullPath)
를 이용해 파일을 저장한다.
@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";
}
BasicController
@RestController
@RequiredArgsConstructor
public class BasicController{
private final BasicService service;
@Value("${file.dir}")
private String fileDir;
@PostMapping("/upload")
public String saveFile(@RequestParam String itemName, @RequestParam MultipartFile file) {
if (!file.isEmpty()) {
String fullPath = fileDir + file.getOriginalFilename
log.info("파일 저장 fullPath={}", fullPath);
service.processFile(file,fullPath);
}
return filePath;
}
}
BasicService
@Service
public class BasicService {
public void processFile(MultipartFile file,String fullPath) {
if (!file.isEmpty()) {
try {
file.transferTo(new File(fullPath));
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
}
BasicService
에서 지금 transferTo() 메소드를 사용하고 있다. 이 때 코드를 따라가 보면 Stream.Utils
에서 다음과 같이 InputStream을 받아 OutputStream으로 Copy하고 있다. 여기서 BUFFER_SIZE
가 정의 되어 있는데 4096로 되어 있기 때문에 한번에 4KB만큼 버퍼에 담아 두었다가 저장하고 있다.
public static int copy(InputStream in, OutputStream out) throws IOException {
Assert.notNull(in, "No InputStream specified");
Assert.notNull(out, "No OutputStream specified");
int byteCount = 0;
byte[] buffer = new byte[BUFFER_SIZE];
int bytesRead;
while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
byteCount += bytesRead;
}
out.flush();
return byteCount;
}
만약 transferTo메소드를 사용하지 않고 아래와 같은 방식으로 파일을 저장하려고 한다면 OutOfMemoryError
가 발생한다. 왜냐하면 앱에 할당된 힙 메모리는 한정되어 있기 때문이다. 따라서 버퍼를 사용하지 않으면 멀티파트로 전달된 데이터를 메모리에 모두 저장하려하기 때문에 에러가 발생한다.
혹은 파일을 분할해서 저장하는 방법도 있는데 이 방법은 다음에 다뤄보기로 하자.
Path filepath = Paths.get(fileDir, file.getOriginalFilename());
try (OutputStream os = Files.newOutputStream(filepath)) {
os.write(file.getBytes());
} catch (IOException e) {
throw new RuntimeException(e);
}