본문 바로가기

HTTP 기본 구조

HTTP 메시지를 시작 줄, 헤더, 빈 줄, 본문으로 나누어 직접 만들고 읽어 봅니다. URL의 각 부분과 퍼센트 인코딩, 메서드의 의미를 정리하고, 안전성과 멱등성이 재시도와 어떻게 이어지는지 확인한 뒤, HTTP/1.0부터 HTTP/3까지 바뀐 것과 바뀌지 않은 것을 비교합니다.

앞 장에서 DNS로 서버의 주소를 알아냈으니, 이제 그 서버와 데이터를 주고받을 차례입니다. 웹에서 이 일을 맡는 프로토콜이 HTTP(hypertext transfer protocol)입니다. 매일 쓰는 프로토콜이지만 "GET은 조회, POST는 생성" 정도로만 알고 넘어가기 쉽습니다. 메시지가 어떻게 생겼고 메서드가 무엇을 약속하는지 정확히 알면, API를 설계하거나 요청이 실패했을 때 다시 보내도 되는지를 판단하기가 훨씬 쉬워집니다.

메시지는 시작 줄, 헤더, 빈 줄, 본문이다

HTTP는 요청과 응답으로 동작합니다. 클라이언트가 요청을 보내면 서버가 응답을 하나 돌려줍니다. HTTP/1.1의 메시지(RFC 9112)는 시작 줄 하나, 헤더 여러 줄, 빈 줄 하나, 본문으로 이루어지며, 줄 끝은 CRLF(\r\n)입니다.

POST /api/users HTTP/1.1
Host: example.com
Content-Type: application/json; charset=utf-8
Content-Length: 21

{"name": "홍길동"}

요청의 시작 줄은 메서드(POST), 요청 대상(/api/users), 버전(HTTP/1.1)입니다. Host는 한 서버가 여러 도메인을 함께 운영할 때 어느 사이트에 대한 요청인지 알려 주므로 HTTP/1.1에서는 반드시 있어야 합니다. Content-Type은 본문의 형식을, Content-Length는 본문의 바이트 수를 알려 줍니다. 빈 줄은 헤더가 끝나고 본문이 시작된다는 표시입니다.

HTTP/1.1 201 Created
Location: /api/users/42
Content-Type: application/json
Content-Length: 9

{"id":42}

응답의 시작 줄은 버전, 상태 코드(201), 사람이 읽기 위한 문구(Created)입니다. Location은 새로 만들어진 자원의 위치를 알려 줍니다. 다음 코드는 이 요청을 바이트로 만들고, 응답을 빈 줄에서 잘라 읽습니다.

import json

body = json.dumps({"name": "홍길동"}, ensure_ascii=False).encode("utf-8")
request = (
    "POST /api/users HTTP/1.1\r\n"
    "Host: example.com\r\n"
    "Content-Type: application/json; charset=utf-8\r\n"
    f"Content-Length: {len(body)}\r\n"
    "\r\n"
).encode("ascii") + body
print("본문 글자 수:", len(body.decode("utf-8")), "/ 본문 바이트 수:", len(body))
print(request.split(b"\r\n")[0])

response = (
    b"HTTP/1.1 201 Created\r\n"
    b"Location: /api/users/42\r\n"
    b"Content-Type: application/json\r\n"
    b"Content-Length: 9\r\n"
    b"\r\n"
    b'{"id":42}'
)
head, _, rest = response.partition(b"\r\n\r\n")             # 빈 줄이 헤더의 끝이다
status_line, *header_lines = head.decode("ascii").split("\r\n")
headers = {name.lower(): value for name, value in (line.split(": ", 1) for line in header_lines)}
payload = rest[: int(headers["content-length"])]
version, code, reason = status_line.split(" ", 2)
print(code, reason, headers["location"], payload)
본문 글자 수: 15 / 본문 바이트 수: 21
b'POST /api/users HTTP/1.1'
201 Created /api/users/42 b'{"id":42}'

본문은 15글자이지만 한글 세 글자가 UTF-8에서 각각 3바이트이므로 Content-Length는 21입니다. 글자 수를 적으면 서버는 본문의 끝을 잘못 알아, 뒤쪽 바이트를 다음 요청의 시작으로 읽게 됩니다. 헤더 이름은 대소문자를 구별하지 않으므로(RFC 9110 §5.1) 읽을 때 소문자로 맞춰 두었습니다. 실제 HTTP는 본문을 조각으로 나눠 보내는 Transfer-Encoding: chunked, 압축, 연결 재사용 같은 경우까지 다뤄야 하므로, 이렇게 손으로 해석하지 말고 검증된 라이브러리를 씁니다.

