Validation : Getter 필수? is 를 prefix로?

땡글이·2023년 7월 17일
1

Spring Framework

목록 보기
8/8
post-thumbnail

Validation은 클라이언트로부터 온 요청 메시지의 바디 혹은 파라미터를 검증 함으로써, 부적절한 요청은 비즈니스 로직의 실행을 막는 방식이다.

스프링에서는 Validation을 라이브러리(starter-validation)에서 제공하는 기본 어노테이션만으로도 쉽게 구현할 수 있게끔 되어있다. 그 방식들에 대해 알아보고 구현해보았다.

다만, 구현하는 과정에 있어서 접했던 문제점들과 해결방법들에 대해 알아본다!

코드가 궁금하다면, 깃허브 링크로 ~~

Getter 없이는 Validation이 되지 않는다?

코드를 보면, MemberSaveDto 객체 내에 Address 라는 중첩 객체를 두어서 Address에 대해서도 Validation이 이뤄지도록 구현했지만, Address에 Getter가 없을 때에는 예상했던 MethodArgumentNotValidException 예외가 아닌 IllegalStateException이 발생했다.

이상했던 점은 정상적인 값이 들어올 때에는 예외가 발생하지 않는다는 점이었다. 즉, Validation 로직은 실행되고 있는데, Getter가 없을 때, 값의 유효성 여부에 따라 검증이 정상적으로 될 수도 안 될수도 있다는 점이었다.

도대체 왜?!?!

문제가 있을 땐 에러 메시지부터 보자! 아래는 에러메시지의 일부다.

Request processing failed: java.lang.IllegalStateException: JSR-303 validated property 'address.zipCode' does not have a corresponding accessor for Spring data binding - check your DataBinder's configuration (bean property versus direct field access)
jakarta.servlet.ServletException: Request processing failed: java.lang.IllegalStateException: JSR-303 validated property 'address.zipCode' does not have a corresponding accessor for Spring data binding - check your DataBinder's configuration (bean property versus direct field access)
	at app//org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1019)
	at app//org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:914)
	at app//jakarta.servlet.http.HttpServlet.service(HttpServlet.java:590)

...

Caused by: org.springframework.beans.NotReadablePropertyException: Invalid property 'address' of bean class [com.example.demo.dto.MemberSaveDto]: Bean property 'address' is not readable or has an invalid getter method: Does the return type of the getter match the parameter type of the setter?
	at app//org.springframework.beans.AbstractNestablePropertyAccessor.getPropertyValue(AbstractNestablePropertyAccessor.java:626)
	at app//org.springframework.beans.AbstractNestablePropertyAccessor.getNestedPropertyAccessor(AbstractNestablePropertyAccessor.java:839)
	at app//org.springframework.beans.AbstractNestablePropertyAccessor.getPropertyAccessorForPropertyPath(AbstractNestablePropertyAccessor.java:816)
	at app//org.springframework.beans.AbstractNestablePropertyAccessor.getPropertyValue(AbstractNestablePropertyAccessor.java:614)
	at app//org.springframework.validation.AbstractPropertyBindingResult.getActualFieldValue(AbstractPropertyBindingResult.java:104)
	at app//org.springframework.validation.AbstractBindingResult.getRawFieldValue(AbstractBindingResult.java:281)

찾아낸 에러 발생 이유는 다음과 같다.

  • 에러 메시지의 일부 : JSR-303 validated property 'address.zipCode' does not have a corresponding accessor for Spring data binding - check your DataBinder's configuration
    • does not have a corresponding accessor ? 우선 corresponding accessor가 뭔지부터 알아보자. corresponding accessor는 필드에 대한 접근자 메서드를 의미하는데 일반적으로 getter/setter 를 의미한다. 즉, getter/setter가 없어서 필드에 접근할 수 없어서 exception이 발생했다는 것이다.
    • 또한 에러 메시지를 보면 DataBinder 에 문제가 있음을 알 수 있다. 이제 DataBinder의 쓰임새에 대해 알아보자. DataBinder는 공식문서에서 “Binder that allows for setting property values on a target object, including support for validation and binding result analysis.” 라고 나와있다.
  • 즉, DataBinder는 말 그대로 데이터 바인딩에 쓰인다. 정의에서 보면 알 수 있듯이 데이터 바인딩은 대상 객체의 값을 주입해주거나 validation의 결과 혹은 데이터 바인딩 결과를 만들 때 사용된다.
    • validation 에서는 언제 DataBinder가 쓰일까? BindingResult 를 생성할 때 사용된다. 즉, Validation 과정에서 에러가 있을 시에는 BindingResult 에 어떤 필드에서 어떤 오류가 있었는지 담기게 된다. 그런데, nested object 내의 필드에는 접근할 수 없게 되어 이런 오류가 발생한 것이다.
    • 즉, validation 로직은 잘 동작해서, 조건에 맞는 값이 들어오게 되면 잘 처리되지만, 비정상적인 값이 들어와서 validation 로직이 동작한 뒤 이후에 DataBinder에서 에러 결과를 처리해줄 때, Address 내의 필드에 접근하지 못해서 의도했던 MethodArgumentNotValidException 가 아닌 IllegalStateException이 발생한 것이다.

