요청 파라미터 바인딩
RequestParam과 ModelAttribute의 서로 다른 바인딩 경로를 실행하고 기본값·변환·검증·과다 바인딩 경계를 구분합니다.
이 fixture의 쿼리와 URL 인코딩 폼은 Servlet 파라미터 맵으로 들어옵니다. 단일·반복 값은 @RequestParam이 해석·변환하고, 필드 묶음은 @ModelAttribute가 전용 객체에 생성자 또는 속성 바인딩한 뒤 검증합니다. 두 resolver 경로를 하나의 파이프라인으로 일반화하지 않습니다.
FLOWCHART · RESOLVER-SPECIFIC PIPELINES · SERVICE GATE
요청 파라미터는 입력 모양별 resolver와 검증 경계를 거쳐 애플리케이션 타입이 된다
단순 값과 입력 객체는 같은 파이프라인이 아니다. 각 resolver의 변환·바인딩·검증 실패는 service 호출 전에 멈추고, 통과한 값만 명시적으로 query 또는 command로 매핑됩니다.
-
RAW INPUT
Servlet parameter map에서 시작한다
HTTP 요청의 이름-값 입력을 아직 domain 타입으로 취급하지 않습니다.
-
INPUT SHAPE
입력 모양으로 resolver를 고른다
@RequestParam: 단순 값 또는 반복 값@ModelAttribute: 전용 입력 객체
-
REQUESTPARAM BRANCH
required·default·repeated 뒤 변환과 직접 제약을 검사한다
분기 결과: conversion 또는 직접
@Min·@Max검증 실패는 pre-handler 400이다. -
MODELATTRIBUTE BRANCH
전용 객체 생성 뒤 property binding과 객체 검증을 분리한다
allowedFields와 suppressed guard는 property binding에만 적용하고, 객체 검증 결과는 바로 뒤의BindingResult에서 읽습니다.분기 결과: suppressed field는 명시적으로 거부하고, binding·validation 오류는 same-form render로 돌아갑니다.
-
MERGE
통과한 두 경로만 명시적 mapper에서 합류한다
web input을 애플리케이션 query 또는 command로 변환합니다.
-
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는 경로가 붙은 완전한 파일입니다.
rootProject.name = 'request-parameter-binding'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 이름은 직접 적습니다.
애플리케이션 타입을 완결한다
웹 입력은 바로 엔티티가 되지 않습니다. 검색은 불변 쿼리로, 등록 폼은 명령으로 명시적으로 매핑됩니다.
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);
}
}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());
}
}package board.post;
@FunctionalInterface
public interface PostSearch {
PostPage find(FindPostsQuery query);
}package board.post;
import java.time.LocalDate;
public record CreatePostCommand(String title, String content, LocalDate publishedOn) {}package board.post;
public record SavedPost(long id) {}package board.post;
public interface PostService {
SavedPost register(CreatePostCommand command);
}FindPostsQuery와 CreatePostCommand는 Spring MVC를 모릅니다. 서비스에는 검증을 통과한 불변 타입만 넘깁니다.
RequestParam은 이름 있는 값을 해석한다
query는 필수이고 page·size는 누락·빈 값에 기본값을 씁니다. tag는 반복 키와 쉼표 값을 같은 List<String>으로, 날짜는 ISO로 받습니다.
범위는 IllegalArgumentException이 아니라 파라미터 @Min·@Max로 선언합니다. Spring MVC built-in method validation을 쓰며 컨트롤러 클래스에는 @Validated를 붙이지 않습니다.
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는 전용 폼을 바인딩한다
등록 폼은 세 필드만 노출하고 불변 명령으로 변환하는 웹 입력 어댑터입니다.
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()에 기록되므로 컨트롤러가 명시적으로 거부합니다.
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 값 | @RequestParam | required/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 오류와 인접한 BindingResult | 200, 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, 매핑 값, 핸들러·서비스 호출 횟수를 고정합니다.
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 테스트, 후자는 배포 테스트가 맡습니다.