HTTP Multipart

POST /save HTTP/1.1
Host: localhost:8080
Content-Type: multipart/form-data; boundary=-----XXX

-----XXX
Content-Disposition: form-data; name="username"

kim
-----XXX
Content-Disposition: form-data; name="age"

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

12368asdzxc9qwetwga35468asdfzxvczxc....
-----XXX--

Multipart란 클라이언트에서 서버로 파일을 보낼 때 사용하는 Content Type이다.

클라이언트에서 보내는 요청을 찬찬히 뜯어보면 boundary=-----XXX 로 되어있는 것을 볼 수 있는데, 이는 각 Form 데이터의 경계를 나타내며, 이 경계를 기준으로 각각의 데이터를 표기하여 여러 타입의 데이터를 섞이지 않고 전달하는 것을 목적으로 만들어진 데이터 전송법이다.

username과 age처럼 일반적인 데이터와 달리, 파일의 경우 Content-Type과 바이너리 데이터를 전송되는 것을 확인할 수 있다. HTTP 요청은 모두 문자열로 이루어지는데, 서버는 해당 요청을 받아 HTTP 스펙에 맞춰 이를 변환하는 작업을 진행해야한다.

만약 백엔드 프로그래머 입장에서 자신이 사용하고 있는 프레임워크가 multipart를 지원하지 않는다면 어떻게 해야할까? 답은 간단하다. “이 프레임워크는 multipart 지원 안하는데요?” 해당 요청으로 전달된 문자열을 boundary에 맞춰서 직접 파싱하고, 바이너리 코드는 이미지 파일로 변환하면 된다.

하지만 다행히도 스프링은 MultipartResolver를 통해 multipart에 대한 변환 작업을 지원한다.

MultipartResolver

package org.springframework.web.multipart;

public interface MultipartResolver {

    boolean isMultipart(
            HttpServletRequest request
    );

    MultipartHttpServletRequest resolveMultipart(
            HttpServletRequest request
    ) throws MultipartException;

    void cleanupMultipart(
            MultipartHttpServletRequest request
    );
}

공식 Docs

스프링에서는 Multipart 요청이 오면 DispatcherServlet에서 MultipartResolver의 구현체인 StandardServletMultipartResolver를 실행한다. 해당 리졸버의 메서드들은 다음 역할을 진행한다.

  • boolean isMultipart(HttpServletRequest request)
    • 해당 요청의 Content-Type이 multipart/form-data 인지 확인한다.
  • MultipartHttpServletRequest resolveMultipart(HttpServletRequest request)
    • 해당 요청의 Body값을 파싱하여 Collection<Part> 형태로 만든다.
    • HttpServletRequest를 상속 받은 MultipartHttpServletRequest를 반환한다.
  • void cleanupMultipart(MultipartHttpServletRequest request)
    • request에 남아있는 파일들을 모두 삭제한다.

MultipartRequest

package org.springframework.web.multipart;

public interface MultipartRequest {

    Iterator<String> getFileNames();

    @Nullable
    MultipartFile getFile(String name);

    List<MultipartFile> getFiles(String name);

    Map<String, MultipartFile> getFileMap();

    MultiValueMap<String, MultipartFile> getMultiFileMap();

    @Nullable
    String getMultipartContentType(String paramOrFileName);

}

공식 Docs

MultipartResolver에서 변환 작업에 성공하면 HttpServleRequest와 MultipartRequest를 상속받은 MultipartHttpServletRequest로 바꾸어 변환한다. 스프링에서는 MultipartHttpServletRequest의 구현체인 StandardMultipartHttpServletRequest를 사용한다. MultipartRequest의 메서드가 중요한데 각 메서드의 역할은 다음과 같다.

  • Iterator<String> getFileNames()
    • 해당 요청의 모든 파일 이름을 반환한다.
  • MultipartFile getFile(String name)
    • 개별 파일을 반환한다.
    • 없으면 null을 반환한다.
  • List<MultipartFile> getFiles(String name)
    • 파일 이름에 해당하는 모든 파일을 반환한다.
    • 없으면 List.empty()를 반환한다.
  • Map<String, MultipartFile> getFileMap()
    • 모든 파일에 대해 파일 이름을 Key로, 파일을 Value로 하는 Map을 반환한다.
  • MultiValueMap<String, MultipartFile> getMultiFileMap()
    • 모든 파일에 대해 파일 이름을 Key로, 파일을 Value로 하는 MultiValueMap을 반환한다.
  • String getMultipartContentType(String paramOrFileName)
    • 해당 파일의 Content-Type을 반환한다.

