본문 바로가기

서블릿 요청 입력

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

HTTP 요청 하나에는 URI의 쿼리와 헤더, 본문의 바이트, 직접 연결한 상대의 정보가 함께 들어옵니다. 그러나 서블릿 API는 이 입력을 모두 같은 자료 구조로 바꾸지 않습니다. 컨테이너가 맡는 파라미터 파서와 애플리케이션이 맡는 본문 파서를 구분하지 않으면, JSON을 getParameter()로 찾거나 이미 소비한 폼 본문을 다시 읽는 실수를 하게 됩니다.

URI query와 POST urlencoded form은 컨테이너의 파라미터 API, 구성된 multipart는 part API, JSON은 애플리케이션의 단일 본문 파서로 읽습니다. 문자 인코딩과 boundary를 파싱 전에 결정하며 읽기 이후의 업무 검증은 별도로 설계합니다.

REQUEST SOURCE · CONTENT-TYPE PARAMETERS · DECODE · VALIDATE

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

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

URI 쿼리와 본문의 읽기 소유자쿼리가 있으면 컨테이너 파라미터 API로 읽는다. 본문은 미디어 타입에 맞는 경로 하나를 선택한다. POST urlencoded는 컨테이너 파라미터 API, 구성된 multipart는 컨테이너 part API, JSON 등은 애플리케이션의 단일 스트림 또는 리더와 전용 파서이다. 한 요청이 세 본문 파서를 모두 실행한다는 뜻이 아니다. 업무 검증은 파싱 이후 별도 경계이다.쿼리본문POST formmultipartJSON 등HTTP 요청위치·형식 구분URI querygetParameter*본문 미디어 타입맞는 경로 하나 선택urlencoded formgetParameter*컨테이너 소유구성된 multipartgetPart / getParts컨테이너 소유getInputStream또는 getReader전용 파서 1회
URI 쿼리와 본문의 읽기 소유자쿼리가 있으면 컨테이너 파라미터 API로 읽는다. 본문은 미디어 타입에 맞는 경로 하나를 선택한다. POST urlencoded는 컨테이너 파라미터 API, 구성된 multipart는 컨테이너 part API, JSON 등은 애플리케이션의 단일 스트림 또는 리더와 전용 파서이다. 한 요청이 세 본문 파서를 모두 실행한다는 뜻이 아니다. 업무 검증은 파싱 이후 별도 경계이다.쿼리본문POST formmultipartJSON 등HTTP 요청위치·형식 구분URI querygetParameter*본문 미디어 타입맞는 경로 하나 선택urlencoded formgetParameter*컨테이너 소유구성된 multipartgetPart / getParts컨테이너 소유getInputStream또는 getReader전용 파서 1회

01 · URI QUERY

컨테이너 → getParameter*

쿼리는 URI의 percent-encoded 값입니다. 중복 이름은 getParameterValues()로 읽습니다. query 디코딩은 connector URI 정책이며 본문 setCharacterEncoding과 별도입니다.

02 · URLENCODED FORM

POST form → getParameter*

application/x-www-form-urlencoded 본문은 컨테이너가 파싱합니다. 이 예제는 UTF-8을 먼저 설정합니다. 파라미터 접근 뒤 원시 본문이 다시 남아 있다고 가정하지 않습니다.

03 · MULTIPART FORM

구성된 컨테이너 파서 → getParts

multipart/form-data는 boundary와 Servlet multipart 구성이 필요합니다. 텍스트 field와 파일 part를 분리하고 파일·전체 요청 상한을 각각 적용합니다. fixture의 파일 상한은 8바이트입니다.

04 · JSON BODY

애플리케이션 → 단일 body parser

