Bean 검증과 DTO
Jakarta 검증으로 게시판 생성·수정 DTO를 분리하고 @Valid, 필드 간 규칙, ProblemDetail 응답과 서비스 검증의 책임을 실제 요청으로 확인합니다.
Bean 검증 애노테이션은 반복되는 null·길이·범위 검사를 선언적으로 표현하지만, 애노테이션을 붙였다고 검증 설계가 끝나지는 않습니다.
어느 객체에 어떤 요청 시점의 규칙을 붙일지, 관계 검증과 데이터베이스 상태 검증을 어디서 수행할지 결정해야 합니다.
게시판에서는 생성과 수정 DTO를 분리하고 도메인 불변식은 모든 호출 경로에서 다시 지키는 구조를 사용합니다.
생성 DTO 허용 필드
Spring Boot 4.1과 Spring 프레임워크 7에서는 jakarta.validation.* 패키지를 사용합니다.
오래된 javax.validation 가져오기를 섞지 않습니다.
레코드 컴포넌트에 제약 조건을 두면 JSON 생성자 바인딩 결과를 검증할 수 있습니다.
package board.api;
import java.time.LocalDate;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PastOrPresent;
import jakarta.validation.constraints.Size;
public record CreatePostRequest(
@NotBlank(message = "{post.title.required}")
@Size(max = 80, message = "{post.title.length}")
String title,
@NotBlank(message = "{post.content.required}")
@Size(max = 720, message = "{post.content.length}")
String content,
@NotNull(message = "{post.date.required}")
@PastOrPresent(message = "{post.date.future}")
LocalDate publishedOn
) {
}메시지에 한국어 문장을 직접 넣지 않고 번들 키를 씁니다.
post.content.required와 post.content.length를 나누면 빈 본문과 길이 초과를 서로 다른 행동 지침으로 안내할 수 있습니다.
문구와 애노테이션 인자가 일치하는지 최종 렌더링을 확인합니다.
DTO에는 memberId, approved, createdAt이 없습니다.
인증 회원, 승인 정책, 서버 시계에서 채울 값입니다.
JSON 파서가 모르는 속성을 거부할지 무시할지는 Jackson 설정과 호환성 정책으로 정하되, DTO에 민감한 세터를 만들지 않는 것이 우선입니다.
생성·수정 DTO 분리
검증 그룹 하나에 모든 시나리오를 넣으면 어떤 애노테이션이 어느 엔드포인트에서 실행되는지 찾기 어렵습니다. 전체 교체 PUT, 부분 수정 패치, 생성 POST의 형태가 다르다면 타입을 분리하는 편이 명확합니다.
package board.api;
import java.time.LocalDate;
import java.util.Optional;
import jakarta.validation.constraints.Size;
public record UpdatePostRequest(
Optional<@Size(min = 1, max = 80) String> title,
Optional<@Size(min = 1, max = 720) String> content,
Optional<LocalDate> publishedOn
) {
public UpdatePostRequest {
title = title == null ? Optional.empty() : title;
content = content == null ? Optional.empty() : content;
publishedOn = publishedOn == null ? Optional.empty() : publishedOn;
}
}이 예제는 “속성 없음”을 Optional.empty()로 표현하지만 명시적 JSON null과 없음은 구별하지 않습니다.
null로 지우는 패치가 필요하면 null 허용 래퍼나 JSON 병합 패치처럼 의미가 정의된 형식을 선택합니다.
Optional을 엔티티 필드나 JPA 열에 퍼뜨리지 않고 전송 경계에서만 사용합니다.
단순히 생성 DTO의 필드를 null 허용으로 바꿔 패치에 재사용하면 필수 규칙을 잃습니다.
그룹은 같은 형태에서 실행 시점만 달라질 때 유용하고, 형태와 권한까지 다르면 별도 타입이 읽기 쉽습니다.
@Valid 실행 시점
package board.api;
import java.net.URI;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/posts")
public final class PostApiController {
private final PostApplicationService service;
public PostApiController(PostApplicationService service) {
this.service = service;
}
@PostMapping
ResponseEntity<PostResponse> create(
@Valid @RequestBody CreatePostRequest request
) {
var command = new CreatePostCommand(
request.title().strip(),
request.content(),
request.publishedOn());
PostResponse saved = service.register(command);
return ResponseEntity
.created(URI.create("/api/posts/" + saved.id()))
.body(saved);
}
}JSON 문법이나 타입 변환이 실패하면 Bean 검증까지 가지 않고 읽을 수 없는 본문 오류가 납니다.
JSON은 정상인데 제약 조건이 실패하면 MethodArgumentNotValidException이 발생합니다.
두 경우 모두 400이어도 문제 코드와 필드 목록을 구분해야 클라이언트가 수정 가능한 행동을 선택할 수 있습니다.
HTML 폼에서 @Valid @ModelAttribute 뒤에 BindingResult가 있으면 같은 뷰로 돌아갈 수 있습니다.
REST 본문은 질문 전역 예외 핸들러가 안정적인 오류 표현으로 바꿉니다.
어댑터별 출력만 다르고 제약 조건 자체는 DTO 계약을 표현합니다.
ProblemDetail 검증 오류
package board.api;
import java.net.URI;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public final class ValidationExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
ProblemDetail invalidRequest(MethodArgumentNotValidException exception) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST,
"요청 필드의 형식과 범위를 확인하세요.");
problem.setType(URI.create("https://board.example/problems/validation"));
problem.setTitle("요청 검증 실패");
problem.setProperty("code", "request-validation-failed");
List<FieldViolation> fields = exception.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> new FieldViolation(
error.getField(),
error.getCode(),
error.getDefaultMessage()))
.toList();
problem.setProperty("fields", fields);
return problem;
}
record FieldViolation(String field, String code, String message) {
}
}rejectedValue는 비밀번호, 토큰, 개인 정보일 수 있어 기본 응답에 넣지 않습니다.
필드 순서도 서버 구현 우연에 의존하지 않도록 필요하면 정렬합니다.
type URI는 문서화 가능한 안정적 식별자이고 detail은 사람이 읽는 설명입니다.
클라이언트 분기는 번역 문장이 아니라 code와 필드 코드를 사용합니다.
서비스 호출 차단 검증
package board.api;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
@WebMvcTest({PostApiController.class, ValidationExceptionHandler.class})
class PostApiValidationTest {
@Autowired
MockMvc mvc;
@MockitoBean
PostApplicationService service;
@Test
void constraint_위반은_field_problem을_만들고_service를_호출하지_않는다()
throws Exception {
mvc.perform(post("/api/posts")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": " ",
"content": "",
"publishedOn": "2999-01-01"
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code")
.value("request-validation-failed"))
.andExpect(jsonPath("$.fields.length()").value(3));
verifyNoInteractions(service);
}
}POST /api/posts -> 400
Content-Type: application/problem+json
code = request-validation-failed
fields = [title, content, publishedOn]
service calls = 0테스트가 로케일 문구 전체에 지나치게 결합되지 않게 기계 코드와 필드를 우선 확인합니다. 번역 규칙은 MessageSource 테스트에서 별도로 검증합니다. API 스키마 테스트에서는 RFC 9457 문제 Details의 기본 필드와 확장 필드가 예상 타입인지 확인합니다.
불변식의 다중 방어
Bean 검증은 컨트롤러를 거친 요청만 자동 실행될 수 있습니다.
배치 가져오기, 메시지 사용자, 내부 메서드 호출도 도메인을 만들기 때문에 PostDraft 생성자와 애플리케이션 서비스가 핵심 불변식을 방어해야 합니다.
동일 회원·날짜·제목 중복처럼 DB 상태가 필요한 규칙은 트랜잭션 안에서 조회하고 고유 제약 조건으로 경쟁 조건을 막습니다.
필드 간 제약 조건을 만들 때 애노테이션 이름은 규칙을 설명해야 합니다.
@ValidPost처럼 모든 것을 담기보다 @ChronologicalPostTime처럼 한 관계만 책임지게 합니다.
검증기는 null 처리 정책을 개별 필드 제약 조건과 조율하고 외부 I/O를 하지 않습니다.
| 규칙 | 적절한 위치 | 이유 |
|---|---|---|
| JSON 필드 필수·길이 | 요청 DTO 제약 조건 | 클라이언트 형태 피드백 |
| 게시일 미래 여부 | 클래스 수준 제약 조건 또는 폼 검증기 | 현재 날짜와의 관계 |
| 하루 최대 게시글 수 | 도메인/애플리케이션 | 모든 입력 경로 공통 |
| 동일 게시글 중복 | 트랜잭션 + 고유 제약 조건 | 동시 요청 방어 |
| 현재 회원의 수정 권한 | 애플리케이션 서비스 | 인증과 애그리거트 조회 |
DTO 제약 조건이 도메인 검사를 중복하더라도 목적이 다릅니다. DTO는 빠르고 친절한 클라이언트 오류를 제공하고 도메인은 우회 경로에서도 잘못된 상태가 생기지 않게 합니다. 둘이 서로 다른 범위 값을 갖지 않도록 공통 정책 값나 계약 테스트로 차이를 막습니다.
연습 문제
게시글에 선택적인 summary를 추가하세요.
값이 있으면 120자 이하이고 본문보다 길 수 없습니다.
본문·요약의 단일 필드 길이 오류와 두 필드 관계 오류를 서로 다른 코드로 응답하고 도메인 생성자도 같은 관계를 방어하도록 구현합니다.
해설 보기
단일 길이는 컴포넌트 제약 조건, 관계는 클래스 수준 제약 조건으로 나눕니다.
클래스 수준 검증기는 content나 summary가 null이면 개별 제약 조건에 맡기고 비교하지 않습니다.
도메인 값 객체는 검증된 문자열을 받아 마지막으로 관계를 확인합니다.
package board.domain;
public record PostText(String content, String summary) {
public PostText {
if (content == null || content.isBlank() || content.length() > 720) {
throw new IllegalArgumentException("invalid content");
}
if (summary != null && summary.length() > 120) {
throw new IllegalArgumentException("summary too long");
}
if (summary != null && summary.length() > content.length()) {
throw new IllegalArgumentException("summary longer than content");
}
}
}MockMvc는 요약 없음, 121자 요약, 본문보다 긴 요약, 정상 요약을 각각 보냅니다. 도메인 테스트는 웹 없이 생성자를 직접 호출해 우회 경로도 막히는지 확인합니다.
이 장에서 안전한 출력부터 폼 바인딩, 선택 제어, 메시지와 검증까지 연결했습니다. 다음 장은 로그인 상태·필터·인터셉터·오류 재디스패치처럼 요청 생명주기 전체의 웹 기능을 하나의 게시판에 통합합니다.