Bean 검증과 DTO
JSON 읽기와 Bean Validation을 분리하고, 생성·수정 DTO에서 command·domain·DB까지 이어지는 검증 경계를 실행합니다.
Bean Validation은 JSON 문자열을 읽는 파서도, 도메인과 데이터베이스를 대신하는 최종 방어선도 아닙니다. HTTP 본문이 DTO로 정상 생성된 다음 @Valid가 전송 계약을 검사하고, 통과한 값만 명시적으로 command가 됩니다. 그 뒤에도 domain invariant와 DB 제약은 다른 진입 경로와 동시 요청을 방어합니다.
FLOWCHART · JSON READ · BEAN VALIDATION · DOMAIN · DATABASE
JSON은 DTO 검증을 거쳐 command·domain·DB 경계로 이동한다
JSON 읽기와 Bean Validation은 서로 다른 400 경계다. 통과한 생성·수정 DTO만 command로 매핑되며, domain과 DB가 우회 경로와 경쟁 요청을 다시 방어한다.
-
JSON READ
Jackson이 JSON 문법과 필드 타입을 읽는다
실패하면 DTO 없이
request.json.unreadable400으로 끝나며fields와 service 호출이 없습니다. -
ENDPOINT DTO
생성과 전체 수정은 서로 다른 입력 타입을 만든다
Create는
publishedOn, Update는expectedVersion을 소유하며 서로의 필드를 받지 않습니다. -
BEAN VALIDATION
DTO constraint가 클라이언트가 고칠 필드를 검사한다
실패하면
request.validation.failed와 정렬된fields[].field·안정 code를 반환하며 원문 값은 노출하지 않습니다. -
FIELD-LEVEL DATE
@PastOrPresent는 게시일 하나를 현재 날짜와 비교한다
필드 간 관계나 DB 상태는 이 애노테이션의 범위가 아닙니다.
-
EXPLICIT COMMAND
검증된 DTO의 허용 필드만 command로 복사한다
전송 shape와 애플리케이션 use case 입력을 분리합니다.
-
DOMAIN
PostText와 PostDraft가 핵심 불변식을 다시 검사한다
배치·메시지처럼 HTTP를 우회한 command도 store 전에 막습니다.
-
DATABASE
transaction에서 unique와 expected version을 원자적으로 판정한다
경쟁 요청의 최종 결과는 실제 DB 통합 계약이 소유합니다.
같은 규칙이 여러 경계에 보이는 것은 중복 실수가 아니라 목적이 다른 방어다. DTO는 친절한 HTTP 피드백, domain은 모든 진입 경로, DB는 동시성의 최종 진실을 소유한다.
이 문서는 생성 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를 사용합니다.
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으로 뭉치지 않습니다.
이 장에서는 안전한 출력부터 폼 바인딩, 선택 제어, 국제화, 오류 코드, Bean Validation과 domain 경계까지 연결했습니다. 다음 장은 로그인 상태·필터·인터셉터·오류 재디스패치처럼 요청 생명주기 전체의 웹 기능을 통합합니다.