본문으로 건너뛰기

안동민 개발노트

본문 시작

요청 파라미터 바인딩

RequestParam과 ModelAttribute의 서로 다른 바인딩 경로를 실행하고 기본값·변환·검증·과다 바인딩 경계를 구분합니다.

이 fixture의 쿼리와 URL 인코딩 폼은 Servlet 파라미터 맵으로 들어옵니다. 단일·반복 값은 @RequestParam이 해석·변환하고, 필드 묶음은 @ModelAttribute가 전용 객체에 생성자 또는 속성 바인딩한 뒤 검증합니다. 두 resolver 경로를 하나의 파이프라인으로 일반화하지 않습니다.

Servlet parameter map에서 입력 모양에 따라 RequestParam과 ModelAttribute resolver로 나뉩니다. RequestParam은 required, default, repeated 규칙 뒤 변환과 직접 제약 검증을 거치고, ModelAttribute는 전용 입력 객체 생성 뒤 property binding의 allowedFields와 suppressed guard, 객체 검증과 인접한 BindingResult를 거칩니다. 허용된 입력만 명시적 mapper를 거쳐 애플리케이션 query 또는 command가 되어 service를 호출합니다.

FLOWCHART · RESOLVER-SPECIFIC PIPELINES · SERVICE GATE

요청 파라미터는 입력 모양별 resolver와 검증 경계를 거쳐 애플리케이션 타입이 된다

단순 값과 입력 객체는 같은 파이프라인이 아니다. 각 resolver의 변환·바인딩·검증 실패는 service 호출 전에 멈추고, 통과한 값만 명시적으로 query 또는 command로 매핑됩니다.

요청 파라미터 resolver와 애플리케이션 경계 Servlet parameter map이 RequestParam 단순 값 경로와 ModelAttribute 전용 입력 객체 경로로 나뉩니다. 두 경로는 각자 변환, property binding, 검증 실패를 service 호출 전에 처리한 뒤 병합되어 명시적 mapper와 애플리케이션 query 또는 command 경계로 진행합니다. @REQUESTPARAM @MODELATTR CONVERT PROPERTY BIND OBJECT CHECK ACCEPTED RAW WEB INPUT Servlet parameter map INPUT SHAPE resolver를 고른다 REQUIRED · DEFAULT · REPEATED RequestParam 값 규칙 TYPE CONVERSION 변환 + 직접 @Min / @Max handler 호출 전 검증 DEDICATED INPUT TYPE ModelAttribute 객체 생성 PROPERTY BINDING ONLY allowedFields + suppressed guard 허용 목록 밖 field를 명시적으로 거부 OBJECT VALIDATION 검증 + 인접한 BindingResult 오류가 있으면 같은 form을 렌더링 EXPLICIT MAPPER web input → query / command APPLICATION BOUNDARY 애플리케이션 query / command REJECTED BEFORE SERVICE @REQUESTPARAM conversion / @Min·@Max 실패 → pre-handler 400 SUPPRESSED FIELD → explicit suppressed-field reject BINDING RESULT → same-form render SERVICE GATE accepted input only invokes service
  1. RAW INPUT

    Servlet parameter map에서 시작한다

    HTTP 요청의 이름-값 입력을 아직 domain 타입으로 취급하지 않습니다.

  2. INPUT SHAPE

    입력 모양으로 resolver를 고른다

    • @RequestParam: 단순 값 또는 반복 값
    • @ModelAttribute: 전용 입력 객체
  3. REQUESTPARAM BRANCH

    required·default·repeated 뒤 변환과 직접 제약을 검사한다

    분기 결과: conversion 또는 직접 @Min·@Max 검증 실패는 pre-handler 400이다.

  4. MODELATTRIBUTE BRANCH

    전용 객체 생성 뒤 property binding과 객체 검증을 분리한다

    allowedFields와 suppressed guard는 property binding에만 적용하고, 객체 검증 결과는 바로 뒤의 BindingResult에서 읽습니다.

    분기 결과: suppressed field는 명시적으로 거부하고, binding·validation 오류는 same-form render로 돌아갑니다.

  5. MERGE

    통과한 두 경로만 명시적 mapper에서 합류한다

    web input을 애플리케이션 query 또는 command로 변환합니다.

  6. SERVICE GATE

    accepted input only invokes service

    검증·정책 경계를 통과하지 못한 입력은 이 지점에 도달하지 않습니다.

