바인딩 오류 처리
게시판 등록 요청의 필드·객체 오류, 거부된 값, 인자 순서와 검증기 실행을 추적하고 실패한 폼을 안전하게 재표시합니다.
BindingResult는 “오류가 있다”는 불리언 하나가 아닙니다.
요청 문자열을 객체에 옮긴 바인딩 기록, 변환하지 못한 원문, 검증기가 만든 필드·객체 오류, 메시지 코드와 인자가 한 요청 단위로 들어 있습니다.
이 구조를 이해하면 컨트롤러에서 임의의 오류 문자열을 조립하지 않고도 사용자가 무엇을 고쳐야 하는지 정확히 보여 줄 수 있습니다.
FLOWCHART · ERROR ACCUMULATION · SIDE-EFFECT GUARD
BindingResult는 변환 실패와 검증 오류를 서비스 호출 전에 한 요청 기록으로 모은다
WebDataBinder는 요청 문자열과 변환 실패 원문을 기록하고, 검증기는 그 기록을 보며 필드·객체 오류를 더한다. 컨트롤러는 최종 BindingResult가 비어 있을 때만 서비스를 호출한다.
-
요청 바인딩
허용 필드만 옮기고 문자열을 프로퍼티 타입으로 변환한다.
-
변환 실패 보존
typeMismatch와 거부된 원문을 FieldError에 기록한다. -
검증 오류 누적
기존 필드 오류를 확인하며 FieldError와 ObjectError를 같은 BindingResult에 더한다.
-
오류 흐름 결정
오류가 있으면 요약과 필드 문구가 있는 폼을 다시 보여 준다.
-
부수 효과 보호
BindingResult가 비어 있을 때만 명령을 만들고 서비스를 호출한다.
- 바인딩·누적 흐름
- 오류 경로
- 서비스 경로
BindingResult는 변환과 검증을 분리하면서도 한 요청 기록으로 합친다. 이 기록을 서비스 호출 전 가드로 사용해야 실패한 제출이 상태 변경으로 이어지지 않는다.
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));
}
}단위 테스트는 변환 서비스와 검증기를 실제 순서로 실행하고, 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[]{"시작 시각", "종료 시각"}, ...)로 객체 오류 하나를 기록합니다.
테스트 입력은 정상 순서, 같은 시각, 역순, 시작 변환 실패, 종료 누락을 나눕니다.
관계 검증이 변환 오류를 덮지 않는지 오류 코드와 개수를 함께 확인합니다.
다음 문서에서는 하나의 FieldError에 여러 메시지 코드 후보가 생기는 이유와 가장 구체적인 문구에서 일반 문구로 내려가는 규칙을 다룹니다.