본문으로 건너뛰기

안동민 개발노트

본문 시작

요청 본문·JSON 변환

RequestBody가 미디어 타입 선택, Jackson 3 역직렬화, DTO 검증, 애플리케이션 명령 매핑을 통과하는 경계를 실행합니다.

JSON 요청은 @RequestBody 하나로 곧바로 애플리케이션 명령이 되지 않습니다. 매핑의 consumes 조건이 미디어 타입을 먼저 거르고, 선택된 HttpMessageConverter가 Boot의 Jackson 3 매퍼로 본문을 읽습니다. 그 결과로 만든 DTO를 @Valid가 검사한 뒤에야 컨트롤러가 명령으로 명시적으로 매핑합니다.

JSON 본문은 media type·읽기·검증 경계를 지나 애플리케이션 명령이 된다

FLOWCHART · REQUEST BODY · JACKSON 3 · SERVICE GATE

JSON 본문은 media type·읽기·검증 경계를 지나 애플리케이션 명령이 된다

media type 선택, Jackson 읽기, DTO 검증은 서로 다른 경계다. 어느 경계에서든 실패하면 명시적 command mapping과 service 호출에 도달하지 않는다.

JSON 본문은 media type·읽기·검증 경계를 지나 애플리케이션 명령이 된다 요청 본문과 Content-Type이 media type 선택, Jackson 3 JSON 읽기, DTO 제약 검증을 차례로 통과할 때만 명시적으로 command로 매핑되어 handler와 service 경계에 전달되며, 읽기 실패는 400과 fallback JSON_NOT_READABLE 또는 원인별 JSON_LIMIT_EXCEEDED, JSON_PROPERTY_UNKNOWN, JSON_VALUE_INVALID 코드로 끝나고, media type과 DTO 검증 실패는 각각 415와 REQUEST_INVALID 400으로 끝난다. NO YES NO YES NO YES HTTP INPUT 요청 본문 + Content-Type MEDIA GATE 지원하는 JSON media type인가? UNSUPPORTED MEDIA TYPE 415 본문을 읽기 전에 거부 READ GATE JSON을 DTO로 읽을 수 있나? JSON READ FAILURE 400 fallback 또는 원인별 code MESSAGE CONVERTER Boot Jackson 3 JsonMapper → DTO converter가 애플리케이션 mapper로 읽는다 VALIDATION GATE DTO 제약을 통과했나? REQUEST_INVALID 400 DTO constraint 위반 EXPLICIT MAPPING · SERVICE GATE DTO → CreatePostCommand handler → PostService.register(command) 본문 자체가 없으면 읽기 실패다. {"content":""}는 DTO가 생성된 뒤 validation에서 거부된다. proxy·container의 raw byte 제한, Jackson의 근사적 stream constraints, 역직렬화 뒤 @Size는 서로 다른 층이다. LEGEND decision step terminal outcome accepted mapping / service path
  1. HTTP INPUT · MEDIA GATE

    요청 본문과 Content-Type으로 JSON reader를 선택한다

    지원하지 않는 media type이면 본문을 읽기 전에 415로 끝납니다.

  2. READ GATE

    Jackson이 JSON 문법과 field type을 읽는다

    빈 HTTP body·잘못된 문법은 fallback JSON_NOT_READABLE, stream constraint·unknown property·값 타입 cause는 각각 JSON_LIMIT_EXCEEDED·JSON_PROPERTY_UNKNOWN·JSON_VALUE_INVALID로 분류하며 모두 400으로 끝납니다.

    proxy·container raw byte 상한, Jackson stream constraint, 역직렬화 뒤 @Size는 서로 다른 층입니다.

  3. MESSAGE CONVERTER

    Boot Jackson 3 JsonMapper로 DTO를 만든다

    converter와 애플리케이션이 같은 설정의 mapper를 사용합니다.

  4. VALIDATION GATE

    DTO constraints를 검증한다

    누락·null·빈 값·길이·날짜 제약을 통과하지 못하면 400 REQUEST_INVALID로 끝납니다.

  5. EXPLICIT MAPPING

    DTO를 CreatePostCommand로 명시적으로 변환한다

    HTTP 입력 모델과 애플리케이션 명령의 역할을 분리합니다.

  6. SERVICE GATE

    handler가 검증된 command만 service 경계로 넘긴다

    앞선 경계에서 거부된 요청은 service를 호출하지 않습니다.

DTO와 command/domain의 규칙이 일부 겹치는 것은 의도적이다. 같은 command를 만드는 HTTP 이외의 진입 경로도 애플리케이션 경계에서 동일한 invariant를 지켜야 한다.

그림처럼 JSON 본문은 미디어 타입·역직렬화·검증 경계를 모두 통과해야 명령이 됩니다. 이 문서는 요청을 읽는 경계까지만 소유합니다. 성공 응답의 정확한 상태·헤더 선택은 ch6-5, 컨버터와 콘텐츠 협상의 상세 규칙은 ch6-6에서 다룹니다.


실행 기준과 읽기 한계 고정

예제는 Java 25와 Spring Boot 4.1.1 BOM을 한 그래프로 사용합니다. 웹 MVC, Bean Validation, 실제 서버용 테스트 클라이언트 외의 별도 JSON 매퍼는 추가하지 않습니다.

