본문 바로가기

HTTP 본문 읽기: Content-Length와 chunked

헤더와 본문을 하나의 바이트 스트림에서 읽어 요청이 끝나는 위치를 정확히 찾고, chunked 본문을 풀고, 상태 코드와 메서드에 맞게 응답 본문을 쓰는 방법을 배웁니다.

앞 글에서는 시작 줄의 요청 대상을 경로와 쿼리로 나눴습니다. 그런데 POST 요청처럼 본문이 있으면 풀어야 할 문제가 하나 더 생깁니다. TCP 연결은 바이트가 끊김 없이 이어지는 흐름이라서, 한 요청이 어디서 끝나고 다음 요청이 어디서 시작하는지 알려 주는 표시가 따로 없습니다. 헤더는 빈 줄로 끝나지만 본문에는 어떤 바이트든 들어갈 수 있으니, 빈 줄이나 특정 문자를 본문의 끝 표시로 쓸 수 없습니다. 그래서 HTTP/1.1은 본문의 길이를 헤더에 적어 보냅니다.

이처럼 메시지가 어디서 끝나는지 정하는 방식을 프레이밍(framing)이라고 합니다. 요청의 본문 길이는 세 가지 중 하나로 정해집니다. Content-Length 헤더가 있으면 그 값이 본문의 바이트 수입니다. Transfer-Encoding: chunked가 있으면 본문이 여러 조각, 즉 청크(chunk)로 나뉘어 오고 청크마다 앞에 길이가 붙습니다. 둘 다 없으면 본문은 0바이트입니다. 프레이밍을 한 바이트라도 잘못 읽으면 같은 연결로 이어지는 다음 요청까지 모두 어긋나므로, 서버에서 가장 엄격하게 다뤄야 하는 부분입니다.

curl이 보내는 두 가지 본문

실제 클라이언트가 어떤 바이트를 보내는지부터 보겠습니다. 앞 글에서처럼 한 터미널에서 nc -l 8081로 받은 바이트를 그대로 출력하게 하고, 다른 터미널에서 curl로 한글 두 글자를 본문에 담아 보냈습니다.

curl --data-binary '한글' -H 'Content-Type: text/plain; charset=UTF-8' http://127.0.0.1:8081/notes

받은 바이트는 다음과 같습니다. 줄 끝의 CR LF는 \r\n으로, 본문은 16진수로 표시했습니다.

POST /notes HTTP/1.1\r\n
Host: 127.0.0.1:8081\r\n
User-Agent: curl/8.5.0\r\n
Accept: */*\r\n
Content-Type: text/plain; charset=UTF-8\r\n
Content-Length: 6\r\n
\r\n
ED 95 9C EA B8 80

한글은 두 글자지만 UTF-8로는 6바이트이므로 Content-Length가 6입니다. 받는 쪽은 헤더 끝의 빈 줄 다음부터 정확히 6바이트를 읽으면 본문이 끝났다는 것을 압니다. 같은 명령에 -H 'Transfer-Encoding: chunked'를 덧붙이면 curl은 길이 헤더 대신 청크 형식으로 보냅니다.

POST /notes HTTP/1.1\r\n
Host: 127.0.0.1:8081\r\n
User-Agent: curl/8.5.0\r\n
Accept: */*\r\n
Content-Type: text/plain; charset=UTF-8\r\n
Transfer-Encoding: chunked\r\n
\r\n
6\r\n
ED 95 9C EA B8 80\r\n
0\r\n
\r\n

빈 줄 다음의 6은 뒤따르는 청크가 6바이트라는 뜻이고, 마지막의 0은 더 보낼 청크가 없다는 표시입니다. 청크의 길이는 16진수로 적습니다. 청크 형식은 보내는 쪽이 전체 길이를 미리 모를 때, 예를 들어 데이터를 만들면서 곧바로 보내야 할 때 쓰입니다. 이 글에서는 먼저 Content-Length로 길이를 알리는 요청을 바이트 단위로 읽는 판독기를 만들고, 청크 본문을 푸는 디코더를 따로 만든 뒤, 응답을 쓸 때 본문 길이를 다루는 규칙을 살펴봅니다.

