본문 바로가기

HTTP 요청 프레이밍

바이트 요청 판독기의 연속 요청 경계·크기 검사와 독립 청크 디코더, 남은 문법 검증 및 상태별 응답 본문 규칙을 구분합니다.

HTTP 요청 객체가 편리하려면 그 앞의 전송 형식 판독기가 메시지 경계를 정확히 보장해야 합니다.

시작 줄과 헤더는 줄처럼 보이지만 본문은 임의 바이트일 수 있습니다.

문자 BufferedReader로 헤더를 읽은 뒤 원래 InputStream에서 본문을 읽으면 판독기가 미리 가져간 바이트 때문에 본문이 사라질 수 있습니다.

하나의 바이트 수준 입력기가 줄과 본문을 모두 소비해야 합니다.

운영용 판독기에는 문법 검증과 함께 줄·헤더 총량·개수·본문 크기 및 읽기 시간 제한이 필요합니다. 아래 예제에 적용된 상한과 아직 없는 조건을 구분해 읽습니다.

Content-Length와 Transfer-Encoding처럼 프레이밍을 결정하는 헤더는 일반 Map 저장 전에 특별 검증해야 합니다.

서로 다른 계층이 다른 경계를 선택하면 요청 스머글링으로 이어집니다.


문자·바이트 혼합 오류

다음 코드는 빈 줄까지 BufferedReader로 읽고 같은 소켓의 원래 스트림에서 본문을 읽습니다.

판독기는 성능을 위해 헤더 너머까지 미리 채울 수 있으므로 본문 일부가 내부 문자 버퍼에 남습니다.

헤더 문자열 검색으로 Content-Length를 찾는 방식 역시 대소문자, 중복 값, 공백 규칙을 따로 처리해야 합니다. 아래 반례는 길이를 인자로 받고 문자·바이트 판독기를 섞는 문제에 집중합니다.

bad/MixedHttpBodyReader.java
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 또는 없음만 지원합니다.

src/BoundedHttpRequestReader.java
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";
        String nextHead = "GET /next HTTP/1.1\r\n"
                + "Host: local\r\n"
                + "\r\n";
        var wire = new ByteArrayOutputStream();
        wire.writeBytes(head.getBytes(StandardCharsets.US_ASCII));
        wire.writeBytes(body);
        wire.writeBytes(nextHead.getBytes(StandardCharsets.US_ASCII));

        byte[] requestBytes = wire.toByteArray();
        var input = new ByteArrayInputStream(requestBytes);
        Request first = read(input);
        System.out.println(first.method() + " " + first.target());
        System.out.println(new String(first.body(), StandardCharsets.UTF_8));
        System.out.println("첫 요청 뒤 위치="
                + (requestBytes.length - input.available()));

        Request next = read(input);
        System.out.println(next.method() + " " + next.target());
        System.out.println("다음 요청 본문 bytes=" + next.body().length);
        System.out.println("두 번째 요청 뒤 위치="
                + (requestBytes.length - input.available()));
        System.out.println("남은 bytes=" + input.available());
    }
}

같은 입력에서 다음 요청 읽기

main은 한글 본문이 있는 POST 뒤에 GET을 붙여 하나의 바이트 배열로 만듭니다. ByteArrayInputStream도 하나만 만들고, 같은 input을 read에 두 번 전달합니다. 첫 호출이 남겨 둔 위치에서 다음 호출이 이어집니다.

한글의 String.length()는 UTF-16 코드 단위 2개를 세지만, UTF-8 본문은 ED 95 9C EA B8 80의 6바이트입니다. Content-Length에는 문자 수 대신 이 바이트 수를 적습니다. 본문을 문자열로 바꾸는 일은 지정된 바이트를 모은 뒤에 합니다.

이 POST의 US-ASCII 헤더는 CRLF와 헤더 끝 빈 줄을 포함해 97바이트입니다. 위치 97부터 본문 6바이트를 읽으면 첫 요청이 위치 103에서 끝납니다. 본문 뒤에 CRLF를 더 넣지 않았으므로 바로 그 위치의 G가 다음 GET의 시작입니다.

한글 6바이트 뒤에서 다음 요청 읽기

138바이트의 같은 입력 스트림에서 POST 헤더 97바이트와 본문 6바이트를 읽어 커서 103에 도달한 뒤 다음 GET 35바이트를 읽습니다. 본문 경계를 넘지 않는 두 요청의 전환이 명확합니다.

같은 메모리 입력의 POST 헤더 0–97, 한글 본문 97–103, 다음 GET 103–138 바이트 구간과 두 번의 요청 판독 뒤 예상 위치

같은 입력 · 바이트 구간 · 다음 읽기 위치

한글 6바이트가 끝난 위치에서 다음 GET을 읽는다

POST와 GET을 합친 138바이트 배열을 하나의 ByteArrayInputStream으로 읽습니다. 첫 read(input)은 위치 103까지, 다음 호출은 위치 138까지 소비할 것으로 예상합니다.

동일한 138바이트 입력 안의 두 요청 경계

