본문 바로가기

Bean 검증과 DTO

JSON 읽기와 Bean Validation을 분리하고, 생성·수정 DTO에서 command·domain·DB까지 이어지는 검증 경계를 실행합니다.

Bean Validation은 JSON 문자열을 읽는 파서도 아니고, 도메인과 데이터베이스를 대신하는 마지막 방어선도 아닙니다. HTTP 본문이 DTO로 제대로 만들어진 다음 @Valid가 전송 규칙을 검사하고, 통과한 값만 명시적으로 명령(command)이 됩니다. 그 뒤에도 도메인의 불변식과 DB 제약은 다른 진입 경로와 동시에 들어오는 요청을 막습니다.

JSON은 DTO 검증을 거쳐 command·domain·DB 경계로 이동한다

MVC 기록 서비스 suite의 정상 생성 POST만 추적합니다. 실제 domain/store 단위 검사와 DB 통합 계약은 별도 실행·검증 범위입니다.

Jackson 3의 JSON 읽기 실패는 fields 없는 request.json.unreadable 400이고 DTO 검증 실패는 정렬된 안정 필드 코드를 가진 request.validation.failed 400입니다. 생성 DTO는 게시일을, 수정 DTO는 양의 예상 버전을 받습니다. 정상 MVC는 기록 서비스에 정확한 command를 한 번 전달합니다. 실제 domain 서비스의 고정 Clock 검사와 UTF-16 길이 불변식은 HTTP 우회를 store 전에 막습니다. 이 fixture에는 실제 DB adapter가 없으므로 unique, version 경쟁과 transaction commit은 별도 통합 검사입니다.

FLOWCHART · JSON READ · BEAN VALIDATION · DOMAIN · DATABASE

JSON 읽기·DTO 검증·domain 방어의 실행 범위를 구분한다

생성과 전체 교체는 다른 DTO를 씁니다. HTTP 실패는 안정적인 ProblemDetail 코드로 나누고, 별도 domain suite는 store 호출 전 불변식을 확인합니다. DB 경쟁 검사는 추가 통합 계약입니다.

DTO, command, domain과 구현 범위JSON 읽기와 DTO validation을 통과한 값만 command로 명시적으로 복사합니다. 실제 domain 서비스는 store 전에 불변식을 검사하며 기록 store로 경계 호출을 확인합니다. DB adapter와 unique 및 version 경쟁 검사는 이 fixture에 없고 별도 통합 검사입니다.
JSON DTO로 읽기문법 / 타입 / shape
연결 관계JSON DTO로 읽기 · 문법 / 타입 / shape → @Valid DTO · 전송 필드 제약
@Valid DTO전송 필드 제약
연결 관계@Valid DTO · 전송 필드 제약 → 명시적 command · 허용 필드만 복사
명시적 command허용 필드만 복사
연결 관계명시적 command · 허용 필드만 복사 → domain 불변식 · HTTP 우회도 방어
domain 불변식HTTP 우회도 방어
연결 관계domain 불변식 · HTTP 우회도 방어 → PostStore 경계 · insert / replace
PostStore 경계insert / replace
연결 관계PostStore 경계 · insert / replace → DB adapter는 별도 · 이 fixture에 없음
DB adapter는 별도이 fixture에 없음
DTO, command, domain과 각 검사의 범위원6노드5관계의설계상경계연결이다. HTTP본문읽기→DTO검증→명시command와domain→PostStore→별도DBadapter를구분한다. MVCfixture는기록서비스를사용하므로command후domain영역으로이어진실제통합실행을증명하지않는다.아래domain/store영역은독립단위검사이다.중간화살표는실제domain서비스를연결할때의설계관계이고DB연결은추가구현이다.모바일재배치가별도suite를한실행으로합치지않는다.HTTP 전송 계약별도 domain 단위 검사실제 서비스 연결 시JSON DTO로 읽기문법 · 타입 · shape@Valid DTO전송 필드 제약명시적 command허용 필드만 복사domain 불변식HTTP 우회도 방어PostStore 경계insert · replaceDB adapter는 별도이 fixture에 없음

원 그림과 같은 설계상 경계입니다. MVC suite는 기록 서비스를, 아래 domain/store는 별도 단위 suite를 검사합니다. command 뒤의 실제 서비스 연결과 DB 경쟁·commit은 추가 검증이며 두 suite를 한 실행의 증거로 합치지 않습니다.

JSON READ / 400 / SERVICE 0

DTO 생성 실패에는 fields가 없다

문법 오류, not-a-date, DTO에 없는 속성은 request.json.unreadable입니다. main source의 Jackson 3 customizer가 unknown properties를 거부합니다. DTO 생성이 끝나지 않아 Bean Validation과 서비스로 가지 않습니다.

BEAN VALIDATION / 400 / SERVICE 0

안정 코드와 정렬된 fields를 반환

공백 title/content, 미래 publishedOn, 누락 expectedVersion은 request.validation.failed입니다. fields는 field명→외부 code 순으로 정렬됩니다. NotBlank/Size/PastOrPresent 내부 후보와 rejectedValue 원문을 외부 계약으로 노출하지 않습니다.

CREATE POST / 201 / SERVICE 1

생성은 게시일을 받고 version을 받지 않는다

CreatePostRequest는 title/content/publishedOn입니다. title을 strip하여 정확한 CreatePostCommand 한 개를 넘기고, 기록 서비스의 ID 41로 Location을 만듭니다. @PastOrPresent는 게시일 하나를 provider 시계의 현재 날짜와 비교합니다.

REPLACE PUT / 204 / SERVICE 1

수정은 양의 예상 버전과 path ID를 받는다

Update는 title/content/expectedVersion이며 publishedOn은 받지 않습니다. 정상 ID 41/version 3은 정확한 UpdatePostCommand 한 개입니다. path ID 0은 handler의 request.path.invalid 400으로 서비스 전에 거부됩니다. PATCH의 부재/null 의미는 별도 계약입니다.

DOMAIN / STORE GUARD

고정 Clock과 UTF-16 길이로 우회 경로를 방어

실제 domain 단위 검사는 fixed Asia/Seoul 시계의 미래 생성과 공백 수정이 store를 호출하지 않음을 확인합니다. 오늘 생성과 versioned 수정은 각 1회 전달됩니다. PostText와 @Size의 String 길이는 UTF-16 단위로, 앞 폼 예제의 code point 단위와 다릅니다.

DATABASE / ADDITIONAL INTEGRATION

unique·version·commit은 별도 DB 검사

이 문서에는 PostStore interface와 기록용 구현이 있으며 실제 DB adapter는 없습니다. unique index, optimistic update row count, transaction rollback/commit 및 경쟁 요청의 원자성은 DB 통합 테스트로 확인해야 합니다. 정상 MVC suite도 실제 저장 서비스 대신 기록 서비스를 사용합니다.

실선은 설명된 입력·domain 경계, 점선은 추가 구현이 필요한 DB 경계입니다. MVC 기록 서비스 검사와 domain/store 단위 검사는 각각의 범위를 증명합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • JSON 읽기와 DTO validation을 통과한 값만 command로 명시적으로 복사합니다. 실제 domain 서비스는 store 전에 불변식을 검사하며 기록 store로 경계 호출을 확인합니다. DB adapter와 unique 및 version 경쟁 검사는 이 fixture에 없고 별도 통합 검사입니다.
  • 원래 6노드와 5관계의 설계상 경계 연결이다. HTTP 본문 읽기→DTO 검증→명시적 command와 domain→PostStore→별도 DB adapter를 구분한다. MVC fixture는 기록 서비스를 사용하므로 command 후 domain 영역으로 이어진 실제 통합 실행을 증명하지 않는다. 아래 domain/store 영역은 독립 단위 검사다. 중간 화살표는 실제 domain 서비스를 연결할 때의 설계 관계이고 DB 연결은 추가 구현이다. 모바일 재배치가 별도 suite를 한 실행으로 합치지 않는다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1JSON은 DTO 검증을 거쳐 command·domain·DB 경계로 이동한다