BufferedReader로 헤더를 읽으면 본문이 사라진다

HTTP 서버를 다룬 글의 예제는 헤더를 BufferedReader.readLine()으로 읽었습니다. 본문 없는 요청 하나만 처리할 때는 문제가 없었지만, 본문을 읽으려고 하면 곧바로 문제가 드러납니다. 다음 코드는 빈 줄까지 BufferedReader로 읽은 뒤, 본문은 원래의 InputStream에서 readNBytes로 읽으려 합니다. 요청은 메모리의 바이트 배열로 만들었습니다.

bad/MixedHttpBodyReader.java
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
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()) {
            // 헤더 줄을 건너뛴다. 이때 reader가 본문까지 미리 읽어 둘 수 있다.
        }
        return input.readNBytes(contentLength);
    }

    public static void main(String[] args) throws Exception {
        var wire = new ByteArrayOutputStream();
        wire.writeBytes(("POST /notes HTTP/1.1\r\n"
                + "Host: local\r\n"
                + "Content-Length: 6\r\n"
                + "\r\n").getBytes(StandardCharsets.US_ASCII));
        wire.writeBytes("한글".getBytes(StandardCharsets.UTF_8));

        var input = new ByteArrayInputStream(wire.toByteArray());
        byte[] body = read(input, 6);
        System.out.println("보낸 바이트=" + wire.size());
        System.out.println("읽은 본문 바이트=" + body.length);
        System.out.println("스트림에 남은 바이트=" + input.available());
    }
}

read는 reader로 헤더를 건너뛴 뒤, 같은 input에서 본문 6바이트를 읽으려 합니다. 마지막 줄의 available()은 원래 스트림에 아직 읽히지 않고 남은 바이트 수입니다.

보낸 바이트=62
읽은 본문 바이트=0
스트림에 남은 바이트=0

62바이트를 보냈는데 본문은 한 바이트도 읽지 못했고, 스트림에 남은 바이트도 없습니다. 본문 6바이트는 reader 안에 있습니다. BufferedReader는 readLine()을 부를 때마다 한 줄씩 돌려주지만, 그 아래의 InputStreamReader는 효율을 위해 원래 스트림에서 한 번에 수천 바이트까지 읽어 자기 버퍼에 쌓아 둡니다. 이 예에서는 첫 readLine() 호출 때 62바이트 전체가 버퍼로 들어갔고, 그 뒤 원래 스트림에서 읽으려 했을 때는 남은 바이트가 없었습니다.

소켓에서도 같은 일이 일어납니다. 다만 네트워크에서는 바이트가 나뉘어 도착하므로 본문이 전부 사라질 수도, 일부만 사라질 수도, 우연히 멀쩡할 수도 있습니다. 그래서 테스트에서는 통과하다가 운영 중에 가끔 본문이 잘리는 버그가 됩니다. 해결 방법은 하나입니다. 헤더와 본문을 같은 바이트 입력 하나에서 모두 읽고, 문자열이 필요한 줄만 그 자리에서 문자로 바꾸는 것입니다.

더 깊이: 버퍼를 없애라는 뜻이 아니다

문제는 버퍼가 아니라 버퍼가 두 개라는 점입니다. 소켓 스트림에서 read()로 한 바이트씩 읽으면 호출할 때마다 운영체제를 거치므로 느립니다. 연결마다 BufferedInputStream을 하나만 만들고 헤더를 읽는 코드와 본문을 읽는 코드가 모두 그 객체에서 읽게 하면, 속도와 정확성을 함께 얻을 수 있습니다. 같은 연결의 다음 요청도 같은 객체에서 이어 읽어야 합니다. 그 버퍼에 다음 요청의 앞부분이 이미 들어 있을 수 있기 때문입니다.

바이트 단위로 요청 읽기

