HomeAboutMeBlogGuest
© 2025 Sejin Cha. All rights reserved.
Built with Next.js, deployed on Vercel
🤩
개발
/
Spring
Spring
/
🎺
Validation
🎺

Validation

레이어 별 Validation 중요성(Jackson 의견)입력 유효성 검증Bean Validation 2.0 (JSR 380)Request Body 에 대한 유효성 검증 (@Valid)@RequestParam (쿼리파라미터) & @PathVariable (path param) 에 대한 유효성 검증@Valid vs @Validated@BindingResultValidation 어노테이션 종류Validation 커스텀하게 하기스프링이 제공하는 Validator 를 쓰되, valid 후 exception을 커스텀하게 던지기AssertTrue/False를 이용한 ValidationConstraint Validator를 이용한 재사용 가능한 Validation문자열 유효성 검증 유틸 메서드 StringUtils비즈니스 규칙 검증
 
 
  • 검증 로직은 특정 계층(컨트롤러, 서비스, 도메인)에 종속되기보다는 도메인 오브젝트처럼 독립적으로 만드는 것이 좋음
  • Validator를 써서 검증 작업을 진행하는 곳을 어디로 할지는 고민해보아야 함. 네가지 방법이 존재 — by 토비의 스프링
    • 컨트롤러 메소드 내의 코드(Validator를 빈으로 등록하고 validate() 메서드를 호출해저 검증작업 진행)
    • @Valid를 이용한 자동검증(컨트롤러에서) — JSR-303(Bean Validation) 이용
    • 서비스 계층 오브젝트에서의 검증
      • 여러 개의 서비스 계층 오브젝트에서 반복적으로 같은 검증 기능이 사용된다면 Validator로 검증 코드를 분리하고 이를 DI 받아서 사용할 수 있음
    • 서비스 계층을 활용하는 Validator
      • Validator를 빈으로 등록해서 서비스 계층의 기능을 사용해 검증 작업을 수행할 수 있다
 

레이어 별 Validation 중요성(Jackson 의견)

  • validation은 도메인 > 서비스 > 컨트롤러 순으로 레이어 낮은 곳에서 더 많이, 중요하게 진행되어야 한다!
    • 중요한 것은 어떠한 validation 라이브러리를 사용하는가가 아닌 validation 이 필요한 곳에 적절하게 validation 을 수행했는가 입니다. validation 이 어디에 위치해야 하는가? 에 대한 질문에 사람마다 의견이 다를 순 있지만 개인적 의견은 다음과 같습니다. 도메인 모델 > 서비스 레이어 > 컨트롤러 레이어 예를 들어 Email 클래스의 String getAddress() 메소가 null을 리턴하지 않음을 보장하는 가장 확실한 방법은 무엇을까요? Email 클래스를 불변객체로 만들고 address 를 생성자에서 체크하는 것 입니다. 따라서, 객체의 생성자는 객체를 구성하는 필드들에 대한 validation 을 수행하는데 가장 최적의 위치입니다. 생성자에서 올바른 validation 수행을 보증할수 없다면 getter 메소드를 호출하고, 반환값에 대해 항상 방어적인 코드를 작성해야 합니다. 또한 UserService 클래스의 login 이라는 메소드는 컨트롤러가 아닌 다른 위치에서도 호출될수 있습니다. 이러한 특성을 고려했을 때, validation 로직의 재활용성을 높이기 위해서는 컨트롤러보다는 서비스 레이어에서 validation 처리가 더 좋습니다.
  • 만들면서 배우는 클린아키텍처와 이때까지의 경험을 종합해서 정리를 해보면
    • 컨트롤러 - 입력 유효성 검증
      • 서비스의 입력모델은 서비스의 맥락에서 유효한 입력만 허용. 컨트롤러에서의 입력 모델은 서비스의 입력 모델과는 구조나 의미가 완전히 다를 수 있음
      • 서비스 입력 모델에서 했던 유효성 검증을 똑같이 컨트롤러에서도 구현하는 것은 아니고, 컨트롤러의 입력 모델을 서비스의 입력 모델로 변환할 수 있다는 것을 검증
      • (그럼, 컨트롤러와 서비스에서 같은 모델을 사용한다면 입력 유효성 검증은 한번만 하면 되겠구나)
    • 서비스 - 입력 유효성 검증, 비즈니스 규칙 검증
      • 컨트롤러의 웹 모델을 그대로 사용하게 되면 입력 유효성 검증은 컨트롤러 웹 모델에서 진행되고, 비즈니스 규칙 검증만 하면 됨
    • 도메인 - 비즈니스 규칙 검증 & 생성자로 입력 유효성 검증
      • 서비스에서 진행하는 비즈니스 규칙 검증을 도메인 로직으로 처리해도 됨
      • 도메인이 제일 안쪽에 있으니 입력 유효성 검증은 중요하게 진행되어야 함!

