본문 바로가기

HTTP 메시지 구조

HTTP/1.1 요청·응답의 시작줄, 필드, 빈 줄, 콘텐츠 경계를 실제 바이트 수로 확인하고 서블릿 컨테이너의 프레이밍과 Spring MVC의 매핑·변환 실패 경계를 구분합니다.

컨트롤러 파라미터 바인딩 오류를 이해하려면 JSON 본문만 보아서는 안 됩니다.

HTTP 메시지는 제어 데이터, 필드, 콘텐츠라는 의미 요소를 가지며, HTTP 버전마다 이를 전송하는 문법이 다릅니다.

이 문서의 원문 예시는 시작줄과 CRLF 구분을 사용하는 HTTP/1.1 전송 문법입니다.

HTTP/1.1 요청과 201 응답을 start-line, field lines, 빈 줄, content 순서로 나누고, 표시한 한 줄 JSON의 UTF-8 길이가 각각 Content-Length 97과 105임을 설명합니다.

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가 온다

  1. start-line이 메시지 역할을 정한다

    요청은 method·target·HTTP version을, 응답은 HTTP version·status code·reason phrase를 적습니다.

  2. field lines가 현재 메시지의 metadata를 운반한다

    Content-Type, Accept, Location, framing field는 방향과 의미가 서로 다릅니다.

  3. 빈 줄이 field section을 닫는다

    HTTP/1.1 wire 형식에서는 CRLF로 끝나는 빈 줄 뒤부터 content가 시작됩니다.

  4. 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는 제외합니다.

먼저 앞 절에서 준비한 별도 테스트 단위에 title 경로대로 HttpRequestShapeTest와 PostMessageContractTest 두 파일을 둡니다. 앞 테스트는 실제 요청을 전송하지 않고 HttpRequest의 구성과 본문 발행기의 길이를 확인합니다. MVC 테스트는 Java 17·Spring Framework 6.2.11과 원 import에 맞는 JUnit·Jackson·Jakarta Validation API를 준비하고, standalone MockMvc가 실행하는 여섯 요청을 각각 독립 fixture에서 비교합니다. 원 예제의 버전 묶음을 누적 Boot 프로젝트의 일부 의존성과 섞지 않습니다.

읽을 때는 길이를 세는 JDK 테스트, 요청을 받는 MVC 테스트, 실제 연결에서 메시지 경계를 정하는 컨테이너를 나누세요. 앞의 두 테스트가 통과해도 원시 HTTP/1.1 프레이밍까지 검증한 것은 아닙니다.


HTTP/1.1은 빈 줄에서 필드와 콘텐츠가 갈립니다

다음 등록 요청에서 첫 줄은 요청 라인, 이어지는 줄은 필드, 첫 번째 빈 줄 뒤는 메시지 콘텐츠입니다.

등록 요청 — 콘텐츠 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이 정합니다. 본문에 줄바꿈이 들어가도 그것을 다음 메시지의 시작으로 해석하지 않도록, 먼저 프레이밍으로 정한 바이트를 읽고 그 안을 JSON으로 해석합니다.

위 Content-Length: 97은 UTF-8 JSON 한 줄만 센 값이며 콘텐츠 뒤에 줄바꿈 바이트가 붙지 않는다는 계약입니다.

브라우저 개발자 도구는 메시지를 사람이 읽기 좋게 재구성할 수 있으므로 화면의 줄바꿈을 실제 전송 바이트와 같다고 가정하지 않습니다.

HTTP/2와 HTTP/3에는 이 텍스트 요청 라인과 빈 줄이 없습니다.

대신 요청 스트림의 HEADERS 프레임에서 시작되는 압축된 필드 섹션과 :method, :scheme, :authority, :path 같은 의사 헤더가 같은 의미를 운반하고, 콘텐츠는 DATA 프레임과 스트림 종료로 경계가 정해집니다.

HTTP/2·HTTP/3는 Transfer-Encoding: chunked를 사용하지 않습니다.

이 네 의사 헤더 목록은 일반 요청을 설명한 것이며, 전통적인 CONNECT는 authority를 대상으로 삼고 :scheme과 :path를 생략하는 별도 규칙을 따릅니다.

따라서 HTTP/1.1의 CRLF 문법을 HTTP/2·HTTP/3 프레임에 그대로 적용해서는 안 됩니다.