Part

package jakarta.servlet.http;

public interface Part {

    public InputStream getInputStream() throws IOException;

    public String getContentType();

    public String getName();

    public String getSubmittedFileName();

    public long getSize();

    public void write(String fileName) throws IOException;

    public void delete() throws IOException;

    public String getHeader(String name);

    public Collection<String> getHeaders(String name);

    public Collection<String> getHeaderNames();
}

공식 Docs

MultipartResolver를 통해 변환되어진 요청 Body는 boundary를 기준으로 각 파트로 나뉘어 Part 인터페이스의 구현체인 ApplicationPart에 저장된다. 해당 인터페이스는 다음의 메서드를 지원한다.

  • InputStream getInputStream()
    • 해당 파트의 Body값을 반환한다.
    • 제대로된 값을 가져오기 위해선 UTF_8로 변환하는 작업을 거쳐야한다.
    • StreamUtils.copyToString(part.getInputStream(), StandardCharsets.UTF_8)
  • String getContentType()
    • 해당 파트의 Content-Type을 반환한다.
    • 이미지의 경우 image/png, image/jpeg 등등이 이에 해당한다.
  • String getName()
    • 해당 파트의 name 값을 반환한다.
    • Form으로 넘어온 name attribute가 이에 해당한다.
  • String getSubmittedFileName()
    • 해당 파일의 원본 파일명을 반환한다.
  • long getSize()
    • 해당 파일의 크기를 반환한다.
  • void write(String fileName)
    • 해당 파일을 로컬 스토리지에 저장한다.
    • fileName에 원하는 이름을 지정할 수 있다.
  • void delete()
    • 임시 저장된 파일을 제거한다. write로 저장된 데이터는 삭제하지 않는다.
    • 톰캣의 경우, 다음의 조건이 만족하면 파일을 제거한다.
      • 해당 메서드가 호출되었을 경우
      • 가비지 컬렉터가 해당 인스턴스를 제거한 경우
  • String getHeader(String name)
    • 해당 파트의 헤더 정보를 반환한다.
  • Collection<String> getHeaders(String name)
    • 해당 파트의 헤더 정보를 반환한다.
  • Collection<String> getHeaderNames()
    • 해당 파트의 모든 헤더 이름을 반환한다.

MultipartFile

package org.springframework.web.multipart;

public interface MultipartFile extends InputStreamSource {

    String getName();

    @Nullable
    String getOriginalFilename();

    @Nullable
    String getContentType();

    boolean isEmpty();

    long getSize();

    byte[] getBytes() throws IOException;

    @Override
    InputStream getInputStream() throws IOException;

    default Resource getResource() {
        return new MultipartFileResource(this);
    }

    void transferTo(File dest) throws IOException, IllegalStateException;

    default void transferTo(Path dest) throws IOException, IllegalStateException {
        FileCopyUtils.copy(getInputStream(), Files.newOutputStream(dest));
    }
}
@PostMapping("/upload")
public String saveFile(
        @RequestParam String itemName,
        @RequestParam MultipartFile file,
) throws ServletException, IOException {
    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";
}

공식 Docs

