서블릿 요청 입력
실제 embedded Tomcat에서 쿼리·폼·JSON·multipart 입력의 소유권, 본문 스트림의 일회성, 문자 인코딩, 미디어 타입, 전달 헤더와 계층별 크기 제한을 검증합니다.
HTTP 요청 하나에는 URI의 쿼리, 헤더, 본문 바이트와 직접 연결한 피어 정보가 함께 들어옵니다.
그러나 Servlet API가 이 입력을 모두 같은 자료구조로 바꾸지는 않습니다.
컨테이너가 소유하는 파라미터 파서와 애플리케이션이 소유하는 본문 파서를 구분하지 않으면, JSON을 getParameter()로 찾거나 이미 소비한 폼 본문을 다시 읽는 오류가 생깁니다.
REQUEST SOURCE · CONTENT-TYPE PARAMETERS · DECODE · VALIDATE
입력 위치와 media type이 Servlet 읽기 API를 결정한다
field 이름이 같아도 먼저 어디에 실렸는지 구분합니다.
query와 form의 charset 계약은 서로 다르고,
getParameter*는 form body를 파싱하며 소비할 수
있습니다. JSON body는 getReader()와
getInputStream() 중 하나만 골라 한 번만 decode합니다.
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 허용 여부도
추측하지 않고 애플리케이션 정책으로 명시합니다.
입력 형식마다 읽는 주체가 다릅니다
ServletRequest.getParameter 계열은 HTTP 쿼리와 POST application/x-www-form-urlencoded 폼을 제공합니다.
같은 이름에 값이 여러 개면 getParameter()는 첫 값만 반환하므로, 다중 값 계약에는 getParameterValues() 또는 getParameterMap()을 사용합니다.
첫 값은 본문의 값이라는 뜻이 아닙니다. Servlet 6.1은 쿼리 값과 POST 폼 값을 합칠 때 쿼리 값을 먼저 둡니다. 예를 들어 쿼리가 tag=network이고 폼이 tag=one&tag=two라면 getParameterValues("tag")는 network, one, two 순서이고 getParameter("tag")는 network입니다. 반드시 값 하나만 받는 업무 입력이라면 첫 값을 조용히 고르는 대신 중복 값의 허용 여부도 검증해야 합니다.
파라미터 접근은 아직 파싱하지 않은 쿼리와 폼 본문을 컨테이너가 파싱하게 만들 수 있습니다. 따라서 폼 파라미터를 읽은 뒤 원시 본문도 온전히 남아 있으리라 가정하면 안 됩니다.
multipart/form-data는 Servlet 등록에 MultipartConfig가 있을 때 컨테이너가 part를 제공합니다. 텍스트 part는 파라미터 API에서 보일 수 있고 파일은 getPart와 getParts로 소유권을 명확히 드러냅니다.
반면 application/json은 자동으로 파라미터 맵이 되지 않습니다. 애플리케이션이나 Spring MVC HttpMessageConverter가 요청 본문을 한 번 읽어 JSON 객체로 변환합니다.
| 입력 위치·형식 | 일차 소유자 | 읽는 API |
|---|---|---|
| URI 쿼리 | Servlet 컨테이너 | getParameter*, getQueryString |
POST application/x-www-form-urlencoded | Servlet 컨테이너 | getParameter* |
구성된 multipart/form-data | Servlet 컨테이너 | 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-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, 깨진 미디어 타입, 타입이 없는 본문은 거부합니다.- 허용된 타입이어도 malformed JSON은 별도의
400 Bad Request입니다.
Spring MVC에서는 consumes 조건과 HttpMessageConverter가 같은 종류의 선택과 역직렬화를 담당합니다. 원시 Servlet에서는 이 경계를 직접 구현해야 합니다.
읽을 형식과 업무상 유효한 내용은 서로 다른 검사입니다. 여기서 application/problem+json을 허용하는 것은 JSON 파서 선택 정책을 보여 주며, 본문의 CreatePostInput이 표준 problem 문서의 의미를 검증한다는 뜻이 아닙니다. DTO로 읽힌 뒤에도 필수 title·content, 길이와 업무 조건을 따로 확인해야 합니다. 쿼리·폼 fixture는 tag가 있는 입력으로 작성했으며, 누락 시 getParameterValues("tag")가 null이므로 그대로 String.join에 넘기면 정상 응답을 만들 수 없습니다. 실제 API는 누락을 오류로 볼지 빈 목록으로 볼지도 정해야 합니다.
본문은 한 parser가 읽고 제한된 cache는 chain 뒤에 관찰한다
래퍼의 cache 0에서 downstream 40을 읽으며 사본 16을 모으고 반환 뒤 관찰하는 실제 바이트 상태이다.
TRUST → GATES → ONE READ → BOUNDED OBSERVATION
신뢰를 정규화한 뒤 한 parser만 본문을 읽고, cache는 chain 뒤에 본다
본문 소비자와 관찰자를 분리합니다. 원시 Forwarded와 X-Forwarded-* 값은 edge가 다시 쓴 뒤에만 신뢰하고,
downstream parser 하나가 원본 스트림을 소비하게 둡니다. 캐싱 래퍼는
그 읽기를 따라 제한된 바이트만 모으며, 로깅은
doFilter가 돌아온 뒤 cache를 읽습니다.
새 흐름은 /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 뒤에 관찰한다 |
| 14 | raw/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을 모으고 반환 뒤 관찰하는 실제 바이트 상태이다.
TRUST → GATES → ONE READ → BOUNDED OBSERVATION
신뢰를 정규화한 뒤 한 parser만 본문을 읽고, cache는 chain 뒤에 본다
본문 소비자와 관찰자를 분리합니다. 원시 Forwarded와 X-Forwarded-* 값은 edge가 다시 쓴 뒤에만 신뢰하고,
downstream parser 하나가 원본 스트림을 소비하게 둡니다. 캐싱 래퍼는
그 읽기를 따라 제한된 바이트만 모으며, 로깅은
doFilter가 돌아온 뒤 cache를 읽습니다.
새 흐름은 /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 뒤에 관찰한다 |
| 14 | raw/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바이트 · 요청 거절: 이 복사 관계가 아님 | 필터가 읽기 완료 뒤 제한된 관찰 자료를 얻습니다. |
실제 embedded Tomcat에서 경계를 검증합니다
다음 한 파일은 production 규칙의 작은 예제와 검증 fixture를 함께 둔 독립 compilation unit입니다.
Boot 4.1.1이 임의 포트의 Tomcat 11.0.24를 열고 JDK HttpClient가 127.0.0.1로 실제 HTTP/1.1 요청을 보냅니다. Spring mock은 addParameter()가 네트워크 파서가 아니라는 부정 증명에만 사용합니다.
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) {}
}여덟 테스트가 확인하는 범위는 다음과 같습니다.
- mock의
addParameter()는 원시 폼 본문을 파싱하지 않고 미리 정한 값만 추가합니다. - 실제 Tomcat은 중복 쿼리 값과 UTF-8 폼을 파라미터로 만들고, 40바이트 JSON은 쿼리와 분리된 본문으로 남깁니다.
getReader()와getInputStream()은 어느 쪽을 먼저 호출해도 다른 쪽 호출을 막습니다.- 미디어 타입 파라미터와
application/*+json은 허용하되text/json은415, malformed JSON은400으로 거부합니다. - 구성된 multipart 파서는 텍스트 필드와 파일 part를 제공하고 8바이트 파일 상한을 집행합니다.
- 직접 보낸
X-Forwarded-For는 loopback 피어 주소를 자동으로 신뢰 주소로 바꾸지 않습니다. - 64바이트 애플리케이션 게이트는 알려진
Content-Length와 길이를 모르는 chunked 입력을 모두 제한합니다. - 캐싱 래퍼는 downstream이 40바이트를 소비하기 전에는 0바이트이고, 소비 뒤에도 설정한 16바이트만 보관합니다.
이 테스트는 정상적인 embedded Tomcat 요청 경로를 증명합니다. 외부 프록시의 실제 제한이나 신뢰 헤더 재작성은 이 JVM 안에서 실행하지 않았으므로 배포 환경의 통합 테스트 대상입니다.
전달 헤더는 신뢰 경계에서 다시 써야 합니다
getRemoteAddr()는 요청 객체가 나타내는 원격 끝점입니다. 기본적으로 직접 연결한 클라이언트 또는 마지막 프록시이고, 컨테이너의 전달 헤더 처리 설정에 따라 달라질 수 있습니다.
사용자는 Forwarded와 X-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 connector | form POST·파라미터 파싱 | form 크기, 파라미터 수, swallow 정책 |
| multipart parser | 전체 multipart·개별 part·메모리/디스크 전환 | maxRequestSize, maxFileSize, threshold |
| endpoint parser | JSON·XML·임의 스트림 | 선언 길이 선검사 + 상한을 둔 실제 스트림 읽기 |
| 관측 로깅 | 메모리에 남기는 복사본 | 작은 cache limit, 타입 allowlist, 민감값 마스킹 |
MultipartConfigElement의 파일 상한과 요청 상한은 multipart에만 적용됩니다. JSON 상한을 대신하지 않습니다.
Boot 4.1.1의 MultipartAutoConfiguration도 MultipartProperties를 MultipartConfigElement로 연결합니다. spring.servlet.multipart.max-file-size와 max-request-size는 업로드 계층의 제한이며 connector와 JSON endpoint 제한을 대체하지 않습니다.
Content-Length는 빠른 거절에 유용하지만 chunked 요청처럼 미리 알 수 없는 경우가 있습니다. 따라서 endpoint는 선언 길이를 확인한 뒤에도 limit + 1바이트까지만 읽어 초과를 판정해야 합니다. HTTP 메시지 경계 자체는 프록시·컨테이너가 RFC 9112의 프레이밍 규칙으로 처리합니다. 상충하거나 잘못된 길이 헤더를 애플리케이션의 추가 읽기로 복구하거나, 이 상한 검사만으로 프록시와 컨테이너 사이의 프레이밍 불일치를 방어한다고 해석하지 않습니다.
반대로 로깅 cache limit은 요청 거절 정책이 아닙니다. downstream은 전체 40바이트를 읽는 동안 래퍼가 앞 16바이트만 복사할 수 있습니다.
이 코드의 상한은 URL별로 확인합니다. MAX_BODY_BYTES=64 검사는 /raw/input/sized에만 있고 /raw/input/json의 readAllBytes()에는 같은 검사가 없습니다. multipart의 파일 8바이트·요청 512바이트 설정도 JSON을 제한하지 않습니다. 운영 JSON 경로에는 별도의 제한을 연결한 뒤 파서에 바이트를 전달해야 합니다. 또한 readNBytes(65)는 65바이트, 스트림 끝 또는 읽기 오류까지 기다릴 수 있으므로 바이트 상한이 전송 시간 상한을 대신하지 않습니다. 캐시 16바이트는 앞부분의 복사 한도이며 이 두 제한을 모두 대신하지 않습니다.
본문 로깅은 체인 뒤에 제한된 결과만 봅니다
Spring ContentCachingRequestWrapper는 스트림이나 리더를 통해 실제로 읽힌 내용만 가로채 저장합니다.
래퍼 생성 자체는 본문을 읽지 않습니다. downstream이 본문을 소비하지 않으면 getContentAsByteArray()도 빈 배열입니다.
따라서 필터는 다음 순서를 지킵니다.
- 구체적인 작은 cache limit으로 요청을 감쌉니다.
- 본문을 미리 읽지 않고 필터 체인을 호출합니다.
- 체인이 반환된 뒤 실제 소비된 cache만 확인합니다.
- 콘텐츠 타입 allowlist, 길이 절단, 비밀번호·토큰·개인정보 마스킹을 거친 값만 기록합니다.
cache limit을 넘긴 원문이 자동으로 거부되거나 전체 본문이 안전하게 재생되는 것은 아닙니다. 업로드와 대용량 JSON을 통째로 로깅하지 않고 요청 ID, 타입, 선언 길이, 처리 결과 같은 메타데이터를 우선합니다.
연습 문제
fixture의 JSON 정책에 application/vnd.board+json과 콘텐츠 타입이 없는 본문을 추가해 각각 201과 415를 검증하세요.
그다음 cache limit을 8바이트로 낮추되 downstream이 여전히 40바이트를 읽는지 확인하고, 기록용 preview에서는 content 필드를 남기지 않도록 바꾸세요.
8바이트 사본은 JSON 객체 전체가 아닐 수 있습니다. 작은 사본을 JSON으로 다시 파싱해 특정 필드만 지우는 방식을 정답으로 삼지 마세요. 원 스트림을 한 번 더 읽지 않고도 안전한 로그를 만들려면 본문 preview를 생략하고 요청 ID·타입·선언 길이·처리 결과만 남기는 방법이 있습니다. 필요한 업무 값은 endpoint가 이미 검증한 객체에서 별도 allowlist로 고르되, 관측용 사본과 검증된 객체를 같은 데이터로 취급하지 않습니다.
예시
fixture의 40바이트 JSON을 UTF-8 바이트로 자르면 앞 8바이트는 {"title", 앞 16바이트는 {"title":"HTTP",입니다. 둘 다 완성된 JSON이 아니므로 필드 삭제용 JSON 파서에 그대로 넘길 수 없습니다. 사본 길이가 8이라는 사실은 downstream이 8바이트만 읽었다거나 content의 민감값이 모든 요청에서 마스킹됐다는 뜻도 아닙니다.
독립 확인 cache limit이 8이고 downstream이 40바이트를 모두 읽었습니다. 체인 호출 전·후의 cache 길이는 각각 얼마인가요? 로그를 위해 원 본문을 다시 읽어도 되나요? 앞 8바이트가 완성된 JSON인지도 따로 판단하세요.
캐시 상한과 안전한 관측의 풀이
이 fixture에서 체인 호출 전은 0, 뒤는 8바이트이고 downstream의 소비량은 그대로 40바이트입니다. 기본 캐싱 래퍼의 상한은 사본만 제한하며 그 자체로 요청을 거절하지 않습니다. 다른 소비자가 원 스트림을 다시 읽는 방법은 일회성 소유권을 깨므로 선택하지 않습니다.
예시의 앞 8바이트는 완성된 JSON이 아닙니다. 본문 로그를 생략한 메타데이터 또는 별도로 검증·허용한 필드만 기록하면 잘린 JSON 복구나 전체 본문 재소비에 의존하지 않습니다. 이 답은 원 코드와 바이트 예시를 추적한 예상이며, loopback 테스트를 실행한 결과로 간주하지 않습니다.
마지막으로 실제 배포 프록시가 외부 Forwarded와 X-Forwarded-*를 모두 제거한 뒤 새 값을 넣는지 통합 테스트를 작성하세요. 프록시가 없는 현재 fixture에서 전달 헤더를 신뢰하도록 바꾸는 것은 해답이 아닙니다.
다음 문서에서는 입력 검증이 끝난 뒤 상태·헤더·본문을 어떤 순서로 HttpServletResponse에 쓰고, 응답 커밋 뒤에는 무엇을 바꿀 수 없는지 살펴봅니다.
추가 연습 — 입력의 위치와 바이트 경계를 계산합니다. 아래 조건에서 각각 답하세요. (1) POST urlencoded 요청의 쿼리는 tag=network, UTF-8 폼은 tag=one&tag=two입니다. 파라미터의 첫 값과 전체 값의 순서는 무엇인가요? 별도의 JSON 요청에서 쿼리가 title=query이고 유효한 JSON 본문의 title이 HTTP라면 getParameter("title")와 파싱된 DTO의 title은 각각 무엇인가요? (2) /raw/input/sized에 유효하게 프레이밍된 본문을 보내며, 선언 길이는 모르는 값 -1입니다. 본문이 UTF-8 한 21개 또는 22개이고 각각 정상적으로 끝난다면 읽기 길이와 상태는 무엇인가요? 문자 수만 비교하지 말고 64바이트 상한 및 최대 65바이트 읽기를 사용하세요. (3) 같은 /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은 기록 사본만 제한합니다. 이 답은 정상 프레이밍과 읽기 성공을 조건으로 원 코드의 분기를 추적한 결과이며 실제 Servlet·프록시 통합 실행 결과는 아닙니다.