HTTP 한눈에 보기: 요청, 응답, 그리고 버전 이야기

HTTP 한눈에 보기: 요청, 응답, 그리고 버전 이야기

웹 페이지 하나를 여는 일을 한 줄로 요약하면 “브라우저가 서버한테 뭔가 달라고 부탁하고, 서버가 그걸 보내준다”입니다. 이 단순한 부탁을 둘 사이에서 약속된 모양으로 주고받게 만든 것이 HTTP입니다. 이름이 길어 보이지만 풀어보면 HyperText Transfer Protocol, “문서를 주고받기 위한 약속” 정도로 옮길 수 있죠.

그런데 한 가지 호기심이 생깁니다. 이렇게 단순한 약속이 오랫동안 살아남으면서, 뒷자리 숫자만 1.0에서 1.1, 2, 3까지 바뀌어 왔다는 점이죠. 같은 이름을 달고도 토대를 TCP에서 UDP로 옮길 만큼 큰 변화도 일어났습니다. 이번 글에서는 HTTP가 기본적으로 어떤 모양인지를 짚어본 다음, 버전이 무엇을 풀어왔는지를 차근차근 따라가 보겠습니다.

요청과 응답이 오가는 단순한 약속

HTTP는 클라이언트가 서버에 메시지를 보내면, 서버가 그에 대한 메시지를 돌려주는 식으로 동작합니다. 앞엣것을 요청(request), 뒤엣것을 응답(response)이라고 부르고, 이 한 쌍이 곧 HTTP의 기본 단위입니다. 한 번에 한 쌍씩 오고 가는 모양이라, 무전기에 비유하면 “교신 한 번”에 해당한다고 볼 수 있죠.

상대를 부르고 답을 듣고 끊는 흐름 자체는 TCP 같은 전송 계층이 책임지지만, HTTP는 그 위에서 “메시지를 어떤 모양으로 만들 것인가”만 정의합니다. 즉 어떤 글자가 어디에 들어가야 하는지, 빈 줄은 어디에 두는지, 본문은 어떻게 시작하는지를 약속해두는 것이 전부죠. 이 단순함 덕분에 새로운 도구가 나와도 곧장 HTTP를 받아 적을 수 있어, 웹의 표준으로 자리 잡을 수 있었습니다.

메시지는 어떻게 생겼나

HTTP 메시지를 직접 보면 의외로 친숙합니다. 사람이 읽을 수 있는 텍스트로 되어 있고 줄바꿈으로 구분되어 있어, 한 번이라도 본 사람이라면 구조가 한눈에 들어옵니다.

요청 메시지 예시
GET /index.html HTTP/1.1
Host: example.com
User-Agent: curl/8.0
Accept: text/html

첫 줄은 “어떤 메서드로, 어디에, 어떤 버전으로” 요청한다는 정보를 담습니다. 두 번째 줄부터는 헤더라고 부르는 부가 정보가 줄줄이 등장하고, 빈 줄 하나가 헤더와 본문의 경계가 되죠. GET 요청에는 본문이 거의 없지만, POST나 PUT 같은 요청에서는 빈 줄 다음에 실제 보낼 데이터가 따라옵니다.

응답 메시지 예시
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Content-Length: 53

<html><body><p>Hello, HTTP!</p></body></html>

응답도 비슷한 모양입니다. 첫 줄에 “어떤 버전으로 답하는지, 결과 코드가 무엇인지”가 적히는데, 2xx는 성공, 4xx는 클라이언트 잘못, 5xx는 서버 사고 같은 식으로 분류됩니다. 자세한 의미는 HTTP 상태 코드 안내서에서 따로 정리해 두었고, 헤더 종류는 정말 많은데 이 중 헷갈리기 쉬운 Host, Origin, Referer 같은 헤더도 별도로 다뤘으니 함께 보시면 좋습니다.

자주 만나는 메서드

요청 첫 줄에 등장하는 메서드는 “이 요청이 무엇을 의도하는지”를 알려줍니다. 가장 자주 쓰이는 GET은 “데이터를 가져온다”, POST는 “데이터를 보낸다” 정도로 바꿔 부를 수 있는데요. 이외에도 데이터를 통째로 바꾸는 PUT, 일부만 바꾸는 PATCH, 없애는 DELETE, 그리고 어떤 메서드가 허용되는지 묻는 OPTIONS 정도가 자주 등장합니다.

여기서 놓치지 말아야 할 점은 메서드가 “약속”일 뿐이라는 사실입니다. 서버가 GET 요청을 받았다고 해서 무조건 데이터만 읽고 끝낸다는 보장은 없고, GET으로 와도 내부적으로 데이터를 바꾸도록 짤 수도 있죠. 다만 이렇게 약속을 어기면 캐시나 검색 엔진처럼 “GET은 안전하다”고 가정하고 동작하는 다른 도구들이 사고를 일으키니, 가능한 한 메서드의 의미를 지키는 것이 좋습니다.

