스프링 기반 게시판 애플리케이션을 작성하던 도중 파일이 제대로 전달되지 않는 버그가 있었다. 기존에 구현했던 파일 입력폼에서는 잘 전송됐는데 새로 구현한 곳에서는 제대로 전송되지 않아서 뭐가 문제인지 의아해 하고 있었는데 조사해보니 새로 구현한 폼에서 인코딩 타입, 즉 enctype을 제대로 설정해주지 않았기 때문에 발생한 문제였다.
<form method="post" enctype="multipart/form-data" ... >
문제 자체는 폼 태그에 위처럼 "multipart/form-data"라는 인코딩 타입을 지정하여 해결할 수 있었다. 그렇다면 이 인코딩 타입은 어떤 역할을 하길래 문제를 해결할 수 있던 것일까?
인코딩 타입, 즉 enctype 속성은 MDN 문서에 따르면 다음과 같다.
If the value of the method attribute is post, enctype is the MIME type of the form submission.
인코딩 타입 속성은 폼에서 데이터를 전송(POST)할 때 적용되는 속성으로 전송되는 리소스(텍스트, 파일 등)를 인코딩하기 위한 속성이다. 인코딩 된 리소스는 MIME Type으로 이 리소스가 어떤 타입인지(이미지, 음악 등) 식별할 수 있다.
그럼 이 MIME 타입이란 무엇일까? 위키피디아를 참조하면 다음과 같다.
A media type (formerly known as MIME type) is a two-part identifier for file formats and format contents transmitted on the Internet.
즉 인터넷에서 전송되는 파일, 컨텐츠가 어떤 형식인지 구별할 수 있는 식별자로 현재는 미디어 타입(Media Type)이라 불린다. 윈도우즈에서 파일 확장자와 비슷한 역할인데 예를 들어 오디오 파일이 첨부되어 전송된다면 "audio/ogg"같은 타입이 문자열로 같이 전송되는 것이다. 원래는 이메일 전송 시 사용되던 기술이었으나 웹이 발달하면서 HTTP 요청과 응답에 활용하게 되었다.
실제로 위처럼 1618976872.png라는 이미지 파일을 포함한 폼 데이터를 전송했을 때 서버로 전송되는 요청은 다음과 같다.
POST /board/write HTTP/1.1
Host: localhost:8080
...
Content-Type: multipart/form-data; boundary=...
...
------WebKitFormBoundarykZUO6ei4rflEsHOK
Content-Disposition: form-data; name="writer"
writer
...
------WebKitFormBoundarykZUO6ei4rflEsHOK
Content-Disposition: form-data; name="uploadedFiles"; filename="1618976872.png"
Content-Type: image/png
PNG
...
위의 입력폼에서는 인코딩 타입으로 "multipart/form-data"를 적용했다. 그렇기 때문에 전송된 요청의 Content-Type 헤더에 "multipart/form-data"로 명시된 것을 볼 수 있다. 그리고 아래(바디)에서 폼의 input 태그에 담겨 실제로 전송된 데이터들을 보여주고 있는데 일반 텍스트를 입력한 input 태그와 달리 파일을 첨부한 input에는 Content-Type이라는 헤더가 추가된 것을 볼 수 있다.
이 Content-Type 헤더가 전송된 데이터의 타입을 명시하기 위한 헤더로 png 파일을 업로드했기 때문에 PNG 파일의 MIME 타입인 "image/png"가 전달되는 것을 볼 수 있다.
그럼 어떤 인코딩 방식을 폼에 사용할 수 있을까? 총 세 가지가 있는데 그중 첫번째는 폼의 기본값인 application/x-www-form-urlencoded다.
이 urlencoded 방식은 마치 GET 방식에서 사용되는 쿼리 스트링처럼 폼(정확히는 input 태그)의 이름과 값을 "키=값" 쌍으로 '&'로 묶어서 요청의 바디로 전송한다. 별도의 인코딩 타입이 설정되지 않은 폼에서 POST 요청을 전송해보면 다음과 같다.
POST /comment/create HTTP/1.1
Host: localhost:8080
...
Content-Type: application/x-www-form-urlencoded
...
articleID=52&writer=writer&password=password&content=new+comment
폼에 입력한 값들이 <input 태그 이름>=<입력값>&... 속성으로 줄줄이 이어져서 전달되는 것을 볼 수 있다. 인코딩 타입도 기본값인 "application/x-www-form-urlencoded"가 설정되었다.
"urlencoded"라는 이름답게 URL에 사용될 수 있는 문자가 포함되면 자동으로 인코딩된다. 세미콜론(:)이나 역슬래쉬(/), 앰퍼샌드(&)같은 문자를 포함하여 전송해보자.
CONTENT 폼에 ":emoji_here: & new comment here! (-_-)/"라는 문자열을 입력해서 전송했다. 그 결과 다음처럼 특수문자가 인코딩되어 전송된 것을 볼 수 있다.
articleID=52&...&content=%3Aemoji_here%3A+%26+new+comment+here%21+%28-_-%29%2F+
URL 인코딩 방식은 공백을 '+'로, 콜론(:)을 '%3A'로 변환한다. 인코딩 테이블은 이쪽에서 확인할 수 있다.
두 번째 방식인 "multipart/form-data"는 언급했듯이 게시판 프로젝트에서 파일이 전송되지 않는 버그를 고칠 때 추가한 속성이다. 즉 input 태그를 이용하여 파일을 전송할 때 꼭 필요한 인코딩 타입으로 파일을 전송하지 않는다면 urlencoded 방식이든 multipart 방식이든 별 차이는 없다. 그러나 urlencoded 방식이 더 효율적이라고 한다.
그럼 왜 위의 urlencoded 방식은 파일을 처리할 수가 없는 것일까? urlencoded 방식에서는 특수문자나 알파벳이 아닌 문자들을 '%3A'처럼 인코딩해야 한다. 그러면 파일의 바이너리, 즉 바이트를 인코딩할 수가 없기 때문에 적합하지 않은 것이다. 예를 들어 '%3A'가 있을때 이것이 ':'이 URL 인코딩된 것인지 실제 파일의 '\x3A' 16진수 바이트가 전달된 것인지 구분할 수 없다는 것이다.
multipart 방식에서는 input 태그로 전송된 값들이 HTTP 요청 바디에 각각 블록으로 전달된다. 위의 HTTP 요청을 다시 보자.
POST /board/write HTTP/1.1
Host: localhost:8080
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarykZUO6ei4rflEsHOK
...
------WebKitFormBoundarykZUO6ei4rflEsHOK
Content-Disposition: form-data; name="writer"
writer
------WebKitFormBoundarykZUO6ei4rflEsHOK
Content-Disposition: form-data; name="password"
password
------WebKitFormBoundarykZUO6ei4rflEsHOK
Content-Disposition: form-data; name="title"
check me
------WebKitFormBoundarykZUO6ei4rflEsHOK
Content-Disposition: form-data; name="content"
check my mime type
------WebKitFormBoundarykZUO6ei4rflEsHOK
Content-Disposition: form-data; name="uploadedFiles"; filename="1618976872.png"
Content-Type: image/png
...
각 태그에서 전송된 값들이 "------WebKitFormBoundarykZUO6ei4rflEsHOK"라는 문자열로 구분되어 있는 것을 볼 수 있다. 이 구분된 공간이 블록이며 이 문자열이 블록을 구분하는 경계(boundary) 역할을 한다. 구분자는 요청의 Content-Type 헤더에서 "multipart/form-data" 속성과 함께 boundary 속성으로 전송된다.
파일을 첨부한 input의 경우 Content-Type 헤더에 MIME 타입이 같이 전달되며 위에선 생략했지만 원래 전송된 요청을 보면 전송된 이미지 파일의 블록에 다음처럼 파일의 바이트 데이터가 삽입되어 있는 것을 볼 수 있다.
PNG 파일을 전송했기 때문에 PNG 파일의 매직 넘버로 시작하고 있다.
urlencoded 방식에서는 한 줄로 "키1=값1&키2=값2..."처럼 전송됐지만 multipart 방식에서는 이처럼 각 블록의 Content-Disposition 헤더에 키를, 블록 내부에 값을 저장해서 전송한다.
만약 multipart 인코딩 방식을 사용하지 않고 파일을 전송하면 어떻게 될까? 이때는 파일의 이름만 전송되는 것을 볼 수 있다.
POST /board/write HTTP/1.1
Host: localhost:8080
...
Content-Type: application/x-www-form-urlencoded
...
writer=writer&...&uploadedFiles=1618976872.png
그렇기 때문에 파일을 전송하는 폼에서 multipart 인코딩을 사용하지 않는다면 서버측에는 문자열밖에 도착하지 않아 스프링의 MultipartFile같은 클래스로 바인딩해줄 수 없어 오류가 발생할 것이다.
마지막으로 "text/plain" 인코딩은 HTML5에서 추가된 디버깅용 속성이라고 한다. 이걸 사용할 일은 거의 없다. 사람이 읽을 수 있도록(human-readable) 인코딩하기 때문에 컴퓨터도 제대로 인식할 수 없다고 한다.
게시판 애플리케이션에서 파일이 제대로 전달되지 않는 것 뿐 아니라 어떨때는 MultipartFile이 아닌 String이 전달되서 한참 헤맬 때 알게 된 내용들이다. 이 포스트 덕분에 알게 된 내용이기도 한데 역시 웹의 기초 지식이지만 제대로 알고 있지 못해서 부끄럽다.