필드는 콘텐츠와 처리 조건을 설명합니다

필드 이름은 대소문자를 구분하지 않지만 같은 이름의 여러 필드 줄을 결합할 수 있는지는 각 필드 정의에 달려 있습니다.

모든 중복 필드를 임의로 쉼표 문자열로 합치지 않습니다.

필드방향게시판 계약에서의 의미
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의 문자 수가 아니라 전송되는 콘텐츠의 옥텟 수입니다.

옥텟 하나는 8비트이며 이 문서에서는 바이트와 같은 단위입니다. Java String.length()는 UTF-16 코드 단위 수를 세므로 한글이나 보충 문자까지 포함한 문자열의 UTF-8 바이트 수와 같지 않습니다. 실제 계산은 예제처럼 인코딩한 byte[]의 길이로 확인하세요.

다만 이 필드가 있다고 해서 모든 응답에 실제 콘텐츠가 따라오는 것은 아닙니다.

HEAD 응답은 같은 요청을 GET으로 처리했을 때 보냈을 콘텐츠 길이와 일치할 때, 304 응답은 같은 조건부 요청에 대한 200 응답에서 보냈을 콘텐츠 길이와 일치할 때 Content-Length를 포함할 수 있지만 그 응답 자체에는 메시지 콘텐츠가 없습니다.

1xx와 204 응답에는 Content-Length를 보내지 않으며, 성공한 CONNECT 응답에도 사용할 수 없습니다.

애플리케이션은 문자열 길이를 직접 헤더에 쓰기보다 HTTP 라이브러리가 인코딩 뒤 길이를 계산하거나 해당 버전의 프레이밍을 선택하게 합니다.


JDK API에서 97바이트 계약을 실행합니다

JDK HttpRequest는 메서드, URI, 필드, 본문 발행기를 각각 표현합니다.

다음 테스트의 JSON은 줄바꿈이 없는 한 줄이며 UTF-8로 정확히 97바이트입니다.

src/test/java/board/http/HttpRequestShapeTest.java
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와 연결 관리 필드처럼 클라이언트가 통제하는 제한 필드도 있으므로 요청 빌더가 모든 원시 필드를 임의로 덮어쓰게 해 준다고 가정하지 않습니다.


바이트 수를 바꿔 확인하기

원 요청과 응답의 JSON에서 title 값 HTTP만 웹으로 바꾸고 나머지 문자·공백·줄바꿈 조건을 유지한다고 가정하세요. 새 콘텐츠 길이를 각각 계산해 보세요. Java 문자열의 글자 수가 줄어든 만큼을 바로 Content-Length에서 빼도 될까요?

UTF-8 바이트를 세는 해설

HTTP는 ASCII 네 글자여서 UTF-8 네 바이트이고, 웹은 UTF-8 세 바이트입니다. 다른 부분은 그대로이므로 각각 한 바이트만 줄어 요청 97 − 4 + 3 = 96바이트, 응답 105 − 4 + 3 = 104바이트입니다. 원 계약처럼 콘텐츠 끝에 줄바꿈을 붙이지 않습니다. UTF-8 한글 한 글자가 Java의 글자 수와 같은 한 바이트라고 계산하면 틀립니다.

응답도 제어 데이터, 필드, 콘텐츠를 분리합니다

같은 등록을 처리한 HTTP/1.1 응답은 다음처럼 읽을 수 있습니다.

등록 응답 — 콘텐츠 105바이트
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 프레이밍과 필드 문법을 해석합니다.
  2. 핸들러 매핑이 경로·메서드와 consumes·produces 조건을 대조합니다.
  3. HttpMessageConverter가 Content-Type에 맞추어 콘텐츠를 Java 값으로 읽습니다.
  4. 파라미터에 @Valid가 있고 Validator가 구성되었을 때 변환된 값을 검증합니다.
  5. 컨트롤러가 업무 정책을 실행합니다.
  6. 응답 협상과 반환 값 converter가 선택된 표현을 직렬화합니다.

Spring MVC는 처리 경계마다 호출 가능성과 응답 상태를 결정한다

매핑·읽기·검증 통과 뒤 controller를 호출하고 각 실패에서 downstream 0으로 종료한다.

HTTP container framing부터 Spring MVC handler mapping, request conversion, validation, controller와 application policy, response negotiation과 serialization까지의 경계를 따라 조건부 400, 415, 406, 409와 성공 201의 위치를 설명합니다.