아래 BoundedHttpRequestReader는 InputStream 하나에서 시작 줄, 헤더, 본문을 모두 읽습니다. 핵심은 readLine입니다. 바이트를 하나씩 읽다가 CR(\r) 바로 뒤에 LF(\n)가 오면 한 줄이 끝난 것으로 보고, 모은 바이트를 US-ASCII 문자열로 바꿉니다. CR 없이 LF만 온 줄이나 CR 뒤에 LF가 오지 않는 줄은 거절합니다. RFC 9112는 LF 하나로 끝난 줄을 받아 줘도 된다고 허용하지만, 줄 끝을 판단하는 규칙이 프로그램마다 다르면 같은 바이트를 서로 다르게 나누게 되므로 이 판독기는 CRLF만 받습니다.

시작 줄은 공백으로 나눠 정확히 세 조각인지 봅니다. 헤더 줄은 첫 번째 콜론에서 이름과 값으로 나누고, 이름은 소문자로 바꿔 저장합니다. HTTP 헤더 이름은 대소문자를 구별하지 않으므로, Content-Length와 content-length를 같은 키로 찾기 위해서입니다. 같은 이름이 여러 번 오면 값 목록에 차례로 쌓습니다. 빈 줄을 만나면 헤더가 끝난 것이고, 그다음 프레이밍 헤더를 검사해 본문 길이를 정한 뒤 readNBytes로 정확히 그만큼만 읽습니다. 이 판독기는 Content-Length 방식만 지원하고, Transfer-Encoding이 있으면 거절합니다.

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) {
                if (bytes.size() == 0) {
                    return null;
                }
                throw new EOFException("line truncated");
            }
            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");
    }

    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());
    }
}

readLine이 null을 돌려주는 것은 아무 바이트도 읽기 전에 입력이 끝났을 때뿐입니다. 몇 바이트를 읽은 뒤 CRLF 없이 입력이 끝나면 EOFException을 던집니다. 끝나지 않은 줄을 완성된 줄처럼 돌려주면, 연결이 중간에 끊긴 불완전한 헤더가 정상 요청처럼 처리될 수 있기 때문입니다.

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

main은 한글 본문이 있는 POST 뒤에 본문 없는 GET을 바로 이어 붙여 바이트 배열 하나로 만듭니다. 같은 연결로 요청 두 개가 연달아 도착한 상황을 흉내 낸 것입니다. ByteArrayInputStream도 하나만 만들어 같은 input을 read에 두 번 넘기므로, 두 번째 호출은 첫 호출이 멈춘 곳에서 이어 읽습니다.

바이트 위치를 따라가 보겠습니다. POST의 시작 줄과 헤더는 줄마다 붙은 CRLF와 헤더 끝의 빈 줄까지 합쳐 97바이트이므로 0번부터 96번까지 차지합니다. Content-Length: 6에 따라 97번부터 6바이트를 읽으면 첫 요청은 102번 바이트에서 끝나고, 다음에 읽을 위치는 103이 됩니다. 본문 뒤에 CRLF를 덧붙이지 않았으므로 103번 바이트는 다음 요청의 첫 글자 G입니다. GET은 헤더까지 35바이트이고 길이 헤더가 없어 본문을 0바이트로 보므로, 두 번째 read가 끝나면 위치는 배열의 끝인 138입니다.

한글 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
  • 첫 본문 경계를 지켜 두 요청을 분리한다.

실행 결과:

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

Content-Length에는 글자 수가 아니라 바이트 수를 적는다는 점을 다시 확인할 수 있습니다. "한글".length()는 UTF-16 코드 단위를 세어 2를 돌려주지만, UTF-8 본문은 ED 95 9C EA B8 80 6바이트입니다. 판독기는 바이트 6개를 다 모은 뒤에야 본문을 문자열로 바꿉니다.

위치는 requestBytes.length - input.available()로 구했습니다. ByteArrayInputStream.available()은 배열에 남은 바이트 수를 정확히 돌려주므로, 전체 길이에서 빼면 지금까지 읽은 위치가 됩니다. 소켓 스트림의 available()은 지금 기다리지 않고 읽을 수 있는 바이트 수의 추정값일 뿐이므로, 요청 길이나 연결 종료를 판단하는 데 쓰면 안 됩니다.

