시작줄의 세 조각

HTTP 요청은 시작줄과 헤더, 빈 줄, 본문으로 이루어진다. 그중 맨 앞의 시작줄부터 뜯어본다. GET /articles/42 HTTP/1.1이라는 한 줄은 공백을 기준으로 세 조각으로 나뉜다. 첫 조각은 메서드, 두 번째는 요청 대상, 세 번째는 프로토콜 버전이다. 서버는 이 한 줄을 받으면 공백 두 개를 기준으로 잘라서 각 조각을 해석한다. 조각 순서가 바뀌거나 공백이 두 칸 이상 들어가면 요청 자체가 깨진 것으로 취급되어 400 오류로 되돌아온다.


두 번째 조각인 요청 대상은 평소에는 경로만 담는다. 브라우저가 example.com에 요청을 보낼 때 서버 입장에서는 이미 그 서버로 연결되어 있으므로 도메인을 다시 적을 필요가 없고, /articles/42처럼 경로만 적으면 충분하다. 이런 방식을 origin-form이라 부른다. 다만 요청이 프록시 서버를 거치는 경우에는 프록시가 어느 서버로 중계할지 알아야 하므로 http://example.com/articles/42처럼 전체 주소를 통째로 적는 absolute-form을 쓴다. 회사 네트워크의 웹 프록시나 캐시 서버 로그를 열어보면 경로만이 아니라 전체 주소가 찍혀 있는 이유가 여기 있다.


경로만 적어도 되는 이유는 연결 자체가 이미 특정 서버를 향하고 있기 때문이지만, 정작 서버 한 대가 여러 도메인을 동시에 운영하는 경우에는 이야기가 다르다. 같은 IP 주소로 example.com과 blog.example.com을 함께 서비스하는 가상 호스팅 환경에서는 경로만으로 어느 도메인을 찾는 요청인지 구분할 수 없다. 그래서 요청 대상이 경로만 담더라도 Host 헤더에는 반드시 도메인을 함께 적어 보낸다. 서버는 이 Host 값을 보고 나서야 어느 사이트의 설정으로 요청을 처리할지 결정한다.


본문을 감싸는 세 가지 형식

본문이 필요한 요청, 대표적으로 폼 제출이나 파일 업로드는 Content-Type 헤더에 적힌 값에 따라 본문을 세 가지 방식 중 하나로 인코딩한다. 가장 단순한 방식은 application/x-www-form-urlencoded로, 입력 필드 이름과 값을 등호로 잇고 필드 사이는 앰퍼샌드로 구분한 뒤 공백은 퍼센트 인코딩으로 바꾼다.


POST /login HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Content-Length: 29

username=kim&password=1234


파일을 함께 보내야 할 때는 이 방식으로는 부족하다. 텍스트와 바이너리 데이터를 한 요청에 섞어 보내야 하는데, 단순 인코딩으로는 파일의 시작과 끝을 구분할 방법이 없다. 그래서 multipart/form-data는 임의의 경계 문자열, boundary를 헤더에 선언해두고 본문 안에서 그 경계로 필드마다 구간을 나눈다.


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

------abc123
Content-Disposition: form-data; name="title"

프로필 사진
------abc123
Content-Disposition: form-data; name="file"; filename="me.jpg"
Content-Type: image/jpeg

(바이너리 데이터)
------abc123--


boundary 값은 본문 안에 우연히 등장하지 않을 만큼 충분히 길고 특이한 문자열로 골라야 한다. 짧고 흔한 값을 쓰면 파일 내용 중 우연히 같은 문자열이 나타났을 때 그 지점에서 파일이 끝났다고 서버가 착각할 수 있다.


세 번째는 지금 API 서버 대부분이 쓰는 application/json이다. 필드와 값을 중괄호 구조로 담아 사람이 읽기도, 파서가 처리하기도 편하다. Content-Type을 잘못 적으면 본문 형식과 헤더 값이 어긋나 서버가 파싱에 실패하니, 세 형식 중 실제로 보내는 형식과 헤더 값을 반드시 맞춰야 한다.


