HTTP 메시지 구조
게시판 등록 요청과 응답의 시작-라인·헤더·본문 경계를 원문과 Java API로 대조하고 Spring MVC가 각 부분을 어디에 바인딩하는지 확인합니다.
컨트롤러 파라미터 바인딩 오류를 이해하려면 JSON만 보면 안 됩니다.
HTTP 요청은 메서드와 대상이 있는 시작줄, 메타데이터와 제어 정보를 담은 헤더, 빈 줄 뒤의 선택적 본문으로 구성됩니다.
응답도 HTTP 버전과 상태가 있는 시작줄, 헤더, 본문 순서입니다.
요청 라인의 역할
게시글을 등록하는 HTTP/1.1 원문은 다음과 같은 형태입니다.
POST /api/posts HTTP/1.1
Host: board.example
Content-Type: application/json
Accept: application/json
Content-Length: 75
{"title":"HTTP","content":"HTTP 메시지 구조를 정리합니다.","publishedOn":"2026-07-13"}첫 줄의 POST는 메서드, /api/posts는 요청 대상, HTTP/1.1은 메시지 문법 버전입니다.
대상에는 보통 스킴과 호스트가 아니라 출처-폼 경로와 쿼리가 들어가고 권한 부분은 Host 헤더로 전달됩니다.
프록시에 보내는 요청 등에서는 다른 대상 폼이 쓰일 수 있습니다.
시작 줄과 헤더 줄은 CRLF로 구분되고 빈 줄이 헤더 영역의 끝을 나타냅니다.
브라우저 개발자 도구는 보기 좋게 재구성하므로 전송 형식 바이트와 완전히 같은 표시라고 가정하지 않습니다.
HTTP/2와 HTTP/3는 텍스트 시작 줄 대신 의사 헤더와 바이너리 프레임을 사용하지만 메서드·스킴·권한 부분·경로 의미는 유지합니다.
헤더의 의미와 처리 조건
헤더 이름은 대소문자를 구분하지 않지만 관례적으로 읽기 좋은 표기를 사용합니다.
같은 이름의 필드가 여러 번 올 수 있는 규칙과 쉼표 결합 가능 여부는 헤더마다 다르므로 무조건 문자열로 합치지 않습니다.
| Header | 방향 | 게시판에서의 의미 |
|---|---|---|
Host | 요청 | 어느 가상 서비스인지 선택 |
Content-Type | 양쪽 | 현재 본문의 미디어 타입 |
Accept | 요청 | 클라이언트가 받을 표현 후보 |
Content-Length | 양쪽 | 본문 바이트 길이 |
Authorization | 요청 | 현재 요청의 인증 정보 |
Location | 응답 | 생성 또는 리다이렉트된 URI |
Cache-Control | 양쪽 | 캐시 저장·재검증 지시 |
Content-Type과 Accept를 바꾸어 읽는 실수가 많습니다.
클라이언트가 JSON 본문을 보내면 Content-Type: application/json이고, JSON 응답을 원하면 Accept: application/json입니다.
본문이 없는 GET에서 요청 Content-Type을 붙여도 응답 형식을 요구하는 것이 아닙니다.
HTTP 메시지 구성 요소
JDK HttpRequest를 만들면 메서드, URI, 헤더, 본문 발행기가 분리되어 있어 문자열 원문보다 안전합니다.
package board.http;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.assertj.core.api.Assertions.assertThat;
import java.net.URI;
import java.net.http.HttpRequest;
import org.junit.jupiter.api.Test;
class HttpRequestShapeTest {
@Test
void 등록_요청의_method_uri_header_body를_분리한다() {
var json = """
{"title":"HTTP","content":"HTTP 메시지 구조를 정리합니다.",
"publishedOn":"2026-07-13"}
""".strip();
var request = HttpRequest.newBuilder()
.uri(URI.create(
"https://board.example/api/posts"))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(
json, UTF_8))
.build();
assertThat(request.method()).isEqualTo("POST");
assertThat(request.uri().getPath())
.isEqualTo("/api/posts");
assertThat(request.headers().firstValue("content-type"))
.contains("application/json");
assertThat(request.bodyPublisher())
.get()
.extracting(publisher -> publisher.contentLength())
.isEqualTo(json.getBytes(UTF_8).length);
}
}본문 길이는 Java 문자 수가 아니라 전송할 바이트 수입니다.
한글이 포함되면 UTF-8 한 글자가 여러 바이트일 수 있습니다.
라이브러리가 Content-Length를 계산하거나 청크·프레임 전송을 선택하게 두고 사용자가 문자열 길이를 직접 넣지 않습니다.
Host, 연결 관리 헤더처럼 HTTP 클라이언트가 통제하는 제한 헤더도 있습니다.
애플리케이션이 요청 빌더의 모든 헤더를 자유롭게 덮을 수 있다고 가정하지 않습니다.
응답 라인의 상태 분류
등록 성공 응답은 본문보다 먼저 상태와 리소스 위치를 알려 줍니다.
HTTP/1.1 201 Created
Content-Type: application/json
Location: /api/posts/42
Content-Length: 98
{"id":42,"title":"HTTP","content":"HTTP 메시지 구조를 정리합니다.","publishedOn":"2026-07-13"}클라이언트는 201 범주와 Location을 보고 새 리소스 URI를 얻습니다.
본문은 생성된 표현을 즉시 보여 주는 선택입니다.
생성에는 성공했지만 본문이 없는 설계도 가능하며 계약을 일관되게 유지해야 합니다.
package board.web;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import java.net.URI;
import org.junit.jupiter.api.Test;
import org.springframework.http.ResponseEntity;
import org.springframework.test.web.servlet.client.RestTestClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
class PostMessageContractTest {
@RestController
static class RegistrationController {
@PostMapping(
path = "/api/posts",
consumes = "application/json",
produces = "application/json")
ResponseEntity<PostResponse> register(
@RequestBody CreatePostRequest request) {
var response = new PostResponse(
42L,
request.title(),
request.content(),
request.publishedOn());
return ResponseEntity
.created(URI.create("/api/posts/42"))
.body(response);
}
}
@Test
void status_header_json_body를_함께_검증한다() {
var client = RestTestClient
.bindToController(new RegistrationController())
.build();
client.post()
.uri("/api/posts")
.contentType(APPLICATION_JSON)
.accept(APPLICATION_JSON)
.body("""
{
"title": "HTTP",
"content": "HTTP 메시지 구조를 정리합니다.",
"publishedOn": "2026-07-13"
}
""")
.exchange()
.expectStatus().isCreated()
.expectHeader()
.valueEquals("Location", "/api/posts/42")
.expectHeader()
.contentTypeCompatibleWith(APPLICATION_JSON)
.expectBody()
.jsonPath("$.id").isEqualTo(42)
.jsonPath("$.title").isEqualTo("HTTP");
}
}HttpRequestShapeTest
> 등록_요청의_method_uri_header_body를_분리한다() PASSED
PostMessageContractTest
> status_header_json_body를_함께_검증한다() PASSED
BUILD SUCCESSFUL본문과 Content-Type
MVC가 @RequestBody CreatePostRequest를 만들려면 Content-Type에 맞는 HttpMessageConverter가 있고 본문 문법이 유효해야 합니다.
- JSON 문법이 깨지면 400 Bad Request
- 지원하지 않는 요청 미디어 타입이면 415 지원하지 않는 미디어 타입
- 필요한 본문이 비어 있으면 400
- JSON은 읽혔지만 Bean 검증이 실패하면 400
- 업무 중복이면 애플리케이션 예외를 409로 변환
같은 400이라도 메시지 파싱과 검증은 실패 지점이 다릅니다.
예외 타입과 ProblemDetail의 안정적인 오류 코드로 클라이언트가 고칠 입력을 알려 줍니다.
파서 내부 메시지를 그대로 외부에 노출하면 라이브러리 버전과 민감 구조가 새어 나갈 수 있습니다.
GET 본문은 프로토콜상 절대 금지라고 단정할 수 없지만 의미가 일반적으로 정의되지 않았고 프록시·캐시·클라이언트가 일관되게 지원하지 않습니다.
조회 조건은 URI 쿼리와 헤더로 표현하고 큰 복합 검색이 정말 필요하면 별도 검색 리소스에 POST하는 계약을 설계합니다.
메시지 프레이밍 오류
Content-Length와 전송 프레이밍이 실제 본문과 맞지 않거나 헤더 문법이 깨지면 서블릿 컨테이너가 요청을 만들기 전에 연결을 닫거나 400을 반환할 수 있습니다.
이때 컨트롤러 로그가 없는 것이 정상입니다.
프록시와 애플리케이션이 서로 다른 방식으로 길이와 전송 인코딩을 해석하면 요청 밀수 공격 위험이 생길 수 있습니다.
리버스 프록시와 서버를 최신화하고 모호한 메시지를 거부하며 앞단에서 정규화합니다.
애플리케이션 코드가 원시 Content-Length를 신뢰해 별도 버퍼를 할당하지 않습니다.
응답 헤더에도 사용자 입력을 그대로 이어 붙이지 않습니다.
개행이 들어간 파일명이나 리다이렉트 대상이 추가 헤더처럼 해석되지 않게 프레임워크의 타입이 지정된 빌더와 검증을 사용합니다.
연습 문제
세 등록 요청을 MVC 경계에 보내세요.
첫 요청은 올바른 JSON, 둘째는 같은 JSON에 Content-Type: text/plain, 셋째는 닫는 중괄호가 없는 JSON입니다.
각각 201, 415, 400인지 확인하고 세 경우 컨트롤러 메서드가 실제로 호출된 횟수를 기록합니다.
해설 보기
컨트롤러에 테스트용 AtomicInteger를 주입해 메서드 첫 줄에서 증가시킵니다.
올바른 요청만 컨버터를 통과해 메서드가 한 번 호출돼야 합니다.
client.post()
.uri("/api/posts")
.contentType(MediaType.TEXT_PLAIN)
.body(validJson)
.exchange()
.expectStatus().isEqualTo(415);
client.post()
.uri("/api/posts")
.contentType(MediaType.APPLICATION_JSON)
.body("{\\"title\\":\\"HTTP\\"")
.exchange()
.expectStatus().isBadRequest();
assertThat(invocations.get()).isEqualTo(1);상태만 확인하지 말고 응답 Content-Type과 ProblemDetail의 애플리케이션 오류 코드도 고정하면 컨버터 실패와 업무 실패를 구분할 수 있습니다.
다음 문서에서는 시작-라인의 메서드가 안전·멱등·캐시 가능성에 어떤 기대를 만들며 재시도와 중복 등록 정책을 어떻게 결정하는지 다룹니다.