본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
27장 : HTTP와 웹 서버

HTTP 요청 프레이밍

바이트 수준 요청 판독기로 시작 줄과 헤더 상한을 적용하고 콘텐츠 길이 충돌과 청크 본문을 엄격히 해석하며 상태별 응답 프레이밍을 완성합니다.

HTTP 요청 객체가 편리하려면 그 앞의 전송 형식 판독기가 메시지 경계를 정확히 보장해야 합니다. 시작 줄과 헤더는 줄처럼 보이지만 본문은 임의 바이트일 수 있습니다. 문자 BufferedReader로 헤더를 읽은 뒤 원래 InputStream에서 본문을 읽으면 판독기가 미리 가져간 바이트 때문에 본문이 사라질 수 있습니다. 하나의 바이트 수준 입력기가 줄과 본문을 모두 소비해야 합니다.

판독기는 정상 문법만 구현하는 도구가 아닙니다. 시작 줄 길이, 헤더 한 줄 길이, 헤더 개수와 전체 크기, 본문 최대 크기, 읽기 시간 제한을 적용하는 자원 방어선입니다. Content-LengthTransfer-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가 없는 입력을 거절하고, 각 줄 4096바이트와 전체 헤더 16384바이트를 상한으로 둡니다. 헤더는 첫 콜론에서 나누며 이름을 소문자로 정규화합니다. 본문은 현재 예제에서 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";
        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());
    }
}

조건 표현식 안에서 예외를 던지기 위해 throwTruncatedLine 보조 메서드를 사용했지만 가독성을 우선한다면 EOF 분기를 일반 if 블록으로 펼치는 편이 낫습니다. 핵심은 EOF 직전 일부 바이트를 정상 줄로 반환하지 않는 것입니다. 불완전한 요청을 처리하면 공격자가 불완전 헤더를 정상 요청처럼 만들 수 있습니다.

이 판독기는 Transfer-Encoding을 명시적으로 거절합니다. 지원하지 않는 기능을 조용히 무시하는 것보다 501 또는 400으로 실패시키고 연결을 닫는 편이 안전합니다. chunked를 지원하려면 헤더 뒤 청크 문법을 같은 입력에서 이어 읽습니다.

청크 프레이밍 검증

chunked 본문은 16진수 크기 줄, 그만큼의 데이터, CRLF가 반복되고 크기 0인 청크와 빈 트레일러 줄로 끝납니다. 청크 확장과 트레일러를 지원하지 않는 작은 구현은 문법을 모호하게 처리하지 말고 명확히 거절할 수 있습니다. 모든 청크 합계에는 전체 본문 상한을 적용합니다.

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

세 번째 청크의 크기 7은 공백 한 바이트와 한글 두 글자의 UTF-8 여섯 바이트를 더한 값입니다. 청크 크기는 문자 수가 아니라 데이터 바이트 수이기 때문에 테스트 전송 형식을 만들 때도 전송할 배열을 기준으로 계산합니다. 청크 합계와 CRLF를 각각 검증하면 다음 요청의 시작 줄을 본문으로 삼키는 것을 막습니다.

응답 직렬화 불변식

서비스 코드는 상태, 헤더, 본문을 값으로 구성하고, 응답 기록기가 한 번만 직렬화합니다. 여러 서블릿이 직접 시작 줄과 Content-Length를 쓰게 하면 규칙이 중복되고 상태 코드에 맞지 않는 본문이 붙습니다. 기록기는 홉 단위 헤더와 프레이밍 헤더를 애플리케이션이 임의로 충돌시키지 못하게 통제합니다.

응답 본문이 미리 메모리에 있으면 Content-Length가 가장 단순합니다. 큰 스트리밍 응답은 chunked를 사용할 수 있지만 각 청크 플러시가 지나치게 작으면 네트워크 효율이 떨어집니다. 이미 압축 또는 파일 길이를 아는 경우 고정 길이가 낫습니다. HTTP/2와 HTTP/3는 다른 프레이밍을 사용하므로 애플리케이션 응답 모델과 HTTP/1.1 전송 형식 기록기를 분리해 두면 교체가 쉽습니다.

연습 문제

응답 상태, Content-Type, 본문 바이트와 요청 메서드를 받아 HTTP/1.1 바이트 배열을 만듭니다. HEAD 요청과 204·304 상태에서는 본문을 쓰지 않습니다. HEADContent-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));
    }
}

HEAD 출력에는 실제 본문이 없지만 Content-Length: 6이 남습니다. 204 출력에는 표현 헤더와 본문이 모두 없습니다. 확장 과제로 1xx 상태와 CONNECT 성공 응답의 특수 규칙을 추가하고, 애플리케이션이 Transfer-EncodingContent-Length를 동시에 주입하지 못하게 헤더 API를 제한합니다.

프레이밍 검증

고정 길이 요청은 0, 정확한 길이, 본문 부족, 음수, 숫자 아님, 상한 초과를 검증합니다. 반복 Content-LengthTransfer-Encoding 동시 존재는 연결 재사용 없이 실패해야 합니다. chunked 테스트에는 여러 청크, 대문자 16진수, 0 청크, 빠진 데이터 CRLF, 너무 큰 합계, 지원하지 않는 확장과 트레일러를 넣습니다.

정상 본문 직후에 두 번째 요청 시작 줄을 붙여 판독기를 두 번 호출했을 때 각각 정확한 메시지가 나와야 합니다. 응답은 UTF-8 바이트 길이와 상태별 본문 규칙을 확인합니다. 다음 문서에서는 이 안정된 요청과 응답 객체 위에 경로별 서블릿을 등록하고, 404와 500을 서로 다른 오류 경계로 변환하는 작은 웹 애플리케이션 서버 구조를 완성합니다.