FRAMING · ARGUMENT RESOLUTION · APPLICATION · RESPONSE

Spring MVC는 처리 경계마다 호출 가능성과 응답 상태를 결정한다

모든 4xx가 controller에서 만들어지는 것은 아닙니다. framing, handler 선택, body 변환, validation을 통과해야 application code가 호출되며, 성공 결과도 응답 표현을 쓸 수 있어야 wire 응답이 됩니다.

입력 조건과 컨트롤러 호출의 경계원 standalone MockMvc 단위의 입력 부분 흐름이다. 매핑 조건이 맞아야 값을 읽고, 구성된 검증을 통과해야 컨트롤러를 호출한다. 왼쪽의 거절은 각 단계에서 갈라지는 대안이며 컨트롤러 호출 뒤에 실행하지 않는다. 실제 HTTP 프레이밍은 이 mock 요청 이전의 별도 컨테이너 경계다.조건 불일치JSON 읽기 실패검증 실패매핑 조건 대조method·target·consumes·producesJSON을 Java 값으로 읽기Content-Type에 맞는 converter값의 제약 검증@Valid와 Validator가 구성된 경우컨트롤러 호출다음 응답 흐름으로 이어짐호출 전 거절조건별 415·406JSON 읽기·검증 실패 400원 fixture의 입력 경계
호출 뒤의 업무 충돌과 응답 쓰기앞 그림의 컨트롤러 호출 이후를 펼쳤다. 원 등록 fixture의 업무 중복은 409와 안정 코드를 만든다. 성공은 JSON writer로 201을 쓴다. writer가 없는 구성의 406은 컨트롤러 호출 뒤에 날 수 있는 별도 변형이다. 원 여섯 테스트에 writer 실패 테스트가 포함됐다는 뜻은 아니다.중복 정책writer 없음컨트롤러에서 업무 정책 실행이 단계부터 호출 횟수는 0이 아님응답 converter로 표현 쓰기선택된 응답 미디어 타입201 · JSON과 새 URI원 fixture의 정상 등록 결과409 · 업무 충돌POST_ALREADY_EXISTS406 · writer 없음본문에서 구분한 구성 변형
입력 조건과 컨트롤러 호출의 경계원 standalone MockMvc 단위의 입력 부분 흐름이다. 매핑 조건이 맞아야 값을 읽고, 구성된 검증을 통과해야 컨트롤러를 호출한다. 왼쪽의 거절은 각 단계에서 갈라지는 대안이며 컨트롤러 호출 뒤에 실행하지 않는다. 실제 HTTP 프레이밍은 이 mock 요청 이전의 별도 컨테이너 경계다.조건불일치읽기실패검증실패매핑 조건 대조consumes·producesmethod·targetJSON을 값으로읽기 converter값의 제약 검증@Valid·Validator구성된 경우 적용컨트롤러 호출아래 응답 흐름으로호출 전 거절조건별 415·406읽기·검증 400원 fixture의 범위
호출 뒤의 업무 충돌과 응답 쓰기앞 그림의 컨트롤러 호출 이후를 펼쳤다. 원 등록 fixture의 업무 중복은 409와 안정 코드를 만든다. 성공은 JSON writer로 201을 쓴다. writer가 없는 구성의 406은 컨트롤러 호출 뒤에 날 수 있는 별도 변형이다. 원 여섯 테스트에 writer 실패 테스트가 포함됐다는 뜻은 아니다.중복 정책쓰기불가컨트롤러 업무호출 이후의 판단응답 converter선택된 표현 쓰기201 · JSON새 URI와 표현409 · 충돌안정된 업무 코드406 · 거절writer 실패 변형

INGRESS · BEFORE INVOCATION

container와 MVC가 요청 경계와 handler argument를 준비한다

  1. container가 HTTP framing을 해석한다

    start-line, field section, content 경계가 유효해야 DispatcherServlet까지 도달합니다. 잘못된 framing의 상태 코드나 연결 처리는 container와 오류 종류에 따라 달라질 수 있습니다.

  2. handler mapping이 경로·method·조건을 맞춘다

    등록된 mapping과 consumes·produces 조건이 후보를 좁힙니다. 이 단계에서 항상 controller가 호출되는 것은 아닙니다.

  3. request converter가 content를 argument로 바꾼다

    415 지원하지 않는 Content-Type이면 가능하고, 읽을 수 없는 JSON은 MVC 예외 처리에 따라 400이 될 수 있습니다. 두 경우 모두 handler body 전에 끝날 수 있습니다.