선언한 길이가 실제 본문과 다르면 다음 요청이 어긋납니다. readNBytes는 선언된 숫자만큼 읽을 뿐, 그 숫자가 맞는지는 스스로 알 수 없기 때문입니다. 어떻게 어긋나는지는 연습 문제 2에서 직접 확인해 봅니다.

이 판독기가 검사하는 것과 검사하지 않는 것

BoundedHttpRequestReader에는 크기 제한이 세 가지 있습니다. 한 줄은 4096바이트, 시작 줄과 헤더 전체는 CRLF를 포함해 16384바이트, 본문은 1048576바이트(1 MiB)를 넘을 수 없습니다. 제한이 없으면 끝없이 긴 헤더 줄이나 아주 큰 Content-Length 하나로 서버 메모리를 바닥낼 수 있기 때문입니다. 또 줄이 끝나기 전에 입력이 끊기거나, 빈 줄이 오기 전에 입력이 끝나거나, 본문이 선언보다 짧으면 잘린 요청으로 보고 EOFException을 던집니다.

하지만 크기 제한과 잘림 검사가 문법 검사를 대신하지는 않습니다. 이 판독기는 시작 줄이 세 조각인지만 볼 뿐, 메서드와 요청 대상과 버전이 올바른 모양인지는 검사하지 않습니다. HTTP/1.1 요청에는 Host 헤더가 정확히 하나 있어야 하고 없거나 여러 개면 400으로 거절해야 하는데, 그 검사도 없습니다. Host : local처럼 헤더 이름과 콜론 사이에 공백이 있는 줄은 명세상 400으로 거절해야 하지만, 이 코드는 이름을 trim()해서 받아들입니다. Content-Length는 숫자로만 이루어져야 하는데 Long.parseLong은 +6이나 -0처럼 부호가 붙은 값도 숫자로 바꿔 줍니다. 헤더 개수 제한과 읽기 시간 제한도 아직 없습니다. 아래 그림은 적용된 검사와 남은 검사를 나란히 보여 줍니다.

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

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

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

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

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

시작 줄·헤더

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

요청 문법

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

프레이밍 헤더

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

본문

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

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

이 판독기는 문제를 발견하면 예외를 던질 뿐, HTTP 오류 응답을 보내거나 연결을 닫지는 않습니다. 그 일은 판독기를 부르는 연결 처리 코드가 맡습니다. 상태 코드는 문제의 종류에 따라 고릅니다. 문법 오류는 400(Bad Request), 헤더가 너무 크면 431(Request Header Fields Too Large), 본문이 너무 크면 413(Content Too Large), 이해하지 못하는 전송 코딩은 501(Not Implemented)입니다. 어느 경우든 응답을 보낸 뒤에는 연결을 닫아야 합니다. 요청을 끝까지 읽지 못했으니 다음 요청이 어디서 시작하는지 더는 알 수 없기 때문입니다.

Content-Length와 Transfer-Encoding이 함께 오면

프레이밍 헤더는 다른 헤더보다 엄격하게 다뤄야 합니다. 클라이언트와 서버 사이에 프록시나 로드 밸런서가 있으면 같은 요청을 두 프로그램이 차례로 해석하는데, 둘이 요청의 끝을 다르게 판단하면 위험한 일이 생깁니다. 다음 요청을 보겠습니다.

POST / HTTP/1.1
Host: example.com
Content-Length: 13
Transfer-Encoding: chunked

0

SMUGGLED

Content-Length를 따르는 앞단 프록시는 빈 줄 뒤의 13바이트, 즉 0\r\n\r\nSMUGGLED 전체를 본문으로 보고 요청 하나로 뒤 서버에 넘깁니다. 반면 Transfer-Encoding을 따르는 뒤 서버는 0\r\n\r\n에서 청크 본문이 끝났다고 판단합니다. 그러면 남은 SMUGGLED가 같은 연결로 들어올 다음 요청의 앞부분이 되고, 다른 사용자의 요청과 합쳐질 수도 있습니다. 이런 공격을 요청 스머글링(request smuggling)이라고 합니다.

