Spring/Spring Boot

[Spring Boot][KoLiving] 1. Validation

noahkim_ 2023. 9. 18. 19:15

안녕하세요. 저는 2023.04부터 비사이드라는 직장인 사이드 프로젝트에 15기에 참가하였습니다.

사이드 프로젝트를 진행하면서 공유하고자 하는 내용을 포스팅해보려 합니다.

 

제가 진행한 사이드 프로젝트는 외국인을 상대로 룸메이트를 매칭해주는 글로벌 서비스입니다.

저는 유저 도메인쪽을 맡았고, 회원가입과 인증&인가 기능을 구현했습니다.

 

첫번째로 포스팅할 주제는 "Validation" 입니다.

개념 소개부터 구현까지 공유드리겠습니다

 

1. Validation 이란

  • 클라이언트가 요청중에 전달한 입력값의 유효성 검증을 뜻합니다.
  • 클라이언트가 서버 API를 호출할 때 클라이언트는 요청을 함에 있어 입력값을 전달합니다.
  • 서버는 입력값이라는 요청값에 대해 유효하다 판단이 되면, 응답을 위한 로직을 수행하게 됩니다.

 

2. 기술 선정

Validation에 대해서는 쉽게 이해도 되고 필요성도 공감하실 겁니다.

클라이언트의 요청값이 엉뚱하거나 논리적으로 맞지 않다면 로직을 수행할 수도, 해서도 안되니까요.

그렇다면 "어떻게 Validation을 진행할 것인가"라는 질문을 고민해야 겠습니다.

 

어떻게?

  • 쉽게 생각하면 validation 검사를 메서드 호출 전에, if문으로 검증해서 통과하는 방식으로 사용하면 됩니다. 

 

문제점
  • 클라이언트 코드에 침투적입니다.
    • 검증 코드를 비즈니스 로직과 같이 작성하게 됩니다.
  • 코드 중복이 심합니다.
    • 애플리케이션에서 유효성 검사는 전반적으로 여러 곳에서 일어납니다.
    • 애플리케이션이 복잡해지고 추적이 어려워져 개발하는데 어려움을 겪게 됩니다.

 

Jakarta Bean Validation

  • Java에서 제공하는 데이터 유효성 검사 프레임워크 입니다.
  • Bean Validation어노테이션을 통해 애플리케이션 전체에 분산된 검사 로직을 모아서 처리할 수 있게 합니다.
    • 입력에 대한 선언된 모든 제약 조건을검사하도록 동작합니다.
  • Jakarta Bean Validation에 대한 스펙은 JSR-303 에 정의되어 있습니다.
    • 객체의 유효성 검사를 위한 API가 정의되어 있으며
    • POJO의 속성을 어노테이션을 통해 유효성 검사 규칙을 지정하고 검사하는데 사용되도록 스펙을 정의합니다.

 

3. Spring Boot 사용

이러한 Bean Valitaion 프레임워크를 Spring Boot에서 사용하는 법은 간단합니다. 의존성을 추가하는 것입니다!

미니멀한 설정으로 JSR-303 어노테이션의 검증을 실행시킬 수 있습니다.

 

의존성

spring-boot-starter-validation
  • Spring Boot는 버전별 의존성을 호환되는 버전으로 큐레이션하여 제공합니다.
  • 아래 두개의 모듈은 실제 기능을 가진 가장 중요한 모듈입니다.

 

 

jakarta.validation-api
  • JSR-303
    • 해당 애노테이션에 대한 스펙만 작성된 Specification Request 입니다.
    • 어노테이션을 검증하는 검증기는 jakarta.validation.Validator 인터페이스의 구현체여야 합니다.

 

hibernate-validator
  • hibernate validator : JSR-303 애노테이션을 처리하는 검증기입니다.
  • ValidatorImpl : hibernate validator 검증기를 가지고 중앙 검증 역할을 담당하고 있습니다.

 

Spring Boot에서 사용하기

ValidationAutoConfiguration
  • LocalValidatorFactoryBean과 MethodValidationPostProcessor를 자동으로 빈 등록합니다.

 

LocalValidatorFactoryBean
  • Spring Boot가 검증에 핵심적으로 사용하는 컴포넌트 입니다.
    • JSR-303 표준을 따르고, Spring 내에서 컴포넌트와의 통합을 위해 통합포인트 클래스로 쓰입니다.
    • 글로벌 Validator로 검증기 역할을 수행합니다.
    • hibernate validator에 작업을 위임하여 처리합니다.
  • 유효성 검사 결과
    • FieldError, ObjectError라는 클래스에 wrapping 합니다.
    • BindingResult에 담아서 전달합니다.

 

MethodValidationPostProcessor
  • 메서드 파라미터 혹은 리턴값을 검증하기 위해 사용됩니다.
  • @Valid 어노테이션의 검증 대상 객체를 프록시 객체로 생성합니다.

 

4. 기능 소개

레이어별 사용법

Controller
  • 검증 대상객체에만 @Valid 어노테이션만 붙이면 됩니다.
@RestController
public class AuthController {
	// ...

    @PostMapping("/sign-up")
    public ResponseEntity sendAuthEmailForSignUp(
            final @Valid @RequestBody AuthEmailRequestDto authEmailRequestDto) {
            // ...       
    }    
}

 

다른 레이어
  • 클래스에 @Validated를 붙여주어야 합니다.
@Validated // 붙여주어야 함
@Service
public class UserService {
	
    @Override
    public void setPassword(User user, @Valid PasswordDto dto) {
        String encodedPassword = passwordEncoder.encode(dto.getPassword());
        user.setPassword(encodedPassword);
    }
}

 

