HTTP/1.1 요청과 응답 메시지의 구조
HTTP/1.1 메시지를 시작 줄, 헤더, 빈 줄, 본문으로 나누어 읽고, Content-Length를 바이트로 세야 하는 이유와 메서드·상태 코드의 의미, 본문의 끝을 정하는 규칙을 배웁니다.
앞 장의 채팅 서버에서는 '한 줄이 명령 하나이고, 명령과 인수는 |로 나눈다'는 규칙을 우리가 직접 정했습니다. 서버와 클라이언트를 둘 다 우리가 만들었기 때문에 가능한 일이었습니다. 하지만 웹 브라우저나 curl 같은 프로그램과 대화하려면, 그 프로그램들이 이미 쓰고 있는 규칙을 서버가 따라야 합니다. 그 규칙이 HTTP(HyperText Transfer Protocol)입니다.
HTTP/1.1은 TCP 연결 위로 사람이 읽을 수 있는 텍스트 형식의 메시지를 주고받습니다. 그래서 TCP 첫 연결과 다중 클라이언트 서버에서 다룬 ServerSocket과 Socket만으로도 브라우저가 접속할 수 있는 서버를 만들 수 있습니다. 다만 '텍스트처럼 보인다'는 인상 때문에 생기는 함정이 있습니다. 메시지의 본문은 글자가 아니라 바이트이고, 수신자는 본문이 몇 바이트인지 알아야 그다음 메시지를 제자리에서 읽을 수 있습니다. 이 글에서는 다음 글에서 서버를 직접 만들기 전에, 메시지가 어떻게 생겼고 수신자가 메시지의 끝을 어떻게 아는지를 정확히 익힙니다.
이 글은 2022년에 발표된 RFC 9110(HTTP의 의미)과 RFC 9112(HTTP/1.1의 메시지 형식)를 기준으로 합니다. 예전 자료에 자주 나오는 RFC 2616은 2014년에 RFC 7230부터 7235까지의 여섯 문서로 나뉘었고, 이 문서들은 2022년에 다시 RFC 9110, RFC 9111(캐시), RFC 9112로 정리되어 지금은 모두 폐기된 상태입니다.
실제 요청과 응답을 바이트 단위로 보기
형식을 글로 설명하기 전에 진짜 HTTP 메시지부터 보겠습니다. JDK 18부터는 정적 파일을 내려 주는 간단한 웹 서버 jwebserver가 함께 들어 있어서, 코드를 한 줄도 쓰지 않고 HTTP 서버를 띄울 수 있습니다. 빈 폴더에 hello 한 줄이 들어 있는 hello.txt 파일을 만들고, 그 폴더에서 다음 명령을 실행합니다.
jwebserver -p 8000이 서버는 기본적으로 내 컴퓨터 안(루프백 주소)에서 오는 연결만 받고, 현재 폴더의 파일을 내려 줍니다. 다른 터미널에서 curl에 -v(verbose) 옵션을 붙여 요청하면 주고받은 메시지가 화면에 나타납니다.
curl -v http://localhost:8000/hello.txtcurl이 덧붙이는 연결 안내 줄(*로 시작하는 줄 등)을 빼면 출력은 다음과 같습니다. Date 값과 User-Agent에 적힌 curl 버전은 실행하는 환경마다 다릅니다.
> GET /hello.txt HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/8.5.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Date: Tue, 27 Oct 2026 00:03:40 GMT
< Last-modified: Tue, 27 Oct 2026 00:03:40 GMT
< Content-type: text/plain
< Content-length: 6
<
hello>로 시작하는 줄은 curl이 보낸 요청이고, <로 시작하는 줄은 서버가 돌려준 응답입니다. 요청의 첫 줄 GET /hello.txt HTTP/1.1은 'HTTP/1.1 규칙에 따라 /hello.txt를 가져오고 싶다'는 뜻이고, 그 아래 이름: 값 형태의 줄들이 헤더입니다. 요청 끝에 >만 있는 줄은 출력이 잘못된 것이 아니라 '헤더가 여기서 끝난다'는 빈 줄입니다. 응답도 같은 모양입니다. 첫 줄의 200 OK가 처리 결과를 알리고, 헤더 뒤의 빈 줄 다음에 파일 내용 hello가 본문으로 따라옵니다.
Content-length: 6을 눈여겨보세요. hello는 다섯 글자인데 6이라고 적힌 것은 파일 끝의 줄바꿈 문자(\n)까지 본문 바이트로 세었기 때문입니다. 편집기가 줄바꿈 없이 저장했다면 5, Windows 방식 줄바꿈(\r\n)으로 저장했다면 7이 나옵니다. 또 이 서버는 Content-Type이 아니라 Content-type처럼 둘째 단어를 소문자로 썼는데도 curl은 아무 문제 없이 읽습니다. 헤더 이름은 대소문자를 구분하지 않기 때문입니다.
화면에서는 줄이 바뀌어 보이지만, 그 줄바꿈이 실제로 어떤 바이트인지는 --trace - 옵션으로 확인할 수 있습니다. 이 옵션은 주고받은 바이트를 16진수로 모두 보여 줍니다.
curl -s --trace - http://localhost:8000/hello.txt출력 가운데 curl이 보낸 요청 부분만 옮기면 다음과 같습니다. 바이트 수는 curl 버전 문자열의 길이에 따라 조금 다를 수 있습니다.
=> Send header, 86 bytes (0x56)
0000: 47 45 54 20 2f 68 65 6c 6c 6f 2e 74 78 74 20 48 GET /hello.txt H
0010: 54 54 50 2f 31 2e 31 0d 0a 48 6f 73 74 3a 20 6c TTP/1.1..Host: l
0020: 6f 63 61 6c 68 6f 73 74 3a 38 30 30 30 0d 0a 55 ocalhost:8000..U
0030: 73 65 72 2d 41 67 65 6e 74 3a 20 63 75 72 6c 2f ser-Agent: curl/
0040: 38 2e 35 2e 30 0d 0a 41 63 63 65 70 74 3a 20 2a 8.5.0..Accept: *
0050: 2f 2a 0d 0a 0d 0a /*....줄이 끝날 때마다 0d 0a 두 바이트가 있습니다. 0d는 CR(carriage return, Java 문자열의 \r)이고 0a는 LF(line feed, \n)이며, 둘을 합쳐 CRLF라고 부릅니다. HTTP/1.1은 시작 줄과 헤더 줄을 CRLF로 끝내도록 정해 두었습니다. 맨 끝의 0d 0a 0d 0a는 마지막 헤더 줄을 끝내는 CRLF와 '헤더 끝'을 알리는 빈 줄의 CRLF가 이어진 것입니다. 이 요청에는 본문이 없으므로, 서버는 이 네 바이트를 읽은 순간 요청이 끝났다고 판단합니다.
메시지는 시작 줄, 헤더, 빈 줄, 본문으로 나뉜다
방금 본 요청과 응답은 같은 틀을 따릅니다. RFC 9112는 HTTP/1.1 메시지를 다음과 같이 정의합니다.
HTTP-message = start-line CRLF
*( field-line CRLF )
CRLF
[ message-body ]시작 줄(start-line)이 하나 오고, 헤더 줄(field-line)이 0개 이상 이어진 뒤(*는 반복을 뜻합니다), CRLF만 있는 빈 줄이 오고, 마지막으로 있을 수도 없을 수도 있는 본문([ ])이 옵니다. 명세는 헤더를 필드(field)라고 부르지만 이 글에서는 흔히 쓰는 말인 헤더라고 하겠습니다.
가장 작은 요청과 응답으로 네 부분을 하나씩 짚어 보겠습니다. 다음은 /hello 문서를 요청하는 메시지입니다.
GET /hello HTTP/1.1
Host: localhost:8080
요청의 시작 줄은 요청 줄(request line)이라고 부르며, 메서드와 요청 대상과 버전이 공백 하나씩을 사이에 두고 놓입니다. GET은 대상을 가져오는 메서드이고, /hello는 원하는 자원의 경로, HTTP/1.1은 메시지가 따르는 규칙의 버전입니다. Host 헤더는 요청을 받을 서버의 이름과 포트를 알립니다. IP 주소 하나에 여러 웹사이트가 함께 올라가 있는 경우가 흔해서, 서버는 이 헤더를 보고 어느 사이트에 대한 요청인지 구분합니다. 그래서 HTTP/1.1 요청에는 Host가 반드시 하나 있어야 하고, 없거나 두 개 이상이면 서버는 400 Bad Request로 거절해야 합니다.
서버가 요청을 정상적으로 처리하면 다음과 같이 응답합니다.
HTTP/1.1 200 OK
Content-Type: text/plain; charset=UTF-8
Content-Length: 5
hello응답의 시작 줄은 상태 줄(status line)이라고 부르며, 버전과 상태 코드와 이유 문구(reason phrase)가 놓입니다. 이 가운데 의미를 갖는 것은 세 자리 숫자인 상태 코드이고, OK 같은 이유 문구는 사람이 읽기 위한 설명일 뿐입니다. 명세상 이유 문구는 비워 둘 수 있고, 클라이언트는 그 내용에 의존하지 말고 무시하는 편이 좋다고 되어 있습니다. Content-Type은 본문의 형식과 문자 인코딩을, Content-Length는 본문의 바이트 수를 알립니다. 이 응답의 본문은 hello 다섯 바이트이고 뒤에 줄바꿈은 없습니다.
본문의 GET /hello 요청과 200 OK 응답을 시작 줄, 헤더, 헤더 끝의 빈 줄, 본문으로 나눕니다. 예제 요청의 본문은 0바이트, hello 응답의 본문은 5바이트입니다. 시작 줄과 각 헤더 뒤에는 CRLF가 있고 추가 CRLF 한 번이 헤더 끝을 표시합니다.
HTTP/1.1 · MESSAGE PARTS
시작 줄·헤더·빈 줄·본문을 나누어 읽는다
요청과 응답은 같은 네 영역으로 읽습니다. 시작 줄은 의미를, 빈 줄과 길이 규칙은 메시지 경계를 정합니다.
요청 · client → server
- 1 · 요청 시작 줄
GET /hello HTTP/1.1CRLF 메서드 · 대상 · 버전- 2 · 헤더
Host: localhost:8080CRLF 어느 사이트에 대한 요청인가- 3 · 빈 줄 · 헤더 끝
- CRLF
추가 줄바꿈 한 번으로 헤더를 끝냅니다.
- 4 · 이 요청의 본문
- 0바이트 · 없음
길이·전송 코딩 헤더가 없는 이 요청은 헤더 끝에서 완료됩니다.
응답 · server → client
- 1 · 응답 시작 줄
HTTP/1.1 200 OKCRLF 버전 · 상태 코드 · 이유 문구- 2 · 헤더
Content-Type: text/plain; charset=UTF-8CRLFContent-Length: 5CRLF- 3 · 빈 줄 · 헤더 끝
- CRLF
헤더의 마지막 CRLF와 합쳐
\r\n\r\n이 됩니다. - 4 · 이 응답의 본문
hello· 5바이트유효한 길이 헤더에 따라 정확히 5바이트를 읽습니다.
CRLF는 \r\n, 즉 두 바이트 0D 0A입니다. 본문의 줄바꿈은 헤더 끝 표시와 별개이며 전송한다면 본문 길이에 포함됩니다.
위의 두 코드 블록에서는 줄이 바뀌는 것만 보이지만, 실제로 보내는 바이트에서는 시작 줄과 각 헤더 줄 뒤에 CRLF가 붙고, 헤더가 끝나는 곳에 CRLF가 한 번 더 붙습니다. 그래서 헤더가 끝나는 자리에는 언제나 \r\n\r\n 네 바이트가 나타납니다. 본문 안의 줄바꿈은 이 표시와 상관이 없습니다. 본문에 줄바꿈을 넣었다면 그것도 본문의 바이트이므로 Content-Length에 포함됩니다.
헤더 줄은 이름: 값 형태입니다. 이름과 값은 첫 번째 콜론에서 나뉘고, 값 앞뒤의 공백은 값에 들어가지 않습니다. 첫 번째 콜론만 기준으로 삼는 이유는 Host: localhost:8080처럼 값 안에도 콜론이 들어갈 수 있기 때문입니다. 헤더 이름은 대소문자를 구분하지 않으므로 Content-Length, content-length, Content-length는 모두 같은 헤더입니다.
Content-Length는 글자 수가 아니라 바이트 수
Content-Length의 값은 본문을 이루는 옥텟(octet), 즉 8비트 바이트의 개수입니다. 그런데 Java에서 문자열 길이를 구하는 String.length()는 바이트 수가 아니라 UTF-16 코드 단위의 개수를 돌려줍니다. 영문과 숫자만 있는 문자열은 UTF-8로 인코딩해도 한 글자가 한 바이트라서 두 값이 우연히 같습니다. 그래서 영문으로만 시험한 서버는 body.length()를 Content-Length에 넣어도 잘 동작하는 것처럼 보이고, 한글이나 이모지가 들어온 뒤에야 문제가 드러납니다.
안녕 HTTP로 비교해 보겠습니다. 문자와 바이트: 유니코드와 UTF-8에서 본 것처럼 UTF-8은 한글 한 글자를 3바이트로 인코딩합니다.
| 부분 | String.length()가 세는 값 | UTF-8 바이트 수 |
|---|---|---|
안 | 1 | 3 |
녕 | 1 | 3 |
공백과 HTTP | 5 | 5 |
| 합계 | 7 | 11 |
다음 코드는 String.length()로 Content-Length를 계산하는 잘못된 응답 작성기와, 그 응답을 받는 쪽에서 무슨 일이 생기는지를 함께 보여 줍니다. 실제 네트워크 대신 ByteArrayOutputStream에 응답 두 개를 이어서 써서, 한 연결로 응답이 연달아 도착하는 상황을 흉내 냅니다. 그다음 클라이언트가 하듯이 헤더를 읽고 Content-Length만큼만 본문으로 읽습니다. readLine은 LF가 나올 때까지 한 줄을 읽으면서 CR은 버리고, substring(15)는 Content-Length: 열다섯 글자 뒤에 오는 숫자를 잘라 냅니다.
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
public final class CharacterLengthHttpResponse {
// 잘못된 코드: 문자 수를 Content-Length로 보낸다
static void write(PrintWriter writer, String body) {
writer.print("HTTP/1.1 200 OK\r\n");
writer.print("Content-Type: text/plain; charset=UTF-8\r\n");
writer.print("Content-Length: " + body.length() + "\r\n");
writer.print("\r\n");
writer.print(body);
writer.flush();
}
public static void main(String[] args) throws Exception {
// 한 연결로 응답 두 개가 이어서 간다고 가정하고 바이트를 모은다
var wire = new ByteArrayOutputStream();
var writer = new PrintWriter(
new OutputStreamWriter(wire, StandardCharsets.UTF_8));
write(writer, "안녕 HTTP");
write(writer, "bye");
// 클라이언트처럼 Content-Length만큼만 본문으로 읽는다
InputStream input = new ByteArrayInputStream(wire.toByteArray());
for (int number = 1; number <= 2; number += 1) {
String statusLine = readLine(input);
int length = 0;
String line;
while (!(line = readLine(input)).isEmpty()) {
if (line.startsWith("Content-Length:")) {
length = Integer.parseInt(line.substring(15).trim());
}
}
byte[] body = input.readNBytes(length);
System.out.println(number + "번째 시작 줄: " + statusLine);
System.out.println(number + "번째 본문: ["
+ new String(body, StandardCharsets.UTF_8) + "]");
}
}
// LF가 나올 때까지 한 줄을 읽는다(CR은 버린다)
static String readLine(InputStream input) throws IOException {
var bytes = new ByteArrayOutputStream();
int value;
while ((value = input.read()) != '\n') {
if (value == -1) {
throw new EOFException("line truncated");
}
if (value != '\r') {
bytes.write(value);
}
}
return bytes.toString(StandardCharsets.UTF_8);
}
}body.length()는 안녕 HTTP의 문자 수인 7을 돌려줍니다. 그런데 이 문자열을 UTF-8로 인코딩하면 한글 두 글자가 3바이트씩이라 11바이트가 되므로, 출력에서는 남는 4바이트가 어디로 가는지 보면 됩니다.
1번째 시작 줄: HTTP/1.1 200 OK
1번째 본문: [안녕 ]
2번째 시작 줄: HTTPHTTP/1.1 200 OK
2번째 본문: [bye]첫 응답은 Content-Length: 7이라고 선언했으므로 클라이언트는 7바이트만 본문으로 읽습니다. 그 7바이트는 안(3바이트), 녕(3바이트), 공백(1바이트)이라서 본문이 안녕 에서 끊깁니다. 읽히지 않은 HTTP 4바이트는 사라지지 않고 스트림에 남아 있다가, 클라이언트가 다음 응답의 시작 줄을 읽을 때 그 앞에 붙습니다. 그래서 두 번째 시작 줄이 HTTPHTTP/1.1 200 OK라는 엉뚱한 값이 됩니다.
이 예제의 단순한 판독기는 그래도 계속 읽어 나갔지만, 제대로 된 클라이언트라면 이 줄을 올바른 상태 줄로 인정하지 않고 연결을 버립니다. 한 번 어긋난 읽기 위치는 저절로 바로잡히지 않으므로, 같은 연결로 오는 뒤의 메시지도 모두 엉뚱한 곳에서 시작합니다. 아래 단계별 그림은 같은 본문을 길이 7로 읽을 때와 11로 읽을 때 남는 바이트를 나란히 비교합니다.
문자 수 7과 UTF-8 본문 11바이트는 다르다
안녕 HTTP를 UTF-8 11바이트로 인코딩한 뒤 잘못된 길이 7과 올바른 길이 11로 소비했을 때 남는 HTTP 네 바이트를 비교합니다. 문자 수와 바이트 경계의 인과가 실제 소비 과정으로 드러납니다.
본문 연습 문제의 안녕 HTTP를 분석합니다. Java String.length는 UTF-16 코드 단위 7이고 UTF-8 인코딩은 안3+녕3+공백HTTP5로11바이트입니다. Content-Length7을 선언하면안녕공백까지만현재본문으로읽고HTTP4바이트가남습니다. 본문배열length11로선언하면본문끝에서다음메시지를읽습니다.
HTTP/1.1 · BYTE BOUNDARY
문자 수 7과 UTF-8 본문 11바이트는 다르다
먼저 본문을 UTF-8 byte[]로 만들고 그 배열의 length를 선언합니다. 헤더와 헤더 끝의 빈 줄은 본문 길이에 넣지 않습니다.
인코딩 배열의 길이가 같은 본문의 끝을 정한다
안 3·녕 3·공백 1·HTTP 4의 UTF-8 11바이트를 한 줄로 묶는다. 문자 길이 7의 경계는 HTTP 4바이트를 남기고 바이트 길이 11의 경계는 다음 메시지에서 끝난다.
상태별 상세 해설
같은 본문 · 안녕 HTTP
안
UTF-16 코드 단위 1
UTF-8 3바이트
녕
UTF-16 코드 단위 1
UTF-8 3바이트
␠HTTP · ␠는 공백
UTF-16 코드 단위 5
UTF-8 5바이트
잘못된 문자 길이
body.length() = 7
Content-Length: 7
현재 본문으로 읽는 7바이트: 안녕␠
남는 4바이트: HTTP
다음 응답 시작 줄 앞에 본문의 나머지가 남아 메시지 경계를 어긋나게 합니다.
인코딩한 바이트 길이
bodyBytes.length = 11
Content-Length: 11
현재 본문으로 읽는 11바이트: 안녕␠HTTP
남는 본문 바이트: 0
본문을 정확히 소비한 뒤 다음 메시지의 시작 줄을 읽습니다. 연결 재사용 조건은 별도로 검사합니다.
본문의 HttpWireResponseSolution.okText("안녕 HTTP")와 같은 입력입니다. 이 그림은 본문이 허용되는 일반 200 응답의 Content-Length 경계를 비교합니다. HEAD 등 본문 금지 응답에는 별도 규칙을 적용합니다.
이 예의 조건
- 본문이 허용되는 일반 200 응답의 같은 안녕 HTTP에 잘못된 문자 길이와 올바른 바이트 길이를 독립 적용해 공통 단계마다 비교합니다.
- 헤더·헤더 끝 빈 줄은 본문 길이에 포함하지 않습니다. HEAD 등 본문 금지 응답과 연결 재사용 조건은 별도 규칙입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 본문 연습 문제의 안녕 HTTP를 분석합니다. Java String.length는 UTF-16 코드 단위 7이고 UTF-8 인코딩은 안 3+녕 3+공백 HTTP 5로 11바이트입니다. Content-Length 7을 선언하면 안녕과 공백까지만 현재 본문으로 읽고 HTTP 4바이트가 남습니다. 본문 배열 length 11로 선언하면 본문 끝에서 다음 메시지를 읽습니다. |
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 문자 수 7과 UTF-8 본문 11바이트는 다르다 문자 수 7과 UTF-8 본문 11바이트는 다르다 본문 연습 문제의 안녕 HTTP를 분석합니다. Java String.length는 UTF-16 코드 단위 7이고 UTF-8 인코딩은 안 3+녕 3+공백 HTTP 5로 11바이트입니다. Content-Length 7을 선언하면 안녕과 공백까지만 현재 본문으로 읽고 HTTP 4바이트가 남습니다. 본문 배열 length 11로 선언하면 본문 끝에서 다음 메시지를 읽습니다. HTTP/1.1 · BYTE BOUNDARY 문자 수 7과 UTF-8 본문 11바이트는 다르다 먼저 본문을 UTF-8 byte[]로 만들고 그 배열의 length를 선언합니다. 헤더와 헤더 끝의 빈 줄은 본문 길이에 넣지 않습니다. 같은 본문 · 안녕 HTTP 안 UTF-16 코드 단위 1 UTF-8 3바이트 녕 UTF-16 코드 단위 1 UTF-8 3바이트 ␠HTTP · ␠는 공백 UTF-16 코드 단위 5 UTF-8 5바이트 잘못된 문자 길이 body.length() = 7 Content-Length: 7 현재 본문으로 읽는 7바이트: 안녕␠ 남는 4바이트: HTTP 다음 응답 시작 줄 앞에 본문의 나머지가 남아 메시지 경계를 어긋나게 합니다. 인코딩한 바이트 길이 bodyBytes.length = 11 Content-Length: 11 현재 본문으로 읽는 11바이트: 안녕␠HTTP 남는 본문 바이트: 0 본문을 정확히 소비한 뒤 다음 메시지의 시작 줄을 읽습니다. 연결 재사용 조건은 별도로 검사합니다. 본문의 HttpWireResponseSolution.okText("안녕 HTTP") 와 같은 입력입니다. 이 그림은 본문이 허용되는 일반 200 응답의 Content-Length 경계를 비교합니다. HEAD 등 본문 금지 응답에는 별도 규칙을 적용합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 같은 인코딩과 다른 선언 안녕 HTTP를 UTF-8로 만들고 두 길이 정책을 비교합니다. |
|
|
| 각 선언만큼 소비 잘못된 조건은 7바이트만, 올바른 조건은 11바이트 전체를 현재 본문으로 읽습니다. |
|
|
| 다음 메시지의 경계 남은 꼬리 여부가 다음 시작 줄을 읽을 위치를 결정합니다. |
|
|
문자 수 7과 UTF-8 본문 11바이트는 다르다
안녕 HTTP를 UTF-8 11바이트로 인코딩한 뒤 잘못된 길이 7과 올바른 길이 11로 소비했을 때 남는 HTTP 네 바이트를 비교합니다. 문자 수와 바이트 경계의 인과가 실제 소비 과정으로 드러납니다.
본문 연습 문제의 안녕 HTTP를 분석합니다. Java String.length는 UTF-16 코드 단위 7이고 UTF-8 인코딩은 안3+녕3+공백HTTP5로11바이트입니다. Content-Length7을 선언하면안녕공백까지만현재본문으로읽고HTTP4바이트가남습니다. 본문배열length11로선언하면본문끝에서다음메시지를읽습니다.
HTTP/1.1 · BYTE BOUNDARY
문자 수 7과 UTF-8 본문 11바이트는 다르다
먼저 본문을 UTF-8 byte[]로 만들고 그 배열의 length를 선언합니다. 헤더와 헤더 끝의 빈 줄은 본문 길이에 넣지 않습니다.
인코딩 배열의 길이가 같은 본문의 끝을 정한다
안 3·녕 3·공백 1·HTTP 4의 UTF-8 11바이트를 한 줄로 묶는다. 문자 길이 7의 경계는 HTTP 4바이트를 남기고 바이트 길이 11의 경계는 다음 메시지에서 끝난다.
상태별 상세 해설
같은 본문 · 안녕 HTTP
안
UTF-16 코드 단위 1
UTF-8 3바이트
녕
UTF-16 코드 단위 1
UTF-8 3바이트
␠HTTP · ␠는 공백
UTF-16 코드 단위 5
UTF-8 5바이트
잘못된 문자 길이
body.length() = 7
Content-Length: 7
현재 본문으로 읽는 7바이트: 안녕␠
남는 4바이트: HTTP
다음 응답 시작 줄 앞에 본문의 나머지가 남아 메시지 경계를 어긋나게 합니다.
인코딩한 바이트 길이
bodyBytes.length = 11
Content-Length: 11
현재 본문으로 읽는 11바이트: 안녕␠HTTP
남는 본문 바이트: 0
본문을 정확히 소비한 뒤 다음 메시지의 시작 줄을 읽습니다. 연결 재사용 조건은 별도로 검사합니다.
본문의 HttpWireResponseSolution.okText("안녕 HTTP")와 같은 입력입니다. 이 그림은 본문이 허용되는 일반 200 응답의 Content-Length 경계를 비교합니다. HEAD 등 본문 금지 응답에는 별도 규칙을 적용합니다.
이 예의 조건
- 본문이 허용되는 일반 200 응답의 같은 안녕 HTTP에 잘못된 문자 길이와 올바른 바이트 길이를 독립 적용해 공통 단계마다 비교합니다.
- 헤더·헤더 끝 빈 줄은 본문 길이에 포함하지 않습니다. HEAD 등 본문 금지 응답과 연결 재사용 조건은 별도 규칙입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 본문 연습 문제의 안녕 HTTP를 분석합니다. Java String.length는 UTF-16 코드 단위 7이고 UTF-8 인코딩은 안 3+녕 3+공백 HTTP 5로 11바이트입니다. Content-Length 7을 선언하면 안녕과 공백까지만 현재 본문으로 읽고 HTTP 4바이트가 남습니다. 본문 배열 length 11로 선언하면 본문 끝에서 다음 메시지를 읽습니다. |
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 문자 수 7과 UTF-8 본문 11바이트는 다르다 문자 수 7과 UTF-8 본문 11바이트는 다르다 본문 연습 문제의 안녕 HTTP를 분석합니다. Java String.length는 UTF-16 코드 단위 7이고 UTF-8 인코딩은 안 3+녕 3+공백 HTTP 5로 11바이트입니다. Content-Length 7을 선언하면 안녕과 공백까지만 현재 본문으로 읽고 HTTP 4바이트가 남습니다. 본문 배열 length 11로 선언하면 본문 끝에서 다음 메시지를 읽습니다. HTTP/1.1 · BYTE BOUNDARY 문자 수 7과 UTF-8 본문 11바이트는 다르다 먼저 본문을 UTF-8 byte[]로 만들고 그 배열의 length를 선언합니다. 헤더와 헤더 끝의 빈 줄은 본문 길이에 넣지 않습니다. 같은 본문 · 안녕 HTTP 안 UTF-16 코드 단위 1 UTF-8 3바이트 녕 UTF-16 코드 단위 1 UTF-8 3바이트 ␠HTTP · ␠는 공백 UTF-16 코드 단위 5 UTF-8 5바이트 잘못된 문자 길이 body.length() = 7 Content-Length: 7 현재 본문으로 읽는 7바이트: 안녕␠ 남는 4바이트: HTTP 다음 응답 시작 줄 앞에 본문의 나머지가 남아 메시지 경계를 어긋나게 합니다. 인코딩한 바이트 길이 bodyBytes.length = 11 Content-Length: 11 현재 본문으로 읽는 11바이트: 안녕␠HTTP 남는 본문 바이트: 0 본문을 정확히 소비한 뒤 다음 메시지의 시작 줄을 읽습니다. 연결 재사용 조건은 별도로 검사합니다. 본문의 HttpWireResponseSolution.okText("안녕 HTTP") 와 같은 입력입니다. 이 그림은 본문이 허용되는 일반 200 응답의 Content-Length 경계를 비교합니다. HEAD 등 본문 금지 응답에는 별도 규칙을 적용합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 같은 인코딩과 다른 선언 안녕 HTTP를 UTF-8로 만들고 두 길이 정책을 비교합니다. |
|
|
| 각 선언만큼 소비 잘못된 조건은 7바이트만, 올바른 조건은 11바이트 전체를 현재 본문으로 읽습니다. |
|
|
| 다음 메시지의 경계 남은 꼬리 여부가 다음 시작 줄을 읽을 위치를 결정합니다. |
|
|
실제 클라이언트도 이런 응답을 받으면 곤란해집니다. 이 예제처럼 길이를 잘못 적어 응답하는 작은 서버를 만들고 curl 8.5.0에 주소 두 개를 한꺼번에 주어 요청하면, curl은 같은 연결을 다시 쓰려고 하면서 첫 본문을 안녕 까지만 출력합니다. 그리고 두 번째 요청을 보내기 전에 연결에 읽지 않은 바이트가 남아 있는 것을 보고 Connection 0 seems to be dead라는 메시지와 함께 그 연결을 버린 뒤 새 연결을 엽니다. curl은 이렇게 스스로를 지켰지만 모든 클라이언트가 그렇게 하지는 않습니다.
올바른 방법은 순서를 바꾸는 것입니다. 먼저 본문을 getBytes(StandardCharsets.UTF_8)로 인코딩해 byte[]를 만들고, 그 배열의 length를 Content-Length에 씁니다. 헤더는 원칙적으로 ASCII 문자로 쓰므로 US_ASCII로 인코딩하고, 헤더 바이트와 본문 바이트를 차례로 OutputStream에 씁니다. 이렇게 하면 선언한 길이와 실제로 보낸 바이트 수가 언제나 같습니다. 이 방법은 글 끝의 연습 문제에서 직접 구현해 봅니다.
PrintWriter가 편해 보여도 HTTP 메시지를 쓰기에 맞지 않는 이유는 더 있습니다. 먼저 println()은 CRLF가 아니라 운영체제의 줄 구분자를 쓰는데, Linux와 macOS에서는 \n 하나라서 HTTP가 요구하는 CRLF가 되지 않습니다. 또 PrintWriter의 쓰기 메서드는 IOException을 던지지 않고 내부에 오류 상태만 기록하므로, checkError()를 따로 호출하지 않으면 클라이언트가 연결을 끊어도 알 수 없습니다. 문자 집합을 지정하지 않으면 기본 문자 집합을 쓰는데, 그 기본값은 JDK 18부터 UTF-8이고 그 전에는 운영체제 설정에 따라 달랐습니다. 바이트를 직접 다루면 이런 문제를 한꺼번에 피할 수 있습니다.
요청 본문도 같은 원칙으로 읽습니다. Content-Length: 12는 글자 열두 개가 아니라 바이트 열두 개라는 뜻입니다. 서버는 먼저 정확히 12바이트를 읽고, 그다음 Content-Type에 적힌 문자 집합으로 그 바이트를 문자열로 바꿉니다. 요청을 처리하는 코드가 본문을 쓰지 않더라도 12바이트는 반드시 읽어서 버려야 합니다. 그러지 않으면 남은 본문이 다음 요청의 시작 줄 앞에 붙어서, 방금 응답 쪽에서 본 것과 같은 어긋남이 요청 쪽에서 생깁니다.
요청의 시작 줄과 헤더를 객체로 나누기
이제 요청의 시작 줄과 헤더를 Java 객체로 바꾸는 코드를 보겠습니다. 간단해 보이지만, 문자열을 아무렇게나 자르면 정상적인 값이 망가지거나 거절해야 할 요청을 받아들이게 됩니다. 이 코드는 다음 규칙을 따릅니다.
- 요청 줄은 공백 하나로 구분된 정확히 세 부분이어야 합니다.
- 헤더는 첫 번째 콜론에서 이름과 값으로 나눕니다. 값 안의 콜론은 값의 일부입니다.
- 헤더 이름은 소문자로 바꿔 저장하고, 찾을 때도 소문자로 찾습니다. 값은 바꾸지 않습니다.
- 헤더 이름과 콜론 사이에 공백이 있으면 거절합니다.
Host헤더가 정확히 하나 있어야 합니다.
네 번째 규칙은 RFC 9112가 서버에 요구하는 것입니다. Content-Length : 5처럼 이름 뒤에 공백이 붙은 헤더를 어떤 프로그램은 Content-Length로 읽고 어떤 프로그램은 모르는 헤더로 무시한다면, 두 프로그램이 본문 길이를 서로 다르게 판단하게 됩니다. 뒤에서 설명할 요청 스머글링이 바로 이런 차이를 노리기 때문에, 명세는 이런 요청을 400으로 거절하라고 정해 두었습니다.
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
public final class HttpRequestModel {
record RequestLine(String method, String target, String version) {
RequestLine {
if (!method.matches("[A-Z]+")) {
throw new IllegalArgumentException("invalid method: " + method);
}
if (!target.startsWith("/")) {
throw new IllegalArgumentException("origin-form target required");
}
if (!version.equals("HTTP/1.1")) {
throw new IllegalArgumentException("unsupported version: " + version);
}
}
}
record Request(RequestLine line, Map<String, List<String>> headers,
byte[] body) {
String firstHeader(String name) {
List<String> values = headers.get(name.toLowerCase(Locale.ROOT));
return values == null ? null : values.getFirst();
}
}
// 헤더 이름에 쓸 수 있는 문자(RFC 9110의 token 규칙)
private static final String TOKEN = "[!#$%&'*+.^_`|~0-9A-Za-z-]+";
static Request parse(List<String> headLines, byte[] body) {
if (headLines.isEmpty()) {
throw new IllegalArgumentException("request line missing");
}
String[] start = headLines.getFirst().split(" ", -1);
if (start.length != 3) {
throw new IllegalArgumentException(
"invalid request line: " + headLines.getFirst());
}
RequestLine line = new RequestLine(start[0], start[1], start[2]);
Map<String, List<String>> headers = new LinkedHashMap<>();
for (String raw : headLines.subList(1, headLines.size())) {
int colon = raw.indexOf(':');
String name = colon < 0 ? "" : raw.substring(0, colon);
if (!name.matches(TOKEN)) {
throw new IllegalArgumentException("invalid header: " + raw);
}
String value = raw.substring(colon + 1).trim();
headers.computeIfAbsent(name.toLowerCase(Locale.ROOT),
key -> new ArrayList<>())
.add(value);
}
if (headers.getOrDefault("host", List.of()).size() != 1) {
throw new IllegalArgumentException("exactly one Host required");
}
return new Request(line, headers, body);
}
public static void main(String[] args) {
Request request = parse(
List.of(
"POST /notes?q=java HTTP/1.1",
"Host: localhost:8080",
"Content-Type: text/plain; charset=UTF-8",
"X-Trace: stage:parse"),
"한글".getBytes(StandardCharsets.UTF_8));
System.out.println(request.line());
System.out.println(request.firstHeader("CONTENT-TYPE"));
System.out.println(request.firstHeader("x-trace"));
System.out.println("bodyBytes=" + request.body().length);
List<List<String>> badHeads = List.of(
List.of("GET /a HTTP/1.1"),
List.of("GET /a HTTP/1.1", "Host : localhost"),
List.of("GET /a HTTP/1.1", "Host: localhost"));
for (List<String> bad : badHeads) {
try {
parse(bad, new byte[0]);
} catch (IllegalArgumentException error) {
System.out.println("거절: " + error.getMessage());
}
}
}
}parse는 이미 줄 단위로 나뉜 헤더와 본문 바이트를 받습니다. 소켓에서 줄과 본문을 읽어 오는 일은 HTTP 본문 읽기: Content-Length와 chunked에서 따로 다룹니다. 요청 줄은 split(" ", -1)로 나눕니다. 공백이 두 칸 들어가면 그 사이에 빈 토큰이 생기고, 두 번째 인수 -1은 줄 끝에 공백이 붙었을 때 생기는 빈 토큰까지 버리지 말라는 뜻입니다. 어느 쪽이든 세 부분이 되지 않으므로 요청이 거절됩니다. 헤더 이름은 RFC 9110이 정한 토큰 문자만 허용하는 정규식 TOKEN으로 검사합니다. 공백은 토큰 문자가 아니므로 Host : localhost 같은 줄이 여기서 걸리고, 같은 이름의 헤더가 여러 번 오면 computeIfAbsent가 만든 목록에 차례로 쌓입니다.
실행 결과:
RequestLine[method=POST, target=/notes?q=java, version=HTTP/1.1]
text/plain; charset=UTF-8
stage:parse
bodyBytes=6
거절: exactly one Host required
거절: invalid header: Host : localhost
거절: invalid request line: GET /a HTTP/1.1첫 줄은 레코드의 기본 toString()이 만든 요청 줄입니다. 둘째 줄은 CONTENT-TYPE처럼 대문자로 찾아도 값이 나온다는 것을, 셋째 줄은 값 안의 콜론이 잘리지 않고 stage:parse로 남았다는 것을 보여 줍니다. 본문 한글은 두 글자지만 UTF-8로는 6바이트입니다. 나머지 세 줄은 차례대로 Host가 없는 요청, 이름 뒤에 공백이 있는 헤더, 공백이 두 칸인 요청 줄이 거절된 결과입니다.
이 모델은 학습을 위해 범위를 좁혔습니다. 메서드는 대문자로만 된 것을 받는데, 명세상 메서드는 대소문자를 구분하는 토큰이고 표준 메서드가 모두 대문자일 뿐입니다. 요청 대상도 /로 시작하는 원점 형식(origin-form)만 받습니다. 프록시에 보내는 http://로 시작하는 절대 형식(absolute-form), CONNECT가 쓰는 호스트:포트 모양의 권한 형식(authority-form), OPTIONS *의 * 하나만 쓰는 별표 형식(asterisk-form)은 받지 않습니다.
더 중요한 한계는 크기입니다. 실제 서버는 이런 모델을 만들기 전에, 네트워크에서 바이트를 읽는 단계에서 줄 길이, 헤더 개수, 헤더 전체 크기, 본문 크기에 상한을 두어야 합니다. 모델의 검사가 아무리 엄격해도, 그 앞 단계에서 끝없이 들어오는 헤더를 메모리에 모두 읽어 들였다면 이미 늦습니다.
헤더 값을 Map<String, List<String>>에 목록으로 보관한 데도 이유가 있습니다. 어떤 헤더는 한 메시지에 여러 번 올 수 있고, 여러 값을 쉼표로 합쳐도 되는지는 헤더마다 다릅니다. 그래서 헤더를 보관하는 단계에서는 값을 덮어쓰지 않고 모두 남겨 둡니다. 반대로 Content-Length처럼 본문 길이를 정하는 헤더가 서로 다른 값으로 두 번 오면, 어느 쪽을 믿을지 고를 것이 아니라 요청 자체를 거절해야 합니다. 이 검사는 본문을 읽기 전에 해야 하므로 HTTP 본문 읽기: Content-Length와 chunked에서 함께 다룹니다.
메서드의 성질: 안전한 메서드와 멱등 메서드
GET은 조회, POST는 생성이라고 외워 두면 간단하지만, 서버를 설계하거나 클라이언트의 재시도 정책을 정할 때는 그것만으로 부족합니다. RFC 9110은 메서드의 성질을 안전성과 멱등성 두 가지로 구분합니다.
안전한(safe) 메서드는 클라이언트가 서버의 상태를 바꾸려는 의도가 없는 메서드입니다. 서버가 요청을 로그에 남기는 정도의 부수 효과는 생길 수 있지만, 클라이언트가 그것을 요청한 것은 아닙니다. 멱등(idempotent) 메서드는 같은 요청을 여러 번 보냈을 때 서버에 의도한 효과가 한 번 보냈을 때와 같은 메서드입니다. 안전한 메서드는 애초에 상태를 바꾸려 하지 않으므로 모두 멱등하기도 합니다.
예를 들어 PUT /notes/7로 같은 내용을 두 번 보내도 7번 메모는 그 내용 하나로 남고, DELETE /notes/7을 두 번 보내도 메모는 지워진 상태 그대로입니다. 두 번째 DELETE의 응답은 404처럼 달라질 수 있지만, 멱등성은 응답이 아니라 서버 상태에 대한 의도를 말합니다. 반면 POST /orders를 두 번 보내면 주문이 두 건 생길 수 있습니다.
| 메서드 | 주된 용도 | 안전 | 멱등 |
|---|---|---|---|
GET | 대상의 현재 상태를 가져온다 | 예 | 예 |
HEAD | GET과 같지만 응답 본문 없이 헤더만 받는다 | 예 | 예 |
OPTIONS | 대상이 지원하는 기능을 묻는다 | 예 | 예 |
POST | 보낸 데이터를 대상이 처리하게 한다(생성 등) | 아니요 | 아니요 |
PUT | 대상의 상태를 보낸 내용으로 바꾼다 | 아니요 | 예 |
DELETE | 대상을 삭제한다 | 아니요 | 예 |
PATCH | 대상의 일부를 수정한다(RFC 5789) | 아니요 | 아니요 |
RFC 9110에는 이 밖에 TRACE(안전, 멱등)와 CONNECT(둘 다 아님)도 정의되어 있지만, 일반적인 웹 애플리케이션 서버에서는 거의 쓰지 않습니다.
이 구분이 실제로 쓰이는 곳은 재시도입니다. 요청을 보낸 뒤 응답을 받기 전에 연결이 끊어지면, 클라이언트는 서버가 요청을 처리했는지 알 수 없습니다. 멱등 메서드라면 다시 보내도 결과가 같으니 재시도할 수 있습니다. 하지만 POST를 자동으로 다시 보내면 주문이 두 번 들어갈 수 있으므로, RFC 9110은 멱등하지 않은 요청을 클라이언트가 자동으로 재시도하지 말라고 권고합니다. 예외는 그 요청이 실제로는 멱등하다는 것을 알거나, 원래 요청이 처리되지 않았음을 확인할 수단이 있을 때뿐입니다.
반대로 멱등하다고 해서 언제나 자동으로 재시도해도 되는 것은 아닙니다. 요청 본문을 다시 만들어 보낼 수 있어야 하고, 그사이 인증 토큰이 만료되지 않았는지, 전체 처리 기한이 남았는지도 따져야 합니다. 무엇보다 서버가 메서드의 의미를 실제로 지켜야 합니다. GET /delete?id=7처럼 조회 메서드로 상태를 바꾸는 서버는, 재시도하는 클라이언트나 링크를 미리 읽어 두는 브라우저와 검색 로봇 때문에 의도하지 않은 삭제를 겪게 됩니다.
POST에 요청마다 고유한 키를 붙여 서버가 같은 키의 요청을 한 번만 처리하게 만드는 방법도 널리 쓰입니다. 이것은 서버의 업무 로직이 중복을 막아 주는 것이지, POST라는 메서드 자체가 멱등해지는 것은 아닙니다. 그래서 이런 약속이 없는 일반 클라이언트는 여전히 POST를 자동으로 재시도하면 안 됩니다.
상태 코드는 첫 자리로 분류된다
상태 코드는 세 자리 숫자이고, 첫 자리가 응답의 종류를 나타냅니다.
| 분류 | 의미 | 대표적인 예 |
|---|---|---|
| 1xx | 정보: 요청을 받았고 처리가 이어진다 | 100 Continue |
| 2xx | 성공 | 200 OK, 204 No Content |
| 3xx | 리다이렉션: 요청을 끝내려면 추가 동작이 필요하다 | 301 Moved Permanently, 304 Not Modified |
| 4xx | 클라이언트 오류: 요청에 문제가 있다 | 400 Bad Request, 404 Not Found, 408 Request Timeout |
| 5xx | 서버 오류: 서버가 요청을 처리하지 못했다 | 500 Internal Server Error, 503 Service Unavailable |
분류가 중요한 이유는, 클라이언트가 모르는 상태 코드를 받으면 같은 분류의 x00 코드처럼 처리하도록 명세가 정해 두었기 때문입니다. 예를 들어 429를 처음 보는 클라이언트도 첫 자리 4를 보고 400과 같은 클라이언트 오류로 다룰 수 있습니다. 그래서 서버는 상태 코드를 고를 때 적어도 분류만큼은 정확해야 합니다. 요청 형식이 잘못됐으면 4xx이고, 서버 내부 문제나 과부하로 처리하지 못했으면 5xx입니다.
상태 코드는 본문이 있는지에도 영향을 줍니다. 1xx, 204, 304 응답과 HEAD 요청에 대한 응답에는 본문이 없습니다. 수신자는 이런 응답이 헤더 끝의 빈 줄에서 끝났다고 판단하므로, 서버가 여기에 본문 바이트를 붙여 보내면 그 바이트는 다음 응답의 시작으로 읽힙니다. 앞에서 본 어긋남이 그대로 생기는 것입니다.
HEAD 응답은 GET이었다면 보냈을 헤더를 그대로 보내고 본문만 뺍니다. 이때 Content-Length를 보낸다면 그 값은 GET이었다면 보냈을 본문의 바이트 수와 같아야 합니다. 그래서 응답을 쓰는 코드는 상태 코드와 요청 메서드를 함께 보고 본문을 붙일지 결정해야 합니다.
본문이 어디서 끝나는지 정하는 규칙
HTTP/1.1에서는 연결이 기본적으로 유지됩니다. 응답을 하나 보낸 뒤에도 TCP 연결을 닫지 않고 같은 연결로 다음 요청을 받는데, 이를 지속 연결(persistent connection), 흔히 keep-alive라고 부릅니다. 연결을 새로 맺는 비용을 아낄 수 있는 대신, 수신자는 연결이 닫히기를 기다리지 않고도 본문이 어디서 끝나는지 알아야 합니다. 그 방법은 세 가지입니다.
첫째는 지금까지 본 Content-Length입니다. 둘째는 Transfer-Encoding: chunked로, 본문을 여러 조각(chunk)으로 나누고 조각마다 크기를 16진수로 먼저 적는 방식입니다. 만들면서 바로 보내는 응답처럼, 보내기 시작할 때 전체 길이를 알 수 없는 경우에 씁니다. 다음은 hello world를 두 조각으로 보내는 응답을 줄 단위로 펼친 것입니다. 실제 바이트에서는 각 줄 끝에 CRLF가 붙습니다.
HTTP/1.1 200 OK
Content-Type: text/plain; charset=UTF-8
Transfer-Encoding: chunked
5
hello
6
world
0
5는 뒤따르는 조각이 5바이트라는 뜻이고, 6은 앞의 공백 1바이트를 포함한 world 6바이트라는 뜻입니다. 크기가 0인 조각이 본문의 끝을 알리고, 그 뒤에 트레일러(trailer)라는 추가 헤더가 올 수 있으며, 마지막 빈 줄로 메시지가 끝납니다. 이 응답을 보내는 서버에 curl로 요청하면 curl은 조각 정보를 걷어 내고 hello world만 출력합니다.
셋째는 연결을 닫는 것입니다. 응답에 길이 정보가 없으면 수신자는 서버가 연결을 닫을 때까지 받은 바이트를 모두 본문으로 봅니다. 이 방법은 응답에만 쓸 수 있고, 당연히 그 연결은 다시 쓸 수 없습니다. 요청에는 이런 방법이 없어서, 길이 정보가 없는 요청은 본문이 없는 것으로 봅니다(아래 표의 7번).
RFC 9112는 수신자가 본문 길이를 정하는 순서를 다음과 같이 정해 두었습니다. 위에서부터 먼저 해당하는 규칙을 적용합니다.
| 순서 | 상황 | 본문의 끝 | 이 장의 서버가 할 일 |
|---|---|---|---|
| 1 | HEAD 응답, 1xx·204·304 응답 | 헤더 끝(본문 없음) | 본문을 붙이지 않는다 |
| 2 | CONNECT에 대한 2xx 응답 | 빈 줄 뒤로는 터널 | 다루지 않는다 |
| 3 | Transfer-Encoding과 Content-Length가 함께 있음 | Transfer-Encoding을 따른다 | 400으로 거절하고 연결을 닫는다 |
| 4 | Transfer-Encoding이 chunked로 끝남 | 크기 0 조각과 트레일러 뒤 빈 줄 | 조각 문법과 크기 상한을 검사한다 |
| 5 | Content-Length가 잘못됐거나 서로 다른 값이 여러 개 | 판단할 수 없음 | 400으로 거절하고 연결을 닫는다 |
| 6 | 올바른 Content-Length 하나 | 정확히 그 바이트 수 | 그만큼 읽는다. 중간에 끊기면 불완전한 메시지다 |
| 7 | 위에 해당하지 않는 요청 | 본문 0바이트 | 헤더 끝에서 요청이 끝난다 |
| 8 | 위에 해당하지 않는 응답 | 연결이 닫힐 때 | 그 연결은 다시 쓸 수 없다 |
3번 규칙에서 명세는 서버가 이런 요청을 거절하거나 Transfer-Encoding만 따라 처리하는 것을 모두 허용합니다. 다만 어느 쪽이든 응답한 뒤에는 연결을 닫아야 하고, 이 장의 서버는 더 단순한 쪽인 거절을 택합니다. 또 요청의 Transfer-Encoding이 chunked로 끝나지 않으면 길이를 알 수 없으므로 이것도 400으로 거절합니다. 5번처럼 길이를 판단할 수 없는 경우를 추측으로 처리하지 않고 거절하는 이유는 아래 '더 깊이'에서 설명합니다.
지속 연결과 관련해 자주 생기는 오해도 짚어 두겠습니다. Connection: keep-alive 헤더는 지속 연결이 기본이 아니었던 HTTP/1.0 시절에 연결을 유지해 달라고 요청하던 방법입니다. HTTP/1.1에서는 아무 표시가 없어도 연결이 유지되고, 끝내고 싶을 때 Connection: close를 보냅니다. 또 Connection 헤더는 바로 맞닿은 두 프로그램 사이의 연결에만 적용되는 홉 단위(hop-by-hop) 헤더라서, 프록시는 이 헤더를 다음 구간으로 전달하지 않습니다.
지속 연결을 지원하는 일은 헤더 하나로 끝나지 않습니다. 한 연결에서 요청을 반복해 읽는 반복문, 요청마다 상태를 새로 만드는 처리, 연결당 최대 요청 수와 유휴 시간 제한, 그리고 무엇보다 정확한 본문 길이 처리가 모두 필요합니다. 그래서 요청 하나만 처리하고 연결을 닫는 학습용 서버라면, 응답에 Connection: close를 적고 실제로 소켓도 닫는 편이 정직합니다. 다음 글에서 만드는 서버도 그렇게 시작합니다.
더 깊이: 요청 스머글링
클라이언트와 서버 사이에 프록시나 로드밸런서가 있으면, 요청 하나를 두 프로그램이 차례로 읽습니다. 공격자가 Content-Length와 Transfer-Encoding을 함께 넣은 요청을 보냈는데 앞의 프록시는 Content-Length를, 뒤의 서버는 Transfer-Encoding을 따른다고 해 봅시다. 두 프로그램은 요청이 끝나는 위치를 서로 다르게 판단하고, 프록시가 본문의 일부라고 생각해 넘긴 바이트를 서버는 다음 요청의 시작으로 읽습니다.
프록시는 보통 여러 사용자의 요청을 같은 백엔드 연결로 보냅니다. 그러면 공격자가 심어 둔 바이트가 다음 사용자의 요청 앞에 붙어 처리되고, 그 사용자의 요청이 공격자가 원하는 요청으로 바뀌거나 응답이 엉뚱한 사용자에게 돌아갈 수 있습니다. 이것을 요청 스머글링(request smuggling)이라고 부릅니다. 헤더 이름 뒤의 공백, 서로 다른 Content-Length 값, 잘못된 줄 끝 처리처럼 구현마다 해석이 갈릴 수 있는 지점은 모두 같은 공격에 쓰일 수 있습니다. 그래서 명세는 헤더 이름 뒤의 공백이나 서로 다른 Content-Length 값처럼 해석이 갈리는 입력을 400으로 거절하게 하고, 길이가 모호한 요청에 응답한 뒤에는 연결을 닫으라고 요구합니다.
더 깊이: HTTP/2와 HTTP/3에서 달라지는 것
HTTP 명세가 RFC 9110과 RFC 9112로 나뉘어 있는 것은 HTTP에 여러 버전이 있기 때문입니다. 메서드, 상태 코드, 헤더의 의미처럼 버전과 상관없는 내용은 RFC 9110에 모으고, 메시지를 바이트로 어떻게 적는지는 버전마다 별도 문서로 정의합니다. RFC 9112는 HTTP/1.1의 텍스트 형식을, RFC 9113과 RFC 9114는 각각 HTTP/2와 HTTP/3의 형식을 다룹니다.
HTTP/2와 HTTP/3는 텍스트 줄 대신 길이가 적힌 이진 프레임으로 메시지를 보내고, 한 연결에서 여러 요청을 동시에 주고받습니다. 그래서 CRLF를 찾거나 chunked를 해석하는 일이 없고, 이 글에서 본 줄 끝과 본문 길이 문제의 상당 부분이 형식 차원에서 사라집니다. 하지만 GET이 안전하고 PUT이 멱등하다는 것, 404가 무엇을 뜻하는지는 그대로입니다. 이 장에서는 소켓만으로 직접 구현할 수 있는 HTTP/1.1을 다룹니다.
정리
- HTTP/1.1 메시지는 시작 줄, 헤더 줄들, 빈 줄, 본문으로 이루어집니다. 시작 줄과 헤더 줄은 CRLF(
\r\n)로 끝나므로 헤더가 끝나는 곳에는\r\n\r\n이 나타납니다. - 요청 줄은 메서드, 요청 대상, 버전으로, 상태 줄은 버전, 상태 코드, 이유 문구로 이루어집니다. 의미를 갖는 것은 상태 코드입니다.
- 헤더는 첫 번째 콜론에서 이름과 값으로 나누고, 이름은 대소문자를 구분하지 않습니다. HTTP/1.1 요청에는
Host가 정확히 하나 있어야 합니다. Content-Length는 본문의 바이트 수입니다. Java에서는 본문을 먼저byte[]로 인코딩하고 그 배열의 길이를 적어야 하며, UTF-16 코드 단위 수인String.length()를 쓰면 안 됩니다.- 안전한 메서드는 상태를 바꾸려는 의도가 없고, 멱등 메서드는 여러 번 보내도 의도한 효과가 같습니다. 멱등하지 않은 요청은 자동으로 재시도하지 않습니다.
- 본문의 끝은
Content-Length,chunked, 연결 종료 가운데 하나로 정해집니다. 1xx·204·304 응답과HEAD응답에는 본문이 없고, 길이를 판단할 수 없는 요청은 400으로 거절한 뒤 연결을 닫습니다.
연습 문제
- 텍스트 본문을 받아
200 OK응답 전체를byte[]로 만드는okText(String body)를 작성하세요. 시작 줄과 헤더 줄은 CRLF로 끝나야 하고, 헤더 끝에는 빈 줄이 정확히 하나 있어야 하며,Content-Length는 UTF-8 본문의 바이트 수여야 합니다. 만든 배열을 다시 헤더와 본문으로 나누어, 선언한 길이와 실제 본문 길이가 같은지도 확인하세요. - 본문이
A한😀일 때String.length()와 UTF-8 바이트 수는 각각 얼마인가요? 같은 자원을GET과HEAD로 요청했을 때, 각 응답의Content-Length값과 실제로 붙는 본문 바이트 수를 적어 보세요. - 이 글에서 정한 규칙으로 판단할 때, 다음 요청 가운데 서버가 400으로 거절해야 하는 것을 모두 고르고 이유를 설명하세요.
- (가)
Host헤더가 없는GET요청 - (나)
Content-Length: 5와Content-Length: 6이 함께 있는 요청 - (다)
Content-Length와Transfer-Encoding: chunked가 함께 있는 요청 - (라) 본문 길이 헤더가 하나도 없는
GET요청 - (마)
Host : localhost처럼 이름 뒤에 공백이 있는 헤더가 들어 있는 요청
- (가)
1번 풀이: 헤더와 본문을 따로 인코딩해 이어 붙이기
헤더는 US-ASCII로, 본문은 UTF-8로 각각 인코딩한 뒤 ByteArrayOutputStream에 순서대로 씁니다. 문자 스트림을 거치지 않으므로 println()의 줄 구분자나 기본 문자 집합에 영향을 받지 않습니다. Content-Length는 인코딩을 마친 본문 배열의 length로 계산합니다.
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
public final class HttpWireResponseSolution {
static byte[] okText(String body) {
byte[] bodyBytes = body.getBytes(StandardCharsets.UTF_8);
String head = "HTTP/1.1 200 OK\r\n"
+ "Content-Type: text/plain; charset=UTF-8\r\n"
+ "Content-Length: " + bodyBytes.length + "\r\n"
+ "Connection: close\r\n"
+ "\r\n";
var output = new ByteArrayOutputStream();
output.writeBytes(head.getBytes(StandardCharsets.US_ASCII));
output.writeBytes(bodyBytes);
return output.toByteArray();
}
public static void main(String[] args) {
byte[] response = okText("안녕 HTTP");
byte[] separator = "\r\n\r\n".getBytes(StandardCharsets.US_ASCII);
int bodyStart = indexAfter(response, separator);
byte[] headBytes = Arrays.copyOfRange(response, 0, bodyStart);
byte[] bodyBytes = Arrays.copyOfRange(response, bodyStart, response.length);
String head = new String(headBytes, StandardCharsets.US_ASCII);
String lengthLine = head.lines()
.filter(line -> line.startsWith("Content-Length:"))
.findFirst()
.orElseThrow();
int declared = Integer.parseInt(lengthLine.split(":", 2)[1].trim());
System.out.println("declared=" + declared);
System.out.println("actual=" + bodyBytes.length);
System.out.println(new String(bodyBytes, StandardCharsets.UTF_8));
}
private static int indexAfter(byte[] source, byte[] marker) {
outer:
for (int index = 0; index <= source.length - marker.length; index += 1) {
for (int offset = 0; offset < marker.length; offset += 1) {
if (source[index + offset] != marker[offset]) {
continue outer;
}
}
return index + marker.length;
}
throw new IllegalArgumentException("header terminator missing");
}
}실행 결과:
declared=11
actual=11
안녕 HTTPmain은 받는 쪽의 입장에서 검사합니다. indexAfter로 \r\n\r\n이 처음 나오는 위치를 찾아 그 뒤를 본문으로 자르고, 헤더 부분에서 Content-Length 줄을 찾아 선언된 값과 실제 본문 길이를 비교합니다. String.lines()는 CRLF도 줄 끝으로 인식하므로 헤더를 줄 단위로 나누는 데 쓸 수 있습니다. 앞의 단계별 그림이 비교한 것도 바로 이 okText("안녕 HTTP")의 본문입니다.
이미지처럼 문자열이 아닌 본문을 보내려면 okText가 String 대신 이미 만들어진 byte[]를 받게 바꾸고 그 배열의 길이를 쓰면 됩니다. HEAD 요청을 지원하려면, Content-Length 헤더는 같은 값으로 두고 본문 배열만 붙이지 않는 분기를 추가합니다.
2번 풀이: 글자 수, 선언한 길이, 실제로 보내는 본문
String.length()는 4입니다. A와 한은 UTF-16 코드 단위 하나씩이고, 😀은 기본 다국어 평면 밖의 문자라서 서로게이트 쌍, 즉 코드 단위 두 개로 저장되기 때문입니다. UTF-8 바이트 수는 A 1바이트, 한 3바이트, 😀 4바이트를 더한 8입니다.
GET 응답은 Content-Length: 8을 보내고 본문 8바이트를 붙입니다. HEAD 응답도 GET이었다면 보냈을 길이인 Content-Length: 8을 그대로 보내지만, 본문은 0바이트입니다. 수신자는 HEAD 응답에 본문이 없다는 것을 알고 있으므로 8바이트를 기다리지 않습니다. 이처럼 '본문을 보내지 않는다'는 것과 '본문의 길이가 0이다'는 것은 서로 다른 사실입니다. 또 Content-Length에는 시작 줄과 헤더의 바이트를 더하지 않습니다.
3번 풀이: 길이와 해석이 갈리는 요청 찾기
(가), (나), (다), (마)를 거절합니다. (가)는 HTTP/1.1 요청에 반드시 있어야 하는 Host가 없으므로 RFC 9112에 따라 400입니다. (나)는 본문 길이가 5인지 6인지 판단할 수 없는 경우라서 400으로 응답하고 연결을 닫습니다. 같은 값이 반복된 경우는 받아 줄 수 있지만, 서로 다른 값 가운데 하나를 고르는 것은 허용되지 않습니다.
(다)는 명세상 Transfer-Encoding을 따라 처리할 수도 있지만, 이 장의 서버는 거절하기로 했고 어느 쪽이든 응답 뒤에 연결을 닫아야 합니다. (마)는 헤더 이름과 콜론 사이의 공백을 RFC 9112가 금지하므로 400입니다. (라)는 정상입니다. 길이 헤더가 없는 요청은 본문이 0바이트이므로, 헤더 끝의 빈 줄에서 요청이 끝납니다.
다음 글
소켓으로 HTTP 서버 만들기: 동시 처리와 keep-alive에서는 이 글에서 정리한 형식대로 응답을 쓰는 서버를 ServerSocket으로 직접 만들고 curl로 확인합니다. 그리고 느린 연결 하나가 다른 요청을 막지 않게 하는 방법과, 한 연결에서 요청 두 개를 차례로 처리하는 지속 연결을 다룹니다.