settings.gradle
rootProject.name = 'json-request-body'
build.gradle
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에서 오타로 보고 거부합니다. 읽기 제약은 작은 등록 요청이 비정상적으로 커지거나 깊어질 때 파서 작업을 중단하는 방어선입니다.

src/main/resources/application.properties
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=1000

max-document-lengthmax-string-length는 Jackson 입력 방식과 파서가 세는 단위에 따른 근사적 처리 한계입니다. 프록시·Servlet 컨테이너가 적용하는 원시 HTTP 본문 바이트 상한을 대신하지 않습니다. 운영에서는 전송 계층의 바이트 상한과 파서 한계를 별도로 둡니다.


애플리케이션 명령의 불변식

애플리케이션 시작점은 웹 어댑터보다 상위 패키지에 둡니다.

src/main/java/board/BoardApplication.java
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를 거치지 않는 진입점도 같은 명령을 만들 수 있으므로 핵심 문자열 불변식은 명령에서 다시 지킵니다. 이 중복은 역할이 다른 두 경계의 의도적인 겹침입니다.

src/main/java/board/post/CreatePostCommand.java
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 응답으로 표현할지는 다음 문서의 책임입니다.

src/main/java/board/post/PostService.java
package board.post;

@FunctionalInterface
public interface PostService {
    long register(CreatePostCommand command);
}

DTO에서 명령으로 명시적으로 건넨다

JSON DTO는 외부 필드 모양과 입력 오류를 담당합니다. 엔티티에 직접 바인딩하지 않고 허용한 세 필드만 명령으로 복사합니다.

src/main/java/board/web/CreatePostRequest.java
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);
    }
}

@SizeString에 적용할 때 세는 것은 Java CharSequence.length() 길이, 즉 UTF-16 코드 단위입니다. JSON 전송 바이트나 사용자가 보는 글자소 수와 같다고 설명하면 안 됩니다. 바이트 과금이나 화면 글자 수가 계약이라면 각각 별도 규칙을 설계합니다.

src/main/java/board/web/PostRegistrationController.java
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 검증이 실패하면 메서드 본문에 들어오지 않습니다. 따라서 서비스 호출 이력은 상태 코드와 함께 경계가 작동했다는 핵심 증거가 됩니다.


읽기 오류와 필드 오류를 안전하게 분류한다

필드 오류 응답은 외부 계약에 필요한 필드명과 안정적인 검증 코드만 담습니다.

src/main/java/board/web/FieldProblem.java
package board.web;

public record FieldProblem(String field, String code) {}

Jackson 내부 메시지를 그대로 반환하지 않습니다. 원시 입력 조각이나 Java 타입명이 포함될 수 있기 때문입니다. 대신 제한 초과, 알 수 없는 속성, 값 변환, 그 밖의 읽기 실패를 안전한 코드로 분류합니다.

src/main/java/board/web/ApiInputExceptionHandler.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/plainconsumes와 불일치415 MEDIA_TYPE_UNSUPPORTED0
본문 부재·문법 오류·날짜 변환·알 수 없는 속성·파서 한계400의 분류된 JSON 읽기 코드0
DTO의 공백·길이·미래 날짜 제약 위반400 REQUEST_INVALID0
모든 경계를 통과한 JSON명령 한 번 매핑, 이 fixture에서는 2011

일반 record에서 누락된 content와 명시적인 "content": null은 모두 null로 역직렬화됩니다. 이 예제는 둘을 구별한다고 주장하지 않고 같은 NotBlank 실패군으로 검증합니다. PATCH처럼 “누락”과 “null로 지움”이 다른 API라면 존재 여부를 표현하는 별도 입력 타입이 필요합니다.


실제 Boot 매퍼와 서버 경계를 검증한다

독립형 컨트롤러 바인딩만으로는 Boot가 구성한 매퍼를 사용했다고 증명할 수 없습니다. 테스트는 RANDOM_PORT 서버를 띄우고 Boot가 주입한 RestTestClient로 HTTP를 보냅니다. 동시에 MVC의 Jackson 컨버터가 같은 JsonMapper 인스턴스를 쓰는지 검사합니다.

src/test/java/board/web/JsonRequestBodyTest.java
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는 서로 다른 층입니다. 한 숫자를 세 곳에 복사하기보다 위협과 사용자 계약에 맞춰 각각 정하고, 세 경계의 테스트를 따로 둡니다.

연습으로 content에 이모지와 결합 문자를 넣어 Java 길이, UTF-8 바이트 수, 화면 글자소 수를 비교해 보세요. 그다음 요구사항이 어느 단위를 뜻하는지 먼저 명시한 뒤 DTO 제약을 선택합니다. 서비스가 호출되지 않았다는 단언까지 포함해야 입력 거부가 실제 애플리케이션 경계를 넘지 않았음을 증명할 수 있습니다.


공식 근거

다음 문서에서는 컨트롤러 반환값이 논리 뷰, 응답 본문, 리다이렉트, ResponseEntity 중 어느 경로로 해석되는지 검증합니다.