이 인터페이스를 사용하게 되면, MultipartRequest를 사용하지 않고 @RequestParam, @ModelAttribue, @RequestPart 애노테이션을 통해 값을 주입받을 수 있게 된다. 스프링에서는 해당 인터페이스의 구현체인 StandardMultipartFile을 사용한다. 해당 인터페이스의 메서드 정보는 다음과 같다.

  • String getName()
    • 해당 파트의 name 값을 반환한다.
    • Form으로 넘어온 name attribute가 이에 해당한다.
  • String getOriginalFilename()
    • 해당 파일의 원본 파일명을 반환한다.
  • String getContentType()
    • 해당 파트의 Content-Type을 반환한다.
    • 이미지의 경우 image/png, image/jpeg 등등이 이에 해당한다.
  • boolean isEmpty()
    • 해당 파트가 파일인지 아닌지 여부를 반환한다.
    • 해당 파트가 단순 문자열인지 파일인지를 확인할 수 있다.
  • long getSize()
    • 해당 파트의 파일 크기를 반환한다.
    • 파일이 아닌 경우 0을 반환한다.
  • byte[] getBytes()
    • 파일의 내용을 바이트 배열로 반환한다.
    • 파일이 아닌 경우 빈 배열을 반환한다.
  • InputStream getInputStream()
    • 해당 파트의 Body값을 반환한다.
    • 제대로된 값을 가져오기 위해선 UTF_8로 변환하는 작업을 거쳐야한다.
    • StreamUtils.copyToString(part.getInputStream(), StandardCharsets.UTF_8)
  • Resource getResource()
    • MultipartFile 인터페이스를 MultipartFileResource(Resource)로 변환한 값을 반환한다.
  • void transferTo(File dest)
    • Java의 기본 File Class를 생성하여 해당 메서드에게 전달하면 해당 인터페이스가 임시 저장한 파일을 전송(복사)한다.
    • 이 경우 File Class의 생성자로 전달한 path에 파일이 저장되며, 이미 저장된 파일이 있다면 덮어씌워버린다. (정확히는 삭제 후 저장)
    • 해당 File을 저장할 경로가 없는 경우, IOException이 발생한다.
    • 임시 저장한 파일이 이미 전송되었거나 삭제된 경우, IllegalStateException이 발생한다.
  • void transferTo(Path dest)
    • Java의 기본 Path Interface를 생성하여 해당 메서드에게 전달하면 해당 인터페이스가 임시 저장한 파일을 전송(복사)한다.
    • 전송하는 과정은 위의 transferTo와 완전히 동일하다.
      • FileCopyUtils.copy(from, to)

@RequestPart

package org.springframework.web.bind.annotation;

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RequestPart {

    @AliasFor("name")
    String value() default "";

    @AliasFor("value")
    String name() default "";

    boolean required() default true;

}

공식 Docs

기존의 Multipart Request(multipart/form-data)는 각각의 파트가 일반 문자열이거나 바이너리 코드로 되어 있어서 @RequestParam과 @ModelAttribute를 사용하여 데이터를 가져올 수 있었다.

그런데 갑자기 클라이언트에서 해당 form을 수정해서 파트 중에 하나가 JSON 형태로 날아오게 되었다. 이 경우에는 JSON을 처리하는 HttpMessageConverter를 통해 작동되어야한다. 그래서 @RequestParam과 @ModelAttribute를 사용할 수 없게 되었다. 그러면 이 경우에는 어떻게 해야 할까?

스프링에서는 이 경우에 사용할 수 있는 @RequestPart를 지원한다. 이 애노테이션을 적용할 경우 각 파트마다 지정되어있는 Content-Type을 이용하여 그에 맞는 변환을 진행한다. 이 때 변환을 진행하는 주체가 Converter가 아닌 HttpMessageConverter이다!

즉, Multipart Request 중에서 각 파트별로 JSON, XML, File 같이 다양한 형식의 데이터를 보내고 있다면 @RequestPart 애노테이션을 사용하면 말끔히 해결된다.

application.properties

# 개별 파일당 최대 크기
# 기본값: 1MB
spring.servlet.multipart.max-file-size = 1MB

# HTTP 요청당 최대 크기
# 기본값: 10MB
spring.servlet.multipart.max-request-size = 10MB

# 서버에서 Multipart Request를 허용할지 여부
# 기본값: true
spring.servlet.multipart.enabled = true

# 파일의 임시 저장 경로
spring.servlet.multipart.location =

# 파일 또는 매개변수 접근 시 Multipart Request를 게으르게 해결할지 여부
# 기본값: false
spring.servlet.multipart.resolve-lazily = false

# 파일을 디스크에 쓸 때까지의 임계값
# 기본값: 0B
spring.servlet.multipart.file-size-threshold = 0B

공식 Docs

스프링 부트를 사용하고 있다면 위처럼 기본 설정을 변경할 수 있다.

주의할 것이 spring.servlet.multipart.location은 실제로 저장되는 곳이 아닌 임시 파일이 저장되는 경로를 나타낸다. 실제로 저장하기 위해선 Part.writeMultipartFile.transferTo 메서드를 통해 경로를 지정해야 한다.

정리

profile
백엔드 개발자 지망생

0개의 댓글