allowedFields@ModelAttribute의 property binding에만 적용됩니다. constructor binding은 constructor parameter와 전용 입력 타입으로 별도 제약해야 하며, @RequestParam의 변환·직접 메서드 검증 경로와 동일한 처리로 보아서는 안 됩니다.

그림의 400과 폼 재렌더링은 아래 Spring Framework 7.0.9 fixture에 한정됩니다. defaultValue는 누락과 빈 값을 합칠 수 있고, 인접한 BindingResult는 오류 뒤에도 폼 핸들러 진입을 허용합니다.


실행 기준 고정

예제는 Java 25, Spring Boot 4.1.1 BOM, Spring Framework 7.0.9, JUnit 6.0.3을 한 의존성 그래프로 사용합니다. 모든 fence는 경로가 붙은 완전한 파일입니다.

settings.gradle
rootProject.name = 'request-parameter-binding'
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() }

웹 MVC starter만으로 Bean Validation 구현은 보장되지 않습니다. spring-boot-starter-validation을 명시하고 @NotBlank 실행을 테스트합니다. -parameters를 켜도 모든 @RequestParam 이름은 직접 적습니다.


애플리케이션 타입을 완결한다

웹 입력은 바로 엔티티가 되지 않습니다. 검색은 불변 쿼리로, 등록 폼은 명령으로 명시적으로 매핑됩니다.

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

import java.time.LocalDate;
import java.util.List;

public record FindPostsQuery(String query, int page, int size, List<String> tags, LocalDate from) {
    public FindPostsQuery {
        tags = List.copyOf(tags);
    }
}
src/main/java/board/post/PostPage.java
package board.post;

import java.util.List;

public record PostPage(List<Long> postIds) {
    public PostPage {
        postIds = List.copyOf(postIds);
    }

    public static PostPage empty() {
        return new PostPage(List.of());
    }
}
src/main/java/board/post/PostSearch.java
package board.post;

@FunctionalInterface
public interface PostSearch {
    PostPage find(FindPostsQuery query);
}
src/main/java/board/post/CreatePostCommand.java
package board.post;

import java.time.LocalDate;

public record CreatePostCommand(String title, String content, LocalDate publishedOn) {}
src/main/java/board/post/SavedPost.java
package board.post;

public record SavedPost(long id) {}
src/main/java/board/post/PostService.java
package board.post;

public interface PostService {
    SavedPost register(CreatePostCommand command);
}

FindPostsQueryCreatePostCommand는 Spring MVC를 모릅니다. 서비스에는 검증을 통과한 불변 타입만 넘깁니다.


RequestParam은 이름 있는 값을 해석한다

query는 필수이고 page·size는 누락·빈 값에 기본값을 씁니다. tag는 반복 키와 쉼표 값을 같은 List<String>으로, 날짜는 ISO로 받습니다.

범위는 IllegalArgumentException이 아니라 파라미터 @Min·@Max로 선언합니다. Spring MVC built-in method validation을 쓰며 컨트롤러 클래스에는 @Validated를 붙이지 않습니다.

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

import board.post.FindPostsQuery;
import board.post.PostPage;
import board.post.PostSearch;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import java.time.LocalDate;
import java.util.List;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/posts")
public final class PostSearchController {
    private final PostSearch search;

    public PostSearchController(PostSearch search) {
        this.search = search;
    }