조금 더 들여다보면 HTTP 명세는 메서드를 두 가지 성질로 나눠 설명합니다. 첫 번째는 “안전한가” 즉 서버 상태를 바꾸지 않는가 하는 점이고(GET, HEAD, OPTIONS), 두 번째는 “여러 번 호출해도 결과가 같은가”입니다(멱등성, GET / PUT / DELETE 등). 네트워크가 불안정해서 요청이 두 번 갈 수도 있는 환경에서는 이 두 성질이 굉장히 중요해집니다. 멱등하지 않은 POST를 자동 재시도하면 같은 결제가 두 번 일어날 수 있고, 그래서 라이브러리들이 기본적으로 POST는 재시도하지 않도록 설계되어 있습니다.

무상태라는 약속

HTTP의 또 한 가지 특징은 매 요청이 그전 요청과 무관하게 동작한다는 점입니다. 서버는 같은 클라이언트가 방금 무엇을 했는지 기본적으로 기억하지 않습니다. 이를 두고 무상태(stateless) 프로토콜이라고 부르는데요.

이 결정 덕분에 서버 쪽이 단순해집니다. 요청 하나만 보면 그에 맞는 응답을 만들 수 있어서, 같은 서버를 여러 대 띄워두고 트래픽을 나눠도 문제가 생기지 않죠. 다만 사용자 입장에서는 “로그인을 했는데 다음 페이지에서 다시 풀려 있다”는 현상을 그냥 둘 수가 없으니, 여기에 살을 붙이기 위해 쿠키와 세션이 등장하게 됩니다.

HTTP/1.0과 1.1, 연결을 재사용하다

HTTP가 처음 표준이 되었을 때(1.0) 모양은 “한 요청을 보내고, 응답을 받고, 연결을 끊는다”였습니다. 이 흐름은 명료하지만, 페이지 하나에 이미지가 수십 장 들어가는 시대가 오자 한 페이지를 그리려고 TCP 연결을 수십 번 새로 맺는 일이 생기게 됩니다. 연결 한 번을 맺는 비용이 만만치 않은데, 그 비용을 자원마다 무조건 치러야 했던 셈이죠.

HTTP/1.1은 이 문제를 가장 먼저 손봤습니다. 한 번 맺은 연결을 다음 요청에도 그대로 쓰는 keep-alive를 기본 동작으로 만들어, 같은 서버에 추가로 보낼 요청이 있다면 새 연결을 만들 필요가 없게 했죠. 같은 줄에서 여러 요청을 미리 줄지어 보내는 파이프라이닝도 함께 도입되었지만, 응답 순서가 꼭 요청 순서를 따라야 한다는 제약 때문에 실제로는 잘 쓰이지 못했습니다.

또 하나 1.1에서 자리 잡은 것이 Host 헤더의 의무화입니다. 같은 IP 주소에 여러 도메인이 함께 호스팅되는 일이 흔해지면서, 서버가 “어떤 도메인으로 들어왔는지”를 알아야 적절한 콘텐츠를 골라낼 수 있게 된 건데요. 요즘 웹 호스팅이 가능한 것도 이 작은 결정 덕분이라고 할 수 있습니다.

청크 전송 인코딩(chunked transfer encoding)도 1.1이 들고 온 또 다른 변화입니다. 이전에는 응답을 보내기 전에 본문 전체 크기를 미리 알아야 Content-Length 헤더에 적을 수 있었는데, 이게 동적으로 만들어지는 페이지나 스트리밍 응답에는 잘 맞지 않았습니다. 청크 인코딩은 본문을 작은 조각으로 나눠 차례로 흘려보내고, 마지막에 “끝났다”는 신호를 주는 식이라 전체 크기를 미리 정할 필요가 없습니다. 서버가 한꺼번에 메모리에 다 올리지 않고도 응답을 시작할 수 있어, 검색 결과 페이지처럼 점진적으로 채워지는 화면에서 자주 쓰이게 됩니다.

HTTP/2, 한 연결에 여러 흐름을

1.1이 오랫동안 자리를 지키면서 또 다른 한계가 드러났습니다. 연결을 재사용한다고 해도, 그 연결 안에서는 여전히 한 번에 한 요청씩 차례로만 처리할 수 있었거든요. 앞 요청이 늦으면 뒤 요청도 함께 멈추는 head-of-line blocking 현상이 자주 발생했고, 브라우저는 이걸 우회하려고 한 페이지를 그리는 데 여러 개의 연결을 동시에 열어 두는 식으로 대응해 왔습니다.

HTTP/2는 메시지를 사람이 읽을 수 있는 텍스트가 아니라 바이너리 프레임으로 잘게 쪼개서 흘려보내는 식으로 이 문제를 풀었습니다. 한 연결 안에서도 여러 요청이 동시에 흘러갈 수 있고(멀티플렉싱), 자주 등장하는 헤더는 짧은 부호로 압축해 보내는 HPACK 방식을 도입해 헤더 크기 자체도 줄였습니다. 표면적으로 개발자가 쓰는 인터페이스는 거의 똑같지만, 같은 페이지를 받을 때 통신 효율이 눈에 띄게 좋아졌습니다.

