본문 바로가기

바인딩 오류 처리

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

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

BindingResult는 변환 실패와 검증 오류를 서비스 호출 전에 한 요청 기록으로 모은다

변환 오류와 validator 오류를 BindingResult에 모은 뒤 hasErrors가 폼 또는 command/service로 갈라진다.

BindingResult는 날짜 변환의 typeMismatch와 거부된 원문, 제목과 내용의 필드 오류, 공개 게시물의 객체 오류를 모읍니다. 관계 검증은 content 필드 오류만 확인하므로 날짜 오류와 함께 기록될 수 있습니다. handler는 hasErrors로 같은 폼 200과 정상 command 및 서비스 1회 호출을 나눕니다. suppressedFields는 이 fixture에서 독립 오류로 바꾸지 않습니다. 오류 요약과 필드 ID 연결은 실제 DOM 계약이며 자동 초점 이동 및 redirect 목적지 GET은 이 fixture의 검사 범위 밖입니다.

FLOWCHART · ERROR ACCUMULATION · SIDE-EFFECT GUARD

BindingResult의 오류 유무로 재표시와 서비스 호출을 나눈다

변환 실패 원문과 필드·객체 오류가 같은 요청 기록에 쌓입니다. 실제 validator의 조건을 구분하고, handler는 오류가 없을 때만 서비스를 호출합니다.

BindingResult 오류 누적과 handler 분기바인더가 변환 오류를 기록하고 validator가 필드 및 객체 오류를 더합니다. handler는 hasErrors를 검사하여 오류가 있으면 같은 폼을 보여 주고, 오류가 없을 때만 command를 생성하여 서비스를 한 번 호출합니다. 공개 게시물의 관계 검증은 content 필드 오류만 확인하므로 날짜 오류와 관계 오류가 함께 존재할 수 있습니다.WebDataBinder변환 오류와 원문Validator필드·객체 오류 추가BindingResult같은 요청의 오류인접 BindingResult를 확인handler: hasErrors()?오류 있음 · 같은 폼 200오류 요약 / 서비스 0회오류 없음 · command 생성서비스 1회 / redirect
BindingResult 오류 누적과 handler의 두 대안기존6노드5관계의모바일배치다.바인더변환오류와검증기의필드/객체오류를같은BindingResult에모은다.인접결과를받는handler가hasErrors를판단한다.오류없음은command/서비스1회/redirect로,오류있음은같은폼200/서비스0회로분기한다.아래오류상자는앞의정상command뒤에실행하는단계가아니다.억제admin은이fixture에서별도오류로승격하지않는다.오류 없음오류 있음WebDataBinder변환 오류와 원문Validator필드·객체 오류 추가BindingResult같은 요청의 오류command 생성서비스 1회redirect같은 폼 200서비스 0회오류 요약·필드 연결hasErrors()?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에서 별도 오류로 승격하지 않는다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1BindingResult는 변환 실패와 검증 오류를 서비스 호출 전에 한 요청 기록으로 모은다
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로 갈라진다.

BindingResult는 날짜 변환의 typeMismatch와 거부된 원문, 제목과 내용의 필드 오류, 공개 게시물의 객체 오류를 모읍니다. 관계 검증은 content 필드 오류만 확인하므로 날짜 오류와 함께 기록될 수 있습니다. handler는 hasErrors로 같은 폼 200과 정상 command 및 서비스 1회 호출을 나눕니다. suppressedFields는 이 fixture에서 독립 오류로 바꾸지 않습니다. 오류 요약과 필드 ID 연결은 실제 DOM 계약이며 자동 초점 이동 및 redirect 목적지 GET은 이 fixture의 검사 범위 밖입니다.

FLOWCHART · ERROR ACCUMULATION · SIDE-EFFECT GUARD

BindingResult의 오류 유무로 재표시와 서비스 호출을 나눈다