본문은 자동으로 파라미터 map이 되지 않습니다. getInputStream()과 getReader() 중 하나만 사용합니다. fixture는 바이트를 JSON 파서에 넘기며 application/json과 application/*+json을 허용합니다.

DECODE → APPLICATION CONTRACT

읽기 성공 뒤 업무 검증 경계를 설계한다

fixture는 query·form·parts·JSON의 파싱과 미디어 타입·문법 오류를 검증합니다. 실제 command에 required·range·cross-field 검증을 모으는 것은 이후 애플리케이션 설계이며, 여기서 업무 실행이나 검증 구현이 완료됐다고 볼 수 없습니다.

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

그림은 입력이 실린 위치와 미디어 타입에 따라 읽는 API가 달라진다는 것을 보여 줍니다. URI 쿼리는 컨테이너의 파라미터 API로 읽고, 본문은 미디어 타입에 맞는 경로 하나를 고릅니다. POST의 urlencoded 폼은 컨테이너의 파라미터 API로, 설정된 multipart는 컨테이너의 part API로, JSON 같은 본문은 애플리케이션이 스트림이나 리더 하나로 한 번 읽어 전용 파서에 넘깁니다. 한 요청이 세 본문 파서를 모두 실행한다는 뜻이 아니며, 업무 검증은 파싱이 끝난 뒤의 별도 단계입니다.


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

ServletRequest.getParameter 계열은 HTTP 쿼리와 POST application/x-www-form-urlencoded 폼의 값을 제공합니다. 같은 이름에 값이 여러 개면 getParameter()는 첫 값만 돌려주므로, 값이 여러 개일 수 있다면 getParameterValues()나 getParameterMap()을 씁니다. 첫 값이 본문의 값이라는 뜻도 아닙니다. 서블릿 6.1은 쿼리 값과 POST 폼 값을 합칠 때 쿼리 값을 앞에 두므로, 쿼리가 tag=network이고 폼이 tag=one&tag=two라면 getParameterValues("tag")는 network, one, two의 순서이고 getParameter("tag")는 network입니다. 값이 하나여야 하는 업무 입력이라면 첫 값을 조용히 고르지 말고 중복 값을 허용할지도 검사해야 합니다.

파라미터에 접근하면 아직 파싱하지 않은 쿼리와 폼 본문을 컨테이너가 그때 파싱할 수 있습니다. 그러니 폼 파라미터를 읽은 뒤에도 원시 본문이 그대로 남아 있으리라 가정하면 안 됩니다. multipart/form-data는 서블릿 등록에 MultipartConfig가 있을 때 컨테이너가 part로 제공합니다. 텍스트 part는 파라미터 API에서도 보일 수 있고, 파일은 getPart와 getParts로 읽어 누가 처리하는지를 분명히 드러냅니다. 반면 application/json은 저절로 파라미터 맵이 되지 않으며, 애플리케이션이나 Spring MVC의 HttpMessageConverter가 요청 본문을 한 번 읽어 JSON 객체로 바꿉니다.

입력의 위치와 형식먼저 처리하는 쪽읽는 API
URI 쿼리서블릿 컨테이너getParameter*, getQueryString
POST application/x-www-form-urlencoded서블릿 컨테이너getParameter*
설정된 multipart/form-data서블릿 컨테이너getParameter*, getPart, getParts
JSON, XML, 임의의 바이너리 본문애플리케이션이나 변환기getInputStream이나 getReader 뒤 전용 파서

Spring의 MockHttpServletRequest.addParameter()는 이 컨테이너의 파싱을 재현하지 않습니다. 테스트 작성자가 이미 파싱되었다고 가정한 값을 가짜 요청의 파라미터 맵에 직접 넣는 설정용 API일 뿐입니다. 실제 퍼센트 디코딩과 파라미터 결합, 소비 시점, 크기 제한은 루프백 HTTP로 컨테이너를 거쳐야 따로 확인할 수 있습니다.


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

getInputStream()은 본문을 바이트로 읽고, getReader()는 정해진 문자 인코딩으로 디코딩해 읽습니다. 한 요청에서는 둘 중 하나만 쓸 수 있어서, 리더를 얻은 뒤 스트림을 얻거나 스트림을 얻은 뒤 리더를 얻으면 IllegalStateException이 납니다.

이 규칙과 별개로 파라미터 파싱도 폼 본문을 소비할 수 있습니다. 이 픽스처의 Tomcat에서는 getParameter()를 호출한 뒤 getInputStream()에 0바이트가 남습니다. 서블릿 명세는 두 접근이 서로 간섭할 수 있다고만 정하므로, 다른 컨테이너에서도 오류가 나는지 빈 스트림인지에 기대지 말고 원문이 다시 재생되지 않는다고 생각하고 설계합니다. 필터가 로그를 남기려고 먼저 readAllBytes()를 호출하면, 뒤의 서블릿이나 메시지 변환기가 읽을 데이터도 사라집니다. 본문을 여러 소비자에게 넘겨야 한다면 누가 읽고 언제 다시 재생하는지를 정한 별도의 래퍼가 필요합니다.


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

setCharacterEncoding은 파라미터를 읽거나 getReader()를 얻기 전에 호출해야 하며, 그 뒤에 바꾸면 효과가 없습니다. 이 픽스처의 폼 경로는 UTF-8을 먼저 설정한 뒤 %EC%9A%94%EC%B2%AD을 요청으로 디코딩합니다. 쿼리 문자열의 디코딩 설정과 본문의 문자 인코딩 설정은 컨테이너에서 서로 다른 옵션일 수 있으므로, setCharacterEncoding 호출 하나가 둘 다 해결한다고 일반화하지 않습니다. JSON은 보통 바이트 스트림을 그대로 파서에 넘기며, 미디어 타입에 charset이 있더라도 어떤 문자 집합을 받고 어떻게 디코딩할지는 파서와 프레임워크의 규칙으로 정해 두어야 합니다.


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

Content-Type은 application/json;charset=UTF-8처럼 파라미터를 가질 수 있으므로, "application/json".equals(request.getContentType())는 정상 요청을 거부합니다. Spring의 MediaType으로 타입과 하위 타입을 해석한 뒤 지원 정책을 적용합니다. 이 글의 정책은 application/json과 그 파라미터 조합을 허용하고, application/problem+json이나 application/vnd.board+json처럼 하위 타입이 +json으로 끝나는 application/*도 허용합니다. text/json과 깨진 미디어 타입, 타입이 없는 본문은 거부하며, 허용한 타입이어도 문법이 깨진 JSON은 별도로 400 Bad Request입니다. Spring MVC에서는 consumes 조건과 HttpMessageConverter가 이런 선택과 역직렬화를 맡지만, 원시 서블릿에서는 직접 구현해야 합니다.

읽을 수 있는 형식인지와 업무상 올바른 내용인지는 서로 다른 검사입니다. 여기서 application/problem+json을 허용하는 것은 JSON 파서를 고르는 정책을 보여 줄 뿐, 본문의 CreatePostInput이 표준 문제 문서의 의미를 검증한다는 뜻이 아닙니다. DTO로 읽은 뒤에도 필수인 title과 content, 길이, 업무 조건을 따로 확인해야 합니다. 쿼리와 폼의 픽스처는 tag가 있는 입력으로 작성했으며, tag가 없으면 getParameterValues("tag")가 null이어서 그대로 String.join에 넘기면 정상 응답을 만들 수 없습니다. 실제 API는 누락을 오류로 볼지 빈 목록으로 볼지도 정해야 합니다.

본문은 한 parser가 읽고 제한된 cache는 chain 뒤에 관찰한다

래퍼의 cache 0에서 downstream 40을 읽으며 사본 16을 모으고 반환 뒤 관찰하는 실제 바이트 상태이다.

프록시 전달 헤더 신뢰, 컨테이너 multipart 제한, endpoint 스트림·미디어 타입 판정은 서로 다른 경계입니다. 본문 캐싱 fixture는 래퍼 생성 직후 0바이트, downstream의 40바이트 소비 이후 16바이트 cache를 확인합니다. 관측용 cache limit은 요청 거절 제한이나 전체 본문 재생 계약이 아닙니다.

TRUST → GATES → ONE READ → BOUNDED OBSERVATION

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

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

단일 본문 소비와 제한된 관측 사본raw/input/cache fixture에서 필터가 래퍼를 만들 때 캐시는0바이트이다. chain.doFilter의 downstream이40바이트를 읽는 동안 점선 관계로 제한된 사본16바이트만 모인다. 체인이 반환한 뒤 필터가 그 사본을 읽는다. 점선은 읽는 동안의 복사이고, 제어 호출이나 요청 거절이 아니다. 프록시 신뢰와 multipart의 다른 게이트는 아래 비교에서 별도로 본다.호출반환소비 시 사본체인 뒤관측 데이터필터: 래퍼 생성cache 0 bytesdownstream원본 40 bytes 소비필터로 반환체인 실행 완료관측 사본 16 bytescache limit 16cache 확인 16요청 거절 상한 아님
단일 본문 소비와 제한된 관측 사본raw/input/cache fixture에서 필터가 래퍼를 만들 때 캐시는0바이트이다. chain.doFilter의 downstream이40바이트를 읽는 동안 점선 관계로 제한된 사본16바이트만 모인다. 체인이 반환한 뒤 필터가 그 사본을 읽는다. 점선은 읽는 동안의 복사이고, 제어 호출이나 요청 거절이 아니다. 프록시 신뢰와 multipart의 다른 게이트는 아래 비교에서 별도로 본다.호출반환소비 시 사본체인 뒤사본 확인필터: 래퍼 생성cache 0 bytesdownstream원본 40 bytes 소비단일 읽기필터로 반환체인 실행 완료관측 사본 16 bytescache limit 16cache 확인 16요청 거절 상한 아님

새 흐름은 /raw/input/cache의 40바이트 읽기·16바이트 사본만 연결합니다. 점선은 downstream이 읽는 동안의 사본 생성이며, 요청 거절 또는 다른 parser 실행 순서가 아닙니다.

01 · DEPLOYMENT TRUST

Forwarded와 X-Forwarded-*를 모두 다시 쓴다

신뢰 경계의 프록시는 외부 전달 헤더 두 계열을 제거하고 관측한 값으로 다시 씁니다. 이 fixture는 전달 헤더 전략 none으로 직접 연결 피어와 사용자 헤더가 다름만 확인합니다. 실제 프록시 재작성은 배포 통합 검증 대상입니다.

02 · SEPARATE INPUT GATES

컨테이너와 endpoint의 제한을 구분한다

multipart는 컨테이너 part 파서의 8바이트 파일 상한입니다. 임의 스트림은 endpoint가 선언 길이를 검사한 뒤 최대 64 + 1바이트를 읽어 초과 시 413을 반환합니다. JSON endpoint가 미지원 타입을 415, 문법 오류를 400으로 판정합니다.

03 · WRAP → DOWNSTREAM READ

래퍼 생성 시 cache는 0바이트

ContentCachingRequestWrapper를 cache limit 16으로 만듭니다. 필터는 본문을 미리 읽지 않고 chain.doFilter를 호출합니다. downstream의 단일 본문 읽기를 따라 읽힌 바이트만 제한적으로 복사합니다.

04 · AFTER CHAIN

downstream 40바이트 / cache 16바이트

체인이 돌아온 뒤 fixture는 실제 소비 40바이트와 캐시 16바이트를 각각 관찰합니다. cache limit은 요청 거절 상한이 아닙니다. 실서비스 로그는 사본의 타입 허용·마스킹·별도 잘림 추적을 설계하고 전체 본문 자동 재생을 가정하지 않습니다.

ACTUAL FIXTURE · BYTES

생성 직후 cache 0 → downstream 소비 40 → 체인 이후 cache 16

같은 40바이트 JSON을 요청 거절 없이 읽으면서 관측용 사본만 16바이트로 제한합니다. 로깅 마스킹·업무 command 검증은 이 테스트의 구현 범위와 별도로 설계합니다.

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

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • raw/input/cache fixture에서 필터가 래퍼를 만들 때 캐시는 0바이트이다. chain.doFilter의 downstream이 40바이트를 읽는 동안 점선 관계로 제한된 사본 16바이트만 모인다. 체인이 반환한 뒤 필터가 그 사본을 읽는다. 점선은 읽는 동안의 복사이고, 제어 호출이나 요청 거절이 아니다. 프록시 신뢰와 multipart의 다른 게이트는 아래 비교에서 별도로 본다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1본문은 한 parser가 읽고 제한된 cache는 chain 뒤에 관찰한다
14raw/input/cache fixture에서 필터가 래퍼를 만들 때 캐시는 0바이트이다. chain.doFilter의 downstream이 40바이트를 읽는 동안 점선 관계로 제한된 사본 16바이트만 모인다. 체인이 반환한 뒤 필터가 그 사본을 읽는다. 점선은 읽는 동안의 복사이고, 제어 호출이나 요청 거절이 아니다. 프록시 신뢰와 multipart의 다른 게이트는 아래 비교에서 별도로 본다.
39으로 직접 연결 피어와 사용자 헤더가 다르다는 것만 확인합니다. 실제 프록시 재작성은 배포 통합 검증 대상입니다.
65실패 응답은 예시 정책입니다. 실제 배포에서는 proxy·컨테이너·메시지 converter의 상한과 예외 매핑을 같은 계약으로 맞추고, 인증 헤더·쿠키·비밀번호·전체 JSON 본문은 기본 로그에서 제외합니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
빈 cache 래퍼 생성

필터가 원 input을 감싼 래퍼를 만듭니다.

cache: 0바이트 · downstream: 읽기 전관찰할 사본의 초기 상태가 생깁니다.
downstream이 원 input 읽기

chain.doFilter 안의 downstream이 40바이트를 읽는 동안 제한된 사본을 모읍니다.

원 input 읽기: 40바이트 · cache: 16바이트 · 사본: 읽는 동안 생성본문 읽기와 별개의 사본 크기가 함께 늘어납니다.
chain 뒤 사본 관찰

체인이 반환한 뒤 필터가 래퍼의 16바이트 사본을 읽습니다.

chain: 반환 · 관찰 cache: 16바이트 · 요청 거절: 이 복사 관계가 아님필터가 읽기 완료 뒤 제한된 관찰 자료를 얻습니다.

본문은 한 parser가 읽고 제한된 cache는 chain 뒤에 관찰한다

래퍼의 cache 0에서 downstream 40을 읽으며 사본 16을 모으고 반환 뒤 관찰하는 실제 바이트 상태이다.

프록시 전달 헤더 신뢰, 컨테이너 multipart 제한, endpoint 스트림·미디어 타입 판정은 서로 다른 경계입니다. 본문 캐싱 fixture는 래퍼 생성 직후 0바이트, downstream의 40바이트 소비 이후 16바이트 cache를 확인합니다. 관측용 cache limit은 요청 거절 제한이나 전체 본문 재생 계약이 아닙니다.

TRUST → GATES → ONE READ → BOUNDED OBSERVATION

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

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

단일 본문 소비와 제한된 관측 사본raw/input/cache fixture에서 필터가 래퍼를 만들 때 캐시는0바이트이다. chain.doFilter의 downstream이40바이트를 읽는 동안 점선 관계로 제한된 사본16바이트만 모인다. 체인이 반환한 뒤 필터가 그 사본을 읽는다. 점선은 읽는 동안의 복사이고, 제어 호출이나 요청 거절이 아니다. 프록시 신뢰와 multipart의 다른 게이트는 아래 비교에서 별도로 본다.호출반환소비 시 사본체인 뒤관측 데이터필터: 래퍼 생성cache 0 bytesdownstream원본 40 bytes 소비필터로 반환체인 실행 완료관측 사본 16 bytescache limit 16cache 확인 16요청 거절 상한 아님
단일 본문 소비와 제한된 관측 사본raw/input/cache fixture에서 필터가 래퍼를 만들 때 캐시는0바이트이다. chain.doFilter의 downstream이40바이트를 읽는 동안 점선 관계로 제한된 사본16바이트만 모인다. 체인이 반환한 뒤 필터가 그 사본을 읽는다. 점선은 읽는 동안의 복사이고, 제어 호출이나 요청 거절이 아니다. 프록시 신뢰와 multipart의 다른 게이트는 아래 비교에서 별도로 본다.호출반환소비 시 사본체인 뒤사본 확인필터: 래퍼 생성cache 0 bytesdownstream원본 40 bytes 소비단일 읽기필터로 반환체인 실행 완료관측 사본 16 bytescache limit 16cache 확인 16요청 거절 상한 아님

새 흐름은 /raw/input/cache의 40바이트 읽기·16바이트 사본만 연결합니다. 점선은 downstream이 읽는 동안의 사본 생성이며, 요청 거절 또는 다른 parser 실행 순서가 아닙니다.

01 · DEPLOYMENT TRUST

Forwarded와 X-Forwarded-*를 모두 다시 쓴다

신뢰 경계의 프록시는 외부 전달 헤더 두 계열을 제거하고 관측한 값으로 다시 씁니다. 이 fixture는 전달 헤더 전략 none으로 직접 연결 피어와 사용자 헤더가 다름만 확인합니다. 실제 프록시 재작성은 배포 통합 검증 대상입니다.

02 · SEPARATE INPUT GATES

컨테이너와 endpoint의 제한을 구분한다

multipart는 컨테이너 part 파서의 8바이트 파일 상한입니다. 임의 스트림은 endpoint가 선언 길이를 검사한 뒤 최대 64 + 1바이트를 읽어 초과 시 413을 반환합니다. JSON endpoint가 미지원 타입을 415, 문법 오류를 400으로 판정합니다.

03 · WRAP → DOWNSTREAM READ

래퍼 생성 시 cache는 0바이트

ContentCachingRequestWrapper를 cache limit 16으로 만듭니다. 필터는 본문을 미리 읽지 않고 chain.doFilter를 호출합니다. downstream의 단일 본문 읽기를 따라 읽힌 바이트만 제한적으로 복사합니다.

04 · AFTER CHAIN

downstream 40바이트 / cache 16바이트

체인이 돌아온 뒤 fixture는 실제 소비 40바이트와 캐시 16바이트를 각각 관찰합니다. cache limit은 요청 거절 상한이 아닙니다. 실서비스 로그는 사본의 타입 허용·마스킹·별도 잘림 추적을 설계하고 전체 본문 자동 재생을 가정하지 않습니다.

ACTUAL FIXTURE · BYTES

생성 직후 cache 0 → downstream 소비 40 → 체인 이후 cache 16

같은 40바이트 JSON을 요청 거절 없이 읽으면서 관측용 사본만 16바이트로 제한합니다. 로깅 마스킹·업무 command 검증은 이 테스트의 구현 범위와 별도로 설계합니다.

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

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • raw/input/cache fixture에서 필터가 래퍼를 만들 때 캐시는 0바이트이다. chain.doFilter의 downstream이 40바이트를 읽는 동안 점선 관계로 제한된 사본 16바이트만 모인다. 체인이 반환한 뒤 필터가 그 사본을 읽는다. 점선은 읽는 동안의 복사이고, 제어 호출이나 요청 거절이 아니다. 프록시 신뢰와 multipart의 다른 게이트는 아래 비교에서 별도로 본다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1본문은 한 parser가 읽고 제한된 cache는 chain 뒤에 관찰한다
14raw/input/cache fixture에서 필터가 래퍼를 만들 때 캐시는 0바이트이다. chain.doFilter의 downstream이 40바이트를 읽는 동안 점선 관계로 제한된 사본 16바이트만 모인다. 체인이 반환한 뒤 필터가 그 사본을 읽는다. 점선은 읽는 동안의 복사이고, 제어 호출이나 요청 거절이 아니다. 프록시 신뢰와 multipart의 다른 게이트는 아래 비교에서 별도로 본다.
39으로 직접 연결 피어와 사용자 헤더가 다르다는 것만 확인합니다. 실제 프록시 재작성은 배포 통합 검증 대상입니다.
65실패 응답은 예시 정책입니다. 실제 배포에서는 proxy·컨테이너·메시지 converter의 상한과 예외 매핑을 같은 계약으로 맞추고, 인증 헤더·쿠키·비밀번호·전체 JSON 본문은 기본 로그에서 제외합니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
빈 cache 래퍼 생성

필터가 원 input을 감싼 래퍼를 만듭니다.

cache: 0바이트 · downstream: 읽기 전관찰할 사본의 초기 상태가 생깁니다.
downstream이 원 input 읽기

chain.doFilter 안의 downstream이 40바이트를 읽는 동안 제한된 사본을 모읍니다.

원 input 읽기: 40바이트 · cache: 16바이트 · 사본: 읽는 동안 생성본문 읽기와 별개의 사본 크기가 함께 늘어납니다.
chain 뒤 사본 관찰

체인이 반환한 뒤 필터가 래퍼의 16바이트 사본을 읽습니다.

chain: 반환 · 관찰 cache: 16바이트 · 요청 거절: 이 복사 관계가 아님필터가 읽기 완료 뒤 제한된 관찰 자료를 얻습니다.

그림은 이 글의 마지막 테스트인 /raw/input/cache 픽스처의 흐름입니다. 필터가 래퍼를 만든 순간의 캐시는 0바이트이고, chain.doFilter 안의 뒤쪽 처리기가 원본 40바이트를 읽는 동안 제한된 사본 16바이트만 모이며, 체인이 돌아온 뒤 필터가 그 사본을 확인합니다. 점선은 읽는 동안 사본이 만들어진다는 뜻이며, 요청을 거절하는 상한이 아닙니다. 아래쪽 상자들은 프록시의 전달 헤더 신뢰와 multipart, 임의 스트림의 입력 제한이 서로 다른 관문이라는 점을 정리합니다.


실제 embedded Tomcat에서 확인합니다

다음 파일은 작은 예제 규칙과 확인용 픽스처를 함께 둔 하나의 단위이며, 게시판 프로젝트의 src/test/java에 둡니다. Boot 4.1.2가 임의의 포트로 Tomcat 11.0.27을 열고, JDK HttpClient가 127.0.0.1로 실제 HTTP/1.1 요청을 보냅니다. Spring의 가짜 요청은 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가짜 요청의 addParameter()는 원시 폼 본문을 파싱하지 않고 미리 정한 값만 넣음
2실제 Tomcat은 중복 쿼리 값과 UTF-8 폼을 파라미터로 만들고, 40바이트 JSON은 쿼리와 분리된 본문으로 남김
3getReader()와 getInputStream()은 어느 쪽을 먼저 불러도 다른 쪽을 막음
4미디어 타입 파라미터와 application/*+json은 허용하고, text/json은 415, 깨진 JSON은 400
5설정된 multipart 파서가 텍스트 필드와 파일 part를 주고 8바이트 파일 상한을 지킴
6직접 보낸 X-Forwarded-For가 루프백 상대 주소를 신뢰할 주소로 저절로 바꾸지 않음
764바이트 애플리케이션 관문이 Content-Length를 아는 입력과 길이를 모르는 chunked 입력을 모두 제한
8캐싱 래퍼는 뒤쪽이 40바이트를 읽기 전에는 0바이트이고, 읽은 뒤에도 정한 16바이트만 보관

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


전달 헤더는 신뢰하는 프록시가 다시 써야 합니다

getRemoteAddr()는 요청 객체가 나타내는 원격 끝점입니다. 기본적으로 직접 연결한 클라이언트나 마지막 프록시이며, 컨테이너의 전달 헤더 처리 설정에 따라 달라질 수 있습니다. 사용자는 Forwarded와 X-Forwarded-* 헤더를 마음대로 보낼 수 있으므로, Spring Framework의 전달 헤더 보안 지침처럼 신뢰할 수 있는 가장자리의 프록시가 외부에서 들어온 두 헤더 계열을 모두 지우고 자신이 관찰한 값으로 다시 써야 합니다. 그것이 지켜진 뒤에만 Boot의 server.forward-headers-strategy로 컨테이너의 기본 처리나 Spring Framework의 처리를 고릅니다. 애플리케이션 코드가 원시 X-Forwarded-For 문자열의 첫 토큰을 그대로 인증이나 감사, 요청률 제한의 클라이언트 주소로 써서는 안 됩니다.

이 글의 루프백 테스트는 전략을 none으로 고정해, 상대 주소 127.0.0.1과 사용자가 넣은 203.0.113.9 헤더가 서로 다른 값임을 확인합니다. 실제 프록시 체인의 구간 수와 신뢰하는 주소 범위(CIDR)는 배포 구성에서 확인해야 합니다.


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

제한 하나가 모든 입력 경로를 지켜 주지는 않습니다.

계층먼저 막는 대상예시 정책
가장자리 프록시연결, 전체 요청, 헤더최대 요청 크기, 헤더 크기, 전송 시간
서블릿 커넥터폼 POST와 파라미터 파싱폼 크기, 파라미터 수, 남은 본문 처리 정책
multipart 파서전체 multipart, 개별 part, 메모리와 디스크 전환maxRequestSize, maxFileSize, 임계값
엔드포인트 파서JSON, XML, 임의 스트림선언 길이 사전 검사와 상한을 둔 실제 읽기
관측 로그메모리에 남기는 사본작은 캐시 한도, 허용 타입 목록, 민감한 값 가리기

MultipartConfigElement의 파일 상한과 요청 상한은 multipart에만 적용되며 JSON의 상한을 대신하지 않습니다. Boot 4.1.2의 MultipartAutoConfiguration도 MultipartProperties를 MultipartConfigElement로 연결하므로, spring.servlet.multipart.max-file-size와 max-request-size는 업로드 계층의 제한일 뿐 커넥터와 JSON 엔드포인트의 제한을 대신하지 않습니다.

Content-Length는 빨리 거절하는 데 쓸모가 있지만, chunked 요청처럼 미리 알 수 없는 경우가 있습니다. 그래서 엔드포인트는 선언된 길이를 확인한 뒤에도 limit + 1바이트까지만 읽어 초과 여부를 판정해야 합니다. HTTP 메시지의 끝 자체는 프록시와 컨테이너가 RFC 9112의 프레이밍 규칙으로 처리하므로, 서로 어긋나거나 잘못된 길이 헤더를 애플리케이션이 더 읽어서 복구하거나, 이 상한 검사만으로 프록시와 컨테이너 사이의 프레이밍 불일치를 막는다고 생각하지 않습니다. 반대로 로그의 캐시 한도는 요청을 거절하는 정책이 아니어서, 뒤쪽은 40바이트를 모두 읽는 동안 래퍼는 앞의 16바이트만 복사할 수 있습니다.

이 코드의 상한은 URL마다 다르다는 점도 확인합니다. MAX_BODY_BYTES=64 검사는 /raw/input/sized에만 있고 /raw/input/json의 readAllBytes()에는 같은 검사가 없으며, multipart의 파일 8바이트와 요청 512바이트 설정도 JSON을 제한하지 않습니다. 운영의 JSON 경로에는 별도의 제한을 연결한 뒤 바이트를 파서에 넘겨야 합니다. 또 readNBytes(65)는 65바이트를 다 받거나 스트림이 끝나거나 읽기 오류가 날 때까지 기다릴 수 있으므로, 바이트 상한이 전송 시간의 상한을 대신하지는 않습니다.

더 깊이: Tomcat 11과 Boot가 정해 둔 커넥터 한도

표의 서블릿 커넥터 행에는 따로 설정하지 않아도 기본값이 있습니다. Tomcat 11의 HTTP 커넥터 문서에 따르면 maxPostSize의 기본값은 2MiB인데, 이것은 Tomcat이 urlencoded 폼이나 multipart 본문을 요청 파라미터로 바꿀 때만 적용되며 POST 본문 전체의 일반적인 상한이 아닙니다. 그래서 이 한도는 JSON 본문을 전혀 막지 않으며, 앞에서 JSON 경로에 별도의 제한을 연결해야 한다고 한 이유와 같습니다. maxParameterCount는 쿼리와 폼, multipart에서 얻는 파라미터를 업로드 파일까지 합쳐 세며 기본값 1000을 넘으면 요청을 거절합니다.

multipart에는 한도가 더 있습니다. 한 요청의 part 수를 제한하는 maxPartCount는 기본 50이고, part 하나의 헤더 바이트를 제한하는 maxPartHeaderSize는 기본 512이며, 둘 다 넘으면 요청을 거절합니다. Boot 4.1.2도 server.tomcat.max-http-form-post-size(2MB), max-parameter-count(1000), max-part-count(50), max-part-header-size(512B)로 같은 값을 노출하므로, 파일과 텍스트 필드를 합쳐 part가 50개를 넘는 폼이라면 spring.servlet.multipart.*의 크기 제한과 상관없이 이 한도에 걸릴 수 있습니다. 업로드를 거절한 뒤 Tomcat이 클라이언트가 계속 보내는 본문을 얼마나 읽어 버릴지는 maxSwallowSize(Boot의 max-swallow-size, 기본 2MB)가 정하며, 문서는 이것을 읽어 주지 않으면 클라이언트가 응답을 보지 못할 가능성이 크다고 설명합니다.

part 수를 늘릴 때는 메모리도 함께 계산해야 합니다. Tomcat 문서는 multipart 처리에 필요한 메모리를 대략 maxPartHeaderSize × maxPartCount × maxConnections × 2로 어림하라고 안내하며, 기본값이면 512 × 50 × 8192 × 2로 400MB입니다. 한도 하나를 올리면 최악의 메모리 사용도 같은 비율로 커진다는 뜻입니다.


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

Spring의 ContentCachingRequestWrapper는 스트림이나 리더로 실제로 읽힌 내용만 가로채 저장합니다. 래퍼를 만드는 것만으로는 본문을 읽지 않으므로, 뒤쪽에서 본문을 소비하지 않으면 getContentAsByteArray()도 빈 배열입니다. 그래서 필터는 구체적인 작은 캐시 한도로 요청을 감싸고, 본문을 미리 읽지 않은 채 필터 체인을 호출하며, 체인이 돌아온 뒤에 실제로 소비된 캐시만 확인합니다. 그리고 콘텐츠 타입의 허용 목록과 길이 자르기, 비밀번호나 토큰, 개인정보 가리기를 거친 값만 기록합니다. 캐시 한도를 넘긴 원문이 저절로 거부되거나, 전체 본문이 안전하게 다시 재생되는 것은 아닙니다. 업로드나 큰 JSON을 통째로 기록하지 말고, 요청 ID와 타입, 선언 길이, 처리 결과 같은 메타데이터를 먼저 남깁니다.


정리

  • 쿼리와 urlencoded 폼, 설정된 multipart는 컨테이너의 파라미터와 part API로, JSON 같은 본문은 애플리케이션이 스트림이나 리더로 한 번 읽어 파서에 넘깁니다.
  • getParameter()는 첫 값만 돌려주고 쿼리 값을 폼 값보다 앞에 두며, 파라미터 파싱은 폼 본문을 소비할 수 있습니다.
  • getInputStream()과 getReader()는 한 요청에서 하나만 쓸 수 있고, 문자 인코딩은 파싱하기 전에 정해야 합니다.
  • 미디어 타입은 문자열 비교가 아니라 MediaType으로 해석해 판정하며, 형식 검사와 업무 검증은 따로 합니다.
  • 전달 헤더는 신뢰하는 프록시가 다시 쓴 뒤에만 믿고, 크기 제한은 프록시와 커넥터, multipart, 엔드포인트, 로그의 계층마다 따로 둡니다.

연습 문제

1. 미디어 타입과 캐시 한도 바꾸기

픽스처의 JSON 정책 테스트에 application/vnd.board+json 요청과 콘텐츠 타입이 없는 본문 요청을 추가해 각각 201과 415가 나오는지 확인하세요. 그다음 캐시 한도를 8바이트로 낮추고, 뒤쪽은 여전히 40바이트를 읽는지 확인한 뒤 기록용 미리보기에서 content 필드를 남기지 않도록 바꾸세요. 캐시 한도가 8이고 뒤쪽이 40바이트를 모두 읽었다면 체인 호출 전과 후의 캐시 길이는 각각 얼마이며, 로그를 위해 원래 본문을 다시 읽어도 될까요?

해설 보기

application/vnd.board+json은 타입이 application이고 하위 타입의 접미사가 json이므로 supportsJson이 참을 돌려주고, 기존 요청과 같은 올바른 JSON_BODY를 보내면 파싱도 성공해 201이 됩니다. send에 콘텐츠 타입으로 null을 넘기면 Content-Type 헤더를 넣지 않으므로 getContentType()이 null이 되고, supportsJson이 바로 거짓을 돌려주어 415입니다. 두 경우 모두 정책 코드는 바꾸지 않아도 됩니다.

이 픽스처에서 체인 호출 전의 캐시는 0, 호출 뒤는 8바이트이고, 뒤쪽이 소비한 양은 그대로 40바이트입니다. 기본 캐싱 래퍼의 한도는 사본만 제한할 뿐 요청을 거절하지 않습니다. 다른 소비자가 원래 스트림을 다시 읽는 방법은 한 번만 읽는다는 원칙을 깨므로 쓰지 않습니다.

40바이트 JSON을 UTF-8 바이트로 자르면 앞 8바이트는 {"title", 앞 16바이트는 {"title":"HTTP",입니다. 둘 다 완성된 JSON이 아니므로, 작은 사본을 JSON으로 다시 파싱해 특정 필드만 지우는 방식은 쓸 수 없습니다. 사본의 길이가 8이라고 뒤쪽이 8바이트만 읽었다거나 content의 민감한 값이 모든 요청에서 가려졌다는 뜻도 아닙니다. 본문 미리보기를 빼고 요청 ID와 타입, 선언 길이, 처리 결과만 남기거나, 엔드포인트가 이미 검증한 객체에서 허용 목록으로 고른 필드만 기록하면, 잘린 JSON을 복구하거나 본문 전체를 다시 소비하지 않아도 됩니다. 관측용 사본과 검증된 객체를 같은 데이터로 다루지 않습니다.

2. 입력의 위치와 바이트 수 계산하기

① POST urlencoded 요청의 쿼리는 tag=network, UTF-8 폼은 tag=one&tag=two입니다. 파라미터의 첫 값과 전체 값의 순서는 무엇인가요? 별도의 JSON 요청에서 쿼리가 title=query이고 올바른 JSON 본문의 title이 HTTP라면, getParameter("title")와 파싱된 DTO의 title은 각각 무엇인가요? ② /raw/input/sized에 올바르게 프레이밍된 본문을 보내고, 선언 길이는 모르는 값 -1입니다. 본문이 UTF-8 한 21개이거나 22개이고 각각 정상적으로 끝난다면, 읽는 길이와 상태는 무엇인가요? ③ 같은 /sized에서 선언 길이가 65인 경우와, 선언 길이 -1에 실제 본문 64바이트가 정상적으로 끝나는 경우를 비교하세요. 이 결과가 /json이나 캐시 한도 16에도 저절로 적용될까요?

해설 보기

① 첫 값은 network, 전체 값은 network, one, two입니다. 컨테이너가 쿼리를 폼보다 앞에 합치기 때문입니다. JSON은 폼 파라미터로 합쳐지지 않으므로, 별도 요청의 getParameter("title")는 쿼리의 query이고 DTO의 title은 JSON의 HTTP입니다. 이름이 같아도 읽는 API와 처리하는 쪽이 다릅니다.

② 한은 UTF-8로 한 글자에 3바이트입니다. 21개는 63바이트이므로 스트림 끝까지 63바이트를 읽고 상태 204를 설정합니다. 22개는 66바이트이므로 최대 65바이트를 읽는 순간 상한 64를 넘어 413으로 돌아갑니다. 초과를 판정하려고 읽은 바이트는 글자 중간에서 끝날 수 있으므로 정상 문자 본문으로 디코딩하지 않습니다. 전송이 정상적으로 끝난다는 조건이 없으면 이 숫자만으로 응답 시각을 정할 수 없습니다.

③ 선언 길이 65는 사전 검사에서 413으로 돌아가므로 본문을 읽지 않습니다. 선언 길이 -1에 실제 64바이트로 끝나면 readNBytes(65)가 64바이트를 돌려주고 204가 됩니다. 65번째 바이트가 없다는 것은 스트림의 끝에서 확인합니다. /json은 별도의 readAllBytes() 경로여서 그 코드에 상한을 연결해야 하고, 캐시 한도 16은 기록용 사본만 제한합니다. 이 답은 프레이밍과 읽기가 정상이라는 조건에서 코드의 분기를 따라간 결과이며, 실제 서블릿과 프록시의 통합 실행 결과는 아닙니다.

3. 프록시의 전달 헤더 재작성

실제 배포 프록시가 외부의 Forwarded와 X-Forwarded-*를 모두 지운 뒤 새 값을 넣는지 확인하는 통합 테스트를 설계하세요. 프록시가 없는 지금의 픽스처에서 전달 헤더를 믿도록 설정을 바꾸는 것이 해답일까요?

해설 보기

아닙니다. 프록시가 없는 픽스처에서 전달 헤더를 믿게 바꾸면, 사용자가 직접 보낸 X-Forwarded-For를 클라이언트 주소로 받아들이게 되어 위험합니다. 통합 테스트는 실제 프록시를 앞에 두고, 클라이언트가 위조한 Forwarded와 X-Forwarded-For를 함께 보낸 뒤, 애플리케이션이 받은 헤더에서 위조한 값이 사라지고 프록시가 관찰한 값만 남았는지 확인해야 합니다. 그 조건이 확인된 뒤에야 server.forward-headers-strategy로 신뢰하는 처리 방식을 켭니다.

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