[CS300 #152] HTTP/1.1 — 사람이 읽을 수 있는 텍스트 프로토콜의 규칙들
컴퓨터공학 300 주제 시리즈의 152번째 글이다. 전체 지도는 여기.
한 줄 요약
HTTP/1.1 은 TCP 위에서 “요청 줄 + 헤더 + 빈 줄 + 본문” 형태의 텍스트 메시지를 주고받는 요청-응답 프로토콜이다. 연결 재사용이 기본이고, 본문 길이는 Content-Length 나 청크 전송으로 알린다.
왜 필요한가
HTTP/2, HTTP/3 가 나왔어도 HTTP 의 의미(메서드, 상태 코드, 헤더, 캐시 규칙)는 그대로다. 그 의미가 가장 눈에 잘 보이는 형태가 HTTP/1.1 의 텍스트 메시지다. 또 로드 밸런서 뒤 백엔드 구간, 헬스 체크, curl 디버깅, 많은 내부 서비스 호출은 여전히 HTTP/1.1 로 오간다.
“POST 를 재시도했더니 주문이 두 번 들어갔다”, “프록시를 거치면 응답이 잘린다”, “연결이 너무 많이 열린다” 같은 문제는 메서드의 의미, 본문 길이 규칙, 연결 관리를 알아야 풀린다.
핵심 개념
명세의 구성
HTTP 명세는 2022년에 재정리됐다. 버전에 상관없는 의미는 RFC 9110(HTTP Semantics), 캐시는 RFC 9111, HTTP/1.1 의 메시지 형식은 RFC 9112 가 맡는다.
메시지 형식
GET /items?id=7 HTTP/1.1\r\n ← 요청 줄: 메서드, 대상, 버전
Host: api.example.com\r\n ← 헤더 (HTTP/1.1 에서 Host 는 필수)
Accept: application/json\r\n
\r\n ← 빈 줄: 헤더 끝
(본문 없음)
HTTP/1.1 200 OK\r\n ← 상태 줄: 버전, 상태 코드, 이유 문구
Content-Type: application/json\r\n
Content-Length: 15\r\n
\r\n
{"id":7,"ok":1} ← 정확히 15바이트
- 줄 끝은 CRLF(
\r\n)다. Host헤더 덕분에 IP 하나에 여러 도메인을 올리는 가상 호스팅이 된다. HTTP/1.1 요청에Host가 없으면 서버는 400 으로 거절해야 한다.
본문 길이를 아는 방법
TCP 는 경계가 없으므로(150번 글) HTTP 가 본문 끝을 알려야 한다.
Content-Length: N이면 정확히 N 바이트를 읽는다.Transfer-Encoding: chunked면 “16진수 길이 + CRLF + 데이터 + CRLF” 조각을 반복하고 길이 0 조각으로 끝낸다. 전체 크기를 미리 모르는 스트리밍 응답에 쓴다.- 둘 다 없는 응답은 연결이 닫힐 때까지가 본문이다.
두 헤더가 함께 오거나 값이 모호하면 프록시와 서버가 경계를 다르게 해석할 수 있다. 이 틈을 노리는 공격이 HTTP 요청 스머글링(request smuggling)이다. RFC 9112 는 두 헤더가 함께 오면 Transfer-Encoding 이 우선하고, 그런 메시지는 스머글링 시도일 수 있으니 오류로 다루는 것이 마땅하다고 적는다.
메서드의 성질
| 메서드 | 안전(safe) | 멱등(idempotent) | 용도 |
|---|---|---|---|
| GET, HEAD | 예 | 예 | 조회 |
| PUT | 아니오 | 예 | 전체 교체 |
| DELETE | 아니오 | 예 | 삭제 |
| POST | 아니오 | 아니오 | 생성, 처리 요청 |
| PATCH | 아니오 | 아니오(보장 안 됨) | 부분 수정 |
- 안전: 서버 상태를 바꾸지 않는다는 약속. 크롤러와 프리페치가 마음 놓고 부를 수 있다.
- 멱등: 같은 요청을 여러 번 해도 결과가 한 번 한 것과 같다. 네트워크 오류 뒤 자동 재시도를 해도 되는지가 여기서 갈린다. POST 재시도로 중복 주문이 생기는 이유다. 결제 API 들이 “멱등 키” 헤더를 따로 받는 것도 이 때문이다.
상태 코드
| 범위 | 의미 | 자주 보는 코드 |
|---|---|---|
| 1xx | 정보 | 101 Switching Protocols (웹소켓 업그레이드) |
| 2xx | 성공 | 200 OK, 201 Created, 204 No Content |
| 3xx | 리다이렉트 | 301, 302, 304 Not Modified |
| 4xx | 클라이언트 오류 | 400, 401, 403, 404, 429 Too Many Requests |
| 5xx | 서버 오류 | 500, 502 Bad Gateway, 503, 504 Gateway Timeout |
502 와 504 는 프록시가 내는 코드다. 502 는 백엔드가 잘못된 응답을 줬거나 연결이 끊겼다는 뜻, 504 는 백엔드가 제때 답하지 않았다는 뜻이다. “타임아웃이 어디서 났나”를 가리는 첫 단서다.
연결 관리
HTTP/1.0 은 요청마다 TCP 연결을 새로 열었다. HTTP/1.1 은 연결 유지(persistent connection)가 기본이라 한 연결로 여러 요청을 차례로 보낸다. 닫으려면 Connection: close 를 보낸다.
하지만 한 연결에서는 응답이 요청 순서대로만 와야 한다. 앞 요청이 느리면 뒤 요청이 기다린다(HTTP 수준의 head-of-line blocking). 요청을 응답 없이 연달아 보내는 파이프라이닝은 이 문제와 구현 호환성 때문에 브라우저에서 거의 쓰이지 않는다. 그래서 브라우저는 서버마다 연결을 여러 개 열어 병렬화했고, 이것이 HTTP/2 의 동기가 됐다(153번 글).
직접 해 보기
파이썬 표준 라이브러리로 로컬 서버를 띄우고, 원시 소켓으로 HTTP/1.1 요청 두 개를 한 연결에 보내 본다. 하나는 Content-Length, 하나는 청크 응답이다.
import socket, threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
class H(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1" # 연결 유지 허용
def do_GET(self):
if self.path == "/fixed":
body = b'{"id":7,"ok":1}'
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers(); self.wfile.write(body)
else:
self.send_response(200)
self.send_header("Transfer-Encoding", "chunked")
self.end_headers()
for part in (b"hello ", b"chunked ", b"world"):
self.wfile.write(b"%x\r\n%s\r\n" % (len(part), part))
self.wfile.write(b"0\r\n\r\n")
def log_message(self, *a): pass
srv = ThreadingHTTPServer(("127.0.0.1", 0), H)
threading.Thread(target=srv.serve_forever, daemon=True).start()
port = srv.server_address[1]
def read_response(f):
status = f.readline().decode().strip()
headers = {}
while (line := f.readline()) != b"\r\n":
k, v = line.decode().split(":", 1); headers[k.lower()] = v.strip()
if "content-length" in headers:
body = f.read(int(headers["content-length"]))
else: # chunked 해석
body = b""
while (size := int(f.readline().strip(), 16)) > 0:
body += f.read(size); f.readline()
f.readline()
return status, headers, body
s = socket.create_connection(("127.0.0.1", port))
f = s.makefile("rb")
for path in ("/fixed", "/stream"):
s.sendall(f"GET {path} HTTP/1.1\r\nHost: api.example.com\r\n\r\n".encode())
status, headers, body = read_response(f)
te = headers.get("transfer-encoding"); cl = headers.get("content-length")
print(status, "| CL:", cl, "| TE:", te, "|", body)
print("두 요청 모두 같은 TCP 연결(로컬 포트 하나)로 처리됨")
s.close(); srv.shutdown()
HTTP/1.1 200 OK | CL: 15 | TE: None | b'{"id":7,"ok":1}'
HTTP/1.1 200 OK | CL: None | TE: chunked | b'hello chunked world'
두 요청 모두 같은 TCP 연결(로컬 포트 하나)로 처리됨
같은 소켓으로 두 번째 요청을 보냈는데 정상 응답이 왔다. 연결 유지가 동작한 것이다. 클라이언트 코드가 본문 끝을 찾는 두 가지 방법(길이 읽기, 청크 해석)을 모두 구현해야 했다는 점도 눈여겨보라.
현업에서는
curl -v는 요청 줄과 헤더를>로, 응답을<로 그대로 보여 준다. HTTP 문제 디버깅의 기본 도구다.- 프록시(nginx, Ingress 컨트롤러)와 백엔드 사이 타임아웃이 엇갈리면 502·504 가 난다. 백엔드의 keep-alive 유휴 타임아웃이 프록시보다 짧으면, 프록시가 막 닫힌 연결에 요청을 실어 502 를 내는 일이 생긴다. 백엔드 쪽 유휴 타임아웃을 프록시보다 길게 잡는 것이 일반적인 처방이다.
- 재시도 정책은 메서드의 멱등성에 맞춘다. GET 은 재시도해도 되지만 POST 는 멱등 키가 없으면 재시도하지 않는다.
- 쿠버네티스의 HTTP 헬스 체크(liveness·readiness probe)는 200 이상 400 미만 응답을 성공으로 본다.
확인 문제
- HTTP/1.1 요청에서 반드시 있어야 하는 헤더는 무엇이며, 왜 필요한가?
- 응답에
Content-Length도Transfer-Encoding도 없으면 클라이언트는 본문 끝을 어떻게 아는가? - PUT 은 멱등이고 POST 는 아닌 이유를 예로 설명하라.
- 프록시가 502 를 냈을 때와 504 를 냈을 때 각각 무엇을 의심하는가?
- HTTP/1.1 연결 유지만으로는 해결되지 않는 성능 문제는 무엇인가?
풀이
Host. 한 IP 에 여러 도메인을 올리는 가상 호스팅에서 서버가 어느 사이트 요청인지 알아야 하기 때문이다.- 서버가 연결을 닫을 때까지가 본문이다.
- PUT 으로 “자원 7 을 이 내용으로”를 두 번 해도 결과는 같다. POST 로 “주문 생성”을 두 번 하면 주문이 두 개 생긴다.
- 502 는 백엔드가 연결을 끊었거나 잘못된 응답을 준 경우(프로세스 죽음, keep-alive 타임아웃 엇갈림), 504 는 백엔드가 프록시 타임아웃 안에 응답하지 않은 경우(느린 쿼리, 과부하)다.
- 한 연결 안에서 응답이 순서대로만 오는 head-of-line blocking 이다. 앞 응답이 느리면 뒤 요청이 모두 기다린다.
더 읽을거리 (References)
- RFC 9110, HTTP Semantics: https://www.rfc-editor.org/rfc/rfc9110.html
- RFC 9112, HTTP/1.1: https://www.rfc-editor.org/rfc/rfc9112.html
- MDN, Connection management in HTTP/1.x: https://developer.mozilla.org/en-US/docs/Web/HTTP/Connection_management_in_HTTP_1.x
- Python
http.server문서: https://docs.python.org/3/library/http.server.html