2Jackson 3의 JSON 읽기 실패는 fields가 없는 request.json.unreadable 400이고 DTO 검증 실패는 정렬된 안정 필드 코드를 가진 request.validation.failed 400입니다. 생성 DTO는 게시일을, 수정 DTO는 양의 예상 버전을 받습니다. 정상 MVC는 기록 서비스에 정확한 command를 한 번 전달합니다. 실제 domain 서비스의 고정 Clock 검사와 UTF-16 길이 불변식은 HTTP 우회를 store 전에 막습니다. 이 fixture에는 실제 DB adapter가 없으므로 unique, version 경쟁과 transaction commit은 별도 통합 검사입니다.
21원래 6노드와 5관계의 설계상 경계 연결이다. HTTP 본문 읽기→DTO 검증→명시적 command와 domain→PostStore→별도 DB adapter를 구분한다. MVC fixture는 기록 서비스를 사용하므로 command 후 domain 영역으로 이어진 실제 통합 실행을 증명하지 않는다. 아래 domain/store 영역은 독립 단위 검사다. 중간 화살표는 실제 domain 서비스를 연결할 때의 설계 관계이고 DB 연결은 추가 구현이다. 모바일 재배치가 별도 suite를 한 실행으로 합치지 않는다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
생성 JSON과 DTO

정상 생성 POST의 JSON을 CreatePostRequest로 읽고 Bean Validation을 통과시킵니다.

선택 검사: MVC 기록 서비스 suite의 정상 생성 POST · 검증 결과: title·content·publishedOn DTO 준비JSON 읽기 실패와 DTO 실패에서는 서비스로 진행하지 않으며 이 단계는 통과한 생성 입력만 추적합니다.
생성 command 전달

controller가 title을 strip하고 허용한 DTO 필드를 CreatePostCommand로 복사해 기록 서비스에 한 번 전달합니다.

산출물: CreatePostCommand · 기록 서비스: 1회·반환 ID 41 · 실제 domain 서비스: 이 MVC suite에 연결하지 않음MVC 검사는 정확한 command 전달을 기록하며 별도 domain/store 단위 검사를 실행한 것으로 합치지 않습니다.
MVC 응답 검증

기록 서비스가 돌려준 ID 41로 Location을 만들고 정상 생성 201 응답을 확인합니다.

MVC 결과: 201·ID 41의 Location · 별도 domain/store suite: 고정 Clock·불변식·기록 store 호출 검사 · DB adapter·경쟁·commit: fixture에 없으며 별도 통합 검사이 실행의 증거는 MVC 응답과 기록 서비스 호출까지입니다. 별도 suite의 근거는 원표에 보존합니다.

JSON은 DTO 검증을 거쳐 command·domain·DB 경계로 이동한다

MVC 기록 서비스 suite의 정상 생성 POST만 추적합니다. 실제 domain/store 단위 검사와 DB 통합 계약은 별도 실행·검증 범위입니다.

Jackson 3의 JSON 읽기 실패는 fields 없는 request.json.unreadable 400이고 DTO 검증 실패는 정렬된 안정 필드 코드를 가진 request.validation.failed 400입니다. 생성 DTO는 게시일을, 수정 DTO는 양의 예상 버전을 받습니다. 정상 MVC는 기록 서비스에 정확한 command를 한 번 전달합니다. 실제 domain 서비스의 고정 Clock 검사와 UTF-16 길이 불변식은 HTTP 우회를 store 전에 막습니다. 이 fixture에는 실제 DB adapter가 없으므로 unique, version 경쟁과 transaction commit은 별도 통합 검사입니다.

FLOWCHART · JSON READ · BEAN VALIDATION · DOMAIN · DATABASE

JSON 읽기·DTO 검증·domain 방어의 실행 범위를 구분한다

생성과 전체 교체는 다른 DTO를 씁니다. HTTP 실패는 안정적인 ProblemDetail 코드로 나누고, 별도 domain suite는 store 호출 전 불변식을 확인합니다. DB 경쟁 검사는 추가 통합 계약입니다.

DTO, command, domain과 구현 범위JSON 읽기와 DTO validation을 통과한 값만 command로 명시적으로 복사합니다. 실제 domain 서비스는 store 전에 불변식을 검사하며 기록 store로 경계 호출을 확인합니다. DB adapter와 unique 및 version 경쟁 검사는 이 fixture에 없고 별도 통합 검사입니다.
JSON DTO로 읽기문법 / 타입 / shape
연결 관계JSON DTO로 읽기 · 문법 / 타입 / shape → @Valid DTO · 전송 필드 제약
@Valid DTO전송 필드 제약
연결 관계@Valid DTO · 전송 필드 제약 → 명시적 command · 허용 필드만 복사
명시적 command허용 필드만 복사
연결 관계명시적 command · 허용 필드만 복사 → domain 불변식 · HTTP 우회도 방어
domain 불변식HTTP 우회도 방어
연결 관계domain 불변식 · HTTP 우회도 방어 → PostStore 경계 · insert / replace
PostStore 경계insert / replace
연결 관계PostStore 경계 · insert / replace → DB adapter는 별도 · 이 fixture에 없음
DB adapter는 별도이 fixture에 없음
DTO, command, domain과 각 검사의 범위원6노드5관계의설계상경계연결이다. HTTP본문읽기→DTO검증→명시command와domain→PostStore→별도DBadapter를구분한다. MVCfixture는기록서비스를사용하므로command후domain영역으로이어진실제통합실행을증명하지않는다.아래domain/store영역은독립단위검사이다.중간화살표는실제domain서비스를연결할때의설계관계이고DB연결은추가구현이다.모바일재배치가별도suite를한실행으로합치지않는다.HTTP 전송 계약별도 domain 단위 검사실제 서비스 연결 시JSON DTO로 읽기문법 · 타입 · shape@Valid DTO전송 필드 제약명시적 command허용 필드만 복사domain 불변식HTTP 우회도 방어PostStore 경계insert · replaceDB adapter는 별도이 fixture에 없음

원 그림과 같은 설계상 경계입니다. MVC suite는 기록 서비스를, 아래 domain/store는 별도 단위 suite를 검사합니다. command 뒤의 실제 서비스 연결과 DB 경쟁·commit은 추가 검증이며 두 suite를 한 실행의 증거로 합치지 않습니다.

JSON READ / 400 / SERVICE 0

DTO 생성 실패에는 fields가 없다

문법 오류, not-a-date, DTO에 없는 속성은 request.json.unreadable입니다. main source의 Jackson 3 customizer가 unknown properties를 거부합니다. DTO 생성이 끝나지 않아 Bean Validation과 서비스로 가지 않습니다.

BEAN VALIDATION / 400 / SERVICE 0

안정 코드와 정렬된 fields를 반환

공백 title/content, 미래 publishedOn, 누락 expectedVersion은 request.validation.failed입니다. fields는 field명→외부 code 순으로 정렬됩니다. NotBlank/Size/PastOrPresent 내부 후보와 rejectedValue 원문을 외부 계약으로 노출하지 않습니다.

CREATE POST / 201 / SERVICE 1