    @GetMapping
    public PostPage find(
            @RequestParam(name = "query") String query,
            @RequestParam(name = "page", defaultValue = "0") @Min(0) int page,
            @RequestParam(name = "size", defaultValue = "20") @Min(1) @Max(100) int size,
            @RequestParam(name = "tag", defaultValue = "") List<String> tags,
            @RequestParam(name = "from", required = false)
                    @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from) {
        return search.find(new FindPostsQuery(query, page, size, tags, from));
    }
}

defaultValue는 required를 해제하고 누락·빈 값을 같은 기본값으로 모읍니다. 기본값 없는 빈 String은 누락과 다르며, 변환 결과가 null이면 변환 뒤 필수 누락이 될 수도 있습니다.

기본 conversion service는 반복 키와 tag=network,spring을 모두 컬렉션으로 바꿉니다. 이 fixture는 두 모양을 허용합니다.


ModelAttribute는 전용 폼을 바인딩한다

등록 폼은 세 필드만 노출하고 불변 명령으로 변환하는 웹 입력 어댑터입니다.

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

import board.post.CreatePostCommand;
import jakarta.validation.constraints.*;
import java.time.LocalDate;
import org.springframework.format.annotation.DateTimeFormat;

public final class CreatePostForm {
    @NotBlank
    private String title;

    @NotBlank
    @Size(max = 720)
    private String content;

    @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
    private LocalDate publishedOn;

    public String getTitle() { return title; }

    public void setTitle(String title) { this.title = title; }

    public String getContent() { return content; }

    public void setContent(String content) { this.content = content; }

    public LocalDate getPublishedOn() { return publishedOn; }

    public void setPublishedOn(LocalDate publishedOn) { this.publishedOn = publishedOn; }

    public CreatePostCommand toCommand() {
        return new CreatePostCommand(title, content, publishedOn);
    }
}

setAllowedFields는 속성 바인딩 허용 목록입니다. 제외한 값은 자동 오류가 아니라 getSuppressedFields()에 기록되므로 컨트롤러가 명시적으로 거부합니다.

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

import board.post.PostService;
import jakarta.validation.Valid;
import java.util.Arrays;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ExtendedServletRequestDataBinder;

@Controller
public final class PostFormPageController {
    private final PostService service;

    public PostFormPageController(PostService service) {
        this.service = service;
    }

    @InitBinder("form")
    void configureFormBinder(WebDataBinder binder) {
        binder.setAllowedFields("title", "content", "publishedOn");
        if (binder instanceof ExtendedServletRequestDataBinder servletBinder) {
            servletBinder.setHeaderPredicate(name -> false);
        }
    }

    @PostMapping("/posts")
    public String register(
            @Valid @ModelAttribute("form") CreatePostForm form,
            BindingResult binding) {
        var suppressed = binding.getSuppressedFields();
        if (suppressed.length > 0) {
            Arrays.sort(suppressed);
            binding.reject("binding.disallowed", suppressed, "Disallowed form fields");
        }

        if (binding.hasErrors()) {
            return "post-form";
        }

        var saved = service.register(form.toCommand());
        return "redirect:/posts/" + saved.id();
    }
}

BindingResult는 검증할 @ModelAttribute 바로 뒤에 둡니다. 오류면 post-form을 렌더링하고 서비스를 건너뜁니다. 컨트롤러는 선언된 String 반환만 사용하며, 정확한 리다이렉트 상태와 Location은 ch6-5가 소유합니다.

allowedFields는 생성자 바인딩에 적용되지 않습니다. 생성자 파라미터가 불변 입력의 표면이고, 허용·suppressed 검사는 변경 가능한 속성 바인딩만 지킵니다.

ExtendedServletRequestDataBinder는 파라미터 외 URI 변수와 헤더도 후보로 삼습니다. query/form 전용 fixture라서 setHeaderPredicate(name -> false)로 헤더만 제외하며, 이를 일반 기본값으로 주장하지 않습니다.


도구 선택과 실패 결과를 표로 고정한다

단순 비교는 별도 그림 대신 실제 Markdown 표로 남깁니다. @PathVariable은 앞 문서 ch6-2가 소유합니다.