동일한 138바이트 입력 안의 두 요청 경계POST 헤더 0–97과 한글 본문 97–103이 첫 read의 범위다. 다음 GET은 103의 G에서 시작해 두 번째 read 뒤 138이 된다. ByteArrayInputStream의 계산이며 소켓 available 일반화가 아니다. 상자의 높이는 바이트 수에 비례하지 않는 구간도다.같은 138 B 배열POST 헤더[0,97) · 97 B한글 본문[97,103) · 6 B한 3 B + 글 3 B다음 GET[103,138) · 35 B097103138첫 read 끝 103 · 남은 35 B둘째 read 끝 138 · 남은 0 B
동일한 138바이트 입력 안의 두 요청 경계POST 헤더 0–97과 한글 본문 97–103이 첫 read의 범위다. 다음 GET은 103의 G에서 시작해 두 번째 read 뒤 138이 된다. ByteArrayInputStream의 계산이며 소켓 available 일반화가 아니다. 상자의 높이는 바이트 수에 비례하지 않는 구간도다.같은 138 B 배열POST 헤더[0,97) · 97 B한글 본문[97,103) · 6 B한 3 B + 글 3 B다음 GET[103,138) · 35 B097103138첫 read 끝 103 · 남은 35 B둘째 read 끝 138 · 남은 0 B

POST 헤더 0–97과 한글 본문 97–103이 첫 read의 범위다. 다음 GET은 103의 G에서 시작해 두 번째 read 뒤 138이 된다. ByteArrayInputStream의 계산이며 소켓 available 일반화가 아니다. 상자의 높이는 바이트 수에 비례하지 않는 구간도다.

상태별 상세 해설

바이트 번호는 0부터, 구간의 끝은 포함하지 않는다

POST 헤더의 마지막은 Content-Length: 6\r\n\r\n입니다. 헤더 끝 빈 줄은 [95, 97)에 있으며, 본문 뒤에는 CRLF를 더 넣지 않습니다.

본문 main이 만드는 같은 입력의 세 구간
입력 부분바이트 구간길이읽을 내용
첫 POST 헤더[0, 97)97바이트시작 줄·헤더·각 CRLF와 헤더 끝 빈 줄을 읽습니다.
한글 본문[97, 103)6바이트Content-Length: 6에 따라 한 [97, 100), 글 [100, 103)을 읽습니다.
다음 GET 헤더[103, 138)35바이트GET /next HTTP/1.1의 G부터 읽습니다. 길이·전송 코딩 헤더가 없어 본문은 0바이트입니다.
  1. POST 헤더 · [0, 97)

    97바이트입니다. 시작 줄·헤더·각 CRLF와 헤더 끝 빈 줄을 읽습니다.

  2. 한글 본문 · [97, 103)

    Content-Length: 6에 따라 6바이트를 읽습니다. 한 [97, 100)과 글 [100, 103)은 각각 3바이트입니다.

  3. 다음 GET · [103, 138)

    35바이트입니다. GET /next HTTP/1.1의 G부터 읽습니다. 길이·전송 코딩 헤더가 없어 본문은 0바이트입니다.

한: ED 95 9C  // [97, 100)
글: EA B8 80  // [100, 103)
다음 G: 47   // 위치 103

다음 호출도 같은 input을 사용한다

  1. 첫 read 뒤 예상 위치 103

    POST /notes와 한글 본문을 반환합니다. 다음 GET의 35바이트가 남고, 다음 읽을 바이트는 G입니다.

  2. 두 번째 read 뒤 예상 위치 138

    GET /next와 길이 0인 본문을 반환합니다. 배열에 남은 바이트는 0입니다.

단위의 범위: 138 - input.available()은 이 전체 메모리 배열의 소비 위치를 구합니다. 소켓의 available()을 전체 요청 길이나 EOF 판정으로 사용하지 않습니다.

이 예의 조건

  • ByteArrayInputStream의 138바이트를 계산하며 Socket.available 계약으로 일반화하지 않는다.