그래서 RFC 9112는 두 헤더가 함께 오면 Transfer-Encoding을 따르되 이런 메시지는 오류로 다루는 편이 좋다고 하고, 서버는 이런 요청을 거절해도 되며 처리하든 거절하든 응답 뒤에는 연결을 닫아야 한다고 정합니다. Content-Length가 여러 번 오는 경우도 비슷합니다. 값이 모두 같다면 하나로 합쳐 처리해도 되지만, 서로 다른 값 가운데 첫 값이나 마지막 값을 골라 쓰면 안 됩니다. 이 글의 판독기는 가장 단순한 쪽을 택해, Transfer-Encoding이 있으면 거절하고 Content-Length가 두 번 이상 나와도 거절합니다.

다만 짚어 둘 점이 있습니다. RFC 9112는 HTTP/1.1 수신자가 청크 전송 코딩을 반드시 해석할 수 있어야 한다고 정합니다. 따라서 Transfer-Encoding을 무조건 거절하는 이 판독기만으로는 완전한 HTTP/1.1 서버를 만들 수 없고, 다음 절의 디코더를 연결해야 합니다.

chunked 본문 풀기

청크 본문은 '길이 줄, 데이터, CRLF'가 반복되다가 길이가 0인 마지막 청크로 끝납니다. 길이는 16진수라서 1A라고 적혀 있으면 26바이트입니다. 마지막 청크 뒤에는 트레일러(trailer)라고 부르는 헤더 줄이 올 수 있고, 빈 줄 하나로 전체가 끝납니다. 길이 뒤에 ;name=value 모양의 청크 확장(chunk extension)을 붙일 수도 있습니다. 앞에서 curl이 보낸 본문은 청크 하나(6과 데이터 6바이트), 마지막 청크 0, 빈 트레일러로 이루어진 가장 단순한 경우였습니다.

ChunkedBodyDecoder는 이 형식을 읽어 데이터만 이어 붙입니다. 학습용으로 범위를 좁혀, 청크 확장과 비어 있지 않은 트레일러는 지원하지 않는다는 예외로 거절합니다. RFC 9112는 수신자가 모르는 청크 확장을 무시해야 한다고 정하므로, 이 디코더를 표준을 완전히 따르는 디코더로 보아서는 안 됩니다. 트레일러는 명세상 버려도 되지만 이 디코더는 아예 받지 않습니다. 그리고 청크를 모두 합친 크기에도 본문 상한 1 MiB를 적용합니다. 청크 하나하나는 작아도 개수가 많으면 결국 메모리를 다 쓸 수 있기 때문입니다.

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) {
                if (bytes.size() == 0) {
                    return null;
                }
                throw new EOFException("chunk line truncated");
            }
            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");
    }

    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);
    }
}

main의 입력에는 청크가 세 개 있습니다. 4와 Wiki, 5와 pedia, 그리고 7과 공백 하나에 이어지는 한글입니다. 마지막 청크의 길이가 7인 것은 공백 1바이트와 한글 6바이트를 더했기 때문입니다. 아래 그림은 디코더가 청크를 하나 읽을 때마다 본문 길이가 4, 9, 16으로 늘어나는 과정을 보여 줍니다. 길이 줄과 CRLF는 입력에는 있지만 본문에는 들어가지 않습니다.

청크의 길이 줄과 구분 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바이트
  • 종료 표식 자체는 본문 데이터가 아니다.

실행 결과:

Wikipedia 한글
bytes=16

