요청 본문·JSON 변환
RequestBody가 미디어 타입 선택, Jackson 3 역직렬화, DTO 검증, 애플리케이션 명령 매핑을 통과하는 경계를 실행합니다.
JSON 요청은 @RequestBody 하나로 곧바로 애플리케이션 명령이 되지 않습니다. 매핑의 consumes 조건이 미디어 타입을 먼저 거르고, 선택된 HttpMessageConverter가 Boot의 Jackson 3 매퍼로 본문을 읽습니다. 그 결과로 만든 DTO를 @Valid가 검사한 뒤에야 컨트롤러가 명령으로 명시적으로 매핑합니다.
JSON 본문은 media type·읽기·검증 경계를 지나 애플리케이션 명령이 된다
consumes·converter/Jackson DTO·검증·command/불변식·service 순서의 실제 실패 위치가 있다.
FLOWCHART · REQUEST BODY · JACKSON 3 · SERVICE GATE
JSON 본문은 media type·읽기·검증 경계를 지나 애플리케이션 명령이 된다
media type 선택, Jackson 읽기, DTO 검증은 서로 다른 경계다. 어느 경계에서든 실패하면 명시적 command mapping과 service 호출에 도달하지 않는다.
화살표는 앞 경계를 통과한 요청의 진행입니다. media415·읽기400·DTO검증400은 서로 다른 실패이며 모두 서비스 호출 0회입니다. 상세 실패 조건과 길이 단위는 아래 설명을 함께 읽습니다.
MEDIA TYPE · 415
지원되지 않는 요청 표현
이 fixture의 text/plain은 JSON consumes 조건에서 415 MEDIA_TYPE_UNSUPPORTED입니다. 본문 DTO를 만들기 전 거부하며 service 호출은 0입니다.
JSON READ · 400
문법·값·알 수 없는 필드·파서 한계
컨버터가 Boot의 Jackson 3 mapper로 읽습니다. fallback JSON_NOT_READABLE, 한계 JSON_LIMIT_EXCEEDED, unknown 속성 JSON_PROPERTY_UNKNOWN, 값 변환 JSON_VALUE_INVALID로 분류하며 모두 service 호출 0입니다.
DTO VALIDATION · 400
DTO를 만든 뒤 제약 위반
공백·길이·미래 날짜 위반은 400 REQUEST_INVALID입니다. 필드 이름과 안정적인 검증 코드만 반환하며 service를 호출하지 않습니다. content의 누락·null은 같은 null로 읽혀 NotBlank 실패군에 속합니다.
EXPLICIT COMMAND MAPPING
검증을 통과한 요청만 command와 service로
handler가 세 허용 필드를 CreatePostCommand로 명시적으로 복사합니다. command는 핵심 문자열 불변식을 다시 확인합니다. 이 fixture의 정상 요청은 service 1회와 201이지만 상세 응답 정책은 다음 문서의 책임입니다.
THREE LIMIT LAYERS
원시 bytes · 파서 단위 · UTF-16 길이
proxy/container의 HTTP byte 상한과 Jackson의 근사적 stream constraint는 별도입니다. DTO @Size는 Java UTF-16 코드 단위입니다. content 720은 통과하고 721은 DTO 검증, 4096자 token은 더 앞선 parser에서 거부됩니다.
MISSING BODY / EMPTY FIELD
본문 부재와 빈 content의 첫 실패가 다르다
빈 HTTP body는 JSON_NOT_READABLE입니다. DTO가 만들어질 수 있는 빈 content는 REQUEST_INVALID입니다. 둘 다 400이어도 같은 실패 경계로 합치지 않습니다.
DTO와 command/domain의 규칙이 일부 겹치는 것은 의도적이다. 같은 command를 만드는 HTTP 이외의 진입 경로도 애플리케이션 경계에서 동일한 invariant를 지켜야 한다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- mapping consumes를 통과한 본문을 message converter와 Boot Jackson 3 JsonMapper가 DTO로 읽습니다. DTO 검증이 통과하면 handler가 명령을 만들고 핵심 불변식을 확인해 service를 호출합니다. 각 실패는 아래 media 415/read 400/validation 400 비교에 표시합니다.
- 원 데스크톱의 여섯 노드와 다섯 관계를 세로로 재배치한다. Content-Type과 consumes, converter의 Boot JsonMapper 읽기, DTO 검증을 통과한 경우에만 handler에 진입한다. 이어 명령의 핵심 불변식을 확인하고 서비스를 한 번 호출한다. 어느 앞선 경계든 실패하면 그곳에서 끝나며 아래 media 415/read 400/validation 400 설명을 따른다. 이 그림은 실패 후 정상 노드를 계속 실행하는 흐름을 뜻하지 않는다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 2 | Content-Type과 mapping consumes를 통과한 본문을 converter와 Boot JsonMapper가 DTO로 읽고 @Valid로 검증합니다. media 415, JSON 읽기 400, DTO 검증 400은 service 호출 0이며 정상 입력만 handler의 명시적 command 매핑과 불변식 검사를 거쳐 service 1회로 이어집니다. 본문 부재와 빈 필드, raw byte 상한과 파서 제약, UTF-16 Size를 구분합니다. |
| 6 | mapping consumes를 통과한 본문을 message converter와 Boot Jackson 3 JsonMapper가 DTO로 읽습니다. DTO 검증이 통과하면 handler가 명령을 만들고 핵심 불변식을 확인해 service를 호출합니다. 각 실패는 아래 media 415/read 400/validation 400 비교에 표시합니다. |
| 21 | 원 데스크톱의 여섯 노드와 다섯 관계를 세로로 재배치한다. Content-Type과 consumes, converter의 Boot JsonMapper 읽기, DTO 검증을 통과한 경우에만 handler에 진입한다. 이어 명령의 핵심 불변식을 확인하고 서비스를 한 번 호출한다. 어느 앞선 경계든 실패하면 그곳에서 끝나며 아래 media 415/read 400/validation 400 설명을 따른다. 이 그림은 실패 후 정상 노드를 계속 실행하는 흐름을 뜻하지 않는다. |
| 22 | 화살표는 앞 경계를 통과한 요청의 진행입니다. media 415·읽기 400·DTO 검증 400은 서로 다른 실패이며 모두 서비스 호출 0회입니다. 상세 실패 조건과 길이 단위는 아래 설명을 함께 읽습니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| consumes와 DTO 읽기 mapping 뒤 converter·Jackson 3 JsonMapper가 body를 읽습니다. | 미디어 실패: 415 · 읽기 실패: 400 | 바이트 입력이 DTO가 됩니다. |
| DTO validation DTO validation을 통과해야 handler를 호출합니다. | 검증 실패: 400 · 정상: handler 진입 | 웹 검증이 명령 생성을 허용합니다. |
| 명령·불변식·service handler가 명령을 만들고 핵심 불변식 뒤 service를 호출합니다. | 산출물: command · 업무: 불변식 확인·service | 유효 DTO가 업무 계약으로 넘어갑니다. |
JSON 본문은 media type·읽기·검증 경계를 지나 애플리케이션 명령이 된다
consumes·converter/Jackson DTO·검증·command/불변식·service 순서의 실제 실패 위치가 있다.
FLOWCHART · REQUEST BODY · JACKSON 3 · SERVICE GATE
JSON 본문은 media type·읽기·검증 경계를 지나 애플리케이션 명령이 된다
media type 선택, Jackson 읽기, DTO 검증은 서로 다른 경계다. 어느 경계에서든 실패하면 명시적 command mapping과 service 호출에 도달하지 않는다.
화살표는 앞 경계를 통과한 요청의 진행입니다. media415·읽기400·DTO검증400은 서로 다른 실패이며 모두 서비스 호출 0회입니다. 상세 실패 조건과 길이 단위는 아래 설명을 함께 읽습니다.
MEDIA TYPE · 415
지원되지 않는 요청 표현
이 fixture의 text/plain은 JSON consumes 조건에서 415 MEDIA_TYPE_UNSUPPORTED입니다. 본문 DTO를 만들기 전 거부하며 service 호출은 0입니다.
JSON READ · 400
문법·값·알 수 없는 필드·파서 한계
컨버터가 Boot의 Jackson 3 mapper로 읽습니다. fallback JSON_NOT_READABLE, 한계 JSON_LIMIT_EXCEEDED, unknown 속성 JSON_PROPERTY_UNKNOWN, 값 변환 JSON_VALUE_INVALID로 분류하며 모두 service 호출 0입니다.
DTO VALIDATION · 400
DTO를 만든 뒤 제약 위반
공백·길이·미래 날짜 위반은 400 REQUEST_INVALID입니다. 필드 이름과 안정적인 검증 코드만 반환하며 service를 호출하지 않습니다. content의 누락·null은 같은 null로 읽혀 NotBlank 실패군에 속합니다.
EXPLICIT COMMAND MAPPING
검증을 통과한 요청만 command와 service로
handler가 세 허용 필드를 CreatePostCommand로 명시적으로 복사합니다. command는 핵심 문자열 불변식을 다시 확인합니다. 이 fixture의 정상 요청은 service 1회와 201이지만 상세 응답 정책은 다음 문서의 책임입니다.
THREE LIMIT LAYERS
원시 bytes · 파서 단위 · UTF-16 길이
proxy/container의 HTTP byte 상한과 Jackson의 근사적 stream constraint는 별도입니다. DTO @Size는 Java UTF-16 코드 단위입니다. content 720은 통과하고 721은 DTO 검증, 4096자 token은 더 앞선 parser에서 거부됩니다.
MISSING BODY / EMPTY FIELD
본문 부재와 빈 content의 첫 실패가 다르다
빈 HTTP body는 JSON_NOT_READABLE입니다. DTO가 만들어질 수 있는 빈 content는 REQUEST_INVALID입니다. 둘 다 400이어도 같은 실패 경계로 합치지 않습니다.
DTO와 command/domain의 규칙이 일부 겹치는 것은 의도적이다. 같은 command를 만드는 HTTP 이외의 진입 경로도 애플리케이션 경계에서 동일한 invariant를 지켜야 한다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- mapping consumes를 통과한 본문을 message converter와 Boot Jackson 3 JsonMapper가 DTO로 읽습니다. DTO 검증이 통과하면 handler가 명령을 만들고 핵심 불변식을 확인해 service를 호출합니다. 각 실패는 아래 media 415/read 400/validation 400 비교에 표시합니다.
- 원 데스크톱의 여섯 노드와 다섯 관계를 세로로 재배치한다. Content-Type과 consumes, converter의 Boot JsonMapper 읽기, DTO 검증을 통과한 경우에만 handler에 진입한다. 이어 명령의 핵심 불변식을 확인하고 서비스를 한 번 호출한다. 어느 앞선 경계든 실패하면 그곳에서 끝나며 아래 media 415/read 400/validation 400 설명을 따른다. 이 그림은 실패 후 정상 노드를 계속 실행하는 흐름을 뜻하지 않는다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 2 | Content-Type과 mapping consumes를 통과한 본문을 converter와 Boot JsonMapper가 DTO로 읽고 @Valid로 검증합니다. media 415, JSON 읽기 400, DTO 검증 400은 service 호출 0이며 정상 입력만 handler의 명시적 command 매핑과 불변식 검사를 거쳐 service 1회로 이어집니다. 본문 부재와 빈 필드, raw byte 상한과 파서 제약, UTF-16 Size를 구분합니다. |
| 6 | mapping consumes를 통과한 본문을 message converter와 Boot Jackson 3 JsonMapper가 DTO로 읽습니다. DTO 검증이 통과하면 handler가 명령을 만들고 핵심 불변식을 확인해 service를 호출합니다. 각 실패는 아래 media 415/read 400/validation 400 비교에 표시합니다. |
| 21 | 원 데스크톱의 여섯 노드와 다섯 관계를 세로로 재배치한다. Content-Type과 consumes, converter의 Boot JsonMapper 읽기, DTO 검증을 통과한 경우에만 handler에 진입한다. 이어 명령의 핵심 불변식을 확인하고 서비스를 한 번 호출한다. 어느 앞선 경계든 실패하면 그곳에서 끝나며 아래 media 415/read 400/validation 400 설명을 따른다. 이 그림은 실패 후 정상 노드를 계속 실행하는 흐름을 뜻하지 않는다. |
| 22 | 화살표는 앞 경계를 통과한 요청의 진행입니다. media 415·읽기 400·DTO 검증 400은 서로 다른 실패이며 모두 서비스 호출 0회입니다. 상세 실패 조건과 길이 단위는 아래 설명을 함께 읽습니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| consumes와 DTO 읽기 mapping 뒤 converter·Jackson 3 JsonMapper가 body를 읽습니다. | 미디어 실패: 415 · 읽기 실패: 400 | 바이트 입력이 DTO가 됩니다. |
| DTO validation DTO validation을 통과해야 handler를 호출합니다. | 검증 실패: 400 · 정상: handler 진입 | 웹 검증이 명령 생성을 허용합니다. |
| 명령·불변식·service handler가 명령을 만들고 핵심 불변식 뒤 service를 호출합니다. | 산출물: command · 업무: 불변식 확인·service | 유효 DTO가 업무 계약으로 넘어갑니다. |
그림처럼 JSON 본문은 미디어 타입·역직렬화·검증 경계를 모두 통과해야 명령이 됩니다. 이 문서는 요청을 읽는 경계까지만 소유합니다. 성공 응답의 정확한 상태·헤더 선택은 ch6-5, 컨버터와 콘텐츠 협상의 상세 규칙은 ch6-6에서 다룹니다.
실행 기준과 읽기 한계 고정
예제는 Java 25와 Spring Boot 4.1.1 BOM을 한 그래프로 사용합니다. 웹 MVC, Bean Validation, 실제 서버용 테스트 클라이언트 외의 별도 JSON 매퍼는 추가하지 않습니다.
rootProject.name = 'json-request-body'plugins { id 'java' }
java { toolchain { languageVersion = JavaLanguageVersion.of(25) } }
repositories { mavenCentral() }
dependencies {
implementation platform('org.springframework.boot:spring-boot-dependencies:4.1.1')
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
implementation 'org.springframework.boot:spring-boot-starter-validation'
testImplementation platform('org.springframework.boot:spring-boot-dependencies:4.1.1')
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
options.release = 25
options.compilerArgs += ['-parameters']
}
tasks.named('test') { useJUnitPlatform() }알 수 없는 속성은 이 API에서 오타로 보고 거부합니다. 읽기 제약은 작은 등록 요청이 비정상적으로 커지거나 깊어질 때 파서 작업을 중단하는 방어선입니다.
spring.jackson.deserialization.fail-on-unknown-properties=true
spring.jackson.factory.constraints.read.max-document-length=8192
spring.jackson.factory.constraints.read.max-nesting-depth=20
spring.jackson.factory.constraints.read.max-string-length=1000max-document-length와 max-string-length는 Jackson 입력 방식과 파서가 세는 단위에 따른 근사적 처리 한계입니다. 프록시·Servlet 컨테이너가 적용하는 원시 HTTP 본문 바이트 상한을 대신하지 않습니다. 운영에서는 전송 계층의 바이트 상한과 파서 한계를 별도로 둡니다.
애플리케이션 명령의 불변식
애플리케이션 시작점은 웹 어댑터보다 상위 패키지에 둡니다.
package board;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class BoardApplication {
public static void main(String[] args) {
SpringApplication.run(BoardApplication.class, args);
}
}DTO 검증을 통과했다는 사실만 믿고 도메인 입력을 무방비로 두지 않습니다. 배치나 메시지 소비자처럼 HTTP를 거치지 않는 진입점도 같은 명령을 만들 수 있으므로 핵심 문자열 불변식은 명령에서 다시 지킵니다. 이 중복은 역할이 다른 두 경계의 의도적인 겹침입니다.
두 경계의 규칙이 전부 같은 것은 아닙니다. 이 명령은 문자열의 공백·길이와 날짜의 null 여부를 검사하지만, DTO의 @PastOrPresent 규칙을 다시 검사하지 않습니다. 일반 Java 코드에서 record를 만들거나 toCommand()를 호출하는 것만으로 Bean Validation이 자동 실행되지도 않습니다. HTTP 밖에서도 과거·현재 날짜만 허용해야 하는 업무라면 그 정책과 기준 시계를 명령·서비스 경계에도 명시해야 합니다.
package board.post;
import java.time.LocalDate;
import java.util.Objects;
public record CreatePostCommand(String title, String content, LocalDate publishedOn) {
public CreatePostCommand {
requireText(title, 120, "title");
requireText(content, 720, "content");
Objects.requireNonNull(publishedOn, "publishedOn");
}
private static void requireText(String value, int maxLength, String name) {
Objects.requireNonNull(value, name);
if (value.isBlank() || value.length() > maxLength) {
throw new IllegalArgumentException(name + " is invalid");
}
}
}서비스 계약에는 검증된 명령만 들어옵니다. 반환 ID를 어떻게 HTTP 응답으로 표현할지는 다음 문서의 책임입니다.
package board.post;
@FunctionalInterface
public interface PostService {
long register(CreatePostCommand command);
}DTO에서 명령으로 명시적으로 건넨다
JSON DTO는 외부 필드 모양과 입력 오류를 담당합니다. 엔티티에 직접 바인딩하지 않고 허용한 세 필드만 명령으로 복사합니다.
package board.web;
import board.post.CreatePostCommand;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PastOrPresent;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
public record CreatePostRequest(
@NotBlank @Size(max = 120) String title,
@NotBlank @Size(max = 720) String content,
@NotNull @PastOrPresent LocalDate publishedOn) {
public CreatePostCommand toCommand() {
return new CreatePostCommand(title, content, publishedOn);
}
}@Size가 String에 적용할 때 세는 것은 Java CharSequence.length() 길이, 즉 UTF-16 코드 단위입니다. JSON 전송 바이트나 사용자가 보는 글자소 수와 같다고 설명하면 안 됩니다. 바이트 과금이나 화면 글자 수가 계약이라면 각각 별도 규칙을 설계합니다.
package board.web;
import board.post.PostService;
import jakarta.validation.Valid;
import java.net.URI;
import org.springframework.http.MediaType;
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")
public final class PostRegistrationController {
private final PostService service;
public PostRegistrationController(PostService service) {
this.service = service;
}
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Void> register(@Valid @RequestBody CreatePostRequest request) {
var postId = service.register(request.toCommand());
return ResponseEntity.created(URI.create("/api/posts/" + postId)).build();
}
}본문을 읽지 못하거나 DTO 검증이 실패하면 메서드 본문에 들어오지 않습니다. 따라서 서비스 호출 이력은 상태 코드와 함께 경계가 작동했다는 핵심 증거가 됩니다.
입력 모양과 사용 권한은 따로 확인합니다. 허용한 세 필드만 복사하고 알 수 없는 admin 속성을 거부하는 것은 입력 계약을 좁히는 장치입니다. 제목·내용·날짜가 모두 유효하다는 사실만으로 그 호출자에게 게시물 등록 권한이 있다고 증명되지는 않습니다. 인증·인가가 필요한 API에서는 그 경계를 별도로 설계합니다. 이 fixture의 서비스 호출 횟수는 입력 경계 검증용이며 권한 검사를 실행한 증거가 아닙니다.
읽기 오류와 필드 오류를 안전하게 분류한다
필드 오류 응답은 외부 계약에 필요한 필드명과 안정적인 검증 코드만 담습니다.
package board.web;
public record FieldProblem(String field, String code) {}Jackson 내부 메시지를 그대로 반환하지 않습니다. 원시 입력 조각이나 Java 타입명이 포함될 수 있기 때문입니다. 대신 제한 초과, 알 수 없는 속성, 값 변환, 그 밖의 읽기 실패를 안전한 코드로 분류합니다.
package board.web;
import java.util.Comparator;
import java.util.Objects;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.FieldError;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import tools.jackson.core.exc.StreamConstraintsException;
import tools.jackson.databind.exc.InvalidFormatException;
import tools.jackson.databind.exc.UnrecognizedPropertyException;
@RestControllerAdvice
public final class ApiInputExceptionHandler {
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ProblemDetail> unreadable(HttpMessageNotReadableException failure) {
var code = unreadableCode(failure);
var problem = problem(HttpStatus.BAD_REQUEST, "Request JSON could not be read", code);
return ResponseEntity.badRequest().body(problem);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ProblemDetail> invalidFields(
MethodArgumentNotValidException failure) {
var fields = failure.getBindingResult().getFieldErrors().stream()
.sorted(Comparator.comparing(FieldError::getField)
.thenComparing(error -> Objects.requireNonNullElse(error.getCode(), "")))
.map(error -> new FieldProblem(
error.getField(), Objects.requireNonNullElse(error.getCode(), "Invalid")))
.toList();
var problem = problem(
HttpStatus.BAD_REQUEST, "Request fields are invalid", "REQUEST_INVALID");
problem.setProperty("fields", fields);
return ResponseEntity.badRequest().body(problem);
}
@ExceptionHandler(HttpMediaTypeNotSupportedException.class)
public ResponseEntity<ProblemDetail> unsupportedMediaType(
HttpMediaTypeNotSupportedException failure) {
var problem = problem(
failure.getStatusCode(), "Content-Type is not supported", "MEDIA_TYPE_UNSUPPORTED");
return ResponseEntity.status(failure.getStatusCode())
.headers(failure.getHeaders())
.body(problem);
}
private static String unreadableCode(HttpMessageNotReadableException failure) {
if (failure.contains(StreamConstraintsException.class)) {
return "JSON_LIMIT_EXCEEDED";
}
if (failure.contains(UnrecognizedPropertyException.class)) {
return "JSON_PROPERTY_UNKNOWN";
}
if (failure.contains(InvalidFormatException.class)) {
return "JSON_VALUE_INVALID";
}
return "JSON_NOT_READABLE";
}
private static ProblemDetail problem(
org.springframework.http.HttpStatusCode status, String detail, String code) {
var problem = ProblemDetail.forStatusAndDetail(status, detail);
problem.setProperty("code", code);
return problem;
}
}415 처리에서 예외가 제공한 헤더를 보존합니다. 이 fixture는 Accept: application/json으로 지원 요청 형식을 알리지만, 미디어 타입 선택과 406까지 포함한 상세 협상 규칙은 ch6-6의 범위입니다.
| 입력과 첫 실패 경계 | fixture 관찰 | 서비스 호출 |
|---|---|---|
text/plain이 consumes와 불일치 | 415 MEDIA_TYPE_UNSUPPORTED | 0 |
| 본문 부재·문법 오류·날짜 변환·알 수 없는 속성·파서 한계 | 400의 분류된 JSON 읽기 코드 | 0 |
| DTO의 공백·길이·미래 날짜 제약 위반 | 400 REQUEST_INVALID | 0 |
| 모든 경계를 통과한 JSON | 명령 한 번 매핑, 이 fixture에서는 201 | 1 |
일반 record에서 누락된 content와 명시적인 "content": null은 모두 null로 역직렬화됩니다. 이 예제는 둘을 구별한다고 주장하지 않고 같은 NotBlank 실패군으로 검증합니다. PATCH처럼 “누락”과 “null로 지움”이 다른 API라면 존재 여부를 표현하는 별도 입력 타입이 필요합니다.
실제 Boot 매퍼와 서버 경계를 검증한다
독립형 컨트롤러 바인딩만으로는 Boot가 구성한 매퍼를 사용했다고 증명할 수 없습니다. 테스트는 RANDOM_PORT 서버를 띄우고 Boot가 주입한 RestTestClient로 HTTP를 보냅니다. 동시에 MVC의 Jackson 컨버터가 같은 JsonMapper 인스턴스를 쓰는지 검사합니다.
package board.web;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
import board.post.CreatePostCommand;
import board.post.PostService;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter;
import org.springframework.test.web.servlet.client.RestTestClient;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter;
import tools.jackson.core.exc.StreamConstraintsException;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.json.JsonMapper;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
@Import(JsonRequestBodyTest.TestBeans.class)
class JsonRequestBodyTest {
@Autowired
private RestTestClient client;
@Autowired
private JsonMapper mapper;
@Autowired
private RequestMappingHandlerAdapter handlerAdapter;
@Autowired
private RecordingPostService service;
@BeforeEach
void resetService() {
service.reset();
}
@Test
void boot_매퍼와_MVC_컨버터가_같은_읽기_계약을_사용한다() {
var converters = handlerAdapter.getMessageConverters().stream()
.filter(JacksonJsonHttpMessageConverter.class::isInstance)
.map(JacksonJsonHttpMessageConverter.class::cast)
.toList();
assertThat(converters).hasSize(1);
assertThat(converters.getFirst().getMapper()).isSameAs(mapper);
var constraints = mapper.tokenStreamFactory().streamReadConstraints();
assertThat(constraints.getMaxDocumentLength()).isEqualTo(8192L);
assertThat(constraints.getMaxNestingDepth()).isEqualTo(20);
assertThat(constraints.getMaxStringLength()).isEqualTo(1000);
assertThat(mapper.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)).isTrue();
assertThatThrownBy(() -> mapper.readTree(nestedObject(21)))
.isInstanceOf(StreamConstraintsException.class);
}
@Test
void valid_JSON은_명령으로_한_번_매핑된다() {
client.post()
.uri("/api/posts")
.contentType(APPLICATION_JSON)
.body(requestJson("Jackson이 JSON 본문을 DTO로 변환합니다."))
.exchange()
.expectStatus().isCreated()
.expectHeader().valueEquals(HttpHeaders.LOCATION, "/api/posts/42");
assertThat(service.commands()).containsExactly(new CreatePostCommand(
"JSON binding", "Jackson이 JSON 본문을 DTO로 변환합니다.",
LocalDate.of(2025, 5, 1)));
}
@Test
void malformed_JSON과_본문_부재는_읽기_오류다() {
expectBadRequest("{\"title\":\"broken\"", "JSON_NOT_READABLE");
client.post()
.uri("/api/posts")
.contentType(APPLICATION_JSON)
.exchange()
.expectStatus().isBadRequest()
.expectHeader().contentType(MediaType.APPLICATION_PROBLEM_JSON)
.expectBody()
.jsonPath("$.code").isEqualTo("JSON_NOT_READABLE");
assertThat(service.commands()).isEmpty();
}
@Test
void invalid_date는_값_변환_오류다() {
expectBadRequest("""
{
"title": "JSON binding",
"content": "date",
"publishedOn": "not-a-date"
}
""", "JSON_VALUE_INVALID");
}
@Test
void content_누락_null_빈값_공백은_필드_검증_오류다() {
var requests = List.of(
"""
{"title":"JSON binding","publishedOn":"2025-05-01"}
""",
"""
{"title":"JSON binding","content":null,"publishedOn":"2025-05-01"}
""",
requestJson(""),
requestJson(" "));
for (var request : requests) {
expectInvalidContent(request);
}
}
@Test
void content_720은_통과하고_721은_거부된다() {
client.post()
.uri("/api/posts")
.contentType(APPLICATION_JSON)
.body(requestJson("x".repeat(720)))
.exchange()
.expectStatus().isCreated();
assertThat(service.commands()).hasSize(1);
service.reset();
expectInvalidContent(requestJson("x".repeat(721)));
}
@Test
void 미래_날짜는_필드_검증_오류다() {
expectBadRequest("""
{
"title": "JSON binding",
"content": "future",
"publishedOn": "2999-01-01"
}
""", "REQUEST_INVALID");
}
@Test
void unknown_field는_엄격하게_거부된다() {
var requests = List.of(
"""
{
"title": "JSON binding",
"content": "strict",
"publishedOn": "2025-05-01",
"contents": "typo"
}
""",
"""
{
"title": "JSON binding",
"content": "strict",
"publishedOn": "2025-05-01",
"admin": true
}
""");
for (var request : requests) {
expectBadRequest(request, "JSON_PROPERTY_UNKNOWN");
}
}
@Test
void text_plain은_415이고_지원_Content_Type을_알린다() {
client.post()
.uri("/api/posts")
.contentType(MediaType.TEXT_PLAIN)
.body("title=wrong-format")
.exchange()
.expectStatus().isEqualTo(415)
.expectHeader().valueEquals(HttpHeaders.ACCEPT, APPLICATION_JSON_VALUE)
.expectHeader().contentType(MediaType.APPLICATION_PROBLEM_JSON)
.expectBody()
.jsonPath("$.code").isEqualTo("MEDIA_TYPE_UNSUPPORTED");
assertThat(service.commands()).isEmpty();
}
@Test
void 긴_JSON_문자열은_파서_읽기_한계에서_거부된다() {
expectBadRequest(requestJson("x".repeat(4096)), "JSON_LIMIT_EXCEEDED");
}
@Test
void 애플리케이션_명령도_핵심_불변식을_지킨다() {
assertThatThrownBy(() -> new CreatePostCommand(
"JSON binding", " ", LocalDate.of(2025, 5, 1)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("content is invalid");
}
private void expectInvalidContent(String json) {
client.post()
.uri("/api/posts")
.contentType(APPLICATION_JSON)
.body(json)
.exchange()
.expectStatus().isBadRequest()
.expectHeader().contentType(MediaType.APPLICATION_PROBLEM_JSON)
.expectBody()
.jsonPath("$.code").isEqualTo("REQUEST_INVALID")
.jsonPath("$.fields[0].field").isEqualTo("content");
assertThat(service.commands()).isEmpty();
}
private void expectBadRequest(String json, String code) {
client.post()
.uri("/api/posts")
.contentType(APPLICATION_JSON)
.body(json)
.exchange()
.expectStatus().isBadRequest()
.expectHeader().contentType(MediaType.APPLICATION_PROBLEM_JSON)
.expectBody()
.jsonPath("$.code").isEqualTo(code);
assertThat(service.commands()).isEmpty();
}
private static String requestJson(String content) {
return """
{
"title": "JSON binding",
"content": "%s",
"publishedOn": "2025-05-01"
}
""".formatted(content);
}
private static String nestedObject(int depth) {
return "{\"value\":".repeat(depth) + "0" + "}".repeat(depth);
}
@TestConfiguration(proxyBeanMethods = false)
static class TestBeans {
@Bean
RecordingPostService postService() {
return new RecordingPostService();
}
}
static final class RecordingPostService implements PostService {
private final List<CreatePostCommand> commands = new ArrayList<>();
@Override
public long register(CreatePostCommand command) {
commands.add(command);
return 42L;
}
List<CreatePostCommand> commands() {
return List.copyOf(commands);
}
void reset() {
commands.clear();
}
}
}열한 테스트는 같은 Boot 애플리케이션 컨텍스트와 실제 Servlet 서버에서 동작합니다. 720 코드 단위는 DTO를 통과하고 721은 Bean Validation에서 멈추며, 4,096자 토큰은 더 앞선 Jackson 읽기 한계에서 멈춥니다. 실패 요청 어느 것도 서비스 호출 이력을 남기지 않습니다.
경계 해석과 연습
이 fixture에서 본문 전체 부재는 읽을 대상이 없어 JSON_NOT_READABLE이고, {"content":""}는 DTO가 만들어진 뒤 NotBlank가 실패해 REQUEST_INVALID입니다. 둘 다 400이어도 실패 위치가 다릅니다.
프록시의 원시 바이트 상한, Jackson의 근사적 스트림 제약, 역직렬화 뒤 @Size는 서로 다른 층입니다. 한 숫자를 세 곳에 복사하기보다 위협과 사용자 계약에 맞춰 각각 정하고, 세 경계의 테스트를 따로 둡니다.
예시
입력 문자열을 정규화하지 않고 그대로 비교해 봅시다. 😀 하나는 UTF-16 코드 단위 2개와 UTF-8 바이트 4개입니다. 이를 360번 반복하면 Java 길이는 720, 문자열 자체의 UTF-8 길이는 1,440바이트입니다. 반면 e 뒤에 U+0301 결합 악센트를 붙인 한 묶음은 코드 단위 2개, UTF-8 바이트 3개이므로 360묶음은 길이 720과 1,080바이트입니다. 두 입력 모두 이 예의 화면 글자소는 360개지만 전송 크기는 다릅니다.
DTO의 content 최대 720은 첫 번째 계산을 사용합니다. 화면에 보이는 글자 수나 UTF-8 바이트 수를 720과 비교하지 않습니다. 또한 이 바이트 수는 content 문자열만의 값입니다. JSON의 필드명·따옴표·다른 값과 이스케이프 표현을 포함한 전체 HTTP 본문 크기는 별도로 세어야 합니다.
연습으로 content에 이모지와 결합 문자를 넣어 Java 길이, UTF-8 바이트 수, 화면 글자소 수를 비교해 보세요. 그다음 요구사항이 어느 단위를 뜻하는지 먼저 명시한 뒤 DTO 제약을 선택합니다. 서비스가 호출되지 않았다는 단언까지 포함해야 입력 거부가 실제 애플리케이션 경계를 넘지 않았음을 증명할 수 있습니다.
독립 확인
원 JSON fixture에서 각 요청을 별도로 시작하며 title은 JSON binding, publishedOn은 2025-05-01, 요청 Content-Type은 application/json입니다. 알 수 없는 추가 필드나 다른 읽기 오류는 없고, 360·361개 이모지는 그대로 UTF-8로 보내며 작은 본문이 파서 제약을 통과한다고 가정합니다. content만 아래처럼 바꿨을 때 첫 실패 경계, 상태·오류 코드와 서비스 호출 횟수를 적으세요.
😀360개😀361개- 원 테스트와 같은 ASCII x 4,096개
- 나머지 필드는 유지하되 content 필드 자체를 생략
문자열 길이와 첫 실패 위치를 나누는 풀이
1번의 UTF-16 길이는 360 × 2 = 720이므로 DTO 길이와 명령 불변식을 통과합니다. 이 fixture는 서비스 1회와 201을 예상합니다. 2번은 361 × 2 = 722이므로 400 REQUEST_INVALID, content의 Size 오류와 서비스 0회입니다. 화면 글자소 361개가 720보다 적다는 비교는 원 제약의 단위와 맞지 않습니다.
3번은 원 테스트가 고정한 Jackson 읽기 한계에서 먼저 멈춰 400 JSON_LIMIT_EXCEEDED와 서비스 0회입니다. DTO 길이 제한도 넘지만, DTO를 만들지 못한 요청을 Bean Validation 실패로 분류하지 않습니다. 4번은 JSON 문법은 유효하고 record의 content가 null로 읽힌 뒤 NotBlank가 실패하므로 400 REQUEST_INVALID와 서비스 0회입니다. 전체 HTTP 본문 부재와도 다른 경우입니다.
이모지 문자열의 길이·바이트 수 계산과, 그 요청을 실제 Boot 서버로 보내 검증하는 일은 구분합니다. 새 두 이모지 조합의 HTTP 결과는 원 코드·제약과 명시한 파서 통과 조건에서 추론한 예상입니다. 원 예제의 날짜·파서 설정·필드 검증·서비스 기록 단언을 함께 유지한 테스트로 확인할 수 있습니다.
공식 근거
- Java 25 String의 UTF-16 표현과 length
- Spring MVC
@RequestBody - Spring MVC REST 예외와 ProblemDetail
- Spring MVC 메시지 컨버터
- Spring Boot JSON
- Spring Boot 애플리케이션 속성
- Spring Boot 애플리케이션 테스트
다음 문서에서는 컨트롤러 반환값이 논리 뷰, 응답 본문, 리다이렉트, ResponseEntity 중 어느 경로로 해석되는지 검증합니다.
겹치는 제약과 호출 경로를 구별하는 추가 연습
- 원 fixture와 같은 JSON 미디어 타입·매퍼·예외 처리기를 사용한다. title은 정상이고 content는
"", publishedOn은"not-a-date"이며 다른 읽기 오류나 파서 한계 초과는 없다. 날짜 값 변환이InvalidFormatException으로 실패한다고 하자. 첫 실패 경계, 응답 코드와 서비스 호출 횟수는 무엇인가? content의NotBlank를 먼저 보고하는가? - HTTP를 거치지 않고
new CreatePostCommand("JSON binding", "batch", 내일날짜)를 직접 만든다. 내일날짜는null이 아닌LocalDate이고 두 문자열은 원래 길이·공백 규칙을 만족한다. 원 명령이 생성되는가? 같은 값을 원 MVC DTO로 요청하면@PastOrPresent를 통과하는가? 일반 명령 생성과 HTTP 서비스 호출 횟수는 같은 관찰인가? - 원 HTTP fixture에서 모든 다른 필드·날짜·미디어 타입이 정상이고 읽기 제약을 통과한다. title만 ASCII x 120개와 그 뒤 공백 한 개로 보낸다. 서버는 trim이나 다른 정규화를 하지 않는다. 화면에서 공백을 제외하면 제목이 120자라는 이유로 통과하는가? title의 Java 길이, 첫 실패와 서비스 호출 횟수를 적어라.
추가 연습 풀이
- DTO를 완성하기 전 Jackson의 날짜 읽기가 실패하므로 400
JSON_VALUE_INVALID, 서비스 0회다. 이 조건에서는 검증 단계에 도달하지 않으므로 빈 content의NotBlank를 먼저 보고하지 않는다. 같은 요청에 여러 문제가 있더라도 이후 경계의 오류를 모두 수집했다고 해석하면 안 된다. - 원 명령 생성자는 날짜가
null인지 검사할 뿐 미래인지 검사하지 않으므로 생성된다. 같은 값은 원 MVC 경로에서 DTO의@PastOrPresent를 위반해 400REQUEST_INVALID, 서비스 0회를 예상한다. 일반 명령 객체를 만들었다는 관찰을 HTTP 서비스가 호출됐다는 기록으로 바꾸지 않는다. 두 경로의 날짜 규칙을 같게 만들지 여부는 업무 요구에 따라 별도로 결정한다. - 정규화를 하지 않으므로 Java 길이는 120 + 1 = 121이다. 공백 하나가 붙었다고 전체 문자열이 blank가 되는 것은 아니지만
@Size(max = 120)를 초과하여 400REQUEST_INVALID, title의Size오류와 서비스 0회를 예상한다. 화면에서 끝 공백이 눈에 띄지 않는다는 사실은 UTF-16 길이 계약을 바꾸지 않는다.
위 HTTP 결과는 원 fixture의 명시한 계약에서 도출한 예상이며, 별도 MVC·HTTP 실행 로그와 길이·명령 규칙을 계산하는 순수 Java 검증은 구별합니다.