dependency

아래 디펜던시를 추가 후 진행해야 합니다.

implementation 'org.springframework.boot:spring-boot-starter-validation'

1. Dto validation - @Valid

  • @Valid 어노테이션을 사용하여 검증할 수 있다.

        // Controller 코드 일부
        @PostMapping
        public ApiResult<HelloInsertRspDto> insert(@Valid @RequestBody HelloInsertReqDto reqDto) {
            return ok(helloService.insert(reqDto));
        }
    // RequestDto 내 Bean Validator 설정
    public class HelloInsertReqDto {
    
        @Email
        @Schema(description = "이메일", required = true, example = "test@email.com")
        private String email;
    
        @NotBlank
        @Schema(description = "이름", required = true, example = "kdy")
        private String name;
    
        @Schema(description = "생일", example = "19930724")
        private String birthday;
    
        public Hello toEntity() {
            return Hello.builder()
                    .email(email)
                    .name(name)
                    .birthday(birthday)
                    .build();
        }
    
    }
  • 설정 시, 컨트롤러 진입 전 Validator 를 통해 Dto 의 Bean Validator 값들을 검증한다.

  • 검증 실패 시, MethodArgumentNotValidException 이 발생한다.

    @Test
      public void 헬로추가_실패_이름없음() throws Exception {
          // given
          var helloInsertReqDto = HelloInsertReqDto.builder()
                  .email("email@base.com")
                  .build();
          var reqJson = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(helloInsertReqDto);
    
          // then
          mockMvc.perform(post("/hello/v1")
                          .contentType(MediaType.APPLICATION_JSON_VALUE)
                          .content(reqJson))
                  .andExpect(status().isBadRequest())
                  .andExpect(result -> assertThat(getApiResultExceptionClass(result))
                          .isEqualTo(ConstraintViolationException.class) // 다른 예외 클래스와 비교
                  )
                  .andDo(this::printExceptionMessage);
      }

    테스트 실패 케이스

  • 예외발생 메시지템플릿은 인터페이스 별로 상이하며, 스프링부트 기본 값을 기준으로 resources 하위 ValidationMessages.properties 에 설정하면 수정할 수 있다.

    ValidationAutoConfiguration 클래스를 통해 LocalValidatorFactoryBean 으로 Validator 를 생성한다.
    이 때, LocalValidatorFactoryBean 는 ValidationMessages.properties 를 클래스패스 내 기본 설정파일로 지정한다.

    LocalValidatorFactoryBean 기본 properties 설정

    // 따라서 Bean Validator 의 메시지 템플릿을 확인한 뒤
    public @interface NotBlank {
    
        String message() default "{javax.validation.constraints.NotBlank.message}";
        /* 이하 생략 */
    }
    // resources(Spring Boot 기본 classpath) 하위 ValidationMessages.properties 에 설정하여 기본 메시지를 수정할 수 있다.
    javax.validation.constraints.Email.message=이메일 형식에 맞게 입력해주세요.
    javax.validation.constraints.NotBlank.message=유효한 값을 입력해주세요.

    기본 메시지템플릿 커스텀
    출력되는 메시지

2. Entity Validation - @Validated

  • @Validated 어노테이션은 AOP 방식으로 동작한다.
@Validated // 어노테이션 적용 시, 해당 클래스 내 @Valid 를 인식
@RequiredArgsConstructor
@Service
public class HelloService {

    private final HelloRepository helloRepository;

	// @Valid가 선언된 경우, 해당 메소드는 Spring AOP Proxy 객체가 대신 수행
    // 실제 작업 전 MethodValidationInterceptor.invoke() 로 검증
    // AOP 검증 예외로 ConstraintViolationException 발생
    public HelloInsertRspDto insert(@Valid HelloInsertReqDto reqDto) {
        return new HelloInsertRspDto(helloRepository.save(reqDto.toEntity()));
    }

}
 @Test
 public void 헬로추가_실패_잘못된이메일형식() {
     // given
     var reqDto = HelloInsertReqDto.builder()
             .email("email.com")
             .name("name")
             .birthday("0724")
             .build();

	 // when
     assertThrows(ConstraintViolationException.class,
             () -> helloService.insert(reqDto)
     );
 }

