Spring 폼 바인딩
허용 필드·변환·검증·PRG 경계를 실제 Spring Boot 폼과 MockMvc 최종 HTML로 검증합니다.
HTML 폼은 이름이 붙은 문자열 묶음을 전송합니다. Spring MVC는 그 문자열을 객체 속성에 바인딩하지만, 편리한 변환이 안전한 변경 권한까지 뜻하지는 않습니다.
게시글 등록에서는 title, content, publishedOn만 폼 객체에 열고 회원 식별자와 승인 상태는 서버가 결정해야 합니다.
FLOWCHART · FORM BINDING · PRG
허용·변환·검증을 모두 통과한 폼만 도메인 명령이 된다
WebDataBinder가 허용 필드와 타입 변환을 맡고, 억제·바인딩 오류와
실제 @Valid 검증 오류는 서비스를 호출하지 않은 채 접근
가능한 같은 폼으로 돌아간다.
-
POST → WEB DATA BINDER
allowedFields + 타입 변환
제목·본문·작성일만 열고 추가 필드는 억제합니다. 날짜 변환 실패의 원문은
BindingResult의 거부 값으로 보존합니다. -
STEP 2 · VALIDATION
바인딩 뒤 실제 검증기를 실행한다
@Valid가 커스텀 검증기를 호출합니다. 변환 실패가 든 필드는 기존typeMismatch를 보존하고 관계 검증을 건너뜁니다. -
HANDLER · SINGLE ERROR GATE
억제 필드를 전역 오류로 승격한 뒤 한 번 판단한다
핸들러는 억제 필드를 기존
BindingResult에 더하고hasErrors()를 한 번 호출합니다. 오류가 있으면 서비스를 호출하지 않고 조건부 ARIA가 있는 같은 폼을 렌더링합니다. -
SUCCESS · PRG
CreatePostCommand → service → redirect
모든 경계를 통과한 값만 명령이 됩니다. 서비스가 반환한
id를 넣어/formbinding/posts/{id}로 리다이렉트하고, 후속 GET이 flash 알림과 상세 HTML을 렌더링합니다.
바인딩과 검증은 핸들러 전에 끝나고, 핸들러는 억제 필드 오류를 더한 뒤 하나의 BindingResult 게이트만 통과시킨다. 실제 MockMvc 테스트는 거부 문자열, ARIA, 서비스 명령, 302와 후속 상세 GET의 최종 HTML을 고정한다.
도식은 정상 경로와 세 실패 경로를 한 흐름에 놓습니다. 허용하지 않은 필드는 억제 후 오류로 승격하고, 타입 변환 또는 검증이 실패하면 같은 폼을 렌더링합니다. 모든 경계를 통과한 요청만 도메인 명령이 되어 PRG(Post/Redirect/Get)로 이동합니다.
충돌 없는 실행 단위
이 문서는 ch7-1의 루트 Gradle 계약과 validation 의존성을 사용합니다. 패키지, 요청, 템플릿 경로는 formbinding으로 분리합니다.
package board.formbinding;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class FormBindingApplication {
public static void main(String[] args) {
SpringApplication.run(FormBindingApplication.class, args);
}
}엔티티와 폼 객체를 분리한다
폼은 빈 값과 변환하지 못한 입력을 오류 응답까지 보존해야 합니다. 엔티티는 불변식이 성립한 상태로만 존재해야 하므로 엔티티에 웹 바인딩용 세터를 추가하지 않습니다.
package board.formbinding;
import java.time.LocalDate;
import org.springframework.format.annotation.DateTimeFormat;
public final class CreatePostForm {
private String title = "";
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;
}
}LocalDate는 참조 타입이므로 빈 입력을 null로 나타낼 수 있습니다. 정수 입력에서 “입력하지 않음”과 0을 구별해야 한다면 원시 int가 아니라 Integer 같은 래퍼를 선택합니다. 잘못된 날짜 문자열은 객체 속성에 들어가지 못하지만 BindingResult의 거부 값으로 남습니다.
폼에는 approved, memberId 같은 속성을 미리 만들지 않습니다. 나중에 같은 이름의 필드가 추가되더라도 클라이언트 권한이 자동으로 넓어지지 않도록 바인더 허용 목록도 함께 둡니다.
커스텀 검증기를 실제 바인딩 경로에 연결한다
검증기를 WebDataBinder에 추가하는 것만으로는 충분하지 않습니다. 핸들러의 모델 속성에 @Valid 또는 @Validated가 있어야 바인딩 뒤 검증 단계가 실행됩니다.
package board.formbinding;
import org.springframework.stereotype.Component;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;
@Component
public final class CreatePostFormValidator implements Validator {
@Override
public boolean supports(Class<?> type) {
return CreatePostForm.class.isAssignableFrom(type);
}
@Override
public void validate(Object target, Errors errors) {
var form = (CreatePostForm) target;
if (form.getTitle() == null || form.getTitle().isBlank()) {
errors.rejectValue(
"title", "title.required", "제목을 입력하세요.");
}
if (form.getContent() == null || form.getContent().isBlank()) {
errors.rejectValue(
"content", "content.required", "본문을 입력하세요.");
}
if (!errors.hasFieldErrors("publishedOn")
&& form.getPublishedOn() == null) {
errors.rejectValue(
"publishedOn",
"publishedOn.required",
"작성일을 입력하세요.");
}
}
}변환 오류가 이미 있는 날짜에 “필수” 오류를 하나 더 붙이지 않습니다. 빈 제목과 본문은 커스텀 검증기가 맡고, 미래 날짜 금지나 일일 등록 한도처럼 저장 상태가 필요한 규칙은 애플리케이션·도메인 계층이 맡습니다.
package board.formbinding;
import java.time.LocalDate;
import java.util.Objects;
public record CreatePostCommand(
String title,
String content,
LocalDate publishedOn
) {
public CreatePostCommand {
Objects.requireNonNull(title, "title");
Objects.requireNonNull(content, "content");
Objects.requireNonNull(publishedOn, "publishedOn");
if (title.isBlank() || content.isBlank()) {
throw new IllegalArgumentException(
"validated command must not be blank");
}
}
}package board.formbinding;
import java.time.LocalDate;
import java.util.Objects;
public record PostDetail(
long id,
String title,
String content,
LocalDate publishedOn
) {
public PostDetail {
if (id < 1) {
throw new IllegalArgumentException("id must be positive");
}
Objects.requireNonNull(title, "title");
Objects.requireNonNull(content, "content");
Objects.requireNonNull(publishedOn, "publishedOn");
}
}package board.formbinding;
public interface PostApplicationService {
long register(CreatePostCommand command);
PostDetail findDetail(long id);
}허용 필드·오류·PRG를 컨트롤러에서 확정한다
BindingResult는 대상 @ModelAttribute 바로 다음 파라미터에 있어야 합니다. 그 사이에 다른 인자가 오면 MVC가 어느 객체의 오류인지 연결하지 못해 핸들러 실행 전에 실패합니다.
package board.formbinding;
import java.util.Arrays;
import jakarta.validation.Valid;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Controller
public final class PostFormController {
private final PostApplicationService service;
private final CreatePostFormValidator validator;
public PostFormController(
PostApplicationService service,
CreatePostFormValidator validator
) {
this.service = service;
this.validator = validator;
}
@InitBinder("form")
void restrictFields(WebDataBinder binder) {
binder.setAllowedFields("title", "content", "publishedOn");
binder.addValidators(validator);
}
@GetMapping("/formbinding/posts/new")
String createForm(Model model) {
model.addAttribute("form", new CreatePostForm());
return "formbinding/posts/new";
}
@GetMapping("/formbinding/posts/{id}")
String detail(@PathVariable long id, Model model) {
model.addAttribute("post", service.findDetail(id));
return "formbinding/posts/detail";
}
@PostMapping("/formbinding/posts")
String create(
@Valid @ModelAttribute("form") CreatePostForm form,
BindingResult bindingResult,
RedirectAttributes redirectAttributes
) {
rejectSuppressedFields(bindingResult);
if (bindingResult.hasErrors()) {
return "formbinding/posts/new";
}
long id = service.register(new CreatePostCommand(
form.getTitle().strip(),
form.getContent(),
form.getPublishedOn()));
redirectAttributes.addAttribute("id", id);
redirectAttributes.addFlashAttribute(
"notice", "게시글을 저장했습니다.");
return "redirect:/formbinding/posts/{id}";
}
private static void rejectSuppressedFields(
BindingResult bindingResult
) {
var fields = bindingResult.getSuppressedFields();
if (fields.length == 0) {
return;
}
Arrays.sort(fields);
bindingResult.reject(
"binding.suppressed",
"허용하지 않은 입력 필드: " + String.join(", ", fields));
}
}허용하지 않은 필드를 단순히 무시하면 공격 시도를 관찰하기 어렵습니다. 이 예제는 억제된 필드를 객체 오류로 승격해 서비스를 호출하지 않습니다. 실제 서비스에서는 민감도에 따라 보안 로그와 지표도 남깁니다.
성공 시에는 서비스가 반환한 ID를 리다이렉트 속성에 넣습니다. {id} 자리표시자만 둔 채 값을 공급하지 않는 리다이렉트는 완성된 PRG 계약이 아닙니다. 목적지 GET도 같은 ID로 저장 결과를 조회해 실제 상세 템플릿을 렌더링해야 새로고침 가능한 PRG가 완성됩니다.
오류가 있을 때만 ARIA 관계를 만든다
th:field는 속성 경로로 name과 id를 만들고, 변환 실패 후에는 BindingResult의 거부 값을 다시 출력합니다. 오류 요소가 없는 성공 화면에서 aria-describedby="title-error"를 항상 남기면 존재하지 않는 ID를 가리킵니다.
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>게시글 등록</title>
</head>
<body>
<main>
<h1>게시글 등록</h1>
<form th:action="@{/formbinding/posts}"
th:object="${form}"
method="post"
novalidate>
<div id="form-errors"
role="alert"
th:if="${#fields.hasGlobalErrors()}">
<p th:each="error : ${#fields.globalErrors()}"
th:text="${error}">폼 오류</p>
</div>
<div>
<label for="title">제목</label>
<input th:field="*{title}"
th:attr="aria-invalid=${#fields.hasErrors('title')
? 'true' : null},
aria-describedby=${#fields.hasErrors('title')
? 'title-error' : null}" />
<p id="title-error"
role="alert"
th:if="${#fields.hasErrors('title')}"
th:errors="*{title}">제목 오류</p>
</div>
<div>
<label for="content">본문</label>
<textarea th:field="*{content}"
th:attr="aria-invalid=${#fields.hasErrors('content')
? 'true' : null},
aria-describedby=${#fields.hasErrors('content')
? 'content-error' : null}"></textarea>
<p id="content-error"
role="alert"
th:if="${#fields.hasErrors('content')}"
th:errors="*{content}">본문 오류</p>
</div>
<div>
<label for="publishedOn">작성일</label>
<input type="date"
th:field="*{publishedOn}"
th:attr="aria-invalid=${#fields.hasErrors('publishedOn')
? 'true' : null},
aria-describedby=${#fields.hasErrors('publishedOn')
? 'publishedOn-error' : null}" />
<p id="publishedOn-error"
role="alert"
th:if="${#fields.hasErrors('publishedOn')}"
th:errors="*{publishedOn}">작성일 오류</p>
</div>
<button type="submit">저장</button>
</form>
</main>
</body>
</html>aria-invalid와 aria-describedby는 해당 오류 요소와 함께 나타나고 함께 사라집니다. 색상만으로 실패를 알리지 않고 텍스트 오류와 프로그램적 관계를 모두 제공합니다.
날짜 변환이 실패하면 서버가 렌더링한 HTML의 value에는 거부 문자열이 남습니다. 다만 HTML input[type=date]는 표준 날짜 형식이 아닌 값을 브라우저 UI에 표시하지 않을 수 있습니다. 반드시 보이는 원문 복원이 제품 요구사항이면 텍스트 입력이나 별도 원문 안내를 설계하고 브라우저 수준에서도 검증해야 합니다.
성공 PRG의 상세 화면
리다이렉트 목적지 GET은 저장 결과와 flash 알림을 새 응답의 HTML로 완성합니다.
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>게시글 상세</title>
</head>
<body>
<main th:attr="data-post-id=${post.id}">
<p role="status" th:if="${notice}"
th:text="${notice}">저장 알림</p>
<article>
<h1 th:text="${post.title}">게시글 제목</h1>
<p th:text="${post.content}">게시글 본문</p>
<time th:attr="datetime=${post.publishedOn}"
th:text="${post.publishedOn}">2026-08-20</time>
</article>
<a th:href="@{/formbinding/posts/new}">새 게시글</a>
</main>
</body>
</html>바인딩·검증·도메인 실패를 분리한다
| 실패 위치 | 예시 | 재표시 근거 | 다음 행동 |
|---|---|---|---|
| 허용 필드 | approved=true | 억제 필드 목록 | 객체 오류와 보안 관찰 |
| 타입 변환 | publishedOn=날짜아님 | BindingResult 거부 값 | 같은 폼 렌더링 |
| 필드 검증 | 공백 title | 폼 속성 값 | 필드 오류 안내 |
| 객체 검증 | 종료일이 시작일보다 빠름 | 관련 필드 값 | 객체 오류 안내 |
| 도메인·서비스 | 하루 등록 한도 초과 | 검증된 제출 값 | 업무 오류로 변환 |
문자열이 LocalDate로 변환되고 필수 값이 채워졌다고 업무 규칙까지 만족한 것은 아닙니다. 동시에 들어오는 요청의 중복과 한도는 트랜잭션과 데이터베이스 제약이 마지막 방어선을 맡습니다. 반대로 날짜 입력 형식, 레이블, 필드 순서, 오류 문구 같은 표시 규칙은 웹 어댑터에 둡니다.
실제 검증기와 최종 폼을 MockMvc로 검증한다
검증기를 목으로 바꾸면 @Valid가 실제 규칙을 호출하는지 증명할 수 없습니다. 다음 테스트는 실제 CreatePostFormValidator를 사용하고 서비스만 기록용 포트로 대체합니다.
package board.formbinding;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.flash;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.annotation.Bean;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest(classes = {
FormBindingApplication.class,
PostFormBindingTest.FixtureConfiguration.class
})
@AutoConfigureMockMvc
class PostFormBindingTest {
@Autowired
MockMvc mvc;
@Autowired
RecordingPostApplicationService service;
@BeforeEach
void resetService() {
service.clear();
}
@Test
void GET_폼은_존재하지_않는_오류_id를_참조하지_않는다()
throws Exception {
var result = mvc.perform(get("/formbinding/posts/new"))
.andExpect(status().isOk())
.andExpect(view().name("formbinding/posts/new"))
.andReturn();
var document = parse(result.getResponse()
.getContentAsString(StandardCharsets.UTF_8));
assertThat(document.select("[aria-invalid=true], [aria-describedby]"))
.isEmpty();
assertThat(document.select("label[for]").stream()
.allMatch(label -> document.getElementById(
label.attr("for")) != null))
.isTrue();
}
@Test
void 잘못된_날짜는_거부값과_접근가능한_field_error를_보존한다()
throws Exception {
var result = mvc.perform(post("/formbinding/posts")
.param("title", "Spring MVC")
.param("content", "바인딩 경계를 기록합니다.")
.param("publishedOn", "날짜아님"))
.andExpect(status().isOk())
.andExpect(view().name("formbinding/posts/new"))
.andExpect(model().attributeHasFieldErrors(
"form", "publishedOn"))
.andReturn();
var document = parse(result.getResponse()
.getContentAsString(StandardCharsets.UTF_8));
var input = document.selectFirst("#publishedOn");
assertThat(input.attr("value")).isEqualTo("날짜아님");
assertThat(input.attr("aria-invalid")).isEqualTo("true");
assertThat(input.attr("aria-describedby"))
.isEqualTo("publishedOn-error");
assertThat(document.selectFirst("#publishedOn-error").text())
.isNotBlank();
assertThat(service.commands()).isEmpty();
}
@Test
void 억제된_필드는_객체_오류가_되어_service를_막는다()
throws Exception {
var result = mvc.perform(post("/formbinding/posts")
.param("title", "Spring MVC")
.param("content", "허용 필드만 저장합니다.")
.param("publishedOn", "2026-08-20")
.param("approved", "true"))
.andExpect(status().isOk())
.andExpect(view().name("formbinding/posts/new"))
.andExpect(model().attributeHasErrors("form"))
.andReturn();
var document = parse(result.getResponse()
.getContentAsString(StandardCharsets.UTF_8));
assertThat(document.selectFirst("#form-errors").text())
.contains("approved");
assertThat(service.commands()).isEmpty();
}
@Test
void 실제_custom_validator가_공백_제목을_거부한다()
throws Exception {
mvc.perform(post("/formbinding/posts")
.param("title", " ")
.param("content", "본문")
.param("publishedOn", "2026-08-20"))
.andExpect(status().isOk())
.andExpect(model().attributeHasFieldErrors(
"form", "title"));
assertThat(service.commands()).isEmpty();
}
@Test
void 유효한_요청은_redirect를_follow해_상세_HTML을_렌더링한다()
throws Exception {
var redirect = mvc.perform(post("/formbinding/posts")
.param("title", " Spring MVC ")
.param("content", "검증 뒤 명령을 만듭니다.")
.param("publishedOn", "2026-08-20"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/formbinding/posts/41"))
.andExpect(flash().attribute(
"notice", "게시글을 저장했습니다."))
.andReturn();
assertThat(service.commands()).containsExactly(
new CreatePostCommand(
"Spring MVC",
"검증 뒤 명령을 만듭니다.",
LocalDate.of(2026, 8, 20)));
var detail = mvc.perform(get(redirect.getResponse()
.getRedirectedUrl())
.flashAttrs(redirect.getFlashMap()))
.andExpect(status().isOk())
.andExpect(view().name("formbinding/posts/detail"))
.andExpect(model().attribute("post", new PostDetail(
41L,
"Spring MVC",
"검증 뒤 명령을 만듭니다.",
LocalDate.of(2026, 8, 20))))
.andReturn();
var document = parse(detail.getResponse()
.getContentAsString(StandardCharsets.UTF_8));
assertThat(document.selectFirst("main").attr("data-post-id"))
.isEqualTo("41");
assertThat(document.selectFirst("h1").text())
.isEqualTo("Spring MVC");
assertThat(document.selectFirst("[role=status]").text())
.isEqualTo("게시글을 저장했습니다.");
assertThat(document.selectFirst("time").attr("datetime"))
.isEqualTo("2026-08-20");
}
private static Document parse(String html) {
return Jsoup.parse(html);
}
static final class RecordingPostApplicationService
implements PostApplicationService {
private final List<CreatePostCommand> commands =
new ArrayList<>();
@Override
public long register(CreatePostCommand command) {
commands.add(command);
return 41L;
}
@Override
public PostDetail findDetail(long id) {
if (id != 41L || commands.isEmpty()) {
throw new IllegalArgumentException("post not found: " + id);
}
var command = commands.get(commands.size() - 1);
return new PostDetail(
id,
command.title(),
command.content(),
command.publishedOn());
}
List<CreatePostCommand> commands() {
return List.copyOf(commands);
}
void clear() {
commands.clear();
}
}
@TestConfiguration(proxyBeanMethods = false)
static class FixtureConfiguration {
@Bean
RecordingPostApplicationService postApplicationService() {
return new RecordingPostApplicationService();
}
}
}다섯 테스트가 성공·실패의 호출 횟수만 보는 것이 아니라 오류 원문, 실제 검증기, 억제 필드, ARIA 참조, 정확한 명령, 확장된 리다이렉트와 후속 상세 GET의 최종 HTML까지 고정합니다.
연습 문제
게시글 수정 화면을 추가하되 URL의 게시글 ID를 숨은 입력으로 받지 마세요. 경로 변수로 얻은 ID와 인증된 회원을 서버에서 결합하고, 폼에는 수정 가능한 제목·본문·날짜만 둡니다. 공격자가 memberId와 approved를 추가한 요청도 서비스에 도달하지 않는 MVC 테스트를 작성합니다.
해설 보기
수정 DTO도 엔티티와 분리하고 allowedFields를 고정합니다. 게시글 ID는 핸들러의 경로 변수, 회원 ID는 인증 주체에서 얻습니다. 서비스가 두 값으로 소유권을 확인한 뒤에만 명령을 실행합니다.
정상 수정, 다른 회원, 경로 ID 변환 실패, 추가 파라미터를 각각 보내세요. 정상일 때만 서비스가 정확히 한 번 호출되고, 나머지 경로에서는 필드·객체·권한 오류가 서로 구별되는지 확인합니다.
다음 문서에서는 체크박스, 라디오 버튼, 선택 목록이 하나의 문자열이 아닌 선택 상태를 어떤 HTTP 값으로 표현하는지 살펴봅니다.