요청 본문·JSON 변환
RequestBody와 Jackson 메시지 컨버터로 게시판 JSON을 DTO로 변환하고 400·415를 구분합니다.
@RequestBody는 JSON 문자열을 마법처럼 DTO로 바꾸는 애노테이션이 아닙니다.
매핑의 consumes 조건을 통과한 뒤 HttpMessageConverter가 요청 Content-Type에 맞는 리더를 고르고 Jackson 3가 바이트를 DTO로 역직렬화합니다.
그다음 @Valid가 필드 제약을 검사하고 컨트롤러가 애플리케이션 명령으로 변환합니다.
요청 DTO 검증 경계
본문은 실제 문자열로 받고 @NotBlank와 @Size로 공백·길이를 검증합니다.
길이 숫자만 받으면 저장할 본문이 없어지므로 클라이언트가 계산한 길이를 도메인 값처럼 신뢰하지 않습니다.
package board.web;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
public record CreatePostRequest(
@NotBlank
String title,
@NotBlank
@Size(max = 720)
String content,
@NotNull
LocalDate publishedOn) {
CreatePostCommand toCommand() {
return new CreatePostCommand(
title, content, publishedOn);
}
}Boot가 구성한 Jackson 매퍼는 Java 시간 지원과 애플리케이션 설정을 공유합니다.
컨트롤러나 컨버터에서 new ObjectMapper()를 만들면 날짜 모듈, 이름 지정 전략, 알 수 없는 속성 정책이 달라질 수 있습니다.
Boot 4의 기본 JSON 스택은 Jackson 3의 tools.jackson.databind 계열을 사용하므로 예전 가져오기를 무심코 붙이지 않습니다.
DTO 검증은 HTTP 입력에 빠른 필드 오류를 제공합니다.
미래 날짜 금지나 같은 날 중복처럼 저장 상태와 현재 시각이 필요한 규칙은 애플리케이션/도메인에서도 검증합니다.
DTO와 애플리케이션 명령
package board.web;
import jakarta.validation.Valid;
import java.net.URI;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/posts")
class PostRegistrationController {
private final PostService service;
PostRegistrationController(
PostService service) {
this.service = service;
}
@PostMapping(
consumes = "application/json",
produces = "application/json")
ResponseEntity<PostResponse> register(
@Valid @RequestBody
CreatePostRequest request) {
var saved = service.register(
request.toCommand());
return ResponseEntity
.created(URI.create(
"/api/posts/" + saved.id()))
.body(PostResponse.from(saved));
}
}@RequestBody가 DTO를 만들지 못하면 컨트롤러는 호출되지 않습니다.
검증도 컨트롤러 본문 전에 실행됩니다.
서비스 호출 카운터를 두면 경계가 실제로 지켜지는지 확인할 수 있습니다.
파싱·검증·업무 오류
package board.web;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.converter
.HttpMessageNotReadableException;
import org.springframework.web.bind
.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation
.ExceptionHandler;
import org.springframework.web.bind.annotation
.RestControllerAdvice;
@RestControllerAdvice
class ApiInputExceptionHandler {
@ExceptionHandler(
HttpMessageNotReadableException.class)
ProblemDetail malformedJson() {
var problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST,
"Request JSON could not be read");
problem.setProperty(
"code", "JSON_NOT_READABLE");
return problem;
}
@ExceptionHandler(
MethodArgumentNotValidException.class)
ProblemDetail invalidFields(
MethodArgumentNotValidException failure) {
var problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST,
"Request fields are invalid");
problem.setProperty(
"code", "REQUEST_INVALID");
problem.setProperty(
"fields",
failure.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> new FieldProblem(
error.getField(),
error.getCode()))
.toList());
return problem;
}
}파서의 내부 예외 메시지를 상세로 그대로 보내지 않습니다.
잘못된 토큰 주변의 원시 본문나 Java 클래스 이름이 노출될 수 있습니다.
안정적인 오류 코드와 필드 경로를 제공합니다.
중복 게시글은 JSON 입력이 유효한 뒤 현재 저장 상태와 충돌한 것이므로 409입니다.
JSON 날짜 형식 오류는 파싱 400, 미래 날짜는 검증 또는 도메인 400으로 세부 코드를 구분할 수 있습니다.
JSON 요청 유형 검증
package board.web;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.client.RestTestClient;
class JsonRequestBodyTest {
@Test
void valid_malformed_invalid_media_type을_구분한다() {
var invocations = new AtomicInteger();
var service = new RecordingPostService(
invocations, post(42L));
var client = RestTestClient
.bindToController(
new PostRegistrationController(
service))
.configureServer(builder ->
builder.setControllerAdvice(
new ApiInputExceptionHandler()))
.build();
client.post()
.uri("/api/posts")
.contentType(APPLICATION_JSON)
.body("""
{
"title": "JSON binding",
"content": "Jackson이 JSON 본문을 DTO로 변환합니다.",
"publishedOn": "2026-07-13"
}
""")
.exchange()
.expectStatus().isCreated()
.expectHeader()
.valueEquals(
"Location", "/api/posts/42");
client.post()
.uri("/api/posts")
.contentType(APPLICATION_JSON)
.body("{\\"title\\":\\"broken\\"")
.exchange()
.expectStatus().isBadRequest()
.expectBody()
.jsonPath("$.code")
.isEqualTo("JSON_NOT_READABLE");
client.post()
.uri("/api/posts")
.contentType(MediaType.TEXT_PLAIN)
.body("title=wrong-format")
.exchange()
.expectStatus().isEqualTo(415);
assertThat(invocations.get()).isEqualTo(1);
}
}JsonRequestBodyTest
> valid_malformed_invalid_media_type을_구분한다() PASSED
valid JSON = 201
malformed JSON = 400 JSON_NOT_READABLE
text/plain = 415
service invocations = 1
BUILD SUCCESSFUL알 수 없는 필드 정책
알 수 없는 필드를 무시하면 새 클라이언트가 필드를 추가해도 이전 서버와 통신하기 쉽습니다.
반면 클라이언트 오타 contents를 조용히 무시하면 content 누락 오류로만 보여 원인을 찾기 어렵습니다.
엄격한 거부는 오타를 빨리 찾지만 전방 호환성을 줄입니다.
API별 정책을 정하고 계약 테스트로 확인합니다.
보안에 민감한 명령은 허용 필드만 명확히 두고 엔티티 바인딩을 금지합니다.
알 수 없는 필드를 허용해도 admin 같은 값이 애플리케이션 명령에 들어가지 않게 DTO 자체에 필드가 없어야 합니다.
JSON null, 필드 누락, 빈 문자열도 구분합니다.
패치에서는 “변경하지 않음”과 “값을 null로 지움”이 다를 수 있어 일반 DTO보다 작업 타입이나 명시적 존재 여부 래퍼가 필요합니다.
본문 크기·중첩 제한
파서가 읽기 전에 프록시와 컨테이너 최대 본문 크기를 두고, Jackson의 스트림 읽기 제약 조건으로 과도한 중첩과 긴 토큰을 제한합니다.
오류 본문에도 원시 요청을 되풀이하지 않습니다.
압축 요청을 허용한다면 압축 해제 후 크기 폭증을 고려합니다.
작은 JSON 등록 API에 무제한 gzip 본문이 필요하지 않습니다.
타임아웃과 입력 스트림 취소도 클라이언트 연결 해제 상황에서 관찰합니다.
연습 문제
JSON 등록 요청에서 분 누락, 0, 721, publishedOn 잘못된 날짜, 알 수 없는 필드 다섯 사례를 분리하세요.
파싱과 검증 오류 코드가 다르고 서비스 호출은 유효한 요청에서만 증가해야 합니다.
알 수 없는 필드는 팀이 선택한 엄격·관대한 정책을 검증으로 고정합니다.
해설 보기
본문은 String과 @NotBlank를 사용해 누락·공백을 거부합니다.
빈 본문은 필드 검증, 잘못된 날짜 문자열은 파싱 오류입니다.
client.post()
.uri("/api/posts")
.contentType(APPLICATION_JSON)
.body("""
{
"title": "Limits",
"content": "",
"publishedOn": "2026-07-13"
}
""")
.exchange()
.expectStatus().isBadRequest()
.expectBody()
.jsonPath("$.code")
.isEqualTo("REQUEST_INVALID")
.jsonPath("$.fields[0].field")
.isEqualTo("content");필드 오류 배열 순서는 검증기 구현에 따라 바뀔 수 있으므로 여러 오류가 있을 때 고정 인덱스보다 필드별 검색을 사용합니다.
애플리케이션 서비스가 호출되지 않았는지도 함께 확인합니다.
다음 문서에서는 컨트롤러 반환값을 논리 뷰, 리다이렉트, 응답 본문, ResponseEntity 중 무엇으로 해석할지 선택하고 같은 문자열이 다른 의미가 되는 이유를 검증합니다.