bytes=16은 데이터만 더한 값이고, 입력 전체는 길이 줄과 CRLF까지 포함해 36바이트입니다. 디코더는 청크 데이터를 읽을 때마다 expectCrlf로 바로 뒤에 CRLF가 있는지 확인하므로, 선언한 길이가 실제 데이터와 다르면 대개 chunk CRLF missing으로 드러납니다. 하지만 잘못 적힌 길이가 다음 요청의 바이트까지 삼키고 그 끝에 우연히 CRLF가 있다면, 디코더 혼자서는 그 사실을 알 수 없습니다.

청크 길이도 문법상 16진 숫자로만 이루어져야 합니다. 그런데 Integer.parseInt(sizeLine, 16)은 +4처럼 부호가 붙은 값도 받아들입니다. -1처럼 음수가 되는 값은 size < 0 검사에서 걸러지지만, 부호를 아예 받지 않으려면 글자를 하나씩 검사해야 합니다. 반대로 int 범위를 넘는 16진수는 parseInt가 NumberFormatException을 던지므로 정수 넘침이 일어나지 않습니다. 명세도 수신자가 큰 16진수 때문에 넘침 오류를 일으키지 않도록 대비하라고 요구합니다.

이 디코더는 아직 BoundedHttpRequestReader와 연결되어 있지 않습니다. 실제 서버라면 판독기가 Transfer-Encoding: chunked를 만났을 때 거절하는 대신, 같은 입력 스트림을 이 디코더에 넘겨 본문을 읽게 해야 합니다.

응답을 쓸 때의 본문 길이 규칙

요청을 읽으며 본 규칙은 응답을 쓸 때도 그대로 적용됩니다. 클라이언트도 응답이 어디서 끝나는지 알아야 같은 연결로 다음 요청을 보낼 수 있기 때문입니다. 응답 본문이 이미 메모리에 다 있다면 Content-Length가 가장 단순합니다. 본문을 만들면서 바로 보내야 하는 긴 응답에는 청크 형식을 쓸 수 있는데, 청크를 너무 잘게 나눠 자주 보내면 길이 줄과 CRLF가 늘어 네트워크 효율이 떨어집니다. 파일처럼 길이를 미리 알 수 있다면 Content-Length를 쓰는 편이 낫습니다.

응답에는 요청에 없는 규칙이 하나 더 있습니다. 상태 코드와 요청 메서드에 따라 본문을 아예 보내지 않아야 하는 경우입니다. RFC 9110에 따르면 1xx, 204(No Content), 304(Not Modified) 응답과 HEAD 요청에 대한 응답에는 본문이 없습니다. 이때 Content-Length도 마음대로 쓸 수 없습니다. HEAD에 대한 응답에 Content-Length를 넣는다면 같은 요청을 GET으로 보냈을 때의 본문 길이여야 하고, 304에 넣는다면 200 응답이었을 때의 본문 길이여야 합니다. 1xx와 204 응답에는 Content-Length를 아예 보내면 안 됩니다.

이런 규칙을 서비스 코드마다 따로 지키게 하면 언젠가 하나는 틀립니다. 그래서 서비스는 상태, 헤더, 본문을 값으로만 만들고, 그 값을 바이트로 바꾸는 일은 응답 기록기(response writer) 한곳에서 한 번만 하게 하는 것이 좋습니다. 그러면 Content-Length를 실제 본문 길이와 맞추는 일, 204 응답에 본문을 붙이지 않는 일, Content-Length와 Transfer-Encoding이 함께 들어가지 않게 막는 일을 기록기 하나에서 처리할 수 있습니다. 이런 기록기는 연습 문제 3에서 직접 만들어 봅니다.

이렇게 나눠 두면 HTTP/2나 HTTP/3로 옮겨 갈 때도 유리합니다. 두 프로토콜은 본문의 끝을 Content-Length나 청크가 아니라 자체 프레임으로 알리므로, 서비스가 만든 응답 값은 그대로 두고 기록기만 바꾸면 됩니다.

판독기를 시험할 입력