Hibernate Validation 모듈의 Provider를 통한 검증 (JSR-303 어노테이션 검증)

  • 표준 JSR-303 어노테이션을 사용하여 Bean의 유효성 검사를 수행합니다. 
  • @Valid 어노테이션이 붙은 객체는 자동으로 Hibernate Validator에 의해 검사됩니다.

 

Spring의 Validator 인터페이스를 구현한 검증

  • Spring Framework 내장 Validator 인터페이스를 구현하여 검증 로직을 만들 수 있습니다.
    • 검증하는 역할을 분리해서 별도의 클래스로 구현합니다.
  • 보통 데이터베이스 접근이 필요한 경우 자주 사용됩니다.
@RestController
public class AuthController {

    private final EmailDuplicationValidator emailDuplicationValidator;

    public ResponseEntity sendAuthEmailForSignUp(final @Valid @RequestBody AuthEmailRequestDto authEmailRequestDto) {
        checkEmailDuplication(authEmailRequestDto, emailDuplicationValidator);

		// ...

        return new ResponseEntity<>(noContent);
    }
    
    private void checkEmailDuplication(AuthEmailRequestDto authEmailRequestDto, Validator validator) {
        BeanPropertyBindingResult errors = new BeanPropertyBindingResult(authEmailRequestDto, "authEmailRequestDto");
        validator.validate(authEmailRequestDto, errors);
        if (errors.hasErrors()) {
			// exception handling
        }
    }
}
@Component
@RequiredArgsConstructor
public class EmailDuplicationValidator implements Validator {

    private final UserRepository userRepository;

    @Override
    public boolean supports(Class<?> clazz) {
        return AuthEmailRequestDto.class.isAssignableFrom(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {
        AuthEmailRequestDto emailRequest = (AuthEmailRequestDto) target;
        Optional<User> userOptional = userRepository.findByEmail(emailRequest.email());
        userOptional.ifPresent(user -> errors.rejectValue("email",  "duplication", emailRequest.email()));
    }
}

 

Spring의 ConstraintValidator 인터페이스를 구현한 검증

  • 사용자가 직접 정의한 어노테이션에 대한 검증 로직을 작성할 수 있습니다. 
  • 이를 통해 더 복잡한 유효성 검사나 다양한 커스터마이징이 가능합니다.
    • 필드 제약, 그룹 제약 등 다양한 커스텀 제약이 가능합니다. (예시 레퍼런스를 참고하세요)

 

클래스 단위 제약
@PasswordMatches
public record ResetPasswordDto(
    @NotBlank
    @Size(min = 8, max = 20)
    String password,
    
    String passwordVerify
    ) {
}
@Documented
@Target({TYPE})
@Retention(RUNTIME)
@Constraint(validatedBy = PasswordMatchesValidator.class)
public @interface PasswordMatches {
    String message() default "";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}
@Component
@RequiredArgsConstructor
public class PasswordMatchesValidator implements ConstraintValidator<PasswordMatches, ResetPasswordDto> {

    @Override
    public boolean isValid(ResetPasswordDto value, ConstraintValidatorContext context) {
        String password = value.password();
        String passwordVerify = value.passwordVerify();

        if (!password.equals(passwordVerify)) {
            String messagePattern = "mismatched_password";
            context.disableDefaultConstraintViolation();
            context.buildConstraintViolationWithTemplate(messagePattern)
                    .addPropertyNode("passwordVerify")
                    .addConstraintViolation();

            return false;
        }

        return true;
    }

    @Override
    public void initialize(PasswordMatches constraintAnnotation) {
        ConstraintValidator.super.initialize(constraintAnnotation);
    }
}

 

5. 유효성 검사 예외

MethodArgumentNotValidException

  • @RequestBody 데이터 바인딩 중에 Validation 검증 실패가 발생할 경우 던져지는 예외입니다.
  • 기본적으로 HTTP 400 오류로 처리됩니다.
  • 예외클래스 내부 필드에 BindingResult라는 객체가 있으며 검증 오류를 보관합니다. 

 

BindingResult

  • 바인딩 결과를 표현하는 인터페이스 입니다.
    • Errors 인터페이스를 상속하며, MethodArgumentNotValidException의 부모 인터페이스입니다.
  • 예외가 발생할 경우, 검증기는 유효하지 않은 검증결과를 BindingResult에 저장해줍니다.

 

 

List<FieldError>
  • 유효성을 위반한 필드의 에러내용을 담은 클래스의 리스트입니다.
  • BindingResult를 접근하여 사용자에게 예외메시지를 생성하는데 활용됩니다.
public record ValidationResult(List<FieldErrorDetail> errors) {

    public static ValidationResult of(Errors errors) {
        List<FieldErrorDetail> details =
                errors.getFieldErrors()
                        .stream()
                        .map(error -> FieldErrorDetail.of(error))
                        .collect(Collectors.toList());
        return new ValidationResult(details);
    }
}
public record FieldErrorDetail(String objectName, String field, String code, String message) {

    public static FieldErrorDetail of(FieldError error) {
        return new FieldErrorDetail(
                error.getObjectName(),
                error.getField(),
                error.getCode(),
                error.getDefaultMessage()
        );
    }
}

 

Errors 

  • 데이터 바인딩과 유효성 검증 결과를 표현하는 인터페이스입니다.
    • reject() : BindingResult를 채우기 위한 메서드
  • JSR-303 을 위반할 경우 해당 메서드를 가지고 BindingResult의 위배내용을 채우고 예외를 발생시킵니다.

 

 

참고