입력 유효성 검증

  • 검증해야 할 값이 많은 경우 코드의 길이가 길어진다.
  • 구현에 따라서 달라질 수 있지만 Service Logic과의 분리가 필요함
  • 흩어져 잇는 경우 어디에서 검증을 하는지 알기 어려우며, 재사용의 한계가 있음
  • 구현에 따라 달라 질 수 있지만, 검증 Logic이 변경되는 경우 테스트 코드 등 참조하는 클래스에서 Logic이 변경되어야 하는 부분이 발생 할 수 있다.
  • implementation ‘org.springframework.boot:spring-boot-starter-validation’
notion image

Bean Validation 2.0 (JSR 380)

[Baeldung] Java Bean Validation Basics — JSR(Java Specification Request) 380 (Bean Validation 2.0)
Spring Boot에서의 Bean Validation (1)
Spring Boot에서의 Bean Validation (2) — Custom Validator
implementation('org.springframework.boot:spring-boot-starter-validation') // 혹은 hibernate-validator 만 추가해도 됨. 근데 이 때 spring boot 버전이랑 안맞으면 동작을 안하더라 implementation 'org.hibernate.validator:hibernate-validator:6.2.0.Final'
  • Bean Validation 2.0 은 JSR-380 으로 불리는 api에 대한 정의
  • Hibernate Validator 는 그것에 대한 구현임. 2018.4월 기준으로 유일한 jSR-380 에대한 인증받은 구현
    • Stack Overflow 참고

Request Body 에 대한 유효성 검증 (@Valid)

@RestController public class ValidateRequestBodyController { @PostMapping("/validateBody") ResponseEntity<String> validateBody(@Valid @RequestBody InputRequest request) { return ResponseEntity.ok("valid"); } }
  • @Valid 어노테이션이 붙음으로 Spring이 다른 작업을 수행하기 전에 먼저 객체를 Validator에 전달해서 유효성 검사를 하게 됨
  • 만약 InputRequest안에 유효성을 검사해야 하는 다른 객체가 필드로 포함되는 경우 그 객체에도 @Valid를 붙여줘야 함 — Complex Type
  • @RequestBody앞에 @Valid를 붙이고 InputRequest 필드들에 대해 @Min, @Max 이런 애들 붙이면 됨
  • 유효성 검사에 실패할 경우 MethodArgumentNotValidException 예외가 발생
  • Kotlin에서는 @field:NotBlank 와 같은 식으로 적용해야함 [ 참고 ]

@RequestParam (쿼리파라미터) & @PathVariable (path param) 에 대한 유효성 검증

@Validated @RestController public class ValidateParametersController { @GetMapping("/validatePathVariable/{id}") ResponseEntity<String> validatePathVariable(@PathVariable("id") @Min(5) int id) { return ResponseEntity.ok("valid"); } @GetMapping("/validateRequestParameter") ResponseEntity<String> validateRequestParameter(@RequestParam("param") @Min(5) int param) { return ResponseEntity.ok("valid"); } }
  • @Validated 어노테이션을 클래스 레벨의 Controller에 추가해 Spring이 메서드 매개 변수에 대한 제한 조건 annotation을 평가하게 해야한다.
  • 유효성 검사에 실패할 경우 ConstraintViolationException 발생
 

@Valid vs @Validated

  • @Valid
    • method level validation
    • member attribute for validation 에서도 사용함
    • 유효성 검증 실패시 → MethodArgumentNotValidException
  • @Validated
    • group level validation에서 사용 (클래스 수준에서 평가됨)
    • 유효성 검증 실패시 → ConstraintViolationException
 

@BindingResult