생성은 게시일을 받고 version을 받지 않는다

CreatePostRequest는 title/content/publishedOn입니다. title을 strip하여 정확한 CreatePostCommand 한 개를 넘기고, 기록 서비스의 ID 41로 Location을 만듭니다. @PastOrPresent는 게시일 하나를 provider 시계의 현재 날짜와 비교합니다.

REPLACE PUT / 204 / SERVICE 1

수정은 양의 예상 버전과 path ID를 받는다

Update는 title/content/expectedVersion이며 publishedOn은 받지 않습니다. 정상 ID 41/version 3은 정확한 UpdatePostCommand 한 개입니다. path ID 0은 handler의 request.path.invalid 400으로 서비스 전에 거부됩니다. PATCH의 부재/null 의미는 별도 계약입니다.

DOMAIN / STORE GUARD

고정 Clock과 UTF-16 길이로 우회 경로를 방어

실제 domain 단위 검사는 fixed Asia/Seoul 시계의 미래 생성과 공백 수정이 store를 호출하지 않음을 확인합니다. 오늘 생성과 versioned 수정은 각 1회 전달됩니다. PostText와 @Size의 String 길이는 UTF-16 단위로, 앞 폼 예제의 code point 단위와 다릅니다.

DATABASE / ADDITIONAL INTEGRATION

unique·version·commit은 별도 DB 검사

이 문서에는 PostStore interface와 기록용 구현이 있으며 실제 DB adapter는 없습니다. unique index, optimistic update row count, transaction rollback/commit 및 경쟁 요청의 원자성은 DB 통합 테스트로 확인해야 합니다. 정상 MVC suite도 실제 저장 서비스 대신 기록 서비스를 사용합니다.

실선은 설명된 입력·domain 경계, 점선은 추가 구현이 필요한 DB 경계입니다. MVC 기록 서비스 검사와 domain/store 단위 검사는 각각의 범위를 증명합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • JSON 읽기와 DTO validation을 통과한 값만 command로 명시적으로 복사합니다. 실제 domain 서비스는 store 전에 불변식을 검사하며 기록 store로 경계 호출을 확인합니다. DB adapter와 unique 및 version 경쟁 검사는 이 fixture에 없고 별도 통합 검사입니다.
  • 원래 6노드와 5관계의 설계상 경계 연결이다. HTTP 본문 읽기→DTO 검증→명시적 command와 domain→PostStore→별도 DB adapter를 구분한다. MVC fixture는 기록 서비스를 사용하므로 command 후 domain 영역으로 이어진 실제 통합 실행을 증명하지 않는다. 아래 domain/store 영역은 독립 단위 검사다. 중간 화살표는 실제 domain 서비스를 연결할 때의 설계 관계이고 DB 연결은 추가 구현이다. 모바일 재배치가 별도 suite를 한 실행으로 합치지 않는다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1JSON은 DTO 검증을 거쳐 command·domain·DB 경계로 이동한다
2Jackson 3의 JSON 읽기 실패는 fields가 없는 request.json.unreadable 400이고 DTO 검증 실패는 정렬된 안정 필드 코드를 가진 request.validation.failed 400입니다. 생성 DTO는 게시일을, 수정 DTO는 양의 예상 버전을 받습니다. 정상 MVC는 기록 서비스에 정확한 command를 한 번 전달합니다. 실제 domain 서비스의 고정 Clock 검사와 UTF-16 길이 불변식은 HTTP 우회를 store 전에 막습니다. 이 fixture에는 실제 DB adapter가 없으므로 unique, version 경쟁과 transaction commit은 별도 통합 검사입니다.
21원래 6노드와 5관계의 설계상 경계 연결이다. HTTP 본문 읽기→DTO 검증→명시적 command와 domain→PostStore→별도 DB adapter를 구분한다. MVC fixture는 기록 서비스를 사용하므로 command 후 domain 영역으로 이어진 실제 통합 실행을 증명하지 않는다. 아래 domain/store 영역은 독립 단위 검사다. 중간 화살표는 실제 domain 서비스를 연결할 때의 설계 관계이고 DB 연결은 추가 구현이다. 모바일 재배치가 별도 suite를 한 실행으로 합치지 않는다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
생성 JSON과 DTO

정상 생성 POST의 JSON을 CreatePostRequest로 읽고 Bean Validation을 통과시킵니다.

선택 검사: MVC 기록 서비스 suite의 정상 생성 POST · 검증 결과: title·content·publishedOn DTO 준비JSON 읽기 실패와 DTO 실패에서는 서비스로 진행하지 않으며 이 단계는 통과한 생성 입력만 추적합니다.
생성 command 전달

controller가 title을 strip하고 허용한 DTO 필드를 CreatePostCommand로 복사해 기록 서비스에 한 번 전달합니다.

산출물: CreatePostCommand · 기록 서비스: 1회·반환 ID 41 · 실제 domain 서비스: 이 MVC suite에 연결하지 않음MVC 검사는 정확한 command 전달을 기록하며 별도 domain/store 단위 검사를 실행한 것으로 합치지 않습니다.
MVC 응답 검증

기록 서비스가 돌려준 ID 41로 Location을 만들고 정상 생성 201 응답을 확인합니다.

MVC 결과: 201·ID 41의 Location · 별도 domain/store suite: 고정 Clock·불변식·기록 store 호출 검사 · DB adapter·경쟁·commit: fixture에 없으며 별도 통합 검사이 실행의 증거는 MVC 응답과 기록 서비스 호출까지입니다. 별도 suite의 근거는 원표에 보존합니다.

그림은 JSON을 DTO로 읽고, @Valid로 전송 필드를 검사하고, 허용된 필드만 명령으로 복사한 뒤, 도메인 불변식과 PostStore를 거쳐 DB 어댑터로 이어지는 단계를 나눠 보여 줍니다. 실선은 이 글에서 설명하는 입력과 도메인의 단계이고, 점선인 DB 어댑터는 이 예제에 없는 추가 구현입니다. MVC 테스트는 기록용 서비스를, 도메인과 store는 별도의 단위 테스트를 검사하므로 두 테스트를 하나의 실행으로 합쳐 보지 않습니다. 아래 칸들은 JSON 읽기 실패와 Bean Validation 실패, 생성 POST와 교체 PUT, 도메인 방어, DB의 추가 통합 검사를 하나씩 설명합니다.

이 글은 생성 POST와 전체 교체 PUT을 서로 다른 DTO로 모델링합니다. 부분 수정 PATCH는 속성이 없는 것과 명시적인 null, 기존 값 유지의 의미를 따로 정해야 하므로 이 DTO를 다시 쓰지 않습니다.


생성 DTO는 초기 상태를 완성합니다

Spring Boot 4.1과 Spring Framework 7의 Bean Validation API는 jakarta.validation.* 패키지를 씁니다. 레코드 컴포넌트에 제약을 두면 JSON 생성자 바인딩으로 만든 DTO를 검증할 수 있습니다.

src/main/java/board/validationdto/CreatePostRequest.java
package board.validationdto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PastOrPresent;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;

public record CreatePostRequest(
        @NotBlank
        @Size(max = 80)
        String title,

        @NotBlank
        @Size(max = 720)
        String content,

        @NotNull
        @PastOrPresent
        LocalDate publishedOn) {

    public CreatePostCommand toCommand() {
        return new CreatePostCommand(
                title.strip(),
                content,
                publishedOn);
    }
}