@Validated 로 @Valid AOP 적용

  • JPA save 시, AOPProxy 객체가 작업을 대신 수행한다.

    JpaRepository 기본 Save 시
    EntityIdentityInsertAction.preInsert() 메소드를 통해 
    실제 DB Insert 전 검증과정을 거친다. 이 때, 검증 작업이 진행된다.
    package org.hibernate.action.internal;
    public class EntityIdentityInsertAction extends AbstractEntityInsertAction  {
    	@Override
    		public void execute() throws HibernateException {
    			nullifyTransientReferencesIfNotAlready();
    
    			final EntityPersister persister = getPersister();
    			final SharedSessionContractImplementor session = getSession();
    			final Object instance = getInstance();
    
    			setVeto( preInsert() ); // DB 작업 전 검증
    
    			// Don't need to lock the cache here, since if someone
    			// else inserted the same pk first, the insert would fail
    
    			// 이후 DB 작업 진행
    			if ( !isVeto() ) {
    				generatedId = persister.insert( getState(), instance, session );
    				if ( persister.hasInsertGeneratedProperties() ) {
    					persister.processInsertGeneratedProperties( generatedId, instance, getState(), session );
    				}
    				//need to do that here rather than in the save event listener to let
    				//the post insert events to have a id-filled entity when IDENTITY is used (EJB3)
    				persister.setIdentifier( instance, generatedId, session );
    				final PersistenceContext persistenceContext = session.getPersistenceContextInternal();
    				persistenceContext.registerInsertedKey( getPersister(), generatedId );
    				entityKey = session.generateEntityKey( generatedId, persister );
    				persistenceContext.checkUniqueness( entityKey, getInstance() );
    			}
    
    			postInsert();
    
    			final StatisticsImplementor statistics = session.getFactory().getStatistics();
    			if ( statistics.isStatisticsEnabled() && !isVeto() ) {
    				statistics.insertEntity( getPersister().getEntityName() );
    			}
    
    			markExecuted();
    		}
    }
  • JPA 엔티티 컬럼에 Bean Validator 설정 시, Spring Boot 기본 설정으로 hibernate-core 패키지의 BeanValidationEventListener 으로 검증한다.
    JPA hibernate validate

  • 이는 JdkDynamicAopProxy 객체를 통해 JPA 기능 수행 중 발생한 오류로 ConstraintViolationException 을 발생시킨다.

@Test
    public void 사용자추가_실패_필수값누락_이름() {
        // given
        var hello = Hello.builder()
                .email("insertNameTest@email.com")
                .birthday("19990101")
                .build();

        // when
        assertThrows(ConstraintViolationException.class,
                () -> hellosRepository.save(hello)
        );
    }

AOP 프록시로 JPA 수행
AOP에서 주로 발생하는 ConstraintViolationException

  • 예외발생 메시지템플릿은 스프링부트 기본 값을 기준으로 resources 하위 ValidationMessages.properties 에 설정하면 수정할 수 있다.(Dto 와 동일)
    메시지 에러로그
    ValidationMessages.properties

3. 예외메시지 처리

  • 예외메시지는 컨트롤러어드바이스에서 받아 처리할 수 있습니다.

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<?> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        // @Valid 어노테이션으로 Dto 를 검증하는 경우 발생한다.
        BindingResult bindingResult = e.getBindingResult();
    
        StringBuilder builder = new StringBuilder();
        for (FieldError fieldError : bindingResult.getFieldErrors()) {
            builder.append("[");
            builder.append(fieldError.getField());
            builder.append("](은)는 ");
            builder.append(fieldError.getDefaultMessage());
            builder.append(" 입력된 값: [");
            builder.append(fieldError.getRejectedValue());
            builder.append("]");
        }
        log.error(builder.toString());
        return new ResponseEntity<>(ApiResult.error(e), HttpStatus.BAD_REQUEST);
    }
profile
프레임워크와 함께하는 백엔드 개발자입니다.

1개의 댓글

comment-user-thumbnail
2024년 1월 8일

좋은 글이네요!! 👍 혹시

JpaRepository 기본 Save 시
EntityIdentityInsertAction.preInsert() 메소드를 통해 
실제 DB Insert 전 검증과정을 거친다. 이 때, 검증 작업이 진행된다.

이 부분에 대한 내용은
"초보 웹 개발자를 위한 스프링5 프로그래밍 입문" 에 수록 된 내용인가요?!

답글 달기