본문 바로가기

스프링/Spring

서블릿의 파일 업로드 - 서블릿이 제공하는 Part

파일 업로드 소개

HTML Form을 통한 파일 업로드를 이해하려면 먼저 폼을 전송하는 다음 두 가지 방식의 차이를 이해해야 합니다.

 

HTML 폼 전송 방식

  • application/x-www-form-urlencoded
  • multipart/form-data

application/x-www-form-urlencoded 방식

<form action="/save" method="post">
    <input type="text" name="name"/>
    <input type="text" name="age"/>
    <input type="submit"/>
</form>

 

웹 브라우저가 생성한 요청 HTTP 메시지

POST /save HTTP/1.1
Host: Iocalhost:8080
Content-Type: application/x-www-form-urlencoded

name=as&age=20

 

application/x-www-form-urlencoded 방식은 HTML 폼 데이터를 서버로 전송하는 가장 기본적인 방법입니다. Form 태그에 별도의 enctype 옵션이 없으면 웹 브라우저는 요청 HTTP 메시지의 헤더에 다음 내용을 추가합니다.

Content-Type: application/x-www-form-urlencoded

그리고 폼에 입력한 전송할 항목을 HTTP Body에 문자로 name=as&age=20와 같이 &로 구분해서 전송합니다.

파일을 업로드하려면 파일은 문자가 아니라 바이너리 데이터를 전송해야 합니다.

문자를 전송하는 이 방식으로 파일을 전송하기는 어렵습니다.

그리고 또 한 가지 문제가 더 있는데, 보통 폼을 전송할 때 파일만 전송하지 않습니다.

 

다음 예를 보면

- 이름
- 나이
- 첨부파일

여기에서 이름과 나이도 전송해야 하고, 첨부파일도 함께 전송해야 합니다.

문제는 이름과 나이는 문자로 전송하고, 첨부파일은 바이너리로 전송해야 한다는 점입니다.

여기에서 문제가 발생한다. 문자와 바이너리를 동시에 전송해야 하는 상황이다.

이 문제를 해결하기 위해 HTTP는 multipart/form-data라는 전송 방식을 제공한다.

 

multipart/form-data 방식

<form action="/save" method="post" enctype="multipart/form-data">
	<input type="text" name="name"/>
	<input type="text" name="age"/>
	<input type="file" name="file1"/>
	<input type="submit"/>
</form>
POST /save HTTP/1.1
Host: localhost:8080
Content-Type: multipart/form-data; boundary=----- XXX
Content-Length: 10457
------XXX
Content-Disposition: form-data; narne="name"

as
------XXX
 Content-Disposition: form-data; narne="age"

20
------XXX
Content-Disposition: form-data; name="file1"; filename="intro.png"
Content-Type: image/png

109238a9o0p3eqwokjasdOgou3oinwoe9u34ouief...
------XXX--

 

이 방식을 사용하려면 Form 태그에 별도의 enctype="multipart/form-data"를 지정해야 합니다.

multipart/form-data 방식은 다른 종류의 여러 파일과 폼의 내용 함께 전송할 수 있습니다. (그래서 이름이 multipart입니다.)

 

폼의 입력 결과로 생성된 HTTP 메시지를 보면 각각의 전송 항목이 구분이 되어있습니다.

Content-Disposition이라는 항목별 헤더가 추가되어 있고 여기에 부가 정보가 있습니다.

예제에서는 name, age, file1이 각각 분리되어 있고, 폼의 일반 데이터는 각 항목별로 문자가 전송되고, 파일의 경우 파일 이름과 Content-Type이 추가되고 바이너리 데이터가 전송됩니다.

 

multipart/form-data는 이렇게 각각의 항목을 구분해서, 한 번에 전송하는 것입니다.

 

Part

multipart/form-dataapplication/x-www-form-urlencoded와 비교해서 매우 복잡하고 각각의 부분(Part)로 나누어져 있습니다.

그렇다면 이렇게 복잡한 HTTP 메시지를 서버에서 어떻게 사용하는지 다뤄보겠습니다.

 

서블릿과 파일 업로드

ServletUploadController

@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";
    }
}

resources/templates/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>

 

application.properties

HTTP 요청 메시지를 확인하기 위해 다음 옵션을 추가합니다.

logging.level.org.apache.coyote.http11=debug

 

실행

http://localhost:8080/servlet/v1/upload

실행해보면 logging.level.org.apache.coyote.http11 옵션을 통한 로그에서 multipart/formdata 방식으로 전송된 것을 확인할 수 있습니다.

Content-Type: multipart/form-data; boundary=----xxxx

------xxxx
Content-Disposition: form-data; name="itemName"

as
------xxxx
Content-Disposition: form-data; name="file"; filename="test.data"
Content-Type: application/octet-stream

sdklajkljdf...

 

멀티파트 사용 옵션

  • 업로드 사이즈 제한
spring.servlet.multipart.max-file-size=1MB
spring.servlet.multipart.max-request-size=10MB

큰 파일을 무제한 업로드하게 둘 수는 없으므로 업로드 사이즈를 제한할 수 있습니다.

사이즈를 넘으면 예외(SizeLimitExceededException)가 발생합니다.

max-file-size : 파일 하나의 최대 사이즈, 기본 1MB

max-request-size : 멀티파트 요청 하나에 여러 파일을 업로드할 수 있는데, 그 전체 합이다. 기본 10MB

 

  • spring.servlet.multipart.enabled 끄기(기본 true)
spring.servlet.multipart.enabled=false

spring.servlet.multipart.enabled 옵션을 끄면 서블릿 컨테이너는 멀티파트와 관련된 처리를 하지 않습니다.

그래서 결과 로그를 보면 request.getParameter("itemName"), request.getParts()의 결과가 비어있습니다.

 

서블릿으로 실제 파일 업로드하기

먼저 파일을 업로드를 하려면 실제 파일이 저장되는 경로가 필요합니다.

해당 경로에 실제 폴더를 만들어두고, 만들어진 경로를 입력합니다.

 

application.properties

file.dir=파일 업로드 경로 설정

 

ServletUploadControllerV2

@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 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) {

            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";
    }
}

application.properties 에서 설정한 file.dir의 값을 주입합니다.

 

멀티파트 형식은 전송 데이터를 하나하나 각각 부분(Part)으로 나누어 전송합니다. parts에는 이렇게 나누어진 데이터가 각각 담깁니다.

서블릿이 제공하는 Part 는 멀티파트 형식을 편리하게 읽을 수 있는 다양한 메서드를 제공합니다.

 

Part 주요 메서드

  • part.getSubmittedFileName() : 클라이언트가 전달한 파일명
  • part.getInputStream(): Part의 전송 데이터를 읽을 수 있다.
  • part.write(...): Part를 통해 전송된 데이터를 저장할 수 있다.

 

실행

http://localhost:8080/servlet/v2/upload

 

파일 저장 경로에 가보면 실제 파일이 저장된 것을 확인할 수 있습니다. 

 

결론

서블릿이 제공하는 Part는 편하기는 하지만, HttpServletRequest를 사용해야 하고, 추가로 파일 부분만 구분하려면 여러 가지 코드를 넣어야 합니다. 다음에는 스프링이 이 부분을 얼마나 편리하게 제공하는지 다루겠습니다.