@PastOrPresent는 필드 하나에 붙는 제약(field-level constraint)입니다. publishedOn 하나를 Bean Validation 구현체의 ClockProvider가 주는 현재 날짜와 비교할 뿐, 두 필드의 순서나 DB 상태를 비교하는 클래스 수준의 규칙이 아닙니다. 운영에서 업무 시간대가 중요하다면 구현체의 시계와 애플리케이션 Clock의 zone 정책을 맞춥니다.

DTO에는 memberId, approved, createdAt, DB 식별자가 없습니다. 각각 인증 주체와 승인 정책, 서버 시계, 저장 결과가 정하는 값이기 때문입니다. 모르는 JSON 속성은 뒤에 나오는 ValidationJsonConfiguration이 거부하지만, 그 정책과 별개로 민감한 세터를 DTO에 추가하지 않는 것이 먼저입니다.

더 깊이: @PastOrPresent가 쓰는 현재 시각과 시간대

Jakarta Validation 3.1 명세는 @PastOrPresent의 현재를 Validator나 ValidatorFactory에 연결된 ClockProvider가 정한다고 적습니다. 따로 등록하지 않은 기본 ClockProvider는 JVM의 현재 시각에 필요하면 JVM의 기본 시간대를 적용하며, 명세는 구현체가 Clock.systemDefaultZone()으로 시계를 얻기를 권합니다. 현재의 범위는 타입에 따라 정해져서, 예를 들어 Year라면 올해 전체가 현재입니다.

그래서 LocalDate의 오늘은 서버 JVM의 기본 시간대가 정합니다. JVM의 기본 시간대가 UTC인 컨테이너에서 한국 시각 11월 2일 0시 30분에 요청이 오면, UTC로는 아직 11월 1일 15시 30분입니다. 이때 한국 사용자가 오늘 날짜로 보낸 2026-11-02는 검증기에게 미래 날짜가 되어 @PastOrPresent에 걸립니다. 반대로 도메인의 PostDraft가 주입받은 Clock의 시간대를 쓴다면, 같은 입력을 두 단계가 서로 다르게 판정할 수 있습니다.

명세는 테스트나 로그인한 사용자의 시간대 적용, 실제 날짜와 다른 논리 날짜로 실행하는 배치 작업처럼 다른 시계가 필요할 때 부트스트랩 과정에서 다른 ClockProvider를 등록할 수 있다고 설명합니다. Spring Boot에서는 ValidationConfigurationCustomizer 빈으로 ValidatorFactory를 만드는 Configuration을 고칠 수 있으므로, 여기서 애플리케이션의 Clock을 돌려주는 ClockProvider를 지정하면 두 단계가 같은 시계를 쓰게 됩니다.


수정 DTO는 생성 DTO가 아닙니다

이 예제의 PUT은 제목과 본문 전체를 바꾸고 예상 버전을 요구합니다. 최초 게시일은 바꿀 수 없으므로 publishedOn이 없고, 생성에는 필요 없는 expectedVersion이 있습니다.

src/main/java/board/validationdto/UpdatePostRequest.java
package board.validationdto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;

public record UpdatePostRequest(
        @NotBlank
        @Size(max = 80)
        String title,

        @NotBlank
        @Size(max = 720)
        String content,

        @NotNull
        @Positive
        Long expectedVersion) {

    public UpdatePostCommand toCommand(long postId) {
        return new UpdatePostCommand(
                postId,
                title.strip(),
                content,
                expectedVersion);
    }
}
항목CreatePostRequestUpdatePostRequest
HTTP 의미새 게시글 생성기존 게시글 전체 교체
필수 전송 값title, content, publishedOntitle, content, expectedVersion
서버가 정하는 값ID, 작성자, 초기 버전대상 ID는 경로에서, 게시일은 그대로
동시성 입력없음양수인 expectedVersion
속성이 없을 때생성의 필수 값 오류PUT의 필수 값 오류
명시적인 null해당 필드의 제약 위반해당 필드의 제약 위반

검증 그룹은 객체의 형태가 같고 실행 시점만 다를 때 유용합니다. 필드의 형태와 권한이 다르다면 별도의 타입이 더 분명합니다. PATCH가 필요하다면 JSON Merge Patch나 값이 있었는지를 보존하는 전용 입력 타입으로 규칙부터 정합니다. 일반적인 Optional<T>만으로는 Jackson 설정에 따라 속성이 없는 것과 명시적인 null이 같은 빈 값으로 합쳐질 수 있습니다.


명령은 전송 모델을 애플리케이션 입력으로 바꿉니다

명령은 HTTP 애노테이션을 갖지 않고 사용 사례의 입력만 나타냅니다. DTO에서 허용한 값을 명시적으로 복사하므로, 과다 바인딩(mass assignment)을 어디서 막는지도 눈에 보입니다.

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

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");
    }
}
src/main/java/board/validationdto/UpdatePostCommand.java
package board.validationdto;

import java.util.Objects;

public record UpdatePostCommand(
        long postId,
        String title,
        String content,
        long expectedVersion) {

    public UpdatePostCommand {
        if (postId <= 0) {
            throw new IllegalArgumentException("postId must be positive");
        }
        Objects.requireNonNull(title, "title");
        Objects.requireNonNull(content, "content");
        if (expectedVersion <= 0) {
            throw new IllegalArgumentException(
                    "expectedVersion must be positive");
        }
    }
}

명령의 null과 식별자 검사는 객체를 만들기 위한 최소한의 조건입니다. 문자열 길이와 날짜 같은 업무 규칙은 도메인 타입이 다시 검사하므로, HTTP를 거치지 않고 배치나 메시지 소비자가 명령을 만들어도 잘못된 도메인 상태가 저장소로 가지 않습니다.


도메인은 모든 진입 경로를 다시 막습니다

제목과 본문의 공통 불변식은 전송 DTO가 아니라 도메인 값에 둡니다. 아래 PostText의 컴팩트 생성자는 null, 공백뿐인 문자열, 길이 상한을 모두 검사하므로, 이 레코드가 만들어졌다면 제목과 본문은 언제나 규칙을 지킨 값입니다. 생성과 수정이 모두 PostText를 거치므로 두 경로의 길이 규칙도 어긋나지 않습니다.

src/main/java/board/validationdto/PostText.java
package board.validationdto;

import java.util.Objects;

public record PostText(String title, String content) {
    public PostText {
        Objects.requireNonNull(title, "title");
        Objects.requireNonNull(content, "content");
        if (title.isBlank() || title.length() > 80) {
            throw new IllegalArgumentException("title is invalid");
        }
        if (content.isBlank() || content.length() > 720) {
            throw new IllegalArgumentException("content is invalid");
        }
    }
}

게시일의 오늘 기준은 서비스가 주입받은 Clock으로 계산해 도메인 팩토리에 명시적으로 넘깁니다. 아래 PostDraft의 생성자는 private이므로 바깥에서는 create로만 만들 수 있고, create는 날짜 검사를 통과한 뒤에야 PostText를 만듭니다. today를 인자로 받으므로 이 클래스는 시스템 시계를 직접 읽지 않습니다.

src/main/java/board/validationdto/PostDraft.java
package board.validationdto;

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

public final class PostDraft {
    private final PostText text;
    private final LocalDate publishedOn;

    private PostDraft(PostText text, LocalDate publishedOn) {
        this.text = Objects.requireNonNull(text, "text");
        this.publishedOn = Objects.requireNonNull(
                publishedOn, "publishedOn");
    }

    public static PostDraft create(
            String title,
            String content,
            LocalDate publishedOn,
            LocalDate today) {
        Objects.requireNonNull(publishedOn, "publishedOn");
        Objects.requireNonNull(today, "today");
        if (publishedOn.isAfter(today)) {
            throw new IllegalArgumentException(
                    "publishedOn must not be in the future");
        }
        return new PostDraft(
                new PostText(title, content),
                publishedOn);
    }