VALIDATION · CONTROLLER · POLICY

변환된 argument가 검증을 통과한 뒤 application policy를 실행한다

  1. validation은 만들어진 객체의 제약을 검사한다

    @Valid와 실제 Validator 구성이 있고 제약 위반을 오류 응답으로 번역하면 400이 될 수 있고, method body 호출을 막을 수 있습니다.

  2. controller는 유효한 argument로 use case를 호출한다

    controller는 transport parsing을 반복하지 않고 request DTO와 현재 요청 문맥을 application 경계에 전달합니다.

  3. application policy가 현재 resource 상태를 판정한다

    버전·고유성·현재 상태 충돌을 명시적으로 매핑한 경우 409를 선택할 수 있습니다. 모든 업무 실패가 자동으로 409가 되는 것은 아닙니다.

NEGOTIATION · SERIALIZATION

성공 결과도 client가 받을 수 있는 representation으로 써야 한다

  1. application이 성공 의미와 resource 위치를 정한다

    생성이 완료되면 201과 Location: /api/posts/42를 선택하고 response DTO를 반환할 수 있습니다.

  2. response negotiation이 표현 가능성을 확인한다

    Accept·produces는 handler 선택에도 관여합니다. 선택된 결과를 허용 가능한 media type으로 쓸 수 없으면 406이 될 수 있습니다.

  3. response converter가 representation을 직렬화한다

    협상이 성공하면 converter가 DTO를 JSON octet으로 쓰고 Content-Type을 확정합니다. 이때 성공 경로가 201 Created 응답으로 완성됩니다.

  • 415 request media type · 409 명시적 application conflict
  • 400 unreadable content 또는 validation translation · 406 response representation
  • 201 생성 성공과 새 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으로 종료한다.

HTTP container framing부터 Spring MVC handler mapping, request conversion, validation, controller와 application policy, response negotiation과 serialization까지의 경계를 따라 조건부 400, 415, 406, 409와 성공 201의 위치를 설명합니다.

FRAMING · ARGUMENT RESOLUTION · APPLICATION · RESPONSE

Spring MVC는 처리 경계마다 호출 가능성과 응답 상태를 결정한다

모든 4xx가 controller에서 만들어지는 것은 아닙니다. framing, handler 선택, body 변환, validation을 통과해야 application code가 호출되며, 성공 결과도 응답 표현을 쓸 수 있어야 wire 응답이 됩니다.

입력 조건과 컨트롤러 호출의 경계원 standalone MockMvc 단위의 입력 부분 흐름이다. 매핑 조건이 맞아야 값을 읽고, 구성된 검증을 통과해야 컨트롤러를 호출한다. 왼쪽의 거절은 각 단계에서 갈라지는 대안이며 컨트롤러 호출 뒤에 실행하지 않는다. 실제 HTTP 프레이밍은 이 mock 요청 이전의 별도 컨테이너 경계다.조건 불일치JSON 읽기 실패검증 실패매핑 조건 대조method·target·consumes·producesJSON을 Java 값으로 읽기Content-Type에 맞는 converter값의 제약 검증@Valid와 Validator가 구성된 경우컨트롤러 호출다음 응답 흐름으로 이어짐호출 전 거절조건별 415·406JSON 읽기·검증 실패 400원 fixture의 입력 경계
호출 뒤의 업무 충돌과 응답 쓰기앞 그림의 컨트롤러 호출 이후를 펼쳤다. 원 등록 fixture의 업무 중복은 409와 안정 코드를 만든다. 성공은 JSON writer로 201을 쓴다. writer가 없는 구성의 406은 컨트롤러 호출 뒤에 날 수 있는 별도 변형이다. 원 여섯 테스트에 writer 실패 테스트가 포함됐다는 뜻은 아니다.중복 정책writer 없음컨트롤러에서 업무 정책 실행이 단계부터 호출 횟수는 0이 아님응답 converter로 표현 쓰기선택된 응답 미디어 타입201 · JSON과 새 URI원 fixture의 정상 등록 결과409 · 업무 충돌POST_ALREADY_EXISTS406 · writer 없음본문에서 구분한 구성 변형
입력 조건과 컨트롤러 호출의 경계원 standalone MockMvc 단위의 입력 부분 흐름이다. 매핑 조건이 맞아야 값을 읽고, 구성된 검증을 통과해야 컨트롤러를 호출한다. 왼쪽의 거절은 각 단계에서 갈라지는 대안이며 컨트롤러 호출 뒤에 실행하지 않는다. 실제 HTTP 프레이밍은 이 mock 요청 이전의 별도 컨테이너 경계다.조건불일치읽기실패검증실패매핑 조건 대조consumes·producesmethod·targetJSON을 값으로읽기 converter값의 제약 검증@Valid·Validator구성된 경우 적용컨트롤러 호출아래 응답 흐름으로호출 전 거절조건별 415·406읽기·검증 400원 fixture의 범위
호출 뒤의 업무 충돌과 응답 쓰기앞 그림의 컨트롤러 호출 이후를 펼쳤다. 원 등록 fixture의 업무 중복은 409와 안정 코드를 만든다. 성공은 JSON writer로 201을 쓴다. writer가 없는 구성의 406은 컨트롤러 호출 뒤에 날 수 있는 별도 변형이다. 원 여섯 테스트에 writer 실패 테스트가 포함됐다는 뜻은 아니다.중복 정책쓰기불가컨트롤러 업무호출 이후의 판단응답 converter선택된 표현 쓰기201 · JSON새 URI와 표현409 · 충돌안정된 업무 코드406 · 거절writer 실패 변형