@PostMapping public ResponseEntity post(@Valid @RequestBody User user, BindingResult bindingResult){ if(bindingResult.hasErrors()){ StringBuilder sb = new StringBuilder(); bindingResult.getAllErrors().forEach(objectError->{ FieldError field = (FieldError) objectError; String message = objectError.getDefaultMessage(); sb.append("field : " + field.getField()); sb.append("message : " + message); }); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(sb.toString()); } //logic return ResponseEntity.ok(user); } public class User{ private String name; private int age; @Email private String email; @Pattern(regexp = "^\\d{2,3}-\\d{3,4}-\\d{4}$", message="핸드폰 번호 양식과 맞지 않습니다.") private String phoneNumber; ... }
  • BindingResult라는 파라미터를 함수에서 받음으로 변수의 형태에 맞게 값이 들어오지 않았을때 아예 에러를 내는 것이 아니라 어떤 식으로 에러가 났는지를 반환 가능함 → 이것보다는 exception 처리가 나을듯

Validation 어노테이션 종류

  • @Past : annotated element must be an instant, date or time in the past.
  • @Future : annotated element must be an instant, date or time in the future.
 

Validation 커스텀하게 하기

스프링이 제공하는 Validator 를 쓰되, valid 후 exception을 커스텀하게 던지기

public static void validate(ReserveTownHallRequest request) { ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory(); Validator validator = validatorFactory.getValidator(); Set<ConstraintViolation<ReserveTownHallRequest>> violations = validator.validate(request); if (!violations.isEmpty()) { for (ConstraintViolation<ReserveTownHallRequest> violation: violations) { Path propertyPath = violation.getPropertyPath(); if (propertyPath.toString().equals("name")) { throw new TownHallCreateException(ErrorCode.TOWNHALL_TITLE_IS_EMPTY); } } throw new ConstraintViolationException(violations); } }
  • 스프링에서 제공하는 Validator를 가져온 뒤, 직접 violations를 하나씩 보면서 해당하는 field 에 대해서 exception을 커스텀하게 throw

AssertTrue/False를 이용한 Validation

@AssertTrue(message= "yyyyMM의 형식에 맞지 않습니다.") public boolean isreqYearMonth(){ try{ LocalDate localDate = LocalDate.parse(this.reqYearMonth + "01", DateTimeFormatter.ofPattern("yyyyMMdd")); } catch(Exception e){ return false; } return true; }
  • @AssertTrue annotation이 붙은 메써드는 is로 시작해야 동작이 됨
  • 근데 위와 같이 작성할 때의 문제점은 class 별로 저 메써드를 다 만들어 주어야 한다는 것 → Custom Annotation을 만들자!

Constraint Validator를 이용한 재사용 가능한 Validation

//YearMonth.java - annotation @Constraint(validatedBy = { YearMonthValidator.class}) @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE}) @Retention(RUNTIME) public @interface YearMonth{ String message() default "yyyyMM 형식에 맞지 않습니다." Class<?>[] groups() default { }; Class<? extends Payload>[] payload() default { }; String pattern() default "yyyyMM"; } //YearMonthValidator.java public class YearMonthValidator implements ConstraintValidator<YearMonth, String>{ private String pattern; @Override public void initialize(YearMonth constraintAnnotation){ this.pattern = constraintAnnotation.pattern() + "dd"; } @Override public boolean isValid(String value, ConstraintValidatorContext context){ // yyyyMM try{ LocalDate localDate = LocalDate.parse(value + "01", DateTimeFormatter.ofPattern(this.pattern)); } catch(Exception e){ return false; } return true; } }
  • 위에서 만든 Annotation YearMonth를 validation 하고 싶은 변수위에다가 붙이기
 

문자열 유효성 검증 유틸 메서드 StringUtils

[참고] link

비즈니스 규칙 검증

  • 비즈니스 규칙에 대한 검증은 도메인 엔티티 안에 위치하거나, 서비스 코드에서 도메인 엔티티를 사용하기 전에 진행하는 것이 좋음
public class Account{ public boolean withdraw(Money money, AccountId targetAccountId) { if (!mayWithdraw(money)){ return false; } // ... } }
도메인에서 비즈니스 규칙 검증
public class SendMoneyService implement SendMoneyUseCase{ // ... @Override public boolean sendMoney(SendMoneyCommand command) { requireAccountExists(command.getSourceAccountId()); requireAccountExists(command.getTargetAccountId());