직접 만든 판독기와 디코더는 적어도 다음 입력으로 확인해 보세요.

  • Content-Length 방식: 길이 0, 정확한 길이, 선언보다 짧은 본문, 음수, 숫자가 아닌 값, 상한을 넘는 값.
  • 프레이밍 헤더 충돌: Content-Length 반복, Transfer-Encoding과의 동시 사용. 둘 다 거절되고 연결을 다시 쓰지 않아야 합니다.
  • 청크 방식: 청크 여러 개, 대문자 16진수 길이(1A), 0 청크만 있는 본문, 데이터 뒤 CRLF 누락, 합계가 상한을 넘는 입력, 청크 확장과 트레일러.
  • 연속 요청: 한글 본문이 있는 POST 뒤에 GET을 이어 붙여 같은 입력에서 두 번 읽기.
  • 응답: 본문의 UTF-8 바이트 길이, HEAD와 204·304 응답의 본문 생략.

정리

  • TCP는 바이트의 흐름일 뿐이므로, HTTP/1.1은 Content-Length나 청크 형식으로 본문이 어디서 끝나는지 알립니다. 둘 다 없는 요청의 본문은 0바이트입니다.
  • 헤더와 본문은 같은 바이트 입력 하나에서 읽어야 합니다. BufferedReader로 헤더를 읽고 원래 스트림에서 본문을 읽으면, 리더가 미리 가져간 본문이 사라집니다.
  • Content-Length는 글자 수가 아니라 바이트 수입니다. 선언된 길이만큼 정확히 읽어야 다음 요청이 올바른 위치에서 시작합니다.
  • Content-Length와 Transfer-Encoding이 함께 오거나 Content-Length가 서로 다른 값으로 반복되면 거절하고 연결을 닫습니다. 프로그램마다 요청의 끝을 다르게 판단하면 요청 스머글링으로 이어지기 때문입니다.
  • 청크 본문은 데이터만 이어 붙이고 길이 줄과 CRLF는 버립니다. HTTP/1.1 서버는 청크 본문을 반드시 해석할 수 있어야 합니다.
  • 응답은 기록기 한곳에서 바이트로 만들고, HEAD 요청과 1xx, 204, 304 응답에는 본문을 쓰지 않습니다.

연습 문제

1. 청크 입력의 바이트 수

ChunkedBodyDecoder.read에 3\r\n한\r\n1\r\n!\r\n0\r\n\r\n을 UTF-8 바이트로 넘깁니다. 복원된 본문, 본문의 바이트 수, 그리고 청크 형식을 포함한 입력 전체의 바이트 수를 계산하세요.

해설

본문은 한!이고, 한글 3바이트와 느낌표 1바이트를 더해 4바이트입니다. 입력 쪽을 세면 첫 청크는 길이 줄 3\r\n 3바이트, 데이터 3바이트, 끝의 CRLF 2바이트를 합해 8바이트입니다. 둘째 청크는 같은 방식으로 3 + 1 + 2 = 6바이트이고, 마지막 청크 0\r\n과 빈 트레일러 줄 \r\n이 5바이트입니다. 그래서 입력 전체는 8 + 6 + 5 = 19바이트입니다.

실행해 보면 디코더는 4바이트짜리 본문을 돌려줍니다. 19는 전송된 바이트 수일 뿐 본문 길이가 아니므로, 이 본문을 다른 곳에 Content-Length로 다시 보낸다면 4를 적어야 합니다.

2. Content-Length를 1 크게 적으면

BoundedHttpRequestReader의 main에서 "Content-Length: " + body.length를 "Content-Length: " + (body.length + 1)로 바꾸면 출력이 어떻게 달라질까요? 실행하기 전에 첫 요청의 본문, 첫 요청 뒤 위치, 두 번째 요청의 메서드를 예상해 보세요.

해설

선언한 길이가 7이 되므로 판독기는 97번부터 7바이트를 읽습니다. 한글 6바이트 다음 바이트는 다음 요청의 첫 글자 G이므로 첫 요청의 본문은 한글G가 되고, 위치는 104가 됩니다. 두 번째 read는 104번의 E부터 읽기 시작해 시작 줄을 ET /next HTTP/1.1로 읽습니다. 실제 출력은 다음과 같습니다.

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