HTTPS에서는 이 메시지 전체가 TLS로 암호화되므로 패킷 캡처로는 내용을 읽을 수 없습니다. HTTP/2와 HTTP/3는 메시지를 텍스트가 아닌 바이너리 프레임으로 보내지만, 메서드, 요청 대상, 상태 코드, 헤더, 본문이라는 의미는 RFC 9110에 버전과 상관없이 정의되어 있어 그대로 유지됩니다.

더 깊이: 본문의 길이를 서로 다르게 읽을 때 생기는 요청 밀반입

HTTP/1.1에서 본문의 끝을 알려 주는 방법은 Content-Length와 Transfer-Encoding: chunked 두 가지입니다. 앞단의 프록시와 뒤의 서버가 같은 바이트열에서 본문의 끝을 서로 다른 곳으로 읽으면, 한쪽이 본문으로 여긴 바이트를 다른 쪽은 다음 요청의 시작으로 읽게 됩니다. 공격자는 이 차이로 프록시의 검사를 거치지 않은 요청을 뒤의 서버에 몰래 끼워 넣을 수 있는데, 이것이 요청 밀반입(request smuggling)입니다. RFC 9112는 이 공격을 줄이려고 본문의 길이를 정하는 규칙을 엄격하게 적어 두었습니다.

두 헤더가 함께 오면 Transfer-Encoding이 Content-Length보다 우선하지만, 이런 메시지는 밀반입의 시도일 수 있으므로 오류로 다루어야 합니다. 이런 메시지를 넘기는 중개자는 Content-Length를 지운 뒤에 넘겨야 하고, 서버는 요청을 거절하든 Transfer-Encoding만으로 처리하든 응답한 뒤에는 반드시 연결을 닫아야 합니다. Content-Length 값이 잘못되었거나 서로 다른 값이 여러 개라면 복구할 수 없는 오류로 보고, 서버는 400으로 답한 뒤 연결을 닫습니다. 연결을 닫게 하는 것은, 길이를 잘못 읽었을 수 있는 연결에서 뒤따르는 바이트를 다음 요청으로 해석하지 않기 위해서입니다.

URL의 각 부분

요청 대상은 URL에서 나옵니다. https://shop.example.com:8443/search?q=network&page=2#reviews라면 https는 스킴(scheme), shop.example.com은 호스트, 8443은 포트, /search는 경로(path), q=network&page=2는 쿼리(query), reviews는 프래그먼트(fragment)입니다. 포트를 생략하면 스킴의 기본 포트인 HTTP의 80, HTTPS의 443을 씁니다.

URL에는 영문자와 숫자, 몇몇 기호만 그대로 쓸 수 있으므로, 한글처럼 다른 문자는 UTF-8 바이트로 바꾼 뒤 바이트마다 %와 16진수 두 자리로 적는 퍼센트 인코딩(percent-encoding)을 합니다. 다음 코드는 한글 검색어를 넣은 URL을 만들고 다시 나누어 봅니다.

from urllib.parse import parse_qs, urlencode, urlsplit

query = urlencode({"q": "네트워크", "page": 2})
url = f"https://shop.example.com:8443/search?{query}#reviews"
parts = urlsplit(url)
print(parts.scheme, parts.hostname, parts.port, parts.path)
print("쿼리:", parts.query)
print("되돌린 값:", parse_qs(parts.query))
print("서버로 가지 않는 부분:", parts.fragment)
https shop.example.com 8443 /search
쿼리: q=%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC&page=2
되돌린 값: {'q': ['네트워크'], 'page': ['2']}
서버로 가지 않는 부분: reviews

네트워크 네 글자는 UTF-8로 12바이트이므로 %로 시작하는 조각이 12개가 되었습니다. 쿼리의 값은 같은 이름이 여러 번 나올 수 있으므로 parse_qs는 값을 목록으로 돌려줍니다. 프래그먼트는 브라우저가 페이지 안의 위치를 찾는 데만 쓰고 서버에는 보내지 않습니다. 이 요청의 시작 줄은 GET /search?q=%EB...&page=2 HTTP/1.1이 되며, #reviews는 어디에도 실리지 않습니다.

메서드는 요청의 의도를 밝힌다