관계도의 보충 설명
원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보한글 6바이트 뒤에서 다음 요청 읽기 같은 메모리 입력의 POST 헤더 0–97, 한글 본문 97–103, 다음 GET 103–138 바이트 구간과 두 번의 요청 판독 뒤 예상 위치 같은 입력 · 바이트 구간 · 다음 읽기 위치 한글 6바이트가 끝난 위치에서 다음 GET을 읽는다 POST와 GET을 합친 138바이트 배열을 하나의 ByteArrayInputStream 으로 읽습니다. 첫 read(input) 은 위치 103까지, 다음 호출은 위치 138까지 소비할 것으로 예상합니다. 바이트 번호는 0부터, 구간의 끝은 포함하지 않는다 POST 헤더의 마지막은 Content-Length: 6\r\n\r\n 입니다. 헤더 끝 빈 줄은 [95, 97) 에 있으며, 본문 뒤에는 CRLF를 더 넣지 않습니다. 본문 main이 만드는 같은 입력의 세 구간 입력 부분 바이트 구간 길이 읽을 내용 첫 POST 헤더 [0, 97) 97바이트 시작 줄·헤더·각 CRLF와 헤더 끝 빈 줄을 읽습니다. 한글 본문 [97, 103) 6바이트 Content-Length: 6 에 따라 한 [97, 100) , 글 [100, 103) 을 읽습니다. 다음 GET 헤더 [103, 138) 35바이트 GET /next HTTP/1.1 의 G부터 읽습니다. 길이·전송 코딩 헤더가 없어 본문은 0바이트입니다. POST 헤더 · [0, 97) 97바이트입니다. 시작 줄·헤더·각 CRLF와 헤더 끝 빈 줄을 읽습니다. 한글 본문 · [97, 103) Content-Length: 6 에 따라 6바이트를 읽습니다. 한 [97, 100) 과 글 [100, 103) 은 각각 3바이트입니다. 다음 GET · [103, 138) 35바이트입니다. GET /next HTTP/1.1 의 G부터 읽습니다. 길이·전송 코딩 헤더가 없어 본문은 0바이트입니다. 한: ED 95 9C // [97, 100) 글: EA B8 80 // [100, 103) 다음 G: 47 // 위치 103 다음 호출도 같은 input을 사용한다 첫 read 뒤 예상 위치 103 POST /notes 와 한글 본문을 반환합니다. 다음 GET의 35바이트가 남고, 다음 읽을 바이트는 G입니다. 두 번째 read 뒤 예상 위치 138 GET /next 와 길이 0인 본문을 반환합니다. 배열에 남은 바이트는 0입니다. 단위의 범위: 138 - input.available() 은 이 전체 메모리 배열의 소비 위치를 구합니다. 소켓의 available() 을 전체 요청 길이나 EOF 판정으로 사용하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 요청 시작

POST와 다음 GET이 이어진 동일 스트림에서 시작합니다.

  • 전체: [0,138)
  • 커서: 0
  • 다음 요청 바이트도 같은 입력에 존재한다.
POST 헤더 끝

빈 줄까지 97바이트를 소비합니다.

  • 헤더 범위: [0,97)
  • 빈 줄: [95,97)
  • 커서: 97
  • Content-Length에 맞춘 본문 읽기를 준비한다.
POST 본문 끝

한글 본문 6바이트만 읽고 첫 요청을 반환합니다.

  • 본문 범위: [97,103)
  • 커서: 103
  • 남은 바이트: 35
  • 다음 GET의 첫 바이트를 소비하지 않는다.
다음 요청 끝

GET 35바이트를 읽어 둘째 요청을 반환합니다.

  • GET 범위: [103,138)
  • 커서: 138
  • 남은 바이트: 0
  • 첫 본문 경계를 지켜 두 요청을 분리한다.

한글 6바이트 뒤에서 다음 요청 읽기

138바이트의 같은 입력 스트림에서 POST 헤더 97바이트와 본문 6바이트를 읽어 커서 103에 도달한 뒤 다음 GET 35바이트를 읽습니다. 본문 경계를 넘지 않는 두 요청의 전환이 명확합니다.

같은 메모리 입력의 POST 헤더 0–97, 한글 본문 97–103, 다음 GET 103–138 바이트 구간과 두 번의 요청 판독 뒤 예상 위치

같은 입력 · 바이트 구간 · 다음 읽기 위치

한글 6바이트가 끝난 위치에서 다음 GET을 읽는다

POST와 GET을 합친 138바이트 배열을 하나의 ByteArrayInputStream으로 읽습니다. 첫 read(input)은 위치 103까지, 다음 호출은 위치 138까지 소비할 것으로 예상합니다.

동일한 138바이트 입력 안의 두 요청 경계

동일한 138바이트 입력 안의 두 요청 경계POST 헤더 0–97과 한글 본문 97–103이 첫 read의 범위다. 다음 GET은 103의 G에서 시작해 두 번째 read 뒤 138이 된다. ByteArrayInputStream의 계산이며 소켓 available 일반화가 아니다. 상자의 높이는 바이트 수에 비례하지 않는 구간도다.같은 138 B 배열POST 헤더[0,97) · 97 B한글 본문[97,103) · 6 B한 3 B + 글 3 B다음 GET[103,138) · 35 B097103138첫 read 끝 103 · 남은 35 B둘째 read 끝 138 · 남은 0 B
동일한 138바이트 입력 안의 두 요청 경계POST 헤더 0–97과 한글 본문 97–103이 첫 read의 범위다. 다음 GET은 103의 G에서 시작해 두 번째 read 뒤 138이 된다. ByteArrayInputStream의 계산이며 소켓 available 일반화가 아니다. 상자의 높이는 바이트 수에 비례하지 않는 구간도다.같은 138 B 배열POST 헤더[0,97) · 97 B한글 본문[97,103) · 6 B한 3 B + 글 3 B다음 GET[103,138) · 35 B097103138첫 read 끝 103 · 남은 35 B둘째 read 끝 138 · 남은 0 B

POST 헤더 0–97과 한글 본문 97–103이 첫 read의 범위다. 다음 GET은 103의 G에서 시작해 두 번째 read 뒤 138이 된다. ByteArrayInputStream의 계산이며 소켓 available 일반화가 아니다. 상자의 높이는 바이트 수에 비례하지 않는 구간도다.

