HTTP 메시지 구조
HTTP/1.1 요청·응답의 시작줄, 필드, 빈 줄, 콘텐츠 경계를 실제 바이트 수로 확인하고 서블릿 컨테이너의 프레이밍과 Spring MVC의 매핑·변환 실패 경계를 구분합니다.
컨트롤러의 파라미터 바인딩 오류를 이해하려면 JSON 본문만 보아서는 안 됩니다. HTTP 메시지는 제어 데이터와 필드, 그리고 흔히 본문(body)이라고 부르는 콘텐츠(content)라는 요소로 이루어지고, HTTP 버전마다 이것을 전송하는 문법이 다릅니다. 이 글의 원시 메시지 예는 시작 줄과 CRLF로 구분하는 HTTP/1.1의 전송 문법입니다.
HTTP/1.1 · FIELD SECTION · CONTENT OCTETS
HTTP/1.1 메시지는 줄 경계와 content octet 수를 함께 해석한다
이 예제의 Content-Length는 아래 한 줄 JSON content의 UTF-8 octet 수와 정확히 같습니다. start-line과 field section은 그 수에 포함되지 않으며, 빈 줄이 field section의 끝을 표시합니다.
MESSAGE ANATOMY · ORDERED
start-line 다음에 field lines, 빈 줄, content가 온다
start-line이 메시지 역할을 정한다
요청은 method·target·HTTP version을, 응답은 HTTP version·status code·reason phrase를 적습니다.
field lines가 현재 메시지의 metadata를 운반한다
Content-Type,Accept,Location, framing field는 방향과 의미가 서로 다릅니다.빈 줄이 field section을 닫는다
HTTP/1.1 wire 형식에서는 CRLF로 끝나는 빈 줄 뒤부터 content가 시작됩니다.
framing이 content 경계를 정한다
여기서는
Content-Length가 content의 octet 수를 명시합니다. 모든 메시지가 이 framing을 쓰는 것은 아닙니다.
REQUEST · CONTENT-LENGTH 97
표시한 request content는 UTF-8로 정확히 97 octet이다
POST /api/posts HTTP/1.1
Host: board.example
Content-Type: application/json
Accept: application/json
Content-Length: 97
빈 CRLF 뒤 · 콘텐츠
{"title":"HTTP","content":"HTTP 메시지 구조를 정리합니다.","publishedOn":"2026-07-13"}
UTF-8(content) = 97 octets · header와 빈 줄은 제외
RESPONSE · CONTENT-LENGTH 105
표시한 response content는 UTF-8로 정확히 105 octet이다
HTTP/1.1 201 Created
Content-Type: application/json
Location: /api/posts/42
Content-Length: 105
빈 CRLF 뒤 · 콘텐츠
{"id":42,"title":"HTTP","content":"HTTP 메시지 구조를 정리합니다.","publishedOn":"2026-07-13"}
UTF-8(content) = 105 octets · 추가된 "id":42,는 8 octets
- HTTP/1.1위 예제처럼 textual start-line과 CRLF 빈 줄로 field section을 구분합니다.
- HTTP/2 · HTTP/3binary frame과
:method·:path·:status같은 pseudo-header field가 같은 역할을 운반하며 textual start-line이나 CRLF 빈 줄을 사용하지 않습니다.
계산 대상: JSON 콘텐츠는 화면 폭에 맞춰 자동 줄바꿈해 표시하지만, 실제 문자열은 추가 줄바꿈 없는 한 줄입니다. UTF-8 콘텐츠 바이트만 세며 시작줄·필드·빈 CRLF는 제외합니다.
그림은 HTTP/1.1 요청과 201 응답을 시작 줄, 필드 줄, 빈 줄, 콘텐츠의 순서로 나누어 보여 줍니다. 두 예의 Content-Length 97과 105는 아래쪽에 표시한 한 줄짜리 JSON 콘텐츠의 UTF-8 바이트 수와 정확히 같으며, 시작 줄과 필드, 빈 줄은 이 수에 들어가지 않습니다. 응답이 8바이트 많은 것은 "id":42,가 더해졌기 때문입니다. 그림에서는 JSON을 화면 폭에 맞춰 줄바꿈해 보여 주지만 실제 문자열은 줄바꿈 없는 한 줄입니다.
앞 글에서 준비한 별도 테스트 단위에 제목의 경로대로 HttpRequestShapeTest와 PostMessageContractTest를 둡니다. 앞의 테스트는 실제 요청을 보내지 않고 HttpRequest의 구성과 본문 발행기(body publisher)의 길이를 확인합니다. MVC 테스트에는 Java 17과 Spring Framework 6.2.11, 코드가 임포트하는 JUnit, Jackson, Jakarta Validation API가 필요하며, 독립형 MockMvc가 실행하는 여섯 요청을 각각 별도의 픽스처에서 비교합니다. 길이를 세는 JDK 테스트, 요청을 받는 MVC 테스트, 실제 연결에서 메시지를 나누는 서블릿 컨테이너를 서로 다른 것으로 읽어야 합니다. 두 테스트가 통과해도 원시 HTTP/1.1의 메시지 구분까지 확인한 것은 아닙니다.
HTTP/1.1은 빈 줄에서 필드와 콘텐츠가 갈립니다
다음 등록 요청에서 첫 줄은 요청 줄, 이어지는 줄들은 필드, 첫 번째 빈 줄 뒤는 메시지의 콘텐츠입니다. 각 줄 끝에는 화면에 보이지 않는 CRLF가 있습니다. 마지막 줄의 JSON은 75글자이지만 한글 11자가 UTF-8에서 각각 3바이트이므로, Content-Length에는 글자 수가 아닌 97을 적습니다.
POST /api/posts HTTP/1.1
Host: board.example
Content-Type: application/json
Accept: application/json
Content-Length: 97
{"title":"HTTP","content":"HTTP 메시지 구조를 정리합니다.","publishedOn":"2026-07-13"}요청 줄의 POST는 메서드, /api/posts는 요청 대상, HTTP/1.1은 프로토콜 버전입니다. 원 서버에 바로 보내는 보통의 요청은 경로와 선택적인 쿼리를 담은 origin-form 대상을 쓰고, 권한 부분은 Host 필드로 보냅니다. 프록시에 보내는 absolute-form, CONNECT의 authority-form, 서버 전체를 대상으로 하는 OPTIONS *처럼 메서드와 연결 방식에 따라 다른 형식도 있습니다.
시작 줄과 각 필드 줄은 CRLF로 끝나고, CRLF만 있는 빈 줄이 필드 영역을 닫습니다. 빈 줄은 콘텐츠가 어디서 시작하는지를 알려 줄 뿐, 콘텐츠가 어디서 끝나는지는 알려 주지 않습니다. 이 요청의 콘텐츠가 끝나는 곳은 Content-Length: 97이 정합니다. 본문에 줄바꿈이 있어도 그것을 다음 메시지의 시작으로 오해하지 않도록, 먼저 프레이밍(framing)으로 정한 바이트만큼 읽은 뒤 그 안을 JSON으로 해석합니다. 여기서 97은 UTF-8 JSON 한 줄만 센 값이며, 콘텐츠 뒤에 줄바꿈 바이트가 붙지 않는다는 전제입니다. 브라우저의 개발자 도구는 메시지를 읽기 좋게 다시 그려 보여 줄 수 있으므로, 화면의 줄바꿈이 실제로 보낸 바이트와 같다고 가정하지 않습니다.
HTTP/2와 HTTP/3에는 이런 텍스트 요청 줄과 빈 줄이 없습니다. 그 대신 요청 스트림의 HEADERS 프레임에서 시작하는 압축된 필드와 :method, :scheme, :authority, :path 같은 의사 헤더가 같은 의미를 실어 나르고, 콘텐츠는 DATA 프레임과 스트림의 끝으로 구분됩니다. HTTP/2와 HTTP/3는 Transfer-Encoding: chunked도 쓰지 않습니다. 이 네 의사 헤더는 일반 요청의 경우이며, 전통적인 CONNECT는 권한을 대상으로 삼고 :scheme과 :path를 생략하는 별도의 규칙을 따릅니다. 그러니 HTTP/1.1의 CRLF 문법을 HTTP/2나 HTTP/3의 프레임에 그대로 적용하지 않습니다.
필드는 콘텐츠와 처리 조건을 설명합니다
필드 이름은 대소문자를 구분하지 않지만, 같은 이름의 필드 줄 여러 개를 하나로 합쳐도 되는지는 필드마다 정의가 다릅니다. 그러니 중복된 필드를 아무렇게나 쉼표로 이어 붙이지 않습니다.
| 필드 | 방향 | 게시판 API에서의 뜻 |
|---|---|---|
Host | 요청 | HTTP/1.1에서 대상 가상 호스트를 가리킴 |
Content-Type | 요청과 응답 | 그 메시지 콘텐츠의 미디어 타입 |
Accept | 요청 | 클라이언트가 응답으로 받을 수 있는 표현의 선호 범위 |
Content-Length | 요청과 응답 | 이 필드를 쓸 수 있는 메시지에서 콘텐츠의 10진수 바이트 길이 |
Authorization | 요청 | 현재 요청이 내미는 인증 자격 증명 |
Location | 응답 | 만들어졌거나 옮겨 간 리소스의 URI 참조 |
Cache-Control | 요청과 응답 | 캐시 동작과 재검증에 대한 지시 |
JSON을 보내는 쪽은 Content-Type: application/json으로 지금 보내는 콘텐츠를 설명합니다. 요청의 Accept: application/json은 JSON 응답을 원하고 받을 수 있다는 뜻일 뿐, 서버가 언제나 JSON을 돌려준다는 보장은 아니며, 서버가 맞는 표현을 고르지 못하면 406을 돌려줄 수 있습니다.
Content-Length는 Java의 글자 수가 아니라 전송되는 콘텐츠의 옥텟(octet) 수입니다. 옥텟 하나는 8비트이며, 이 글에서는 바이트와 같은 단위로 씁니다. Java의 String.length()는 UTF-16 코드 단위를 세므로, 한글이나 보충 문자가 들어 있는 문자열의 UTF-8 바이트 수와 같지 않습니다. 실제 길이는 예제처럼 인코딩한 byte[]의 길이로 확인합니다.
또 이 필드가 있다고 해서 모든 응답에 실제 콘텐츠가 따라오는 것도 아닙니다. HEAD 응답은 같은 요청을 GET으로 처리했을 때의 콘텐츠 길이와 같다면 Content-Length를 담을 수 있고, 304 응답도 같은 조건부 요청에 200으로 응답했을 때의 길이와 같다면 담을 수 있지만, 두 응답 자체에는 콘텐츠가 없습니다. 1xx와 204 응답에는 Content-Length를 보내지 않으며, 성공한 CONNECT 응답에도 쓸 수 없습니다. 그래서 애플리케이션이 문자열 길이를 직접 헤더에 적기보다, HTTP 라이브러리가 인코딩한 뒤의 길이를 계산하거나 그 버전에 맞는 프레이밍을 고르게 합니다.
JDK API로 97바이트를 확인합니다
JDK의 HttpRequest는 메서드, URI, 필드, 본문 발행기를 각각 나타냅니다. 다음 테스트의 JSON은 줄바꿈이 없는 한 줄이며, UTF-8로 정확히 97바이트입니다.
package board.http;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.net.URI;
import java.net.http.HttpRequest;
import org.junit.jupiter.api.Test;
final class HttpRequestShapeTest {
private static final String JSON =
"{\"title\":\"HTTP\",\"content\":\"HTTP 메시지 구조를 정리합니다.\","
+ "\"publishedOn\":\"2026-07-13\"}";
@Test
void 메서드_uri_필드와_utf8_콘텐츠_길이를_분리한다() {
HttpRequest.BodyPublisher publisher =
HttpRequest.BodyPublishers.ofString(JSON, UTF_8);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://board.example/api/posts"))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.POST(publisher)
.build();
assertEquals(97, JSON.getBytes(UTF_8).length);
assertEquals(97L, publisher.contentLength());
assertEquals(97L,
request.bodyPublisher().orElseThrow().contentLength());
assertEquals("POST", request.method());
assertEquals("/api/posts", request.uri().getPath());
assertEquals("application/json",
request.headers()
.firstValue("content-type")
.orElseThrow());
}
}BodyPublisher.contentLength()가 돌려준 97은 이 발행기가 보낼 콘텐츠의 길이이며, 요청 줄과 필드, 필드 끝의 빈 줄은 들어가지 않습니다. ofString은 이미 정해진 문자열을 인코딩하므로 길이를 알지만, 다른 스트리밍 발행기는 길이를 모른다는 뜻으로 음수를 돌려줄 수 있습니다. 그것은 길이가 음수인 콘텐츠라는 뜻도, Content-Length: -1을 보내라는 뜻도 아닙니다. 실제 네트워크에서 JDK 클라이언트는 협상한 HTTP 버전에 맞추어 이 의미를 HTTP/1.1의 필드나 HTTP/2의 프레임으로 바꿉니다. Host나 연결 관리 필드처럼 클라이언트가 직접 관리하는 제한 필드도 있으므로, 요청 빌더로 모든 원시 필드를 마음대로 덮어쓸 수 있다고 가정하지 않습니다.
응답도 제어 데이터, 필드, 콘텐츠를 분리합니다
같은 등록을 처리한 HTTP/1.1 응답은 다음처럼 읽을 수 있습니다. 응답의 시작 줄은 상태 줄이며, 프로토콜 버전, 세 자리 상태 코드, 이유 문구의 순서로 적힙니다. 이유 문구는 사람이 읽기 위한 것이고 비어 있을 수도 있으므로, 클라이언트는 숫자 상태 코드로 결과를 판단합니다.
HTTP/1.1 201 Created
Content-Type: application/json
Location: /api/posts/42
Content-Length: 105
{"id":42,"title":"HTTP","content":"HTTP 메시지 구조를 정리합니다.","publishedOn":"2026-07-13"}상태 줄의 201은 새 리소스가 만들어졌다는 뜻이고, Location은 그 리소스의 URI를 알려 줍니다. 105는 표시된 UTF-8 JSON 한 줄의 바이트 수입니다. 만들어진 표현을 본문으로 돌려주는 것은 이 API의 선택이며, 본문 없이 201만 돌려주는 설계도 가능합니다. 중요한 것은 상태와 필드, 표현의 형식을 클라이언트와 일관되게 맞추는 것입니다.
Spring MVC에서는 실패 지점이 순서대로 달라집니다
서블릿 기반 Spring MVC에서 등록 요청은 대략 다음 단계를 차례로 거칩니다. 각 단계에서 일어날 수 있는 실패도 함께 적었습니다.
| 단계 | 하는 일 | 이 단계에서 날 수 있는 실패 |
|---|---|---|
| 1 | 서블릿 컨테이너가 HTTP/1.1 프레이밍과 필드 문법을 해석 | 400이나 연결 종료 |
| 2 | 핸들러 매핑이 경로와 메서드, consumes와 produces 조건을 비교 | 415, 406 |
| 3 | HttpMessageConverter가 Content-Type에 맞추어 콘텐츠를 Java 값으로 읽음 | 415, 깨진 JSON의 400 |
| 4 | @Valid가 있고 검증기가 구성되어 있으면 읽은 값을 검증 | 400 |
| 5 | 컨트롤러가 업무 정책을 실행 | 명시적으로 매핑한 409 등 |
| 6 | 응답 협상과 반환값 변환기가 고른 표현을 직렬화 | 406 |
Spring MVC는 처리 경계마다 호출 가능성과 응답 상태를 결정한다
매핑·읽기·검증 통과 뒤 controller를 호출하고 각 실패에서 downstream 0으로 종료한다.
FRAMING · ARGUMENT RESOLUTION · APPLICATION · RESPONSE
Spring MVC는 처리 경계마다 호출 가능성과 응답 상태를 결정한다
모든 4xx가 controller에서 만들어지는 것은 아닙니다. framing, handler 선택, body 변환, validation을 통과해야 application code가 호출되며, 성공 결과도 응답 표현을 쓸 수 있어야 wire 응답이 됩니다.
INGRESS · BEFORE INVOCATION
container와 MVC가 요청 경계와 handler argument를 준비한다
container가 HTTP framing을 해석한다
start-line, field section, content 경계가 유효해야
DispatcherServlet까지 도달합니다. 잘못된 framing의 상태 코드나 연결 처리는 container와 오류 종류에 따라 달라질 수 있습니다.handler mapping이 경로·method·조건을 맞춘다
등록된 mapping과
consumes·produces조건이 후보를 좁힙니다. 이 단계에서 항상 controller가 호출되는 것은 아닙니다.request converter가 content를 argument로 바꾼다
415 지원하지 않는
Content-Type이면 가능하고, 읽을 수 없는 JSON은 MVC 예외 처리에 따라 400이 될 수 있습니다. 두 경우 모두 handler body 전에 끝날 수 있습니다.
VALIDATION · CONTROLLER · POLICY
변환된 argument가 검증을 통과한 뒤 application policy를 실행한다
validation은 만들어진 객체의 제약을 검사한다
@Valid와 실제 Validator 구성이 있고 제약 위반을 오류 응답으로 번역하면 400이 될 수 있고, method body 호출을 막을 수 있습니다.controller는 유효한 argument로 use case를 호출한다
controller는 transport parsing을 반복하지 않고 request DTO와 현재 요청 문맥을 application 경계에 전달합니다.
application policy가 현재 resource 상태를 판정한다
버전·고유성·현재 상태 충돌을 명시적으로 매핑한 경우 409를 선택할 수 있습니다. 모든 업무 실패가 자동으로 409가 되는 것은 아닙니다.
NEGOTIATION · SERIALIZATION
성공 결과도 client가 받을 수 있는 representation으로 써야 한다
application이 성공 의미와 resource 위치를 정한다
생성이 완료되면 201과
Location: /api/posts/42를 선택하고 response DTO를 반환할 수 있습니다.response negotiation이 표현 가능성을 확인한다
Accept·produces는 handler 선택에도 관여합니다. 선택된 결과를 허용 가능한 media type으로 쓸 수 없으면 406이 될 수 있습니다.response converter가 representation을 직렬화한다
협상이 성공하면 converter가 DTO를 JSON octet으로 쓰고
Content-Type을 확정합니다. 이때 성공 경로가 201 Created 응답으로 완성됩니다.
415request media type ·409명시적 application conflict400unreadable content 또는 validation translation ·406response representation201생성 성공과 새 resource 위치
처리 흐름: container framing → mapping·request conversion → validation → controller·application policy → response negotiation·serialization. 이 문서의 standalone MockMvc는 MVC 이후 경계를 검증하며 원시 HTTP 프레이밍은 실행하지 않습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 원 standalone MockMvc 단위의 입력 부분 흐름이다. 매핑 조건이 맞아야 값을 읽고, 구성된 검증을 통과해야 컨트롤러를 호출한다. 왼쪽의 거절은 각 단계에서 갈라지는 대안이며 컨트롤러 호출 뒤에 실행하지 않는다. 실제 HTTP 프레이밍은 이 mock 요청 이전의 별도 컨테이너 경계다.
- 앞 그림의 컨트롤러 호출 이후를 펼쳤다. 원 등록 fixture의 업무 중복은 409와 안정 코드를 만든다. 성공은 JSON writer로 201을 쓴다. writer가 없는 구성의 406은 컨트롤러 호출 뒤에 날 수 있는 별도 변형이다. 원 여섯 테스트에 writer 실패 테스트가 포함됐다는 뜻은 아니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 38 | 읽기 실패 |
| 49 | 쓰기 불가 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 매핑과 읽기 매핑이 맞아야 mock 요청의 값을 읽습니다. | 환경: standalone MockMvc · 매핑 실패: 종료 | 선택된 요청만 입력 처리로 갑니다. |
| 구성된 검증 검증 실패는 controller 전에 거부합니다. | 실패: controller 0 · 정상: 호출 가능 | 준비된 입력만 handler 경계를 넘습니다. |
| controller 실행 통과한 입력으로 controller를 호출합니다. | 결과: handler 실행 · 프레이밍: mock 이전 컨테이너 경계 | 웹 입력 검사와 업무 실행을 구분합니다. |
Spring MVC는 처리 경계마다 호출 가능성과 응답 상태를 결정한다
매핑·읽기·검증 통과 뒤 controller를 호출하고 각 실패에서 downstream 0으로 종료한다.
FRAMING · ARGUMENT RESOLUTION · APPLICATION · RESPONSE
Spring MVC는 처리 경계마다 호출 가능성과 응답 상태를 결정한다
모든 4xx가 controller에서 만들어지는 것은 아닙니다. framing, handler 선택, body 변환, validation을 통과해야 application code가 호출되며, 성공 결과도 응답 표현을 쓸 수 있어야 wire 응답이 됩니다.
INGRESS · BEFORE INVOCATION
container와 MVC가 요청 경계와 handler argument를 준비한다
container가 HTTP framing을 해석한다
start-line, field section, content 경계가 유효해야
DispatcherServlet까지 도달합니다. 잘못된 framing의 상태 코드나 연결 처리는 container와 오류 종류에 따라 달라질 수 있습니다.handler mapping이 경로·method·조건을 맞춘다
등록된 mapping과
consumes·produces조건이 후보를 좁힙니다. 이 단계에서 항상 controller가 호출되는 것은 아닙니다.request converter가 content를 argument로 바꾼다
415 지원하지 않는
Content-Type이면 가능하고, 읽을 수 없는 JSON은 MVC 예외 처리에 따라 400이 될 수 있습니다. 두 경우 모두 handler body 전에 끝날 수 있습니다.
VALIDATION · CONTROLLER · POLICY
변환된 argument가 검증을 통과한 뒤 application policy를 실행한다
validation은 만들어진 객체의 제약을 검사한다
@Valid와 실제 Validator 구성이 있고 제약 위반을 오류 응답으로 번역하면 400이 될 수 있고, method body 호출을 막을 수 있습니다.controller는 유효한 argument로 use case를 호출한다
controller는 transport parsing을 반복하지 않고 request DTO와 현재 요청 문맥을 application 경계에 전달합니다.
application policy가 현재 resource 상태를 판정한다
버전·고유성·현재 상태 충돌을 명시적으로 매핑한 경우 409를 선택할 수 있습니다. 모든 업무 실패가 자동으로 409가 되는 것은 아닙니다.
NEGOTIATION · SERIALIZATION
성공 결과도 client가 받을 수 있는 representation으로 써야 한다
application이 성공 의미와 resource 위치를 정한다
생성이 완료되면 201과
Location: /api/posts/42를 선택하고 response DTO를 반환할 수 있습니다.response negotiation이 표현 가능성을 확인한다
Accept·produces는 handler 선택에도 관여합니다. 선택된 결과를 허용 가능한 media type으로 쓸 수 없으면 406이 될 수 있습니다.response converter가 representation을 직렬화한다
협상이 성공하면 converter가 DTO를 JSON octet으로 쓰고
Content-Type을 확정합니다. 이때 성공 경로가 201 Created 응답으로 완성됩니다.
415request media type ·409명시적 application conflict400unreadable content 또는 validation translation ·406response representation201생성 성공과 새 resource 위치
처리 흐름: container framing → mapping·request conversion → validation → controller·application policy → response negotiation·serialization. 이 문서의 standalone MockMvc는 MVC 이후 경계를 검증하며 원시 HTTP 프레이밍은 실행하지 않습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 원 standalone MockMvc 단위의 입력 부분 흐름이다. 매핑 조건이 맞아야 값을 읽고, 구성된 검증을 통과해야 컨트롤러를 호출한다. 왼쪽의 거절은 각 단계에서 갈라지는 대안이며 컨트롤러 호출 뒤에 실행하지 않는다. 실제 HTTP 프레이밍은 이 mock 요청 이전의 별도 컨테이너 경계다.
- 앞 그림의 컨트롤러 호출 이후를 펼쳤다. 원 등록 fixture의 업무 중복은 409와 안정 코드를 만든다. 성공은 JSON writer로 201을 쓴다. writer가 없는 구성의 406은 컨트롤러 호출 뒤에 날 수 있는 별도 변형이다. 원 여섯 테스트에 writer 실패 테스트가 포함됐다는 뜻은 아니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 38 | 읽기 실패 |
| 49 | 쓰기 불가 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 매핑과 읽기 매핑이 맞아야 mock 요청의 값을 읽습니다. | 환경: standalone MockMvc · 매핑 실패: 종료 | 선택된 요청만 입력 처리로 갑니다. |
| 구성된 검증 검증 실패는 controller 전에 거부합니다. | 실패: controller 0 · 정상: 호출 가능 | 준비된 입력만 handler 경계를 넘습니다. |
| controller 실행 통과한 입력으로 controller를 호출합니다. | 결과: handler 실행 · 프레이밍: mock 이전 컨테이너 경계 | 웹 입력 검사와 업무 실행을 구분합니다. |
그림의 위쪽은 컨트롤러가 호출되기 전의 단계로, 매핑 조건이 맞아야 값을 읽고 검증을 통과해야 컨트롤러가 호출되며, 그 전에 끝나는 415, 406, 400은 각 단계에서 갈라지는 대안입니다. 아래쪽은 컨트롤러 호출 뒤의 단계로, 업무 중복은 409와 안정된 코드를 만들고 성공은 JSON 변환기로 201을 씁니다. 응답을 쓸 변환기가 없는 구성의 406은 컨트롤러가 호출된 뒤에도 날 수 있는 별도의 경우이며, 이 글의 여섯 테스트에 그런 경우가 들어 있다는 뜻은 아닙니다.
이 순서 때문에 상태 코드가 같아도 원인은 다를 수 있습니다. 지원하지 않는 요청 미디어 타입은 매핑의 consumes 조건이나 읽기 변환기를 고르는 단계에서 415가 되고, JSON 문법이 깨지면 읽기 단계에서 보통 400이 되며 컨트롤러는 호출되지 않습니다. JSON을 읽었다고 Bean Validation이 저절로 실행되지도 않습니다. @Valid와 실제로 구성된 검증기가 있어야 검증 실패를 400으로 이을 수 있습니다. 이 테스트는 setValidator(new CreatePostValidator())로 Spring Validator를 직접 지정하고 그 구현이 제목과 본문의 필수 값을 검사하므로, Bean Validation 제공자가 제약 애노테이션을 실행하는 경우와는 구분해 읽습니다. publishedOn은 여기서 문자열로 읽을 뿐 날짜 형식까지 검사하지 않습니다. 중복 등록 같은 업무 정책은 컨트롤러가 실행된 뒤에 명시적으로 409로 바꿀 수 있습니다.
다음 Spring Framework 6.2.11 테스트는 가짜 서블릿 요청에서 시작해 MVC의 매핑, JSON 변환, 검증, 컨트롤러, 응답 변환 단계를 실행합니다. 독립형 MockMvc는 실제 소켓이나 프록시, 서블릿 컨테이너의 네트워크 파서를 열지 않으므로, 원시 CRLF나 Content-Length와 Transfer-Encoding 프레이밍, HTTP/2와 HTTP/3의 프레임은 확인하지 않습니다.
package board.web;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.Valid;
import java.net.URI;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RestControllerAdvice;
final class PostMessageContractTest {
private static final String VALID_JSON =
"{\"title\":\"HTTP\",\"content\":\"HTTP 메시지 구조를 정리합니다.\","
+ "\"publishedOn\":\"2026-07-13\"}";
private static final String CREATED_JSON =
"{\"id\":42,\"title\":\"HTTP\",\"content\":"
+ "\"HTTP 메시지 구조를 정리합니다.\","
+ "\"publishedOn\":\"2026-07-13\"}";
private AtomicInteger invocations;
private ObjectMapper objectMapper;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
invocations = new AtomicInteger();
objectMapper = Jackson2ObjectMapperBuilder.json().build();
mockMvc = MockMvcBuilders
.standaloneSetup(new RegistrationController(invocations))
.setControllerAdvice(new ApiExceptionHandler())
.setValidator(new CreatePostValidator())
.setMessageConverters(
new MappingJackson2HttpMessageConverter(
objectMapper))
.build();
}
@Test
void 올바른_json은_201_location과_json_표현을_반환한다()
throws Exception {
MvcResult result = perform(
VALID_JSON,
MediaType.APPLICATION_JSON,
MediaType.APPLICATION_JSON);
assertEquals(201, result.getResponse().getStatus());
assertEquals("/api/posts/42",
result.getResponse().getHeader(HttpHeaders.LOCATION));
assertCompatible(
MediaType.APPLICATION_JSON,
result.getResponse().getContentType());
byte[] content = result.getResponse().getContentAsByteArray();
assertEquals(105, content.length);
assertEquals(CREATED_JSON, new String(content, UTF_8));
JsonNode body = objectMapper.readTree(content);
assertEquals(42L, body.path("id").longValue());
assertEquals("HTTP", body.path("title").textValue());
assertEquals(1, invocations.get());
}
@Test
void text_plain은_415이고_controller를_호출하지_않는다()
throws Exception {
MvcResult result = perform(
VALID_JSON,
MediaType.TEXT_PLAIN,
MediaType.APPLICATION_JSON);
assertEquals(415, result.getResponse().getStatus());
assertEquals(0, invocations.get());
}
@Test
void 깨진_json은_400이고_controller를_호출하지_않는다()
throws Exception {
MvcResult result = perform(
"{\"title\":\"HTTP\"",
MediaType.APPLICATION_JSON,
MediaType.APPLICATION_JSON);
assertEquals(400, result.getResponse().getStatus());
assertEquals(0, invocations.get());
}
@Test
void validator가_거부한_값은_400이고_controller를_호출하지_않는다()
throws Exception {
String blankTitle =
"{\"title\":\" \",\"content\":\"본문\","
+ "\"publishedOn\":\"2026-07-13\"}";
MvcResult result = perform(
blankTitle,
MediaType.APPLICATION_JSON,
MediaType.APPLICATION_JSON);
assertEquals(400, result.getResponse().getStatus());
assertEquals(0, invocations.get());
}
@Test
void 업무_중복은_409와_안정적인_problem_code를_반환한다()
throws Exception {
String duplicate =
"{\"title\":\"duplicate\",\"content\":\"본문\","
+ "\"publishedOn\":\"2026-07-13\"}";
MvcResult result = perform(
duplicate,
MediaType.APPLICATION_JSON,
MediaType.ALL);
assertEquals(409, result.getResponse().getStatus());
assertCompatible(
MediaType.APPLICATION_PROBLEM_JSON,
result.getResponse().getContentType());
JsonNode body = readBody(result);
assertEquals(409, body.path("status").intValue());
assertEquals("POST_ALREADY_EXISTS",
body.path("code").textValue());
assertEquals(1, invocations.get());
}
@Test
void produces와_맞지_않는_accept는_매핑에서_406이다()
throws Exception {
MvcResult result = perform(
VALID_JSON,
MediaType.APPLICATION_JSON,
MediaType.APPLICATION_XML);
assertEquals(406, result.getResponse().getStatus());
assertEquals(0, invocations.get());
}
private MvcResult perform(
String json,
MediaType contentType,
MediaType accept) throws Exception {
return mockMvc.perform(post("/api/posts")
.contentType(contentType)
.accept(accept)
.content(json))
.andReturn();
}
private JsonNode readBody(MvcResult result) throws Exception {
return objectMapper.readTree(
result.getResponse().getContentAsByteArray());
}
private static void assertCompatible(
MediaType expected,
String actual) {
assertTrue(actual != null);
assertTrue(expected.isCompatibleWith(
MediaType.parseMediaType(actual)));
}
record CreatePostRequest(
String title,
String content,
String publishedOn) {
}
record PostResponse(
long id,
String title,
String content,
String publishedOn) {
}
@RestController
static final class RegistrationController {
private final AtomicInteger invocations;
RegistrationController(AtomicInteger invocations) {
this.invocations = invocations;
}
@PostMapping(
path = "/api/posts",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
ResponseEntity<PostResponse> register(
@Valid @RequestBody CreatePostRequest request) {
invocations.incrementAndGet();
if ("duplicate".equals(request.title())) {
throw new DuplicatePostException();
}
PostResponse response = new PostResponse(
42L,
request.title(),
request.content(),
request.publishedOn());
return ResponseEntity
.created(URI.create("/api/posts/42"))
.body(response);
}
}
static final class CreatePostValidator implements Validator {
@Override
public boolean supports(Class<?> candidate) {
return CreatePostRequest.class.isAssignableFrom(candidate);
}
@Override
public void validate(Object target, Errors errors) {
CreatePostRequest request = (CreatePostRequest) target;
if (request.title() == null
|| request.title().isBlank()) {
errors.rejectValue("title", "title.required");
}
if (request.content() == null
|| request.content().isBlank()) {
errors.rejectValue("content", "content.required");
}
}
}
static final class DuplicatePostException
extends RuntimeException {
}
@RestControllerAdvice
static final class ApiExceptionHandler {
@ExceptionHandler(DuplicatePostException.class)
ResponseEntity<ProblemDetail> handleDuplicate() {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.CONFLICT,
"같은 제목의 게시글이 이미 있습니다.");
problem.setTitle("Post conflict");
problem.setProperty(
"code",
"POST_ALREADY_EXISTS");
return ResponseEntity
.status(HttpStatus.CONFLICT)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problem);
}
}
}이 테스트의 406은 produces = application/json 조건에서 매핑이 실패한 것이므로 컨트롤러 호출 횟수가 0입니다. 깨진 JSON과 공백 제목은 둘 다 400에 호출 0회이지만, 앞의 것은 객체를 읽는 단계에서, 뒤의 것은 읽은 객체를 검증하는 단계에서 멈춥니다. 상태 코드와 호출 횟수만으로는 두 원인을 구별할 수 없으므로 입력 조건과 실패한 단계를 함께 기록합니다. 반대로 메서드가 돌려준 객체를 쓸 변환기가 없는 구성이라면 컨트롤러가 호출된 뒤에도 406이 날 수 있으므로 "406은 언제나 컨트롤러 전"이라고 일반화하지 않습니다.
409 응답은 ProblemDetail의 status뿐 아니라 애플리케이션이 직접 정한 code도 확인합니다. 파서 내부의 예외 문구를 바깥에 약속하지 말고 공개할 안정적인 오류 코드를 직접 정해야, 라이브러리의 버전이나 내부 구조가 바깥에 드러나지 않습니다.
프레이밍 실패는 MVC에 도달하지 않을 수 있습니다
HTTP/1.1에서는 올바른 Content-Length만 있으면 그 바이트 수가 콘텐츠의 끝을 정하고, 마지막 전송 코딩이 chunked이면 마지막 청크(chunk)까지 읽습니다. 요청에 두 필드가 모두 없으면 콘텐츠 길이는 0이고, 길이를 밝히지 않은 응답은 앞의 예외에 해당하지 않으면 연결이 끝나는 곳에서 콘텐츠가 끝날 수 있습니다. 반대로 Content-Length가 실제 콘텐츠와 맞지 않거나 길이 정보가 모호하면, 서블릿 컨테이너나 앞단의 프록시가 MVC 요청을 만들기도 전에 거부할 수 있습니다. 그때 400 응답이 오는지 연결이 바로 끊기는지는 서버와 프록시, 오류가 난 시점에 따라 다르므로, 애플리케이션이 한 가지 형태만 보장한다고 쓰지 않습니다.
프록시와 원 서버가 같은 바이트를 서로 다른 프레이밍 규칙으로 해석하면 요청 밀수(request smuggling) 공격이 생길 수 있습니다. Transfer-Encoding과 Content-Length가 함께 있거나, 값이 다른 Content-Length가 여러 개 있거나, 요청에서 chunked가 마지막 코딩이 아닌 경우에는, 오염된 HTTP/1.1 연결을 다시 쓰지 않고 400과 연결 종료로 거부하는 정책을 모든 구간에서 똑같이 적용합니다. 애플리케이션도 원시 길이 값을 믿고 따로 버퍼를 잡거나, 사용자 입력을 응답 필드 줄에 이어 붙이지 않습니다. 프레임워크의 URI와 헤더 빌더, 입력 검증을 써서 줄바꿈 문자가 새 필드처럼 해석되는 경로를 막습니다.
조회 조건은 URI로 드러냅니다
GET 요청에 콘텐츠를 싣는 것이 문법상 언제나 금지된 것은 아니지만, 그 의미는 일반적으로 정해져 있지 않고 클라이언트와 프록시, 캐시의 지원도 제각각입니다. 그래서 정렬이나 필터, 페이지 같은 보통의 조회 조건은 아무 사용자 정의 헤더가 아니라 URI의 쿼리로 나타냅니다. URI에 담기 어려울 만큼 큰 복합 검색 조건이 필요하다면, 검색 요청을 받는 별도의 리소스에 POST하는 방식으로 설계합니다. 헤더는 인증이나 조건부 요청, 콘텐츠 협상처럼 HTTP가 정의했거나 그 API가 분명히 합의한 메타데이터에 씁니다.
정리
- HTTP/1.1 메시지는 시작 줄, 필드 줄, 빈 줄, 콘텐츠로 이루어지며, 빈 줄은 콘텐츠의 시작을,
Content-Length나chunked는 콘텐츠의 끝을 정합니다. Content-Length는 Java의 글자 수가 아니라 인코딩한 콘텐츠의 바이트 수이고, 시작 줄과 필드, 빈 줄은 포함하지 않습니다.- HTTP/2와 HTTP/3는 텍스트 시작 줄 대신 의사 헤더와 프레임으로 같은 의미를 전달합니다.
- Spring MVC는 매핑, 읽기, 검증, 컨트롤러, 응답 쓰기의 순서로 처리하므로 같은 400이나 406이라도 실패한 단계가 다를 수 있습니다.
- 프레이밍이 모호한 요청은 MVC에 닿기 전에 거부될 수 있으며, 모든 구간이 같은 규칙으로 거부해야 요청 밀수를 막을 수 있습니다.
연습 문제
1. 빈 본문과 Accept 범위
PostMessageContractTest에 본문이 비어 있는 요청을 추가하고 400과 컨트롤러 호출 0회를 함께 확인하세요. 그다음 produces 조건은 그대로 둔 채 Accept: application/*+json과 Accept: */*를 각각 보내 어떤 표현이 선택되는지 응답의 Content-Type까지 기록하세요. 마지막으로 중복 테스트에서 상태 409만 남겼을 때와 code = POST_ALREADY_EXISTS까지 확인했을 때, 클라이언트가 믿고 분기할 수 있는 정보가 어떻게 달라지는지 설명하세요.
해설 보기
필수 @RequestBody가 비어 있으면 읽는 단계에서 요청 값 자체를 만들 수 없으므로, 컨트롤러에 들어가기 전에 400이 되어야 합니다.
application/*+json은 application/json과 늘 같은 미디어 타입이 아니므로 이 컨트롤러의 produces 조건과 Spring의 호환성 판단을 테스트로 확인해야 합니다. 이 단위의 Spring 6.2.11은 접미사(suffix) 와일드카드를 호환되는 것으로 판단하므로, 제공하는 표현이 application/json 하나이고 정상 JSON 요청을 보낸 조건에서는 두 Accept 모두 201과 application/json 응답을 예상합니다. 문자열이 같아서가 아니라 매핑의 호환성 판단과 쓸 수 있는 변환기가 맞기 때문입니다. 의존성이나 제공하는 표현을 바꾸었다면 같은 결과를 가정하지 말고 상태와 응답 타입, 컨트롤러 호출 횟수를 다시 확인합니다. */*도 어떤 응답이든 성공시킨다는 뜻이 아니라, 서버가 줄 수 있는 표현을 제한하지 않는다는 뜻이며 이 예제에서는 JSON 변환기와 produces 조건이 JSON을 고릅니다.
상태 409는 충돌이라는 범주만 알려 줍니다. 안정적인 코드가 있어야 클라이언트가 사람이 읽는 detail 문구나 내부 예외 클래스 이름에 기대지 않고 "같은 제목" 정책을 구분할 수 있습니다.
2. 바이트 수 다시 세기
다음 변형마다 콘텐츠 길이를 구하세요. 모든 콘텐츠는 UTF-8이며, 원시 연결을 열거나 Content-Length를 직접 정하지 말고 getBytes(UTF_8).length와 BodyPublishers.ofString(..., UTF_8).contentLength()로 확인합니다. ① 요청과 응답 JSON에서 title 값 HTTP만 웹으로 바꿉니다. ② 97바이트 요청 JSON 뒤에 LF 하나, 또는 CRLF 하나를 붙입니다. 시작 줄과 필드의 CRLF도 더해야 할까요? ③ 줄바꿈 없이 title의 HTTP만 😀로 바꿉니다. title 자체의 Java length()와 UTF-8 바이트 수도 구하세요. ④ 같은 리소스의 GET 200 표현이 105바이트일 때, HEAD 응답에 Content-Length: 105가 있다면 실제로 105바이트를 읽어야 할까요?
해설 보기
① HTTP는 ASCII 네 글자여서 4바이트이고, 웹은 UTF-8로 3바이트입니다. 다른 부분은 그대로이므로 각각 1바이트씩 줄어 요청은 97 − 4 + 3 = 96바이트, 응답은 105 − 4 + 3 = 104바이트입니다. 한글 한 글자를 Java의 글자 수처럼 1바이트로 계산하면 틀립니다.
② LF는 1바이트이므로 98, CRLF는 2바이트이므로 99입니다. 붙인 줄바꿈은 콘텐츠에 들어가지만, 시작 줄과 필드, 필드 끝의 빈 줄은 들어가지 않습니다. 콘텐츠를 바꾸고도 예전 길이 97을 그대로 보내면 프레이밍이 맞지 않게 됩니다.
③ ASCII HTTP는 4바이트이고 😀도 UTF-8로 4바이트이므로 전체 길이는 97로 같습니다. 다만 title의 Java length()는 UTF-16 코드 단위 2개여서 2이고, UTF-8 인코딩은 4바이트입니다. 화면에 한 글자로 보인다는 것으로 전송 길이를 계산하지 않습니다.
④ 읽지 않습니다. HEAD 응답에는 콘텐츠가 없고, 105는 같은 조건의 GET 표현의 길이를 알려 줄 뿐입니다. Content-Length의 숫자를 보기 전에 요청 메서드와 응답 상태가 콘텐츠를 허용하는지부터 확인해야 합니다.
다음 글 HTTP 메서드 속성에서는 요청 줄의 메서드가 안전성, 멱등성, 캐시 가능성에 대해 어떤 기대를 만들고, 그것이 재시도와 중복 등록 정책에 어떤 영향을 주는지 다룹니다.