HTTP/1.1 메시지 구조
HTTP 요청과 응답을 시작 줄, 헤더, 빈 줄, 본문 프레이밍으로 분해하고 메서드의 안전성·멱등성 및 상태 코드 의미를 서버 구현 전에 고정합니다.
HTTP는 TCP 위에서 동작하지만 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는 문자 인코딩과 오류 처리 특성을 숨깁니다.
응답 헤더는 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);
}
}이 모델은 이미 분리된 헤더 줄과 본문을 받습니다. 실제 네트워크 판독기는 줄 수, 한 줄 길이, 전체 헤더 크기, 본문 크기 상한을 적용해야 합니다. 모델 생성자만 엄격해도 전송 계층이 무한 입력을 읽은 뒤라면 자원 고갈을 막을 수 없습니다. 전송 형식 판독기와 도메인 모델의 검증 책임을 둘 다 둡니다.
중복 헤더를 단일 Map 값으로 덮어쓰지 않은 이유도 있습니다.
일부 헤더는 여러 번 나타날 수 있고 쉼표 결합 규칙이 헤더마다 다릅니다.
반면 서로 다른 Content-Length가 반복되면 요청 스머글링 위험이 있으므로 프레이밍 계층이 거절해야 합니다.
범용 헤더 보관과 보안상 특수한 프레이밍 검증을 분리합니다.
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 청크로 경계를 알립니다.
둘을 동시에 받아 임의로 하나를 선택하면 해석 차이를 악용한 요청 스머글링에 노출될 수 있습니다.
| 상황 | 본문 경계 | 연결 재사용 | 구현 판단 |
|---|---|---|---|
Content-Length 하나 | 지정 바이트 수 | 가능 | 정확히 그만큼 소비 |
Transfer-Encoding: chunked | 0 청크까지 | 가능 | 청크 문법과 트레일러 제한 |
| 둘 다 존재 | 모호함 | 금지 | 400 후 연결 종료 |
| 길이 정보 없는 응답 | 연결 close | 불가 | HTTP/1.0식 경계 |
HEAD·204·304 | 본문 없음 | 가능 | 본문 바이트를 쓰지 않음 |
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를 줄 경계로 처리하므로 검증 코드에서 길이 헤더를 찾을 수 있습니다.
서버 본문이 바이너리라면 문자열 변환 없이 같은 기록기를 사용할 수 있습니다.
추가 과제로 HEAD 요청일 때 계산된 Content-Length 헤더는 유지하되 본문 배열을 붙이지 않는 분기를 구현합니다.
기초 검증
한글과 이모지가 포함된 본문에서 선언 길이와 실제 바이트가 일치해야 합니다.
헤더 이름 대소문자를 바꾸어도 조회 결과가 같고, 값 안의 콜론은 보존되어야 합니다.
반복된 상충 Content-Length, 길이와 실제 본문 부족, Transfer-Encoding 동시 존재는 정상 요청으로 받아들이지 않습니다.
메서드 의미 테스트는 GET·HEAD의 안전성과 PUT·DELETE의 멱등성을 구분하고 POST 자동 재시도가 기본적으로 위험하다는 점을 드러내야 합니다.
다음 문서에서는 이 메시지 계약을 짧게 적용하는 일회성 서버를 만들고, 순차 accept 처리와 무제한 작업 생성 없이 동시 요청과 keep-alive 수명을 다룹니다.