상태별 상세 해설

바이트 번호는 0부터, 구간의 끝은 포함하지 않는다

POST 헤더의 마지막은 Content-Length: 6\r\n\r\n입니다. 헤더 끝 빈 줄은 [95, 97)에 있으며, 본문 뒤에는 CRLF를 더 넣지 않습니다.

본문 main이 만드는 같은 입력의 세 구간
입력 부분바이트 구간길이읽을 내용
첫 POST 헤더[0, 97)97바이트시작 줄·헤더·각 CRLF와 헤더 끝 빈 줄을 읽습니다.
한글 본문[97, 103)6바이트Content-Length: 6에 따라 한 [97, 100), 글 [100, 103)을 읽습니다.
다음 GET 헤더[103, 138)35바이트GET /next HTTP/1.1의 G부터 읽습니다. 길이·전송 코딩 헤더가 없어 본문은 0바이트입니다.
  1. POST 헤더 · [0, 97)

    97바이트입니다. 시작 줄·헤더·각 CRLF와 헤더 끝 빈 줄을 읽습니다.

  2. 한글 본문 · [97, 103)

    Content-Length: 6에 따라 6바이트를 읽습니다. 한 [97, 100)과 글 [100, 103)은 각각 3바이트입니다.

  3. 다음 GET · [103, 138)

    35바이트입니다. GET /next HTTP/1.1의 G부터 읽습니다. 길이·전송 코딩 헤더가 없어 본문은 0바이트입니다.

한: ED 95 9C  // [97, 100)
글: EA B8 80  // [100, 103)
다음 G: 47   // 위치 103

다음 호출도 같은 input을 사용한다

  1. 첫 read 뒤 예상 위치 103

    POST /notes와 한글 본문을 반환합니다. 다음 GET의 35바이트가 남고, 다음 읽을 바이트는 G입니다.

  2. 두 번째 read 뒤 예상 위치 138

    GET /next와 길이 0인 본문을 반환합니다. 배열에 남은 바이트는 0입니다.

단위의 범위: 138 - input.available()은 이 전체 메모리 배열의 소비 위치를 구합니다. 소켓의 available()을 전체 요청 길이나 EOF 판정으로 사용하지 않습니다.

이 예의 조건

  • ByteArrayInputStream의 138바이트를 계산하며 Socket.available 계약으로 일반화하지 않는다.

관계도의 보충 설명

원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보한글 6바이트 뒤에서 다음 요청 읽기 같은 메모리 입력의 POST 헤더 0–97, 한글 본문 97–103, 다음 GET 103–138 바이트 구간과 두 번의 요청 판독 뒤 예상 위치 같은 입력 · 바이트 구간 · 다음 읽기 위치 한글 6바이트가 끝난 위치에서 다음 GET을 읽는다 POST와 GET을 합친 138바이트 배열을 하나의 ByteArrayInputStream 으로 읽습니다. 첫 read(input) 은 위치 103까지, 다음 호출은 위치 138까지 소비할 것으로 예상합니다. 바이트 번호는 0부터, 구간의 끝은 포함하지 않는다 POST 헤더의 마지막은 Content-Length: 6\r\n\r\n 입니다. 헤더 끝 빈 줄은 [95, 97) 에 있으며, 본문 뒤에는 CRLF를 더 넣지 않습니다. 본문 main이 만드는 같은 입력의 세 구간 입력 부분 바이트 구간 길이 읽을 내용 첫 POST 헤더 [0, 97) 97바이트 시작 줄·헤더·각 CRLF와 헤더 끝 빈 줄을 읽습니다. 한글 본문 [97, 103) 6바이트 Content-Length: 6 에 따라 한 [97, 100) , 글 [100, 103) 을 읽습니다. 다음 GET 헤더 [103, 138) 35바이트 GET /next HTTP/1.1 의 G부터 읽습니다. 길이·전송 코딩 헤더가 없어 본문은 0바이트입니다. POST 헤더 · [0, 97) 97바이트입니다. 시작 줄·헤더·각 CRLF와 헤더 끝 빈 줄을 읽습니다. 한글 본문 · [97, 103) Content-Length: 6 에 따라 6바이트를 읽습니다. 한 [97, 100) 과 글 [100, 103) 은 각각 3바이트입니다. 다음 GET · [103, 138) 35바이트입니다. GET /next HTTP/1.1 의 G부터 읽습니다. 길이·전송 코딩 헤더가 없어 본문은 0바이트입니다. 한: ED 95 9C // [97, 100) 글: EA B8 80 // [100, 103) 다음 G: 47 // 위치 103 다음 호출도 같은 input을 사용한다 첫 read 뒤 예상 위치 103 POST /notes 와 한글 본문을 반환합니다. 다음 GET의 35바이트가 남고, 다음 읽을 바이트는 G입니다. 두 번째 read 뒤 예상 위치 138 GET /next 와 길이 0인 본문을 반환합니다. 배열에 남은 바이트는 0입니다. 단위의 범위: 138 - input.available() 은 이 전체 메모리 배열의 소비 위치를 구합니다. 소켓의 available() 을 전체 요청 길이나 EOF 판정으로 사용하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 요청 시작

