들어가며
Spring MVC 환경에서 클라이언트 요청 데이터의 신뢰성을 확보하려면 체계적인 파라미터 검증 메커니즘이 필수적입니다. 본 문서에서는 JSR-380 표준을 중심으로 다양한 검증 전략과 실무 적용 방법을 살펴봅니다.
의존성 구성
Spring Boot 2.3 이후부터는 spring-boot-starter-validation을 명시적으로 추가해야 합니다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
요청 본문 검증: @RequestBody
JSON 형태의 요청 본문을 검증할 때는 @Valid를 메서드 파라미터에 적용합니다.
@PostMapping("/members")
public ResponseEntity<String> registerMember(@Valid @RequestBody MemberRequest request) {
return ResponseEntity.ok("가입 완료");
}
검증 대상 클래스는 다음과 같이 구성합니다.
public class MemberRequest {
@NotBlank(message = "계정명은 필수 입력값입니다")
@Length(min = 4, max = 30)
private String accountName;
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d]{8,}$",
message = "비밀번호는 8자 이상, 영문과 숫자를 포함해야 합니다")
private String password;
@NotNull
@Range(min = 14, max = 120, message = "연령은 14세 이상 120세 이하여야 합니다")
private Integer age;
@FutureOrPresent
private LocalDate serviceStartDate;
// 접근자 메서드
}
폼 데이터 검증: @ModelAttribute
HTML 폼 제출 시 BindingResult를 활용하면 검증 오류를 직접 처리할 수 있습니다.
@PostMapping("/signup")
public String processSignup(@Valid @ModelAttribute("member") SignupForm member,
BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
return "signup/form";
}
return "redirect:/welcome";
}
경로 및 쿼리 파라미터 검증
개별 파라미터 검증을 위해서는 컨트롤러 클래스에 @Validated를 선언하고, 각 파라미터에 제약 조건을 직접 명시합니다.
@RestController
@RequestMapping("/v1")
@Validated
public class ProductController {
@GetMapping("/items/{itemCode}")
public ResponseEntity<ItemDetail> findItem(
@PathVariable @Pattern(regexp = "^ITM-\\d{6}$") String itemCode,
@RequestParam @PositiveOrZero int pageOffset,
@RequestParam @Min(1) @Max(100) int pageSize) {
return ResponseEntity.ok(itemService.fetchDetail(itemCode, pageOffset, pageSize));
}
}
자주 사용하는 제약 조건
| 어노테이션 | 검증 내용 | 적용 대상 |
|---|---|---|
@NotNull | 값이 null이 아님 | 모든 객체 |
@NotEmpty | null이 아니며 길이가 0보다 큼 | 문자열, 컬렉션, 배열 |
@NotBlank | 공백 제거 후 길이가 0보다 큼 | 문자열 |
@Positive | 양수 여부 | 숫자 |
@NegativeOrZero | 0 또는 음수 | 숫자 |
@Digits | 정수 및 소수 자릿수 제한 | 숫자 |
@DecimalMax | 지정 값 이하 | 숫자 |
@AssertTrue | 조건식이 true | boolean 메서드 |
@URL | URL 형식 준수 | 문자열 |
맞춤형 검증 구현
업무 규칙이 복잡한 경우 ConstraintValidator를 직접 구현합니다.
1. 제약 어테이션 정의
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = CorporateRegistrationValidator.class)
public @interface ValidCorporateNumber {
String message() default "유효하지 않은 사업자등록번호 형식입니다";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
2. 검증 로직 구현
public class CorporateRegistrationValidator
implements ConstraintValidator<ValidCorporateNumber, String> {
private static final int[] CHECK_WEIGHTS = {1, 3, 7, 1, 3, 7, 1, 3, 5};
@Override
public boolean isValid(String registrationNumber,
ConstraintValidatorContext context) {
if (registrationNumber == null || !registrationNumber.matches("\\d{10}")) {
return false;
}
int[] digits = registrationNumber.chars()
.map(Character::getNumericValue)
.toArray();
int weightedSum = IntStream.range(0, 9)
.map(idx -> digits[idx] * CHECK_WEIGHTS[idx])
.sum();
int checkDigit = (10 - ((weightedSum + (digits[8] * 5) / 10) % 10)) % 10;
return digits[9] == checkDigit;
}
}
3. 도메인 모델 적용
public class CompanyRegisterRequest {
@ValidCorporateNumber
private String bizNumber;
@NotBlank
private String companyName;
}
검증 그룹을 통한 시나리오 분리
동일한 DTO로 생성과 수정을 모두 처리할 때 그룹을 활용합니다.
public interface OnCreate { }
public interface OnModify { }
public class ArticleEditRequest {
@Null(groups = OnCreate.class, message = "생성 시 ID는 null이어야 합니다")
@NotNull(groups = OnModify.class, message = "수정 시 ID는 필수입니다")
private Long articleId;
@NotBlank(groups = {OnCreate.class, OnModify.class})
@Size(max = 200, groups = {OnCreate.class, OnModify.class})
private String subject;
@NotBlank(groups = {OnCreate.class, OnModify.class})
private String content;
}
@PostMapping("/articles")
public ResponseEntity<Long> publish(@Validated(OnCreate.class)
@RequestBody ArticleEditRequest request) {
return ResponseEntity.ok(articleService.save(request));
}
@PutMapping("/articles/{id}")
public ResponseEntity<Void> revise(@PathVariable Long id,
@Validated(OnModify.class)
@RequestBody ArticleEditRequest request) {
articleService.update(id, request);
return ResponseEntity.ok().build();
}
전역 예외 처리
일관된 오류 응답을 위해 @RestControllerAdvice로 중앙 집중화합니다.
@RestControllerAdvice
public class ValidationExceptionResolver {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorPayload> resolveBodyFieldErrors(
MethodArgumentNotValidException ex) {
Map<String, String> fieldErrors = ex.getBindingResult()
.getFieldErrors()
.stream()
.collect(Collectors.toMap(
FieldError::getField,
fieldError -> Optional.ofNullable(fieldError.getDefaultMessage())
.orElse("잘못된 입력값입니다")
));
return ResponseEntity.badRequest()
.body(new ErrorPayload("FIELD_VALIDATION_FAILED", fieldErrors));
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ErrorPayload> resolveParameterErrors(
ConstraintViolationException ex) {
Map<String, String> parameterErrors = ex.getConstraintViolations()
.stream()
.collect(Collectors.toMap(
cv -> cv.getPropertyPath().toString(),
ConstraintViolation::getMessage
));
return ResponseEntity.badRequest()
.body(new ErrorPayload("PARAMETER_VALIDATION_FAILED", parameterErrors));
}
public record ErrorPayload(String errorCode, Map<String, String> details) { }
}
@Valid와 @Validated 핵심 차이
| 비교 항목 | @Valid | @Validated |
|---|---|---|
| 규격 출처 | JSR-380 (Bean Validation) | Spring Framework 전용 |
| 적용 위치 | 메서드 파라미터, 필드 | 클래스, 메서드 파라미터 |
| 그룹 지정 | 미지원 | groups 속성으로 지원 |
| 중첩 객체 검증 | 직접 지원 | 단독 사용 시 미지원, @Valid와 병행 필요 |
| 개별 파라미터 검증 | 가능 | @RequestParam, @PathVariable 검증 가능 |
| 발생 예외 | MethodArgumentNotValidException | ConstraintViolationException |
혼합 사용 패턴
클래스 레벨에 @Validated를 선언하고, 중첩 객체가 있는 @RequestBody에는 @Valid를 함께 적용하는 것이 일반적인 조합입니다.
@RestController
@Validated
public class OrderController {
@PostMapping("/orders")
public ResponseEntity<OrderReceipt> placeOrder(
@Valid @RequestBody OrderSubmission submission,
@RequestHeader @NotBlank String requestId) {
// requestId는 @Validated로,
// submission 내부 필드는 @Valid로 검증됨
return ResponseEntity.ok(orderService.process(submission, requestId));
}
}
실무 권장 사항
- 복잡한 검증 규칙은 DTO 내부가 아닌 별도
Validator로 분리하여 단일 책임 원칙을 지킵니다 - 검증 메시지는
messages.properties로 외부화하여 다국어 대응을 용이하게 합니다 - API 응답의 필드 오류는 클라이언트에서 직접 매핑할 수 있는 명확한 키 체계를 설계합니다
- 커스텀 어노테이션은 재사용 빈도가 높은 규칙에만 적용하여 과도한 추상화를 피합니다