길이를 세는 두 가지 방법

서버가 본문을 어디까지 읽어야 하는지 알려면 길이 정보가 필요하다. 가장 흔한 방법은 Content-Length 헤더에 본문의 바이트 수를 정확히 적는 것이다. 서버는 헤더와 본문 사이 빈 줄을 지난 뒤 그 숫자만큼만 읽고 멈춘다.


문제는 본문 크기를 미리 알 수 없는 경우다. 실시간으로 생성되는 로그 스트림이나 동영상 조각을 순차로 내려보낼 때는 전체 크기를 계산하고 나서야 응답을 시작할 수 있는데, 그러면 지연이 커진다. 이때는 Transfer-Encoding: chunked를 써서 본문을 여러 조각으로 나눠 보내고, 각 조각 앞에 그 조각만의 크기를 16진수로 적는다. 마지막에는 크기 0인 조각으로 끝을 알린다.


HTTP/1.1 200 OK
Transfer-Encoding: chunked

7
Mozilla
9
Developer
0


Content-Length와 Transfer-Encoding은 동시에 쓰지 않는다. 두 값이 함께 있으면 어느 쪽을 믿어야 할지 애매해지고, 이 애매함을 악용해 방화벽과 서버가 본문 길이를 서로 다르게 해석하도록 만드는 밀수 공격도 알려져 있다.


연결 하나로 여러 요청

초기 HTTP는 요청 하나마다 TCP 연결을 새로 열고 응답을 받으면 바로 끊었다. 페이지 하나에 이미지가 열 개면 연결도 열 번 맺어야 했고, 연결을 맺는 절차 자체에 시간이 든다. HTTP/1.1부터는 Connection: keep-alive를 기본값으로 삼아 한 번 맺은 연결을 여러 요청과 응답에 재사용한다. 응답 헤더에 Connection: close가 없으면 브라우저는 그 연결을 계속 붙들고 다음 요청도 같은 연결로 보낸다.


연결이 재사용되어도 요청과 응답의 짝은 순서대로 맞아떨어져야 한다. 요청 A를 보내고 응답을 받기 전에 요청 B를 먼저 보내는 방식, 파이프라이닝은 이론상 가능했지만 중간 장비 호환성 문제로 널리 쓰이지 못했다. HTTP/2가 되어서야 하나의 연결 위에서 여러 요청과 응답을 뒤섞어 보내고 각 조각에 스트림 번호를 매겨 순서와 무관하게 다시 조립하는 멀티플렉싱이 자리잡았다.


구조를 직접 확인하는 법

지금까지 설명한 시작줄, 헤더, 빈 줄, 본문은 브라우저나 서버가 알아서 처리해주므로 평소에는 눈에 보이지 않는다. 직접 확인하려면 터미널에서 curl에 -v 옵션을 붙이면 된다.


curl -v https://example.com/articles/42
> GET /articles/42 HTTP/1.1
> Host: example.com
> Accept: */*
<
< HTTP/1.1 200 OK
< Content-Type: application/json
< Content-Length: 81


화살표가 오른쪽을 향한 줄은 실제로 보낸 요청, 왼쪽을 향한 줄은 받은 응답이다. 브라우저에서는 개발자 도구의 Network 탭에서 요청을 하나 클릭하고 Headers 항목을 열면 같은 정보를 확인할 수 있다. 크롬 기준으로는 Response Headers 옆의 view source 링크를 누르면 서버가 보낸 원시 헤더 텍스트 그대로를 볼 수 있어, 지금까지 글로 설명한 구조가 실제로 어떻게 한 줄씩 나열되는지 눈으로 대조할 수 있다.


응답이 예상과 다를 때 화면에 그려진 결과만 보고 원인을 짐작하기보다는 이 원시 텍스트부터 열어보는 편이 문제를 훨씬 빨리 좁혀준다. 헤더가 하나 빠졌는지, 본문 형식이 맞는지는 화면만으로는 드러나지 않고 raw 데이터를 봐야 확인된다.