그러면 DataBinder가 직접 필드에 접근할 수 있도록 수정해주면 되지 않을까? 그래서 아래와 같은 코드를 작성해보고 테스트해보았다. initDirectFieldAccess() 메서드를 호출해서 대상 객체의 필드에 직접적으로 접근할 수 있도록 수정해주면??

    @RestControllerAdvice
    public class MyExceptionHandler {
    
        @ExceptionHandler(MethodArgumentNotValidException.class)
        public ResponseEntity<?> validationError(MethodArgumentNotValidException exception) {
            return ResponseEntity.badRequest().body(exception.getMessage());
        }
    
        @InitBinder
        private void activateDirectFieldAccess(DataBinder dataBinder) {
            dataBinder.initDirectFieldAccess();
        }
    }
  • 결과는 당연히 Pass!
  • 즉 validation 이후에 bindingResult를 만들다가 IllegalStateException이 발생하는 것을 알게 되었다.

nested object 에 대해서는 getter가 있어야 되고, outer object 에선 getter 없어도 정상 동작?

근데 아직 문제가 남았다. MemberSaveDto (outer object) 에 대한 필드는 @Getter 없이도 잘 validation 되는데에도 불구하고, nested object로 쓰인 Address 클래스의 필드에 대해서는 왜 @Getter가 없으면 잘 동작하지 않았을까?

왜 nested object인 Address는 getter가 있어야 접근 가능하고 MemberSaveDto 클래스의 멤버 변수들에 대해서는 getter없이도 접근이 가능할까? 왜 validation이 정상작동하고 binding result를 잘 만들어내는가? 에 대한 궁금증은 해소되지 않았다.

Spring의 데이터 바인딩은 기본적으로는 JavaBeans 규약을 따른다. JavaBeans 규약에 따르면 필드에 직접 접근하는 대신, Getter와 Setter 메서드를 통해 필드에 접근해야 하는데, 데이터 바인딩 과정에서 getter가 없으니 동작을 못했다는 것이다.

다만 Spring에서 일부 기능은 getter, setter만으로 바인딩되는 것이 아니라 리플렉션(Reflection)을 사용하여 Getter와 Setter 메서드 없이도 필드에 직접 접근할 수 있는 기능을 제공한다.

즉, getter가 있으면 getter를 사용하지만, getter가 없으면 데이터 바인딩 과정에서 리플렉션이 사용된다. 아까 위에서 봤듯이 DataBinderinitDirectFieldAccess 메서드를 통해서 리플렉션 기능을 활성화시켜주면 리플렉션이 정상 작동한다.

그럼 이제 다시 본론으로 돌아와서 MemberSaveDto에서는 리플렉션 설정을 해주지 않아도 리플렉션이 되었는데 왜 nested object 인 Address에 대해서는 리플렉션이 정상 작동하지 않았을까?

그 이유는 당연히 리플렉션에 있다. Spring 에서 @RequestBody 어노테이션으로 설정된 DTO 클래스들은 기본적으로 @NoArgsConstructor 어노테이션이 있어야 정상 작동하게 된다. 이유는 DTO로 사용되는 클래스의 필드들에 대해선 리플렉션이 진행되지만, nested object에 대해서까지 리플렉션이 구현되어 있지 않은 것 같다. 그렇기에 위에서 봤던 에러들이 나오게 되는 것이다.

즉, @RequestBody에 대한 리플렉션이 동작할 때는, nested object의 필드에 대해서까지 고려되지 않은채 구현이 되어있어서 IllegalStateException이 발생한 것이다. 음,, 개인적인 생각으론 이렇게 구현한 데에는 이유가 있다고 생각하지만, 어떤 이유로 nested object의 필드에 대해서는 리플렉션이 동작하지 않도록 구현해두었는지는 의문이다.

