요청 파라미터 바인딩
RequestParam과 ModelAttribute의 서로 다른 바인딩 경로를 실행하고 기본값·변환·검증·과다 바인딩 경계를 구분합니다.
이 fixture의 쿼리와 URL 인코딩 폼은 Servlet 파라미터 맵으로 들어옵니다. 단일·반복 값은 @RequestParam이 해석·변환하고, 필드 묶음은 @ModelAttribute가 전용 객체에 생성자 또는 속성 바인딩한 뒤 검증합니다. 두 resolver 경로를 하나의 파이프라인으로 일반화하지 않습니다.
요청 파라미터는 입력 모양별 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 타입으로 바뀝니다. |
그림의 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을 한 의존성 그래프로 사용합니다. 모든 코드 블록은 경로가 붙은 완전한 파일입니다.
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를 모릅니다. 서비스에는 검증을 통과한 불변 타입만 넘깁니다.
이 웹 경로의 검증과 레코드 생성자의 책임을 구별합니다. FindPostsQuery의 생성자는 tags를 변경 불가능한 목록으로 보존하지만 page·size의 범위를 검사하지 않습니다. 다른 코드에서 직접 생성하면 페이지가 음수이거나 크기가 101인 쿼리도 만들 수 있습니다. 웹의 @Min·@Max가 모든 호출 경로의 도메인 불변식을 대신하지 않으므로, 웹 밖에서도 같은 규칙이 필수라면 애플리케이션 계층의 책임을 별도로 정합니다.
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이면 변환 뒤 필수 누락이 될 수도 있습니다.
기본값은 정보를 합치는 계약이기도 합니다. 이 page 선언에서는 누락, page=, page=0이 모두 정수 0으로 전달됩니다. 서비스가 받은 FindPostsQuery만으로 사용자가 어느 모양을 보냈는지 되살릴 수 없습니다. 세 입력을 다르게 취급해야 하는 API라면 기본값을 합치기 전의 입력 모양을 별도로 보존하도록 설계해야 합니다.
기본 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()에 기록되므로 컨트롤러가 명시적으로 거부합니다.
@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은 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을 만듭니다.
예시
검색 요청을 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을 반환하므로, 이 fixture는 200으로 폼을 다시 보여 주고 서비스 등록은 0회입니다. 200과 핸들러 진입만 보고 등록에 성공했다고 판단하면 안 됩니다.
열두 계약을 실행으로 증명한다
테스트는 상태와 함께 정확한 예외, 오류 코드, 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 실행 증거입니다.
독립 확인
각 요청은 새 원 fixture에서 독립적으로 시작합니다. 검색은 GET /api/posts, 폼은 POST /posts의 URL 인코딩 폼이며, 별도 advice·Security·추가 formatter는 없습니다. 다음 네 경우의 상태 또는 선언한 뷰 이름, 핸들러 진입 여부, 서비스 호출 횟수와 전달 값 또는 오류를 적으세요.
- 검색의 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을 반환합니다.
3번에서만 BindingResult 인자를 제거한 별도 폼 핸들러를 사용한다면, 날짜 오류가 있을 때 핸들러 진입과 서비스 호출은 어떻게 달라질까요? 과다 입력 admin의 오류를 만드는 코드가 어디에 있는지도 설명하세요.
필수 값·인자 실패·폼 오류를 구분하는 풀이
1번은 200이며 검색 핸들러와 서비스가 각각 1회 실행됩니다. 원 query 선언에는 @NotBlank가 없고 빈 String은 누락과 다르므로, 전달 값은 FindPostsQuery("", 0, 20, List.of(), null)입니다. required는 이 선언에서 내용이 있는 문자열까지 보장하지 않습니다.
2번은 @Min(0) 위반에 따른 400과 HandlerMethodValidationException입니다. 검색 핸들러에 진입하지 않아 서비스는 0회입니다. 숫자 변환 자체는 성공했다는 점에서 page=not-a-number와 실패 이유가 다릅니다.
3번은 날짜의 typeMismatch 필드 오류와, suppressed admin을 핸들러가 바꾼 binding.disallowed 전역 오류가 함께 남습니다. 인접한 BindingResult를 받아 폼 핸들러에는 진입하지만 hasErrors()가 참이므로 200과 post-form, 서비스 0회입니다. 폼 객체를 만들었다는 사실은 날짜 변환·등록의 성공을 뜻하지 않습니다.
4번은 CreatePostCommand("Spring MVC", "binding", LocalDate.of(2026, 8, 28))로 등록을 1회 호출하고 redirect:/posts/41을 선언합니다. 원 테스트처럼 3xx 및 뷰 이름까지만 판단하며, 구체 리다이렉트 상태·Location 정책은 다음 반환값 문서에서 확인합니다.
3번의 별도 핸들러에 인접한 BindingResult가 없으면, 날짜 바인딩 오류로 MethodArgumentNotValidException과 400이 되어 메서드·서비스 호출은 0회입니다. 원 메서드 안의 suppressed 명시 거부도 실행되지 않습니다. 이 변형에는 다른 인자의 직접 제약이나 별도 예외 처리 정책을 추가하지 않습니다. 위 조합은 원 코드와 테스트 계약을 추적한 예상이며, 새로운 MVC 실행 결과와 구분합니다.
소유권과 독립형 테스트 한계
이 문서는 다음 경계만 소유합니다.
@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 테스트, 후자는 배포 테스트가 맡습니다.
공식 근거
- Spring MVC
@RequestParam - Spring MVC
@ModelAttribute - MVC data binding,
@InitBinder - MVC validation
- Core data binding
- Boot validation
기본값·불변식·문자열 길이를 추적하는 연습
별도 formatter·advice·Security를 추가하지 않고 원 코드와 BOM의 기본 문자열 검증을 사용합니다.
- 웹 요청 없이 변경 가능한
ArrayList에network만 넣고new FindPostsQuery("HTTP", -1, 101, tags, null)을 직접 생성합니다. 그 뒤 원 목록에spring을 추가합니다. 생성이 범위 오류로 실패하나요? 쿼리의 tags는 무엇이며 그 목록에 직접 값을 추가할 수 있나요? - 각각 새 원 폼 fixture에 title=
Spring MVC, publishedOn 생략, 과다 필드 없음으로 보냅니다. content는 첫 요청에😀360개, 둘째에 361개입니다. UTF-16 길이·Unicode 코드 포인트 수·UTF-8 바이트 수를 계산하고 원@NotBlank·@Size조건 아래 선언한 뷰와 서비스 호출 횟수를 구하세요. - 각각 새 검색 fixture에 query=
HTTP를 보내고 size·tag·from은 생략합니다. page만 첫 요청은 생략, 둘째는 빈 값, 셋째는 문자열0입니다. 전달할 세 쿼리는 다른가요? 서비스가 쿼리만 받아 원 입력 모양에 따라 서로 다른 요금을 매길 수 있나요?
정답과 보장 범위
- 생성자는 page·size를 검사하지 않아 해당 값으로 생성됩니다. tags는
[network]를 유지하며 원 목록의 후속 변경이 반영되지 않습니다. 쿼리의 목록에 추가하면UnsupportedOperationException입니다. 목록 변경 방지와 숫자 범위 검증은 별개입니다. 원 웹 요청에서 -1/101을 보냈다면 직접 메서드 제약이 핸들러 전에 막는다는 기존 계약과 구별합니다. - 첫 문자열은 UTF-16 길이 720, 코드 포인트 360개, UTF-8 1440바이트이고 둘째는 722·361·1444입니다. 둘 다 비어 있지 않지만 첫 요청만 길이 상한을 통과하므로 서비스 1회와
redirect:/posts/41을 선언합니다. 둘째는Size필드 오류가 인접한BindingResult에 담기고, 서비스 0회와post-form입니다. 바이트 수는 인코딩 계산이며 HTTP 전송량·DB 저장 한도를 검증한 값은 아닙니다. - 모두
FindPostsQuery("HTTP", 0, 20, List.of(), null)이고 검색 서비스는 요청마다 1회입니다. 이 쿼리만으로 세 원 입력을 구별할 수 없으므로, 구별에 따라 요금이 달라지는 계약은 현재 표현만으로 구현할 수 없습니다.
문자열 길이·목록 복사·직접 생성의 JDK 확인과 MVC/Bean Validation의 실제 통합 실행을 구분합니다. 여기의 폼·검색 결과는 원 코드와 공식 계약을 적용한 예상입니다.