본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
7장 : View·폼·메시지·검증

바인딩 오류 처리

게시판 등록 요청의 필드·객체 오류, 거부된 값, 인자 순서와 검증기 실행을 추적하고 실패한 폼을 안전하게 재표시합니다.

BindingResult는 “오류가 있다”는 불리언 하나가 아닙니다. 요청 문자열을 객체에 옮긴 바인딩 기록, 변환하지 못한 원문, 검증기가 만든 필드·객체 오류, 메시지 코드와 인자가 한 요청 단위로 들어 있습니다. 이 구조를 이해하면 컨트롤러에서 임의의 오류 문자열을 조립하지 않고도 사용자가 무엇을 고쳐야 하는지 정확히 보여 줄 수 있습니다.


BindingResult 위치

Spring MVC는 핸들러 인자를 왼쪽부터 해석합니다. @ModelAttribute CreatePostForm form을 만든 직후의 BindingResult가 그 폼에 연결됩니다. 둘 사이에 다른 모델 속성나 일반 인자를 놓으면 연결이 끊어집니다.

src/main/java/board/web/ValidatedPostController.java
package board.web;

import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;

@Controller
public final class ValidatedPostController {
    private final PostApplicationService service;

    public ValidatedPostController(PostApplicationService service) {
        this.service = service;
    }

    @PostMapping("/posts/validated")
    String register(
            @ModelAttribute("form") CreatePostForm form,
            BindingResult result
    ) {
        validateRelation(form, result);
        if (result.hasErrors()) {
            return "posts/new";
        }
        service.register(new CreatePostCommand(
                form.getTitle().strip(),
                form.getContent(),
                form.getPublishedOn()));
        return "redirect:/posts";
    }

    private void validateRelation(
            CreatePostForm form,
            BindingResult result
    ) {
        if (result.hasFieldErrors("content")
                || form.getContent() == null) {
            return;
        }
        if (form.isPublicVisible() && form.getContent().strip().length() < 25) {
            result.reject(
                    "post.publicVisible.minimum",
                    new Object[]{25},
                    "공개 게시글의 본문은 25자 이상이어야 합니다.");
        }
    }
}

관계 검증 전에 content의 필드 오류와 null을 확인합니다. 빈 본문이나 길이 초과 오류가 이미 있다면 관계 검증이 같은 입력에 중복 오류를 만들지 않게 건너뜁니다.

FieldError

rejectValue("content", "post.content.length", args, default)는 필드 이름, 코드, 인자, 대체 문구를 기록합니다. Spring은 객체 이름과 필드 타입을 조합해 더 구체적인 메시지 코드 후보도 만듭니다. 화면은 그 후보를 MessageSource로 해석합니다.

src/main/java/board/web/CreatePostFormValidator.java
package board.web;

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 (form.getTitle().strip().length() > 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 (content.strip().length() > 720) {
                errors.rejectValue(
                        "content",
                        "post.content.length",
                        new Object[]{720},
                        "본문은 720자 이하여야 합니다.");
            }
        }
    }
}

supportsfalse인 검증기를 바인더에 연결하면 대상 타입 계약이 깨집니다. 기반 클래스를 허용할지 정확한 클래스만 허용할지 의도적으로 정합니다. 검증기가 리포지토리를 조회하기 시작하면 화면 검증이 느려지고 트랜잭션 경계가 모호해지므로 형식·단일 객체 관계까지만 맡기고 중복·소유권은 서비스로 넘깁니다.

ObjectError

“공개 게시글의 본문은 25자 이상” 규칙은 publicVisiblecontent 두 값을 함께 봅니다. 어느 한 필드만 잘못됐다고 단정하기 어렵다면 reject로 객체 오류를 만듭니다. 템플릿에서는 폼 상단 요약 영역과 관련 필드의 설명을 함께 제공합니다.

필드 오류만 출력하면 화면 낭독기 사용자가 제출 후 무엇이 실패했는지 찾기 어렵습니다. 상단에는 오류 수와 앵커 링크를, 각 입력 옆에는 구체 문구를 둡니다. 같은 문구를 두 번 읽히지 않게 aria-describedby와 실시간 알림 영역 정책을 확인합니다.

src/main/resources/templates/posts/new-errors.html
<section xmlns:th="http://www.thymeleaf.org"
         th:if="${#fields.hasAnyErrors()}"
         aria-labelledby="error-summary-title">
  <h2 id="error-summary-title">입력 내용을 확인하세요</h2>
  <ul>
    <li th:each="error : ${#fields.allErrors()}"
        th:text="${error}">오류 내용</li>
  </ul>
</section>

