HTTP — 웹이 말하는 법
DNS로 이름이 IP가 되고 TCP로 연결이 섰다. 이제 그 위에서 실제 대화가 오간다 — 브라우저가 "이 페이지 줘"라고 묻고 서버가 "여기 있다"고 답한다. 이 요청과 응답의 규약이 **HTTP**다. 응용 계층의 맨 위에 있어 사람·앱에 가장 가깝고, 그래서 우리가 매일 다루는 얼굴이다. HTTP의 기본 모양과 *상태 없음(stateless)*이라는 성질, 그리고 HTTP/1.1 → 2 → 3로 이어진 진화의 동기를 본다.
요청과 응답 — 단순한 한 쌍
HTTP는 놀랍도록 단순하다. 요청은 — 메서드 + URL + 헤더들 + (본문), 응답은 — *상태 코드 + 헤더들 + (본문)*이다.
GET /users/1 HTTP/1.1 ← 메서드 + 경로
Host: api.shop.com ← 헤더
Accept: application/json
HTTP/1.1 200 OK ← 상태 코드
Content-Type: application/json
{ "id": 1, "name": "..." } ← 본문
메서드는 무엇을 할지를 말한다 — GET(조회)·POST(생성)·PUT(교체)·PATCH(부분 수정)·DELETE(삭제). 여기엔 의미상의 약속이 있다. GET은 안전(safe) — 서버 상태를 바꾸지 않는다. PUT·DELETE는 멱등(idempotent) — 같은 요청을 여러 번 보내도 결과가 같다. 반면 POST는 멱등이 아니라서 두 번 보내면 자원이 둘 생길 수 있다(그래서 네트워크 재시도가 잦은 환경에서는 멱등 키 같은 보완이 필요해진다). 상태 코드는 결과를 한눈에 알린다 — 2xx(성공)·3xx(리다이렉트)·4xx(클라이언트 잘못: 404 없음, 401 미인증)·5xx(서버 잘못). 이 약속을 지켜야 캐시·프록시·클라이언트가 예상대로 동작한다.
상태가 없다 — 그래서 쿠키
HTTP는 기본적으로 stateless다 — 각 요청이 서로를 모른다. 서버는 방금 요청한 사람이 조금 전 로그인한 그 사람인지 HTTP만으로는 알 수 없다. 이 단순함이 서버 확장에는 유리하지만(어느 서버가 받아도 되니), 로그인 같은 연속성은 따로 얹어야 한다. 그래서 쿠키가 등장한다 — 서버가 응답에 Set-Cookie로 식별자를 심으면, 브라우저가 이후 요청마다 그 쿠키를 자동으로 실어 보내 "아, 그 사람"을 잇는다. 이 *"쿠키를 자동으로 붙인다"*는 성질이 — CSRF 공격이 노리는 바로 그 지점이고, *서버에 세션을 저장할지 토큰에 담을지(세션 vs 토큰)*의 갈림길이기도 하다.
왜 버전이 올라갔나 — HoL 블로킹과의 싸움
HTTP의 진화는 사실상 *"한 줄로 줄 서서 생기는 막힘(head-of-line blocking)"*과의 싸움이었다.
HTTP/1.1은 keep-alive로 TCP 연결을 재사용해(매 요청마다 핸드셰이크하지 않게) 개선했지만, 한 연결에선 요청을 한 줄로 처리해서 — 앞 요청이 느리면 뒤가 다 막혔다. 그래서 브라우저는 연결을 여러 개 열어 우회했다. HTTP/2는 바이너리 프레이밍으로 한 연결 안에 여러 스트림을 섞어 보내는(멀티플렉싱) 방식을 도입해 — 앱 계층의 HoL을 없앴다(헤더 압축·서버 푸시도 추가). 그런데 한계가 남았다 — TCP가 한 패킷을 잃으면, 그 아래에선 모든 스트림이 함께 막혔다(TCP 레벨 HoL). HTTP/3는 아예 전송을 *QUIC(UDP 위)*로 바꿔 — 스트림을 진짜 독립시켜, 한 스트림의 손실이 다른 스트림을 안 막게 했고 TLS 1.3을 통합해 연결을 더 빠르게 세운다(0-RTT). (출처: HTTP/2 vs HTTP/3.)
서버가 먼저 말하려면 — 폴링에서 WebSocket까지
지금까지 본 HTTP에는 한 가지 전제가 깔려 있다 — 언제나 클라이언트가 먼저 묻는다. 그런데 알림·시세·채팅처럼 서버 쪽에서 사건이 생겼을 때 알려야 하는 일은 이 모델로 표현할 수가 없다. 서버에는 "말을 걸 수단"이 없기 때문이다. 이 문제를 우회하고, 끝내 정면으로 푼 과정이 아래 계보다.
① 폴링(polling) — 클라이언트가 3초마다 같은 요청을 반복한다. 가장 단순하지만 낭비가 크다. 새 소식이 없어도 요청은 나가고(대부분이 헛걸음), 소식이 생겨도 다음 주기까지 기다려야 알게 된다. 즉 트래픽과 지연을 맞바꿀 수밖에 없다 — 주기를 줄이면 지연이 줄고 트래픽이 늘고, 늘리면 반대다.
② 롱 폴링(long polling) — 발상을 뒤집는다. 서버가 요청을 받고 응답을 곧바로 주지 않고 붙잡고 있다가, 알릴 일이 생기는 순간 응답한다. 클라이언트는 응답을 받자마자 다시 요청을 건다. 헛걸음이 사라지고 지연도 거의 없어지지만, 이벤트 하나마다 요청-응답이 한 번씩 끝난다 — 사건이 잦으면 연결을 다시 세우는 비용이 계속 든다.
③ SSE(Server-Sent Events) — 여기서 한 걸음 더 간다. 응답을 아예 끝내지 않는다.
GET /events HTTP/1.1
Accept: text/event-stream
HTTP/1.1 200 OK
Content-Type: text/event-stream ← 이 응답은 끝나지 않는다
id: 42
event: price
data: {"symbol":"AAPL","price":231.4}
← 빈 줄 하나가 메시지 하나의 끝
data: 그냥 메시지
평범한 HTTP 응답 본문을 조금씩 흘려보내는 방식이라 새 프로토콜이 아니다. 규격도 단순해서 data:·event:(이벤트 이름)·id:(식별자)·retry:(재연결 간격) 네 필드가 전부이고, 메시지 하나는 빈 줄로 끝난다.
특별한 건 재연결이 규격에 들어 있다는 점이다. 연결이 끊기면 클라이언트가 알아서 다시 붙고, 이때 마지막으로 받은 id를 Last-Event-ID 헤더에 실어 보낸다. 서버는 그 지점부터 이어 보내면 되므로 끊긴 사이에 놓친 이벤트를 복구할 수 있다. 대신 방향이 서버 → 클라이언트 한쪽뿐이다.
여기에 HTTP/1.1의 제약이 하나 걸린다 — 브라우저는 같은 도메인에 연결을 6개까지만 연다. SSE 연결 하나가 그 자리를 계속 차지하므로 탭을 여러 개 열면 금방 한계에 닿는다. HTTP/2에서는 멀티플렉싱 덕분에 이 문제가 사라진다 — 앞 절에서 본 스트림이 여기서 값을 한다.
④ WebSocket — 우회를 그만두고 연결의 성격 자체를 바꾼다. 시작은 여전히 평범한 HTTP 요청이다.
GET /chat HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== ← 무작위 16바이트를 base64로
Sec-WebSocket-Version: 13
HTTP/1.1 101 Switching Protocols ← 200이 아니라 101
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
101은 **"지금부터 이 TCP 연결에서 HTTP를 쓰지 않겠다"**는 뜻이다. 이 응답 이후로는 요청도 응답도 없고, 양쪽이 대등하게 프레임을 주고받는다.
Sec-WebSocket-Accept는 서버가 계산해서 돌려준다 — 클라이언트가 보낸 키에 고정된 문자열 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 을 이어 붙이고 SHA-1로 해시한 뒤 base64로 인코딩한 값이다. 값이 고정 공개된 만큼 이건 보안 장치가 아니다. 목적은 "상대가 WebSocket을 정말로 이해하고 응답했는가" 를 확인하는 것 — 중간의 캐시나 프록시가 이 요청을 평범한 HTTP로 착각해 엉뚱한 응답을 돌려주는 사고를 막는다.
프레임의 구조는 이렇다.
| 필드 | 뜻 |
|---|---|
| FIN(1비트) | 이 프레임이 메시지의 마지막 조각인가(긴 메시지는 쪼개 보낸다) |
| opcode(4비트) | 0x1 텍스트 · 0x2 바이너리 · 0x8 종료 · 0x9 ping · 0xA pong |
| MASK(1비트) | 페이로드가 마스킹돼 있는가 |
| 길이(7 / 7+16 / 7+64비트) | 0~125는 그대로, 126이면 뒤 16비트, 127이면 뒤 64비트를 길이로 읽는다 |
헤더가 최소 2바이트다. HTTP 요청마다 수백 바이트씩 붙던 헤더와 비교하면, 잦은 소량 메시지에서 차이가 크다.
클라이언트가 보내는 프레임은 반드시 마스킹해야 한다 — 4바이트 키로 페이로드를 XOR한다. 이것도 암호화가 아니다(키가 프레임 안에 그대로 들어 있다). 목적은 공격자가 회선에 나타날 바이트를 마음대로 고르지 못하게 하는 것이다. WebSocket을 모르는 중간 프록시가 그 바이트열을 HTTP 요청으로 잘못 읽어 캐시를 오염시키는 공격(캐시 포이즈닝)을 막기 위한 장치다.
연결을 살아 있게 유지하는 것도 규격 안에 있다. ping / pong 제어 프레임으로 상대가 살아 있는지 확인하는데, 이게 실무에서 중요한 이유는 따로 있다 — 로드밸런서나 프록시는 대개 유휴 연결을 몇 분 뒤 끊는다. 조용한 채팅방의 연결이 소리 없이 끊기는 걸 막으려면 주기적인 ping이 필요하다.
정리하면 — 무엇을 고를 것인가
| 방향 | 프로토콜 | 재연결 | 헤더 비용 | |
|---|---|---|---|---|
| 폴링 | 클라이언트가 요청 | HTTP 그대로 | 해당 없음 | 요청마다 |
| 롱 폴링 | 클라이언트가 요청 | HTTP 그대로 | 해당 없음 | 이벤트마다 |
| SSE | 서버 → 클라이언트 | HTTP 그대로 | 규격에 내장(Last-Event-ID) | 최초 1회 |
| WebSocket | 양방향 | 101 이후 별도 | 직접 구현 | 프레임당 2바이트~ |
알림·피드·진행률처럼 서버가 일방적으로 밀어 주면 되는 일에는 SSE가 더 단순하고, 채팅·협업 편집·게임처럼 클라이언트도 자주 보내야 하면 WebSocket이다. "실시간 = WebSocket"이 아니라, 양방향이 정말 필요한가가 갈림길이다.
마지막으로 버전 이야기가 하나 더 붙는다. 위 핸드셰이크는 HTTP/1.1의 Upgrade에 기대는데, HTTP/2에는 Upgrade 자체가 없다. 그래서 RFC 8441이 확장 CONNECT 방식으로 HTTP/2 위의 WebSocket을 정의했고(한 TCP 연결 안의 여러 스트림으로 WebSocket을 여러 개 쓸 수 있다), RFC 9220이 이를 HTTP/3까지 넓혔다. 다만 2026년 기준 HTTP/3 위 WebSocket을 프로덕션에서 구현한 주요 브라우저·서버는 아직 없어, 실제 배포는 HTTP/1.1 또는 HTTP/2 위가 대부분이다. (출처: RFC 6455 — The WebSocket Protocol · RFC 8441 · MDN — Using server-sent events.)
실무에서는 — 연결을 재사용하는 게 성능의 핵심이라, 클라이언트는 연결 풀·keep-alive로 핸드셰이크 왕복을 아낀다(TCP 편에서 본 그 교훈). 응답엔 캐시 헤더(
Cache-Control·ETag)를 잘 붙여 안 바뀐 건 304로 돌려보내고, 본문은 *압축(gzip·br)*하며, 정적 자원은 *CDN*에 둬 사용자 가까이서 준다(뒤의 로드밸런싱 편). 그리고 0-RTT 같은 최적화는 재전송돼도 안전한(멱등) 요청에만 허용한다 — 여기서도 멱등성이 안전의 열쇠다.
실제로 눈으로 보기
요청과 응답을 통째로 본다
curl -sv https://example.com -o /dev/null 2>&1 | grep -E '^[<>]'
# > GET / HTTP/2
# > host: example.com
# > user-agent: curl/8.5.0
# > accept: */*
# <
# < HTTP/2 200
# < content-type: text/html; charset=UTF-8
# < cache-control: max-age=604800
# < content-length: 1256
>가 보낸 것, <가 받은 것이다. HTTP/2 라 헤더 이름이 전부 소문자인 것도 보인다 — HTTP/2 는 헤더를 압축(HPACK)하면서 소문자로 정규화한다.
버전별로 무엇이 달라지나
curl -s -o /dev/null -w '%{http_version} %{time_total}s\n' --http1.1 https://example.com
curl -s -o /dev/null -w '%{http_version} %{time_total}s\n' --http2 https://example.com
curl -s -o /dev/null -w '%{http_version} %{time_total}s\n' --http3 https://example.com
curl -s -o /dev/null -w \
'dns=%{time_namelookup} tcp=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' \
https://example.com
# dns=0.021 tcp=0.045 tls=0.118 ttfb=0.164 total=0.166
어디에 시간이 갔는지 단계별로 나온다. tls - tcp가 크면 핸드셰이크가 느린 것이고, ttfb - tls가 크면 서버가 느린 것이다. "사이트가 느리다" 를 이 네 구간으로 쪼개면 책임 소재가 바로 갈린다.
상태가 없다는 것을 확인한다
curl -s -c jar.txt https://example.com/login -d 'id=a&pw=b' -o /dev/null
cat jar.txt
# example.com FALSE / TRUE 1787000000 SESSIONID 8f2a...
curl -s -b jar.txt https://example.com/me # 쿠키를 실어야 나를 안다
curl -s https://example.com/me # 안 실으면 남이다
서버는 매 요청을 처음 보는 것처럼 다룬다. 같은 연결로 보내도 마찬가지다 — 연결은 전송 계층의 일이고 HTTP 는 그것을 신경 쓰지 않는다. 쿠키가 그 틈을 메운다.
HoL 블로킹이 어디에 남았나
HTTP/1.1 요청 하나 끝나야 다음 → 앱 계층 HoL
(그래서 브라우저가 도메인당 6연결을 연다)
HTTP/2 한 TCP 에 스트림 다중화 → 앱 계층 HoL 해소
그런데 패킷 하나가 유실되면 그 연결의 모든 스트림이 멈춘다 → 전송 계층 HoL
HTTP/3 QUIC(UDP) 위에서 스트림마다 독립 재전송 → 전송 계층 HoL 도 해소
HTTP/2 가 HoL 을 완전히 해결했다는 설명은 틀렸다. 앱 계층만 풀었고 전송 계층은 TCP 에 갇혀 있었다. 손실이 있는 망에서 HTTP/2 가 오히려 HTTP/1.1 보다 느릴 수 있는 이유가 이것이다 — 연결이 하나라 손실 하나가 전부에 영향을 준다.
캐시 헤더를 직접 확인한다
curl -sI https://example.com/app.js | grep -iE 'cache-control|etag|last-modified'
# cache-control: public, max-age=31536000, immutable
# etag: "a1b2c3d4"
curl -sI -H 'If-None-Match: "a1b2c3d4"' https://example.com/app.js | head -1
# HTTP/2 304 ← 안 바뀌었으니 본문을 안 보낸다
304 는 본문이 없다. 헤더만 오가므로 대역폭을 크게 아낀다. max-age가 살아 있으면 아예 요청조차 안 나가고, 만료된 뒤에야 If-None-Match로 확인하러 간다.
정리하면, HTTP는 메서드+URL+헤더+본문의 요청과 상태코드+헤더+본문의 응답이라는 단순한 한 쌍으로 웹의 대화를 나른다 — 메서드엔 안전·멱등의 약속이 있고(POST는 비멱등 → 멱등 키), stateless라 연속성은 쿠키로 얹는다(→ CSRF·세션/JWT). 그리고 그 진화(1.1 keep-alive → 2 멀티플렉싱 → 3 QUIC)는 head-of-line 블로킹을 한 겹씩 걷어낸 역사다. 요청-응답이라는 한 방향 전제를 넘어서려는 시도도 같은 줄기다 — *폴링 → 롱 폴링 → SSE*까지는 HTTP를 그대로 두고 우회했고, **WebSocket**은 101로 프로토콜 자체를 바꿔 양방향 프레임 교환으로 넘어간다(서버가 밀어 주기만 하면 SSE, 양쪽이 자주 말하면 WebSocket). 실무에선 연결 재사용·캐시·압축·CDN·멱등성이 그 위에서 성능과 안전을 쥔다.