Spring 폼 바인딩
허용 필드·변환·검증·PRG 경계를 실제 Spring Boot 폼과 MockMvc 최종 HTML로 검증합니다.
HTML 폼은 이름이 붙은 문자열 묶음을 전송합니다. Spring MVC는 그 문자열을 객체 속성에 바인딩하지만, 편리한 변환이 안전한 변경 권한까지 뜻하지는 않습니다.
게시글 등록에서는 title, content, publishedOn만 폼 객체에 열고 회원 식별자와 승인 상태는 서버가 결정해야 합니다.
허용·변환·검증을 모두 통과한 폼만 도메인 명령이 된다
허용 필드·변환·validator·BindingResult·command/service·302/GET과 오류 200/service 0의 처리 상태가 있다.
FLOWCHART · FORM BINDING · PRG
허용·변환·검증을 모두 통과한 폼만 도메인 명령이 된다
WebDataBinder가 허용 필드와 타입 변환을 맡고, 억제·바인딩 오류와
실제 @Valid 검증 오류는 서비스를 호출하지 않은 채 접근
가능한 같은 폼으로 돌아간다.
왼쪽 우회선은 오류가 있는 대안이며 성공 PRG 뒤에 같은 폼을 렌더링한다는 뜻이 아닙니다. 인접 BindingResult를 받은 handler는 오류가 있어도 실행되고 그 안의 판단이 서비스 진입을 막습니다.
ALLOWED FIELDS · SERVICE 0
추가 필드는 억제 후 전역 오류로
title·content·publishedOn만 바인딩합니다. approved=true는 suppressed이며 자동 오류로 간주하지 않습니다. handler가 binding.suppressed를 명시적으로 더하고 같은 폼을 200으로 렌더링합니다.
TYPE CONVERSION · SERVICE 0
잘못된 날짜의 거부 원문과 typeMismatch
날짜아님은 LocalDate가 되지 못해 BindingResult에 남습니다. validator는 publishedOn에 기존 field error가 있으면 required를 중복 추가하지 않습니다. 렌더링 HTML의 value 원문 보존과 브라우저 date UI의 표시 여부는 별개입니다.
CUSTOM VALIDATOR · SERVICE 0
등록과 @Valid가 실제 규칙을 연결한다
바인더에 실제 CreatePostFormValidator를 추가하고 모델 인자에 @Valid를 둡니다. 공백 title은 title.required입니다. 인접 BindingResult를 받은 handler는 모든 오류를 판단해 서비스를 막습니다. 미래 날짜·일일 한도는 이 검증기가 구현한 규칙이 아닙니다.
SUCCESS · REGISTER 1
정확한 명령과 반환 ID로 PRG를 완성
제목의 앞뒤 공백을 strip하고 본문·날짜를 그대로 command에 옮깁니다. service 반환 ID 41로 /formbinding/posts/41을 완성합니다. 기본 redirect는 302이고 테스트는 3xx와 정확한 목적지·flash를 확인합니다. flash를 넘긴 후속 GET은 상세 HTML 200입니다.
FINAL HTML / BROWSER
오류 요소와 ARIA 참조가 함께 나타난다
GET 폼에는 aria-invalid/describedby가 없습니다. 날짜 오류에는 실제 publishedOn-error와 연결한 참조·텍스트가 있습니다. 브라우저 date input은 잘못된 날짜 값을 빈 값으로 정리할 수 있으므로 보이는 원문 복원이 필요하면 텍스트 입력이나 별도 안내와 브라우저 검사를 설계합니다.
오류가 있어도 BindingResult를 받은 handler는 실행됩니다. 서비스 호출을 막는 주체는 handler의 단일 오류 판단입니다. MockMvc의 실제 Thymeleaf HTML 검사와 브라우저 입력 UI 검사는 서로 다른 경계를 검증합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 문자열 POST는 허용 필드와 타입 변환, Valid의 실제 custom validator를 거친 뒤 바로 뒤의 BindingResult와 handler로 들어옵니다. handler가 억제 필드를 global error로 추가하고 hasErrors를 한 번 판단합니다. 오류는 같은 폼 200/service 0, 오류 없을 때만 command/service/register 1→서비스 ID 41의 302 redirect→상세 GET 200/flash입니다.
- 원 데스크톱의 여섯 정상 경로 노드·다섯 관계를 재배치하고 원 본문과 패널의 오류 재표시 대안을 명시한다. 바인더·실제 검증 뒤 인접 BindingResult를 받은 handler가 억제 필드를 객체 오류로 더한 뒤 한 번 판단한다. 오류가 있으면 같은 폼 200/service 0으로 끝난다. 오류 없을 때만 command와 register 1, 반환 ID 41의 기본 302 redirect 및 후속 GET 200/flash로 진행한다. 새 그림은 HTTP·브라우저·DB 실행 증명이 아니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 2 | 바인딩과 실제 custom validator는 handler 전에 실행되고 인접 BindingResult를 받은 handler가 suppressed field를 global error로 추가하고 hasErrors를 한 번 판단합니다. approved 억제/날짜 typeMismatch/공백 title은 같은 폼 200·service 0입니다. 정상 입력만 strip한 제목의 command로 register 1 후 ID 41 목적지와 flash의 PRG를 완성합니다. 오류 요소/ARIA 관계와 HTML 거부 값은 최종 DOM으로 확인하며 date 브라우저 UI의 원문 표시는 별도 경계입니다. |
| 8 | 문자열 POST는 허용 필드와 타입 변환, Valid의 실제 custom validator를 거친 뒤 바로 뒤의 BindingResult와 handler로 들어옵니다. handler가 억제 필드를 global error로 추가하고 hasErrors를 한 번 판단합니다. 오류는 같은 폼 200/service 0, 오류 없을 때만 command/service/register 1→서비스 ID 41의 302 redirect→상세 GET 200/flash입니다. |
| 19 | ID 41 상세 · flash |
| 21 | 원 데스크톱의 여섯 정상 경로 노드·다섯 관계를 재배치하고 원 본문과 패널의 오류 재표시 대안을 명시한다. 바인더·실제 검증 뒤 인접 BindingResult를 받은 handler가 억제 필드를 객체 오류로 더한 뒤 한 번 판단한다. 오류가 있으면 같은 폼 200/service 0으로 끝난다. 오류 없을 때만 command와 register 1, 반환 ID 41의 기본 302 redirect 및 후속 GET 200/flash로 진행한다. 새 그림은 HTTP·브라우저·DB 실행 증명이 아니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 문자열 입력 바인딩·검증 POST가 허용 필드·타입 변환·실제 custom validator를 지나 바로 뒤 BindingResult와 handler로 들어옵니다. | 입력: 문자열 POST · 검증 결과: BindingResult · 서비스: 미호출 | 문자열 입력이 검증된 필드와 오류 정보로 나뉩니다. |
| 오류를 한 번 판단 handler가 억제 필드를 global error로 더하고 hasErrors를 판단합니다. | 오류 분기: 같은 폼 200 · 오류 시 서비스 호출: 0 | 거부된 입력이면 서비스로 진행하지 않습니다. |
| 정상 입력 등록 오류 없는 경우만 command와 service를 호출합니다. | register 호출: 1 · 서비스 ID: 41 | 검증을 통과한 입력이 등록 업무로 넘어갑니다. |
| redirect와 상세 GET 등록 뒤 302 redirect를 보내고 별도 상세 GET이 200·flash를 표시합니다. | 응답: 302→GET 200 · 서비스 ID: 41 · GET: 새 요청 | 폼 처리 결과가 별도 조회 화면에 표시됩니다. |
허용·변환·검증을 모두 통과한 폼만 도메인 명령이 된다
허용 필드·변환·validator·BindingResult·command/service·302/GET과 오류 200/service 0의 처리 상태가 있다.
FLOWCHART · FORM BINDING · PRG
허용·변환·검증을 모두 통과한 폼만 도메인 명령이 된다
WebDataBinder가 허용 필드와 타입 변환을 맡고, 억제·바인딩 오류와
실제 @Valid 검증 오류는 서비스를 호출하지 않은 채 접근
가능한 같은 폼으로 돌아간다.
왼쪽 우회선은 오류가 있는 대안이며 성공 PRG 뒤에 같은 폼을 렌더링한다는 뜻이 아닙니다. 인접 BindingResult를 받은 handler는 오류가 있어도 실행되고 그 안의 판단이 서비스 진입을 막습니다.
ALLOWED FIELDS · SERVICE 0
추가 필드는 억제 후 전역 오류로
title·content·publishedOn만 바인딩합니다. approved=true는 suppressed이며 자동 오류로 간주하지 않습니다. handler가 binding.suppressed를 명시적으로 더하고 같은 폼을 200으로 렌더링합니다.
TYPE CONVERSION · SERVICE 0
잘못된 날짜의 거부 원문과 typeMismatch
날짜아님은 LocalDate가 되지 못해 BindingResult에 남습니다. validator는 publishedOn에 기존 field error가 있으면 required를 중복 추가하지 않습니다. 렌더링 HTML의 value 원문 보존과 브라우저 date UI의 표시 여부는 별개입니다.
CUSTOM VALIDATOR · SERVICE 0
등록과 @Valid가 실제 규칙을 연결한다
바인더에 실제 CreatePostFormValidator를 추가하고 모델 인자에 @Valid를 둡니다. 공백 title은 title.required입니다. 인접 BindingResult를 받은 handler는 모든 오류를 판단해 서비스를 막습니다. 미래 날짜·일일 한도는 이 검증기가 구현한 규칙이 아닙니다.
SUCCESS · REGISTER 1
정확한 명령과 반환 ID로 PRG를 완성
제목의 앞뒤 공백을 strip하고 본문·날짜를 그대로 command에 옮깁니다. service 반환 ID 41로 /formbinding/posts/41을 완성합니다. 기본 redirect는 302이고 테스트는 3xx와 정확한 목적지·flash를 확인합니다. flash를 넘긴 후속 GET은 상세 HTML 200입니다.
FINAL HTML / BROWSER
오류 요소와 ARIA 참조가 함께 나타난다
GET 폼에는 aria-invalid/describedby가 없습니다. 날짜 오류에는 실제 publishedOn-error와 연결한 참조·텍스트가 있습니다. 브라우저 date input은 잘못된 날짜 값을 빈 값으로 정리할 수 있으므로 보이는 원문 복원이 필요하면 텍스트 입력이나 별도 안내와 브라우저 검사를 설계합니다.
오류가 있어도 BindingResult를 받은 handler는 실행됩니다. 서비스 호출을 막는 주체는 handler의 단일 오류 판단입니다. MockMvc의 실제 Thymeleaf HTML 검사와 브라우저 입력 UI 검사는 서로 다른 경계를 검증합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 문자열 POST는 허용 필드와 타입 변환, Valid의 실제 custom validator를 거친 뒤 바로 뒤의 BindingResult와 handler로 들어옵니다. handler가 억제 필드를 global error로 추가하고 hasErrors를 한 번 판단합니다. 오류는 같은 폼 200/service 0, 오류 없을 때만 command/service/register 1→서비스 ID 41의 302 redirect→상세 GET 200/flash입니다.
- 원 데스크톱의 여섯 정상 경로 노드·다섯 관계를 재배치하고 원 본문과 패널의 오류 재표시 대안을 명시한다. 바인더·실제 검증 뒤 인접 BindingResult를 받은 handler가 억제 필드를 객체 오류로 더한 뒤 한 번 판단한다. 오류가 있으면 같은 폼 200/service 0으로 끝난다. 오류 없을 때만 command와 register 1, 반환 ID 41의 기본 302 redirect 및 후속 GET 200/flash로 진행한다. 새 그림은 HTTP·브라우저·DB 실행 증명이 아니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 2 | 바인딩과 실제 custom validator는 handler 전에 실행되고 인접 BindingResult를 받은 handler가 suppressed field를 global error로 추가하고 hasErrors를 한 번 판단합니다. approved 억제/날짜 typeMismatch/공백 title은 같은 폼 200·service 0입니다. 정상 입력만 strip한 제목의 command로 register 1 후 ID 41 목적지와 flash의 PRG를 완성합니다. 오류 요소/ARIA 관계와 HTML 거부 값은 최종 DOM으로 확인하며 date 브라우저 UI의 원문 표시는 별도 경계입니다. |
| 8 | 문자열 POST는 허용 필드와 타입 변환, Valid의 실제 custom validator를 거친 뒤 바로 뒤의 BindingResult와 handler로 들어옵니다. handler가 억제 필드를 global error로 추가하고 hasErrors를 한 번 판단합니다. 오류는 같은 폼 200/service 0, 오류 없을 때만 command/service/register 1→서비스 ID 41의 302 redirect→상세 GET 200/flash입니다. |
| 19 | ID 41 상세 · flash |
| 21 | 원 데스크톱의 여섯 정상 경로 노드·다섯 관계를 재배치하고 원 본문과 패널의 오류 재표시 대안을 명시한다. 바인더·실제 검증 뒤 인접 BindingResult를 받은 handler가 억제 필드를 객체 오류로 더한 뒤 한 번 판단한다. 오류가 있으면 같은 폼 200/service 0으로 끝난다. 오류 없을 때만 command와 register 1, 반환 ID 41의 기본 302 redirect 및 후속 GET 200/flash로 진행한다. 새 그림은 HTTP·브라우저·DB 실행 증명이 아니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 문자열 입력 바인딩·검증 POST가 허용 필드·타입 변환·실제 custom validator를 지나 바로 뒤 BindingResult와 handler로 들어옵니다. | 입력: 문자열 POST · 검증 결과: BindingResult · 서비스: 미호출 | 문자열 입력이 검증된 필드와 오류 정보로 나뉩니다. |
| 오류를 한 번 판단 handler가 억제 필드를 global error로 더하고 hasErrors를 판단합니다. | 오류 분기: 같은 폼 200 · 오류 시 서비스 호출: 0 | 거부된 입력이면 서비스로 진행하지 않습니다. |
| 정상 입력 등록 오류 없는 경우만 command와 service를 호출합니다. | register 호출: 1 · 서비스 ID: 41 | 검증을 통과한 입력이 등록 업무로 넘어갑니다. |
| redirect와 상세 GET 등록 뒤 302 redirect를 보내고 별도 상세 GET이 200·flash를 표시합니다. | 응답: 302→GET 200 · 서비스 ID: 41 · GET: 새 요청 | 폼 처리 결과가 별도 조회 화면에 표시됩니다. |
도식은 정상 경로와 세 실패 경로를 한 흐름에 놓습니다. 허용하지 않은 필드는 억제 후 오류로 승격하고, 타입 변환 또는 검증이 실패하면 같은 폼을 렌더링합니다. 모든 경계를 통과한 요청만 도메인 명령이 되어 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 같은 속성을 미리 만들지 않습니다. 나중에 같은 이름의 필드가 추가되더라도 클라이언트 권한이 자동으로 넓어지지 않도록 바인더 허용 목록도 함께 둡니다.
이 핸들러의 @ModelAttribute는 요청 파라미터를 폼 속성에 연결합니다. application/x-www-form-urlencoded의 이름·값과 JSON 본문의 프로퍼티는 같은 입력 계약이 아닙니다. JSON을 읽는 @RequestBody와 메시지 변환기는 별도 경로이며, Content-Type만 JSON으로 바꾼다고 현재 폼 객체에 JSON 값이 자동으로 들어오지 않습니다.
커스텀 검증기를 실제 바인딩 경로에 연결한다
검증기를 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",
"작성일을 입력하세요.");
}
}
}변환 오류가 이미 있는 날짜에 “필수” 오류를 하나 더 붙이지 않습니다. 빈 제목과 본문은 커스텀 검증기가 맡고, 미래 날짜 금지나 일일 등록 한도처럼 저장 상태가 필요한 규칙은 애플리케이션·도메인 계층이 맡습니다.
여기서 제목의 “공백”은 Java String.isBlank()의 판정입니다. 화면에서 빈칸처럼 보이는 모든 문자가 그 범위에 포함되지는 않습니다. 예를 들어 줄바꿈 없는 공백 U+00A0는 isBlank()로 거절되지 않고 strip()도 제거하지 않습니다. 제품이 그런 문자만 있는 제목도 거절하려면 시각적 빈 값의 범위와 정규화 정책을 별도로 정의해야 합니다. 이 검증기가 그 정책까지 구현했다고 해석하지 않습니다.
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));
}
}허용하지 않은 필드를 단순히 무시하면 공격 시도를 관찰하기 어렵습니다. 이 예제는 억제된 필드를 객체 오류로 승격해 서비스를 호출하지 않습니다. 실제 서비스에서는 민감도에 따라 보안 로그와 지표도 남깁니다.
예시
같은 요청에 title= , publishedOn=날짜아님, approved=true가 함께 있고 content는 유효하다고 생각해 보세요. 바인더는 날짜의 변환 오류와 억제 필드를 기록하고, 실제 검증기는 제목 오류를 더합니다. 날짜에는 이미 변환 오류가 있어 publishedOn.required를 중복 추가하지 않습니다. 인접 BindingResult가 있으므로 컨트롤러는 여전히 실행되어 억제 필드를 객체 오류로 승격한 뒤 hasErrors() 한 번으로 서비스 진입을 막습니다.
따라서 “검증 오류가 있으니 컨트롤러가 실행되지 않는다”는 설명은 이 메서드에 맞지 않습니다. 모델 인자와 오류 객체를 받는 경계, 실제 도메인 명령·서비스로 넘어가는 경계를 나누어 읽어야 합니다. Spring의 모델 속성과 오류 처리 설명도 오류 객체를 대상 바로 뒤에 두는 방식과 예외로 처리하는 방식을 구분합니다.
성공 시에는 서비스가 반환한 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 변환 실패, 추가 파라미터를 각각 보내세요. 정상일 때만 서비스가 정확히 한 번 호출되고, 나머지 경로에서는 필드·객체·권한 오류가 서로 구별되는지 확인합니다.
독립 확인
각 경우는 원 RecordingPostApplicationService를 비운 상태에서 시작합니다. 요청은 원 등록 POST이며 Content-Type은 폼입니다. 별도 표시가 없으면 title은 저장 , content는 원문 , publishedOn은 2026-08-20입니다.
approved=false만 추가합니다. 필드 값이 false이므로 억제 오류를 피할 수 있나요? 오류 종류, 필드의 ARIA 오류 참조 유무, register 횟수를 정하세요.- title을 공백 세 개, 날짜를
날짜아님으로 바꾸고approved=true도 보냅니다. 제목·날짜·억제 오류 중 무엇이 남고, 서비스에 도달하나요? - 추가 필드 없는 유효한 POST를 같은 fixture에 두 번 보내고 각각의 리다이렉트 목적지 GET을 따릅니다. commands 수, 명령의 제목·본문, 두 목적지 ID를 정하세요. PRG가 두 번째 등록을 막나요?
- 별도 변형에서 원
@Valid만 제거하고 공백 제목·유효한 본문·날짜·추가 필드 없는 요청을 보냅니다. 커스텀 제목 오류가 없어졌다면 서비스 등록은 성공하나요?
바인딩 오류·명령 불변식·PRG를 나누는 풀이
1번은 값의 참·거짓이 아니라 허용하지 않은 이름 approved 때문에 억제됩니다. handler가 binding.suppressed 객체 오류를 추가해 같은 폼 200, register 0회입니다. 유효한 세 필드에는 필드 오류가 없으므로 그 필드의 aria-invalid·aria-describedby는 없고 전역 오류 안내가 생깁니다.
2번은 제목의 title.required, 날짜의 변환 오류와 binding.suppressed가 함께 남습니다. publishedOn.required를 추가하지 않습니다. handler에는 도달하지만 폼 재표시 200, register 0회입니다. 억제된 approved를 폼 객체에 저장하거나 필드 오류로 바꾸는 코드는 없습니다.
3번은 register 2회로 commands 두 개를 기록합니다. 제목만 strip해서 저장, 본문은 앞뒤 공백을 포함한 원문 , 날짜는 2026-08-20입니다. 원 기록용 fixture는 호출마다 고정 ID 41을 반환하므로 두 목적지는 모두 /formbinding/posts/41입니다. 이것은 테스트 포트의 정책이며 실제 저장소가 중복 ID를 발급한다는 계약이 아닙니다. PRG는 후속 GET의 새로고침을 도와도 같은 POST를 다시 보내는 일을 차단하지 않습니다.
4번은 이 폼에 붙은 커스텀 검증이 요청 인자의 검증 단계에서 실행되지 않습니다. 하지만 공백 제목으로 만든 CreatePostCommand 생성자가 IllegalArgumentException을 던져 register 호출 전 멈춥니다. 서비스 성공으로 바뀌는 것이 아니며, 필드 오류를 갖춘 정상 재표시도 아닙니다. 이 변형의 정확한 HTTP 오류 응답은 별도 예외 처리 정책까지 정해야 알 수 있습니다. 입력 오류 안내와 명령 불변식을 둘 다 유지할 이유가 여기 있습니다.
다음 문서에서는 체크박스, 라디오 버튼, 선택 목록이 하나의 문자열이 아닌 선택 상태를 어떤 HTTP 값으로 표현하는지 살펴봅니다.
적용: 빈 날짜·유니코드 공백·JSON 입력을 나누기
조건 1. 추가 필드 없이 제목과 본문은 유효합니다. 첫 폼 요청의 날짜 값은 빈 문자열이고 두 번째는 날짜아님입니다. 다른 변환기를 등록하지 않고 원 ISO.DATE 바인딩과 검증기를 사용합니다. 두 경우 publishedOn의 폼 값과 오류 종류·서비스 호출은 어떻게 다른가요?
풀이. 빈 문자열은 날짜 속성의 null로 처리되고, 기존 날짜 필드 오류가 없으므로 커스텀 검증기가 publishedOn.required를 추가합니다. 날짜아님은 변환에 실패해 typeMismatch 계열의 필드 오류와 거부 원문이 남습니다. 원 검증기는 그 필드 오류를 확인해 required를 중복 추가하지 않습니다. 둘 다 오류 판단에서 멈춰 register는 0회입니다. 잘못된 날짜의 서버 HTML value와 브라우저 date UI가 실제로 보여주는 값은 기존 설명처럼 별도 관측입니다.
조건 2. 제목은 U+00A0 한 문자이고 본문·날짜는 유효하며 추가 필드는 없습니다. 원 검증기·명령 생성자를 그대로 사용합니다. 시각적으로 비어 보인다는 이유만으로 title.required가 생기거나 strip 뒤 빈 제목이 되나요?
풀이. 아닙니다. Java에서 이 문자는 isBlank()가 false이고 strip() 뒤에도 한 문자로 남습니다. 따라서 원 제목 조건과 명령 생성자의 blank 조건을 통과할 수 있습니다. 이것은 현재 코드의 문자 판정에서 도출한 결과이며 모든 빈 제목을 만족스럽게 거절하는 제품 정책의 증거가 아닙니다. 정책을 확장한다면 허용·거부할 문자와 실제 입력·정규화 결과를 함께 테스트해야 합니다.
조건 3. 브라우저 폼 전송 대신 같은 URL에 JSON 객체만 보냅니다. query 파라미터·폼 파라미터·관련 이름의 헤더는 없고 본문의 title·content·publishedOn만 유효한 값입니다. 원 @ModelAttribute를 그대로 두고 JSON을 보냈다는 이유로 같은 명령이 만들어진다고 가정해도 되나요?
풀이. 안 됩니다. JSON 본문을 읽는 메시지 변환기가 이 모델 인자를 대신 채우는 경로가 아닙니다. 폼 값과 JSON DTO는 각각의 엔드포인트·입력 규칙을 명시해야 합니다. 원 코드의 기본 빈 title·content와 null 날짜가 남는 경우에는 검증 오류가 발생합니다. 이 비교는 입력 경계의 설명이며 JSON 요청의 정확한 HTTP 응답·필터 동작까지 실행해 검증했다는 뜻은 아닙니다.