본문으로 건너뛰기

안동민 개발노트

본문 시작

바인딩 오류 처리

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

BindingResult는 “오류가 있다”는 불리언 하나가 아닙니다.

요청 문자열을 객체에 옮긴 바인딩 기록, 변환하지 못한 원문, 검증기가 만든 필드·객체 오류, 메시지 코드와 인자가 한 요청 단위로 들어 있습니다.

이 구조를 이해하면 컨트롤러에서 임의의 오류 문자열을 조립하지 않고도 사용자가 무엇을 고쳐야 하는지 정확히 보여 줄 수 있습니다.

BindingResult는 입력 실패의 순서를 보존한다

사용자가 보낸 문자열부터 property 변환과 validation까지 한 대상 객체에 연결해 오류 code·argument·rejected value의 원인을 추적한다.

  1. 1
    요청 문자열

    · RAW field 이름과 사용자가 실제 보낸 값을 수집한다.

  2. 2
    Property·type

    · BIND 허용 field에 값을 넣고 conversion 실패를 기록한다.

  3. 3
    Field·object 규칙

    · VALIDATE 변환된 값 사이의 제약과 관계 오류를 추가한다.

  4. 4
    순서 있는 오류 기록

    · RESULT code 후보·argument·rejected value를 대상 객체와 함께 보존한다.

  5. 5
    재표시

    · VIEW 성공 값과 수정 가능한 오류를 같은 form에 돌려준다.


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인 검증기를 바인더에 연결하면 대상 타입 계약이 깨집니다.

기반 클래스를 허용할지 정확한 클래스만 허용할지 의도적으로 정합니다.

검증기가 리포지토리를 조회하기 시작하면 화면 검증이 느려지고 트랜잭션 경계가 모호해지므로 형식·단일 객체 관계까지만 맡기고 중복·소유권은 서비스로 넘깁니다.

FieldError와 ObjectError는 수정 범위가 다르다

하나의 입력 문제와 여러 값의 관계 문제를 같은 field에 억지로 붙이지 않는다.

오류적용 범위화면 위치
FieldError한 propertyinput 옆 안내
ObjectError여러 field 관계요약과 관련 영역
Domain error저장 상태·권한업무 알림

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 경로로 보강합니다.

conversion 실패가 있으면 관계 검증을 멈춘다

Type mismatch 뒤 null property를 비교해 새 예외를 만들지 않고 최초 원문과 type code를 유지한 채 사용자 수정 경로로 보낸다.

  1. Validator 중단

    YES null 비교를 건너뛰고 최초 conversion 오류를 그대로 유지한다.

  2. 관계 규칙 실행

    NO 변환된 두 field의 순서·범위 관계를 안전하게 검사한다.


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

사용자에게 스택 트레이스나 Java 타입 이름을 보여 주지 않습니다.

typeMismatch.java.lang.Integer 같은 내부 후보는 메시지 해결에 쓰되 최종 문구는 “자 단위 숫자를 입력하세요”처럼 수정 행동을 설명합니다.

운영 로그에는 요청 ID, 객체 이름, 필드, 최종 코드를 남기고 비밀번호나 토큰의 거부된 값은 절대 기록하지 않습니다.

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

BindingResult에 오류가 있을 때 서비스를 호출하지 않는 것도 중요한 계약입니다.

서비스가 일부 상태를 바꾼 뒤 폼으로 돌아오면 재제출에서 중복이 생깁니다.

컨트롤러 검증은 부수 효과 앞에 두고, 서비스 내부 불변식과 데이터베이스 제약 조건은 트랜잭션으로 원자성을 보장합니다.

오류 정보는 사용자와 운영 목적에 맞게 나눈다

하나의 Error record에서 공개 문구·민감 정보 제거·metric code·request trace를 목적별 소비자에게 다르게 분배한다.

  1. 수정 가능한 문구

    USER field label과 안전한 안내만 표시한다.

  2. 민감 값 제외

    SECURE LOG 비밀번호·token·본문 원문을 재표시하거나 기록하지 않는다.

  3. 안정적인 code

    METRIC 낮은 cardinality의 오류 의미만 집계한다.

  4. 예상 밖 예외

    TRACE request ID로 stack과 운영 문맥을 연결한다.


연습 문제

게시글의 시작 시각과 종료 시각을 입력받아 종료가 시작보다 늦은지 검증하세요.

한 필드가 날짜·시간 형식 변환에 실패한 경우 관계 검증을 건너뛰고, 두 값이 모두 유효하지만 순서가 잘못된 경우 객체 오류 하나만 만드세요.

화면 상단과 두 입력의 접근성 연결도 테스트합니다.

해설 보기

먼저 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에 여러 메시지 코드 후보가 생기는 이유와 가장 구체적인 문구에서 일반 문구로 내려가는 규칙을 다룹니다.