Spring 폼 바인딩
게시판 폼의 th:object·th:field와 WebDataBinder 연결을 이해하고 바인딩 오류를 처리합니다.
HTML 폼은 이름이 붙은 문자열 묶음을 전송합니다.
Spring MVC는 이 값을 Java 객체에 넣어 주지만, 편리함이 곧 안전한 계약을 뜻하지는 않습니다.
게시판 등록 화면에서는 사용자가 수정할 수 있는 title, content, publishedOn만 전송 객체에 열고 회원 식별자·승인 상태는 서버가 결정해야 합니다.
폼 객체
등록 화면과 도메인 엔티티의 생명주기는 다릅니다.
폼은 빈 값과 잘못된 날짜도 잠시 보존해야 하지만 엔티티는 불변식이 성립한 상태로만 존재해야 합니다.
따라서 엔티티에 세터를 추가하지 않고 화면 전용 객체를 둡니다.
package board.web;
import java.time.LocalDate;
public final class CreatePostForm {
private String title = "";
private String content = "";
private LocalDate publishedOn;
private boolean publicVisible;
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 boolean isPublicVisible() {
return publicVisible;
}
public void setPublicVisible(boolean publicVisible) {
this.publicVisible = publicVisible;
}
}Integer와 null을 허용하는 LocalDate는 입력이 비었을 때 바인딩 결과를 보존합니다.
int를 쓰면 빈 값이 0과 섞여 “입력하지 않음”과 “0을 입력함”을 구별하기 어렵습니다.
폼에서 허용한 불완전 상태는 검증 이후 애플리케이션 명령으로 변환할 때 끝납니다.
GET·POST 폼 흐름
@ModelAttribute("form")의 이름은 템플릿의 th:object="${form}"과 일치해야 합니다.
성공 경로는 Post/리다이렉트/Get으로 상세 화면에 이동하고, 실패 경로는 같은 객체와 오류를 그대로 렌더링합니다.
package board.web;
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.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("/posts/new")
String createForm(Model model) {
model.addAttribute("form", new CreatePostForm());
return "posts/new";
}
@PostMapping("/posts")
String create(
@ModelAttribute("form") CreatePostForm form,
BindingResult bindingResult,
RedirectAttributes redirectAttributes
) {
if (bindingResult.hasErrors()) {
return "posts/new";
}
long id = service.register(new CreatePostCommand(
form.getTitle().strip(),
form.getContent(),
form.getPublishedOn()));
redirectAttributes.addFlashAttribute(
"notice", "게시글을 저장했습니다.");
return "redirect:/posts/{id}";
}
}BindingResult는 대상 폼 바로 다음 파라미터에 있어야 합니다.
사이에 Model 같은 다른 인자를 끼우면 어떤 객체의 바인딩 결과인지 연결할 수 없어 요청 처리 전에 예외가 납니다.
이 순서는 취향이 아니라 MVC 리졸버 계약입니다.
th:field 바인딩
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8" />
<title>게시글 등록</title>
</head>
<body>
<main>
<h1>게시글 등록</h1>
<form th:action="@{/posts}" th:object="${form}" method="post">
<div>
<label for="title">제목</label>
<input th:field="*{title}" aria-describedby="title-error" />
<p id="title-error" th:if="${#fields.hasErrors('title')}"
th:errors="*{title}">제목 오류</p>
</div>
<div>
<label for="content">본문</label>
<textarea th:field="*{content}"
aria-describedby="content-error"></textarea>
<p id="content-error" th:if="${#fields.hasErrors('content')}"
th:errors="*{content}">본문 오류</p>
</div>
<div>
<label for="publishedOn">작성일</label>
<input type="date" th:field="*{publishedOn}" />
</div>
<button type="submit">저장</button>
</form>
</main>
</body>
</html>th:field는 속성 경로에서 name과 id를 만들고 실패 후에는 사용자가 보낸 값을 다시 출력합니다.
날짜 변환이 실패하면 Java 속성에는 null이 남지만 BindingResult에는 원래 문자열이 남습니다.
Spring의 필드 값 처리기를 통하면 사용자가 입력한 값을 오류 화면에 그대로 재표시할 수 있습니다.
레이블의 for와 입력의 id, 오류 안내의 aria-describedby도 함께 검증합니다.
단순히 빨간 테두리만 그리면 화면 낭독기 사용자는 어떤 필드가 실패했는지 알기 어렵습니다.
바인딩 실패 검증
package board.web;
import static org.hamcrest.Matchers.containsString;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
@WebMvcTest(PostFormController.class)
class PostFormControllerTest {
@Autowired
MockMvc mvc;
@MockitoBean
PostApplicationService service;
@MockitoBean
CreatePostFormValidator validator;
@Test
void 잘못된_날짜는_원래_문자열과_field_error를_보존한다()
throws Exception {
mvc.perform(post("/posts")
.param("title", "Spring MVC")
.param("content", "Spring MVC 바인딩을 정리합니다.")
.param("publishedOn", "날짜아님")
.param("approved", "true"))
.andExpect(status().isOk())
.andExpect(view().name("posts/new"))
.andExpect(model().attributeHasFieldErrors(
"form", "publishedOn"))
.andExpect(content().string(containsString("날짜아님")));
verifyNoInteractions(service);
}
}PostFormControllerTest
> 잘못된_날짜는_원래_문자열과_field_error를_보존한다() PASSED
field = publishedOn
rejected value = 날짜아님
service calls = 0
suppressed field = approvedapproved는 폼 속성이 없어도 무시된다는 사실에 기대지 않습니다.
allowedFields를 명시하면 나중에 속성이 추가되어도 클라이언트가 갑자기 수정 권한을 얻지 않습니다.
보안상 중요한 억제된 필드가 들어오면 로그를 남기거나 요청을 거부하는 정책도 선택할 수 있습니다.
바인딩과 도메인 생성
문자열을 LocalDate로 바꾸고 필수 값을 채웠다고 업무 규칙까지 만족한 것은 아닙니다.
미래 날짜 금지, 하루 총 본문 길이 제한, 동일 회원의 중복 기록은 애플리케이션 또는 도메인 계층에서 검사합니다.
동시에 수정되는 동시 요청은 데이터베이스 제약 조건과 트랜잭션이 마지막 방어선을 맡습니다.
반대로 HTML 표시 규칙을 도메인에 넣지 않습니다.
날짜 입력 형식, 자리표시자, 필드 순서, 오류 문구는 웹 어댑터 책임입니다.
폼 DTO가 명령으로 바뀌는 한 줄을 경계로 각 계층의 실패 의미를 구별할 수 있어야 합니다.
| 실패 위치 | 예시 | 재표시 값 | 다음 행동 |
|---|---|---|---|
| 바인딩 | publishedOn=날짜아님 | 원문 날짜아님 | 같은 폼 렌더링 |
| 필드 검증 | 빈 content | 빈 문자열 | 필드별 안내 |
| 객체 검증 | 종료일이 시작일보다 빠름 | 두 필드 모두 | 객체 오류 안내 |
| 도메인/서비스 | 하루 한도 초과 | 제출 값 | 업무 오류로 변환 |
연습 문제
게시판 수정 화면을 추가하되 URL의 게시글 ID를 숨은 입력으로 받지 마세요.
경로 변수로 조회한 ID와 현재 사용자를 서버에서 결합하고, 폼에는 수정 가능한 제목·본문·날짜만 두세요.
공격자가 memberId와 approved 파라미터를 더 보낸 경우 상태가 바뀌지 않는 MVC 테스트를 작성합니다.
해설 보기
수정용 DTO도 엔티티와 분리하고 바인더 허용 목록을 고정합니다.
경로 ID는 핸들러 인자, 로그인 회원은 인증 주체에서 얻습니다.
서비스는 둘을 이용해 소유권을 확인한 뒤 명령을 실행합니다.
package board.web;
import java.time.LocalDate;
public record UpdatePostCommand(
long postId,
long memberId,
String title,
String content,
LocalDate publishedOn
) {
public UpdatePostCommand {
if (postId <= 0 || memberId <= 0) {
throw new IllegalArgumentException("positive identifiers required");
}
}
}테스트는 정상 수정, 다른 회원의 ID, 숫자 변환 실패, 추가 파라미터를 각각 보냅니다.
정상일 때만 서비스가 한 번 호출되고 실패 경로에서는 원래 입력과 오류가 모델에 남는지 확인합니다.
다음 문서에서는 체크박스, 라디오 버튼, 선택 목록이 문자열 하나가 아닌 선택 상태를 어떤 HTTP 값으로 표현하는지 살펴봅니다.