INGRESS · BEFORE INVOCATION

container와 MVC가 요청 경계와 handler argument를 준비한다

  1. container가 HTTP framing을 해석한다

    start-line, field section, content 경계가 유효해야 DispatcherServlet까지 도달합니다. 잘못된 framing의 상태 코드나 연결 처리는 container와 오류 종류에 따라 달라질 수 있습니다.

  2. handler mapping이 경로·method·조건을 맞춘다

    등록된 mapping과 consumes·produces 조건이 후보를 좁힙니다. 이 단계에서 항상 controller가 호출되는 것은 아닙니다.

  3. request converter가 content를 argument로 바꾼다

    415 지원하지 않는 Content-Type이면 가능하고, 읽을 수 없는 JSON은 MVC 예외 처리에 따라 400이 될 수 있습니다. 두 경우 모두 handler body 전에 끝날 수 있습니다.

VALIDATION · CONTROLLER · POLICY

변환된 argument가 검증을 통과한 뒤 application policy를 실행한다

  1. validation은 만들어진 객체의 제약을 검사한다

    @Valid와 실제 Validator 구성이 있고 제약 위반을 오류 응답으로 번역하면 400이 될 수 있고, method body 호출을 막을 수 있습니다.

  2. controller는 유효한 argument로 use case를 호출한다

    controller는 transport parsing을 반복하지 않고 request DTO와 현재 요청 문맥을 application 경계에 전달합니다.

  3. application policy가 현재 resource 상태를 판정한다

    버전·고유성·현재 상태 충돌을 명시적으로 매핑한 경우 409를 선택할 수 있습니다. 모든 업무 실패가 자동으로 409가 되는 것은 아닙니다.

NEGOTIATION · SERIALIZATION

성공 결과도 client가 받을 수 있는 representation으로 써야 한다

  1. application이 성공 의미와 resource 위치를 정한다

    생성이 완료되면 201과 Location: /api/posts/42를 선택하고 response DTO를 반환할 수 있습니다.

  2. response negotiation이 표현 가능성을 확인한다

    Accept·produces는 handler 선택에도 관여합니다. 선택된 결과를 허용 가능한 media type으로 쓸 수 없으면 406이 될 수 있습니다.

  3. response converter가 representation을 직렬화한다

    협상이 성공하면 converter가 DTO를 JSON octet으로 쓰고 Content-Type을 확정합니다. 이때 성공 경로가 201 Created 응답으로 완성됩니다.

  • 415 request media type · 409 명시적 application conflict
  • 400 unreadable content 또는 validation translation · 406 response representation
  • 201 생성 성공과 새 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 이전 컨테이너 경계웹 입력 검사와 업무 실행을 구분합니다.

