이미지나 파일 같은 데이터는 HTTP로 어떻게 주고받을까.
이미지는 텍스트가 아니다. 0과 1로 이루어진 바이너리 데이터다.
PNG 파일 (일부): 89 50 4E 47 0D 0A 1A 0A 00 00 00 0D 49 48 44 52 ...
그런데 HTTP는 텍스트 기반 프로토콜이다. 요청과 응답 모두 사람이 읽을 수 있는 ASCII 문자로 이루어진다. Content-Type, Authorization 같은 헤더도, JSON body도 전부 텍스트다. 바이너리를 그대로 HTTP에 담을 수 없다.
그래서 바이너리를 HTTP에 담는 방법이 생겼다.
JSON API로 이미지를 응답해야 하는 상황을 생각해보자. JSON은 텍스트 포맷이라 바이너리를 직접 필드 값으로 넣을 수 없다. 이때 쓰는 방법이 Base64 인코딩이다.
Base64는 바이너리 데이터를 A-Z, a-z, 0-9, +, / 총 64개의 ASCII 문자로 변환한다. 바이너리 3바이트를 ASCII 문자 4개로 매핑하는 방식이다. 64가지 문자만 쓰는 이유는 64 = 2^6으로, 6비트씩 끊어서 ASCII 안전 문자에 대응시킬 수 있기 때문이다.
원본 바이너리: 01001000 01100101 01101100 (3 bytes)
Base64 결과: SGVs (4 chars)
변환하면 텍스트가 되니 JSON 필드에 담을 수 있다.
{
"filename": "photo.png",
"data": "iVBORw0KGgoAAAANSUhEUgAA..."
}
HTML이나 CSS에서 이미지를 URL 없이 인라인으로 넣을 때도 같은 방식이다.
<img src="data:image/png;base64,iVBORw0KGgo..." />
단점이 하나 있다. 3바이트를 4글자로 바꾸는 구조라 용량이 약 33% 증가한다. 이미지를 원본보다 33% 크게 전송하는 셈이다. 그럼에도 Base64를 쓰는 이유는 선택이 아니기 때문이다. JSON에 바이너리를 담으려면 텍스트로 변환하는 것 외에 방법이 없다.
JSON에 Base64로 담는 방법은 용량 손해가 크다. 5MB 이미지를 올리면 6.6MB가 되어 전송된다. 파일 업로드처럼 용량이 중요한 상황에선 다른 방법이 필요하다.
multipart/form-data는 바이너리를 인코딩하지 않고 그대로 HTTP body에 담아 전송한다. 텍스트 필드와 파일을 boundary라는 구분자로 나눠서 하나의 요청에 함께 보낼 수 있다.
POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxk
------WebKitFormBoundary7MA4YWxk
Content-Disposition: form-data; name="title"
프로필 사진
------WebKitFormBoundary7MA4YWxk
Content-Disposition: form-data; name="file"; filename="photo.png"
Content-Type: image/png
[PNG 바이너리 그대로]
------WebKitFormBoundary7MA4YWxk--
boundary 문자열은 매 요청마다 랜덤으로 생성된다. 바이너리 데이터 안에 boundary와 같은 문자열이 우연히 포함되면 파싱이 깨지기 때문에, 충돌 확률을 줄이기 위해서다. 각 파트마다 Content-Type을 따로 선언할 수 있어서 텍스트와 파일을 섞어 보내도 서버가 boundary 기준으로 파트를 나눠서 처리할 수 있다.
Base64와 multipart/form-data가 "어떻게 담을 것인가"의 문제라면, Content-Encoding: gzip은 "얼마나 작게 보낼 것인가"의 문제다.
브라우저는 요청을 보낼 때 자신이 지원하는 압축 방식을 헤더에 함께 보낸다.
GET /api/users HTTP/1.1
Accept-Encoding: gzip, deflate, br
서버가 gzip을 지원하면 응답 body를 압축하고 Content-Encoding: gzip을 붙여서 응답한다. 브라우저는 이 헤더를 보고 자동으로 압축을 해제한다. 별도 구현 없이 nginx나 Apache 설정 한 줄로 켤 수 있다.
텍스트 데이터(HTML, CSS, JS, JSON)는 반복 패턴이 많아 60~80%까지 줄어든다. 반면 JPEG, PNG, 동영상은 이미 자체 압축 알고리즘이 적용된 포맷이라 gzip을 추가로 해도 거의 변화가 없다. 그래서 이미지에는 gzip을 적용하지 않는다.
파일 크기가 중요한 상황이라면 바이너리 그대로 전송할 수 있다. JSON을 써야 하는 상황이라면 어쩔 수 없이 Base64로 인코딩해서 담아야 한다.
| Base64 | multipart/form-data | |
|---|---|---|
| 바이너리 처리 | 텍스트로 변환 | 그대로 전송 |
| 용량 | 원본 +33% | 원본 그대로 |
| 왜 쓰는가 | JSON에 바이너리를 직접 담을 수 없어서 | 용량 손해 없이 파일을 올려야 해서 |