바인딩 오류 처리
게시판 등록 요청의 필드·객체 오류, 거부된 값, 인자 순서와 검증기 실행을 추적하고 실패한 폼을 안전하게 재표시합니다.
BindingResult는 “오류가 있다”는 불리언 하나가 아닙니다.
요청 문자열을 객체에 옮긴 바인딩 기록, 변환하지 못한 원문, 검증기가 만든 필드·객체 오류, 메시지 코드와 인자가 한 요청 단위로 들어 있습니다.
이 구조를 이해하면 컨트롤러에서 임의의 오류 문자열을 조립하지 않고도 사용자가 무엇을 고쳐야 하는지 정확히 보여 줄 수 있습니다.
BindingResult는 변환 실패와 검증 오류를 서비스 호출 전에 한 요청 기록으로 모은다
변환 오류와 validator 오류를 BindingResult에 모은 뒤 hasErrors가 폼 또는 command/service로 갈라진다.
FLOWCHART · ERROR ACCUMULATION · SIDE-EFFECT GUARD
BindingResult의 오류 유무로 재표시와 서비스 호출을 나눈다
변환 실패 원문과 필드·객체 오류가 같은 요청 기록에 쌓입니다. 실제 validator의 조건을 구분하고, handler는 오류가 없을 때만 서비스를 호출합니다.
TYPE CONVERSION
날짜 원문과 typeMismatch를 함께 보존
publishedOn=28-08-2026은 ISO LocalDate로 변환되지 않습니다. FieldError에 typeMismatch와 rejectedValue 원문이 남으며, handler는 같은 폼을 재표시하고 서비스를 호출하지 않습니다.
FIELD ERRORS
제목 80 · 내용 720은 strip 후 code point
title의 공백은 post.title.required, 길이 초과는 post.title.length입니다. content도 required/length를 구분합니다. 이 validator는 strip 후 code point를 세므로 emoji 한 개를 UTF-16 두 단위로 세지 않습니다.
OBJECT ERROR / PRECONDITION
공개·짧은 내용의 관계 오류는 content 기준
publicVisible=true이고 content가 25 code point 미만이면 post.publicVisible.minimum을 객체 오류로 더합니다. content 필드 오류가 있을 때만 이 관계 검증을 생략합니다. 날짜 변환 실패는 이 조건을 막지 않아 날짜 오류와 객체 오류가 함께 존재합니다.
HANDLER / SUPPRESSED FIELDS
오류 유무와 억제된 필드는 다른 기록
@Validated 폼 바로 뒤의 BindingResult를 handler가 검사합니다. 실제 조건은 hasErrors()이며 “기록이 비었는가”가 아닙니다. admin은 allowedFields에서 억제되지만 이 fixture는 suppressedFields 자체를 오류로 바꾸는 별도 코드를 넣지 않았습니다.
ACTUAL ERROR DOM
요약 한 곳 + 실제 오류 필드의 연결
role=alert 요약은 전체 오류 수를 보여 주고 객체 오류 문구는 한 번 표시됩니다. title/date는 자신의 오류 ID로 aria-describedby를 연결합니다. tabindex=-1은 초점을 받을 수 있게 할 뿐, 이 fixture에 자동 초점 이동 JavaScript는 없습니다.
VALID POST / TEST SCOPE
오류 없을 때만 typed command를 한 번 전달
정상 POST는 strip한 제목, 내용, ISO 날짜, publicVisible로 CreatePostCommand를 만들고 서비스를 1회 호출한 뒤 collection URI로 redirect합니다. suite는 이 redirect와 정확한 command를 검사하며 목적지 GET 화면까지 구현·검사하지 않습니다.
BindingResult는 변환과 검증을 분리하면서도 한 요청 기록으로 합친다. 이 기록을 서비스 호출 전 가드로 사용해야 실패한 제출이 상태 변경으로 이어지지 않는다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 바인더가 변환 오류를 기록하고 validator가 필드 및 객체 오류를 더합니다. handler는 hasErrors를 검사하여 오류가 있으면 같은 폼을 보여 주고, 오류가 없을 때만 command를 생성하여 서비스를 한 번 호출합니다. 공개 게시물의 관계 검증은 content 필드 오류만 확인하므로 날짜 오류와 관계 오류가 함께 존재할 수 있습니다.
- 기존 6노드 5관계의 모바일 배치다. 바인더 변환 오류와 검증기의 필드/객체 오류를 같은 BindingResult에 모은다. 인접 결과를 받는 handler가 hasErrors를 판단한다. 오류 없음은 command/서비스 1회/redirect로, 오류 있음은 같은 폼 200/서비스 0회로 분기한다. 아래 오류 상자는 앞의 정상 command 뒤에 실행하는 단계가 아니다. 억제 admin은 이 fixture에서 별도 오류로 승격하지 않는다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | BindingResult는 변환 실패와 검증 오류를 서비스 호출 전에 한 요청 기록으로 모은다 |
| 21 | 기존 6노드와 5관계의 모바일 배치다. 바인더의 변환 오류와 검증기의 필드/객체 오류를 같은 BindingResult에 모은다. 인접 결과를 받는 handler가 hasErrors를 판단한다. 오류 없음은 command/서비스 1회/redirect로, 오류 있음은 같은 폼 200/서비스 0회로 분기한다. 아래 오류 상자는 앞의 정상 command 뒤에 실행하는 단계가 아니다. 억제 admin은 이 fixture에서 별도 오류로 승격하지 않는다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 변환 오류 수집 binder가 변환 오류를 BindingResult에 기록합니다. | 기록: 변환 오류 · 서비스: 아직 0 | 원 문자열의 실패가 요청 기록에 남습니다. |
| 검증 오류 합류 validator가 필드·객체 오류를 같은 기록에 더합니다. | 기록: 필드·객체 오류 · 날짜·관계: 공존 가능 | 서로 다른 오류가 한 요청 결과로 모입니다. |
| hasErrors 분기 오류는 같은 폼이고 정상만 command·service 한 번입니다. | 오류: 폼·service 0 · 정상: command·service 1 | 오류 기록이 업무 진입을 결정합니다. |
BindingResult는 변환 실패와 검증 오류를 서비스 호출 전에 한 요청 기록으로 모은다
변환 오류와 validator 오류를 BindingResult에 모은 뒤 hasErrors가 폼 또는 command/service로 갈라진다.
FLOWCHART · ERROR ACCUMULATION · SIDE-EFFECT GUARD
BindingResult의 오류 유무로 재표시와 서비스 호출을 나눈다
변환 실패 원문과 필드·객체 오류가 같은 요청 기록에 쌓입니다. 실제 validator의 조건을 구분하고, handler는 오류가 없을 때만 서비스를 호출합니다.
TYPE CONVERSION
날짜 원문과 typeMismatch를 함께 보존
publishedOn=28-08-2026은 ISO LocalDate로 변환되지 않습니다. FieldError에 typeMismatch와 rejectedValue 원문이 남으며, handler는 같은 폼을 재표시하고 서비스를 호출하지 않습니다.
FIELD ERRORS
제목 80 · 내용 720은 strip 후 code point
title의 공백은 post.title.required, 길이 초과는 post.title.length입니다. content도 required/length를 구분합니다. 이 validator는 strip 후 code point를 세므로 emoji 한 개를 UTF-16 두 단위로 세지 않습니다.
OBJECT ERROR / PRECONDITION
공개·짧은 내용의 관계 오류는 content 기준
publicVisible=true이고 content가 25 code point 미만이면 post.publicVisible.minimum을 객체 오류로 더합니다. content 필드 오류가 있을 때만 이 관계 검증을 생략합니다. 날짜 변환 실패는 이 조건을 막지 않아 날짜 오류와 객체 오류가 함께 존재합니다.
HANDLER / SUPPRESSED FIELDS
오류 유무와 억제된 필드는 다른 기록
@Validated 폼 바로 뒤의 BindingResult를 handler가 검사합니다. 실제 조건은 hasErrors()이며 “기록이 비었는가”가 아닙니다. admin은 allowedFields에서 억제되지만 이 fixture는 suppressedFields 자체를 오류로 바꾸는 별도 코드를 넣지 않았습니다.
ACTUAL ERROR DOM
요약 한 곳 + 실제 오류 필드의 연결
role=alert 요약은 전체 오류 수를 보여 주고 객체 오류 문구는 한 번 표시됩니다. title/date는 자신의 오류 ID로 aria-describedby를 연결합니다. tabindex=-1은 초점을 받을 수 있게 할 뿐, 이 fixture에 자동 초점 이동 JavaScript는 없습니다.
VALID POST / TEST SCOPE
오류 없을 때만 typed command를 한 번 전달
정상 POST는 strip한 제목, 내용, ISO 날짜, publicVisible로 CreatePostCommand를 만들고 서비스를 1회 호출한 뒤 collection URI로 redirect합니다. suite는 이 redirect와 정확한 command를 검사하며 목적지 GET 화면까지 구현·검사하지 않습니다.
BindingResult는 변환과 검증을 분리하면서도 한 요청 기록으로 합친다. 이 기록을 서비스 호출 전 가드로 사용해야 실패한 제출이 상태 변경으로 이어지지 않는다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 바인더가 변환 오류를 기록하고 validator가 필드 및 객체 오류를 더합니다. handler는 hasErrors를 검사하여 오류가 있으면 같은 폼을 보여 주고, 오류가 없을 때만 command를 생성하여 서비스를 한 번 호출합니다. 공개 게시물의 관계 검증은 content 필드 오류만 확인하므로 날짜 오류와 관계 오류가 함께 존재할 수 있습니다.
- 기존 6노드 5관계의 모바일 배치다. 바인더 변환 오류와 검증기의 필드/객체 오류를 같은 BindingResult에 모은다. 인접 결과를 받는 handler가 hasErrors를 판단한다. 오류 없음은 command/서비스 1회/redirect로, 오류 있음은 같은 폼 200/서비스 0회로 분기한다. 아래 오류 상자는 앞의 정상 command 뒤에 실행하는 단계가 아니다. 억제 admin은 이 fixture에서 별도 오류로 승격하지 않는다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | BindingResult는 변환 실패와 검증 오류를 서비스 호출 전에 한 요청 기록으로 모은다 |
| 21 | 기존 6노드와 5관계의 모바일 배치다. 바인더의 변환 오류와 검증기의 필드/객체 오류를 같은 BindingResult에 모은다. 인접 결과를 받는 handler가 hasErrors를 판단한다. 오류 없음은 command/서비스 1회/redirect로, 오류 있음은 같은 폼 200/서비스 0회로 분기한다. 아래 오류 상자는 앞의 정상 command 뒤에 실행하는 단계가 아니다. 억제 admin은 이 fixture에서 별도 오류로 승격하지 않는다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 변환 오류 수집 binder가 변환 오류를 BindingResult에 기록합니다. | 기록: 변환 오류 · 서비스: 아직 0 | 원 문자열의 실패가 요청 기록에 남습니다. |
| 검증 오류 합류 validator가 필드·객체 오류를 같은 기록에 더합니다. | 기록: 필드·객체 오류 · 날짜·관계: 공존 가능 | 서로 다른 오류가 한 요청 결과로 모입니다. |
| hasErrors 분기 오류는 같은 폼이고 정상만 command·service 한 번입니다. | 오류: 폼·service 0 · 정상: command·service 1 | 오류 기록이 업무 진입을 결정합니다. |
BindingResult 위치
Spring MVC는 핸들러 인자를 왼쪽부터 해석합니다.
@ModelAttribute CreatePostForm form을 만든 직후의 BindingResult가 그 폼에 연결됩니다.
둘 사이에 다른 모델 속성이나 일반 인자를 놓으면 연결이 끊어집니다.
package board.bindingerrors;
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;
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;
}
}package board.bindingerrors;
import java.time.LocalDate;
public record CreatePostCommand(
String title,
String content,
LocalDate publishedOn,
boolean publicVisible
) {}package board.bindingerrors;
public interface PostApplicationService {
void register(CreatePostCommand command);
}package board.bindingerrors;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
@Controller
public final class ValidatedPostController {
private final PostApplicationService service;
private final CreatePostFormValidator validator;
public ValidatedPostController(
PostApplicationService service,
CreatePostFormValidator validator
) {
this.service = service;
this.validator = validator;
}
@InitBinder("form")
void configureFormBinder(WebDataBinder binder) {
binder.setAllowedFields(
"title", "content", "publishedOn", "publicVisible");
binder.addValidators(validator);
}
@PostMapping("/b88/binding-errors/posts")
String register(
@Validated @ModelAttribute("form") CreatePostForm form,
BindingResult result
) {
if (result.hasErrors()) {
return "binding-errors/new";
}
service.register(new CreatePostCommand(
form.getTitle().strip(),
form.getContent(),
form.getPublishedOn(),
form.isPublicVisible()));
return "redirect:/b88/binding-errors/posts";
}
}@Validated가 @InitBinder에 등록한 검증기를 실행하고, 그 결과는 폼 바로 뒤의 BindingResult에 누적됩니다.
컨트롤러는 오류를 모두 확인한 뒤에만 애플리케이션 서비스를 호출합니다.
FieldError
rejectValue("content", "post.content.length", args, default)는 필드 이름, 코드, 인자, 대체 문구를 기록합니다.
Spring은 객체 이름과 필드 타입을 조합해 더 구체적인 메시지 코드 후보도 만듭니다.
화면은 그 후보를 MessageSource로 해석합니다.
package board.bindingerrors;
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",
"post.title.required",
"제목을 입력하세요.");
} else if (characterCount(form.getTitle()) > 80) {
errors.rejectValue(
"title",
"post.title.length",
new Object[]{80},
"제목은 80자 이하여야 합니다.");
}
if (!errors.hasFieldErrors("content")) {
String content = form.getContent();
if (content == null || content.isBlank()) {
errors.rejectValue(
"content",
"post.content.required",
"본문을 입력하세요.");
} else if (characterCount(content) > 720) {
errors.rejectValue(
"content",
"post.content.length",
new Object[]{720},
"본문은 720자 이하여야 합니다.");
}
}
if (!errors.hasFieldErrors("content")
&& form.getContent() != null
&& form.isPublicVisible()
&& characterCount(form.getContent()) < 25) {
errors.reject(
"post.publicVisible.minimum",
new Object[]{25},
"공개 게시글의 본문은 25자 이상이어야 합니다.");
}
}
private int characterCount(String value) {
String stripped = value.strip();
return stripped.codePointCount(0, stripped.length());
}
}길이 규칙의 “자”는 String.length()의 UTF-16 코드 단위가 아니라 앞뒤 공백을 제거한 Unicode code point 수입니다.
보조 평면 문자 하나는 1로 세지만 여러 code point로 구성된 grapheme cluster 수와는 다릅니다.
supports가 false인 검증기를 바인더에 연결하면 대상 타입 계약이 깨집니다.
기반 클래스를 허용할지 정확한 클래스만 허용할지 의도적으로 정합니다.
검증기가 리포지토리를 조회하기 시작하면 화면 검증이 느려지고 트랜잭션 경계가 모호해지므로 형식·단일 객체 관계까지만 맡기고 중복·소유권은 서비스로 넘깁니다.
ObjectError
“공개 게시글의 본문은 25자 이상” 규칙은 publicVisible과 content 두 값을 함께 봅니다.
어느 한 필드만 잘못됐다고 단정하기 어렵다면 reject로 객체 오류를 만듭니다.
템플릿에서는 폼 상단 요약 영역과 관련 필드의 설명을 함께 제공합니다.
필드 오류만 출력하면 화면 낭독기 사용자가 제출 후 무엇이 실패했는지 찾기 어렵습니다.
상단에는 오류 수와 요약을, 각 입력 옆에는 구체 문구를 둡니다.
같은 문구를 두 번 읽히지 않게 aria-describedby와 실시간 알림 영역 정책을 확인합니다.
<!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="@{/b88/binding-errors/posts}"
th:object="${form}" method="post"
th:attr="aria-describedby=${#fields.hasGlobalErrors()} ? 'global-error-detail' : null">
<section id="error-summary"
th:if="${#fields.hasAnyErrors()}"
role="alert"
aria-labelledby="error-summary-title"
aria-describedby="error-summary-count"
tabindex="-1">
<h2 id="error-summary-title">입력 내용을 확인하세요</h2>
<p id="error-summary-count"
th:text="|${#fields.allErrors().size()}개의 오류가 있습니다.|">
오류가 있습니다.
</p>
<ul id="global-error-detail"
th:if="${#fields.hasGlobalErrors()}">
<li th:each="error : ${#fields.globalErrors()}"
th:text="${error}">오류 내용</li>
</ul>
</section>
<label for="title">제목</label>
<input type="text" th:field="*{title}"
th:attr="aria-invalid=${#fields.hasErrors('title')},aria-describedby=${#fields.hasErrors('title')} ? 'title-error' : null" />
<p id="title-error" th:if="${#fields.hasErrors('title')}"
th:errors="*{title}">제목 오류</p>
<label for="content">본문</label>
<textarea th:field="*{content}"
th:attr="aria-invalid=${#fields.hasErrors('content')},aria-describedby=${#fields.hasErrors('content')} ? 'content-error' : null"></textarea>
<p id="content-error" th:if="${#fields.hasErrors('content')}"
th:errors="*{content}">본문 오류</p>
<label for="publishedOn">게시일</label>
<input type="date" th:field="*{publishedOn}"
th:attr="aria-invalid=${#fields.hasErrors('publishedOn')},aria-describedby=${#fields.hasErrors('publishedOn')} ? 'publishedOn-error' : null" />
<p id="publishedOn-error"
th:if="${#fields.hasErrors('publishedOn')}"
th:errors="*{publishedOn}">게시일 오류</p>
<label>
<input type="checkbox" th:field="*{publicVisible}" />
다른 회원에게 공개
</label>
<button type="submit">등록</button>
</form>
</main>
</body>
</html>객체 오류를 필드 오류로 억지로 복제하지 않습니다.
두 필드 아래 같은 문구를 반복하면 사용자가 무엇을 바꿔야 하는지 오히려 흐려집니다.
필요한 경우 객체 오류 코드를 보고 컨트롤러가 관련 필드 ID 목록을 뷰 모델에 제공할 수 있습니다.
이 예제는 새 응답의 role="alert"가 요약을 한 번 알리게 하고 스크립트로 포커스를 자동 이동하지 않습니다. tabindex="-1"과 #error-summary는 사용자가 요청한 포커스 이동을 나중에 추가할 수 있는 결정적 대상일 뿐입니다.
클라이언트 보강에서 요약으로 포커스를 옮긴다면 같은 영역의 실시간 알림을 함께 쓰지 않아 중복 낭독을 피합니다. 객체 오류는 form[aria-describedby="global-error-detail"]로 폼 전체에 한 번 연결하고, 필드 오류는 각 입력 옆에서만 읽습니다.
바인딩·검증 결과
package board.bindingerrors;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.beans.MutablePropertyValues;
import org.springframework.format.support.DefaultFormattingConversionService;
import org.springframework.validation.DataBinder;
class CreatePostFormValidatorTest {
@Test
void 제목과_본문_field_error를_함께_보존한다() {
var form = new CreatePostForm();
var binder = binderFor(form);
var values = new MutablePropertyValues();
values.add("title", " ");
values.add("content", "a".repeat(721));
values.add("publishedOn", "2026-08-28");
binder.bind(values);
binder.validate();
var result = binder.getBindingResult();
assertThat(result.getFieldError("content").getCode())
.isEqualTo("post.content.length");
assertThat(result.getFieldError("title").getCode())
.isEqualTo("post.title.required");
assertThat(result.getErrorCount()).isEqualTo(2);
}
@Test
void 공개_본문의_최소_길이는_object_error로_남는다() {
var form = new CreatePostForm();
var binder = binderFor(form);
var values = new MutablePropertyValues();
values.add("title", "Spring MVC");
values.add("content", "짧지만 비어 있지 않은 본문");
values.add("publicVisible", "true");
binder.bind(values);
binder.validate();
var result = binder.getBindingResult();
assertThat(result.hasFieldErrors()).isFalse();
assertThat(result.getGlobalError().getCode())
.isEqualTo("post.publicVisible.minimum");
}
@Test
void 날짜_conversion_실패는_원문과_typeMismatch를_보존한다() {
var form = new CreatePostForm();
var binder = binderFor(form);
var values = new MutablePropertyValues();
values.add("title", "Spring MVC");
values.add("content", "공개하지 않는 정상 길이의 게시글 본문입니다.");
values.add("publishedOn", "날짜아님");
binder.bind(values);
binder.validate();
var error = binder.getBindingResult()
.getFieldError("publishedOn");
assertThat(error.getCode()).isEqualTo("typeMismatch");
assertThat(error.getRejectedValue()).isEqualTo("날짜아님");
}
@Test
void 길이_규칙은_보조_평면_문자를_code_point_하나로_센다() {
var accepted = new CreatePostForm();
accepted.setTitle("😀".repeat(80));
accepted.setContent("😀".repeat(25));
accepted.setPublicVisible(true);
var acceptedBinder = binderFor(accepted);
acceptedBinder.validate();
assertThat(acceptedBinder.getBindingResult().hasErrors()).isFalse();
var rejected = new CreatePostForm();
rejected.setTitle("😀".repeat(81));
rejected.setContent("정상 본문");
var rejectedBinder = binderFor(rejected);
rejectedBinder.validate();
assertThat(rejectedBinder.getBindingResult()
.getFieldError("title").getCode())
.isEqualTo("post.title.length");
}
@Test
void field_error가_있으면_object_error를_중복하지_않는다() {
var form = new CreatePostForm();
form.setContent(" ");
form.setPublicVisible(true);
var binder = binderFor(form);
binder.validate();
var result = binder.getBindingResult();
assertThat(result.getFieldError("content").getCode())
.isEqualTo("post.content.required");
assertThat(result.hasGlobalErrors()).isFalse();
}
@Test
void validator는_form_type만_지원한다() {
var validator = new CreatePostFormValidator();
assertThat(validator.supports(CreatePostForm.class)).isTrue();
assertThat(validator.supports(String.class)).isFalse();
}
private DataBinder binderFor(CreatePostForm form) {
var binder = new DataBinder(form, "form");
binder.setConversionService(new DefaultFormattingConversionService());
binder.addValidators(new CreatePostFormValidator());
return binder;
}
}package board.bindingerrors;
import java.util.ArrayList;
import java.util.List;
final class MvcRecordingPostApplicationService
implements PostApplicationService {
private final List<CreatePostCommand> commands = new ArrayList<>();
@Override
public void register(CreatePostCommand command) {
commands.add(command);
}
List<CreatePostCommand> commands() {
return List.copyOf(commands);
}
void reset() {
commands.clear();
}
}package board.bindingerrors;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
@SpringBootConfiguration(proxyBeanMethods = false)
@EnableAutoConfiguration
@Import({
ValidatedPostController.class,
CreatePostFormValidator.class
})
public class BindingErrorsTestApplication {
@Bean
MvcRecordingPostApplicationService postApplicationService() {
return new MvcRecordingPostApplicationService();
}
}package board.bindingerrors;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
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 org.jsoup.Jsoup;
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.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.validation.BindingResult;
@SpringBootTest(classes = BindingErrorsTestApplication.class)
@AutoConfigureMockMvc
class ValidatedPostMvcTest {
private static final String ROUTE = "/b88/binding-errors/posts";
@Autowired
private MockMvc mockMvc;
@Autowired
private MvcRecordingPostApplicationService service;
@BeforeEach
void resetService() {
service.reset();
}
@Test
void 실제_MVC는_binder_validation_오류를_렌더하고_service를_건너뛴다()
throws Exception {
var result = mockMvc.perform(post(ROUTE)
.param("title", " ")
.param("content", "짧은 본문")
.param("publishedOn", "28-08-2026")
.param("publicVisible", "true")
.param("admin", "true"))
.andExpect(status().isOk())
.andExpect(view().name("binding-errors/new"))
.andReturn();
var modelAndView = result.getModelAndView();
assertThat(modelAndView).isNotNull();
var binding = (BindingResult) modelAndView.getModel().get(
BindingResult.MODEL_KEY_PREFIX + "form");
var document = Jsoup.parse(
result.getResponse().getContentAsString(UTF_8));
assertThat(binding).isNotNull();
assertThat(binding.getFieldError("title").getCode())
.isEqualTo("post.title.required");
assertThat(binding.getFieldError("publishedOn").getCode())
.isEqualTo("typeMismatch");
assertThat(binding.getGlobalError().getCode())
.isEqualTo("post.publicVisible.minimum");
assertThat(binding.getSuppressedFields()).containsExactly("admin");
assertThat(service.commands()).isEmpty();
assertThat(document.select(
"#error-summary[role=alert][tabindex=-1]"
+ "[aria-describedby=error-summary-count]"))
.hasSize(1);
assertThat(document.select(
"form[aria-describedby=global-error-detail]"))
.hasSize(1);
assertThat(document.select("#global-error-detail li").eachText())
.containsExactly(
"공개 게시글의 본문은 25자 이상이어야 합니다.");
assertThat(document.select("#title-error")).hasSize(1);
assertThat(document.select("#publishedOn-error")).hasSize(1);
assertThat(document.select(
"#publishedOn[value=28-08-2026]"))
.hasSize(1);
}
@Test
void ISO_날짜와_code_point_최소_길이는_검증된_command로_전달된다()
throws Exception {
String content = "😀".repeat(25);
mockMvc.perform(post(ROUTE)
.param("title", " Spring MVC ")
.param("content", content)
.param("publishedOn", "2026-08-28")
.param("publicVisible", "true"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl(ROUTE));
assertThat(service.commands()).containsExactly(new CreatePostCommand(
"Spring MVC",
content,
java.time.LocalDate.of(2026, 8, 28),
true));
}
}예시
원 복합 실패 요청을 순서대로 따라가면 날짜 바인딩에서 typeMismatch 하나, 검증기에서 빈 제목의 필드 오류 하나, 공개·짧은 본문의 객체 오류 하나가 쌓입니다. 짧은 본문은 앞뒤 공백을 뺀 code point 5개로, content의 required·최대 길이 오류는 아닙니다. 따라서 content 필드 오류를 확인하는 관계 검증 가드를 통과하고 객체 오류가 추가됩니다. 날짜 오류는 이 가드의 검사 대상이 아닙니다.
오류 수는 3입니다. admin은 억제된 필드 기록으로 남지만 현재 핸들러가 이를 별도 오류로 바꾸지 않아 4번째 오류가 되지 않습니다. 인접한 BindingResult가 있으므로 핸들러는 진입하고, hasErrors()가 참이어서 같은 폼 200을 반환합니다. 서비스 호출은 0회입니다. “200”은 저장 성공 여부를 대신하는 판단 기준이 아닙니다.
날짜 원문은 오류 객체와 응답 HTML의 value 속성에 남는다는 것을 원 Jsoup 검사가 확인합니다. 이것이 브라우저의 날짜 입력창에 잘못된 원문이 그대로 보인다는 검증은 아닙니다. HTML 날짜 입력 규칙은 유효한 날짜 문자열이 아닌 제어의 값을 비우도록 정합니다. 원문을 사용자에게 알려야 하는 제품이라면 안전한 오류 설명 영역의 재표시 계약과 실제 브라우저 접근성 검사를 별도로 설계하세요.
단위 테스트는 변환 서비스와 검증기를 실제 순서로 실행하고, MVC suite는 @InitBinder, @Validated, 인접한 BindingResult, ISO 날짜 변환, 서비스 차단, Thymeleaf 재렌더링을 한 요청 경로에서 확인합니다.
검증기만 직접 호출하는 단위 테스트도 빠르지만 거부된 값과 타입 불일치를 재현하지 못합니다.
중요한 바인딩 계약은 DataBinder 또는 MockMvc 경로로 보강합니다.
사용자·운영 오류 정보 분리
사용자에게 스택 트레이스나 Java 타입 이름을 보여 주지 않습니다.
typeMismatch.java.lang.Integer 같은 내부 후보는 메시지 해결에 쓰되 최종 문구는 “자 단위 숫자를 입력하세요”처럼 수정 행동을 설명합니다.
운영 로그에는 요청 ID, 객체 이름, 필드, 최종 코드를 남기고 비밀번호나 토큰의 거부된 값은 절대 기록하지 않습니다.
| 속성 | 사용자 화면 | 운영 관찰 |
|---|---|---|
| 필드 이름 | 사람이 읽는 레이블 | 속성 경로 |
| 거부된 값 | 안전한 일반 입력만 재표시 | 민감도에 따라 마스킹 |
| 메시지 코드 | 로케일 문장으로 해석 | 안정적인 코드 집계 |
| 예외 | 노출하지 않음 | 원인과 요청 ID |
BindingResult에 오류가 있을 때 서비스를 호출하지 않는 것도 중요한 계약입니다.
서비스가 일부 상태를 바꾼 뒤 폼으로 돌아오면 재제출에서 중복이 생깁니다.
컨트롤러 검증은 부수 효과 앞에 두고, 서비스 내부 불변식과 데이터베이스 제약 조건은 트랜잭션으로 원자성을 보장합니다.
연습 문제
게시글의 시작 시각과 종료 시각을 입력받아 종료가 시작보다 늦은지 검증하세요.
한 필드가 날짜·시간 형식 변환에 실패한 경우 관계 검증을 건너뛰고, 두 값이 모두 유효하지만 순서가 잘못된 경우 객체 오류 하나만 만드세요.
화면 상단과 두 입력의 접근성 연결도 테스트합니다.
해설 보기
먼저 errors.hasFieldErrors("startedAt")와 errors.hasFieldErrors("endedAt")를 확인합니다.
두 값이 존재할 때만 비교하고, 관계 실패는 안정적인 코드와 두 필드 레이블을 인자로 넣습니다.
검증 순서는 startedAt·endedAt 필드 오류 확인 → 두 값의 null 확인 → endedAt.isAfter(startedAt) 비교입니다. 마지막 비교가 실패할 때만 errors.reject("post.time.order", new Object[]{"시작 시각", "종료 시각"}, ...)로 객체 오류 하나를 기록합니다.
테스트 입력은 정상 순서, 같은 시각, 역순, 시작 변환 실패, 종료 누락을 나눕니다.
관계 검증이 변환 오류를 덮지 않는지 오류 코드와 개수를 함께 확인합니다.
독립 확인
각 요청은 원 MVC fixture의 서비스 기록을 비운 새 상태에서 시작합니다. 모두 publicVisible=true와 admin=true를 전송합니다. A는 제목 Spring, 본문 😀 24개, 유효한 ISO 날짜입니다. B는 제목·본문이 공백뿐이고 날짜가 날짜아님입니다. C는 제목 Spring, 본문이 공백 한 개+😀 25개+공백 한 개, 유효한 ISO 날짜입니다.
각 요청의 필드 오류 수·객체 오류 수, 억제된 필드와 hasErrors(), 서비스 호출 횟수, 같은 화면 또는 redirect를 정하세요. C가 전달한 command의 제목과 본문에는 어떤 정리가 실제로 적용됐는지도 설명하세요.
오류·억제 기록·원래 본문 값을 나누는 풀이
A는 필드 오류 0개·객체 오류 1개입니다. 이 정책의 본문 길이는 code point 24개라 공개 최소 25개를 충족하지 않습니다. UTF-16 길이 48을 비교하지 않습니다. admin은 억제 기록에 남고 hasErrors()는 참이므로 서비스 0회·같은 폼 200입니다.
B는 title.required, content.required, 날짜 typeMismatch로 필드 오류 3개입니다. content의 필드 오류가 있으므로 공개 최소 길이 객체 오류는 더하지 않습니다. 억제된 admin 자체도 오류를 더하지 않습니다. hasErrors()는 참이고 서비스 0회·같은 폼 200입니다.
C는 strip 후 code point 25개로 길이 검사를 통과해 필드·객체 오류가 없습니다. admin은 여전히 억제되지만 이 fixture가 억제를 오류로 승격하지 않아 서비스 1회·원 collection 경로로 redirect입니다. command의 제목은 Spring으로 strip하고, 본문은 원 getContent() 값 그대로 전달합니다. 본문에는 양쪽 공백이 남아 code point 27개입니다. 길이를 검사할 때 임시로 strip한 것과 저장할 값 자체를 정규화한 것은 다릅니다. 무시된 admin 입력이 권한을 부여한 것도 아닙니다.
이 답은 기존 바인더·검증기·핸들러의 순서에서 도출한 별도 입력의 예상입니다. 원 테스트의 검증 범위와 새 입력을 실제 MVC에서 실행한 기록을 구분합니다.
다음 문서에서는 하나의 FieldError에 여러 메시지 코드 후보가 생기는 이유와 가장 구체적인 문구에서 일반 문구로 내려가는 규칙을 다룹니다.