POST와 다음 GET이 이어진 동일 스트림에서 시작합니다.

  • 전체: [0,138)
  • 커서: 0
  • 다음 요청 바이트도 같은 입력에 존재한다.
POST 헤더 끝

빈 줄까지 97바이트를 소비합니다.

  • 헤더 범위: [0,97)
  • 빈 줄: [95,97)
  • 커서: 97
  • Content-Length에 맞춘 본문 읽기를 준비한다.
POST 본문 끝

한글 본문 6바이트만 읽고 첫 요청을 반환합니다.

  • 본문 범위: [97,103)
  • 커서: 103
  • 남은 바이트: 35
  • 다음 GET의 첫 바이트를 소비하지 않는다.
다음 요청 끝

GET 35바이트를 읽어 둘째 요청을 반환합니다.

  • GET 범위: [103,138)
  • 커서: 138
  • 남은 바이트: 0
  • 첫 본문 경계를 지켜 두 요청을 분리한다.

다음 GET은 헤더까지 35바이트이며, Content-Length와 Transfer-Encoding이 없어서 이 판독기는 본문을 0바이트로 봅니다. 같은 입력에서 두 번째 read가 반환하면 위치는 138이고 배열에 남은 바이트는 0입니다.

예상 출력

POST /notes
한글
첫 요청 뒤 위치=103
GET /next
다음 요청 본문 bytes=0
두 번째 요청 뒤 위치=138
남은 bytes=0

requestBytes.length - input.available()은 이 전체 메모리 바이트 배열에서 소비한 바이트 수를 구합니다. ByteArrayInputStream.available()은 배열에 남은 바이트 수를 반환합니다. 소켓이나 일반 InputStream의 available()로 전체 요청 길이나 EOF를 판단해서는 안 됩니다.

첫 위치가 다르면 헤더 마지막의 \r\n\r\n, UTF-8 본문 길이, 본문 뒤에 덧붙인 바이트부터 확인합니다. Content-Length를 실수로 2로 선언하면 위치 99에서 한의 UTF-8 바이트 중간이 잘립니다. 7로 선언하면 다음 요청의 G까지 본문에 들어가 위치 104에서 ET /next HTTP/1.1을 읽게 됩니다.

readNBytes는 선언된 길이를 기준으로 읽으므로 그 숫자가 실제 본문 길이와 맞는지 스스로 알아내지는 못합니다. 이 판독기는 메서드·대상·버전 문법을 아직 검사하지 않아, 경계가 어긋난 다음 시작 줄을 항상 즉시 거절한다고 보장할 수도 없습니다. 아래 그림에서 적용된 검사와 남은 검증을 함께 확인합니다.

바이트 상한은 요청 문법 전체의 검증을 대신하지 않는다

BoundedHttpRequestReader가 적용하는 검사와 별도로 필요한 검증을 구분합니다.

요청 판독 · 크기 상한과 문법 검증

바이트 상한은 요청 문법 전체의 검증을 대신하지 않는다

줄·헤더·본문 크기와 잘린 입력을 검사합니다. 이 상한만으로 메서드·Host·필드명이나 길이 값의 문법 검증이 끝나지는 않습니다.

시작 줄·헤더

적용된 조건
줄 4096바이트 · 전체 16384바이트
별도로 남는 검증
전체에는 시작 줄·CRLF 포함 · 헤더 개수와 읽기 기한은 별도 설정 없음

요청 문법

적용된 조건
시작 줄의 세 조각 · 첫 콜론 분리
별도로 남는 검증
메서드·대상·버전·Host와 필드명 문법 미검증

프레이밍 헤더

적용된 조건
전송 코딩 거절 · 반복 길이 거절
별도로 남는 검증
콜론 앞 공백을 trim해 수용하며 RFC의 400 거절 조건과 다름

본문

적용된 조건
길이 0..1048576 · 선언보다 짧으면 거절
별도로 남는 검증
+1·-0까지 변환하므로 ASCII 숫자만 허용하는 문법 검사가 더 필요

길이 헤더가 없으면 본문을 0바이트로 봅니다. main은 한 메모리 입력에서 POST 뒤 GET을 읽는 예입니다. HTTP 오류 응답과 소켓 연결 종료는 별도 처리기의 책임입니다.

조건 표현식 안에서 예외를 던지기 위해 throwTruncatedLine 보조 메서드를 사용했지만 가독성을 우선한다면 EOF 분기를 일반 if 블록으로 펼치는 편이 낫습니다.

핵심은 EOF 직전 일부 바이트를 정상 줄로 반환하지 않는 것입니다.

불완전한 요청을 처리하면 공격자가 불완전 헤더를 정상 요청처럼 만들 수 있습니다.

이 예제는 예외만 던지며 HTTP 오류 응답이나 연결 종료를 직접 수행하지 않습니다. 연결 처리기가 실패 종류에 맞는 응답과 종료 정책을 맡아야 합니다.


청크 프레이밍 검증

chunked 본문은 크기 줄·데이터·CRLF가 반복된 뒤 0 청크, 선택적 트레일러 필드, 마지막 빈 줄로 끝납니다.

