요청 본문·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 본문은 Content-Type과 consumes, 메시지 컨버터와 Boot의 JsonMapper, @Valid DTO 검증을 모두 통과해야 핸들러에 들어가고, 핸들러가 명령을 만들어 불변식을 확인한 뒤 서비스를 한 번 호출합니다. 미디어 타입의 415, JSON 읽기의 400, DTO 검증의 400은 서로 다른 실패이며 모두 서비스 호출이 0회입니다. 이 글은 요청을 읽는 단계까지만 다룹니다. 성공 응답의 정확한 상태와 헤더는 뷰·리다이렉트·응답 본문에서, 컨버터와 콘텐츠 협상의 자세한 규칙은 HTTP 메시지 변환기에서 다룹니다.
실행 기준과 읽기 한계를 정합니다
예제는 Java 25와 Spring Boot 4.1.2 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.2')
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.2')
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의 입력 방식과 파서가 세는 단위에 따른 대략적인 처리 한계입니다. 프록시나 서블릿 컨테이너가 적용하는 원시 HTTP 본문의 바이트 상한을 대신하지 않으므로, 운영에서는 전송 계층의 바이트 상한과 파서의 한계를 따로 둡니다.
더 깊이: 이 설정을 지우면 남는 Jackson 3의 기본값
fail-on-unknown-properties=true를 일부러 적은 것은 Jackson 3의 기본값이 반대이기 때문입니다. jackson-databind 3.1.7의 DeserializationFeature는 FAIL_ON_UNKNOWN_PROPERTIES가 3.0부터 기본으로 꺼져 있다고 적습니다(2.x에서는 켜져 있었습니다). 그래서 이 줄을 지우면 admin 같은 모르는 속성은 400이 되지 않고 조용히 무시됩니다. 반대로 원시 타입 필드에 null이 오면 실패하는 FAIL_ON_NULL_FOR_PRIMITIVES와, JSON 값 하나 뒤에 다른 토큰이 이어지면 실패하는 FAIL_ON_TRAILING_TOKENS는 3.0부터 기본으로 켜졌습니다.
읽기 제약에도 기본값이 있습니다. jackson-core 3.1.7의 StreamReadConstraints에서 중첩 깊이는 500(2.x의 1000에서 3.0에 낮아짐), 문자열 길이는 100,000,000, 숫자 길이는 1000, 속성 이름 길이는 50,000이며, 문서 전체 길이와 토큰 수는 제한이 없습니다. 위 설정의 8192와 20, 1000은 이 기본값보다 훨씬 좁은 값이므로, 설정을 지우면 수십 메가바이트짜리 문자열도 파서 단계에서는 통과할 수 있습니다.
Boot 4는 Jackson 3를 기본 JSON 라이브러리로 쓰고, Boot의 JSON 문서는 Jackson 2 지원을 이후의 4.x에서 없앨 예정인 이전용 기능으로 설명합니다. Jackson 2를 쓰던 애플리케이션을 옮길 때는 spring.jackson.use-jackson2-defaults=true로 Boot가 Jackson 2에서 쓰던 기본값에 최대한 가깝게 맞출 수 있으므로, 어떤 기본값이 적용되는지 판단하기 전에 이 속성부터 확인합니다.
애플리케이션 명령의 불변식
애플리케이션의 시작점은 웹 어댑터보다 상위 패키지에 둡니다. BoardApplication이 board 패키지에 있으므로, 컴포넌트 스캔은 board.web의 컨트롤러와 어드바이스를 모두 찾습니다. 시작 클래스를 board.web 안에 두면 그 바깥의 형제 패키지는 스캔 범위에서 빠집니다.
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 코드에서 레코드를 만들거나 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으로 전송되는 바이트 수나 사용자가 보는 글자(grapheme) 수와 같다고 설명하면 안 됩니다. 바이트 과금이나 화면 글자 수가 요구 사항이라면 각각 별도의 규칙을 설계합니다.
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에서는 그것을 따로 설계합니다. 이 예제의 서비스 호출 횟수는 입력 검증을 확인하기 위한 것이며 권한 검사를 실행했다는 뜻이 아닙니다.
읽기 오류와 필드 오류를 안전하게 분류합니다
필드 오류 응답에는 외부에 알려야 할 필드 이름과 바뀌지 않는 검증 코드만 담습니다. 아래 FieldProblem은 두 문자열만 가진 레코드이며, 검증기가 만든 사람용 메시지나 거부된 값은 넣지 않습니다. 그래서 클라이언트는 메시지의 언어나 문구가 바뀌어도 code로 오류를 구분할 수 있습니다.
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를 처리할 때는 예외가 제공한 헤더를 그대로 보존합니다. 이 예제는 Accept: application/json으로 지원하는 요청 형식을 알리지만, 미디어 타입 선택과 406을 포함한 자세한 협상 규칙은 HTTP 메시지 변환기에서 다룹니다.
| 입력과 처음 실패한 단계 | 이 예제의 결과 | 서비스 호출 |
|---|---|---|
text/plain이 consumes와 맞지 않음 | 415 MEDIA_TYPE_UNSUPPORTED | 0 |
| 본문 없음, 문법 오류, 날짜 변환, 알 수 없는 속성, 파서 한계 | 분류된 JSON 읽기 코드의 400 | 0 |
| DTO의 공백·길이·미래 날짜 제약 위반 | 400 REQUEST_INVALID | 0 |
| 모든 단계를 통과한 JSON | 명령으로 한 번 매핑, 이 예제에서는 201 | 1 |
일반 레코드에서는 누락된 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 애플리케이션 컨텍스트와 실제 서블릿 서버에서 동작합니다. 720 코드 단위는 DTO를 통과하고 721은 Bean Validation에서 멈추며, 4,096자 토큰은 그보다 앞선 Jackson의 읽기 한계에서 멈춥니다. 실패한 요청은 어느 것도 서비스 호출 기록을 남기지 않습니다.
결과를 해석하는 방법
이 예제에서 본문이 아예 없으면 읽을 대상이 없어 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 본문의 크기는 따로 세어야 합니다. 요구 사항이 어떤 단위를 뜻하는지 먼저 정한 뒤 DTO 제약을 고르고, 서비스가 호출되지 않았다는 단언까지 넣어야 거부된 입력이 애플리케이션 안으로 들어가지 않았다는 것을 확인할 수 있습니다.
이 글의 내용은 Java 25 String의 UTF-16 표현과 length, Spring MVC @RequestBody, Spring MVC REST 예외와 ProblemDetail, Spring MVC 메시지 컨버터, Spring Boot JSON, Spring Boot 애플리케이션 속성, Spring Boot 애플리케이션 테스트를 기준으로 합니다.
정리
- JSON 본문은
consumes의 미디어 타입 확인, 메시지 컨버터와 Jackson 3의 읽기,@ValidDTO 검증을 차례로 통과해야 명령이 됩니다. - 415와 JSON 읽기의 400, DTO 검증의 400은 실패한 위치가 다르며, 어느 경우든 서비스는 호출되지 않습니다.
- Jackson의 내부 메시지 대신 안전한 오류 코드로 분류하고, 알 수 없는 속성은 오타로 보고 거부합니다.
- 프록시의 바이트 상한과 Jackson의 읽기 제약, UTF-16 단위의
@Size는 서로 다른 층이므로 따로 정하고 따로 테스트합니다. - 명령은 HTTP 밖의 진입점을 위해 핵심 불변식을 다시 확인하지만, DTO의 모든 규칙을 되풀이하지는 않습니다.
연습 문제
1. 문자열 길이와 처음 실패한 위치
각 요청은 따로 시작하며 title은 JSON binding, publishedOn은 2025-05-01, Content-Type은 application/json입니다. 알 수 없는 추가 필드나 다른 읽기 오류는 없고, 이모지는 그대로 UTF-8로 보내며 작은 본문은 파서 제약을 통과합니다. content만 ① 😀 360개, ② 😀 361개, ③ 테스트와 같은 ASCII x 4,096개, ④ 필드 자체를 생략으로 바꿨을 때, 처음 실패한 단계와 상태·오류 코드, 서비스 호출 횟수를 적으세요.
해설 보기
① UTF-16 길이는 360 × 2 = 720이므로 DTO의 길이 제약과 명령의 불변식을 통과해, 서비스 1회와 201이 예상됩니다. ② 361 × 2 = 722이므로 400 REQUEST_INVALID와 content의 Size 오류, 서비스 0회입니다. 화면 글자 361개가 720보다 적다는 비교는 제약의 단위와 맞지 않습니다.
③ 테스트가 고정한 Jackson의 읽기 한계에서 먼저 멈춰 400 JSON_LIMIT_EXCEEDED와 서비스 0회입니다. DTO의 길이 제한도 넘지만, DTO를 만들지 못한 요청을 Bean Validation 실패로 분류하지 않습니다. ④ JSON 문법은 올바르고 레코드의 content가 null로 읽힌 뒤 NotBlank가 실패하므로 400 REQUEST_INVALID와 서비스 0회이며, 본문 전체가 없는 경우와도 다릅니다. 길이와 바이트 계산은 확실하지만, 이모지를 넣은 두 요청의 HTTP 결과는 코드와 제약에서 추론한 예상이므로 테스트에 추가해 확인합니다.
2. 겹치는 제약과 호출 경로
① 테스트와 같은 미디어 타입과 매퍼, 예외 처리기를 씁니다. title은 정상이고 content는 "", publishedOn은 "not-a-date"이며, 날짜 변환은 InvalidFormatException으로 실패합니다. 처음 실패한 단계와 응답 코드, 서비스 호출 횟수는 무엇이며, content의 NotBlank를 먼저 보고하나요? ② HTTP를 거치지 않고 new CreatePostCommand("JSON binding", "batch", 내일 날짜)를 직접 만듭니다. 내일 날짜는 null이 아닌 LocalDate이고 두 문자열은 길이와 공백 규칙을 만족합니다. 명령이 만들어지나요? 같은 값을 MVC DTO로 요청하면 @PastOrPresent를 통과하나요? ③ 다른 필드와 미디어 타입이 모두 정상이고 읽기 제약을 통과하는 요청에서, title만 ASCII x 120개 뒤에 공백 한 개를 붙여 보냅니다. 서버는 trim이나 다른 정규화를 하지 않습니다. title의 Java 길이와 처음 실패한 단계, 서비스 호출 횟수를 적으세요.
해설 보기
① DTO를 완성하기 전에 Jackson의 날짜 읽기가 실패하므로 400 JSON_VALUE_INVALID와 서비스 0회입니다. 검증 단계에 도달하지 않으므로 빈 content의 NotBlank는 보고되지 않습니다. 한 요청에 문제가 여럿 있어도 뒤 단계의 오류까지 모두 모았다고 해석하면 안 됩니다.
② 명령의 생성자는 날짜가 null인지만 검사하고 미래인지는 검사하지 않으므로 만들어집니다. 같은 값을 MVC로 보내면 DTO의 @PastOrPresent를 위반해 400 REQUEST_INVALID와 서비스 0회가 예상됩니다. 명령 객체를 만들었다는 것과 HTTP로 서비스가 호출되었다는 것은 다른 일이며, 두 경로의 날짜 규칙을 같게 할지는 업무 요구에 따라 정합니다.
③ 정규화를 하지 않으므로 Java 길이는 120 + 1 = 121입니다. 공백 하나가 붙어도 문자열 전체가 공백이 되지는 않지만 @Size(max = 120)를 넘으므로, 400 REQUEST_INVALID와 title의 Size 오류, 서비스 0회가 예상됩니다. 화면에서 끝의 공백이 눈에 띄지 않는다고 UTF-16 길이의 기준이 바뀌지는 않습니다.
다음 글 뷰·리다이렉트·응답 본문에서는 컨트롤러의 반환 값이 논리 뷰와 응답 본문, 리다이렉트, ResponseEntity 가운데 어느 경로로 해석되는지 확인합니다.