<div xmlns:th="http://www.thymeleaf.org">
  <label for="content">본문</label>
  <textarea th:field="*{content}" aria-describedby="content-error"></textarea>
  <p id="content-error" th:errors="*{content}">본문 오류</p>
</div>

객체 오류를 필드 오류로 억지로 복제하지 않습니다. 두 필드 아래 같은 문구를 반복하면 사용자가 무엇을 바꿔야 하는지 오히려 흐려집니다. 필요한 경우 객체 오류 코드를 보고 컨트롤러가 관련 필드 ID 목록을 뷰 모델에 제공할 수 있습니다.

바인딩·검증 결과

src/test/java/board/web/CreatePostFormValidatorTest.java
package board.web;

import static org.assertj.core.api.Assertions.assertThat;

import java.time.LocalDate;

import org.junit.jupiter.api.Test;
import org.springframework.beans.MutablePropertyValues;
import org.springframework.validation.DataBinder;

class CreatePostFormValidatorTest {
    @Test
    void 제목과_본문_field_error를_함께_보존한다() {
        var form = new CreatePostForm();
        var binder = new DataBinder(form, "form");
        binder.addValidators(new CreatePostFormValidator());
        var values = new MutablePropertyValues();
        values.add("title", "   ");
        values.add("content", "a".repeat(721));
        values.add("publishedOn", LocalDate.now().toString());

        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);
    }
}
실행 결과
CreatePostFormValidatorTest
  > 제목과_본문_field_error를_함께_보존한다() PASSED
content.code = post.content.length
title.code = post.title.required
errorCount = 2

이 테스트는 변환 서비스와 검증기를 실제 순서로 실행합니다. 검증기만 직접 호출하는 단위 테스트도 빠르지만 거부된 값과 타입 불일치를 재현하지 못합니다. 중요한 바인딩 계약은 DataBinder 또는 MockMvc 경로로 보강합니다.

사용자·운영 오류 정보 분리

사용자에게 스택 트레이스나 Java 타입 이름을 보여 주지 않습니다. typeMismatch.java.lang.Integer 같은 내부 후보는 메시지 해결에 쓰되 최종 문구는 “자 단위 숫자를 입력하세요”처럼 수정 행동을 설명합니다. 운영 로그에는 요청 ID, 객체 이름, 필드, 최종 코드를 남기고 비밀번호나 토큰의 거부된 값은 절대 기록하지 않습니다.

속성사용자 화면운영 관찰
필드 이름사람이 읽는 레이블속성 경로
거부된 값안전한 일반 입력만 재표시민감도에 따라 마스킹
메시지 코드로케일 문장으로 해석안정적인 코드 집계
예외노출하지 않음원인과 요청 ID

BindingResult에 오류가 있을 때 서비스를 호출하지 않는 것도 중요한 계약입니다. 서비스가 일부 상태를 바꾼 뒤 폼으로 돌아오면 재제출에서 중복이 생깁니다. 컨트롤러 검증은 부수 효과 앞에 두고, 서비스 내부 불변식과 데이터베이스 제약 조건은 트랜잭션으로 원자성을 보장합니다.

연습 문제

게시글의 시작 시각과 종료 시각을 입력받아 종료가 시작보다 늦은지 검증하세요. 한 필드가 날짜·시간 형식 변환에 실패한 경우 관계 검증을 건너뛰고, 두 값이 모두 유효하지만 순서가 잘못된 경우 객체 오류 하나만 만드세요. 화면 상단과 두 입력의 접근성 연결도 테스트합니다.

해설 보기

먼저 errors.hasFieldErrors("startedAt")errors.hasFieldErrors("endedAt")를 확인합니다. 두 값이 존재할 때만 비교하고, 관계 실패는 안정적인 코드와 두 필드 레이블을 인자로 넣습니다.

package board.web;

import org.springframework.validation.Errors;

public final class PostTimeValidator {
    public void validate(PostTimeForm form, Errors errors) {
        if (errors.hasFieldErrors("startedAt")
                || errors.hasFieldErrors("endedAt")) {
            return;
        }
        if (form.startedAt() == null || form.endedAt() == null) {
            return;
        }
        if (!form.endedAt().isAfter(form.startedAt())) {
            errors.reject(
                    "post.time.order",
                    new Object[]{"시작 시각", "종료 시각"},
                    "종료 시각은 시작 시각보다 늦어야 합니다.");
        }
    }
}

테스트 입력은 정상 순서, 같은 시각, 역순, 시작 변환 실패, 종료 누락을 나눕니다. 관계 검증이 변환 오류를 덮지 않는지 오류 코드와 개수를 함께 확인합니다.

다음 문서에서는 하나의 FieldError에 여러 메시지 코드 후보가 생기는 이유와 가장 구체적인 문구에서 일반 문구로 내려가는 규칙을 다룹니다.