    public PostText text() {
        return text;
    }

    public LocalDate publishedOn() {
        return publishedOn;
    }
}

DTO의 @PastOrPresent와 도메인의 날짜 검사는 겹치지만 목적이 다릅니다. DTO는 HTTP 클라이언트에 빠른 필드 안내를 주고, 도메인은 HTTP가 아닌 경로도 막습니다. 두 곳의 날짜나 길이 정책이 서로 달라지지 않도록, 고정된 Clock과 경곗값 테스트를 둡니다.

String에 적용된 @Size와 이 예제의 String.length()는 UTF-16 코드 단위를 셉니다. 전송 바이트나 사용자가 보는 글자 수가 요구 사항이라면, 이름과 구현이 분명한 별도의 검증기를 씁니다.

예를 들어 앞의 폼 검증은 strip()한 뒤 코드 포인트를 셌지만, 이 DTO와 PostText는 UTF-16 길이를 씁니다. 제목 😀 40개는 UTF-16 단위로 80개여서 길이 제한을 통과하고, 41개는 82개여서 길이 오류입니다. 사람이 보는 글자 수만 비교하면 두 글의 서로 다른 정책을 놓칩니다.

제목의 앞뒤 공백을 지우는 일도 검증 뒤의 명령 매핑에서 일어납니다. ASCII 80자 제목의 앞뒤에 공백을 하나씩 붙이면 전송된 길이는 82이므로, DTO의 @Size(max=80)를 먼저 위반합니다. strip()한 결과가 80자라는 이유로 통과하지 않으며, 다르게 정규화하려면 전송 규칙과 도메인 정책, 테스트를 함께 바꿔야 합니다.


DB는 현재 상태와 경쟁하는 요청을 판정합니다

메모리 안의 Bean Validation은 이 제목이 현재 회원에게 이미 있는지, 읽은 버전이 아직 최신인지를 원자적으로 보장할 수 없습니다. 저장소는 도메인 값만 받고, DB의 유일성과 버전 제약이 같은 트랜잭션 안에서 최종 판정을 합니다.

src/main/java/board/validationdto/PostStore.java
package board.validationdto;

public interface PostStore {
    long insert(PostDraft draft);

    void replace(
            long postId,
            PostText text,
            long expectedVersion);
}
src/main/java/board/validationdto/PostApplicationService.java
package board.validationdto;

public interface PostApplicationService {
    long register(CreatePostCommand command);

    void revise(UpdatePostCommand command);
}
src/main/java/board/validationdto/DefaultPostApplicationService.java
package board.validationdto;

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

public final class DefaultPostApplicationService
        implements PostApplicationService {
    private final PostStore store;
    private final Clock clock;

    public DefaultPostApplicationService(
            PostStore store,
            Clock clock) {
        this.store = Objects.requireNonNull(store, "store");
        this.clock = Objects.requireNonNull(clock, "clock");
    }

    @Override
    public long register(CreatePostCommand command) {
        Objects.requireNonNull(command, "command");
        var draft = PostDraft.create(
                command.title(),
                command.content(),
                command.publishedOn(),
                LocalDate.now(clock));
        return store.insert(draft);
    }

    @Override
    public void revise(UpdatePostCommand command) {
        Objects.requireNonNull(command, "command");
        var text = new PostText(
                command.title(),
                command.content());
        store.replace(
                command.postId(),
                text,
                command.expectedVersion());
    }
}
규칙처음 친절하게 알려 주는 곳최종으로 판정하는 곳이유
필수와 길이DTO 컴포넌트 제약PostText모든 진입 경로에 공통
게시일이 미래가 아님@PastOrPresent 필드 제약PostDraft와 주입된 ClockHTTP를 거치지 않는 경로 방어
생성과 수정의 형태 차이별도의 DTO 타입별도의 명령엔드포인트와 권한의 차이
현재 회원의 작성 권한애플리케이션 서비스애플리케이션 서비스인증 주체와 애그리거트 조회가 필요
제목 중복선택적인 사전 조회DB 유일성 제약경쟁하는 요청의 원자적 판정
예상 버전 일치DTO의 양수 검사DB의 버전 조건오래된 쓰기의 경쟁 방어

표의 애그리거트(aggregate)는 게시글과 그에 딸린 값처럼 한 단위로 함께 읽고 바꾸는 도메인 객체의 묶음입니다. 현재 회원의 작성 권한은 인증 주체와 함께 이런 묶음을 저장소에서 조회해야 판정할 수 있으므로, DTO가 아니라 애플리케이션 서비스가 맡습니다.

실제 유일성 인덱스와 수정된 행 수는 DB 통합 테스트로 확인해야 합니다. 뒤의 단위 테스트는 store를 호출하기 전의 도메인 방어만 보여 줄 뿐, 특정 DB의 제약이나 트랜잭션 커밋을 확인하지 않습니다.


컨트롤러는 검증된 DTO만 명령으로 매핑합니다

고유한 라우트와 패키지를 써서 이 장의 다른 글의 예제와 격리합니다. DTO의 형태도 테스트 전용 속성이 아니라 main 소스의 Jackson 3 customizer가 정하므로, 이 패키지를 스캔하는 애플리케이션은 같은 규칙을 쓰고 격리된 테스트는 설정 클래스를 명시적으로 import합니다.

src/main/java/board/validationdto/ValidationJsonConfiguration.java
package board.validationdto;

import org.springframework.boot.jackson.autoconfigure.JsonMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import tools.jackson.databind.DeserializationFeature;

@Configuration(proxyBeanMethods = false)
public final class ValidationJsonConfiguration {
    @Bean
    JsonMapperBuilderCustomizer rejectUnknownRequestFields() {
        return builder -> builder.enable(
                DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
    }
}

양수가 아닌 경로 ID를 명령 생성자까지 흘려보내 500으로 만들지 않습니다. HTTP 어댑터가 전용 예외로 표시하고, 어드바이스가 바뀌지 않는 400으로 바꿉니다. 명령의 양수 검사는 HTTP가 아닌 진입 경로를 위한 방어로 그대로 둡니다.

src/main/java/board/validationdto/InvalidPostIdException.java
package board.validationdto;

public final class InvalidPostIdException extends RuntimeException {
    public InvalidPostIdException() {
        super("postId must be positive");
    }
}
src/main/java/board/validationdto/ValidationPostController.java
package board.validationdto;

import jakarta.validation.Valid;
import java.net.URI;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/b88/validation-posts")
public final class ValidationPostController {
    private final PostApplicationService service;

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

    @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<Void> create(
            @Valid @RequestBody CreatePostRequest request) {
        var postId = service.register(request.toCommand());
        return ResponseEntity
                .created(URI.create(
                        "/api/b88/validation-posts/" + postId))
                .build();
    }