변환 실패 원문과 필드·객체 오류가 같은 요청 기록에 쌓입니다. 실제 validator의 조건을 구분하고, handler는 오류가 없을 때만 서비스를 호출합니다.

BindingResult 오류 누적과 handler 분기바인더가 변환 오류를 기록하고 validator가 필드 및 객체 오류를 더합니다. handler는 hasErrors를 검사하여 오류가 있으면 같은 폼을 보여 주고, 오류가 없을 때만 command를 생성하여 서비스를 한 번 호출합니다. 공개 게시물의 관계 검증은 content 필드 오류만 확인하므로 날짜 오류와 관계 오류가 함께 존재할 수 있습니다.WebDataBinder변환 오류와 원문Validator필드·객체 오류 추가BindingResult같은 요청의 오류인접 BindingResult를 확인handler: hasErrors()?오류 있음 · 같은 폼 200오류 요약 / 서비스 0회오류 없음 · command 생성서비스 1회 / redirect
BindingResult 오류 누적과 handler의 두 대안기존6노드5관계의모바일배치다.바인더변환오류와검증기의필드/객체오류를같은BindingResult에모은다.인접결과를받는handler가hasErrors를판단한다.오류없음은command/서비스1회/redirect로,오류있음은같은폼200/서비스0회로분기한다.아래오류상자는앞의정상command뒤에실행하는단계가아니다.억제admin은이fixture에서별도오류로승격하지않는다.오류 없음오류 있음WebDataBinder변환 오류와 원문Validator필드·객체 오류 추가BindingResult같은 요청의 오류command 생성서비스 1회redirect같은 폼 200서비스 0회오류 요약·필드 연결hasErrors()?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에서 별도 오류로 승격하지 않는다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1BindingResult는 변환 실패와 검증 오류를 서비스 호출 전에 한 요청 기록으로 모은다
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오류 기록이 업무 진입을 결정합니다.

그림은 WebDataBinder가 변환 오류와 원문을 기록하고, 검증기가 필드 오류와 객체 오류를 같은 BindingResult에 더하는 과정을 보여 줍니다. 인접한 BindingResult를 받은 핸들러가 hasErrors()로 판단해, 오류가 있으면 같은 폼을 200으로 보여 주고 서비스는 호출하지 않으며, 오류가 없을 때만 명령을 만들어 서비스를 한 번 부르고 리다이렉트합니다. 두 갈래는 하나만 선택되는 대안입니다. 공개 게시글의 관계 검증은 content의 필드 오류만 확인하므로 날짜 오류와 함께 기록될 수 있고, 억제된 admin은 이 예제에서 따로 오류로 바뀌지 않습니다.


BindingResult의 위치

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

src/main/java/board/bindingerrors/CreatePostForm.java
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;
    }
}
src/main/java/board/bindingerrors/CreatePostCommand.java
package board.bindingerrors;

import java.time.LocalDate;

public record CreatePostCommand(
        String title,
        String content,
        LocalDate publishedOn,
        boolean publicVisible
) {}
src/main/java/board/bindingerrors/PostApplicationService.java
package board.bindingerrors;

public interface PostApplicationService {
    void register(CreatePostCommand command);
}
src/main/java/board/bindingerrors/ValidatedPostController.java
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로 해석합니다.

src/main/java/board/bindingerrors/CreatePostFormValidator.java
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 코드 단위가 아니라, 앞뒤 공백을 지운 유니코드 코드 포인트의 수입니다. 보충 평면 문자 하나는 1로 세지만, 여러 코드 포인트로 된 grapheme cluster의 수와는 다릅니다.

supports가 false인 검증기를 바인더에 연결하면 대상 타입이 맞지 않게 됩니다. 상위 클래스까지 허용할지, 정확한 클래스만 허용할지는 의도적으로 정합니다. 검증기가 저장소를 조회하기 시작하면 화면 검증이 느려지고 트랜잭션의 범위가 흐려지므로, 형식과 한 객체 안의 관계까지만 맡기고 중복과 소유권은 서비스로 넘깁니다.


ObjectError

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

필드 오류만 출력하면 화면 낭독기 사용자는 제출한 뒤 무엇이 실패했는지 찾기 어렵습니다. 위쪽에는 오류 수와 요약을, 각 입력 옆에는 구체적인 문구를 두고, 같은 문구가 두 번 읽히지 않도록 aria-describedby와 실시간 알림 영역의 정책을 확인합니다.

src/main/resources/templates/binding-errors/new.html
<!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"]로 폼 전체에 한 번 연결하고, 필드 오류는 각 입력 옆에서만 읽힙니다.


바인딩과 검증 결과

src/test/java/board/bindingerrors/CreatePostFormValidatorTest.java
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;
    }
}
src/test/java/board/bindingerrors/MvcRecordingPostApplicationService.java
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();
    }
}
src/test/java/board/bindingerrors/BindingErrorsTestApplication.java
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();
    }
}
src/test/java/board/bindingerrors/ValidatedPostMvcTest.java
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 하나, 검증기에서 빈 제목의 필드 오류 하나, 공개 게시글의 짧은 본문에 대한 객체 오류 하나가 쌓입니다. 짧은 본문은 앞뒤 공백을 뺀 코드 포인트가 5개이고 content의 필수나 최대 길이 오류는 아니므로, content의 필드 오류를 확인하는 관계 검증의 조건을 통과해 객체 오류가 더해집니다. 날짜 오류는 이 조건이 검사하는 대상이 아닙니다.

오류는 모두 3개입니다. admin은 억제된 필드로 기록되지만 지금의 핸들러가 이를 따로 오류로 바꾸지 않으므로 네 번째 오류가 되지 않습니다. 인접한 BindingResult가 있어 핸들러에 들어가고, hasErrors()가 참이므로 같은 폼을 200으로 돌려주며 서비스 호출은 0회입니다. 200이라는 상태가 저장에 성공했다는 뜻은 아닙니다.

날짜 원문이 오류 객체와 응답 HTML의 value 속성에 남는다는 것은 Jsoup 검사가 확인합니다. 그렇다고 브라우저의 날짜 입력창에 잘못된 원문이 그대로 보인다는 뜻은 아닙니다. HTML 날짜 입력 규칙은 올바른 날짜 문자열이 아닌 값을 비우도록 정합니다. 원문을 사용자에게 알려야 하는 제품이라면, 안전한 오류 설명 영역에 다시 보여 주는 방법과 실제 브라우저의 접근성 검사를 따로 설계합니다.

단위 테스트는 변환 서비스와 검증기를 실제 순서대로 실행하고, MVC 테스트는 @InitBinder와 @Validated, 인접한 BindingResult, ISO 날짜 변환, 서비스 차단, Thymeleaf 재렌더링을 한 요청 경로에서 확인합니다. 검증기만 직접 호출하는 단위 테스트도 빠르지만 거부된 값과 타입 불일치를 재현하지 못하므로, 중요한 바인딩 규칙은 DataBinder나 MockMvc 경로로 보강합니다.


사용자용 오류 정보와 운영용 정보를 나눕니다

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

속성사용자 화면운영 기록
필드 이름사람이 읽는 레이블속성 경로
거부된 값안전한 일반 입력만 다시 표시민감도에 따라 가림
메시지 코드로케일에 맞는 문장으로 해석바뀌지 않는 코드로 집계
예외보여 주지 않음원인과 요청 ID

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


정리

  • BindingResult는 검증할 @ModelAttribute 바로 뒤에 두어야 그 폼에 연결되며, 변환 오류와 필드 오류, 객체 오류가 한 요청 기록에 쌓입니다.
  • 한 필드의 문제는 rejectValue로, 여러 필드의 관계는 reject로 객체 오류를 만들고, 객체 오류를 필드마다 복제하지 않습니다.
  • 관계 검증은 관련 필드에 이미 오류가 있으면 건너뛰어 같은 문제를 겹쳐 보고하지 않습니다.
  • 오류 요약은 한 번만 알리고 필드 오류는 각 입력과 aria-describedby로 연결하며, 날짜 입력창이 원문을 보여 주는지는 브라우저에서 따로 확인합니다.
  • 사용자에게는 고칠 행동을, 운영 로그에는 코드와 요청 ID를 남기고, 오류가 있으면 서비스를 호출하지 않습니다.

연습 문제

1. 시작 시각과 종료 시각

게시글의 시작 시각과 종료 시각을 입력받아, 종료가 시작보다 늦은지 검증하려고 합니다. 한 필드가 날짜·시간 형식 변환에 실패하면 관계 검증을 건너뛰고, 두 값이 모두 올바르지만 순서가 틀렸다면 객체 오류 하나만 만들어야 합니다. 화면 위쪽의 요약과 두 입력의 접근성 연결도 테스트해야 합니다. 검증 순서와 테스트 입력을 정하세요.

해설 보기

먼저 errors.hasFieldErrors("startedAt")와 errors.hasFieldErrors("endedAt")를 확인하고, 두 값이 모두 있을 때만 비교합니다. 관계 실패에는 바뀌지 않는 코드와 두 필드의 레이블을 인자로 넣습니다. 순서는 startedAt과 endedAt의 필드 오류 확인, 두 값의 null 확인, endedAt.isAfter(startedAt) 비교입니다. 마지막 비교가 실패할 때만 errors.reject("post.time.order", new Object[]{"시작 시각", "종료 시각"}, ...)로 객체 오류 하나를 기록합니다.

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

2. 세 요청의 오류 세기

각 요청은 MVC 예제의 서비스 기록을 비운 새 상태에서 시작하며, 모두 publicVisible=true와 admin=true를 보냅니다. A는 제목 Spring, 본문 😀 24개, 올바른 ISO 날짜입니다. B는 제목과 본문이 공백뿐이고 날짜는 날짜아님입니다. C는 제목 Spring, 본문은 공백 하나 + 😀 25개 + 공백 하나, 올바른 ISO 날짜입니다. 각 요청의 필드 오류 수와 객체 오류 수, 억제된 필드와 hasErrors(), 서비스 호출 횟수, 같은 화면인지 리다이렉트인지를 정하세요. C가 넘긴 명령의 제목과 본문에는 실제로 어떤 정리가 적용되었는지도 설명하세요.

해설 보기

A는 필드 오류 0개, 객체 오류 1개입니다. 이 정책에서 본문 길이는 코드 포인트 24개라 공개 최소 25개를 채우지 못하며, UTF-16 길이 48로 비교하지 않습니다. admin은 억제된 필드로 기록되고 hasErrors()가 참이므로 서비스 0회, 같은 폼 200입니다.

B는 post.title.required, post.content.required, 날짜의 typeMismatch로 필드 오류가 3개입니다. content에 필드 오류가 있으므로 공개 최소 길이의 객체 오류는 더하지 않으며, 억제된 admin도 오류를 더하지 않습니다. hasErrors()가 참이므로 서비스 0회, 같은 폼 200입니다.

C는 strip()한 뒤 코드 포인트가 25개라 길이 검사를 통과하므로 필드 오류도 객체 오류도 없습니다. admin은 여전히 억제되지만 이 예제는 억제를 오류로 바꾸지 않으므로, 서비스 1회와 컬렉션 경로로의 리다이렉트가 됩니다. 명령의 제목은 strip()해서 Spring이 되지만 본문은 getContent() 값을 그대로 넘기므로, 양쪽 공백이 남아 코드 포인트가 27개입니다. 길이를 검사할 때 잠시 strip()한 것과 저장할 값을 정규화한 것은 다르며, 무시된 admin 입력이 권한을 준 것도 아닙니다. 이 답은 바인더와 검증기, 핸들러의 순서에서 끌어낸 예상이므로 테스트를 실행해 확인합니다.

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