HTTP 요청 프레이밍
바이트 요청 판독기와 독립 청크 디코더의 크기·경계 검사, 남은 문법 검증과 상태별 응답 본문 규칙을 구분합니다.
HTTP 요청 객체가 편리하려면 그 앞의 전송 형식 판독기가 메시지 경계를 정확히 보장해야 합니다.
시작 줄과 헤더는 줄처럼 보이지만 본문은 임의 바이트일 수 있습니다.
문자 BufferedReader로 헤더를 읽은 뒤 원래 InputStream에서 본문을 읽으면 판독기가 미리 가져간 바이트 때문에 본문이 사라질 수 있습니다.
하나의 바이트 수준 입력기가 줄과 본문을 모두 소비해야 합니다.
운영용 판독기에는 문법 검증과 함께 줄·헤더 총량·개수·본문 크기 및 읽기 시간 제한이 필요합니다. 아래 예제에 적용된 상한과 아직 없는 조건을 구분해 읽습니다.
Content-Length와 Transfer-Encoding처럼 프레이밍을 결정하는 헤더는 일반 Map 저장 전에 특별 검증해야 합니다.
서로 다른 계층이 다른 경계를 선택하면 요청 스머글링으로 이어집니다.
문자·바이트 혼합 오류
다음 코드는 빈 줄까지 BufferedReader로 읽고 같은 소켓의 원래 스트림에서 본문을 읽습니다.
판독기는 성능을 위해 헤더 너머까지 미리 채울 수 있으므로 본문 일부가 내부 문자 버퍼에 남습니다.
헤더 문자열 검색으로 Content-Length를 찾는 방식 역시 대소문자, 중복 값, 공백 규칙을 따로 처리해야 합니다. 아래 반례는 길이를 인자로 받고 문자·바이트 판독기를 섞는 문제에 집중합니다.
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
public final class MixedHttpBodyReader {
static byte[] read(InputStream input, int contentLength) throws Exception {
var reader = new BufferedReader(new InputStreamReader(
input,
StandardCharsets.US_ASCII));
while (!reader.readLine().isEmpty()) {
// 헤더를 문자 버퍼에 미리 읽을 수 있다.
}
return input.readNBytes(contentLength);
}
public static void main(String[] args) {
System.out.println("compile-only mixed buffering counterexample");
}
}본문을 읽기 전에 프레이밍 헤더를 정규화합니다.
Transfer-Encoding이 있으면 현재 구현이 지원하는 전송 코딩 목록인지 검사하고, Content-Length와 동시에 나타나면 거절합니다.
Content-Length가 반복되었을 때 모든 값이 동일한 경우를 허용하는 구현도 있지만, 작은 교육용 서버는 반복 자체를 거절하는 보수적 정책을 선택할 수 있습니다.
어느 쪽이든 첫 값이나 마지막 값을 임의로 채택해서는 안 됩니다.
헤더 끝을 EOF로 대신 인정하지 않습니다.
빈 줄 전에 연결이 끝나면 잘린 요청입니다.
readNBytes(length) 결과가 선언 길이보다 짧아도 정상 본문으로 넘기지 않습니다.
다음 요청 시작 줄이 본문으로 흡수되는 반대 경우를 막으려면 선언 길이보다 더 읽지 않는 것도 똑같이 중요합니다.
바이트 요청 판독기
아래 구현은 입력 바이트에서 CRLF 줄을 직접 읽습니다.
단독 LF와 CR 뒤에 LF가 없는 입력을 거절합니다. 줄·헤더·본문의 상한과 남은 문법 검증은 코드 뒤 표에서 구분합니다.
헤더는 첫 콜론에서 나누며 이름을 소문자로 정규화합니다.
본문은 현재 예제에서 Content-Length 또는 없음만 지원합니다.
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.EOFException;
import java.io.InputStream;
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 BoundedHttpRequestReader {
private static final int MAX_LINE = 4_096;
private static final int MAX_HEAD = 16_384;
private static final int MAX_BODY = 1_048_576;
record Request(String method, String target, String version,
Map<String, List<String>> headers, byte[] body) {
Request {
headers = Map.copyOf(headers);
body = body.clone();
}
}
static Request read(InputStream input) throws Exception {
String start = readLine(input);
if (start == null || start.isEmpty()) {
throw new EOFException("request line missing");
}
String[] parts = start.split(" ", -1);
if (parts.length != 3) {
throw new IllegalArgumentException("invalid request line");
}
int headBytes = start.length() + 2;
Map<String, List<String>> headers = new LinkedHashMap<>();
while (true) {
String line = readLine(input);
if (line == null) {
throw new EOFException("headers truncated");
}
headBytes += line.length() + 2;
if (headBytes > MAX_HEAD) {
throw new IllegalArgumentException("headers too large");
}
if (line.isEmpty()) {
break;
}
int colon = line.indexOf(':');
if (colon <= 0) {
throw new IllegalArgumentException("invalid header");
}
String name = line.substring(0, colon)
.trim().toLowerCase(Locale.ROOT);
String value = line.substring(colon + 1).trim();
headers.computeIfAbsent(name, ignored -> new ArrayList<>())
.add(value);
}
if (headers.containsKey("transfer-encoding")) {
throw new IllegalArgumentException(
"transfer coding requires chunk decoder");
}
int length = contentLength(headers.get("content-length"));
byte[] body = input.readNBytes(length);
if (body.length != length) {
throw new EOFException("body truncated");
}
Map<String, List<String>> frozen = new LinkedHashMap<>();
headers.forEach((name, values) ->
frozen.put(name, List.copyOf(values)));
return new Request(parts[0], parts[1], parts[2], frozen, body);
}
private static int contentLength(List<String> values) {
if (values == null) {
return 0;
}
if (values.size() != 1) {
throw new IllegalArgumentException("repeated content-length");
}
long parsed;
try {
parsed = Long.parseLong(values.getFirst());
} catch (NumberFormatException error) {
throw new IllegalArgumentException("invalid content-length", error);
}
if (parsed < 0 || parsed > MAX_BODY) {
throw new IllegalArgumentException("body length out of range");
}
return Math.toIntExact(parsed);
}
static String readLine(InputStream input) throws Exception {
var bytes = new ByteArrayOutputStream();
while (bytes.size() <= MAX_LINE) {
int value = input.read();
if (value == -1) {
return bytes.size() == 0
? null
: throwTruncatedLine();
}
if (value == '\r') {
if (input.read() != '\n') {
throw new IllegalArgumentException("CR without LF");
}
return bytes.toString(StandardCharsets.US_ASCII);
}
if (value == '\n') {
throw new IllegalArgumentException("bare LF");
}
bytes.write(value);
}
throw new IllegalArgumentException("line too long");
}
private static String throwTruncatedLine() throws EOFException {
throw new EOFException("line truncated");
}
public static void main(String[] args) throws Exception {
byte[] body = "한글".getBytes(StandardCharsets.UTF_8);
String head = "POST /notes HTTP/1.1\r\n"
+ "Host: local\r\n"
+ "Content-Type: text/plain; charset=UTF-8\r\n"
+ "Content-Length: " + body.length + "\r\n"
+ "\r\n";
var wire = new ByteArrayOutputStream();
wire.writeBytes(head.getBytes(StandardCharsets.US_ASCII));
wire.writeBytes(body);
Request request = read(new ByteArrayInputStream(wire.toByteArray()));
System.out.println(request.method() + " " + request.target());
System.out.println(new String(request.body(), StandardCharsets.UTF_8));
System.out.println(request.headers().keySet());
}
}BoundedHttpRequestReader가 적용하는 검사와 별도로 필요한 검증을 구분합니다.
| 검사 지점 | 적용된 조건 | 별도로 남는 검증 |
|---|---|---|
| 시작 줄·헤더 | 줄 4096바이트 · 전체 16384바이트 | 전체에는 시작 줄·CRLF 포함 · 헤더 개수와 읽기 기한은 별도 설정 없음 |
| 요청 문법 | 시작 줄의 세 조각 · 첫 콜론 분리 | 메서드·대상·버전·Host와 필드명 문법 미검증 |
| 프레이밍 헤더 | 전송 코딩 거절 · 반복 길이 거절 | 콜론 앞 공백을 trim해 수용하며 RFC의 400 거절 조건과 다름 |
| 본문 | 길이 0..1048576 · 선언보다 짧으면 거절 | +1·-0까지 변환하므로 ASCII 숫자만 허용하는 문법 검사가 더 필요 |
- 시작 줄·헤더
- 적용된 조건: 줄 4096바이트 · 전체 16384바이트별도로 남는 검증: 전체에는 시작 줄·CRLF 포함 · 헤더 개수와 읽기 기한은 별도 설정 없음
- 요청 문법
- 적용된 조건: 시작 줄의 세 조각 · 첫 콜론 분리별도로 남는 검증: 메서드·대상·버전·Host와 필드명 문법 미검증
- 프레이밍 헤더
- 적용된 조건: 전송 코딩 거절 · 반복 길이 거절별도로 남는 검증: 콜론 앞 공백을 trim해 수용하며 RFC의 400 거절 조건과 다름
- 본문
- 적용된 조건: 길이 0..1048576 · 선언보다 짧으면 거절별도로 남는 검증: +1·-0까지 변환하므로 ASCII 숫자만 허용하는 문법 검사가 더 필요
길이 헤더가 없으면 본문을 0바이트로 봅니다. main은 정상 POST 한 개이며 오류 경로나 다음 요청을 연속 판독한 실행은 없습니다.
조건 표현식 안에서 예외를 던지기 위해 throwTruncatedLine 보조 메서드를 사용했지만 가독성을 우선한다면 EOF 분기를 일반 if 블록으로 펼치는 편이 낫습니다.
핵심은 EOF 직전 일부 바이트를 정상 줄로 반환하지 않는 것입니다.
불완전한 요청을 처리하면 공격자가 불완전 헤더를 정상 요청처럼 만들 수 있습니다.
이 예제는 예외만 던지며 HTTP 오류 응답이나 연결 종료를 직접 수행하지 않습니다. 연결 처리기가 실패 종류에 맞는 응답과 종료 정책을 맡아야 합니다.
청크 프레이밍 검증
chunked 본문은 크기 줄·데이터·CRLF가 반복된 뒤 0 청크, 선택적 트레일러 필드, 마지막 빈 줄로 끝납니다.
아래 학습용 구현은 청크 확장과 비어 있지 않은 트레일러를 모두 거절하는 제한 정책을 씁니다. RFC 9112는 알 수 없는 청크 확장을 무시하도록 요구하므로 이 구현을 일반적인 HTTP/1.1 준수 디코더로 보아서는 안 됩니다.
모든 청크 합계에는 전체 본문 상한을 적용합니다.
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.EOFException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
public final class ChunkedBodyDecoder {
private static final int MAX_LINE = 1_024;
private static final int MAX_BODY = 1_048_576;
static byte[] read(InputStream input) throws Exception {
var body = new ByteArrayOutputStream();
while (true) {
String sizeLine = readLine(input);
if (sizeLine == null || sizeLine.isEmpty()) {
throw new EOFException("chunk size missing");
}
if (sizeLine.indexOf(';') >= 0) {
throw new IllegalArgumentException(
"chunk extensions are not supported");
}
int size;
try {
size = Integer.parseInt(sizeLine, 16);
} catch (NumberFormatException error) {
throw new IllegalArgumentException("invalid chunk size", error);
}
if (size < 0 || body.size() + (long) size > MAX_BODY) {
throw new IllegalArgumentException("chunked body too large");
}
if (size == 0) {
String trailerEnd = readLine(input);
if (!"".equals(trailerEnd)) {
throw new IllegalArgumentException(
"trailers are not supported");
}
return body.toByteArray();
}
byte[] chunk = input.readNBytes(size);
if (chunk.length != size) {
throw new EOFException("chunk data truncated");
}
body.writeBytes(chunk);
expectCrlf(input);
}
}
private static void expectCrlf(InputStream input) throws Exception {
if (input.read() != '\r' || input.read() != '\n') {
throw new IllegalArgumentException("chunk CRLF missing");
}
}
private static String readLine(InputStream input) throws Exception {
var bytes = new ByteArrayOutputStream();
while (bytes.size() <= MAX_LINE) {
int value = input.read();
if (value == -1) {
return bytes.size() == 0 ? null : failLine();
}
if (value == '\r') {
if (input.read() != '\n') {
throw new IllegalArgumentException("invalid line end");
}
return bytes.toString(StandardCharsets.US_ASCII);
}
if (value == '\n') {
throw new IllegalArgumentException("bare LF");
}
bytes.write(value);
}
throw new IllegalArgumentException("chunk line too long");
}
private static String failLine() throws EOFException {
throw new EOFException("chunk line truncated");
}
public static void main(String[] args) throws Exception {
String wire = "4\r\nWiki\r\n"
+ "5\r\npedia\r\n"
+ "7\r\n 한글\r\n"
+ "0\r\n\r\n";
byte[] body = read(new ByteArrayInputStream(
wire.getBytes(StandardCharsets.UTF_8)));
System.out.println(new String(body, StandardCharsets.UTF_8));
System.out.println("bytes=" + body.length);
}
}ChunkedBodyDecoder.main의 고정 wire를 바이트 구간으로 풀이합니다.
| 크기 줄 뒤의 구간 | 반환 본문에 합치는 데이터 | 뒤에서 소비하는 프레이밍 |
|---|---|---|
| 크기 4 | Wiki · 4바이트 | 데이터 끝 CRLF |
| 크기 5 | pedia · 5바이트 | 데이터 끝 CRLF |
| 크기 7 | 공백 1 + 한글 6 = 7바이트 | 데이터 끝 CRLF |
| 크기 0 | 추가 데이터 없음 · 합계 16바이트 | 빈 트레일러 줄의 CRLF |
- 크기 4
- 반환 본문에 합치는 데이터: Wiki · 4바이트뒤에서 소비하는 프레이밍: 데이터 끝 CRLF
- 크기 5
- 반환 본문에 합치는 데이터: pedia · 5바이트뒤에서 소비하는 프레이밍: 데이터 끝 CRLF
- 크기 7
- 반환 본문에 합치는 데이터: 공백 1 + 한글 6 = 7바이트뒤에서 소비하는 프레이밍: 데이터 끝 CRLF
- 크기 0
- 반환 본문에 합치는 데이터: 추가 데이터 없음 · 합계 16바이트뒤에서 소비하는 프레이밍: 빈 트레일러 줄의 CRLF
상수 입력을 해석한 값이며 실행 추적이 아닙니다. 이 decoder는 별도 클래스이고 앞 요청 판독기에 연결되어 있지 않습니다.
청크 크기는 ASCII 16진 숫자만 허용해야 합니다. Integer.parseInt는 부호도 받으므로 +1과 -0 같은 입력을 별도로 막아야 합니다.
각 청크를 선언된 길이만큼 읽고 구분 CRLF를 확인하지만, 선언 길이 자체가 뒤 요청까지 포함한 경우를 독립적으로 알아내는 것은 아닙니다.
응답 직렬화 불변식
서비스 코드는 상태, 헤더, 본문을 값으로 구성하고, 응답 기록기가 한 번만 직렬화합니다.
여러 서블릿이 직접 시작 줄과 Content-Length를 쓰게 하면 규칙이 중복되고 상태 코드에 맞지 않는 본문이 붙습니다.
기록기는 홉 단위 헤더와 프레이밍 헤더를 애플리케이션이 임의로 충돌시키지 못하게 통제합니다.
응답 본문이 미리 메모리에 있으면 Content-Length가 가장 단순합니다.
큰 스트리밍 응답은 chunked를 사용할 수 있지만 각 청크 플러시가 지나치게 작으면 네트워크 효율이 떨어집니다.
이미 압축 또는 파일 길이를 아는 경우 고정 길이가 낫습니다.
HTTP/2와 HTTP/3는 다른 프레이밍을 사용하므로 애플리케이션 응답 모델과 HTTP/1.1 전송 형식 기록기를 분리해 두면 교체가 쉽습니다.
연습 문제
응답 상태, Content-Type, 본문 바이트와 요청 메서드를 받아 HTTP/1.1 바이트 배열을 만듭니다.
HEAD 요청과 204·304 상태에서는 본문을 쓰지 않습니다.
HEAD의 Content-Length는 같은 GET이었다면 보낼 표현 길이를 나타내고, 204에는 Content-Length 자체를 넣지 않습니다.
상태와 요청 메서드를 함께 보는 풀이
기록기가 호출될 때 이미 본문 바이트가 완성되어 있다고 가정합니다.
상태 이유 문구는 허용 표로 제한하고, 헤더 문자열은 US-ASCII로 인코딩합니다.
연결 정책은 예제를 명확히 하기 위해 close로 고정합니다.
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.Map;
public final class HttpResponseFramingSolution {
private static final Map<Integer, String> REASONS = Map.of(
200, "OK",
204, "No Content",
304, "Not Modified",
400, "Bad Request",
404, "Not Found",
500, "Internal Server Error");
static byte[] write(int status, String requestMethod,
String contentType, byte[] representation) {
String reason = REASONS.get(status);
if (reason == null) {
throw new IllegalArgumentException("unsupported status");
}
boolean statusForbidsBody = status == 204 || status == 304;
boolean headRequest = requestMethod.equals("HEAD");
boolean sendBody = !statusForbidsBody && !headRequest;
StringBuilder head = new StringBuilder();
head.append("HTTP/1.1 ").append(status).append(' ')
.append(reason).append("\r\n");
if (!statusForbidsBody) {
head.append("Content-Type: ").append(contentType).append("\r\n");
head.append("Content-Length: ")
.append(representation.length).append("\r\n");
}
head.append("Connection: close\r\n");
head.append("\r\n");
var output = new ByteArrayOutputStream();
output.writeBytes(head.toString()
.getBytes(StandardCharsets.US_ASCII));
if (sendBody) {
output.writeBytes(representation);
}
return output.toByteArray();
}
public static void main(String[] args) {
byte[] body = "한글".getBytes(StandardCharsets.UTF_8);
byte[] get = write(200, "GET", "text/plain; charset=UTF-8", body);
byte[] head = write(200, "HEAD", "text/plain; charset=UTF-8", body);
byte[] noContent = write(204, "GET", "text/plain", body);
System.out.println("GET bytes=" + get.length);
System.out.println(new String(head, StandardCharsets.US_ASCII));
System.out.println(new String(noContent, StandardCharsets.US_ASCII));
}
}HttpResponseFramingSolution의 분기를 표현합니다.
| 요청·상태 | 원문이 쓰는 헤더 | 원문이 붙이는 본문 |
|---|---|---|
| GET · 200 | Content-Type과 표현 배열의 길이 | 표현 배열 |
| HEAD · 200 | 같은 표현 배열의 길이 유지 | 없음 |
| GET 또는 HEAD · 204 | Content-Type·Content-Length 생략 | 없음 |
| GET 또는 HEAD · 304 | 이 예제는 두 헤더 모두 생략 | 없음 |
- GET · 200
- 원문이 쓰는 헤더: Content-Type과 표현 배열의 길이원문이 붙이는 본문: 표현 배열
- HEAD · 200
- 원문이 쓰는 헤더: 같은 표현 배열의 길이 유지원문이 붙이는 본문: 없음
- GET 또는 HEAD · 204
- 원문이 쓰는 헤더: Content-Type·Content-Length 생략원문이 붙이는 본문: 없음
- GET 또는 HEAD · 304
- 원문이 쓰는 헤더: 이 예제는 두 헤더 모두 생략원문이 붙이는 본문: 없음
HEAD의 길이는 같은 GET에서 보낼 표현 길이여야 합니다. 304의 조건부 길이 표기는 사양상 가능하지만 이 예제는 생략합니다. main은 200 GET·200 HEAD·204 GET만 구성합니다.
contentType은 검증 없이 헤더에 붙습니다. 외부 값이라면 CR·LF 같은 제어 문자를 거절해야 하며, US-ASCII 인코딩만으로 응답 분할을 막을 수 없습니다.
확장 과제로 1xx 상태와 CONNECT 성공 응답의 특수 규칙을 추가하고, 애플리케이션이 Transfer-Encoding과 Content-Length를 동시에 주입하지 못하게 헤더 API를 제한합니다.
프레이밍 검증
고정 길이 요청은 0, 정확한 길이, 본문 부족, 음수, 숫자 아님, 상한 초과를 검증합니다.
반복 Content-Length와 Transfer-Encoding 동시 존재는 연결 재사용 없이 실패해야 합니다.
chunked 테스트에는 여러 청크, 대문자 16진수, 0 청크, 빠진 데이터 CRLF, 너무 큰 합계, 지원하지 않는 확장과 트레일러를 넣습니다.
고정 길이 요청 두 개를 이어 붙여 판독기를 두 번 호출하는 검사를 추가합니다. 청크 요청은 먼저 독립 디코더를 요청 판독기에 연결해야 같은 검사를 적용할 수 있습니다.
응답은 UTF-8 바이트 길이와 상태별 본문 규칙을 확인합니다.
다음 문서에서는 구조화된 요청과 응답을 전제로 경로별 서블릿 등록과 404·500 오류 경계를 살펴봅니다. 이 장에 남은 문법 검증과 연결 수명 처리는 별도 통합 과제입니다.