티스토리 뷰

개요

스프링 서버를 개발하다보면 클라이언트 측에서 보내주는 파일을 당연한듯이 MultipartFile로 핸들링하게된다. 그런데 MultipartFile을 들어가보면 interface인데, 요청을 받기 위한 MultipartFile은 따로 구현체가 없다. 그렇다면 어떻게 클래스처럼 사용할 수 있을까? 이는 private static 클래스로 숨겨져 있기 때문이다.

 

구조를 정리하면 다음과 같다.

HttpServletRequest
  ↓
StandardServletMultipartResolver
  ↓
StandardMultipartHttpServletRequest (MultipartHttpServletRequest 구현체)
  ↓
StandardMultipartFile (private static class)

StandardMultipartFile은 사용자가 접근/수정할 수 없으며 MultipartFile 구현체를 새로 만들지 않는 이상 추가적인 핸들링이 불가능하다. 그렇다면 스프링은 왜 이렇게 복잡한 구조를 만들어 MultipartFile을 내부적으로 구성하고, 별도의 MultipartResolver나 MultipartHttpServletRequest 같은 객체까지 동원하는 걸까?

 

스프링의 MultipartFile과 Multipart/form-data 형식

그 핵심 이유는 HTTP의 전송 방식 중 하나인 multipart/form-data의 특성 때문이다.

 

multipart/form-data단순한 키-값 쌍으로 데이터를 전송하는 application/x-www-form-urlencoded와 달리, 바이너리 데이터(예: 이미지, PDF 파일)를 포함한 복합 구조를 전송할 수 있는 방식이다.

 

이 방식은 각각의 필드(예: 파일, 텍스트 입력 등)를 별개의 "파트(part)"로 분리하여 전송하며, 각 파트는 고유의 헤더(Content-Disposition, Content-Type 등)를 갖는다.

POST /upload HTTP/1.1
Host: example.com
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW

------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="name"

홍길동
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="profile"; filename="profile.png"
Content-Type: image/png

<PNG 바이너리 데이터>
------WebKitFormBoundary7MA4YWxkTrZu0gW--

위의 예시에서 보이듯이, 각각의 필드는 ------boundary로 구분된 별도의 파트로 전송되며, 각 파트에는 자체적인 헤더와 바디가 존재한다. 특히 파일 필드는 filename과 Content-Type이 포함되어 바이너리 데이터가 전송된다.

 

이러한 구조는 단순히 InputStream만 읽어서는 파싱하기 어렵고, 전체 HTTP Body를 파트 단위로 나눠서 처리해야 하기 때문에 일반적인 서블릿 요청 파라미터 처리로는 감당할 수 없다. 즉, 파일과 폼 데이터를 함께 다루기 위한 별도의 파싱 계층이 필요하다.

 

여기서 스프링은 이 문제를 해결하기 위해 MultipartResolver라는 전략 객체를 도입했고, 이는 서블릿 레벨에서 올라온 요청을 MultipartHttpServletRequest로 변환하여, 파일 필드와 일반 필드를 구분해 접근할 수 있게 해준다.

 

이처럼 각 Part별로 서로다른 데이터들이 들어올 수 있어서 Mulipart/form-data 형식이라 명칭이 붙었고, 스프링에서는 MultipartFile이라고 하는 별도의 객체를 지정해서 다룰 수 있게 해줬다.

 

한발짝 더 나가보면, StandardMultipartFile을 바이트어레이 형태로 파일을 전달 받는다는걸 알 수 있다.

// StandardMultipartHttpServletRequest 내부
private static class StandardMultipartFile implements MultipartFile, Serializable {

    private final Part part;
    private final String filename;
....

// Part 내부
public interface Part {
    InputStream getInputStream() throws IOException;
    String getContentType();
    String getName();
    ...
}

지금까지 설명한 방식은 주로 파일 크기가 정해져 있고, 전송이 완료된 후 서버가 전체 데이터를 수신한 다음에야 MultipartFile로 접근할 수 있는 구조였다. 하지만 예를 들어 Speech-to-Text(STT) 서비스처럼, 사용자가 마이크에 대고 실시간으로 음성을 전송하는 경우를 생각해보자.

 

1. 사용자가 언제 말을 멈출지 모름
2. 전송할 데이터의 크기도 정해져 있지 않음
3. 단일 요청으로 모든 데이터를 전송하기엔 시간도 오래 걸리고, 서버 자원도 낭비됨

 

이런 상황에서 일반적인 multipart/form-data 방식은 한계가 있다. 서버는 모든 데이터를 다 받을 때까지 컨트롤러 로직을 실행할 수 없기 때문이다. 이 경우 HTTP 1.1에서 제공하는 방식인 Transfer-Encoding: Checked Option을 사용하면 된다.

 

Chunked Transfer Encoding

POST /upload HTTP/1.1
Transfer-Encoding: chunked
Content-Type: audio/wav

7\r\n
abcdef1\r\n
5\r\n
23456\r\n
0\r\n
\r\n

이 방식에서는 데이터를 일정한 크기의 청크(chunk) 단위로 잘라서 서버로 전송하고, 모든 데이터가 끝났다는 신호로 "0\r\n\r\n"을 보내 전송을 마무리한다.

 

메타데이터를 함께 보내고 싶다면?

여기서 한 가지 질문이 생긴다.

 

"한 번의 HTTP 요청에서 청크 단위의 음성 데이터와 JSON 메타데이터를 같이 보내려면 어떻게 할까?"

 

이 경우 chunked + multipart/form-data 조합을 사용할 수 있다. 즉, Transfer-Encoding: chunked를 유지하면서 multipart/form-data 형식으로 하나의 요청 안에 JSON과 음성 데이터를 각각 Part로 포함하면 된다.

POST /upload HTTP/1.1
Transfer-Encoding: chunked
Content-Type: multipart/form-data; boundary=----abc123

------abc123
Content-Disposition: form-data; name="meta"
Content-Type: application/json

{"language": "ko", "duration": 120}
------abc123
Content-Disposition: form-data; name="audio"; filename="stream.wav"
Content-Type: audio/wav

<청크 단위로 전송되는 음성 데이터>
------abc123--

이렇게 하면 서버에서 @RequestParam("meta") String metaJson, @RequestParam("audio") MultipartFile audio로 각각 받을 수 있다. 다만 Spring MVC는 모든 청크가 끝난 후에야 MultipartFile을 생성하므로 비효율적인 건 감수해야한다.

 

이런 STT와 관련된 진짜 실시간 처리는 WebSocket이나 WebFlux 스트리밍이 더 적합하다.

 

마치며

사실 이 글을 작성하게 된 계기는 블로그 포스팅과는 역방향으로 진행됐다.

 

HTTP 요청 방식 중 Multipart/form-data에는 왜 Multipart라는 이름이 붙었을까요?라는 질문이 포스팅의 시작점이었다

 

난 Multipart 형식이 한번에 여러 종류의 파일 형식을 한번의 요청으로 보낼 수 있어서라고 생각했는데, 알고보니 그게 아니었다. HTTP에 대해 깊이 생각해보지 않기도 했고, Content-Type 에 대한 고민도 그렇게 많이 해보지 않았어서 이번에 같이한번 정리했다.

 

내가 뭔가 잘 모르고 있다는 것 까지는 알겠는데, 어떤 걸 모른다라고 정확히 정의가 내려지지 않고 있었는데 이런저런 이야기를 하다보니 모르는 이야기가 정말 많이 나왔다. 다양한 장소에서 다양한 사람들과 여러 이야기를 나눠봐야겠다는 생각을 다시한번하게 된 계기였다.

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/08   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
글 보관함