Spring MVC 파라미터 검증 완벽 가이드: @Valid와 @Validated의 실전 활용

들어가며

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이 아님모든 객체
@NotEmptynull이 아니며 길이가 0보다 큼문자열, 컬렉션, 배열
@NotBlank공백 제거 후 길이가 0보다 큼문자열
@Positive양수 여부숫자
@NegativeOrZero0 또는 음수숫자
@Digits정수 및 소수 자릿수 제한숫자
@DecimalMax지정 값 이하숫자
@AssertTrue조건식이 trueboolean 메서드
@URLURL 형식 준수문자열

맞춤형 검증 구현

업무 규칙이 복잡한 경우 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 검증 가능
발생 예외MethodArgumentNotValidExceptionConstraintViolationException

혼합 사용 패턴

클래스 레벨에 @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 응답의 필드 오류는 클라이언트에서 직접 매핑할 수 있는 명확한 키 체계를 설계합니다
  • 커스텀 어노테이션은 재사용 빈도가 높은 규칙에만 적용하여 과도한 추상화를 피합니다

태그: Spring MVC Bean Validation Hibernate Validator JSR-380 ConstraintValidator

5월 23일 10:51에 게시됨