HTTP 메시지 구조
HTTP/1.1 요청·응답의 시작줄, 필드, 빈 줄, 콘텐츠 경계를 실제 바이트 수로 확인하고 서블릿 컨테이너의 프레이밍과 Spring MVC의 매핑·변환 실패 경계를 구분합니다.
컨트롤러 파라미터 바인딩 오류를 이해하려면 JSON 본문만 보아서는 안 됩니다.
HTTP 메시지는 제어 데이터, 필드, 콘텐츠라는 의미 요소를 가지며, 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
{"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
{"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로 인코딩한 content octet만 센다.
HTTP/1.1은 빈 줄에서 필드와 콘텐츠가 갈립니다
다음 등록 요청에서 첫 줄은 요청 라인, 이어지는 줄은 필드, 첫 번째 빈 줄 뒤는 메시지 콘텐츠입니다.
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은 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의 문자 수가 아니라 전송되는 콘텐츠의 옥텟 수입니다.
다만 이 필드가 있다고 해서 모든 응답에 실제 콘텐츠가 따라오는 것은 아닙니다.
HEAD 응답은 같은 요청을 GET으로 처리했을 때 보냈을 콘텐츠 길이와 일치할 때, 304 응답은 같은 조건부 요청에 대한 200 응답에서 보냈을 콘텐츠 길이와 일치할 때 Content-Length를 포함할 수 있지만 그 응답 자체에는 메시지 콘텐츠가 없습니다.
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는 이 발행기가 보낼 콘텐츠 길이입니다.
실제 네트워크에서 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의 등록 요청은 대략 다음 경계를 통과합니다.
- 서블릿 컨테이너가 HTTP/1.1 프레이밍과 필드 문법을 해석합니다.
- 핸들러 매핑이 경로·메서드와
consumes·produces조건을 대조합니다. HttpMessageConverter가Content-Type에 맞추어 콘텐츠를 Java 값으로 읽습니다.- 파라미터에
@Valid가 있고 Validator가 구성되었을 때 변환된 값을 검증합니다. - 컨트롤러가 업무 정책을 실행합니다.
- 응답 협상과 반환 값 converter가 선택된 표현을 직렬화합니다.
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제약 위반을 오류 응답으로 번역하면 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
이 순서 때문에 상태 코드만 같아도 원인이 같지는 않습니다.
- 지원하지 않는 요청 미디어 타입은 매핑의
consumes조건이나 읽기 converter 선택에서 415가 될 수 있습니다. - JSON 문법이 깨지면 읽기 converter 단계에서 보통 400이 되고 컨트롤러는 호출되지 않습니다.
- JSON을 읽었다고 자동으로 Bean Validation이 실행되는 것은 아닙니다.
@Valid와 실제 Validator 구성이 있어야 검증 실패를 400으로 연결할 수 있습니다. - 중복 등록 같은 애플리케이션 정책은 컨트롤러가 실행된 뒤 명시적으로 409로 매핑할 수 있습니다.
- 406은
produces조건을 대조하는 매핑 단계에서 컨트롤러 전에 발생할 수도 있고, 반환 값에 맞는 writer를 찾는 단계에서 컨트롤러 뒤에 발생할 수도 있습니다.
다음 Spring Framework 6.2.11 테스트는 mock Servlet 요청에서 시작해 MVC의 매핑, JSON 변환, 검증, 컨트롤러, 응답 변환 경계를 실행합니다.
standalone 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입니다.
반대로 메서드가 반환한 객체를 만족하는 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의 호환성 판단 결과를 테스트로 확인해야 합니다.
*/*는 어떤 응답도 무조건 성공시킨다는 뜻이 아니라 서버가 제공할 수 있는 표현을 제한하지 않는 범위이며, 이 예제에서는 JSON converter와 produces 조건이 JSON을 선택합니다.
상태 409는 충돌 범주만 알려 줍니다.
명시적인 안정 코드가 있어야 클라이언트가 사람이 읽는 detail 문구나 내부 예외 클래스명에 의존하지 않고 “같은 제목” 정책을 구분할 수 있습니다.
다음 문서에서는 요청 라인의 메서드가 안전성, 멱등성, 캐시 가능성에 어떤 기대를 만들고 재시도와 중복 등록 정책에 어떤 영향을 주는지 다룹니다.