해결방법

즉 해결방법은 두 가지다.

  • DataBinder에서 Getter, Setter로 필드에 접근하는 방식이 아니라, initDirectFieldAccess 메서드를 이용해 리플렉션 기능으로 nested object 의 필드에 대해 접근하고 validation이 정상 동작할 수 있도록 한다.
  • nested object 에 @Getter를 달아준다.

@AssertTrue 어노테이션을 사용하려면 prefix로 is가 필요하다?

is 가 메서드 이름 앞에 있어야 동작하고, 그렇지 않을 경우에는 validation 로직이 처리되지 못했다. 즉, isValidFood() 면 정상 작동하고, validFood() 면 작동하지 않는다. 즉, 정확히는 is 로 시작하면 @AssertTrue 가 동작하고, 아니라면 동작하지 않는다.

@ToString
@Builder
@AllArgsConstructor
public class MemberSaveDto {
	
    ...

    @NotNull
    private String favoriteFood;

    @AssertTrue
    public boolean validFood() {
        return EnumFinder.findBy(Food.class, Food::getType, favoriteFood) != null;
    }
}

이렇게 만든 것은 자바 빈 표준 규약(JavaBean specification)에 따른 관례때문이다. 자바 빈 규약에서는 프로퍼티가 boolean 타입일 때, getter 메서드의 이름을 "is"로 시작하도록 권장하고 있다. 즉 스프링에서도 이러한 관례를 따라 Spring Validation은 @AssertTrue 어노테이션을 사용할 때 대상이 메서드라면 메서드 이름이 "is"로 시작하도록 요구하는 것이다.

간단히 알아보는 자바 빈 표준 규약들

  • 클래스는 public이며, 기본 생성자를 가져야 한다
  • 프로퍼티는 private로 선언되어야 하며, public getter와 setter 메서드를 가져야 한다
  • boolean 타입의 프로퍼티는 is로 시작하는 getter 메서드를 가져야 한다
  • 프로퍼티의 getter와 setter 메서드는 네이밍 컨벤션에 따라 작성 (ex: getName(), setName(String name))
  • 프로퍼티의 getter와 setter 메서드는 일관된 반환 타입과 매개변수 타입을 가진다
  • (선택사항) 직렬화(Serialization)를 지원하기 위해 java.io.Serializable 인터페이스를 구현한다
  • (선택사항) 이벤트 발행 및 수신을 지원하기 위해 이벤트 리스너 인터페이스 및 이벤트 객체를 정의한다
  • (선택사항) equals(), hashCode(), toString()과 같은 일반적인 메서드를 재정의 가능하다

해결방법

  • @AssertTrue 혹은 @AssertFalse 어노테이션이 붙은 메서드의 이름 앞에는 is 를 붙인다.

Reference


(참고) EnumFinder 구현 - 코틀린, 자바

Validation 과는 별개로 코틀린에서 Enum 클래스마다 find 함수를 구현하는 방식이 아니라 전역적으로 사용될 수 있는 EnumFinder 같은 findBy 함수를 구현해본 적이 있는데 굉장히 간단하고, 좋았던 기억이 있어서 Java로도 구현해보면 좋겠다 싶어서 Validation과 더불어 같이 구현해보았다.

  • 코틀린

    inline infix fun <reified E : Enum<E>, V> ((E) -> V).findBy(value: V): E? {
        return enumValues<E>().firstOrNull { this(it) == value }
    }
    
    // 사용방법
    Food food = Food::type findBy "바나나"
  • 자바

    public class EnumFinder {
        public static <E extends Enum<E>, V> E findBy(Class<E> enumClass, Function<E, V> getSelector, V selector) {
            for (E e: enumClass.getEnumConstants()) {
                if (getSelector.apply(e).equals(selector)) {
                    return e;
                }
            }
            return null;
        }
    }
    
    // 사용방법
    Food food = EnumFinder.findBy(Food.class, Food::getType, "바나나");
profile
꾸벅 🙇‍♂️ 매일매일 한발씩 나아가자잇!

1개의 댓글

comment-user-thumbnail
2023년 11월 1일

좋은 글 감사합니다 :) 잘 보고 가요 👍

답글 달기