이 순서 때문에 상태 코드만 같아도 원인이 같지는 않습니다.

  • 지원하지 않는 요청 미디어 타입은 매핑의 consumes 조건이나 읽기 converter 선택에서 415가 될 수 있습니다.

  • JSON 문법이 깨지면 읽기 converter 단계에서 보통 400이 되고 컨트롤러는 호출되지 않습니다.

  • JSON을 읽었다고 자동으로 Bean Validation이 실행되는 것은 아닙니다. @Valid와 실제 Validator 구성이 있어야 검증 실패를 400으로 연결할 수 있습니다.

    이 테스트는 setValidator(new CreatePostValidator())로 Spring Validator를 직접 지정합니다. @Valid가 검증을 요청하고 그 구현이 제목·본문의 필수값을 검사하므로, 별도의 Bean Validation 제공자가 제약 애노테이션을 실행한 테스트와 구분합니다. publishedOn은 여기서 문자열로 읽을 뿐 날짜 형식까지 검사하지 않습니다.

  • 중복 등록 같은 애플리케이션 정책은 컨트롤러가 실행된 뒤 명시적으로 409로 매핑할 수 있습니다.

  • 406은 produces 조건을 대조하는 매핑 단계에서 컨트롤러 전에 발생할 수도 있고, 반환 값에 맞는 writer를 찾는 단계에서 컨트롤러 뒤에 발생할 수도 있습니다.

다음 Spring Framework 6.2.11 테스트는 mock Servlet 요청에서 시작해 MVC의 매핑, JSON 변환, 검증, 컨트롤러, 응답 변환 경계를 실행합니다.

standalone MockMvc는 실제 소켓이나 프록시·서블릿 컨테이너의 네트워크 파서를 열지 않으므로 원시 CRLF, Content-Length·Transfer-Encoding 프레이밍, HTTP/2·HTTP/3 프레임을 검증한 증거는 아닙니다.

src/test/java/board/web/PostMessageContractTest.java
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회지만 앞의 것은 객체를 읽는 단계, 뒤의 것은 읽은 객체를 검증하는 단계에서 멈춥니다. 상태와 호출 횟수만으로 두 원인을 구별할 수 없으므로, 각각의 입력 조건과 실패 지점을 함께 기록하세요.

반대로 메서드가 반환한 객체를 직렬화할 수 있는 writer가 뒤늦게 없어진 구성이라면 컨트롤러 호출 뒤에도 406이 날 수 있으므로 “406은 항상 컨트롤러 전”이라고 일반화하지 않습니다.

409 응답은 ProblemDetail의 status뿐 아니라 애플리케이션이 직접 정한 code도 검증합니다.

파서의 내부 예외 문구를 외부 계약으로 삼지 말고, 공개할 안정적인 오류 코드를 명시적으로 소유해야 라이브러리 버전과 내부 구조가 노출되지 않습니다.


프레이밍 실패는 MVC에 도달하지 않을 수 있습니다

HTTP/1.1에서 유효한 Content-Length만 있으면 그 옥텟 수가 경계를 정하고, 최종 전송 코딩이 chunked이면 종료 chunk까지 읽습니다.

요청에 두 필드가 모두 없으면 콘텐츠 길이는 0이며, 길이를 선언하지 않은 응답은 앞선 예외에 해당하지 않을 때 연결 종료로 경계가 정해질 수 있습니다.

반대로 Content-Length가 실제 콘텐츠와 충돌하거나 길이 정보가 모호하면 서블릿 컨테이너나 앞단 프록시가 MVC 요청을 만들기 전에 거부할 수 있습니다.

그때 보이는 결과가 400 응답인지 즉시 연결 종료인지는 서버, 프록시, 오류 시점에 따라 달라지므로 애플리케이션이 한 형태만 보장한다고 쓰지 않습니다.

프록시와 원 서버가 서로 다른 프레이밍 규칙으로 같은 바이트를 해석하면 요청 밀수 공격의 경계가 생깁니다.

Transfer-Encoding과 Content-Length의 동시 존재, 서로 다른 중복 Content-Length, 요청에서 chunked가 최종 코딩이 아닌 경우는 오염된 HTTP/1.1 연결을 재사용하지 않고 400과 연결 종료로 거부하는 정책을 모든 홉에서 일관되게 적용합니다.

애플리케이션도 원시 길이 값을 신뢰해 별도 버퍼를 할당하거나 사용자 입력을 응답 필드 줄에 이어 붙이지 않습니다.