눈여겨볼 것은 예외가 하나도 나지 않았다는 점입니다. 이 판독기는 시작 줄이 세 조각인지만 검사하므로 ET라는 메서드를 그대로 받아들였습니다. 반대로 길이를 2로 줄이면 첫 본문이 한의 UTF-8 바이트 중간에서 잘리고, 다음 시작 줄은 남은 네 바이트가 깨진 문자로 GET 앞에 붙은 모양이 되는데 이것도 거절되지 않습니다.

서버가 구현한 메서드만 받도록 검사했다면 ET는 501(Not Implemented)로 거절되었을 것이고, 깨진 바이트가 붙은 시작 줄은 메서드 문법 검사에서 걸렸을 것입니다. 다만 그렇게 해도 이미 처리한 첫 요청의 본문이 틀렸다는 사실은 되돌릴 수 없습니다. 문법 검사는 어긋남을 빨리 발견하게 해 줄 뿐이고, 길이를 정확히 적는 것은 보내는 쪽의 몫입니다.

3. 상태와 메서드에 맞는 응답 기록기

응답 상태, Content-Type, 본문 바이트, 요청 메서드를 받아 HTTP/1.1 응답 바이트 배열을 만드는 write 메서드를 작성하세요. HEAD 요청과 204·304 응답에는 본문을 쓰지 않습니다. HEAD의 Content-Length는 같은 요청이 GET이었다면 보냈을 본문 길이를 나타내고, 204에는 Content-Length 자체를 넣지 않습니다.

풀이: 상태와 요청 메서드를 함께 보기

기록기가 호출될 때 본문 바이트는 이미 완성되어 있다고 가정합니다. 이유 문구(reason phrase)는 허용한 상태 코드 표에서만 가져오고, 헤더 문자열은 US-ASCII로 인코딩합니다. 연결 정책은 예제를 단순하게 하려고 Connection: 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));
    }
}

실행 결과:

GET bytes=104
HTTP/1.1 200 OK
Content-Type: text/plain; charset=UTF-8
Content-Length: 6
Connection: close


HTTP/1.1 204 No Content
Connection: close

GET 응답은 헤더 98바이트와 본문 6바이트를 합쳐 104바이트입니다. HEAD 응답은 같은 Content-Length: 6을 알리지만 본문 없이 헤더 끝의 빈 줄에서 끝납니다. 출력에 빈 줄이 두 번 보이는 것은 헤더 끝의 빈 줄 다음에 println이 줄바꿈을 하나 더 넣었기 때문입니다. 204 응답에는 Content-Type과 Content-Length가 모두 없습니다. 이 기록기는 304에서도 두 헤더를 모두 뺍니다. 304에 Content-Length를 넣는 것 자체는 명세상 허용되지만 넣는다면 200 응답의 본문 길이와 같아야 하므로, 이 예제는 생략하는 쪽을 택했습니다.

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

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 같은 제어 문자를 거절해야 합니다. 값에 CRLF가 들어 있으면 공격자가 헤더를 추가하거나 응답을 둘로 나눌 수 있는데(응답 분할, response splitting), CR과 LF도 ASCII 문자이므로 US-ASCII로 인코딩한다고 이를 막을 수는 없습니다.

더 해 볼 만한 확장으로는 1xx 응답과 CONNECT 요청에 대한 성공 응답의 규칙을 추가하는 것이 있습니다. 둘 다 Content-Length를 보내면 안 됩니다. 또 애플리케이션이 Transfer-Encoding과 Content-Length를 동시에 넣을 수 없도록 헤더 API를 제한해 보세요.

다음 글

다음 글에서는 이렇게 읽고 쓰는 요청과 응답을 바탕으로, 경로마다 처리 코드를 등록하는 서블릿(Servlet) 방식의 라우터와 404·500 오류 처리를 살펴봅니다. 이 글에서 남겨 둔 문법 검사와 연결 수명 처리는 판독기를 실제 서버에 합칠 때 함께 다뤄야 할 과제입니다.