메서드는 클라이언트가 그 자원에 무엇을 하려는지 밝힙니다.

HTTP · 메서드 의미

안전성과 멱등성은 요청의 의도한 효과를 분류한다

안전성은 상태 변경을 요청하는지, 멱등성은 같은 요청을 반복해도 의도한 효과가 같은지를 봅니다. 응답 코드나 본문이 매번 동일하거나 부수적 로깅이 없다는 뜻은 아닙니다.

메서드의 의도와 안전성·멱등성
메서드요청 의미와 본문안전 / 멱등
GET조회; 페이지 로드, API 데이터 조회. 보통 없음예 / 예
POST처리/생성; 회원가입, 게시글 작성, 파일 업로드. 본문을 주로 사용아니오 / 아니오
PUT전체 대체; 프로필 전체 수정. 표현을 본문에 전달아니오 / 예
PATCH부분 수정; 이메일만 변경. 변경 내용을 본문에 전달아니오 / 설계에 따라
DELETE삭제; 계정 삭제, 게시글 삭제. 보통 없음아니오 / 예
OPTIONS메서드 확인; CORS 프리플라이트. 보통 없음예 / 예
HEAD헤더만 조회; 리소스 존재 확인, 크기 확인. 응답 본문 없음예 / 예
GET
요청 의미와 본문: 조회; 페이지 로드, API 데이터 조회. 보통 없음
안전 / 멱등: 예 / 예
POST
요청 의미와 본문: 처리/생성; 회원가입, 게시글 작성, 파일 업로드. 본문을 주로 사용
안전 / 멱등: 아니오 / 아니오
PUT
요청 의미와 본문: 전체 대체; 프로필 전체 수정. 표현을 본문에 전달
안전 / 멱등: 아니오 / 예
PATCH
요청 의미와 본문: 부분 수정; 이메일만 변경. 변경 내용을 본문에 전달
안전 / 멱등: 아니오 / 설계에 따라
DELETE
요청 의미와 본문: 삭제; 계정 삭제, 게시글 삭제. 보통 없음
안전 / 멱등: 아니오 / 예
OPTIONS
요청 의미와 본문: 메서드 확인; CORS 프리플라이트. 보통 없음
안전 / 멱등: 예 / 예
HEAD
요청 의미와 본문: 헤더만 조회; 리소스 존재 확인, 크기 확인. 응답 본문 없음
안전 / 멱등: 예 / 예

예/아니오는 메서드가 정의한 의도에 대한 분류입니다. GET의 부수적 로깅이나 반복 요청의 응답 코드 차이까지 없다는 뜻은 아닙니다.

가장 많이 쓰는 GET과 POST는 데이터를 싣는 곳부터 다릅니다.

항목GETPOST
데이터를 싣는 곳URL의 쿼리본문
브라우저 기록과 북마크URL이 그대로 남는다본문은 남지 않는다
크기브라우저, 서버, 프록시의 URL 길이 제한을 받는다서버와 프록시의 본문 크기 제한을 받는다
캐시기본적으로 캐시할 수 있다명시적인 조건이 없으면 캐시하지 않는다
뒤로 가기다시 요청해도 문제없다브라우저가 다시 보낼지 물어본다

POST의 본문이 주소창에 보이지 않는다고 해서 안전한 것은 아닙니다. HTTPS가 아니라면 본문도 경로의 누구에게나 보입니다. 다만 비밀번호나 토큰 같은 값을 GET의 쿼리에 넣으면 브라우저 기록과 서버의 접속 기록, 프록시의 기록에 그대로 남으므로 피해야 합니다. GET에 본문을 붙이는 것은 문법상 불가능하지는 않지만 그 의미가 정의되어 있지 않아, 본문을 버리거나 요청을 거부하는 서버와 프록시가 있으므로 쓰지 않습니다.

안전성과 멱등성

메서드에는 두 가지 중요한 성질이 있습니다. 안전한(safe) 메서드는 서버의 상태를 바꾸어 달라고 요청하지 않는 메서드로, GET, HEAD, OPTIONS와, 받은 요청을 그대로 되돌려 보내 경로를 진단하는 TRACE가 여기에 속합니다(RFC 9110 §9.2.1). 서버가 접속 기록을 남기거나 조회수를 올리는 것까지 막는다는 뜻은 아니고, 클라이언트가 그런 변화를 요청한 것이 아니라는 뜻입니다. 그래서 검색 엔진의 크롤러나 브라우저의 미리 불러오기 기능은 안전한 메서드의 요청만 마음대로 보냅니다.

