본문으로 건너뛰기

안동민 개발노트

본문 시작

서블릿 요청 입력

실제 embedded Tomcat에서 쿼리·폼·JSON·multipart 입력의 소유권, 본문 스트림의 일회성, 문자 인코딩, 미디어 타입, 전달 헤더와 계층별 크기 제한을 검증합니다.

HTTP 요청 하나에는 URI의 쿼리, 헤더, 본문 바이트와 직접 연결한 피어 정보가 함께 들어옵니다.

그러나 Servlet API가 이 입력을 모두 같은 자료구조로 바꾸지는 않습니다.

컨테이너가 소유하는 파라미터 파서와 애플리케이션이 소유하는 본문 파서를 구분하지 않으면, JSON을 getParameter()로 찾거나 이미 소비한 폼 본문을 다시 읽는 오류가 생깁니다.

HTTP 요청의 값 위치와 Content-Type 매개변수, charset 계약을 먼저 확인한 뒤 query와 urlencoded form은 getParameter 계열로, multipart는 getParts로, JSON과 명시적으로 지원하는 application/*+json은 reader나 input stream 중 하나와 단일 body parser로 읽습니다. 각 경로가 만든 typed draft는 하나의 validation과 command 경계를 통과합니다.

REQUEST SOURCE · CONTENT-TYPE PARAMETERS · DECODE · VALIDATE

입력 위치와 media type이 Servlet 읽기 API를 결정한다

field 이름이 같아도 먼저 어디에 실렸는지 구분합니다. query와 form의 charset 계약은 서로 다르고, getParameter*는 form body를 파싱하며 소비할 수 있습니다. JSON body는 getReader()getInputStream() 중 하나만 골라 한 번만 decode합니다.

HTTP 입력 소스별 Servlet 읽기와 단일 검증 경계 HTTP 요청에서 값의 위치, Content-Type 매개변수와 charset을 확인한 뒤 query, urlencoded form, multipart, JSON의 네 경로로 나눕니다. 각 경로는 올바른 Servlet API 하나로 decode하여 typed draft를 만들고, 모든 draft는 하나의 validation과 command 경계로 합류합니다. QUERY FORM MULTIPART JSON HTTP REQUEST 값의 실제 source부터 확인 DECLARED REPRESENTATION CONTRACT source + media type + parameters + charset Content-Type의 charset · boundary를 버리지 말고 정책과 함께 판정 URI QUERY getParameter*() connector의 query decode 정책 request.setCharacterEncoding과 별개 URI의 percent-encoded field name → value / values / map URLENCODED FORM getParameter*() application/x-www-form-urlencoded charset 또는 setCharacterEncoding parameter 접근 전에 charset 확정 접근 시 body 파싱 · 소비 가능 MULTIPART FORM getParts() · getPart() multipart/form-data boundary parameter 필수 multipart 설정과 limit 적용 text part와 file part를 분리 JSON BODY reader XOR input stream application/json; charset=UTF-8 지원 정책: application/*+json getReader 또는 getInputStream 한 body parser로 한 번만 decode DECODED TYPED DRAFT raw field → typed value source별 decode 완료 · 아직 command 아님 ONE VALIDATION / COMMAND BOUNDARY 검증된 command만 실행 계층으로 전달 required · range · enum · cross-field 규칙을 한 번 적용 decode 오류와 validation 오류를 경계에서 명시적으로 반환 READ CONTRACT source-specific decode single validation / command boundary

01 · DECLARED CONTRACT

field 이름보다 source와 Content-Type 매개변수를 먼저 본다

URI query인지 body인지 구분하고 media type뿐 아니라 charset과 multipart boundary도 보존합니다. 선언과 지원 정책이 맞지 않으면 decode 전에 거절합니다.

02 · PARAMETER ROUTES

query charset과 urlencoded form charset은 같은 설정이 아니다

Query decode는 connector의 URI 정책을 따릅니다. Form은 charset 또는 setCharacterEncodinggetParameter* 전에 확정하며, 이 접근이 body를 파싱하고 소비할 수 있습니다.

03 · PARTS OR ONE BODY PARSER

multipart는 parts로, JSON은 reader와 stream 중 하나로 읽는다

multipart/form-data는 boundary와 설정을 확인한 뒤 getParts()를 사용합니다. JSON은 application/json과 명시적으로 지원하는 application/*+json만 받아 한 parser로 한 번 decode합니다.

04 · VALIDATE ONCE

모든 경로의 typed draft를 하나의 command 경계로 모은다

source별 decode가 끝난 값에 required, range, enum, cross-field 규칙을 한 번 적용합니다. 검증된 command만 실행 계층으로 전달하고 decode 오류와 validation 오류를 경계에서 구분합니다.

Content-Type 비교는 parameters를 제거한 media type만 보지 말고, decode에 필요한 charsetboundary를 함께 보존해야 합니다. application/*+json 허용 여부도 추측하지 않고 애플리케이션 정책으로 명시합니다.


입력 형식마다 읽는 주체가 다릅니다

ServletRequest.getParameter 계열은 HTTP 쿼리와 POST application/x-www-form-urlencoded 폼을 제공합니다.

같은 이름에 값이 여러 개면 getParameter()는 첫 값만 반환하므로, 다중 값 계약에는 getParameterValues() 또는 getParameterMap()을 사용합니다.

파라미터 접근은 아직 파싱하지 않은 쿼리와 폼 본문을 컨테이너가 파싱하게 만들 수 있습니다. 따라서 폼 파라미터를 읽은 뒤 원시 본문도 온전히 남아 있으리라 가정하면 안 됩니다.

multipart/form-data는 Servlet 등록에 MultipartConfig가 있을 때 컨테이너가 part를 제공합니다. 텍스트 part는 파라미터 API에서 보일 수 있고 파일은 getPartgetParts로 소유권을 명확히 드러냅니다.

반면 application/json은 자동으로 파라미터 맵이 되지 않습니다. 애플리케이션이나 Spring MVC HttpMessageConverter가 요청 본문을 한 번 읽어 JSON 객체로 변환합니다.

입력 위치·형식일차 소유자읽는 API
URI 쿼리Servlet 컨테이너getParameter*, getQueryString
POST application/x-www-form-urlencodedServlet 컨테이너getParameter*
구성된 multipart/form-dataServlet 컨테이너getParameter*, getPart, getParts
JSON·XML·임의 바이너리 본문애플리케이션 또는 변환기getInputStream 또는 getReader 뒤 전용 파서

Spring의 MockHttpServletRequest.addParameter()는 이 컨테이너 파싱을 재현하지 않습니다. 테스트 작성자가 이미 파싱됐다고 가정한 값을 mock의 파라미터 맵에 직접 넣는 설정 API입니다.

실제 퍼센트 디코딩, 파라미터 결합, 소비 시점과 크기 제한은 loopback HTTP로 컨테이너를 통과시켜 별도로 검증해야 합니다.


본문은 한 경로로 한 번 읽습니다

getInputStream()은 본문을 바이트로 읽고 getReader()는 선택된 문자 인코딩으로 디코딩합니다.

한 요청에서는 둘 중 하나만 호출할 수 있습니다. 리더를 얻은 뒤 스트림을 얻거나 스트림을 얻은 뒤 리더를 얻으면 IllegalStateException입니다.

이 규칙과 별개로 파라미터 파싱도 폼 본문을 소비할 수 있습니다. 이 fixture의 Tomcat에서는 getParameter() 호출 뒤 getInputStream()에 0바이트가 남습니다. Servlet 계약은 두 접근이 간섭할 수 있다고 규정하므로, 다른 컨테이너에서도 오류나 빈 스트림의 구체 형태에 의존하지 않고 원문이 재생되지 않는다고 설계합니다.

필터가 로깅을 위해 먼저 readAllBytes()를 호출하면 downstream Servlet이나 메시지 컨버터가 읽을 데이터도 사라집니다. 본문을 여러 소비자에게 전달해야 한다면 소유권과 재생 정책을 명시한 별도 래퍼가 필요합니다.


문자 인코딩은 파싱 전에 결정합니다

setCharacterEncoding은 파라미터를 읽거나 getReader()를 얻기 전에 호출해야 합니다. 그 뒤의 변경은 효력이 없습니다.

이 fixture의 폼 경로는 UTF-8을 먼저 설정한 뒤 %EC%9A%94%EC%B2%AD요청으로 디코딩합니다.

쿼리 문자열의 디코딩 설정과 본문 문자 인코딩 설정은 컨테이너에서 서로 다른 옵션일 수 있습니다. 둘을 하나의 setCharacterEncoding 호출이 모두 해결한다고 일반화하지 않습니다.

JSON은 기본적으로 바이트 스트림을 파서에 넘깁니다. 미디어 타입에 charset이 있더라도 지원 문자 집합과 디코딩 정책을 파서·프레임워크 계약으로 고정해야 합니다.


미디어 타입은 문자열이 아니라 구조로 판정합니다

Content-Typeapplication/json;charset=UTF-8처럼 파라미터를 포함할 수 있으므로 "application/json".equals(request.getContentType())는 정상 요청을 거부합니다.

Spring MediaType으로 타입과 서브타입을 파싱한 뒤 지원 정책을 적용합니다.

이 단원의 정책은 다음과 같습니다.

  • application/json과 그 파라미터 조합을 허용합니다.
  • application/problem+json, application/vnd.board+json처럼 서브타입 접미사가 +jsonapplication/*도 허용합니다.
  • text/json, 깨진 미디어 타입, 타입이 없는 본문은 거부합니다.
  • 허용된 타입이어도 malformed JSON은 별도의 400 Bad Request입니다.

Spring MVC에서는 consumes 조건과 HttpMessageConverter가 같은 종류의 선택과 역직렬화를 담당합니다. 원시 Servlet에서는 이 경계를 직접 구현해야 합니다.

신뢰 가능한 프록시가 forwarded 메타데이터를 정규화한 뒤 Servlet 컨테이너가 크기·문자 인코딩·미디어 타입을 검사합니다. ContentCachingRequestWrapper는 본문을 미리 읽지 않고 선택된 parser가 원본 스트림을 한 번 소비할 때 읽힌 바이트만 제한적으로 저장합니다. 검증과 필터 체인이 끝난 뒤 로깅은 그 cache의 제한된 사본을 마스킹해 관찰하며, 잘림 여부는 별도 정책으로 추적합니다.

TRUST → GATES → ONE READ → BOUNDED OBSERVATION

신뢰를 정규화한 뒤 한 parser만 본문을 읽고, cache는 chain 뒤에 본다

본문 소비자와 관찰자를 분리합니다. 원시 X-Forwarded-* 값은 edge가 다시 쓴 뒤에만 신뢰하고, downstream parser 하나가 원본 스트림을 소비하게 둡니다. 캐싱 래퍼는 그 읽기를 따라 제한된 바이트만 모으며, 로깅은 doFilter가 돌아온 뒤 cache를 읽습니다.

Servlet 요청 입력의 신뢰·파싱·로깅 swimlane 신뢰 edge, Servlet 컨테이너, 캐싱 래퍼와 선택된 parser, 검증과 관찰의 네 lane에서 forwarded 메타데이터 정규화, 크기·인코딩·미디어 타입 gate, 원본 본문 스트림의 단일 소비, 검증 실패, chain 이후 제한 로깅을 순서대로 보여 줍니다. SIZE + CHARSET ROUTE READ ONCE COMMAND AFTER CHAIN Forwarded metadata allowlist · overwrite normalized → trusted 400 · malformed chain Size · encoding gate proxy + container limits 413 · body too large 400 · unsupported charset Media type gate parsed Content-Type → parser 415 · unsupported type ContentCaching RequestWrapper downstream read만 cache에 누적 limit=8KiB · eager read 없음 선택된 body parser original input stream 1회 소비 400 · JSON syntax / field type Command validation schema → business constraint 400 · constraint violation PASS · handler / 2xx Bounded logging ≤8KiB · mask · 별도 잘림 추적 01 · TRUSTED EDGE 프록시 신뢰 정규화 02 · SERVLET CONTAINER 입력 전제와 route 03 · WRAPPER / PARSER 한 번 읽는 body 경계 04 · VALIDATION / OBSERVABILITY 업무 검증과 사후 관찰 NORMALIZED METADATA ONLY

01 · TRUSTED EDGE

forwarded 메타데이터는 신뢰 edge가 정규화한 뒤에만 믿는다

신뢰할 프록시의 직접 연결만 허용하고, 외부가 보낸 X-Forwarded-* 값을 제거한 뒤 edge가 다시 씁니다. 정규화 전의 사용자 헤더를 IP 제한이나 감사 주체로 사용하지 않습니다.

02 · TRANSPORT & MEDIA GATES

크기·인코딩·미디어 타입 실패를 서로 다른 응답으로 좁힌다

proxy와 컨테이너 한도를 넘으면 413, 지원하지 않는 문자 집합이나 잘못된 인코딩이면 400, 선택할 parser가 없는 미디어 타입이면 415로 구분합니다.

03 · ONE BODY READER

선택된 parser 하나가 원본 stream을 읽고 wrapper는 그 읽기만 cache한다

ContentCachingRequestWrapper는 생성 시 본문을 미리 복제하지 않습니다. downstream parser가 getInputStream()을 한 번 소비하는 동안 읽힌 바이트만 상한까지 저장하며, JSON 문법·필드 타입 실패는 400입니다.

04 · VALIDATE & OBSERVE

command 검증 뒤 chain이 돌아오면 제한된 cache만 기록한다

업무 constraint 실패는 별도의 400으로 남깁니다. 로깅은 chain 이후 cache를 읽고 최대 바이트 수와 민감 필드 마스킹을 적용합니다. 잘림 여부는 wrapper가 자동으로 알려 준다고 가정하지 않고 별도 정책으로 추적하며, 소비되지 않은 원본 전체를 자동으로 얻는다고 가정하지 않습니다.

실패 응답은 예시 정책입니다. 실제 배포에서는 proxy·컨테이너·메시지 converter의 상한과 예외 매핑을 같은 계약으로 맞추고, 인증 헤더·쿠키· 비밀번호·전체 JSON 본문은 기본 로그에서 제외합니다.


실제 embedded Tomcat에서 경계를 검증합니다

다음 한 파일은 production 규칙의 작은 예제와 검증 fixture를 함께 둔 독립 compilation unit입니다.

Boot 4.1.1이 임의 포트의 Tomcat 11.0.24를 열고 JDK HttpClient127.0.0.1로 실제 HTTP/1.1 요청을 보냅니다. Spring mock은 addParameter()가 네트워크 파서가 아니라는 부정 증명에만 사용합니다.

src/test/java/board/servlet/EmbeddedServletRequestInputTest.java
package board.servlet;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.MultipartConfigElement;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.Test;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.web.server.servlet.context
        .ServletWebServerApplicationContext;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.http.InvalidMediaTypeException;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.web.util.ContentCachingRequestWrapper;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.json.JsonMapper;
class EmbeddedServletRequestInputTest {
    private static final HttpClient CLIENT = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(5))
            .version(HttpClient.Version.HTTP_1_1)
            .build();
    private static final String JSON_BODY =
            "{\"title\":\"HTTP\",\"content\":\"HTTP 본문\"}";
    @Test
    void addParameter는_mock_설정이지_본문_파싱이_아니다()
            throws Exception {
        var request = new MockHttpServletRequest(
                "POST",
                "/raw/input/form");
        request.setContentType("application/x-www-form-urlencoded");
        request.setContent("title=wire".getBytes(UTF_8));
        assertNull(request.getParameter("title"));
        request.addParameter("title", "preset");
        assertAll(
                () -> assertEquals(
                        "preset",
                        request.getParameter("title")),
                () -> assertEquals(
                        "title=wire",
                        new String(
                                request.getInputStream().readAllBytes(),
                                UTF_8)));
    }
    @Test
    void 실제_query_form_JSON은_서로_다른_입력_소유권을_보인다()
            throws Exception {
        assertEquals(40, JSON_BODY.getBytes(UTF_8).length);
        try (var server = RunningServer.start()) {
            var query = send(
                    server,
                    "GET",
                    "/raw/input/query?title=HTTP&tag=network&tag=spring",
                    null,
                    null,
                    Map.of());
            var form = send(
                    server,
                    "POST",
                    "/raw/input/form",
                    "application/x-www-form-urlencoded",
                    "title=%EC%9A%94%EC%B2%AD&tag=one&tag=two"
                            .getBytes(UTF_8),
                    Map.of());
            var json = send(
                    server,
                    "POST",
                    "/raw/input/json?source=query",
                    "application/json;charset=UTF-8",
                    JSON_BODY.getBytes(UTF_8),
                    Map.of());
            assertAll(
                    () -> assertEquals(200, query.statusCode()),
                    () -> assertEquals(
                            "title=HTTP;tags=network,spring",
                            query.body()),
                    () -> assertEquals(200, form.statusCode()),
                    () -> assertEquals(
                            "encoding=UTF-8;title=요청;tags=one,two;remaining=0",
                            form.body()),
                    () -> assertEquals(201, json.statusCode()),
                    () -> assertEquals(
                            "query=query;parameterTitle=<null>;bytes=40;"
                                    + "title=HTTP;content=HTTP 본문",
                            json.body()));
        }
    }
    @Test
    void reader와_inputStream은_양방향으로_상호_배타적이다()
            throws Exception {
        try (var server = RunningServer.start()) {
            var readerFirst = send(
                    server,
                    "POST",
                    "/raw/input/reader-first",
                    "text/plain;charset=UTF-8",
                    "body".getBytes(UTF_8),
                    Map.of());
            var streamFirst = send(
                    server,
                    "POST",
                    "/raw/input/stream-first",
                    "text/plain;charset=UTF-8",
                    "body".getBytes(UTF_8),
                    Map.of());
            assertAll(
                    () -> assertEquals(200, readerFirst.statusCode()),
                    () -> assertEquals(
                            "reader->stream:IllegalStateException",
                            readerFirst.body()),
                    () -> assertEquals(200, streamFirst.statusCode()),
                    () -> assertEquals(
                            "stream->reader:IllegalStateException",
                            streamFirst.body()));
        }
    }
    @Test
    void JSON_정책은_파라미터와_application_plus_json을_구조적으로_판정한다()
            throws Exception {
        try (var server = RunningServer.start()) {
            var parameterized = send(
                    server,
                    "POST",
                    "/raw/input/json",
                    "application/json;charset=UTF-8",
                    JSON_BODY.getBytes(UTF_8),
                    Map.of());
            var suffix = send(
                    server,
                    "POST",
                    "/raw/input/json",
                    "application/problem+json;profile=error",
                    JSON_BODY.getBytes(UTF_8),
                    Map.of());
            var rejected = send(
                    server,
                    "POST",
                    "/raw/input/json",
                    "text/json",
                    JSON_BODY.getBytes(UTF_8),
                    Map.of());
            var malformed = send(
                    server,
                    "POST",
                    "/raw/input/json",
                    "application/json",
                    "{".getBytes(UTF_8),
                    Map.of());
            assertAll(
                    () -> assertEquals(201, parameterized.statusCode()),
                    () -> assertEquals(201, suffix.statusCode()),
                    () -> assertEquals(415, rejected.statusCode()),
                    () -> assertEquals(400, malformed.statusCode()));
        }
    }
    @Test
    void multipart는_구성된_part_파서와_파일_크기_게이트를_사용한다()
            throws Exception {
        try (var server = RunningServer.start()) {
            var accepted = multipart(
                    server,
                    "board-boundary-ok",
                    "12345678");
            var rejected = multipart(
                    server,
                    "board-boundary-large",
                    "123456789");
            assertAll(
                    () -> assertEquals(200, accepted.statusCode()),
                    () -> assertEquals(
                            "title=upload;file=a.txt;bytes=8;content=12345678",
                            accepted.body()),
                    () -> assertEquals(413, rejected.statusCode()));
        }
    }
    @Test
    void 직접_연결_피어와_사용자_전달_헤더는_같은_신뢰값이_아니다()
            throws Exception {
        try (var server = RunningServer.start()) {
            var response = send(
                    server,
                    "GET",
                    "/raw/input/peer",
                    null,
                    null,
                    Map.of("X-Forwarded-For", "203.0.113.9"));
            assertAll(
                    () -> assertEquals(200, response.statusCode()),
                    () -> assertEquals(
                            "remote=127.0.0.1;forwarded=203.0.113.9",
                            response.body()));
        }
    }
    @Test
    void 애플리케이션_게이트는_선언_길이와_chunked_스트림을_모두_제한한다()
            throws Exception {
        try (var server = RunningServer.start()) {
            var exact = send(
                    server,
                    "POST",
                    "/raw/input/sized",
                    "application/octet-stream",
                    new byte[64],
                    Map.of());
            var declaredTooLarge = send(
                    server,
                    "POST",
                    "/raw/input/sized",
                    "application/octet-stream",
                    new byte[65],
                    Map.of());
            var chunkedTooLarge = sendUnknownLength(
                    server,
                    "/raw/input/sized",
                    new byte[65]);
            assertAll(
                    () -> assertEquals(204, exact.statusCode()),
                    () -> assertEquals(413, declaredTooLarge.statusCode()),
                    () -> assertEquals(413, chunkedTooLarge.statusCode()));
        }
    }
    @Test
    void contentCachingWrapper는_downstream_소비_뒤_상한까지만_기록한다()
            throws Exception {
        assertEquals(40, JSON_BODY.getBytes(UTF_8).length);
        try (var server = RunningServer.start()) {
            var response = send(
                    server,
                    "POST",
                    "/raw/input/cache",
                    "application/json",
                    JSON_BODY.getBytes(UTF_8),
                    Map.of());
            var sample = server.probe().last.get();
            assertAll(
                    () -> assertEquals(200, response.statusCode()),
                    () -> assertEquals("downstreamBytes=40", response.body()),
                    () -> assertEquals(0, sample.beforeBytes()),
                    () -> assertEquals(16, sample.afterBytes()),
                    () -> assertEquals(40, sample.declaredBytes()));
        }
    }
    private static HttpResponse<String> multipart(
            RunningServer server,
            String boundary,
            String fileContent)
            throws Exception {
        var body = ("--" + boundary + "\r\n"
                        + "Content-Disposition: form-data; name=\"title\"\r\n"
                        + "\r\n"
                        + "upload\r\n"
                        + "--" + boundary + "\r\n"
                        + "Content-Disposition: form-data; name=\"file\"; "
                        + "filename=\"a.txt\"\r\n"
                        + "Content-Type: text/plain\r\n"
                        + "\r\n"
                        + fileContent + "\r\n"
                        + "--" + boundary + "--\r\n")
                .getBytes(UTF_8);
        return send(
                server,
                "POST",
                "/raw/input/multipart",
                "multipart/form-data; boundary=" + boundary,
                body,
                Map.of());
    }
    private static HttpResponse<String> send(
            RunningServer server,
            String method,
            String path,
            String contentType,
            byte[] body,
            Map<String, String> headers)
            throws IOException, InterruptedException {
        var builder = HttpRequest.newBuilder(
                        URI.create(server.origin() + path))
                .timeout(Duration.ofSeconds(5));
        if (contentType != null) builder.header("Content-Type", contentType);
        headers.forEach(builder::header);
        var publisher = body == null
                ? HttpRequest.BodyPublishers.noBody()
                : HttpRequest.BodyPublishers.ofByteArray(body);
        return CLIENT.send(
                builder.method(method, publisher).build(),
                HttpResponse.BodyHandlers.ofString(UTF_8));
    }
    private static HttpResponse<String> sendUnknownLength(
            RunningServer server,
            String path,
            byte[] body)
            throws IOException, InterruptedException {
        var request = HttpRequest.newBuilder(
                        URI.create(server.origin() + path))
                .timeout(Duration.ofSeconds(5))
                .header("Content-Type", "application/octet-stream")
                .POST(HttpRequest.BodyPublishers.ofInputStream(
                        () -> new ByteArrayInputStream(body)))
                .build();
        return CLIENT.send(
                request,
                HttpResponse.BodyHandlers.ofString(UTF_8));
    }
    private record RunningServer(
            ServletWebServerApplicationContext context,
            String origin,
            CacheProbe probe)
            implements AutoCloseable {
        static RunningServer start() {
            var probe = new CacheProbe();
            var application = new SpringApplication(TestApplication.class);
            application.setWebApplicationType(WebApplicationType.SERVLET);
            application.setRegisterShutdownHook(false);
            application.setDefaultProperties(Map.of(
                    "server.address", "127.0.0.1",
                    "server.port", "0",
                    "server.forward-headers-strategy", "none",
                    "spring.main.banner-mode", "off",
                    "logging.level.root", "OFF"));
            application.addInitializers(context -> context
                    .getBeanFactory()
                    .registerSingleton("cacheProbe", probe));
            var context = (ServletWebServerApplicationContext)
                    application.run();
            return new RunningServer(
                    context,
                    "http://127.0.0.1:"
                            + context.getWebServer().getPort(),
                    probe);
        }
        @Override
        public void close() {
            context.close();
        }
    }
    @SpringBootConfiguration(proxyBeanMethods = false)
    @EnableAutoConfiguration
    static class TestApplication {
        @Bean
        ServletRegistrationBean<RequestInputServlet> requestInputServlet() {
            var registration = new ServletRegistrationBean<>(
                    new RequestInputServlet(),
                    "/raw/input/*");
            registration.setName("requestInputServlet");
            registration.setMultipartConfig(
                    new MultipartConfigElement("", 8, 512, 0));
            return registration;
        }
        @Bean
        FilterRegistrationBean<BoundedCacheFilter> boundedCacheFilter(
                CacheProbe probe) {
            var registration = new FilterRegistrationBean<>(
                    new BoundedCacheFilter(probe));
            registration.setName("boundedCacheFilter");
            registration.addUrlPatterns("/raw/input/cache");
            registration.setOrder(1);
            return registration;
        }
    }
    static final class RequestInputServlet extends HttpServlet {
        private static final JsonMapper JSON = JsonMapper.builder().build();
        private static final int MAX_BODY_BYTES = 64;
        @Override
        protected void doGet(
                HttpServletRequest request,
                HttpServletResponse response)
                throws IOException {
            switch (request.getRequestURI()) {
                case "/raw/input/query" -> text(
                        response,
                        200,
                        "title=" + request.getParameter("title")
                                + ";tags="
                                + String.join(
                                        ",",
                                        request.getParameterValues("tag")));
                case "/raw/input/peer" -> text(
                        response,
                        200,
                        "remote=" + request.getRemoteAddr()
                                + ";forwarded="
                                + request.getHeader("X-Forwarded-For"));
                default -> response.sendError(404);
            }
        }
        @Override
        protected void doPost(
                HttpServletRequest request,
                HttpServletResponse response)
                throws IOException, ServletException {
            switch (request.getRequestURI()) {
                case "/raw/input/form" -> readForm(request, response);
                case "/raw/input/json" -> readJson(request, response);
                case "/raw/input/reader-first" -> readerFirst(
                        request,
                        response);
                case "/raw/input/stream-first" -> streamFirst(
                        request,
                        response);
                case "/raw/input/multipart" -> readMultipart(
                        request,
                        response);
                case "/raw/input/sized" -> readSized(request, response);
                case "/raw/input/cache" -> text(
                        response,
                        200,
                        "downstreamBytes="
                                + request.getInputStream()
                                        .readAllBytes()
                                        .length);
                default -> response.sendError(404);
            }
        }
        private static void readForm(
                HttpServletRequest request,
                HttpServletResponse response)
                throws IOException {
            request.setCharacterEncoding(UTF_8);
            var title = request.getParameter("title");
            var tags = request.getParameterValues("tag");
            var remaining = request.getInputStream().readAllBytes().length;
            text(
                    response,
                    200,
                    "encoding=" + request.getCharacterEncoding()
                            + ";title=" + title
                            + ";tags=" + String.join(",", tags)
                            + ";remaining=" + remaining);
        }
        private static void readJson(
                HttpServletRequest request,
                HttpServletResponse response)
                throws IOException {
            if (!supportsJson(request.getContentType())) {
                response.sendError(415, "unsupported content type");
                return;
            }
            var parameterTitle = request.getParameter("title");
            var querySource = request.getParameter("source");
            var body = request.getInputStream().readAllBytes();
            final CreatePostInput input;
            try {
                input = JSON.readValue(body, CreatePostInput.class);
            } catch (JacksonException exception) {
                response.sendError(400, "invalid JSON");
                return;
            }
            text(
                    response,
                    201,
                    "query=" + printable(querySource)
                            + ";parameterTitle="
                            + printable(parameterTitle)
                            + ";bytes=" + body.length
                            + ";title=" + input.title()
                            + ";content=" + input.content());
        }
        private static boolean supportsJson(String rawContentType) {
            if (rawContentType == null) return false;
            final MediaType mediaType;
            try {
                mediaType = MediaType.parseMediaType(rawContentType);
            } catch (InvalidMediaTypeException exception) {
                return false;
            }
            return "application".equals(mediaType.getType())
                    && ("json".equals(mediaType.getSubtype())
                            || "json".equals(
                                    mediaType.getSubtypeSuffix()));
        }
        private static void readerFirst(
                HttpServletRequest request,
                HttpServletResponse response)
                throws IOException {
            request.getReader();
            try {
                request.getInputStream();
                text(response, 500, "reader->stream:allowed");
            } catch (IllegalStateException expected) {
                text(
                        response,
                        200,
                        "reader->stream:IllegalStateException");
            }
        }
        private static void streamFirst(
                HttpServletRequest request,
                HttpServletResponse response)
                throws IOException {
            request.getInputStream();
            try {
                request.getReader();
                text(response, 500, "stream->reader:allowed");
            } catch (IllegalStateException expected) {
                text(
                        response,
                        200,
                        "stream->reader:IllegalStateException");
            }
        }
        private static void readMultipart(
                HttpServletRequest request,
                HttpServletResponse response)
                throws IOException, ServletException {
            try {
                var title = request.getParameter("title");
                var file = request.getPart("file");
                var bytes = file.getInputStream().readAllBytes();
                text(
                        response,
                        200,
                        "title=" + title
                                + ";file=" + file.getSubmittedFileName()
                                + ";bytes=" + bytes.length
                                + ";content=" + new String(bytes, UTF_8));
            } catch (IllegalStateException tooLarge) {
                response.reset();
                response.sendError(413, "multipart limit exceeded");
            }
        }
        private static void readSized(
                HttpServletRequest request,
                HttpServletResponse response)
                throws IOException {
            var declared = request.getContentLengthLong();
            if (declared > MAX_BODY_BYTES) {
                response.sendError(413, "declared body is too large");
                return;
            }
            var bytes = request.getInputStream()
                    .readNBytes(MAX_BODY_BYTES + 1);
            if (bytes.length > MAX_BODY_BYTES) {
                response.sendError(413, "streamed body is too large");
                return;
            }
            response.setStatus(204);
        }
        private static String printable(String value) {
            return value == null ? "<null>" : value;
        }
        private static void text(
                HttpServletResponse response,
                int status,
                String value)
                throws IOException {
            response.setStatus(status);
            response.setContentType("text/plain");
            response.setCharacterEncoding(UTF_8);
            response.getWriter().write(value);
        }
    }
    static final class BoundedCacheFilter implements Filter {
        private static final int CACHE_LIMIT = 16;
        private final CacheProbe probe;
        BoundedCacheFilter(CacheProbe probe) {
            this.probe = probe;
        }
        @Override
        public void doFilter(
                ServletRequest request,
                ServletResponse response,
                FilterChain chain)
                throws IOException, ServletException {
            var wrapped = new ContentCachingRequestWrapper(
                    (HttpServletRequest) request,
                    CACHE_LIMIT);
            var before = wrapped.getContentAsByteArray().length;
            try {
                chain.doFilter(wrapped, response);
            } finally {
                probe.last.set(new CacheSample(
                        before,
                        wrapped.getContentAsByteArray().length,
                        request.getContentLengthLong()));
            }
        }
    }
    static final class CacheProbe {
        final AtomicReference<CacheSample> last = new AtomicReference<>();
    }
    record CacheSample(
            int beforeBytes,
            int afterBytes,
            long declaredBytes) {}
    public record CreatePostInput(String title, String content) {}
}

여덟 테스트가 확인하는 범위는 다음과 같습니다.

  1. mock의 addParameter()는 원시 폼 본문을 파싱하지 않고 미리 정한 값만 추가합니다.
  2. 실제 Tomcat은 중복 쿼리 값과 UTF-8 폼을 파라미터로 만들고, 40바이트 JSON은 쿼리와 분리된 본문으로 남깁니다.
  3. getReader()getInputStream()은 어느 쪽을 먼저 호출해도 다른 쪽 호출을 막습니다.
  4. 미디어 타입 파라미터와 application/*+json은 허용하되 text/json415, malformed JSON은 400으로 거부합니다.
  5. 구성된 multipart 파서는 텍스트 필드와 파일 part를 제공하고 8바이트 파일 상한을 집행합니다.
  6. 직접 보낸 X-Forwarded-For는 loopback 피어 주소를 자동으로 신뢰 주소로 바꾸지 않습니다.
  7. 64바이트 애플리케이션 게이트는 알려진 Content-Length와 길이를 모르는 chunked 입력을 모두 제한합니다.
  8. 캐싱 래퍼는 downstream이 40바이트를 소비하기 전에는 0바이트이고, 소비 뒤에도 설정한 16바이트만 보관합니다.

이 테스트는 정상적인 embedded Tomcat 요청 경로를 증명합니다. 외부 프록시의 실제 제한이나 신뢰 헤더 재작성은 이 JVM 안에서 실행하지 않았으므로 배포 환경의 통합 테스트 대상입니다.


전달 헤더는 신뢰 경계에서 다시 써야 합니다

getRemoteAddr()는 요청 객체가 나타내는 원격 끝점입니다. 기본적으로 직접 연결한 클라이언트 또는 마지막 프록시이고, 컨테이너의 전달 헤더 처리 설정에 따라 달라질 수 있습니다.

사용자는 ForwardedX-Forwarded-* 헤더를 직접 보낼 수 있습니다. Spring Framework의 forwarded header 보안 지침처럼, 신뢰 경계의 프록시는 외부에서 들어온 두 헤더 계열을 모두 제거하고 자신이 관찰한 값으로 다시 써야 합니다.

그 전제가 성립한 뒤에만 Boot의 server.forward-headers-strategy로 컨테이너의 native 처리 또는 Spring Framework 처리를 선택합니다.

애플리케이션 코드가 원시 X-Forwarded-For 문자열의 첫 토큰을 곧바로 인증, 감사, 요청률 제한의 클라이언트 주소로 쓰면 안 됩니다.

이 단원의 loopback 테스트는 전략을 none으로 고정해 127.0.0.1 피어와 사용자가 넣은 203.0.113.9 헤더가 서로 다른 값임을 확인합니다. 실제 프록시 체인의 홉 수와 신뢰 CIDR은 배포 구성에서 검증해야 합니다.


크기 제한은 서로 다른 계층에 둡니다

하나의 제한이 모든 입력 경로를 보호하지 않습니다.

계층먼저 막는 대상예시 정책
edge proxy연결·전체 요청·헤더최대 요청 크기, 헤더 크기, 전송 시간
Servlet connectorform POST·파라미터 파싱form 크기, 파라미터 수, swallow 정책
multipart parser전체 multipart·개별 part·메모리/디스크 전환maxRequestSize, maxFileSize, threshold
endpoint parserJSON·XML·임의 스트림선언 길이 선검사 + 상한을 둔 실제 스트림 읽기
관측 로깅메모리에 남기는 복사본작은 cache limit, 타입 allowlist, 민감값 마스킹

MultipartConfigElement의 파일 상한과 요청 상한은 multipart에만 적용됩니다. JSON 상한을 대신하지 않습니다.

Boot 4.1.1의 MultipartAutoConfigurationMultipartPropertiesMultipartConfigElement로 연결합니다. spring.servlet.multipart.max-file-sizemax-request-size는 업로드 계층의 제한이며 connector와 JSON endpoint 제한을 대체하지 않습니다.

Content-Length는 빠른 거절에 유용하지만 없거나 거짓일 수 있습니다. 따라서 endpoint는 선언 길이를 확인한 뒤에도 limit + 1바이트까지만 읽어 초과를 판정해야 합니다.

반대로 로깅 cache limit은 요청 거절 정책이 아닙니다. downstream은 전체 40바이트를 읽는 동안 래퍼가 앞 16바이트만 복사할 수 있습니다.


본문 로깅은 체인 뒤에 제한된 결과만 봅니다

Spring ContentCachingRequestWrapper는 스트림이나 리더를 통해 실제로 읽힌 내용만 가로채 저장합니다.

래퍼 생성 자체는 본문을 읽지 않습니다. downstream이 본문을 소비하지 않으면 getContentAsByteArray()도 빈 배열입니다.

따라서 필터는 다음 순서를 지킵니다.

  1. 구체적인 작은 cache limit으로 요청을 감쌉니다.
  2. 본문을 미리 읽지 않고 필터 체인을 호출합니다.
  3. 체인이 반환된 뒤 실제 소비된 cache만 확인합니다.
  4. 콘텐츠 타입 allowlist, 길이 절단, 비밀번호·토큰·개인정보 마스킹을 거친 값만 기록합니다.

cache limit을 넘긴 원문이 자동으로 거부되거나 전체 본문이 안전하게 재생되는 것은 아닙니다. 업로드와 대용량 JSON을 통째로 로깅하지 않고 요청 ID, 타입, 선언 길이, 처리 결과 같은 메타데이터를 우선합니다.


연습 문제

fixture의 JSON 정책에 application/vnd.board+json과 콘텐츠 타입이 없는 본문을 추가해 각각 201415를 검증하세요.

그 다음 cache limit을 8바이트로 낮추되 downstream이 여전히 40바이트를 읽는지 확인하고, 기록용 preview에서는 content 필드를 남기지 않도록 바꾸세요.

마지막으로 실제 배포 프록시가 외부 ForwardedX-Forwarded-*를 모두 제거한 뒤 새 값을 넣는지 통합 테스트를 작성하세요. 프록시가 없는 현재 fixture에서 전달 헤더를 신뢰하도록 바꾸는 것은 해답이 아닙니다.

다음 문서에서는 입력 검증이 끝난 뒤 상태·헤더·본문을 어떤 순서로 HttpServletResponse에 쓰고, 응답 커밋 뒤에는 무엇을 바꿀 수 없는지 살펴봅니다.