HTTP/1.1 메시지 구조
HTTP 요청과 응답을 시작 줄, 헤더, 빈 줄, 본문 프레이밍으로 분해하고 메서드의 안전성·멱등성 및 상태 코드 의미를 서버 구현 전에 고정합니다.
이 문서에서 다루는 HTTP/1.1은 TCP 위에서 메시지 구조를 정의합니다.
요청에는 메서드, 요청 대상, 버전이 있는 시작 줄이 있고, 응답에는 버전, 상태 코드, 이유 문구가 있습니다.
그 뒤에는 이름과 값으로 이루어진 헤더가 이어지고 빈 줄이 헤더 영역의 끝을 알립니다.
본문이 있다면 Content-Length, Transfer-Encoding, 메서드와 상태 코드 규칙을 통해 길이를 판단합니다.
브라우저에서 HTML이 보인다는 결과만으로 HTTP 서버가 올바르다고 말할 수 없습니다.
ASCII 문자만 포함한 짧은 응답은 잘못된 길이 계산이나 줄바꿈도 우연히 통과할 수 있습니다.
같은 연결에서 다음 요청을 이어 보내는 keep-alive에서는 첫 메시지 경계를 한 바이트라도 잘못 읽으면 나머지 스트림 전체가 어긋납니다.
서버를 작성하기 전에 전송 형식과 의미 계약을 서로 다른 층으로 이해해야 합니다.
정상 요청과 응답부터 읽기
HTTP는 클라이언트가 요청을 보내고 서버가 응답을 돌려주는 규칙입니다.
브라우저는 대표적인 HTTP 클라이언트이고, 웹 애플리케이션은 HTTP 서버 안에서 요청을 처리합니다.
다음은 브라우저가 /hello 문서를 요청하는 가장 작은 형태입니다.
GET /hello HTTP/1.1
Host: localhost:8080
첫 줄의 GET은 조회 메서드, /hello는 원하는 자원 경로, HTTP/1.1은 사용할 규칙의 버전입니다.
Host 헤더는 같은 서버 주소에서 어느 사이트를 원하는지 알립니다.
마지막 빈 줄은 헤더가 끝났다는 표시입니다.
서버가 정상 처리하면 다음과 같은 응답을 보냅니다.
HTTP/1.1 200 OK
Content-Type: text/plain; charset=UTF-8
Content-Length: 5
hello200은 요청을 정상 처리했다는 상태 코드입니다.
Content-Type은 본문의 형식과 문자 인코딩을, Content-Length는 본문의 바이트 수를 나타냅니다.
먼저 이 네 영역을 눈으로 구분한 뒤 각 영역을 코드로 만들고 해석해야 합니다.
잘못된 Content-Length
String.length()는 UTF-16 코드 단위 수를 반환합니다.
UTF-8로 전송되는 한글과 이모지는 바이트 수가 다르므로 이 값을 Content-Length에 쓰면 클라이언트가 본문을 덜 읽거나 다음 응답의 일부를 현재 본문으로 오인합니다.
아래 코드는 형식상 응답을 만들지만 전송 형식 길이 계약을 위반합니다.
import java.io.PrintWriter;
public final class CharacterLengthHttpResponse {
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) {
System.out.println("compile-only byte length counterexample");
}
}PrintWriter는 생성 시 지정한 문자 집합과 checkError()로 확인하는 오류 상태를 고려해야 합니다. 쓰기 메서드는 I/O 예외를 호출자에게 그대로 던지지 않습니다.
응답 헤더는 US-ASCII로 만들고 본문은 선언한 문자 집합으로 명시적으로 인코딩한 뒤 OutputStream에 바이트를 쓰는 편이 경계를 확인하기 쉽습니다.
헤더의 Content-Length는 인코딩된 본문 배열의 length에서 계산합니다.
요청 본문도 같은 원칙으로 읽습니다.
Content-Length: 12는 문자 열두 개가 아니라 옥텟 열두 개라는 뜻입니다.
먼저 정확히 지정된 바이트를 읽고, Content-Type의 문자 집합 규칙에 따라 애플리케이션 계층에서 문자열로 변환합니다.
본문이 필요 없는 라우트라도 길이만큼 소비하지 않고 다음 요청을 읽으면 keep-alive 연결이 오염됩니다.
HTTP 메시지 모델
시작 줄을 공백으로 무제한 분할하거나 헤더를 모든 콜론에서 나누면 정상 값이 손상됩니다.
요청 시작 줄은 정확히 세 토큰이어야 하고, 헤더는 첫 콜론만 이름과 값의 경계로 사용합니다.
헤더 이름은 대소문자를 구분하지 않으므로 조회할 때 정규화하되 원본 값은 불필요하게 바꾸지 않습니다.
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");
}
if (!target.startsWith("/")) {
throw new IllegalArgumentException("origin-form target required");
}
if (!version.equals("HTTP/1.1")) {
throw new IllegalArgumentException("unsupported version");
}
}
}
record Request(RequestLine line, Map<String, List<String>> headers,
byte[] body) {
Request {
headers = Map.copyOf(headers);
body = body.clone();
}
String firstHeader(String name) {
return headers.getOrDefault(
name.toLowerCase(Locale.ROOT),
List.of()).stream().findFirst().orElse(null);
}
}
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");
}
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(':');
if (colon <= 0) {
throw new IllegalArgumentException("invalid header: " + raw);
}
String name = raw.substring(0, colon)
.trim().toLowerCase(Locale.ROOT);
String value = raw.substring(colon + 1).trim();
headers.computeIfAbsent(name, ignored -> new ArrayList<>())
.add(value);
}
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("bodyBytes=" + request.body().length);
}
}이 모델은 이미 분리된 헤더 줄과 본문을 받습니다. [A-Z]+ 메서드, 슬래시로 시작하는 대상, HTTP/1.1로 범위를 제한한 학습용 모델이며 HTTP의 모든 요청 형식을 검증하는 파서는 아닙니다.
헤더 이름의 콜론 앞 공백을 trim()으로 허용하고 Host와 필드명 문법도 검사하지 않습니다. 실제 HTTP/1.1 수신 계층은 이런 조건을 별도로 검증해야 합니다.
실제 네트워크 판독기는 줄 수, 한 줄 길이, 전체 헤더 크기, 본문 크기 상한을 적용해야 합니다.
모델 생성자만 엄격해도 전송 계층이 무한 입력을 읽은 뒤라면 자원 고갈을 막을 수 없습니다.
전송 형식 판독기와 도메인 모델의 검증 책임을 둘 다 둡니다.
중복 헤더를 단일 Map 값으로 덮어쓰지 않은 이유도 있습니다.
일부 헤더는 여러 번 나타날 수 있고 쉼표 결합 규칙이 헤더마다 다릅니다.
반면 서로 다른 Content-Length가 반복되면 요청 스머글링 위험이 있으므로 프레이밍 계층이 거절해야 합니다.
범용 헤더 보관과 보안상 특수한 프레이밍 검증을 분리합니다.
이 Request의 바깥 Map 복사는 내부 목록을 복사하지 않으며, 생성 시 복사한 본문 배열도 기본 접근자로 수정할 수 있습니다. 완전한 불변 값 객체로 공유하려면 이 경계도 보완해야 합니다.
HTTP 메서드 속성
GET은 조회에 주로 쓰이고 POST는 서버에 데이터를 처리하도록 요청한다는 설명만으로는 운영 결정을 내리기 어렵습니다.
HTTP 사양은 안전한 메서드와 멱등 메서드를 구분합니다.
안전성은 요청이 의도한 서버 상태 변경이 없는가를 말하고, 멱등성은 같은 요청을 여러 번 적용한 최종 의도가 한 번 적용과 같은가를 뜻합니다.
import java.util.EnumMap;
import java.util.Map;
public final class HttpMethodSemantics {
enum Method {
GET,
HEAD,
POST,
PUT,
DELETE,
PATCH,
OPTIONS
}
record Facts(boolean safe, boolean idempotent,
boolean requestBodyCommon) {
}
private static final Map<Method, Facts> FACTS = facts();
static Facts describe(Method method) {
return FACTS.get(method);
}
static boolean automaticRetryCandidate(Method method) {
return describe(method).idempotent();
}
private static Map<Method, Facts> facts() {
Map<Method, Facts> values = new EnumMap<>(Method.class);
values.put(Method.GET, new Facts(true, true, false));
values.put(Method.HEAD, new Facts(true, true, false));
values.put(Method.POST, new Facts(false, false, true));
values.put(Method.PUT, new Facts(false, true, true));
values.put(Method.DELETE, new Facts(false, true, false));
values.put(Method.PATCH, new Facts(false, false, true));
values.put(Method.OPTIONS, new Facts(true, true, false));
return Map.copyOf(values);
}
public static void main(String[] args) {
for (Method method : Method.values()) {
Facts facts = describe(method);
System.out.printf(
"%s safe=%s idempotent=%s retryCandidate=%s%n",
method,
facts.safe(),
facts.idempotent(),
automaticRetryCandidate(method));
}
}
}멱등하다는 사실만으로 무조건 자동 재시도할 수 있는 것은 아닙니다.
인증 토큰 만료, 본문 재전송 가능성, 전체 기한, 서버가 실제로 사양을 지키는지까지 확인해야 합니다.
POST도 멱등성 키를 도입하면 업무 수준 중복을 제어할 수 있지만 메서드 자체가 멱등해지는 것은 아닙니다.
HEAD 응답은 GET과 같은 헤더 의미를 가지되 실제 응답 본문을 보내지 않습니다.
1xx, 204, 304 응답에도 메시지 본문이 허용되지 않는 규칙이 있습니다.
응답 기록기가 모든 상태에 본문을 붙이면 다음 응답 경계가 어긋날 수 있으므로 상태 코드와 요청 메서드를 함께 봅니다.
본문 프레이밍과 연결
HTTP/1.1 연결은 기본적으로 지속될 수 있습니다.
한 응답 뒤 TCP를 닫지 않는다면 수신자는 현재 본문이 어디서 끝나는지 알아야 다음 응답을 읽을 수 있습니다.
가장 단순한 방법은 정확한 Content-Length입니다.
Transfer-Encoding: chunked는 각 청크 크기와 마지막 0 청크로 경계를 알립니다.
둘을 동시에 받아 임의로 하나를 선택하면 해석 차이를 악용한 요청 스머글링에 노출될 수 있습니다.
다음 표는 메시지 본문이 허용되는 경우를 구분합니다. 두 프레이밍 헤더가 함께 온 요청은 이 교육용 서버에서 거절하는 정책을 선택합니다.
‘본문 금지 응답’은 HEAD와 1xx·204·304 응답을 가리킵니다.
| 상황 | 본문 경계 | 구현 판단 |
|---|---|---|
| 유효한 길이 헤더 하나 | 지정 바이트 수 | 정확히 소비 |
| 최종 코딩이 chunked | 0 청크·트레일러 뒤 빈 줄 | 청크 문법·크기 제한 |
| 길이·전송 코딩 공존 | 해석 차이 위험 | 이 서버는 400 후 종료 |
| 두 헤더 없는 요청 | 본문 0바이트 | 헤더 끝에서 요청 완료 |
| 다른 길이 규칙 없는 응답 | 연결 종료 | 재사용 불가 |
| 본문 금지 응답 | 본문 없음 | 본문 바이트 금지 |
경계가 명확해도 연결 종료 신호와 상태별 연결 정책을 확인해야 재사용할 수 있습니다. 연결 종료로 본문 끝을 알리는 방식은 HTTP/1.1에서도 가능합니다.
성공한 CONNECT의 2xx 응답은 헤더 뒤에서 터널로 전환하는 별도 규칙을 가집니다.
keep-alive는 단순히 Connection: keep-alive 헤더를 붙이는 기능이 아닙니다.
한 연결에서 요청을 반복해서 읽는 루프, 요청별 상태 초기화, 최대 요청 수, 유휴 시간 제한, 명확한 프레이밍이 모두 필요합니다.
교육용 요청당 연결 하나 서버라면 응답에 Connection: close를 명시하고 실제 소켓도 닫아 범위를 정직하게 표현하는 편이 낫습니다.
연습 문제
상태 코드 200과 텍스트 본문을 받아 CRLF 헤더와 UTF-8 본문을 결합한 byte[]를 반환합니다.
Content-Length는 본문 바이트 수여야 하고, 헤더 끝에는 정확히 빈 줄 하나가 있어야 합니다.
반환 배열을 다시 나누어 선언 길이와 실제 본문 길이가 같은지도 검사합니다.
바이트 배열을 기준으로 검증하는 풀이
헤더는 US-ASCII, 본문은 UTF-8로 각각 인코딩합니다.
ByteArrayOutputStream에 순서대로 쓰면 문자 기록기의 자동 줄바꿈과 플랫폼 기본 문자 집합을 피할 수 있습니다.
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");
}
}String.lines()는 CRLF를 줄 경계로 처리하므로 검증 코드에서 길이 헤더를 찾을 수 있습니다.
바이너리 본문을 지원하려면 okText(String)의 입력을 이미 인코딩된 byte[]로 받는 형태로 바꾸고 그 배열의 길이를 사용합니다.
추가 과제로 HEAD 요청일 때 계산된 Content-Length 헤더는 유지하되 본문 배열을 붙이지 않는 분기를 구현합니다.
기초 검증
한글과 이모지가 포함된 본문에서 선언 길이와 실제 바이트가 일치해야 합니다.
헤더 이름 대소문자를 바꾸어도 조회 결과가 같고, 값 안의 콜론은 보존되어야 합니다.
반복된 상충 Content-Length, 길이와 실제 본문 부족, Transfer-Encoding 동시 존재는 정상 요청으로 받아들이지 않습니다.
메서드 의미 테스트는 GET·HEAD의 안전성과 PUT·DELETE의 멱등성을 구분하고 POST 자동 재시도가 기본적으로 위험하다는 점을 드러내야 합니다.
다음 문서에서는 일회성 서버, 유한 실행기, 두 요청을 처리하는 지속 연결 예제를 나누어 연결 수명을 살펴봅니다.