아래 학습용 구현은 청크 확장과 비어 있지 않은 트레일러를 모두 거절하는 제한 정책을 씁니다. RFC 9112는 알 수 없는 청크 확장을 무시하도록 요구하므로 이 구현을 일반적인 HTTP/1.1 준수 디코더로 보아서는 안 됩니다.

모든 청크 합계에는 전체 본문 상한을 적용합니다.

src/ChunkedBodyDecoder.java
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);
    }
}

청크의 길이 줄과 구분 CRLF는 본문 길이에 포함하지 않는다

4/Wiki, 5/pedia, 7/공백과 한글 chunk를 순서대로 소비한 뒤 0으로 종료하여 본문 16바이트를 얻습니다. 크기 줄과 CRLF가 본문 길이에 포함되지 않는 누적 경계를 드러낼 수 있습니다.

청크의 길이 줄과 구분 CRLF는 본문 길이에 포함하지 않는다

ChunkedBodyDecoder.main의 고정 wire를 바이트 구간으로 풀이합니다.

청크 전송 · 데이터와 프레이밍 분리

청크의 길이 줄과 구분 CRLF는 본문 길이에 포함하지 않는다

Wiki 4바이트, pedia 5바이트, 공백과 한글 7바이트를 합쳐 본문은 16바이트입니다. 크기 줄과 CRLF는 본문에 합치지 않습니다.

wire의 프레이밍과 16바이트 본문을 분리한다

wire의 프레이밍과 16바이트 본문을 분리한다크기 줄과 데이터 뒤 CRLF, 종료 0과 빈 트레일러 CRLF는 wire에 있으나 본문에 추가하지 않는다. 본문은 Wiki 4·pedia 5·공백한글 7의 세 구간이다.크기 4 · 본문 4 B4CRLFWikiCRLF크기 5 · 본문 9 B5CRLFpediaCRLF크기 7 · 본문 16 B7CRLF␠한글CRLF크기 0 · 본문 16 B0CRLF트레일러CRLFWiki + pedia + ␠한글4 + 5 + 7 = 16 B크기 줄·CRLF는 제외
wire의 프레이밍과 16바이트 본문을 분리한다크기 줄과 데이터 뒤 CRLF, 종료 0과 빈 트레일러 CRLF는 wire에 있으나 본문에 추가하지 않는다. 본문은 Wiki 4·pedia 5·공백한글 7의 세 구간이다.크기 4 · 본문 4 B4CRLFWikiCRLF크기 5 · 본문 9 B5CRLFpediaCRLF크기 7 · 본문 16 B7CRLF␠한글CRLF크기 0 · 본문 16 B0CRLF트레일러CRLFWiki + pedia + ␠한글4 + 5 + 7 = 16 B크기 줄·CRLF는 제외

크기 줄과 데이터 뒤 CRLF, 종료 0과 빈 트레일러 CRLF는 wire에 있으나 본문에 추가하지 않는다. 본문은 Wiki 4·pedia 5·공백한글 7의 세 구간이다.

상태별 상세 해설
  1. 크기 4

    반환 본문에 합치는 데이터
    Wiki · 4바이트
    뒤에서 소비하는 프레이밍
    데이터 끝 CRLF
  2. 크기 5

    반환 본문에 합치는 데이터
    pedia · 5바이트
    뒤에서 소비하는 프레이밍
    데이터 끝 CRLF
  3. 크기 7

    반환 본문에 합치는 데이터
    공백 1 + 한글 6 = 7바이트
    뒤에서 소비하는 프레이밍
    데이터 끝 CRLF
  4. 크기 0

    반환 본문에 합치는 데이터
    추가 데이터 없음 · 합계 16바이트
    뒤에서 소비하는 프레이밍
    빈 트레일러 줄의 CRLF

상수 입력을 해석한 값이며 실행 추적이 아닙니다. 이 decoder는 별도 클래스이고 앞 요청 판독기에 연결되어 있지 않습니다.

이 예의 조건

  • 디코더 입력 바이트의 소스 계산이다.
  • chunk 크기 줄·CRLF·trailer는 본문 누적 길이에 넣지 않는다.
관계도의 보충 설명
원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보청크의 길이 줄과 구분 CRLF는 본문 길이에 포함하지 않는다 청크의 길이 줄과 구분 CRLF는 본문 길이에 포함하지 않는다 ChunkedBodyDecoder.main의 고정 wire를 바이트 구간으로 풀이합니다. 청크 전송 · 데이터와 프레이밍 분리 청크의 길이 줄과 구분 CRLF는 본문 길이에 포함하지 않는다 Wiki 4바이트, pedia 5바이트, 공백과 한글 7바이트를 합쳐 본문은 16바이트입니다. 크기 줄과 CRLF는 본문에 합치지 않습니다. 크기 4 반환 본문에 합치는 데이터 Wiki · 4바이트 뒤에서 소비하는 프레이밍 데이터 끝 CRLF 크기 5 반환 본문에 합치는 데이터 pedia · 5바이트 뒤에서 소비하는 프레이밍 데이터 끝 CRLF 크기 7 반환 본문에 합치는 데이터 공백 1 + 한글 6 = 7바이트 뒤에서 소비하는 프레이밍 데이터 끝 CRLF 크기 0 반환 본문에 합치는 데이터 추가 데이터 없음 · 합계 16바이트 뒤에서 소비하는 프레이밍 빈 트레일러 줄의 CRLF 상수 입력을 해석한 값이며 실행 추적이 아닙니다. 이 decoder는 별도 클래스이고 앞 요청 판독기에 연결되어 있지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 chunk