입력 모양도구이 문서의 핵심 경계
이름 있는 단일·반복 query 값@RequestParamrequired/default 해석 뒤 개별 타입 변환
불변 입력 객체@ModelAttribute 생성자 바인딩생성자 파라미터가 입력 표면이며 property allowlist 대상이 아님
변경 가능한 URL 인코딩 폼@ModelAttribute 속성 바인딩setter별 변환, allowed/suppressed field, 객체 검증

같은 입력 형태도 설정에 따라 실패 지점과 핸들러 실행 여부가 달라집니다.

조건이 fixture의 결과폼/검색 핸들러서비스
필수 query 누락400, MissingServletRequestParameterException실행 안 함0회
page·size 누락 또는 빈 값기본값 0·20실행1회
숫자·날짜 변환 실패400, MethodArgumentTypeMismatchException실행 안 함0회
@Min·@Max 위반400, HandlerMethodValidationException실행 안 함0회
폼 변환·@Valid 오류와 인접한 BindingResult200, post-form실행0회
ownerId·admin 과다 바인딩suppressed 기록 뒤 binding.disallowed실행0회
검증 폼 뒤 BindingResult 없음400, MethodArgumentNotValidException실행 안 함0회
allowlist 없는 strict binder의 일반 unknown property명시 advice가 400으로 변환실행 안 함0회

마지막 두 행은 다릅니다. allowlist 밖 값은 suppressed field가 되고, allowlist 없이 ignoreUnknownFields=false인 일반 unknown 속성은 NotWritablePropertyException입니다. 이 fixture는 명시적 handler로만 400을 만듭니다.


열두 계약을 실행으로 증명한다

테스트는 상태와 함께 정확한 예외, 오류 코드, suppressed field, 매핑 값, 핸들러·서비스 호출 횟수를 고정합니다.

src/test/java/board/web/QueryAndFormBindingTest.java
package board.web;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup;

import board.post.*;
import jakarta.validation.Valid;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.NotWritablePropertyException;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.test.web.servlet.*;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.*;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.method.annotation.*;

class QueryAndFormBindingTest {
    @Test
    void 누락과_빈_값은_page_size의_기본값을_적용한다() throws Exception {
        var fixture = searchFixture();
        fixture.mvc().perform(posts()).andExpect(status().isOk());
        fixture.mvc().perform(posts().param("page", "").param("size", "").param("tag", ""))
                .andExpect(status().isOk());
        var expected = new FindPostsQuery("HTTP", 0, 20, List.of(), null);
        assertThat(fixture.queries()).containsExactly(expected, expected);
    }

    @Test
    void 반복과_쉼표_tag는_같은_List_계약으로_변환한다() throws Exception {
        var fixture = searchFixture();
        fixture.mvc().perform(posts().param("tag", "network", "spring"))
                .andExpect(status().isOk());
        fixture.mvc().perform(posts().param("tag", "network,spring"))
                .andExpect(status().isOk());
        assertThat(fixture.queries()).extracting(FindPostsQuery::tags)
                .containsExactly(List.of("network", "spring"), List.of("network", "spring"));
    }

    @Test
    void 필수_파라미터_누락은_handler_전_400이다() throws Exception {
        rejectedSearch(MissingServletRequestParameterException.class, get("/api/posts"));
    }

    @Test
    void 숫자와_날짜_변환_실패는_service_전_400이다() throws Exception {
        rejectedSearch(MethodArgumentTypeMismatchException.class,
                posts().param("page", "not-a-number"), posts().param("from", "2026-99-99"));
    }

    @Test
    void page와_size의_범위_위반은_method_validation_400이다() throws Exception {
        rejectedSearch(HandlerMethodValidationException.class,
                posts().param("page", "-1"), posts().param("size", "101"));
    }