프레임워크의 URI·헤더 빌더와 입력 검증을 사용해 개행 문자가 추가 필드처럼 해석되는 경로를 닫습니다.


조회 조건은 URI 계약으로 드러냅니다

GET 콘텐츠가 프로토콜 문법상 언제나 금지되는 것은 아니지만 그 의미는 일반적으로 정의되지 않았고 클라이언트·프록시·캐시 지원도 일관되지 않습니다.

일반적인 정렬, 필터, 페이지 조건은 임의의 사용자 정의 헤더가 아니라 URI 쿼리로 표현합니다.

URI에 담기 어려울 만큼 큰 복합 검색 조건이 필요하다면 검색 요청을 받는 별도 리소스에 POST하는 계약을 설계합니다.

헤더는 인증, 조건부 요청, 콘텐츠 협상처럼 HTTP가 정의하거나 해당 API가 명시적으로 합의한 메타데이터에 사용합니다.


연습 문제

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 응답을 예상합니다. 미디어 타입 문자열이 같아서가 아니라 매핑의 호환성 판단과 사용 가능한 converter가 맞기 때문입니다. 의존성이나 제공 표현을 바꾼 변형은 같은 결과를 가정하지 말고 상태·응답 타입·컨트롤러 호출 횟수를 다시 확인하세요.

*/*는 어떤 응답도 무조건 성공시킨다는 뜻이 아니라 서버가 제공할 수 있는 표현을 제한하지 않는 범위이며, 이 예제에서는 JSON converter와 produces 조건이 JSON을 선택합니다.

상태 409는 충돌 범주만 알려 줍니다.

명시적인 안정 코드가 있어야 클라이언트가 사람이 읽는 detail 문구나 내부 예외 클래스명에 의존하지 않고 “같은 제목” 정책을 구분할 수 있습니다.

다음 문서에서는 요청 라인의 메서드가 안전성, 멱등성, 캐시 가능성에 어떤 기대를 만들고 재시도와 중복 등록 정책에 어떤 영향을 주는지 다룹니다.

연습: 표시된 글자와 전송 콘텐츠 길이를 구분하기

위 요청의 97바이트 JSON을 그대로 기준으로 삼습니다. JSON 문자열 내부의 문자·공백은 바꾸지 않고 지정한 변형만 적용하며, 모든 콘텐츠는 UTF-8입니다. 원시 연결을 열거나 Content-Length를 수동 설정하지 않고 getBytes(UTF_8).length와 BodyPublishers.ofString(..., UTF_8).contentLength()로 계산을 확인하세요.

  1. JSON 뒤에 LF 하나만 붙인 경우와 CRLF 하나를 붙인 경우의 새 길이를 각각 구하세요. 시작줄과 필드의 CRLF도 이 길이에 더해야 하나요?
  2. 뒤에 줄바꿈을 붙이지 않고 title 값의 HTTP만 😀로 바꾸면 콘텐츠 길이가 달라지나요? title 자체의 Java length()와 UTF-8 바이트 수를 구하세요.
  3. 같은 리소스의 GET 200 표현이 위 응답 JSON과 같은 105바이트라고 별도로 가정합니다. 같은 조건의 HEAD 응답에 Content-Length: 105가 있다면 실제 콘텐츠 105바이트를 읽어야 하나요?
프레이밍과 인코딩 조건을 나누는 풀이
  1. LF는 1바이트이므로 98, CRLF는 2바이트이므로 99입니다. 추가한 줄바꿈은 콘텐츠에 속하지만 시작줄·필드·필드 끝 빈 줄은 속하지 않습니다. 콘텐츠를 바꾼 뒤 예전 길이 97을 그대로 선언하면 프레이밍 계약이 맞지 않습니다.
  2. ASCII HTTP는 4바이트이고 😀도 UTF-8로 4바이트이므로 전체 길이는 97로 같습니다. 다만 title의 Java length()는 UTF-16 코드 단위 2개를, UTF-8 인코딩은 4바이트를 반환합니다. 화면에서 한 글자로 보인다는 사실로 전송 길이를 계산하지 않습니다.
  3. 읽지 않습니다. 이 HEAD 응답에는 콘텐츠가 없고, 105는 같은 조건의 GET 표현 길이를 나타냅니다. Content-Length 숫자만 보기 전에 요청 메서드와 응답 상태가 콘텐츠를 허용하는지 확인해야 합니다.