Bean 검증과 DTO
JSON 읽기와 Bean Validation을 분리하고, 생성·수정 DTO에서 command·domain·DB까지 이어지는 검증 경계를 실행합니다.
Bean Validation은 JSON 문자열을 읽는 파서도, 도메인과 데이터베이스를 대신하는 최종 방어선도 아닙니다. HTTP 본문이 DTO로 정상 생성된 다음 @Valid가 전송 계약을 검사하고, 통과한 값만 명시적으로 command가 됩니다. 그 뒤에도 domain invariant와 DB 제약은 다른 진입 경로와 동시 요청을 방어합니다.
JSON은 DTO 검증을 거쳐 command·domain·DB 경계로 이동한다
MVC 기록 서비스 suite의 정상 생성 POST만 추적합니다. 실제 domain/store 단위 검사와 DB 통합 계약은 별도 실행·검증 범위입니다.
FLOWCHART · JSON READ · BEAN VALIDATION · DOMAIN · DATABASE
JSON 읽기·DTO 검증·domain 방어의 실행 범위를 구분한다
생성과 전체 교체는 다른 DTO를 씁니다. HTTP 실패는 안정적인 ProblemDetail 코드로 나누고, 별도 domain suite는 store 호출 전 불변식을 확인합니다. DB 경쟁 검사는 추가 통합 계약입니다.
원 그림과 같은 설계상 경계입니다. 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를 한 실행으로 합치지 않는다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | JSON은 DTO 검증을 거쳐 command·domain·DB 경계로 이동한다 |
| 2 | 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은 별도 통합 검사입니다. |
| 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 통합 계약은 별도 실행·검증 범위입니다.
FLOWCHART · JSON READ · BEAN VALIDATION · DOMAIN · DATABASE
JSON 읽기·DTO 검증·domain 방어의 실행 범위를 구분한다
생성과 전체 교체는 다른 DTO를 씁니다. HTTP 실패는 안정적인 ProblemDetail 코드로 나누고, 별도 domain suite는 store 호출 전 불변식을 확인합니다. DB 경쟁 검사는 추가 통합 계약입니다.
원 그림과 같은 설계상 경계입니다. 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를 한 실행으로 합치지 않는다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | JSON은 DTO 검증을 거쳐 command·domain·DB 경계로 이동한다 |
| 2 | 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은 별도 통합 검사입니다. |
| 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의 근거는 원표에 보존합니다. |
이 문서는 생성 POST와 전체 교체 PUT을 서로 다른 DTO로 모델링합니다. 부분 수정 PATCH는 “속성 없음”, 명시적 null, 기존 값 유지의 의미를 별도로 정해야 하므로 이 DTO를 재사용하지 않습니다.
생성 DTO는 초기 상태를 완성한다
Spring Boot 4.1과 Spring Framework 7의 Bean Validation API는 jakarta.validation.* 패키지를 사용합니다. 레코드 컴포넌트에 제약을 두면 JSON 생성자 바인딩으로 만든 DTO를 검증할 수 있습니다.
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 provider의 ClockProvider가 주는 현재 날짜와 비교합니다. 두 필드 사이의 순서나 DB 상태를 비교하는 클래스 수준 규칙이 아닙니다. 운영에서 업무 시간대가 중요하면 provider clock과 애플리케이션 Clock의 zone 정책을 맞춥니다.
DTO에는 memberId, approved, createdAt, DB 식별자가 없습니다. 인증 주체, 승인 정책, 서버 시계, 저장 결과가 소유하는 값이기 때문입니다. 모르는 JSON 속성은 뒤의 운영 ValidationJsonConfiguration이 거부합니다. 이 정책과 별개로 민감한 세터를 DTO에 추가하지 않는 것이 우선입니다.
수정 DTO는 생성 DTO가 아니다
이 예제의 PUT은 제목과 본문 전체를 교체하고 예상 버전을 요구합니다. 최초 게시일은 수정할 수 없으므로 publishedOn이 없고, 생성에는 필요 없는 expectedVersion이 있습니다.
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);
}
}| 계약 | CreatePostRequest | UpdatePostRequest |
|---|---|---|
| HTTP 의미 | 새 게시글 생성 | 기존 게시글 전체 교체 |
| 필수 전송 값 | title, content, publishedOn | title, content, expectedVersion |
| 서버가 결정하는 값 | ID, 작성자, 초기 버전 | 대상 ID는 path, 게시일은 보존 |
| 동시성 입력 | 없음 | 양의 expectedVersion |
| 속성 부재 | 생성 필수값 오류 | PUT 필수값 오류 |
명시적 null | 해당 필드 제약 위반 | 해당 필드 제약 위반 |
검증 그룹은 같은 객체 모양에서 실행 시점만 달라질 때 유용합니다. 필드 모양과 권한이 다르면 별도 타입이 더 명확합니다. PATCH가 필요하면 JSON Merge Patch나 존재 여부를 보존하는 전용 입력 타입으로 계약부터 정합니다. 일반 Optional<T>만으로는 Jackson 설정에 따라 속성 부재와 명시적 null이 같은 빈 값으로 합쳐질 수 있습니다.
command는 전송 모델을 애플리케이션 입력으로 바꾼다
Command는 HTTP 애노테이션을 갖지 않고 use case 입력만 표현합니다. DTO에서 허용한 값을 명시적으로 복사하므로 mass assignment 경계도 눈에 보입니다.
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");
}
}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");
}
}
}Command의 null·식별자 검사는 생성 자체의 최소 조건입니다. 문자열 길이와 날짜 같은 업무 상태 규칙은 domain 타입이 다시 검사합니다. HTTP를 우회해 배치나 메시지 소비자가 command를 만들더라도 잘못된 domain 상태가 저장소로 가지 않아야 합니다.
domain은 모든 진입 경로를 다시 방어한다
제목과 본문의 공통 불변식은 전송 DTO가 아니라 domain 값에 둡니다.
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으로 계산해 domain factory에 명시적으로 넘깁니다.
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와 domain의 날짜 검사가 겹치지만 목적이 다릅니다. DTO는 HTTP 클라이언트에 빠른 필드 피드백을 주고, domain은 HTTP가 아닌 경로도 막습니다. 두 경계가 서로 다른 날짜나 길이 정책을 갖지 않게 고정 Clock과 경계값 계약 테스트를 둡니다.
@Size가 String에 적용될 때와 이 예제의 String.length()는 UTF-16 코드 단위를 셉니다. 전송 바이트나 사용자가 보는 글자소 수가 계약이라면 이름과 구현이 분명한 별도 validator를 사용합니다.
예시
앞 문서의 폼 검증은 strip 후 code point를 세지만 이 DTO와 PostText는 UTF-16 길이를 씁니다. 제목 😀 40개는 UTF-16 단위 80개여서 길이 경계를 통과하고, 41개는 82개여서 길이 오류입니다. 사람이 보는 글자 수만 같게 비교하면 두 문서의 다른 정책을 놓칩니다.
제목 앞뒤의 공백을 지우는 것도 검증 이후 command 매핑에서 일어납니다. ASCII 제목 80개 앞뒤에 공백을 한 개씩 붙이면 원 전송 길이는 82개입니다. DTO의 @Size(max=80)를 먼저 위반하므로 strip 결과가 80개라는 이유로 통과하지 않습니다. 별도 정규화가 필요하다면 전송 계약·domain 정책·테스트를 함께 변경해야 합니다.
DB 경계는 현재 상태와 경쟁 요청을 소유한다
메모리 안의 Bean Validation은 “이 제목이 현재 회원에게 이미 있는가”, “읽은 버전이 아직 최신인가”를 원자적으로 보장할 수 없습니다. 저장 계약은 domain 값만 받고, DB의 unique/version 제약과 같은 트랜잭션 안에서 최종 판정을 수행합니다.
package board.validationdto;
public interface PostStore {
long insert(PostDraft draft);
void replace(
long postId,
PostText text,
long expectedVersion);
}package board.validationdto;
public interface PostApplicationService {
long register(CreatePostCommand command);
void revise(UpdatePostCommand command);
}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 component constraint | PostText | 모든 진입 경로 공통 |
| 게시일이 미래가 아님 | @PastOrPresent field constraint | PostDraft + 주입된 Clock | HTTP 우회 경로 방어 |
| 생성·수정 shape 차이 | 별도 DTO 타입 | 별도 command | endpoint 계약과 권한 차이 |
| 현재 회원의 작성 권한 | 애플리케이션 서비스 | 애플리케이션 서비스 | 인증 주체와 aggregate 조회 필요 |
| 제목 중복 | 선택적 사전 조회 | DB unique constraint | 경쟁 요청의 원자적 판정 |
| 예상 버전 일치 | DTO 양수 검사 | DB version 조건 | stale write 경쟁 방어 |
실제 unique index와 update row-count는 DB 통합 테스트가 필요합니다. 뒤의 단위 테스트는 store 호출 전 domain 방어를 증명할 뿐, 특정 DB의 제약이나 transaction commit을 증명하지 않습니다.
컨트롤러는 검증된 DTO만 command로 매핑한다
고유 라우트와 패키지를 사용해 B88의 다른 문서 fixture와 격리합니다. DTO shape도 테스트 전용 property가 아니라 main source의 Jackson 3 customizer가 소유합니다. 이 패키지를 스캔하는 애플리케이션은 같은 계약을 사용하고, 격리 테스트는 설정 클래스를 명시적으로 import합니다.
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);
}
}양수가 아닌 path ID는 command 생성자까지 흘려보내 500으로 만들지 않습니다. HTTP 어댑터가 전용 예외로 경계를 표시하고 advice가 안정적인 400 계약으로 바꿉니다. Command의 양수 검사는 HTTP 이외의 진입 경로를 위한 방어로 그대로 유지합니다.
package board.validationdto;
public final class InvalidPostIdException extends RuntimeException {
public InvalidPostIdException() {
super("postId must be positive");
}
}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가 생성됐지만 constraint가 실패하면 MethodArgumentNotValidException이 발생합니다. 양수가 아닌 숫자 path ID는 컨트롤러 경계에서 거부합니다. 셋 다 400이지만 문제 코드와 수정 행동은 달라야 합니다.
ProblemDetail의 필드 코드를 명시적으로 소유한다
응답 필드에는 원래 값이나 Spring 내부 후보 배열을 넣지 않습니다. 외부 계약은 필드명과 안정적인 기계 코드만 소유합니다.
package board.validationdto;
public record FieldViolation(String field, String code) {
}Spring의 constraint 이름을 외부 코드로 내보내지 않고 한 곳에서 명시적으로 매핑합니다. 프레임워크 애노테이션이나 메시지 번들을 바꿔도 이 매핑을 의도적으로 바꾸지 않는 한 API 계약은 유지됩니다.
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";
};
}
}예외 handler는 path ID 경계, JSON 읽기 실패, constraint 실패에 서로 다른 안정 코드를 부여합니다. 필드 배열은 필드명과 외부 코드로 정렬해 reflection이나 validator 순서에 의존하지 않습니다.
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를 만들지 않습니다. 양수가 아닌 path ID는 request.path.invalid와 post.id.positive를 사용하며 거부된 원문 값은 내보내지 않습니다.
명시적인 테스트 애플리케이션으로 MVC를 격리한다
공유 Gradle 그래프에는 여러 교육용 fixture가 있으므로 자동 component scan에 맡기지 않습니다. 테스트 애플리케이션이 이 문서의 컨트롤러·advice와 기록 서비스만 명시적으로 가져옵니다.
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();
}
}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, 테스트 application class, /api/b88/validation-posts route가 모두 이 문서 전용입니다. classes = ValidationDtoTestApplication.class도 명시하므로 공유 그래프의 다른 @SpringBootConfiguration을 탐색하지 않습니다.
JSON 읽기·필드 검증·DTO 차이를 실행한다
MVC suite는 정상 생성·수정 mapping, path ID·constraint 차단, parse/변환 실패, DTO별 unknown field 정책, ProblemDetail 필드 코드, 서비스 호출 수를 함께 검증합니다.
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의 field-level 실패를 안정적으로 관찰합니다. “오늘”의 정확한 경계는 기본 provider 시계에 기대지 않고 다음 domain 테스트의 고정 Clock으로 검증합니다.
domain 우회와 store 호출 경계를 실행한다
단위 테스트는 HTTP를 거치지 않은 command가 domain invariant를 위반하면 store를 호출하지 않는지 확인합니다. 정상 경계값은 정확한 domain 값으로 한 번만 전달됩니다.
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 경계까지의 호출과 인자를 증명합니다. unique index, optimistic update의 row count, transaction rollback·commit은 실제 저장소 adapter 통합 테스트가 별도로 소유해야 합니다.
연습 문제
선택적인 summary를 생성과 수정 계약에 추가하세요. 값이 있으면 120자 이하이고 본문보다 길 수 없습니다.
- 단일 필드 길이는 component constraint로 둡니다.
summary와content관계는 한 책임의 class-level constraint로 둡니다.- API 필드 코드
post.summary.length와 객체 코드post.summary.longer-than-content를 명시적으로 매핑합니다. PostText도 같은 관계를 방어해 HTTP 우회 경로를 막습니다.- JSON 읽기 실패, DTO constraint 실패, domain 실패, DB 경쟁 실패를 같은 400으로 뭉치지 않습니다.
독립 확인
각 요청은 원 MVC fixture의 서비스 기록을 비운 새 상태에서 시작하며 JSON Content-Type은 올바릅니다. A는 생성 POST에 제목 😀 41개·본문 body·게시일 2000-01-01을 보냅니다. B는 PUT의 path ID 0에 정상 제목·본문을 보내고 expectedVersion을 생략합니다. C는 B에 expectedVersion=3을 추가합니다. D는 정상 생성 필드에 DTO에 없는 approved=true를 추가합니다.
상태뿐 아니라 최종 문제 code, fields의 유무·내용, 서비스 호출 횟수와 첫 실패 경계를 정하세요. 이 결과로 domain과 실제 DB의 경쟁 처리까지 검증했다고 말할 수 있는지도 판단하세요.
먼저 끝나는 경계와 같은 400의 다른 의미
A는 DTO의 Size 실패입니다. 400·request.validation.failed이고 fields에는 title/post.title.length 한 쌍이 있습니다. code point 41개가 아니라 UTF-16 82개를 검사하며 서비스 0회입니다.
B는 expectedVersion의 NotNull 실패로 400·request.validation.failed, fields 한 쌍은 expectedVersion/post.version.required입니다. ID 0은 long으로 읽을 수 있지만 본문 인자의 검증이 핸들러 진입 전에 실패하므로 메서드 안의 path 양수 검사까지 가지 않습니다. C는 DTO를 통과한 뒤 핸들러의 ID 검사가 실패해 400·request.path.invalid, postId/post.id.positive 한 쌍입니다. B와 C 모두 서비스 0회입니다.
D는 unknown-property 거부로 DTO 생성이 완료되지 않는 JSON 읽기 실패입니다. 400·request.json.unreadable이며 fields 자체가 없고 서비스 0회입니다. DTO 검증이나 command 생성 뒤에 발생한 오류로 분류하지 않습니다.
이 MVC fixture는 기록 서비스에 전달한 command를 검사합니다. DefaultPostApplicationService의 domain 방어는 별도 단위 suite이고 실제 DB adapter는 없습니다. 따라서 위 응답 판정은 domain 우회 방어·unique 경쟁·version update row count·commit을 한꺼번에 증명하지 않습니다. 새 네 입력은 원 코드의 실행 순서에서 도출한 예상이며 새 MVC 실행 기록은 아닙니다.
이 장에서는 안전한 출력부터 폼 바인딩, 선택 제어, 국제화, 오류 코드, Bean Validation과 domain 경계까지 연결했습니다. 다음 장은 로그인 상태·필터·인터셉터·오류 재디스패치처럼 요청 생명 주기 전체의 웹 기능을 통합합니다.