    @PutMapping(
            path = "/{postId}",
            consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<Void> replace(
            @PathVariable long postId,
            @Valid @RequestBody UpdatePostRequest request) {
        if (postId <= 0) {
            throw new InvalidPostIdException();
        }
        service.revise(request.toCommand(postId));
        return ResponseEntity.noContent().build();
    }
}

JSON 문법이나 LocalDate 변환이 실패하면 DTO가 완성되지 않아 Bean Validation까지 가지 않습니다. DTO가 만들어졌지만 제약이 실패하면 MethodArgumentNotValidException이 납니다. 양수가 아닌 숫자 경로 ID는 컨트롤러에서 거부합니다. 셋 다 400이지만 문제 코드와 고칠 행동은 달라야 합니다.


ProblemDetail의 필드 코드를 명시적으로 정합니다

응답 필드에는 원래의 값이나 Spring 내부의 후보 배열을 넣지 않고, 필드 이름과 바뀌지 않는 기계용 코드만 넣습니다. FieldError가 들고 있는 거부된 값은 개인 정보일 수 있고, 후보 코드 배열은 객체 이름이나 메시지 코드 생성 규칙에 따라 달라지기 때문입니다. 그래서 아래 FieldViolation은 두 문자열만 가진 레코드로 둡니다.

src/main/java/board/validationdto/FieldViolation.java
package board.validationdto;

public record FieldViolation(String field, String code) {
}

Spring의 제약 이름을 외부 코드로 내보내지 않고 한곳에서 명시적으로 매핑합니다. 프레임워크의 애노테이션이나 메시지 번들을 바꿔도, 이 매핑을 일부러 바꾸지 않는 한 API의 약속은 유지됩니다.

src/main/java/board/validationdto/ApiFieldCodePolicy.java
package board.validationdto;

import java.util.Objects;
import org.springframework.validation.FieldError;

public final class ApiFieldCodePolicy {
    private ApiFieldCodePolicy() {
    }

    public static String externalCode(FieldError error) {
        Objects.requireNonNull(error, "error");
        var internalCode = Objects.requireNonNullElse(
                error.getCode(), "Invalid");
        return switch (error.getField()) {
            case "title" -> switch (internalCode) {
                case "NotBlank" -> "post.title.required";
                case "Size" -> "post.title.length";
                default -> "post.title.invalid";
            };
            case "content" -> switch (internalCode) {
                case "NotBlank" -> "post.content.required";
                case "Size" -> "post.content.length";
                default -> "post.content.invalid";
            };
            case "publishedOn" -> switch (internalCode) {
                case "NotNull" -> "post.published-on.required";
                case "PastOrPresent" -> "post.published-on.future";
                default -> "post.published-on.invalid";
            };
            case "expectedVersion" -> switch (internalCode) {
                case "NotNull" -> "post.version.required";
                case "Positive" -> "post.version.positive";
                default -> "post.version.invalid";
            };
            default -> "request.field.invalid";
        };
    }
}

예외 핸들러는 경로 ID와 JSON 읽기 실패, 제약 실패에 서로 다른 고정 코드를 붙입니다. 필드 배열은 필드 이름과 외부 코드로 정렬해, 리플렉션이나 검증기의 순서에 기대지 않습니다.

src/main/java/board/validationdto/ValidationProblemHandler.java
package board.validationdto;

import java.net.URI;
import java.util.Comparator;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice(assignableTypes = ValidationPostController.class)
public final class ValidationProblemHandler {
    @ExceptionHandler(InvalidPostIdException.class)
    public ResponseEntity<ProblemDetail> invalidPostId() {
        var problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.BAD_REQUEST,
                "게시글 ID는 양수여야 합니다.");
        problem.setType(URI.create(
                "https://andongmin.com/problems/request-path"));
        problem.setTitle("요청 경로 검증 실패");
        problem.setProperty("code", "request.path.invalid");
        problem.setProperty("fields", List.of(
                new FieldViolation("postId", "post.id.positive")));
        return ResponseEntity.badRequest().body(problem);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ProblemDetail> invalidFields(
            MethodArgumentNotValidException failure) {
        var fields = failure.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(error -> new FieldViolation(
                        error.getField(),
                        ApiFieldCodePolicy.externalCode(error)))
                .sorted(Comparator
                        .comparing(FieldViolation::field)
                        .thenComparing(FieldViolation::code))
                .toList();

        var problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.BAD_REQUEST,
                "요청 필드의 형식과 범위를 확인하세요.");
        problem.setType(URI.create(
                "https://andongmin.com/problems/request-validation"));
        problem.setTitle("요청 검증 실패");
        problem.setProperty("code", "request.validation.failed");
        problem.setProperty("fields", fields);
        return ResponseEntity.badRequest().body(problem);
    }

    @ExceptionHandler(HttpMessageNotReadableException.class)
    public ResponseEntity<ProblemDetail> unreadableJson(
            HttpMessageNotReadableException failure) {
        var problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.BAD_REQUEST,
                "JSON 본문을 읽을 수 없습니다.");
        problem.setType(URI.create(
                "https://andongmin.com/problems/request-json"));
        problem.setTitle("요청 본문 읽기 실패");
        problem.setProperty("code", "request.json.unreadable");
        return ResponseEntity.badRequest().body(problem);
    }
}

type URI는 문서화할 수 있는 문제의 유형이고, code는 클라이언트가 분기하는 데 쓰는 값입니다. title과 detail은 사람이 읽는 설명이므로 번역된 문장으로 분기하지 않습니다. JSON 읽기 실패에는 올바른 필드 객체가 없으므로 fields를 만들지 않습니다. 양수가 아닌 경로 ID는 request.path.invalid와 post.id.positive를 쓰며, 거부된 원문 값은 내보내지 않습니다.


명시적인 테스트 애플리케이션으로 MVC를 격리합니다

공유 Gradle 그래프에는 여러 학습용 예제가 있으므로 자동 컴포넌트 스캔에 맡기지 않습니다. 테스트 애플리케이션이 이 글의 컨트롤러와 어드바이스, 기록용 서비스만 명시적으로 가져옵니다.

src/test/java/board/validationdto/RecordingPostApplicationService.java
package board.validationdto;

import java.util.ArrayList;
import java.util.List;

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

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

    @Override
    public void revise(UpdatePostCommand command) {
        commands.add(command);
    }

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

    public void reset() {
        commands.clear();
    }
}
src/test/java/board/validationdto/ValidationDtoTestApplication.java
package board.validationdto;

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({
        ValidationJsonConfiguration.class,
        ValidationPostController.class,
        ValidationProblemHandler.class
})
public class ValidationDtoTestApplication {
    @Bean
    RecordingPostApplicationService postApplicationService() {
        return new RecordingPostApplicationService();
    }
}

패키지 board.validationdto와 테스트 애플리케이션 클래스, 라우트 /api/b88/validation-posts가 모두 이 글 전용입니다. classes = ValidationDtoTestApplication.class도 명시하므로, 공유 그래프에 있는 다른 @SpringBootConfiguration을 탐색하지 않습니다.


JSON 읽기와 필드 검증, DTO의 차이를 실행합니다

MVC 테스트 묶음은 정상적인 생성과 수정의 매핑, 경로 ID와 제약의 차단, 파싱과 변환 실패, DTO마다 알 수 없는 필드를 거부하는 정책, ProblemDetail의 필드 코드, 서비스 호출 수를 함께 확인합니다. 서비스 자리에는 앞의 테스트 애플리케이션이 빈으로 등록한 RecordingPostApplicationService가 들어가고, @BeforeEach의 resetService()가 테스트마다 그 기록을 비웁니다. 그래서 각 테스트는 자신이 보낸 요청으로 만들어진 명령만 service.commands()에서 읽습니다.

src/test/java/board/validationdto/ValidationApiContractTest.java
package board.validationdto;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import java.time.LocalDate;
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.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

@SpringBootTest(classes = ValidationDtoTestApplication.class)
@AutoConfigureMockMvc
class ValidationApiContractTest {
    private static final String ROUTE =
            "/api/b88/validation-posts";

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private RecordingPostApplicationService service;

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