크기 4 뒤 Wiki를 본문에 추가합니다.

  • 현재 chunk: 4 / Wiki
  • 본문 길이: 4
  • wire framing을 제외하고 데이터만 누산한다.
둘째 chunk

pedia 5바이트를 이어 붙입니다.

  • 현재 chunk: 5 / pedia
  • 본문 길이: 9
  • 본문 값은 Wikipedia가 된다.
셋째 chunk

공백과 한글 7바이트를 이어 붙입니다.

  • 현재 chunk: 7바이트
  • 본문 길이: 16
  • 문자 수 대신 실제 UTF-8 데이터 바이트를 더한다.
종료 chunk

0을 읽어 본문을 마칩니다.

  • 현재 chunk: 0
  • 완성 본문: 16바이트
  • 종료 표식 자체는 본문 데이터가 아니다.

청크의 길이 줄과 구분 CRLF는 본문 길이에 포함하지 않는다

4/Wiki, 5/pedia, 7/공백과 한글 chunk를 순서대로 소비한 뒤 0으로 종료하여 본문 16바이트를 얻습니다. 크기 줄과 CRLF가 본문 길이에 포함되지 않는 누적 경계를 드러낼 수 있습니다.

청크의 길이 줄과 구분 CRLF는 본문 길이에 포함하지 않는다

ChunkedBodyDecoder.main의 고정 wire를 바이트 구간으로 풀이합니다.

청크 전송 · 데이터와 프레이밍 분리

청크의 길이 줄과 구분 CRLF는 본문 길이에 포함하지 않는다

Wiki 4바이트, pedia 5바이트, 공백과 한글 7바이트를 합쳐 본문은 16바이트입니다. 크기 줄과 CRLF는 본문에 합치지 않습니다.

wire의 프레이밍과 16바이트 본문을 분리한다

wire의 프레이밍과 16바이트 본문을 분리한다크기 줄과 데이터 뒤 CRLF, 종료 0과 빈 트레일러 CRLF는 wire에 있으나 본문에 추가하지 않는다. 본문은 Wiki 4·pedia 5·공백한글 7의 세 구간이다.크기 4 · 본문 4 B4CRLFWikiCRLF크기 5 · 본문 9 B5CRLFpediaCRLF크기 7 · 본문 16 B7CRLF␠한글CRLF크기 0 · 본문 16 B0CRLF트레일러CRLFWiki + pedia + ␠한글4 + 5 + 7 = 16 B크기 줄·CRLF는 제외
wire의 프레이밍과 16바이트 본문을 분리한다크기 줄과 데이터 뒤 CRLF, 종료 0과 빈 트레일러 CRLF는 wire에 있으나 본문에 추가하지 않는다. 본문은 Wiki 4·pedia 5·공백한글 7의 세 구간이다.크기 4 · 본문 4 B4CRLFWikiCRLF크기 5 · 본문 9 B5CRLFpediaCRLF크기 7 · 본문 16 B7CRLF␠한글CRLF크기 0 · 본문 16 B0CRLF트레일러CRLFWiki + pedia + ␠한글4 + 5 + 7 = 16 B크기 줄·CRLF는 제외

크기 줄과 데이터 뒤 CRLF, 종료 0과 빈 트레일러 CRLF는 wire에 있으나 본문에 추가하지 않는다. 본문은 Wiki 4·pedia 5·공백한글 7의 세 구간이다.

상태별 상세 해설
  1. 크기 4

    반환 본문에 합치는 데이터
    Wiki · 4바이트
    뒤에서 소비하는 프레이밍
    데이터 끝 CRLF
  2. 크기 5

    반환 본문에 합치는 데이터
    pedia · 5바이트
    뒤에서 소비하는 프레이밍
    데이터 끝 CRLF
  3. 크기 7

    반환 본문에 합치는 데이터
    공백 1 + 한글 6 = 7바이트
    뒤에서 소비하는 프레이밍
    데이터 끝 CRLF
  4. 크기 0

    반환 본문에 합치는 데이터
    추가 데이터 없음 · 합계 16바이트
    뒤에서 소비하는 프레이밍
    빈 트레일러 줄의 CRLF

상수 입력을 해석한 값이며 실행 추적이 아닙니다. 이 decoder는 별도 클래스이고 앞 요청 판독기에 연결되어 있지 않습니다.

이 예의 조건

  • 디코더 입력 바이트의 소스 계산이다.
  • chunk 크기 줄·CRLF·trailer는 본문 누적 길이에 넣지 않는다.

관계도의 보충 설명