    @Test
    void 유효한_폼은_command로_한번_매핑하고_service를_호출한다() throws Exception {
        var fixture = formFixture();
        fixture.mvc().perform(validForm().param("publishedOn", "2026-08-28"))
                .andExpect(status().is3xxRedirection())
                .andExpect(view().name("redirect:/posts/41"));
        assertThat(fixture.commands()).containsExactly(new CreatePostCommand(
                "Spring MVC", "binding", LocalDate.of(2026, 8, 28)));
    }

    @Test
    void 빈_필드는_BindingResult에_담겨_같은_뷰를_렌더링한다() throws Exception {
        var fixture = formFixture();
        var result = fixture.mvc().perform(form(" ", ""))
                .andExpect(status().isOk())
                .andExpect(view().name("post-form"))
                .andExpect(model().attributeHasFieldErrors("form", "title", "content"))
                .andReturn();
        var binding = bindingResult(result);
        assertThat(binding.getFieldError("title").getCode()).isEqualTo("NotBlank");
        assertThat(binding.getFieldError("content").getCode()).isEqualTo("NotBlank");
        assertThat(fixture.commands()).isEmpty();
    }

    @Test
    void 너무_긴_content는_BindingResult에_담긴다() throws Exception {
        fieldError(form("Spring MVC", "x".repeat(721)), "content", "Size");
    }

    @Test
    void publishedOn_변환_오류는_BindingResult에_담긴다() throws Exception {
        var request = validForm().param("publishedOn", "not-a-date");
        fieldError(request, "publishedOn", "typeMismatch");
    }

    @Test
    void 인접한_BindingResult가_없으면_MethodArgumentNotValidException이다() throws Exception {
        var controller = new NoBindingResultController();
        var mvc = standaloneSetup(controller).build();
        rejected(mvc, form("/no-binding-result", "", ""),
                MethodArgumentNotValidException.class);
        assertThat(controller.invocations).isZero();
    }

    @Test
    void ownerId와_admin은_suppressed되고_명시적으로_거부된다() throws Exception {
        var fixture = formFixture();
        var request = validForm().param("ownerId", "9001").param("admin", "true");
        var result = sameForm(fixture.mvc(), request);
        var binding = bindingResult(result);
        assertThat(binding.getSuppressedFields()).containsExactly("admin", "ownerId");
        assertThat(binding.getErrorCount()).isEqualTo(1);
        assertThat(binding.getGlobalError().getCode()).isEqualTo("binding.disallowed");
        assertThat(fixture.commands()).isEmpty();
    }

    @Test
    void ordinary_unknown_property는_allowlist_suppression과_다르다() throws Exception {
        var controller = new StrictUnknownFieldController();
        var mvc = standaloneSetup(controller).build();
        var request = form("/strict-form", "Spring MVC", "binding")
                .param("nickname", "unknown");
        rejected(mvc, request, NotWritablePropertyException.class);
        assertThat(controller.invocations).isZero();
    }

    private static SearchFixture searchFixture() {
        var calls = new ArrayList<FindPostsQuery>();
        PostSearch search = query -> {
            calls.add(query);
            return PostPage.empty();
        };
        return new SearchFixture(standaloneSetup(new PostSearchController(search)).build(), calls);
    }

    private static FormFixture formFixture() {
        var calls = new ArrayList<CreatePostCommand>();
        PostService service = command -> {
            calls.add(command);
            return new SavedPost(41L);
        };
        return new FormFixture(standaloneSetup(new PostFormPageController(service)).build(), calls);
    }

    private static MockHttpServletRequestBuilder posts() {
        return get("/api/posts").param("query", "HTTP");
    }

    private static MockHttpServletRequestBuilder form(String path, String title, String content) {
        return post(path).contentType(APPLICATION_FORM_URLENCODED)
                .param("title", title).param("content", content);
    }

    private static MockHttpServletRequestBuilder form(String title, String content) {
        return form("/posts", title, content);
    }

    private static MockHttpServletRequestBuilder validForm() {
        return form("Spring MVC", "binding");
    }