    @Test
    void validCreateJsonMapsExactlyOneCreateCommand() throws Exception {
        mockMvc.perform(post(ROUTE)
                        .contentType(APPLICATION_JSON)
                        .content("""
                                {
                                  "title": "  Bean Validation  ",
                                  "content": "DTO boundary",
                                  "publishedOn": "2000-01-01"
                                }
                                """))
                .andExpect(status().isCreated())
                .andExpect(header().string(
                        HttpHeaders.LOCATION,
                        ROUTE + "/41"));

        assertThat(service.commands()).containsExactly(
                new CreatePostCommand(
                        "Bean Validation",
                        "DTO boundary",
                        LocalDate.of(2000, 1, 1)));
    }

    @Test
    void invalidCreateFieldsReturnStableSortedCodesAndSkipService()
            throws Exception {
        mockMvc.perform(post(ROUTE)
                        .contentType(APPLICATION_JSON)
                        .content("""
                                {
                                  "title": " ",
                                  "content": "",
                                  "publishedOn": "2999-01-01"
                                }
                                """))
                .andExpect(status().isBadRequest())
                .andExpect(content().contentTypeCompatibleWith(
                        MediaType.APPLICATION_PROBLEM_JSON))
                .andExpect(jsonPath("$.type").value(
                        "https://andongmin.com/problems/request-validation"))
                .andExpect(jsonPath("$.code").value(
                        "request.validation.failed"))
                .andExpect(jsonPath("$.fields.length()").value(3))
                .andExpect(jsonPath("$.fields[0].field").value("content"))
                .andExpect(jsonPath("$.fields[0].code").value(
                        "post.content.required"))
                .andExpect(jsonPath("$.fields[1].field").value(
                        "publishedOn"))
                .andExpect(jsonPath("$.fields[1].code").value(
                        "post.published-on.future"))
                .andExpect(jsonPath("$.fields[2].field").value("title"))
                .andExpect(jsonPath("$.fields[2].code").value(
                        "post.title.required"))
                .andExpect(jsonPath("$.fields[0].rejectedValue")
                        .doesNotExist());

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

    @Test
    void malformedJsonAndInvalidDateFailBeforeBeanValidation()
            throws Exception {
        expectUnreadable("{\"title\":\"broken\"");
        expectUnreadable("""
                {
                  "title": "date",
                  "content": "invalid date",
                  "publishedOn": "not-a-date"
                }
                """);

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

    @Test
    void createAndUpdateRejectEachOthersFields() throws Exception {
        expectUnreadable("""
                {
                  "title": "create",
                  "content": "wrong shape",
                  "publishedOn": "2000-01-01",
                  "expectedVersion": 3
                }
                """);

        mockMvc.perform(put(ROUTE + "/41")
                        .contentType(APPLICATION_JSON)
                        .content("""
                                {
                                  "title": "update",
                                  "content": "wrong shape",
                                  "expectedVersion": 3,
                                  "publishedOn": "2000-01-01"
                                }
                                """))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value(
                        "request.json.unreadable"));

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

    @Test
    void validUpdateMapsVersionedCommandWithoutPublishedDate()
            throws Exception {
        mockMvc.perform(put(ROUTE + "/41")
                        .contentType(APPLICATION_JSON)
                        .content("""
                                {
                                  "title": "  revised  ",
                                  "content": "replacement body",
                                  "expectedVersion": 3
                                }
                                """))
                .andExpect(status().isNoContent());

        assertThat(service.commands()).containsExactly(
                new UpdatePostCommand(
                        41L,
                        "revised",
                        "replacement body",
                        3L));
    }

    @Test
    void nonPositiveUpdateIdReturnsStablePathProblemAndSkipsService()
            throws Exception {
        mockMvc.perform(put(ROUTE + "/0")
                        .contentType(APPLICATION_JSON)
                        .content("""
                                {
                                  "title": "revised",
                                  "content": "replacement body",
                                  "expectedVersion": 3
                                }
                                """))
                .andExpect(status().isBadRequest())
                .andExpect(content().contentTypeCompatibleWith(
                        MediaType.APPLICATION_PROBLEM_JSON))
                .andExpect(jsonPath("$.type").value(
                        "https://andongmin.com/problems/request-path"))
                .andExpect(jsonPath("$.status").value(400))
                .andExpect(jsonPath("$.code").value(
                        "request.path.invalid"))
                .andExpect(jsonPath("$.fields.length()").value(1))
                .andExpect(jsonPath("$.fields[0].field").value("postId"))
                .andExpect(jsonPath("$.fields[0].code").value(
                        "post.id.positive"))
                .andExpect(jsonPath("$.fields[0].rejectedValue")
                        .doesNotExist());

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

    @Test
    void missingUpdateVersionUsesTheVersionFieldCodeContract()
            throws Exception {
        mockMvc.perform(put(ROUTE + "/41")
                        .contentType(APPLICATION_JSON)
                        .content("""
                                {
                                  "title": "revised",
                                  "content": "replacement body"
                                }
                                """))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value(
                        "request.validation.failed"))
                .andExpect(jsonPath("$.fields.length()").value(1))
                .andExpect(jsonPath("$.fields[0].field").value(
                        "expectedVersion"))
                .andExpect(jsonPath("$.fields[0].code").value(
                        "post.version.required"));

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

    private void expectUnreadable(String body) throws Exception {
        mockMvc.perform(post(ROUTE)
                        .contentType(APPLICATION_JSON)
                        .content(body))
                .andExpect(status().isBadRequest())
                .andExpect(content().contentTypeCompatibleWith(
                        MediaType.APPLICATION_PROBLEM_JSON))
                .andExpect(jsonPath("$.type").value(
                        "https://andongmin.com/problems/request-json"))
                .andExpect(jsonPath("$.code").value(
                        "request.json.unreadable"))
                .andExpect(jsonPath("$.fields").doesNotExist());
    }
}

2999-01-01은 이 교재를 쓰는 시점에서 미래이므로, MVC 테스트에서 @PastOrPresent의 필드 실패를 안정적으로 볼 수 있습니다. 오늘이라는 정확한 기준은 기본 구현체의 시계에 기대지 않고, 다음 도메인 테스트의 고정된 Clock으로 확인합니다.


도메인 우회와 store 호출을 실행합니다

단위 테스트는 HTTP를 거치지 않은 명령이 도메인 불변식을 어기면 store를 호출하지 않는지 확인합니다. 올바른 경곗값은 정확한 도메인 값으로 한 번만 전달됩니다.

src/test/java/board/validationdto/DomainBoundaryTest.java
package board.validationdto;

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

import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import org.junit.jupiter.api.Test;

class DomainBoundaryTest {
    private static final Clock FIXED_CLOCK = Clock.fixed(
            Instant.parse("2026-03-10T00:00:00Z"),
            ZoneId.of("Asia/Seoul"));

    @Test
    void futureCreateCommandIsRejectedBeforeStoreInsert() {
        var store = new RecordingPostStore();
        var service = new DefaultPostApplicationService(
                store, FIXED_CLOCK);

        assertThatThrownBy(() -> service.register(
                new CreatePostCommand(
                        "future",
                        "bypassed HTTP validation",
                        LocalDate.of(2026, 3, 11))))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("publishedOn must not be in the future");

        assertThat(store.inserted).isNull();
        assertThat(store.replacement).isNull();
    }

    @Test
    void blankUpdateCommandIsRejectedBeforeStoreReplace() {
        var store = new RecordingPostStore();
        var service = new DefaultPostApplicationService(
                store, FIXED_CLOCK);

        assertThatThrownBy(() -> service.revise(
                new UpdatePostCommand(
                        41L,
                        " ",
                        "bypassed HTTP validation",
                        3L)))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("title is invalid");

        assertThat(store.inserted).isNull();
        assertThat(store.replacement).isNull();
    }

    @Test
    void todayAndVersionedReplacementCrossTheStoreBoundaryOnce() {
        var store = new RecordingPostStore();
        var service = new DefaultPostApplicationService(
                store, FIXED_CLOCK);

        var postId = service.register(new CreatePostCommand(
                "today",
                "valid domain state",
                LocalDate.of(2026, 3, 10)));
        service.revise(new UpdatePostCommand(
                postId,
                "revised",
                "replacement body",
                3L));

        assertThat(postId).isEqualTo(41L);
        assertThat(store.inserted).isNotNull();
        assertThat(store.inserted.text()).isEqualTo(
                new PostText("today", "valid domain state"));
        assertThat(store.inserted.publishedOn()).isEqualTo(
                LocalDate.of(2026, 3, 10));
        assertThat(store.replacement).isEqualTo(new Replacement(
                41L,
                new PostText("revised", "replacement body"),
                3L));
    }

    private static final class RecordingPostStore implements PostStore {
        private PostDraft inserted;
        private Replacement replacement;

        @Override
        public long insert(PostDraft draft) {
            if (inserted != null) {
                throw new AssertionError("insert called more than once");
            }
            inserted = draft;
            return 41L;
        }

        @Override
        public void replace(
                long postId,
                PostText text,
                long expectedVersion) {
            if (replacement != null) {
                throw new AssertionError("replace called more than once");
            }
            replacement = new Replacement(
                    postId, text, expectedVersion);
        }
    }

    private record Replacement(
            long postId,
            PostText text,
            long expectedVersion) {
    }
}

이 테스트는 store까지의 호출과 인자를 보여 줍니다. 유일성 인덱스와 낙관적 갱신의 행 수, 트랜잭션의 롤백과 커밋은 실제 저장소 어댑터의 통합 테스트가 따로 확인해야 합니다.


정리

  • JSON 읽기와 타입 변환이 실패하면 DTO가 만들어지지 않아 Bean Validation까지 가지 않으며, 응답에는 fields가 없습니다.
  • 생성과 전체 교체는 서로 다른 DTO로 모델링하고, PATCH는 값이 없는 것과 null의 의미를 먼저 정한 전용 타입을 씁니다.
  • 검증을 통과한 DTO의 허용된 필드만 명령으로 복사하며, 도메인은 HTTP를 거치지 않는 경로를 위해 불변식을 다시 검사합니다.
  • 외부 API의 문제 코드와 필드 코드는 Spring의 제약 이름과 따로 한곳에서 매핑하고, 거부된 원문은 내보내지 않습니다.
  • 제목 중복과 버전 충돌은 DB가 트랜잭션 안에서 최종 판정하므로 DB 통합 테스트로 확인해야 합니다.

연습 문제

1. 선택 요약 필드 추가하기

선택 입력인 summary를 생성과 수정에 추가하려고 합니다. 값이 있으면 120자 이하이고 본문보다 길 수 없습니다. 단일 필드의 길이와 두 필드의 관계를 어디에 두고, API의 필드 코드 post.summary.length와 객체 코드 post.summary.longer-than-content를 어떻게 매핑하며, HTTP를 거치지 않는 경로는 어떻게 막아야 할까요? JSON 읽기 실패와 DTO 제약 실패, 도메인 실패, DB 경쟁 실패는 어떻게 구분하나요?

해설 보기

단일 필드의 길이는 레코드 컴포넌트에 @Size(max = 120)처럼 두고, null이면 통과하게 합니다. summary와 content의 관계는 하나의 일만 하는 클래스 수준 제약으로 두되, 두 값 가운데 하나라도 없으면 비교하지 않습니다. ApiFieldCodePolicy에는 summary 필드의 Size를 post.summary.length로, 클래스 제약을 post.summary.longer-than-content로 매핑하는 항목을 명시적으로 더합니다.

PostText에도 같은 관계 검사를 넣어, 배치나 메시지 소비자가 만든 명령도 store 전에 막히게 합니다. 실패의 종류는 응답 코드로 나눕니다. JSON 읽기 실패는 request.json.unreadable이고 fields가 없으며, DTO 제약 실패는 request.validation.failed와 정렬된 fields입니다. 도메인 실패는 HTTP 경로에서는 DTO가 먼저 막으므로 단위 테스트에서 store 호출이 없는지로 확인하고, DB 경쟁 실패는 409 같은 별도의 코드로 바꿔 400과 섞지 않습니다.

2. 네 요청의 첫 실패 위치

각 요청은 MVC 예제의 서비스 기록을 비운 새 상태에서 시작하며, JSON의 Content-Type은 올바릅니다. A는 생성 POST에 제목 😀 41개, 본문 body, 게시일 2000-01-01을 보냅니다. B는 경로 ID 0의 PUT에 올바른 제목과 본문을 보내고 expectedVersion은 생략합니다. C는 B에 expectedVersion=3을 더합니다. D는 올바른 생성 필드에 DTO에 없는 approved=true를 더합니다. 각각의 상태와 최종 문제 코드, fields의 유무와 내용, 서비스 호출 횟수, 처음 실패한 단계를 정하세요. 이 결과로 도메인과 실제 DB의 경쟁 처리까지 검증했다고 할 수 있나요?

해설 보기

A는 DTO의 Size 실패입니다. 400과 request.validation.failed이고, fields에는 title과 post.title.length 한 쌍이 있습니다. 코드 포인트 41개가 아니라 UTF-16 82개를 검사하며 서비스는 0회입니다.

B는 expectedVersion의 NotNull 실패로 400과 request.validation.failed이고, fields에는 expectedVersion과 post.version.required 한 쌍이 있습니다. ID 0은 long으로 읽을 수 있지만, 본문 인자의 검증이 핸들러에 들어가기 전에 실패하므로 메서드 안의 경로 양수 검사까지 가지 않습니다. C는 DTO를 통과한 뒤 핸들러의 ID 검사에서 실패해 400과 request.path.invalid, postId와 post.id.positive 한 쌍입니다. B와 C 모두 서비스는 0회입니다.

D는 알 수 없는 속성의 거부로 DTO를 만들지 못한 JSON 읽기 실패입니다. 400과 request.json.unreadable이며 fields 자체가 없고 서비스는 0회입니다. DTO 검증이나 명령 생성 뒤에 생긴 오류로 분류하지 않습니다.

이 MVC 예제는 기록용 서비스에 넘긴 명령만 검사합니다. DefaultPostApplicationService의 도메인 방어는 별도의 단위 테스트이고, 실제 DB 어댑터는 없습니다. 따라서 이 응답들로 도메인 우회 방어와 유일성 경쟁, 버전 갱신의 행 수, 커밋을 한꺼번에 확인했다고 할 수 없습니다. 네 입력의 결과는 코드의 실행 순서에서 끌어낸 예상이므로 테스트를 실행해 확인합니다.

이것으로 7장을 마칩니다. 7장에서는 Thymeleaf의 안전한 출력에서 시작해 조건과 반복, 조각, 폼 바인딩과 선택 컨트롤, 메시지와 국제화, 바인딩 오류와 오류 코드를 거쳐, Bean Validation과 DTO, 명령, 도메인, DB로 이어지는 검증 단계까지 연결했습니다. 다음 장의 쿠키 로그인 취약점부터는 로그인 상태와 세션, 필터, 인터셉터, 오류 재디스패치처럼 요청의 생명 주기 전체에 걸친 웹 기능을 다룹니다.