멱등한(idempotent) 메서드는 같은 요청을 한 번 보내든 여러 번 보내든 서버에 남기려는 효과가 같은 메서드로, 안전한 메서드에 더해 PUT과 DELETE가 여기에 속합니다(RFC 9110 §9.2.2). 응답까지 매번 같아야 한다는 뜻은 아닙니다. 같은 자원을 두 번 지우면 첫 응답은 204, 두 번째는 404일 수 있지만, 자원이 없다는 결과는 같습니다. 멱등성이 중요한 이유는 재시도 때문입니다. 응답을 받기 전에 연결이 끊기면 클라이언트는 요청이 처리되었는지 알 수 없는데, 멱등한 요청이라면 그냥 다시 보내도 됩니다.

다음 코드는 설정을 통째로 바꾸는 PUT과 값을 더하는 POST를 두 번씩 처리하고, 이어서 POST를 안전하게 다시 보낼 수 있도록 요청마다 고유한 키를 받는 방식을 흉내 냅니다.

state = {"theme": "light", "counter": 7}

def put_theme(mode):
    state["theme"] = mode                        # 표현 전체로 바꾼다: 몇 번 해도 결과가 같다

def post_add(value):
    state["counter"] += value                    # 현재 값에 더한다: 할 때마다 결과가 달라진다

for _ in range(2):                               # 응답을 못 받아 같은 요청을 한 번 더 보냈다
    put_theme("dark")
    post_add(8)
print(state)

processed = {}

def post_order(key, item):
    if key in processed:
        return processed[key]                    # 같은 키의 요청에는 저장해 둔 결과를 돌려준다
    order_id = f"order-{len(processed) + 1}"
    processed[key] = order_id
    return order_id

print(post_order("a1b2", "book"), post_order("a1b2", "book"), post_order("c3d4", "pen"))
{'theme': 'dark', 'counter': 23}
order-1 order-1 order-2

PUT은 두 번 처리해도 테마가 dark로 같지만, POST는 7에 8을 두 번 더해 23이 되었습니다. 한 번만 더하려던 요청이 재시도 때문에 두 번 처리된 것입니다. 결제나 주문처럼 POST를 다시 보내야 할 수 있는 API는 아래 방식처럼 클라이언트가 요청마다 고유한 키를 만들어 보내게 하고, 서버는 같은 키의 요청에 처음의 결과를 다시 돌려줍니다. 많은 결제 API가 Idempotency-Key 같은 헤더로 이 키를 받습니다. POST라는 이름만으로 이런 보장이 생기지는 않으므로, API의 문서가 무엇을 약속하는지 확인해야 합니다.

HTTP 버전마다 달라진 것

버전나온 해연결을 쓰는 방식특징
HTTP/1.01996요청마다 새 연결이 기본확장으로 연결을 재사용하기도 했다
HTTP/1.11997연결 재사용(Keep-Alive)이 기본Host 필수, 파이프라이닝은 거의 쓰이지 못했다
HTTP/22015연결 하나에 여러 요청을 섞어 보낸다바이너리 프레임, 헤더 압축
HTTP/32022QUIC 위의 스트림스트림끼리 손실의 영향을 나누어 받는다

HTTP/1.1에도 응답을 기다리지 않고 요청을 이어 보내는 파이프라이닝(pipelining)이 있었지만, 응답이 요청한 순서대로만 와야 하고 이를 잘못 다루는 중간 장비도 많아 거의 쓰이지 못했습니다. 그래서 연결 하나에서는 응답이 다 올 때까지 다음 요청을 기다려야 했고, 브라우저는 서버마다 연결을 여러 개 열었습니다. HTTP/2는 연결 하나에 여러 요청을 섞어 이 문제를 풀었지만, 7장 3편에서 본 것처럼 TCP의 HOL 블로킹이 남았고, HTTP/3는 QUIC으로 이를 줄였습니다. HTTP/2에는 서버가 요청받지 않은 자원을 미리 보내는 서버 푸시도 있었지만, 효과가 기대에 못 미쳐 주요 브라우저가 지원을 끊었습니다. 버전이 바뀌어도 메서드와 상태 코드, 헤더의 의미는 같으므로, 이 장의 내용은 모든 버전에 그대로 적용됩니다. 각 버전의 자세한 동작은 12장에서 다시 봅니다.

