현재 우리가 사용하는 대부분의 웹 서비스는 HTTP를 기반으로 통신한다.
웹 브라우저가 서버에 HTML을 요청할 때도 HTTP를 사용하고, 애플리케이션이 API를 통해 JSON을 주고받을 때도 HTTP를 사용한다. 이미지, 영상, 음성, 파일도 마찬가지다.
처음에는 단순하게 생각했다.
“HTTP는 클라이언트가 서버에 요청을 보내고 응답을 받는 규칙 아닌가?”
물론 맞는 말이다.
그런데 HTTP를 조금 더 공부하다 보니 또 다른 질문들이 생겼다.
“서버가 클라이언트의 상태를 저장하지 않는다는 것은 무슨 의미일까?”
“HTTP가 연결을 유지하지 않는다면 이미지와 CSS를 받을 때마다 다시 연결해야 하는 걸까?”
“우리가 보는 HTTP 메시지는 어떤 구조로 되어 있을까?”
이번 글에서는 HTTP의 전체 기능을 한 번에 다루기보다, HTTP를 이해하기 위한 기본 뼈대를 먼저 정리해보려고 한다.
HTTP는 HyperText Transfer Protocol의 약자다.
이름만 보면 HyperText, 즉 HTML 문서를 전송하기 위한 프로토콜처럼 보인다. 실제로 초기 HTTP는 웹 문서를 전달하기 위한 단순한 형태에서 시작했다.
하지만 지금은 HTML만 전송하지 않는다.
HTTP 메시지는 다양한 형태의 데이터를 담아 전송할 수 있다. 그래서 웹 브라우저와 웹 서버의 통신뿐 아니라 모바일 앱, 백엔드 API, 서버 간 통신 등에서도 널리 사용된다.
물론 세상의 모든 통신이 HTTP를 사용하는 것은 아니다.
그럼에도 현대 웹 서비스에서 데이터를 주고받는 가장 기본적인 통신 방식 중 하나가 HTTP라는 점은 분명하다.
HTTP는 한 번에 지금의 모습이 된 것이 아니다.
| 버전 | 시기 | 특징 |
|---|---|---|
| HTTP/0.9 | 1991년 | GET만 지원한 매우 단순한 형태, HTTP Header 없음 |
| HTTP/1.0 | 1996년 | Method와 Header 등이 추가됨 |
| HTTP/1.1 | 1997년 | 확장성, 안정성, 지속 연결 등이 개선되며 오랫동안 웹의 기본 토대가 됨 |
| HTTP/2 | 2015년 | 하나의 연결에서 여러 요청을 효율적으로 처리하는 Multiplexing과 Header Compression 도입 |
| HTTP/3 | 2022년 표준화 | TCP 대신 UDP 기반의 QUIC을 사용해 여러 Stream을 처리 |
HTTP/1.1은 1997년 RFC 2068로 처음 표준화된 뒤 여러 차례 개정되었다. 현재 HTTP의 공통 의미는 RFC 9110, HTTP/1.1 메시지 문법은 RFC 9112에서 정의한다.
그런데 여기서 한 가지 주의할 점이 있다.
“HTTP/3는 그냥 UDP 위에서 바로 동작하는 걸까?”
조금 더 정확히 말하면 HTTP/3는 UDP 위에서 동작하는 QUIC을 전송 프로토콜로 사용한다.
HTTP/1.1 HTTP/2 HTTP/3
↓ ↓ ↓
TCP TCP QUIC
↓ ↓ ↓
IP IP UDP
↓
IP
HTTP/1.1과 HTTP/2가 사용하는 TCP 연결에서는 연결을 맺기 위해 TCP 3-way Handshake가 필요하다.
반면 HTTP/3는 QUIC을 사용한다. QUIC은 UDP를 기반으로 신뢰성, 보안, 다중 Stream 처리 등을 제공한다.
즉 HTTP/1.1·HTTP/2 = TCP, HTTP/3 = QUIC over UDP로 이해할 수 있다.
HTTP의 대표적인 특징은 다음과 같다.
하나씩 살펴보자.
HTTP는 Request와 Response를 기준으로 역할을 분리한다.
클라이언트 서버
│ │
│ ──────── HTTP Request ───────────> │
│ │ 요청 처리
│ <──────── HTTP Response ────────── │
│ │
클라이언트는 서버에 요청을 보내고 응답을 기다린다.
서버는 요청을 받아 필요한 작업을 처리한 뒤 그 결과를 응답으로 보낸다.
예를 들어 사용자가 상품 페이지를 열었다고 해보자.
클라이언트: GET /products/1 요청
서버: 1번 상품 조회
서버: 상품 정보를 HTTP Response로 반환
이렇게 역할을 분리하면 클라이언트는 화면과 사용자 경험에 집중하고, 서버는 비즈니스 로직과 데이터 처리에 집중할 수 있다. 양쪽이 독립적으로 발전할 수 있다는 것도 장점이다.
HTTP는 Stateless Protocol이다.
말 그대로 해석하면 서버가 클라이언트의 상태를 보존하지 않는다는 뜻이다.
그런데 이 설명만으로는 조금 추상적이다.
그래서 강의 자료에 나온 노트북 판매 예시로 Stateful과 Stateless의 차이를 정리해봤다.
먼저 상태를 유지하는 경우다.
고객: 이 노트북은 얼마인가요?
점원: 100만 원입니다.
고객: 2개 구매하겠습니다.
점원: 200만 원입니다. 신용카드와 현금 중 무엇으로 구매하시겠어요?
고객: 신용카드로 구매하겠습니다.
점원: 200만 원 결제 완료되었습니다.
이 대화에서 고객은 두 번째 요청부터 모든 정보를 다시 말하지 않는다.
2개 구매하겠습니다라는 말이 어떤 상품을 의미하는지, 신용카드로 구매하겠습니다라는 말이 몇 개의 상품에 대한 것인지 점원이 기억하고 있기 때문이다.
즉 점원이 이전 대화의 상태를 보존한다.
상태 1: 고객이 선택한 상품 = 노트북
상태 2: 구매 수량 = 2개
상태 3: 결제 수단 = 신용카드
그런데 중간에 점원이 바뀌면 어떻게 될까?
고객: 이 노트북은 얼마인가요?
점원 A: 100만 원입니다.
고객: 2개 구매하겠습니다.
점원 B: 무엇을 2개 구매하시겠어요?
고객: 신용카드로 구매하겠습니다.
점원 C: 어떤 상품을 몇 개 구매하시겠어요?
점원 B와 점원 C는 이전 대화를 알지 못한다.
상태를 유지하려면 같은 점원이 계속 응대하거나, 점원이 바뀔 때 이전 상태를 새 점원에게 전달해야 한다.
서버도 마찬가지다.
특정 서버가 클라이언트의 이전 요청 상태를 가지고 있다면 이후 요청도 같은 서버가 처리해야 한다. 해당 서버에 장애가 발생하면 상태를 복구하거나 다른 서버로 전달하는 과정도 필요하다.
이번에는 상태를 유지하지 않는 경우다.
고객: 이 노트북은 얼마인가요?
점원 A: 100만 원입니다.
고객: 이 노트북 2개를 구매하겠습니다.
점원 B: 노트북 2개는 200만 원입니다. 신용카드와 현금 중 무엇으로 구매하시겠어요?
고객: 이 노트북 2개를 신용카드로 구매하겠습니다.
점원 C: 200만 원 결제 완료되었습니다.
이번에는 점원이 계속 바뀌어도 문제가 없다.
각 요청에 작업을 처리하기 위해 필요한 정보가 모두 들어 있기 때문이다.
Stateful에서는 이전 상태를 가진 서버가 필요하다. Stateless에서는 요청 자체에 필요한 정보가 있으므로 여러 서버 중 하나가 처리할 수 있다.
서버 1에 장애가 발생해도 요청에 필요한 정보가 포함되어 있다면 서버 2나 서버 3이 대신 응답할 수 있다.
새로운 서버를 추가하기도 쉽다.
그래서 Stateless 설계는 서버를 수평 확장하는 Scale-out에 유리하다.
물론 단점도 있다.
서버가 이전 상태를 기억하지 않으므로 클라이언트는 각 요청에 필요한 정보를 함께 보내야 한다. Stateful 방식에서는 2개 주세요라고 보낼 수 있었지만, Stateless 방식에서는 이 노트북 2개를 구매하겠습니다처럼 더 많은 정보를 보내야 한다.
그리고 모든 기능을 완전한 Stateless로 설계할 수도 없다.
로그인은 보통 Cookie와 Session 또는 Token 등을 이용해 처리한다. HTTP 자체는 Stateless지만 애플리케이션은 필요한 상태를 별도의 방식으로 관리할 수 있다.
중요한 것은 상태를 무조건 없애는 것이 아니다.
상태 유지는 꼭 필요한 곳에서만 사용하고, 나머지는 가능한 한 Stateless하게 설계한다.
이번에는 연결 관점에서 생각해보자.
만약 클라이언트와 서버가 한 번 연결된 뒤 계속 연결을 유지한다면 어떻게 될까?
클라이언트 1 ───────────── 서버
클라이언트 2 ───────────── 서버
클라이언트 3 ───────────── 서버
요청하지 않는 시간에도 연결 유지
클라이언트 2와 클라이언트 3이 아무 작업도 하지 않는 동안에도 서버는 연결을 유지해야 한다. 연결이 많아질수록 서버가 관리해야 할 자원도 늘어난다.
반대로 요청과 응답이 끝난 뒤 연결을 종료하면 필요한 순간에만 자원을 사용할 수 있다.
TCP 연결
↓
HTTP 요청
↓
HTTP 응답
↓
연결 종료
HTTP의 비연결성은 서버가 클라이언트와 영구적인 연결을 계속 유지하지 않아도 된다는 특성을 말한다.
웹 서비스는 보통 사용자가 계속 요청을 보내는 것이 아니라 화면을 보고 생각하는 시간이 더 길다. 1시간 동안 수천 명이 서비스를 이용하더라도 특정 순간에 서버가 실제로 처리하는 요청 수는 전체 사용자 수보다 훨씬 적을 수 있다.
필요할 때만 요청을 처리한다면 서버 자원을 효율적으로 사용할 수 있다.
맞다.
TCP 연결을 새로 만들 때는 3-way Handshake가 필요하다.
그리고 브라우저가 웹 페이지 하나를 보여주기 위해 필요한 것은 HTML 하나가 아니다.
HTML
CSS
JavaScript
Image
Font
그 밖의 여러 자원
자원을 하나 받을 때마다 TCP 연결을 만들고 종료한다면 연결 비용이 반복된다.
초기 HTTP의 연결 방식을 단순화하면 다음과 같다.
연결 → HTML 요청/응답 → 종료
연결 → CSS 요청/응답 → 종료
연결 → 이미지 요청/응답 → 종료
그래서 HTTP/1.1은 Persistent Connection, 즉 지속 연결을 기본으로 사용한다.
지속 연결에서는 한 번 만든 TCP 연결로 HTML, CSS, JavaScript, Image 등의 요청과 응답을 연속해서 처리하고 필요할 때 연결을 종료한다.
하나의 TCP 연결을 여러 HTTP 요청과 응답에 재사용하므로 연결을 반복해서 만드는 비용을 줄일 수 있다.
HTTP/2는 하나의 TCP 연결 안에서 여러 요청과 응답을 동시에 처리할 수 있도록 Multiplexing을 지원한다. HTTP/3는 QUIC의 Stream을 이용해 이를 다시 개선했다.
여기서 처음의 질문으로 돌아가보자.
“HTTP는 비연결성이라면서 왜 연결을 유지하는 거지?”
비연결성은 모든 요청마다 반드시 TCP 연결을 끊어야 한다는 뜻이 아니다.
HTTP/1.1은 전송 효율을 위해 연결을 일정 시간 재사용할 수 있다. 하지만 클라이언트와 서버가 애플리케이션 사용 내내 하나의 연결에 영구적으로 묶여 있어야 하는 것은 아니다.
그리고 이것은 Stateless와도 다른 개념이다.
| 구분 | 질문 |
|---|---|
| Stateless | 서버가 이전 요청의 애플리케이션 상태를 기억해야 하는가? |
| Persistent Connection | 여러 HTTP 메시지를 같은 전송 연결로 주고받을 것인가? |
같은 TCP 연결을 재사용해도 HTTP 요청의 의미는 각각 독립적으로 해석될 수 있다.
서버 개발에서 특히 어려운 상황 중 하나는 많은 요청이 정확히 같은 시각에 몰리는 경우다.
평소에는 충분했던 서버도 특정 시각에 요청이 폭발하면 버티지 못할 수 있다.
Stateless하게 설계하면 요청을 여러 서버에 분산하기 쉬워지고, 필요한 서버를 추가하는 Scale-out에도 유리하다.
┌──────── 서버 1
클라이언트 요청 ── 로드 밸런서 ── 서버 2
└──────── 서버 3
다만 Stateless 설계만으로 대용량 트래픽 문제가 모두 해결되는 것은 아니다. 데이터베이스 병목, 동시성 제어, Cache, Queue, Rate Limiting 등도 함께 고려해야 한다.
그래도 여러 서버가 같은 요청을 처리할 수 있게 만드는 Stateless 설계는 확장의 중요한 출발점이다.
지금까지 HTTP의 동작 특성을 살펴봤다.
그렇다면 실제 요청과 응답은 어떤 모습으로 전달될까?
HTTP 메시지는 요청 메시지와 응답 메시지로 나뉜다.
아래 구조는 사람이 읽을 수 있는 텍스트 형태를 사용하는 HTTP/1.1 메시지 문법을 기준으로 한다. HTTP/2와 HTTP/3는 실제 전송 과정에서 Binary Frame과 Pseudo-header를 사용하므로 전송 형태가 완전히 같지는 않다.
GET /search?q=hello&hl=ko HTTP/1.1
Host: www.google.com
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Content-Length: 3423
<html>
<body>...</body>
</html>
두 메시지는 다음과 같은 공통 구조를 가진다.
요청과 응답은 Start Line의 형태가 다르지만, Header와 Empty Line, 선택적인 Message Body가 뒤따르는 기본 구조는 같다.
HTTP/1.1 메시지는 크게 다음 순서로 구성된다.
Start Line
Header
Empty Line
Message Body
Header와 Message Body 사이에는 반드시 빈 줄이 들어간다. 이 빈 줄은 Header가 끝났다는 것을 나타낸다.
Start Line은 요청 메시지인지 응답 메시지인지에 따라 구조가 달라진다.
start-line = request-line / status-line
GET /search?q=hello&hl=ko HTTP/1.1
Request Line은 세 부분으로 구성된다.
method SP request-target SP HTTP-version
SP는 Space, 즉 공백 한 칸을 의미한다.
위 요청을 나누면 다음과 같다.
| 구성 요소 | 값 | 의미 |
|---|---|---|
| Method | GET | 서버가 수행해야 할 동작 |
| Request Target | /search?q=hello&hl=ko | 요청할 대상과 Query |
| HTTP Version | HTTP/1.1 | 사용할 HTTP 버전 |
HTTP Method는 서버가 요청 대상에 어떤 동작을 수행해야 하는지 알려준다.
| Method | 대표적인 의미 |
|---|---|
GET | Resource 조회 |
POST | 요청 데이터 처리, 주로 등록 등에 사용 |
PUT | Resource를 대체하거나 생성 |
DELETE | Resource 삭제 |
Method는 단순한 문자열이 아니다. 각 Method에는 안전성, 멱등성 등의 의미가 정의되어 있다. 이 내용은 별도의 글에서 더 자세히 정리할 수 있다.
일반적으로 Origin Server에 직접 요청할 때 Request Target에는 절대 경로와 선택적인 Query가 들어간다.
absolute-path [? query]
예:
/search?q=hello&hl=ko
/searchq=hello&hl=ko경로가 비어 있다면 /를 사용한다.
참고로 Request Target에는 상황에 따라 전체 URI나 * 등이 들어가는 다른 형식도 있다. 다만 일반적인 웹 요청에서는 위와 같은 경로 형태를 가장 자주 보게 된다.
응답 메시지의 Start Line은 Status Line이라고 한다.
HTTP/1.1 200 OK
구조는 다음과 같다.
HTTP-version SP status-code SP reason-phrase
| 구성 요소 | 값 | 의미 |
|---|---|---|
| HTTP Version | HTTP/1.1 | HTTP 버전 |
| Status Code | 200 | 요청 처리 결과를 나타내는 숫자 코드 |
| Reason Phrase | OK | 사람이 이해하기 위한 짧은 설명 |
대표적인 상태 코드 범주는 다음과 같다.
2xx: 요청 성공4xx: 클라이언트 요청 오류5xx: 서버 내부 오류예를 들어 200은 요청 성공, 400은 잘못된 클라이언트 요청, 500은 서버 내부 오류를 의미한다.
OK와 같은 Reason Phrase는 HTTP/1.1에서 볼 수 있는 설명이다. HTTP/2와 HTTP/3에는 이 형태의 Reason Phrase가 없다.
HTTP Header에는 메시지를 해석하는 데 필요한 부가 정보가 들어간다.
HTTP/1.1의 Header Field 문법을 단순화하면 다음과 같다.
field-name ":" OWS field-value OWS
OWS는 Optional Whitespace, 즉 허용되는 선택적 공백이다.
예:
Content-Type: text/html; charset=UTF-8
Content-Length: 3423
여기서 주의할 점이 있다.
Host: www.google.com // 올바른 형태
Host : www.google.com // 잘못된 형태
Field Name과 : 사이에는 공백을 넣으면 안 된다.
또한 HTTP Field Name은 대소문자를 구분하지 않는다. 따라서 의미상 Content-Type과 content-type은 같은 Field Name이다. 다만 문서와 코드에서는 일관된 표기를 사용하는 것이 읽기 좋다.
Header에는 다음과 같은 정보가 들어갈 수 있다.
표준 Header Field가 많이 정의되어 있으며, 필요한 경우 새로운 Field를 확장해 사용할 수도 있다.
Message Body에는 실제로 전송하려는 데이터가 들어간다.
<html>
<body>...</body>
</html>
HTML뿐 아니라 JSON, 이미지, 영상, 파일처럼 바이트로 표현할 수 있는 다양한 데이터를 담을 수 있다.
모든 메시지에 Body가 필요한 것은 아니다. GET 요청처럼 Body 없이 전달되는 메시지도 있고, POST 요청처럼 처리할 데이터를 Body에 담는 경우도 많다.
HTTP 메시지의 기본 구조는 생각보다 단순하다.
Start Line
Header
Empty Line
Body
그런데 이 단순한 구조 안에서 Method, Status Code, Header Field, Body를 이용해 다양한 의미와 데이터를 표현할 수 있다.
단순하면서도 확장할 수 있다는 점이 HTTP의 큰 장점이다.
처음에는 HTTP를 단순히 이렇게 생각했다.
클라이언트가 요청
↓
서버가 응답
틀린 설명은 아니다.
하지만 지금은 그 안에 들어 있는 설계 의도가 조금 더 보인다.
특히 이번에 가장 헷갈렸던 것은 Stateless와 비연결성, 지속 연결의 관계였다.
결국 다음처럼 정리할 수 있었다.
Stateless는 이전 요청의 애플리케이션 상태를 서버가 반드시 기억해야 하는지에 대한 문제다.
지속 연결은 여러 HTTP 메시지를 하나의 전송 연결에서 재사용할지에 대한 문제다.
둘은 비슷해 보이지만 같은 개념이 아니다.
이 차이를 이해하고 나니 HTTP가 단순히 데이터를 보내는 형식이 아니라, 수많은 클라이언트와 서버가 효율적으로 통신하기 위한 설계라는 점이 조금 더 명확해졌다.