원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보청크의 길이 줄과 구분 CRLF는 본문 길이에 포함하지 않는다 청크의 길이 줄과 구분 CRLF는 본문 길이에 포함하지 않는다 ChunkedBodyDecoder.main의 고정 wire를 바이트 구간으로 풀이합니다. 청크 전송 · 데이터와 프레이밍 분리 청크의 길이 줄과 구분 CRLF는 본문 길이에 포함하지 않는다 Wiki 4바이트, pedia 5바이트, 공백과 한글 7바이트를 합쳐 본문은 16바이트입니다. 크기 줄과 CRLF는 본문에 합치지 않습니다. 크기 4 반환 본문에 합치는 데이터 Wiki · 4바이트 뒤에서 소비하는 프레이밍 데이터 끝 CRLF 크기 5 반환 본문에 합치는 데이터 pedia · 5바이트 뒤에서 소비하는 프레이밍 데이터 끝 CRLF 크기 7 반환 본문에 합치는 데이터 공백 1 + 한글 6 = 7바이트 뒤에서 소비하는 프레이밍 데이터 끝 CRLF 크기 0 반환 본문에 합치는 데이터 추가 데이터 없음 · 합계 16바이트 뒤에서 소비하는 프레이밍 빈 트레일러 줄의 CRLF 상수 입력을 해석한 값이며 실행 추적이 아닙니다. 이 decoder는 별도 클래스이고 앞 요청 판독기에 연결되어 있지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 chunk

크기 4 뒤 Wiki를 본문에 추가합니다.

  • 현재 chunk: 4 / Wiki
  • 본문 길이: 4
  • wire framing을 제외하고 데이터만 누산한다.
둘째 chunk

pedia 5바이트를 이어 붙입니다.

  • 현재 chunk: 5 / pedia
  • 본문 길이: 9
  • 본문 값은 Wikipedia가 된다.
셋째 chunk

공백과 한글 7바이트를 이어 붙입니다.

  • 현재 chunk: 7바이트
  • 본문 길이: 16
  • 문자 수 대신 실제 UTF-8 데이터 바이트를 더한다.
종료 chunk

0을 읽어 본문을 마칩니다.

  • 현재 chunk: 0
  • 완성 본문: 16바이트
  • 종료 표식 자체는 본문 데이터가 아니다.

청크 크기는 ASCII 16진 숫자만 허용해야 합니다. Integer.parseInt는 부호도 받으므로 +1과 -0 같은 입력을 별도로 막아야 합니다.

각 청크를 선언된 길이만큼 읽고 구분 CRLF를 확인하지만, 선언 길이 자체가 뒤 요청까지 포함한 경우를 독립적으로 알아내는 것은 아닙니다.

확인 문제

독립 ChunkedBodyDecoder에 3\r\n한\r\n1\r\n!\r\n0\r\n\r\n을 UTF-8 바이트로 전달합니다. 복원된 본문, 본문 바이트 수, 청크 프레이밍까지 포함한 전체 입력 바이트 수를 계산하세요. 이 클래스는 앞의 요청 판독기에 아직 연결되지 않았다는 조건을 유지합니다.

데이터와 전송 프레이밍 해설

본문은 "한!"이고 3+1=4바이트입니다. 첫 청크는 크기 줄 3바이트 + 데이터 3 + 끝 CRLF 2로 8바이트, 둘째는 3+1+2=6바이트입니다. 0 청크와 빈 트레일러 줄은 5바이트이므로 전체 입력은 8+6+5=19바이트입니다. 19를 복원된 본문 길이로 쓰지 않습니다. 본문 없는 HEAD와 chunked의 데이터 분리도 다른 규칙입니다. 디코더 main이나 이 입력을 확인한 결과만으로 청크 HTTP 요청의 라우팅·연결 재사용까지 검증했다고 말할 수 없습니다.


응답 직렬화 불변식

서비스 코드는 상태, 헤더, 본문을 값으로 구성하고, 응답 기록기가 한 번만 직렬화합니다.

여러 서블릿이 직접 시작 줄과 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로 고정합니다.

solution/HttpResponseFramingSolution.java
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의 분기를 표현합니다.

응답 프레이밍 · 메서드와 상태의 조합

요청 메서드와 상태가 본문·길이 헤더를 함께 결정한다

200 GET은 본문을 보내고 200 HEAD는 같은 표현 길이만 알립니다. 204·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, 너무 큰 합계, 지원하지 않는 확장과 트레일러를 넣습니다.

앞의 main처럼 한글 본문이 있는 POST 뒤에 본문 없는 GET을 이어 붙여, 같은 입력에서 판독기를 두 번 호출했을 때의 경계를 확인합니다. 청크 요청은 먼저 독립 디코더를 요청 판독기에 연결해야 같은 검사를 적용할 수 있습니다.

응답은 UTF-8 바이트 길이와 상태별 본문 규칙을 확인합니다.

다음 문서에서는 구조화된 요청과 응답을 전제로 경로별 서블릿 등록과 404·500 오류 경계를 살펴봅니다. 이 장에 남은 문법 검증과 연결 수명 처리는 별도 통합 과제입니다.