    private static void rejectedSearch(Class<?> type, MockHttpServletRequestBuilder... requests)
            throws Exception {
        for (var request : requests) {
            var fixture = searchFixture();
            rejected(fixture.mvc(), request, type);
            assertThat(fixture.queries()).isEmpty();
        }
    }

    private static void rejected(MockMvc mvc, MockHttpServletRequestBuilder request, Class<?> type)
            throws Exception {
        var result = mvc.perform(request).andExpect(status().isBadRequest()).andReturn();
        assertThat(result.getResolvedException()).isExactlyInstanceOf(type);
    }

    private static MvcResult sameForm(MockMvc mvc, MockHttpServletRequestBuilder request)
            throws Exception {
        return mvc.perform(request).andExpect(status().isOk())
                .andExpect(view().name("post-form")).andReturn();
    }

    private static void fieldError(MockHttpServletRequestBuilder request, String field, String code)
            throws Exception {
        var fixture = formFixture();
        var error = bindingResult(sameForm(fixture.mvc(), request)).getFieldError(field);
        assertThat(error).isNotNull();
        assertThat(error.getCode()).isEqualTo(code);
        assertThat(fixture.commands()).isEmpty();
    }

    private static BindingResult bindingResult(MvcResult result) {
        var modelAndView = result.getModelAndView();
        assertThat(modelAndView).isNotNull();
        return (BindingResult) modelAndView.getModel()
                .get(BindingResult.MODEL_KEY_PREFIX + "form");
    }

    private record SearchFixture(MockMvc mvc, List<FindPostsQuery> queries) {}
    private record FormFixture(MockMvc mvc, List<CreatePostCommand> commands) {}

    @Controller
    private static final class NoBindingResultController {
        private int invocations;

        @PostMapping("/no-binding-result")
        String register(@Valid @ModelAttribute("form") CreatePostForm form) {
            invocations++;
            return "unexpected";
        }
    }

    @Controller
    private static final class StrictUnknownFieldController {
        private int invocations;

        @InitBinder("form")
        void configure(WebDataBinder binder) {
            binder.setIgnoreUnknownFields(false);
        }

        @PostMapping("/strict-form")
        String register(@ModelAttribute("form") CreatePostForm form) {
            invocations++;
            return "unexpected";
        }

        @ExceptionHandler(NotWritablePropertyException.class)
        ResponseEntity<Void> rejectUnknownProperty() {
            return ResponseEntity.badRequest().build();
        }
    }
}

열두 @Test가 표의 행과 두 tag 모양을 실행합니다. 빈 필드의 정확한 NotBlank 코드는 Hibernate Validator 실행 증거입니다.


소유권과 독립형 테스트 한계

이 문서는 다음 경계만 소유합니다.

  • @RequestParam의 required, default, 반복 값, 변환, 직접 파라미터 제약
  • @ModelAttribute의 전용 입력 객체, 생성자/속성 바인딩 구분
  • 인접한 BindingResult와 폼 핸들러 진입 여부
  • 속성 바인딩의 allowed/suppressed field와 명시적 애플리케이션 매핑

다음 주제는 주변 문서에 남깁니다.

  • @PathVariable, 매핑 조건, 404·405·406·415는 ch6-2
  • @RequestBody, Jackson, 메시지 컨버터와 본문 검증은 ch6-4
  • 반환값 handler, 정확한 303·Location 정책은 ch6-5
  • 전체 MVC 실패 위치와 진단표는 ch6-8
  • Thymeleaf 폼 표시와 상세 WebDataBinder 구성은 ch7-3
  • BindingResult 메시지 코드와 검증 UX는 ch7-6·ch7-8

standaloneSetup은 고정 컨트롤러와 좁은 MVC 인프라만 증명합니다. Boot 컴포넌트 탐색과 운영 validator·formatter·converter·advice·view wiring, 라이브 Servlet 컨테이너, 프록시·TLS·Security 필터, SQL은 범위 밖입니다. 전자는 Boot MVC context 테스트, 후자는 배포 테스트가 맡습니다.


공식 근거