다만 HTTP/2도 결국 TCP 위에서 동작하기 때문에, TCP 자체의 head-of-line blocking은 여전히 남아 있었습니다. 한 연결의 어느 한 패킷이 빠지면 다른 흐름이 멀쩡해도 모든 흐름이 같이 기다려야 하는 한계가 그대로였죠. 이 마지막 한계를 풀기 위해 등장한 것이 HTTP/3입니다.

HTTP/3, 토대 자체를 바꾼 변화

HTTP/3가 흥미로운 이유는 표면이 아니라 바닥을 갈아 끼웠다는 점에 있습니다. 1.0부터 2까지 모든 HTTP 버전은 TCP 위에서 동작했지만, HTTP/3는 UDP 위에 QUIC라는 새 전송 프로토콜을 얹고 그 위에서 동작합니다. “왜 굳이 UDP로?”라는 질문이 자연스럽게 따라오는데요.

이유는 TCP를 더 빠르게 만들기가 어려웠기 때문입니다. TCP는 운영 체제 커널이 다루는 영역이라 새 기능을 넣으려면 모든 운영 체제와 미들박스(중간에 있는 방화벽 같은 장비)가 한꺼번에 따라와야 합니다. QUIC는 신뢰성과 흐름 제어를 사용자 공간에서 새로 구현해 이 발목을 잡지 않도록 한 셈인데요. TLS 핸드셰이크와 연결 핸드셰이크를 한 번에 묶어 첫 요청까지 걸리는 시간을 줄이고, 흐름별로 별도의 신뢰성을 챙겨서 다른 흐름이 빠진 패킷에 발이 묶이지 않게 만들었습니다.

HTTP/3는 RFC 9114로 2022년에 표준이 되었고, 이미 구글, 유튜브, 페이스북 같은 대형 서비스가 일찍부터 채택했습니다. 크롬, 파이어폭스, 사파리 같은 주요 브라우저도 모두 지원하니, 이제는 “보이지 않는 곳에서 이미 쓰이고 있는” 단계에 와 있습니다. 다만 모든 환경이 따라온 것은 아니어서, 회사 방화벽이 UDP를 막아두면 자동으로 HTTP/2 또는 1.1로 떨어져 동작하는 경우도 여전히 있습니다.

QUIC가 가져온 또 다른 선물은 연결 마이그레이션입니다. TCP는 IP 주소와 포트로 연결을 식별하기 때문에, 모바일에서 와이파이로 옮겨가는 순간 기존 연결이 끊기고 새로 맺어야 했습니다. QUIC는 연결마다 별도의 식별자를 두어 네트워크가 바뀌어도 같은 연결을 이어서 쓸 수 있게 만들었습니다. 스트리밍이나 영상 통화처럼 끊김이 곧장 체감되는 서비스에서는 차이가 꽤 크게 느껴집니다.

어떤 버전을 쓰는지 확인해 보기

지금 보고 있는 페이지가 어떤 버전으로 오고 가는지 궁금하면, 브라우저 개발자 도구의 네트워크 탭에서 Protocol 열을 켜면 바로 보입니다. h2로 표시되면 HTTP/2, h3이나 quic로 표시되면 HTTP/3, http/1.1이면 1.1입니다. 대부분의 현대 사이트는 HTTPS를 쓰면서 자동으로 2 이상으로 협상되고, 일부는 3까지 올라가 있습니다.

명령줄에서도 확인할 수 있습니다. curl은 옵션으로 어떤 버전을 사용할지 강제할 수 있는데, 다음처럼 적으면 됩니다.

HTTP 버전 확인
curl -I --http2 https://example.com
curl -I --http3 https://example.com

-I 옵션은 응답 헤더만 보겠다는 뜻이고, 응답 첫 줄에 HTTP/2 200처럼 협상된 버전이 그대로 적혀 있어 한눈에 들어옵니다. 같은 사이트라도 어떤 옵션을 주느냐에 따라 결과가 달라지니, 한두 번 직접 비교해보면 버전이 의미하는 바가 더 손에 잡힙니다.

마치며

HTTP는 단순한 약속에서 출발해 오랫동안 자리를 지켜왔습니다. 중간에 등장한 1.1은 연결 재사용으로, 2는 한 연결의 여러 흐름으로, 3은 토대를 UDP로 옮기는 식으로 그때그때의 한계를 풀어왔습니다. 표면 인터페이스가 거의 그대로라 우리는 큰 차이를 못 느끼고 쓰지만, 그 안에서는 적지 않은 변화가 일어났다는 점만 기억해두어도 다음에 새 버전이 등장할 때 덜 당황하게 됩니다.

다음에는 평문으로 흐르는 HTTP를 어떻게 안전하게 감싸는지, 또 인증서가 신뢰의 사슬을 어떻게 만드는지를 HTTPS 글에서 이어 다뤄두었으니 함께 보시면 좋습니다. HTTP 명세를 더 자세히 살펴보고 싶다면 MDN HTTP 가이드를 추천합니다.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord