본문 바로가기

Spring 폼 바인딩

허용 필드·변환·검증·PRG 경계를 실제 Spring Boot 폼과 MockMvc 최종 HTML로 검증합니다.

HTML 폼은 이름이 붙은 문자열 묶음을 전송합니다. Spring MVC는 그 문자열을 객체 속성에 바인딩하지만, 변환이 편리하다고 해서 그 값을 바꿀 권한까지 생기지는 않습니다. 게시글 등록에서는 title, content, publishedOn만 폼 객체에 열어 두고, 회원 식별자와 승인 상태는 서버가 정해야 합니다.

허용·변환·검증을 모두 통과한 폼만 도메인 명령이 된다

허용 필드·변환·validator·BindingResult·command/service·302/GET과 오류 200/service 0의 처리 상태가 있다.

바인딩과실제customvalidator는handler전에실행되고인접BindingResult를받은handler가suppressedfield를globalerror로추가하고hasErrors를한번판단합니다. approved억제/날짜typeMismatch/공백title은같은폼200·service0입니다. 정상입력만strip한제목의command로register1후ID41목적지와flash의PRG를완성합니다. 오류요소/ARIA관계와HTML거부값은최종DOM으로확인하며date브라우저UI원문표시는별도경계입니다.

FLOWCHART · FORM BINDING · PRG

허용·변환·검증을 모두 통과한 폼만 도메인 명령이 된다

WebDataBinder가 허용 필드와 타입 변환을 맡고, 억제·바인딩 오류와 실제 @Valid 검증 오류는 서비스를 호출하지 않은 채 접근 가능한 같은 폼으로 돌아간다.

폼 바인딩에서 handler의 오류 판단까지문자열POST는허용필드와타입변환,Valid실제customvalidator를거친뒤바로뒤BindingResult와handler로들어옵니다. handler가억제필드를globalerror로추가하고hasErrors를한번판단합니다. 오류는같은폼200/service0,오류없을때만command/service/register1→서비스ID41의302redirect→상세GET200/flash입니다.
POST 문자열title/content/날짜
연결 관계POST 문자열 · title/content/날짜 → WebDataBinder · 허용 필드 · 변환
WebDataBinder허용 필드 · 변환
연결 관계WebDataBinder · 허용 필드 · 변환 → @Valid · 실제 custom 검증
@Valid실제 custom 검증
연결 관계@Valid · 실제 custom 검증 → handler 진입 · 억제 오류 + 판단
handler 진입억제 오류 + 판단
연결 관계handler 진입 · 억제 오류 + 판단 → 오류 없을 때만 · command → service
오류 없을 때만command → service
연결 관계오류 없을 때만 · command → service → 302 redirect → GET · ID41 상세 · flash
302 redirect → GETID41 상세 · flash
오류를 받은 handler의 단일 분기와 PRG원 데스크톱의 여섯 정상 경로 노드·다섯 관계를 재배치하고 원 본문과 패널의 오류 재표시 대안을 명시한다. 바인더·실제 검증 뒤 인접 BindingResult를 받은 handler가 억제 필드를 객체 오류로 더한 뒤 한 번 판단한다. 오류가 있으면 같은 폼200/service0으로 끝난다. 오류 없을 때만 command와register1, 반환ID41의 기본302redirect 및 후속GET200/flash로 진행한다. 새 그림은 HTTP·브라우저·DB 실행 증명이 아니다.오류 없음오류 있음POST 문자열title · contentpublishedOnWebDataBinder허용 필드 · 변환@Valid실제 custom 검증handler · 오류 판단suppressed → 오류hasErrors?command · service제목 stripregister 1회302 → 후속 GET반환 ID 41상세 200 · flash같은 폼 200register 0회오류 요소 · ARIA

왼쪽 우회선은 오류가 있는 대안이며 성공 PRG 뒤에 같은 폼을 렌더링한다는 뜻이 아닙니다. 인접 BindingResult를 받은 handler는 오류가 있어도 실행되고 그 안의 판단이 서비스 진입을 막습니다.

ALLOWED FIELDS · SERVICE 0

추가 필드는 억제 후 전역 오류로

title·content·publishedOn만 바인딩합니다. approved=true는 suppressed이며 자동 오류로 간주하지 않습니다. handler가 binding.suppressed를 명시적으로 더하고 같은 폼을 200으로 렌더링합니다.

TYPE CONVERSION · SERVICE 0

잘못된 날짜의 거부 원문과 typeMismatch

날짜아님은 LocalDate가 되지 못해 BindingResult에 남습니다. validator는 publishedOn에 기존 field error가 있으면 required를 중복 추가하지 않습니다. 렌더링 HTML의 value 원문 보존과 브라우저 date UI의 표시 여부는 별개입니다.

CUSTOM VALIDATOR · SERVICE 0

등록과 @Valid가 실제 규칙을 연결한다

바인더에 실제 CreatePostFormValidator를 추가하고 모델 인자에 @Valid를 둡니다. 공백 title은 title.required입니다. 인접 BindingResult를 받은 handler는 모든 오류를 판단해 서비스를 막습니다. 미래 날짜·일일 한도는 이 검증기가 구현한 규칙이 아닙니다.

SUCCESS · REGISTER 1

정확한 명령과 반환 ID로 PRG를 완성

제목의 앞뒤 공백을 strip하고 본문·날짜를 그대로 command에 옮깁니다. service 반환 ID 41로 /formbinding/posts/41을 완성합니다. 기본 redirect는 302이고 테스트는 3xx와 정확한 목적지·flash를 확인합니다. flash를 넘긴 후속 GET은 상세 HTML 200입니다.

FINAL HTML / BROWSER

오류 요소와 ARIA 참조가 함께 나타난다

GET 폼에는 aria-invalid/describedby가 없습니다. 날짜 오류에는 실제 publishedOn-error와 연결한 참조·텍스트가 있습니다. 브라우저 date input은 잘못된 날짜 값을 빈 값으로 정리할 수 있으므로 보이는 원문 복원이 필요하면 텍스트 입력이나 별도 안내와 브라우저 검사를 설계합니다.

오류가 있어도 BindingResult를 받은 handler는 실행됩니다. 서비스 호출을 막는 주체는 handler의 단일 오류 판단입니다. MockMvc의 실제 Thymeleaf HTML 검사와 브라우저 입력 UI 검사는 서로 다른 경계를 검증합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 문자열 POST는 허용 필드와 타입 변환, Valid의 실제 custom validator를 거친 뒤 바로 뒤의 BindingResult와 handler로 들어옵니다. handler가 억제 필드를 global error로 추가하고 hasErrors를 한 번 판단합니다. 오류는 같은 폼 200/service 0, 오류 없을 때만 command/service/register 1→서비스 ID 41의 302 redirect→상세 GET 200/flash입니다.
  • 원 데스크톱의 여섯 정상 경로 노드·다섯 관계를 재배치하고 원 본문과 패널의 오류 재표시 대안을 명시한다. 바인더·실제 검증 뒤 인접 BindingResult를 받은 handler가 억제 필드를 객체 오류로 더한 뒤 한 번 판단한다. 오류가 있으면 같은 폼 200/service 0으로 끝난다. 오류 없을 때만 command와 register 1, 반환 ID 41의 기본 302 redirect 및 후속 GET 200/flash로 진행한다. 새 그림은 HTTP·브라우저·DB 실행 증명이 아니다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
2바인딩과 실제 custom validator는 handler 전에 실행되고 인접 BindingResult를 받은 handler가 suppressed field를 global error로 추가하고 hasErrors를 한 번 판단합니다. approved 억제/날짜 typeMismatch/공백 title은 같은 폼 200·service 0입니다. 정상 입력만 strip한 제목의 command로 register 1 후 ID 41 목적지와 flash의 PRG를 완성합니다. 오류 요소/ARIA 관계와 HTML 거부 값은 최종 DOM으로 확인하며 date 브라우저 UI의 원문 표시는 별도 경계입니다.
8문자열 POST는 허용 필드와 타입 변환, Valid의 실제 custom validator를 거친 뒤 바로 뒤의 BindingResult와 handler로 들어옵니다. handler가 억제 필드를 global error로 추가하고 hasErrors를 한 번 판단합니다. 오류는 같은 폼 200/service 0, 오류 없을 때만 command/service/register 1→서비스 ID 41의 302 redirect→상세 GET 200/flash입니다.
19ID 41 상세 · flash
21원 데스크톱의 여섯 정상 경로 노드·다섯 관계를 재배치하고 원 본문과 패널의 오류 재표시 대안을 명시한다. 바인더·실제 검증 뒤 인접 BindingResult를 받은 handler가 억제 필드를 객체 오류로 더한 뒤 한 번 판단한다. 오류가 있으면 같은 폼 200/service 0으로 끝난다. 오류 없을 때만 command와 register 1, 반환 ID 41의 기본 302 redirect 및 후속 GET 200/flash로 진행한다. 새 그림은 HTTP·브라우저·DB 실행 증명이 아니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
문자열 입력 바인딩·검증

POST가 허용 필드·타입 변환·실제 custom validator를 지나 바로 뒤 BindingResult와 handler로 들어옵니다.

입력: 문자열 POST · 검증 결과: BindingResult · 서비스: 미호출문자열 입력이 검증된 필드와 오류 정보로 나뉩니다.
오류를 한 번 판단

handler가 억제 필드를 global error로 더하고 hasErrors를 판단합니다.

오류 분기: 같은 폼 200 · 오류 시 서비스 호출: 0거부된 입력이면 서비스로 진행하지 않습니다.
정상 입력 등록

오류 없는 경우만 command와 service를 호출합니다.

register 호출: 1 · 서비스 ID: 41검증을 통과한 입력이 등록 업무로 넘어갑니다.
redirect와 상세 GET

등록 뒤 302 redirect를 보내고 별도 상세 GET이 200·flash를 표시합니다.

응답: 302→GET 200 · 서비스 ID: 41 · GET: 새 요청폼 처리 결과가 별도 조회 화면에 표시됩니다.

허용·변환·검증을 모두 통과한 폼만 도메인 명령이 된다

허용 필드·변환·validator·BindingResult·command/service·302/GET과 오류 200/service 0의 처리 상태가 있다.

바인딩과실제customvalidator는handler전에실행되고인접BindingResult를받은handler가suppressedfield를globalerror로추가하고hasErrors를한번판단합니다. approved억제/날짜typeMismatch/공백title은같은폼200·service0입니다. 정상입력만strip한제목의command로register1후ID41목적지와flash의PRG를완성합니다. 오류요소/ARIA관계와HTML거부값은최종DOM으로확인하며date브라우저UI원문표시는별도경계입니다.

FLOWCHART · FORM BINDING · PRG

허용·변환·검증을 모두 통과한 폼만 도메인 명령이 된다

WebDataBinder가 허용 필드와 타입 변환을 맡고, 억제·바인딩 오류와 실제 @Valid 검증 오류는 서비스를 호출하지 않은 채 접근 가능한 같은 폼으로 돌아간다.

폼 바인딩에서 handler의 오류 판단까지문자열POST는허용필드와타입변환,Valid실제customvalidator를거친뒤바로뒤BindingResult와handler로들어옵니다. handler가억제필드를globalerror로추가하고hasErrors를한번판단합니다. 오류는같은폼200/service0,오류없을때만command/service/register1→서비스ID41의302redirect→상세GET200/flash입니다.
POST 문자열title/content/날짜
연결 관계POST 문자열 · title/content/날짜 → WebDataBinder · 허용 필드 · 변환
WebDataBinder허용 필드 · 변환
연결 관계WebDataBinder · 허용 필드 · 변환 → @Valid · 실제 custom 검증
@Valid실제 custom 검증
연결 관계@Valid · 실제 custom 검증 → handler 진입 · 억제 오류 + 판단
handler 진입억제 오류 + 판단
연결 관계handler 진입 · 억제 오류 + 판단 → 오류 없을 때만 · command → service
오류 없을 때만command → service
연결 관계오류 없을 때만 · command → service → 302 redirect → GET · ID41 상세 · flash
302 redirect → GETID41 상세 · flash
오류를 받은 handler의 단일 분기와 PRG원 데스크톱의 여섯 정상 경로 노드·다섯 관계를 재배치하고 원 본문과 패널의 오류 재표시 대안을 명시한다. 바인더·실제 검증 뒤 인접 BindingResult를 받은 handler가 억제 필드를 객체 오류로 더한 뒤 한 번 판단한다. 오류가 있으면 같은 폼200/service0으로 끝난다. 오류 없을 때만 command와register1, 반환ID41의 기본302redirect 및 후속GET200/flash로 진행한다. 새 그림은 HTTP·브라우저·DB 실행 증명이 아니다.오류 없음오류 있음POST 문자열title · contentpublishedOnWebDataBinder허용 필드 · 변환@Valid실제 custom 검증handler · 오류 판단suppressed → 오류hasErrors?command · service제목 stripregister 1회302 → 후속 GET반환 ID 41상세 200 · flash같은 폼 200register 0회오류 요소 · ARIA

왼쪽 우회선은 오류가 있는 대안이며 성공 PRG 뒤에 같은 폼을 렌더링한다는 뜻이 아닙니다. 인접 BindingResult를 받은 handler는 오류가 있어도 실행되고 그 안의 판단이 서비스 진입을 막습니다.

ALLOWED FIELDS · SERVICE 0

추가 필드는 억제 후 전역 오류로

title·content·publishedOn만 바인딩합니다. approved=true는 suppressed이며 자동 오류로 간주하지 않습니다. handler가 binding.suppressed를 명시적으로 더하고 같은 폼을 200으로 렌더링합니다.

TYPE CONVERSION · SERVICE 0

잘못된 날짜의 거부 원문과 typeMismatch

날짜아님은 LocalDate가 되지 못해 BindingResult에 남습니다. validator는 publishedOn에 기존 field error가 있으면 required를 중복 추가하지 않습니다. 렌더링 HTML의 value 원문 보존과 브라우저 date UI의 표시 여부는 별개입니다.

CUSTOM VALIDATOR · SERVICE 0

등록과 @Valid가 실제 규칙을 연결한다

바인더에 실제 CreatePostFormValidator를 추가하고 모델 인자에 @Valid를 둡니다. 공백 title은 title.required입니다. 인접 BindingResult를 받은 handler는 모든 오류를 판단해 서비스를 막습니다. 미래 날짜·일일 한도는 이 검증기가 구현한 규칙이 아닙니다.

SUCCESS · REGISTER 1

정확한 명령과 반환 ID로 PRG를 완성

제목의 앞뒤 공백을 strip하고 본문·날짜를 그대로 command에 옮깁니다. service 반환 ID 41로 /formbinding/posts/41을 완성합니다. 기본 redirect는 302이고 테스트는 3xx와 정확한 목적지·flash를 확인합니다. flash를 넘긴 후속 GET은 상세 HTML 200입니다.

FINAL HTML / BROWSER

오류 요소와 ARIA 참조가 함께 나타난다

GET 폼에는 aria-invalid/describedby가 없습니다. 날짜 오류에는 실제 publishedOn-error와 연결한 참조·텍스트가 있습니다. 브라우저 date input은 잘못된 날짜 값을 빈 값으로 정리할 수 있으므로 보이는 원문 복원이 필요하면 텍스트 입력이나 별도 안내와 브라우저 검사를 설계합니다.

오류가 있어도 BindingResult를 받은 handler는 실행됩니다. 서비스 호출을 막는 주체는 handler의 단일 오류 판단입니다. MockMvc의 실제 Thymeleaf HTML 검사와 브라우저 입력 UI 검사는 서로 다른 경계를 검증합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 문자열 POST는 허용 필드와 타입 변환, Valid의 실제 custom validator를 거친 뒤 바로 뒤의 BindingResult와 handler로 들어옵니다. handler가 억제 필드를 global error로 추가하고 hasErrors를 한 번 판단합니다. 오류는 같은 폼 200/service 0, 오류 없을 때만 command/service/register 1→서비스 ID 41의 302 redirect→상세 GET 200/flash입니다.
  • 원 데스크톱의 여섯 정상 경로 노드·다섯 관계를 재배치하고 원 본문과 패널의 오류 재표시 대안을 명시한다. 바인더·실제 검증 뒤 인접 BindingResult를 받은 handler가 억제 필드를 객체 오류로 더한 뒤 한 번 판단한다. 오류가 있으면 같은 폼 200/service 0으로 끝난다. 오류 없을 때만 command와 register 1, 반환 ID 41의 기본 302 redirect 및 후속 GET 200/flash로 진행한다. 새 그림은 HTTP·브라우저·DB 실행 증명이 아니다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
2바인딩과 실제 custom validator는 handler 전에 실행되고 인접 BindingResult를 받은 handler가 suppressed field를 global error로 추가하고 hasErrors를 한 번 판단합니다. approved 억제/날짜 typeMismatch/공백 title은 같은 폼 200·service 0입니다. 정상 입력만 strip한 제목의 command로 register 1 후 ID 41 목적지와 flash의 PRG를 완성합니다. 오류 요소/ARIA 관계와 HTML 거부 값은 최종 DOM으로 확인하며 date 브라우저 UI의 원문 표시는 별도 경계입니다.
8문자열 POST는 허용 필드와 타입 변환, Valid의 실제 custom validator를 거친 뒤 바로 뒤의 BindingResult와 handler로 들어옵니다. handler가 억제 필드를 global error로 추가하고 hasErrors를 한 번 판단합니다. 오류는 같은 폼 200/service 0, 오류 없을 때만 command/service/register 1→서비스 ID 41의 302 redirect→상세 GET 200/flash입니다.
19ID 41 상세 · flash
21원 데스크톱의 여섯 정상 경로 노드·다섯 관계를 재배치하고 원 본문과 패널의 오류 재표시 대안을 명시한다. 바인더·실제 검증 뒤 인접 BindingResult를 받은 handler가 억제 필드를 객체 오류로 더한 뒤 한 번 판단한다. 오류가 있으면 같은 폼 200/service 0으로 끝난다. 오류 없을 때만 command와 register 1, 반환 ID 41의 기본 302 redirect 및 후속 GET 200/flash로 진행한다. 새 그림은 HTTP·브라우저·DB 실행 증명이 아니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
문자열 입력 바인딩·검증

POST가 허용 필드·타입 변환·실제 custom validator를 지나 바로 뒤 BindingResult와 handler로 들어옵니다.

입력: 문자열 POST · 검증 결과: BindingResult · 서비스: 미호출문자열 입력이 검증된 필드와 오류 정보로 나뉩니다.
오류를 한 번 판단

handler가 억제 필드를 global error로 더하고 hasErrors를 판단합니다.

오류 분기: 같은 폼 200 · 오류 시 서비스 호출: 0거부된 입력이면 서비스로 진행하지 않습니다.
정상 입력 등록

오류 없는 경우만 command와 service를 호출합니다.

register 호출: 1 · 서비스 ID: 41검증을 통과한 입력이 등록 업무로 넘어갑니다.
redirect와 상세 GET

등록 뒤 302 redirect를 보내고 별도 상세 GET이 200·flash를 표시합니다.

응답: 302→GET 200 · 서비스 ID: 41 · GET: 새 요청폼 처리 결과가 별도 조회 화면에 표시됩니다.

그림은 문자열 POST가 WebDataBinder의 허용 필드와 타입 변환, @Valid의 실제 검증기를 거쳐, 바로 뒤의 BindingResult와 함께 핸들러에 들어오는 흐름을 보여 줍니다. 핸들러는 허용하지 않아 억제된 필드를 전역 오류로 더한 뒤 hasErrors()로 한 번 판단합니다. 오류가 있으면 같은 폼을 200으로 렌더링하고 서비스는 호출하지 않으며, 오류가 없을 때만 명령을 만들어 서비스를 한 번 부르고 반환된 ID 41로 302 리다이렉트를 보냅니다. 이어지는 별도의 상세 GET이 200과 플래시(flash) 알림을 보여 주며 PRG(Post/Redirect/Get)가 완성됩니다. 왼쪽으로 돌아가는 선은 오류가 있을 때의 대안이며, 성공한 PRG 뒤에 같은 폼을 렌더링한다는 뜻이 아닙니다.


충돌 없는 실행 단위

이 글은 Thymeleaf 표현식의 루트 Gradle 빌드와 validation 의존성을 씁니다. 패키지와 요청, 템플릿 경로는 formbinding으로 나눕니다.

src/main/java/board/formbinding/FormBindingApplication.java
package board.formbinding;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class FormBindingApplication {
    public static void main(String[] args) {
        SpringApplication.run(FormBindingApplication.class, args);
    }
}

엔티티와 폼 객체를 나눕니다

폼은 빈 값과 변환하지 못한 입력을 오류 응답까지 보존해야 합니다. 엔티티는 불변식이 성립한 상태로만 있어야 하므로, 엔티티에 웹 바인딩용 세터를 추가하지 않습니다.

src/main/java/board/formbinding/CreatePostForm.java
package board.formbinding;

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;

    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;
    }
}

LocalDate는 참조 타입이므로 빈 입력을 null로 나타낼 수 있습니다. 정수 입력에서 입력하지 않은 것과 0을 구별해야 한다면 원시 int가 아니라 Integer 같은 래퍼를 고릅니다. 잘못된 날짜 문자열은 객체 속성에 들어가지 못하지만, BindingResult에 거부된 값으로 남습니다.

폼에는 approved나 memberId 같은 속성을 미리 만들지 않습니다. 나중에 같은 이름의 필드가 추가되더라도 클라이언트의 권한이 저절로 넓어지지 않도록, 바인더의 허용 목록도 함께 둡니다.

이 핸들러의 @ModelAttribute는 요청 파라미터를 폼 속성에 연결합니다. application/x-www-form-urlencoded의 이름과 값은 JSON 본문의 프로퍼티와 같은 입력이 아닙니다. JSON을 읽는 @RequestBody와 메시지 컨버터는 별도의 경로이므로, Content-Type만 JSON으로 바꾼다고 지금의 폼 객체에 JSON 값이 들어오지는 않습니다.


직접 만든 검증기를 실제 바인딩 경로에 연결합니다

검증기를 WebDataBinder에 추가하는 것만으로는 충분하지 않습니다. 핸들러의 모델 속성에 @Valid나 @Validated가 있어야 바인딩 뒤에 검증 단계가 실행됩니다.

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

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", "title.required", "제목을 입력하세요.");
        }
        if (form.getContent() == null || form.getContent().isBlank()) {
            errors.rejectValue(
                    "content", "content.required", "본문을 입력하세요.");
        }
        if (!errors.hasFieldErrors("publishedOn")
                && form.getPublishedOn() == null) {
            errors.rejectValue(
                    "publishedOn",
                    "publishedOn.required",
                    "작성일을 입력하세요.");
        }
    }
}

이미 변환 오류가 있는 날짜에는 필수 오류를 하나 더 붙이지 않습니다. 빈 제목과 본문은 이 검증기가 맡고, 미래 날짜 금지나 하루 등록 한도처럼 저장된 상태가 필요한 규칙은 애플리케이션과 도메인 계층이 맡습니다.

여기서 제목의 공백은 Java String.isBlank()의 판정입니다. 화면에서 빈칸처럼 보이는 문자가 모두 그 범위에 드는 것은 아닙니다. 예를 들어 줄바꿈하지 않는 공백 U+00A0는 isBlank()로 거절되지 않고 strip()으로도 지워지지 않습니다. 그런 문자만 있는 제목도 거절하려면 눈에 보이는 빈 값의 범위와 정규화 정책을 따로 정해야 하며, 이 검증기가 그 정책까지 구현한 것은 아닙니다.

src/main/java/board/formbinding/CreatePostCommand.java
package board.formbinding;

import java.time.LocalDate;
import java.util.Objects;

public record CreatePostCommand(
        String title,
        String content,
        LocalDate publishedOn
) {
    public CreatePostCommand {
        Objects.requireNonNull(title, "title");
        Objects.requireNonNull(content, "content");
        Objects.requireNonNull(publishedOn, "publishedOn");
        if (title.isBlank() || content.isBlank()) {
            throw new IllegalArgumentException(
                    "validated command must not be blank");
        }
    }
}
src/main/java/board/formbinding/PostDetail.java
package board.formbinding;

import java.time.LocalDate;
import java.util.Objects;

public record PostDetail(
        long id,
        String title,
        String content,
        LocalDate publishedOn
) {
    public PostDetail {
        if (id < 1) {
            throw new IllegalArgumentException("id must be positive");
        }
        Objects.requireNonNull(title, "title");
        Objects.requireNonNull(content, "content");
        Objects.requireNonNull(publishedOn, "publishedOn");
    }
}
src/main/java/board/formbinding/PostApplicationService.java
package board.formbinding;

public interface PostApplicationService {
    long register(CreatePostCommand command);

    PostDetail findDetail(long id);
}

허용 필드와 오류, PRG를 컨트롤러에서 확정합니다

BindingResult는 대상 @ModelAttribute의 바로 다음 파라미터에 있어야 합니다. 그 사이에 다른 인자가 오면 MVC가 어느 객체의 오류인지 연결하지 못해 핸들러가 실행되기 전에 실패합니다.

src/main/java/board/formbinding/PostFormController.java
package board.formbinding;

import java.util.Arrays;

import jakarta.validation.Valid;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import org.springframework.web.servlet.support.ExtendedServletRequestDataBinder;

@Controller
public final class PostFormController {
    private final PostApplicationService service;
    private final CreatePostFormValidator validator;

    public PostFormController(
            PostApplicationService service,
            CreatePostFormValidator validator
    ) {
        this.service = service;
        this.validator = validator;
    }

    @InitBinder("form")
    void restrictFields(WebDataBinder binder) {
        binder.setAllowedFields("title", "content", "publishedOn");
        if (binder instanceof ExtendedServletRequestDataBinder servletBinder) {
            servletBinder.setHeaderPredicate(name -> false);
        }
        binder.addValidators(validator);
    }

    @GetMapping("/formbinding/posts/new")
    String createForm(Model model) {
        model.addAttribute("form", new CreatePostForm());
        return "formbinding/posts/new";
    }

    @GetMapping("/formbinding/posts/{id}")
    String detail(@PathVariable long id, Model model) {
        model.addAttribute("post", service.findDetail(id));
        return "formbinding/posts/detail";
    }

    @PostMapping("/formbinding/posts")
    String create(
            @Valid @ModelAttribute("form") CreatePostForm form,
            BindingResult bindingResult,
            RedirectAttributes redirectAttributes
    ) {
        rejectSuppressedFields(bindingResult);
        if (bindingResult.hasErrors()) {
            return "formbinding/posts/new";
        }

        long id = service.register(new CreatePostCommand(
                form.getTitle().strip(),
                form.getContent(),
                form.getPublishedOn()));
        redirectAttributes.addAttribute("id", id);
        redirectAttributes.addFlashAttribute(
                "notice", "게시글을 저장했습니다.");
        return "redirect:/formbinding/posts/{id}";
    }

    private static void rejectSuppressedFields(
            BindingResult bindingResult
    ) {
        var fields = bindingResult.getSuppressedFields();
        if (fields.length == 0) {
            return;
        }

        Arrays.sort(fields);
        bindingResult.reject(
                "binding.suppressed",
                "허용하지 않은 입력 필드: " + String.join(", ", fields));
    }
}

허용하지 않은 필드를 그냥 무시하면 공격 시도를 알아차리기 어렵습니다. 이 예제는 억제된 필드를 객체 오류로 올려 서비스를 호출하지 않으며, 실제 서비스에서는 민감도에 따라 보안 로그와 지표도 남깁니다.

억제 목록을 오류로 바꾸려면 그 목록에 요청 파라미터만 들어 있어야 합니다. Spring Framework 6.2부터 ExtendedServletRequestDataBinder는 요청 헤더도 바인딩 후보에 넣으므로, 허용 목록이 있으면 브라우저가 늘 보내는 Content-Type이나 User-Agent 같은 헤더도 contentType, userAgent라는 억제된 필드로 기록됩니다. 그래서 이 바인더는 setHeaderPredicate(name -> false)로 헤더를 바인딩 후보에서 뺍니다. 이 줄이 없으면 아래 MockMvc 테스트처럼 헤더 없이 보낸 요청은 통과해도, 실제 브라우저가 보낸 등록 요청은 모두 같은 폼으로 되돌아옵니다.

예를 들어 한 요청에 title= , publishedOn=날짜아님, approved=true가 함께 있고 content는 올바르다고 합시다. 바인더는 날짜의 변환 오류와 억제된 필드를 기록하고, 실제 검증기가 제목 오류를 더합니다. 날짜에는 이미 변환 오류가 있으므로 publishedOn.required는 더하지 않습니다. 인접한 BindingResult가 있으므로 컨트롤러는 그래도 실행되어, 억제된 필드를 객체 오류로 올린 뒤 hasErrors() 한 번으로 서비스 진입을 막습니다.

따라서 검증 오류가 있으니 컨트롤러가 실행되지 않는다는 설명은 이 메서드에 맞지 않습니다. 모델 인자와 오류 객체를 받는 단계와, 실제 도메인 명령과 서비스로 넘어가는 단계를 나누어 읽어야 합니다. Spring의 모델 속성과 오류 처리 설명도 오류 객체를 대상 바로 뒤에 두는 방식과 예외로 처리하는 방식을 구분합니다.

성공하면 서비스가 돌려준 ID를 리다이렉트 속성에 넣습니다. {id} 자리 표시자만 두고 값을 넣지 않은 리다이렉트로는 PRG가 완성되지 않습니다. 목적지의 GET도 같은 ID로 저장 결과를 조회해 실제 상세 템플릿을 렌더링해야, 새로 고침해도 되는 PRG가 됩니다.


오류가 있을 때만 ARIA 관계를 만듭니다

th:field는 속성 경로로 name과 id를 만들고, 변환에 실패한 뒤에는 BindingResult의 거부된 값을 다시 출력합니다. 오류 요소가 없는 성공 화면에 aria-describedby="title-error"를 늘 남겨 두면, 존재하지 않는 ID를 가리키게 됩니다.

src/main/resources/templates/formbinding/posts/new.html
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>게시글 등록</title>
</head>
<body>
  <main>
    <h1>게시글 등록</h1>

    <form th:action="@{/formbinding/posts}"
          th:object="${form}"
          method="post"
          novalidate>
      <div id="form-errors"
           role="alert"
           th:if="${#fields.hasGlobalErrors()}">
        <p th:each="error : ${#fields.globalErrors()}"
           th:text="${error}">폼 오류</p>
      </div>

      <div>
        <label for="title">제목</label>
        <input th:field="*{title}"
               th:attr="aria-invalid=${#fields.hasErrors('title')
                   ? 'true' : null},
                   aria-describedby=${#fields.hasErrors('title')
                   ? 'title-error' : null}" />
        <p id="title-error"
           role="alert"
           th:if="${#fields.hasErrors('title')}"
           th:errors="*{title}">제목 오류</p>
      </div>

      <div>
        <label for="content">본문</label>
        <textarea th:field="*{content}"
                  th:attr="aria-invalid=${#fields.hasErrors('content')
                      ? 'true' : null},
                      aria-describedby=${#fields.hasErrors('content')
                      ? 'content-error' : null}"></textarea>
        <p id="content-error"
           role="alert"
           th:if="${#fields.hasErrors('content')}"
           th:errors="*{content}">본문 오류</p>
      </div>

      <div>
        <label for="publishedOn">작성일</label>
        <input type="date"
               th:field="*{publishedOn}"
               th:attr="aria-invalid=${#fields.hasErrors('publishedOn')
                   ? 'true' : null},
                   aria-describedby=${#fields.hasErrors('publishedOn')
                   ? 'publishedOn-error' : null}" />
        <p id="publishedOn-error"
           role="alert"
           th:if="${#fields.hasErrors('publishedOn')}"
           th:errors="*{publishedOn}">작성일 오류</p>
      </div>

      <button type="submit">저장</button>
    </form>
  </main>
</body>
</html>

aria-invalid와 aria-describedby는 해당 오류 요소와 함께 나타나고 함께 사라집니다. 색만으로 실패를 알리지 않고, 텍스트로 된 오류와 프로그램이 읽을 수 있는 관계를 모두 제공합니다.

날짜 변환이 실패하면 서버가 렌더링한 HTML의 value에는 거부된 문자열이 남습니다. 다만 HTML의 input[type=date]는 표준 날짜 형식이 아닌 값을 브라우저 화면에 표시하지 않을 수 있습니다. 원문이 반드시 보여야 한다는 것이 제품의 요구 사항이라면, 텍스트 입력이나 별도의 원문 안내를 설계하고 브라우저에서도 확인해야 합니다.

성공한 PRG의 상세 화면

리다이렉트 목적지의 GET은 저장 결과와 플래시 알림을 새 응답의 HTML로 완성합니다. notice는 컨트롤러가 리다이렉트 직전에 addFlashAttribute로 넣은 값이므로, 리다이렉트 뒤 첫 GET의 모델에만 들어 있습니다. 그래서 같은 주소를 새로 고치면 th:if="${notice}"가 거짓이 되어 알림 문단은 렌더링되지 않고 게시글 내용만 다시 보입니다.

src/main/resources/templates/formbinding/posts/detail.html
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>게시글 상세</title>
</head>
<body>
  <main th:attr="data-post-id=${post.id}">
    <p role="status" th:if="${notice}"
       th:text="${notice}">저장 알림</p>
    <article>
      <h1 th:text="${post.title}">게시글 제목</h1>
      <p th:text="${post.content}">게시글 본문</p>
      <time th:attr="datetime=${post.publishedOn}"
            th:text="${post.publishedOn}">2026-08-20</time>
    </article>
    <a th:href="@{/formbinding/posts/new}">새 게시글</a>
  </main>
</body>
</html>

더 깊이: 플래시 속성은 두 요청 사이에 어디에 머무를까

Spring MVC의 플래시 속성 문서에 따르면 속성은 FlashMap에 담기고, FlashMapManager가 이를 저장하고 꺼냅니다. 플래시 속성은 리다이렉트 직전에 보통 세션에 잠시 저장되었다가 리다이렉트 뒤의 요청에 넘겨지고 바로 지워집니다. DispatcherServlet이 따로 지정된 빈이 없을 때 쓰는 기본 구현도 SessionFlashMapManager입니다. 이 기능은 늘 켜져 있지만, 쓰지 않으면 HTTP 세션을 만들지 않습니다.

다음 요청이 엉뚱한 요청일 수도 있다는 문제도 다룹니다. 리다이렉트 직후 브라우저가 폴링이나 자원 요청을 먼저 보내면 플래시 속성이 너무 일찍 지워질 수 있으므로, RedirectView는 FlashMap에 리다이렉트 대상의 경로와 쿼리 파라미터를 기록하고 기본 관리자는 이 정보가 맞는 요청에만 속성을 넘깁니다. 문서는 이것이 동시성 문제를 크게 줄일 뿐 완전히 없애지는 않는다고 밝힙니다. 또 AbstractFlashMapManager는 요청이 끝날 때 저장한 FlashMap을 기본 180초 뒤에 만료시킵니다.

세션에 저장된다는 점은 배포 방식과도 이어집니다. 서버가 여러 대이고 세션을 공유하지 않는다면, 리다이렉트 뒤의 GET이 다른 인스턴스로 가는 순간 알림이 보이지 않습니다. 플래시 알림은 놓쳐도 되는 안내에만 쓰고, 저장 결과처럼 반드시 보여야 하는 값은 이 글처럼 목적지 GET이 저장소에서 다시 조회하게 합니다.


바인딩과 검증, 도메인 실패를 나눕니다

실패한 곳예다시 보여 줄 값의 출처다음 행동
허용 필드approved=true억제된 필드 목록객체 오류와 보안 기록
타입 변환publishedOn=날짜아님BindingResult의 거부 값같은 폼 렌더링
필드 검증공백뿐인 title폼 속성 값필드 오류 안내
객체 검증종료일이 시작일보다 빠름관련 필드 값객체 오류 안내
도메인과 서비스하루 등록 한도 초과검증된 제출 값업무 오류로 변환

문자열이 LocalDate로 바뀌고 필수 값이 채워졌다고 업무 규칙까지 만족한 것은 아닙니다. 동시에 들어오는 요청의 중복과 한도는 트랜잭션과 데이터베이스 제약이 마지막으로 막습니다. 반대로 날짜 입력 형식과 레이블, 필드 순서, 오류 문구 같은 표시 규칙은 웹 어댑터에 둡니다.


실제 검증기와 최종 폼을 MockMvc로 확인합니다

검증기를 목으로 바꾸면 @Valid가 실제 규칙을 호출하는지 알 수 없습니다. 다음 테스트는 실제 CreatePostFormValidator를 쓰고, 서비스만 기록용 포트로 바꿉니다.

src/test/java/board/formbinding/PostFormBindingTest.java
package board.formbinding;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.flash;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
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 java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;

import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
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.test.context.TestConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.annotation.Bean;
import org.springframework.test.web.servlet.MockMvc;

@SpringBootTest(classes = {
        FormBindingApplication.class,
        PostFormBindingTest.FixtureConfiguration.class
})
@AutoConfigureMockMvc
class PostFormBindingTest {
    @Autowired
    MockMvc mvc;

    @Autowired
    RecordingPostApplicationService service;

    @BeforeEach
    void resetService() {
        service.clear();
    }

    @Test
    void GET_폼은_존재하지_않는_오류_id를_참조하지_않는다()
            throws Exception {
        var result = mvc.perform(get("/formbinding/posts/new"))
                .andExpect(status().isOk())
                .andExpect(view().name("formbinding/posts/new"))
                .andReturn();
        var document = parse(result.getResponse()
                .getContentAsString(StandardCharsets.UTF_8));

        assertThat(document.select("[aria-invalid=true], [aria-describedby]"))
                .isEmpty();
        assertThat(document.select("label[for]").stream()
                .allMatch(label -> document.getElementById(
                        label.attr("for")) != null))
                .isTrue();
    }

    @Test
    void 잘못된_날짜는_거부값과_접근가능한_field_error를_보존한다()
            throws Exception {
        var result = mvc.perform(post("/formbinding/posts")
                        .param("title", "Spring MVC")
                        .param("content", "바인딩 과정을 기록합니다.")
                        .param("publishedOn", "날짜아님"))
                .andExpect(status().isOk())
                .andExpect(view().name("formbinding/posts/new"))
                .andExpect(model().attributeHasFieldErrors(
                        "form", "publishedOn"))
                .andReturn();
        var document = parse(result.getResponse()
                .getContentAsString(StandardCharsets.UTF_8));
        var input = document.selectFirst("#publishedOn");

        assertThat(input.attr("value")).isEqualTo("날짜아님");
        assertThat(input.attr("aria-invalid")).isEqualTo("true");
        assertThat(input.attr("aria-describedby"))
                .isEqualTo("publishedOn-error");
        assertThat(document.selectFirst("#publishedOn-error").text())
                .isNotBlank();
        assertThat(service.commands()).isEmpty();
    }

    @Test
    void 억제된_필드는_객체_오류가_되어_service를_막는다()
            throws Exception {
        var result = mvc.perform(post("/formbinding/posts")
                        .param("title", "Spring MVC")
                        .param("content", "허용 필드만 저장합니다.")
                        .param("publishedOn", "2026-08-20")
                        .param("approved", "true"))
                .andExpect(status().isOk())
                .andExpect(view().name("formbinding/posts/new"))
                .andExpect(model().attributeHasErrors("form"))
                .andReturn();
        var document = parse(result.getResponse()
                .getContentAsString(StandardCharsets.UTF_8));

        assertThat(document.selectFirst("#form-errors").text())
                .contains("approved");
        assertThat(service.commands()).isEmpty();
    }

    @Test
    void 실제_custom_validator가_공백_제목을_거부한다()
            throws Exception {
        mvc.perform(post("/formbinding/posts")
                        .param("title", "   ")
                        .param("content", "본문")
                        .param("publishedOn", "2026-08-20"))
                .andExpect(status().isOk())
                .andExpect(model().attributeHasFieldErrors(
                        "form", "title"));

        assertThat(service.commands()).isEmpty();
    }

    @Test
    void 유효한_요청은_redirect를_follow해_상세_HTML을_렌더링한다()
            throws Exception {
        var redirect = mvc.perform(post("/formbinding/posts")
                        .param("title", "  Spring MVC  ")
                        .param("content", "검증 뒤 명령을 만듭니다.")
                        .param("publishedOn", "2026-08-20"))
                .andExpect(status().is3xxRedirection())
                .andExpect(redirectedUrl("/formbinding/posts/41"))
                .andExpect(flash().attribute(
                        "notice", "게시글을 저장했습니다."))
                .andReturn();

        assertThat(service.commands()).containsExactly(
                new CreatePostCommand(
                        "Spring MVC",
                        "검증 뒤 명령을 만듭니다.",
                        LocalDate.of(2026, 8, 20)));

        var detail = mvc.perform(get(redirect.getResponse()
                        .getRedirectedUrl())
                        .flashAttrs(redirect.getFlashMap()))
                .andExpect(status().isOk())
                .andExpect(view().name("formbinding/posts/detail"))
                .andExpect(model().attribute("post", new PostDetail(
                        41L,
                        "Spring MVC",
                        "검증 뒤 명령을 만듭니다.",
                        LocalDate.of(2026, 8, 20))))
                .andReturn();
        var document = parse(detail.getResponse()
                .getContentAsString(StandardCharsets.UTF_8));

        assertThat(document.selectFirst("main").attr("data-post-id"))
                .isEqualTo("41");
        assertThat(document.selectFirst("h1").text())
                .isEqualTo("Spring MVC");
        assertThat(document.selectFirst("[role=status]").text())
                .isEqualTo("게시글을 저장했습니다.");
        assertThat(document.selectFirst("time").attr("datetime"))
                .isEqualTo("2026-08-20");
    }

    private static Document parse(String html) {
        return Jsoup.parse(html);
    }

    static final class RecordingPostApplicationService
            implements PostApplicationService {
        private final List<CreatePostCommand> commands =
                new ArrayList<>();

        @Override
        public long register(CreatePostCommand command) {
            commands.add(command);
            return 41L;
        }

        @Override
        public PostDetail findDetail(long id) {
            if (id != 41L || commands.isEmpty()) {
                throw new IllegalArgumentException("post not found: " + id);
            }
            var command = commands.get(commands.size() - 1);
            return new PostDetail(
                    id,
                    command.title(),
                    command.content(),
                    command.publishedOn());
        }

        List<CreatePostCommand> commands() {
            return List.copyOf(commands);
        }

        void clear() {
            commands.clear();
        }
    }

    @TestConfiguration(proxyBeanMethods = false)
    static class FixtureConfiguration {
        @Bean
        RecordingPostApplicationService postApplicationService() {
            return new RecordingPostApplicationService();
        }
    }
}

다섯 테스트는 성공과 실패의 호출 횟수만 보지 않고, 거부된 원문과 실제 검증기, 억제된 필드, ARIA 참조, 정확한 명령, 값이 채워진 리다이렉트와 이어지는 상세 GET의 최종 HTML까지 고정합니다.


정리

  • 폼 객체는 엔티티와 나누고, 바인더의 허용 목록으로 클라이언트가 바꿀 수 있는 필드를 title, content, publishedOn으로 제한합니다.
  • 허용하지 않은 필드는 억제될 뿐 자동으로 오류가 되지 않으므로, 핸들러가 객체 오류로 올려 서비스 호출을 막습니다.
  • 직접 만든 검증기는 바인더에 등록하고 모델 인자에 @Valid를 붙여야 실행되며, 변환 오류가 있는 필드에는 필수 오류를 겹쳐 붙이지 않습니다.
  • 인접한 BindingResult가 있으면 오류가 있어도 핸들러는 실행되므로, hasErrors() 한 번으로 서비스 진입을 판단합니다.
  • 오류 요소와 ARIA 참조는 함께 나타나고 사라지며, 성공하면 반환된 ID로 리다이렉트하고 상세 GET이 새 응답을 만듭니다.

연습 문제

1. 수정 화면 설계하기

게시글 수정 화면을 추가하되, URL의 게시글 ID를 숨은 입력으로 받지 않으려고 합니다. 경로 변수로 얻은 ID와 인증된 회원을 서버에서 결합하고, 폼에는 수정할 수 있는 제목과 본문, 날짜만 둡니다. 공격자가 memberId와 approved를 추가한 요청도 서비스에 닿지 않게 하려면 어떻게 구성하고 무엇을 테스트해야 할까요?

해설 보기

수정 DTO도 엔티티와 나누고 allowedFields를 고정합니다. 게시글 ID는 핸들러의 경로 변수에서, 회원 ID는 인증 주체에서 얻으며, 서비스가 두 값으로 소유권을 확인한 뒤에만 명령을 실행합니다.

테스트에서는 정상 수정과 다른 회원의 요청, 경로 ID의 변환 실패, 추가 파라미터를 각각 보냅니다. 정상일 때만 서비스가 정확히 한 번 호출되고, 나머지 경우에는 필드 오류와 객체 오류, 권한 오류가 서로 구별되는지 확인합니다.

2. 네 요청의 결과

각 경우는 RecordingPostApplicationService를 비운 상태에서 시작합니다. 요청은 등록 POST이고 Content-Type은 폼이며, 따로 적지 않으면 title은 저장 , content는 원문 , publishedOn은 2026-08-20입니다. ① approved=false만 더합니다. 값이 거짓이니 억제 오류를 피할 수 있나요? 오류의 종류와 필드의 ARIA 오류 참조 여부, register 횟수를 정하세요. ② 제목을 공백 세 개, 날짜를 날짜아님으로 바꾸고 approved=true도 보냅니다. 어떤 오류가 남고 서비스에 닿나요? ③ 추가 필드가 없는 올바른 POST를 같은 예제에 두 번 보내고 각각의 리다이렉트 목적지를 GET으로 따라갑니다. commands의 수와 명령의 제목과 본문, 두 목적지의 ID를 정하세요. PRG가 두 번째 등록을 막나요? ④ 별도로 고친 코드에서 @Valid만 빼고, 공백 제목과 올바른 본문·날짜에 추가 필드 없는 요청을 보냅니다. 제목 오류가 없어졌으니 서비스 등록이 성공하나요?

해설 보기

① 값이 참인지 거짓인지가 아니라 허용하지 않은 이름 approved 때문에 억제됩니다. 핸들러가 binding.suppressed 객체 오류를 더해 같은 폼을 200으로 보여 주고 register는 0회입니다. 올바른 세 필드에는 필드 오류가 없으므로 그 필드들의 aria-invalid와 aria-describedby는 없고, 전역 오류 안내가 생깁니다.

② 제목의 title.required, 날짜의 변환 오류, binding.suppressed가 함께 남으며 publishedOn.required는 더하지 않습니다. 핸들러에는 들어가지만 폼을 다시 보여 주는 200이고 register는 0회입니다. 억제된 approved를 폼 객체에 저장하거나 필드 오류로 바꾸는 코드는 없습니다.

③ register가 2회 호출되어 commands가 두 개 기록됩니다. 제목만 strip()해서 저장이 되고, 본문은 앞뒤 공백을 포함한 원문 , 날짜는 2026-08-20입니다. 기록용 예제는 호출마다 고정 ID 41을 돌려주므로 두 목적지는 모두 /formbinding/posts/41입니다. 이것은 테스트 포트의 정책일 뿐 실제 저장소가 같은 ID를 발급한다는 뜻이 아닙니다. PRG는 이어지는 GET을 새로 고칠 수 있게 해 줄 뿐, 같은 POST를 다시 보내는 것을 막지 않습니다.

④ 이 폼에 붙인 검증기가 요청 인자의 검증 단계에서 실행되지 않습니다. 하지만 공백 제목으로 CreatePostCommand를 만들 때 생성자가 IllegalArgumentException을 던져 register 호출 전에 멈춥니다. 서비스가 성공하는 것도, 필드 오류를 갖춘 정상적인 재표시도 아니며, 이때의 정확한 HTTP 오류 응답은 별도의 예외 처리 정책까지 정해야 알 수 있습니다. 입력 오류 안내와 명령의 불변식을 둘 다 유지하는 이유가 여기 있습니다.

3. 빈 날짜와 유니코드 공백, JSON 입력

① 추가 필드 없이 제목과 본문은 올바르고, 첫 요청의 날짜는 빈 문자열, 두 번째는 날짜아님입니다. 다른 변환기는 등록하지 않고 ISO.DATE 바인딩과 검증기를 그대로 씁니다. 두 경우 publishedOn의 폼 값과 오류의 종류, 서비스 호출은 어떻게 다른가요? ② 제목이 U+00A0 한 글자이고 본문과 날짜는 올바르며 추가 필드는 없습니다. 눈에 비어 보인다는 이유로 title.required가 생기거나 strip() 뒤 빈 제목이 되나요? ③ 브라우저 폼 대신 같은 URL에 JSON 객체만 보냅니다. 쿼리와 폼 파라미터, 관련된 이름의 헤더는 없고 본문의 title, content, publishedOn만 올바른 값입니다. @ModelAttribute를 그대로 두었는데 JSON을 보냈다는 이유로 같은 명령이 만들어진다고 가정해도 되나요?

해설 보기

① 빈 문자열은 날짜 속성의 null이 되고, 날짜 필드에 다른 오류가 없으므로 검증기가 publishedOn.required를 더합니다. 날짜아님은 변환에 실패해 typeMismatch 계열의 필드 오류와 거부된 원문이 남고, 검증기는 그 오류를 보고 required를 겹쳐 붙이지 않습니다. 둘 다 오류 판단에서 멈추므로 register는 0회입니다. 잘못된 날짜가 서버 HTML의 value에 남는 것과 브라우저의 date 화면이 실제로 보여 주는 값은 따로 확인해야 합니다.

② 아닙니다. Java에서 이 문자는 isBlank()가 거짓이고 strip() 뒤에도 한 글자로 남으므로, 제목 조건과 명령 생성자의 공백 검사를 통과할 수 있습니다. 이것은 지금 코드의 문자 판정에서 나온 결과일 뿐, 모든 빈 제목을 거절하는 제품 정책이 있다는 뜻이 아닙니다. 정책을 넓힌다면 허용하고 거부할 문자와 실제 입력, 정규화 결과를 함께 테스트해야 합니다.

③ 안 됩니다. JSON 본문을 읽는 메시지 컨버터는 이 모델 인자를 채우는 경로가 아닙니다. 폼 값과 JSON DTO는 각각의 엔드포인트와 입력 규칙을 명시해야 합니다. 폼 객체에는 기본값인 빈 title과 content, null 날짜가 남으므로 검증 오류가 납니다. 이 비교는 입력 경로를 설명한 것이며, JSON 요청의 정확한 HTTP 응답이나 필터 동작까지 실행해 확인한 것은 아닙니다.

다음 글 체크박스·라디오·셀렉트에서는 체크박스와 라디오 버튼, 선택 목록이 문자열 하나가 아닌 선택 상태를 어떤 HTTP 값으로 나타내는지 살펴봅니다.