정리

  • HTTP/1.1의 메시지는 시작 줄, 헤더, 빈 줄, 본문으로 이루어지고, 빈 줄이 헤더의 끝을 알립니다.
  • Content-Length는 글자 수가 아니라 본문의 바이트 수이며, 헤더 이름은 대소문자를 구별하지 않습니다.
  • URL은 스킴, 호스트, 포트, 경로, 쿼리, 프래그먼트로 나뉘고, ASCII가 아닌 문자는 UTF-8 바이트를 퍼센트 인코딩하며, 프래그먼트는 서버로 가지 않습니다.
  • 안전한 메서드는 상태의 변화를 요청하지 않고, 멱등한 메서드는 여러 번 처리해도 의도한 효과가 같아 응답을 받지 못했을 때 다시 보낼 수 있습니다.
  • POST를 다시 보내야 하는 API는 요청마다 고유한 키를 받아 서버가 중복을 걸러 내게 합니다.
  • HTTP/2와 HTTP/3는 메시지를 나르는 방식을 바꾸었을 뿐, 메서드와 상태 코드, 헤더의 의미는 같습니다.

연습 문제

1. 본문의 바이트 수

본문이 json.dumps({"msg": "안녕"}, ensure_ascii=False)로 만든 문자열이라면, Content-Length에는 얼마를 적어야 하나요? 글자 수를 적으면 어떤 일이 생길 수 있나요?

해설 보기

문자열은 {"msg": "안녕"}으로, {"msg": "까지 ASCII 9바이트, 안녕이 UTF-8로 6바이트, "}가 2바이트이므로 모두 17바이트입니다. 글자 수 13을 적으면 서버는 본문을 13바이트에서 끊어 JSON이 망가지고, 남은 4바이트를 같은 연결의 다음 요청의 시작으로 읽으려 합니다. 연결을 재사용하는 HTTP/1.1에서는 이런 길이의 불일치가 뒤의 요청까지 망가뜨리고, 프록시와 서버가 길이를 서로 다르게 해석하면 요청 밀반입(request smuggling) 같은 보안 문제로도 이어집니다.

2. 응답이 달라도 멱등한가

PUT /settings/theme으로 light를 두 번 적용했더니 첫 응답은 200과 JSON 본문, 두 번째 응답은 204와 빈 본문이었습니다. 다른 사람은 이 설정을 바꾸지 않았습니다. 응답이 다르므로 멱등성이 깨졌다고 할 수 있나요? 또 주문을 만드는 POST 요청의 응답만 중간에 사라졌다면, 같은 요청을 다시 보내도 주문이 하나만 생긴다고 할 수 있나요?

해설 보기

멱등성은 서버에 남는 의도한 효과가 같은지를 보는 성질이고, 두 번 처리한 뒤에도 테마는 light 하나이므로 깨지지 않았습니다. 응답의 코드나 본문이 다른 것은 상관없습니다. POST 주문은 다릅니다. 서버가 첫 요청을 이미 처리했는지 클라이언트는 알 수 없으므로, 중복을 거르는 키나 주문을 조회하는 방법을 API가 제공하지 않는다면 다시 보낸 요청이 두 번째 주문을 만들 수 있습니다. 반대로 API가 Idempotency-Key 같은 방식을 약속한다면, POST라도 같은 키로 안전하게 다시 보낼 수 있습니다.

3. 서버로 가지 않는 프래그먼트

사용자가 https://example.com/docs/guide?lang=ko#install을 열었습니다. 서버의 접속 기록에는 요청 대상이 어떻게 남나요? 단일 페이지 애플리케이션이 화면을 #/settings처럼 프래그먼트로 구분한다면, 서버는 사용자가 어느 화면을 열었는지 알 수 있나요?

해설 보기

프래그먼트는 서버로 가지 않으므로 기록에는 /docs/guide?lang=ko만 남습니다. 프래그먼트로 화면을 구분하는 단일 페이지 애플리케이션이라면, 서버는 언제나 같은 경로의 요청만 받으므로 사용자가 어느 화면을 열었는지 알 수 없습니다. 화면별 통계가 필요하다면 브라우저의 스크립트가 따로 서버에 알려야 하고, 서버가 화면마다 다른 응답을 주어야 한다면 프래그먼트가 아니라 경로로 화면을 구분해야 합니다.

다음 글 상태 코드와 헤더에서는 서버가 응답의 결과를 알리는 상태 코드와, 요청과 응답에 붙어 동작을 바꾸는 주요 헤더를 살펴봅니다.