요청 파라미터 바인딩
RequestParam과 ModelAttribute의 서로 다른 바인딩 경로를 실행하고 기본값·변환·검증·과다 바인딩 경계를 구분합니다.
이 예제의 쿼리와 URL 인코딩 폼은 서블릿 파라미터 맵으로 들어옵니다. 이름이 있는 단일 값과 반복 값은 @RequestParam이 해석하고 변환하며, 여러 필드의 묶음은 @ModelAttribute가 전용 객체에 생성자나 속성으로 바인딩한 뒤 검증합니다. 두 리졸버의 경로를 하나의 파이프라인으로 뭉뚱그리지 않습니다.
요청 파라미터는 입력 모양별 resolver와 검증 경계를 거쳐 애플리케이션 타입이 된다
Servlet 파라미터·required/default·변환/제약·FindPostsQuery·service의 네 값 경계이다.
FLOWCHART · RESOLVER-SPECIFIC PIPELINES · SERVICE GATE
요청 파라미터는 입력 모양별 resolver와 검증 경계를 거쳐 애플리케이션 타입이 된다
메서드의 애노테이션·타입에 따라 이름 있는 값과 전용 객체를 다른 경로로 처리합니다. 폼 오류에서는 handler가 실행될 수 있지만 service를 건너뜁니다. 통과한 입력만 애플리케이션 타입으로 매핑합니다.
두 그림은 서로 다른 resolver 경로입니다. 오른쪽 우회선은 준비 완료 또는 오류 없음의 선택이며, 실패 노드를 지난 뒤 성공하는 순서가 아닙니다. 폼 그림은 원 속성 바인딩과 인접한 BindingResult 조건에 한정됩니다.
REQUESTPARAM · 개별 값
이름 규칙 → 타입 변환 → 직접 제약
Servlet parameter map의 값을 메서드 애노테이션·타입에 맞게 해석합니다. 필수 query, 누락·빈 page/size의 0/20 기본값, 반복·쉼표 tag, ISO 날짜를 처리하고 @Min/@Max를 검사합니다.
MODELATTRIBUTE · 속성 바인딩
전용 폼 → allowedFields → @Valid
이 fixture는 변경 가능한 CreatePostForm의 title/content/publishedOn만 속성 바인딩합니다. 허용 목록 밖 필드는 suppressed로 기록되고 자동 오류가 되지 않습니다. 객체 검증 뒤 인접한 BindingResult와 함께 폼 handler에 들어옵니다.
REQUESTPARAM · 실패 경계
누락·변환·직접 제약 실패는 handler 전 400
필수 query 누락, 숫자·날짜 변환 실패, page/size 범위 위반은 각각 다른 예외를 만들며 검색 service 호출은 0입니다. defaultValue가 있는 누락·빈 값은 동일 기본값으로 합쳐질 수 있습니다.
MODELATTRIBUTE · handler의 오류 분기
폼 handler는 실행되어도 service는 0일 수 있다
handler가 suppressed 필드를 명시적으로 binding.disallowed 오류로 바꾼 뒤 hasErrors()를 검사합니다. binding·validation 오류이면 200 post-form을 선택하고 service를 건너뜁니다. 인접한 BindingResult가 없으면 이 fixture는 handler 전 400입니다.
EXPLICIT APPLICATION MAPPING
통과한 입력만 query 또는 command로 전달
검색 값은 FindPostsQuery, 유효한 폼은 CreatePostCommand로 명시적으로 매핑해 service를 호출합니다. JSON 본문·정확한 redirect 상태는 주변 문서의 별도 경계입니다.
CONSTRUCTOR / STRICT UNKNOWN
속성 allowlist와 생성자 입력 표면을 구분한다
allowedFields는 생성자 바인딩에 적용되지 않습니다. 별도 strict binder의 일반 unknown 속성은 suppressed와 다르며, 이 fixture의 명시 advice가 NotWritablePropertyException을 400으로 변환합니다.
allowedFields는 @ModelAttribute의 property
binding에만 적용됩니다. constructor binding은 constructor
parameter와 전용 입력 타입으로 별도 제약해야 하며,
@RequestParam의 변환·직접 메서드 검증 경로와 동일한
처리로 보아서는 안 됩니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 원 검색 컨트롤러의 네 노드와 세 관계이다. Servlet 파라미터를 required와 기본값 규칙으로 해석하고 타입 변환과 직접 파라미터 제약을 확인한다. 준비에 실패하면 이 fixture의 400이며 검색 핸들러와 서비스는 0이다. 준비되면 검색 핸들러가 FindPostsQuery를 만들고 서비스를 호출한다. 폼 BindingResult 경로와 합친 단일 처리 순서가 아니다.
- 원 변경 가능한 폼과 인접한 BindingResult를 쓰는 다섯 노드·네 관계이다. 허용한 속성의 바인딩과 객체 검증 결과를 받아 폼 핸들러에 진입한다. 핸들러가 suppressed를 명시 오류로 바꾸고 hasErrors를 확인하여 오류면 200 post-form과 서비스 0, 없으면 CreatePostCommand와 서비스 1 및 redirect 선언을 선택한다. 생성자 바인딩과 BindingResult 없는 별도 핸들러는 이 흐름의 조건 밖이다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 22 | 원 변경 가능한 폼과 인접한 BindingResult를 쓰는 다섯 노드·네 관계이다. 허용한 속성의 바인딩과 객체 검증 결과를 받아 폼 핸들러에 진입한다. 핸들러가 suppressed를 명시 오류로 바꾸고 hasErrors를 확인하여 오류면 200 post-form과 서비스 0, 없으면 CreatePostCommand와 서비스 1 및 redirect 선언을 선택한다. 생성자 바인딩과 BindingResult 없는 별도 핸들러는 이 흐름의 조건 밖이다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| required·default 적용 Servlet 파라미터에 required와 기본값을 적용합니다. | 입력: Servlet 파라미터 · 규칙: required·default | 원 입력이 해석된 파라미터가 됩니다. |
| 변환·직접 제약 타입 변환과 직접 파라미터 제약을 확인합니다. | 실패: 400 · handler·service: 0 | 준비 실패가 검색 실행 전에 끝납니다. |
| query와 service handler가 FindPostsQuery를 만들어 service에 전달합니다. | 산출물: FindPostsQuery · 실행: 검색 service | 해석한 입력이 application 타입으로 바뀝니다. |
요청 파라미터는 입력 모양별 resolver와 검증 경계를 거쳐 애플리케이션 타입이 된다
Servlet 파라미터·required/default·변환/제약·FindPostsQuery·service의 네 값 경계이다.
FLOWCHART · RESOLVER-SPECIFIC PIPELINES · SERVICE GATE
요청 파라미터는 입력 모양별 resolver와 검증 경계를 거쳐 애플리케이션 타입이 된다
메서드의 애노테이션·타입에 따라 이름 있는 값과 전용 객체를 다른 경로로 처리합니다. 폼 오류에서는 handler가 실행될 수 있지만 service를 건너뜁니다. 통과한 입력만 애플리케이션 타입으로 매핑합니다.
두 그림은 서로 다른 resolver 경로입니다. 오른쪽 우회선은 준비 완료 또는 오류 없음의 선택이며, 실패 노드를 지난 뒤 성공하는 순서가 아닙니다. 폼 그림은 원 속성 바인딩과 인접한 BindingResult 조건에 한정됩니다.
REQUESTPARAM · 개별 값
이름 규칙 → 타입 변환 → 직접 제약
Servlet parameter map의 값을 메서드 애노테이션·타입에 맞게 해석합니다. 필수 query, 누락·빈 page/size의 0/20 기본값, 반복·쉼표 tag, ISO 날짜를 처리하고 @Min/@Max를 검사합니다.
MODELATTRIBUTE · 속성 바인딩
전용 폼 → allowedFields → @Valid
이 fixture는 변경 가능한 CreatePostForm의 title/content/publishedOn만 속성 바인딩합니다. 허용 목록 밖 필드는 suppressed로 기록되고 자동 오류가 되지 않습니다. 객체 검증 뒤 인접한 BindingResult와 함께 폼 handler에 들어옵니다.
REQUESTPARAM · 실패 경계
누락·변환·직접 제약 실패는 handler 전 400
필수 query 누락, 숫자·날짜 변환 실패, page/size 범위 위반은 각각 다른 예외를 만들며 검색 service 호출은 0입니다. defaultValue가 있는 누락·빈 값은 동일 기본값으로 합쳐질 수 있습니다.
MODELATTRIBUTE · handler의 오류 분기
폼 handler는 실행되어도 service는 0일 수 있다
handler가 suppressed 필드를 명시적으로 binding.disallowed 오류로 바꾼 뒤 hasErrors()를 검사합니다. binding·validation 오류이면 200 post-form을 선택하고 service를 건너뜁니다. 인접한 BindingResult가 없으면 이 fixture는 handler 전 400입니다.
EXPLICIT APPLICATION MAPPING
통과한 입력만 query 또는 command로 전달
검색 값은 FindPostsQuery, 유효한 폼은 CreatePostCommand로 명시적으로 매핑해 service를 호출합니다. JSON 본문·정확한 redirect 상태는 주변 문서의 별도 경계입니다.
CONSTRUCTOR / STRICT UNKNOWN
속성 allowlist와 생성자 입력 표면을 구분한다
allowedFields는 생성자 바인딩에 적용되지 않습니다. 별도 strict binder의 일반 unknown 속성은 suppressed와 다르며, 이 fixture의 명시 advice가 NotWritablePropertyException을 400으로 변환합니다.
allowedFields는 @ModelAttribute의 property
binding에만 적용됩니다. constructor binding은 constructor
parameter와 전용 입력 타입으로 별도 제약해야 하며,
@RequestParam의 변환·직접 메서드 검증 경로와 동일한
처리로 보아서는 안 됩니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 원 검색 컨트롤러의 네 노드와 세 관계이다. Servlet 파라미터를 required와 기본값 규칙으로 해석하고 타입 변환과 직접 파라미터 제약을 확인한다. 준비에 실패하면 이 fixture의 400이며 검색 핸들러와 서비스는 0이다. 준비되면 검색 핸들러가 FindPostsQuery를 만들고 서비스를 호출한다. 폼 BindingResult 경로와 합친 단일 처리 순서가 아니다.
- 원 변경 가능한 폼과 인접한 BindingResult를 쓰는 다섯 노드·네 관계이다. 허용한 속성의 바인딩과 객체 검증 결과를 받아 폼 핸들러에 진입한다. 핸들러가 suppressed를 명시 오류로 바꾸고 hasErrors를 확인하여 오류면 200 post-form과 서비스 0, 없으면 CreatePostCommand와 서비스 1 및 redirect 선언을 선택한다. 생성자 바인딩과 BindingResult 없는 별도 핸들러는 이 흐름의 조건 밖이다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 22 | 원 변경 가능한 폼과 인접한 BindingResult를 쓰는 다섯 노드·네 관계이다. 허용한 속성의 바인딩과 객체 검증 결과를 받아 폼 핸들러에 진입한다. 핸들러가 suppressed를 명시 오류로 바꾸고 hasErrors를 확인하여 오류면 200 post-form과 서비스 0, 없으면 CreatePostCommand와 서비스 1 및 redirect 선언을 선택한다. 생성자 바인딩과 BindingResult 없는 별도 핸들러는 이 흐름의 조건 밖이다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| required·default 적용 Servlet 파라미터에 required와 기본값을 적용합니다. | 입력: Servlet 파라미터 · 규칙: required·default | 원 입력이 해석된 파라미터가 됩니다. |
| 변환·직접 제약 타입 변환과 직접 파라미터 제약을 확인합니다. | 실패: 400 · handler·service: 0 | 준비 실패가 검색 실행 전에 끝납니다. |
| query와 service handler가 FindPostsQuery를 만들어 service에 전달합니다. | 산출물: FindPostsQuery · 실행: 검색 service | 해석한 입력이 application 타입으로 바뀝니다. |
그림의 위쪽은 검색 파라미터가 required와 기본값 규칙, 타입 변환, 직접 붙인 @Min·@Max 제약을 거치는 경로입니다. 준비에 실패하면 이 예제에서는 400이고 검색 핸들러와 서비스가 호출되지 않으며, 준비되면 핸들러가 FindPostsQuery를 만들어 서비스를 부릅니다. 아래쪽은 폼이 허용된 속성으로 바인딩되고 검증된 뒤 인접한 BindingResult와 함께 폼 핸들러에 들어오는 경로입니다. 핸들러는 허용 목록 밖의 필드를 명시적인 오류로 바꾸고 hasErrors()를 확인해, 오류가 있으면 200과 post-form을, 없으면 CreatePostCommand로 서비스를 한 번 부릅니다. 두 그림은 서로 다른 리졸버의 경로이며, 400과 폼 재렌더링은 아래 Spring Framework 7.0.10 예제에 한정됩니다.
실행 기준을 정합니다
예제는 Java 25와 Spring Boot 4.1.2 BOM, Spring Framework 7.0.10, JUnit 6.0.3을 하나의 의존성 그래프로 씁니다. 모든 코드 블록은 경로가 붙은 완전한 파일입니다.
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.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() }웹 MVC 스타터만으로는 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를 모르며, 서비스에는 검증을 통과한 불변 타입만 넘깁니다.
웹 경로의 검증과 레코드 생성자가 하는 일은 다릅니다. FindPostsQuery의 생성자는 tags를 변경할 수 없는 목록으로 보존하지만 page와 size의 범위는 검사하지 않습니다. 다른 코드에서 직접 생성하면 페이지가 음수이거나 크기가 101인 쿼리도 만들 수 있습니다. 웹의 @Min과 @Max가 모든 호출 경로에서 도메인 규칙을 대신하지는 않으므로, 웹 밖에서도 같은 규칙이 필요하다면 애플리케이션 계층에서 따로 검사합니다.
RequestParam은 이름이 있는 값을 해석합니다
query는 필수이고 page와 size는 누락되거나 빈 값이면 기본값을 씁니다. tag는 반복된 키와 쉼표로 이은 값을 같은 List<String>으로 받고, 날짜는 ISO 형식으로 받습니다. 범위는 IllegalArgumentException을 던지는 대신 파라미터의 @Min과 @Max로 선언합니다. Spring MVC에 내장된 메서드 검증(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이면 변환 뒤에 필수 값 누락이 될 수도 있습니다.
기본값은 서로 다른 입력을 하나로 합치기도 합니다. 이 page 선언에서는 누락과 page=, page=0이 모두 정수 0으로 전달되므로, 서비스가 받은 FindPostsQuery만으로는 사용자가 어떤 형태로 보냈는지 되살릴 수 없습니다. 세 입력을 다르게 다뤄야 하는 API라면, 기본값으로 합치기 전의 입력 형태를 따로 보존하도록 설계해야 합니다.
기본 변환 서비스(conversion service)는 반복된 키와 tag=network,spring을 모두 컬렉션으로 바꾸며, 이 예제는 두 형태를 모두 허용합니다.
ModelAttribute는 전용 폼을 바인딩합니다
등록 폼은 세 필드만 드러내고 불변 명령으로 변환하는 웹 입력 어댑터입니다. 이 폼은 레코드가 아니라 기본 생성자와 setter를 가진 변경 가능한 클래스이므로, 바인더는 객체를 먼저 만든 뒤 요청 파라미터 이름에 맞는 setter를 하나씩 호출합니다. 이것이 아래 표의 속성 바인딩이며, 생성자로 값을 받는 레코드와 달리 허용 목록이 적용되는 대상입니다.
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()에 기록되므로, 컨트롤러가 명시적으로 거부합니다.
@Size(max = 720)의 단위도 입력 규칙의 일부입니다. 이 BOM의 Hibernate Validator는 문자열에 String.length()를 적용하므로 720은 UTF-16 코드 단위의 수이며, 화면에 보이는 글자 수나 UTF-8 바이트 수와 같지 않습니다. 예를 들어 U+1F600 😀 한 개는 UTF-16 단위로 2개이므로, 360개는 길이 720이고 361개는 722입니다. 사용자에게 보이는 글자 수 기준이 필요하면 그 단위를 따로 정해야 합니다.
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은 뷰·리다이렉트·응답 본문에서 다룹니다.
allowedFields는 생성자 바인딩에는 적용되지 않습니다. 생성자 파라미터가 곧 불변 입력이 받는 값의 목록이고, 허용 목록과 suppressed 검사는 변경 가능한 속성 바인딩만 지킵니다.
ExtendedServletRequestDataBinder는 파라미터 말고도 URI 변수와 헤더를 바인딩 후보로 삼습니다. 이 예제는 쿼리와 폼만 다루므로 setHeaderPredicate(name -> false)로 헤더만 뺐으며, 이것이 일반적인 기본값이라는 뜻은 아닙니다.
도구 선택과 실패 결과
@PathVariable은 앞 글 RequestMapping 조건에서 다뤘습니다. 입력의 형태에 따라 쓰는 도구는 다음과 같습니다.
| 입력 형태 | 도구 | 이 글에서 볼 점 |
|---|---|---|
| 이름이 있는 단일 값, 반복되는 쿼리 값 | @RequestParam | required와 기본값을 해석한 뒤 값마다 타입 변환 |
| 불변 입력 객체 | @ModelAttribute 생성자 바인딩 | 생성자 파라미터가 받는 값의 목록이며, 속성 허용 목록의 대상이 아님 |
| 변경 가능한 URL 인코딩 폼 | @ModelAttribute 속성 바인딩 | setter별 변환, 허용·suppressed 필드, 객체 검증 |
같은 형태의 입력도 설정에 따라 실패하는 지점과 핸들러의 실행 여부가 달라집니다.
| 조건 | 이 예제의 결과 | 폼·검색 핸들러 | 서비스 |
|---|---|---|---|
필수 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회 |
| 허용 목록 없는 엄격한 바인더의 일반적인 알 수 없는 속성 | 컨트롤러에 명시한 @ExceptionHandler가 400으로 변환 | 실행 안 함 | 0회 |
마지막 두 행은 서로 다릅니다. 허용 목록 밖의 값은 suppressed 필드가 되고, 허용 목록 없이 ignoreUnknownFields=false인 바인더에서 알 수 없는 속성은 NotWritablePropertyException이 됩니다. 이 예제는 컨트롤러에 명시한 @ExceptionHandler로만 400을 만듭니다.
예를 들어 검색 요청을 query=HTTP&page=&size=&tag=network,spring으로 보내면, 빈 page와 size가 기본값 0과 20으로 바뀝니다. tag는 두 문자열의 목록으로 변환되고, 생략한 from은 null입니다. 따라서 검색 서비스에 전달되는 값은 FindPostsQuery("HTTP", 0, 20, List.of("network", "spring"), null)입니다. URL에 적힌 문자열을 서비스가 그대로 받는 것이 아니라, 기본값과 변환, 직접 붙인 제약을 거친 값으로 쿼리를 만듭니다.
반면 title이 Spring MVC, content가 binding인 등록 폼에 ownerId=9001을 더하면, 허용 목록 밖의 ownerId는 폼 속성에 쓰이지 않고 suppressed에 남습니다. 그것만으로 검증 오류가 저절로 생기지는 않습니다. 폼 핸들러가 목록을 읽어 binding.disallowed를 직접 더한 뒤 hasErrors()에서 post-form을 돌려주므로, 이 예제는 200으로 폼을 다시 보여 주고 서비스 등록은 0회입니다. 200과 핸들러 진입만 보고 등록에 성공했다고 판단하면 안 됩니다.
열두 가지 경우를 실행으로 확인합니다
테스트는 상태와 함께 정확한 예외와 오류 코드, suppressed 필드, 매핑된 값, 핸들러와 서비스의 호출 횟수를 고정합니다. 테스트들은 standaloneSetup으로 검사할 컨트롤러만 등록한 MockMvc를 쓰고, 서비스 자리에는 호출을 기록하는 대역을 넣습니다. 그래서 둘 다 200으로 끝나는 정상 등록과 폼 재표시도 서비스 호출 횟수로 구별할 수 있습니다.
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와 기본값, 반복 값, 변환, 파라미터에 직접 붙인 제약@ModelAttribute의 전용 입력 객체, 생성자 바인딩과 속성 바인딩의 구분- 인접한
BindingResult와 폼 핸들러의 진입 여부 - 속성 바인딩의 허용·suppressed 필드와 명시적인 애플리케이션 매핑
다음 주제는 다른 글에서 다룹니다.
@PathVariable과 매핑 조건, 404·405·406·415는 RequestMapping 조건@RequestBody와 Jackson, 메시지 컨버터, 본문 검증은 요청 본문·JSON 변환- 반환 값 처리기와 정확한 303·
Location정책은 뷰·리다이렉트·응답 본문 - MVC 전체의 실패 위치와 진단표는 MVC 실패 경계 진단
- Thymeleaf 폼 표시와 자세한
WebDataBinder구성은 Spring 폼 바인딩 BindingResult의 메시지 코드와 검증 화면은 바인딩 오류 처리와 Bean 검증과 DTO
standaloneSetup은 고정된 컨트롤러와 좁은 MVC 구성 요소만 확인합니다. Boot의 컴포넌트 탐색과 운영 환경의 검증기·포매터·컨버터·어드바이스·뷰 구성, 실제 서블릿 컨테이너, 프록시와 TLS, Security 필터, SQL은 범위 밖입니다. 앞의 것들은 Boot MVC 컨텍스트 테스트가, 뒤의 것들은 배포 테스트가 맡습니다.
이 글의 내용은 Spring MVC의 @RequestParam과 @ModelAttribute, MVC 데이터 바인딩과 @InitBinder, MVC 검증, Core 데이터 바인딩, Spring Boot 검증을 기준으로 합니다.
정리
@RequestParam은 이름이 있는 값을 required와 기본값, 변환, 직접 제약 순서로 처리하고, 실패하면 핸들러 전에 400이 됩니다.defaultValue는 누락과 빈 값, 같은 값의 입력을 하나로 합치므로, 서비스는 사용자가 보낸 원래 형태를 알 수 없습니다.@ModelAttribute는 전용 폼에 바인딩하고, 인접한BindingResult가 있으면 오류가 있어도 핸들러에 들어오므로 핸들러가hasErrors()로 서비스를 건너뜁니다.setAllowedFields는 속성 바인딩에만 적용되고 허용 목록 밖의 값은 suppressed로만 기록되므로, 컨트롤러가 명시적으로 거부해야 합니다.@Size는 UTF-16 코드 단위로 세며, 레코드 생성자는 웹 제약을 대신하지 않습니다.
연습 문제
1. 네 요청의 결과
각 요청은 새 예제에서 따로 시작합니다. 검색은 GET /api/posts, 폼은 POST /posts의 URL 인코딩 폼이며, 별도의 어드바이스나 Security, 추가 포매터는 없습니다. ① 검색의 query는 빈 문자열이고 page, size, tag, from은 생략합니다. ② 검색의 query는 HTTP, page는 -1이며 나머지는 생략합니다. ③ 폼의 title은 Spring MVC, content는 binding, publishedOn은 not-a-date, admin은 true이고, 폼 핸들러에는 인접한 BindingResult가 있습니다. ④ 같은 올바른 title과 content에 publishedOn=2026-08-28만 보내며, 서비스는 게시글 ID 41을 돌려줍니다. 각 경우의 상태나 선언한 뷰 이름, 핸들러 진입 여부, 서비스 호출 횟수와 전달 값 또는 오류를 적으세요. ③에서 BindingResult 인자를 뺀 별도 폼 핸들러를 쓴다면 무엇이 달라지는지, 과다 입력인 admin의 오류를 만드는 코드가 어디에 있는지도 설명하세요.
해설 보기
① 200이며 검색 핸들러와 서비스가 각각 1회 실행됩니다. query 선언에는 @NotBlank가 없고 빈 String은 누락과 다르므로, 전달 값은 FindPostsQuery("", 0, 20, List.of(), null)입니다. 이 선언의 required는 내용이 있는 문자열까지 보장하지 않습니다.
② @Min(0) 위반으로 400과 HandlerMethodValidationException이 납니다. 검색 핸들러에 들어가지 않으므로 서비스는 0회입니다. 숫자 변환 자체는 성공했다는 점에서 page=not-a-number와 실패 이유가 다릅니다.
③ 날짜의 typeMismatch 필드 오류와, 핸들러가 suppressed된 admin을 바꾼 binding.disallowed 전역 오류가 함께 남습니다. 인접한 BindingResult가 있어 폼 핸들러에는 들어가지만 hasErrors()가 참이므로 200과 post-form, 서비스 0회입니다. 폼 객체가 만들어졌다고 날짜 변환이나 등록이 성공한 것은 아닙니다.
④ CreatePostCommand("Spring MVC", "binding", LocalDate.of(2026, 8, 28))로 등록을 1회 호출하고 redirect:/posts/41을 선언합니다. 테스트처럼 3xx와 뷰 이름까지만 판단하며, 구체적인 리다이렉트 상태와 Location은 뒤의 반환 값 글에서 확인합니다.
③의 별도 핸들러에 인접한 BindingResult가 없으면, 날짜 바인딩 오류로 MethodArgumentNotValidException과 400이 되어 메서드와 서비스는 호출되지 않습니다. admin을 거부하는 코드는 폼 핸들러 메서드 안에 있으므로 이 경우에는 실행되지도 않습니다. 이 풀이는 코드와 테스트를 따라간 예상이므로 테스트를 실행해 확인합니다.
2. 기본값과 불변 객체, 문자열 길이
별도의 포매터나 어드바이스, Security 없이 예제 코드와 BOM의 기본 문자열 검증을 씁니다. ① 웹 요청 없이 변경할 수 있는 ArrayList에 network만 넣고 new FindPostsQuery("HTTP", -1, 101, tags, null)을 직접 만든 뒤, 원래 목록에 spring을 더합니다. 생성이 범위 오류로 실패하나요? 쿼리의 tags는 무엇이며 그 목록에 직접 값을 더할 수 있나요? ② 각각 새 폼 예제에 title=Spring MVC를 보내고 publishedOn과 과다 필드는 생략합니다. content는 첫 요청에 😀 360개, 둘째에 361개입니다. UTF-16 길이와 유니코드 코드 포인트 수, UTF-8 바이트 수를 계산하고, 선언된 뷰와 서비스 호출 횟수를 구하세요. ③ 각각 새 검색 예제에 query=HTTP를 보내고 size, tag, from은 생략합니다. page만 첫 요청은 생략, 둘째는 빈 값, 셋째는 문자열 0입니다. 전달되는 세 쿼리는 다른가요? 서비스가 쿼리만 받아 입력 형태에 따라 다른 요금을 매길 수 있나요?
해설 보기
① 생성자는 page와 size를 검사하지 않으므로 그 값대로 만들어집니다. tags는 [network]를 유지하며 원래 목록을 나중에 바꿔도 반영되지 않고, 쿼리의 목록에 값을 더하면 UnsupportedOperationException이 납니다. 목록의 변경 방지와 숫자 범위 검증은 별개입니다. 웹 요청으로 -1과 101을 보냈다면 메서드에 직접 붙인 제약이 핸들러 전에 막는다는 점과 구분합니다.
② 첫 문자열은 UTF-16 길이 720, 코드 포인트 360개, UTF-8 1,440바이트이고, 둘째는 722, 361, 1,444입니다. 둘 다 비어 있지 않지만 첫 요청만 길이 상한을 통과하므로 서비스를 1회 부르고 redirect:/posts/41을 선언합니다. 둘째는 Size 필드 오류가 인접한 BindingResult에 담겨 서비스 0회와 post-form입니다. 바이트 수는 인코딩 계산일 뿐, HTTP 전송량이나 DB의 저장 한도를 확인한 값은 아닙니다.
③ 세 요청 모두 FindPostsQuery("HTTP", 0, 20, List.of(), null)이고 검색 서비스는 요청마다 1회 호출됩니다. 이 쿼리만으로는 세 입력을 구별할 수 없으므로, 입력 형태에 따라 요금이 달라지는 규칙은 지금의 표현으로 구현할 수 없습니다. 이 결과들은 코드와 공식 문서의 규칙을 적용한 예상이므로, MVC와 Bean Validation을 실제로 거치는 테스트로 확인합니다.
다음 글 요청 본문·JSON 변환에서는 파라미터 대신 요청 본문을 @RequestBody로 읽을 때, Jackson과 메시지 컨버터, 본문 검증이 어떻게 이어지는지 다룹니다.