개발

자바 벨리데이터(validator)과 리플렉션(reflection)

동고킴 2023. 3. 15. 05:57
반응형

validator는 null을 검사하지 않고 순서를 보장하지 않는다.

 

아래 클래스처럼 벨리데이터를 사용하던 중 의문이 든 내용이다.

class User {
    @Size(min = 1, max = 50)
    @Pattern(regexp = "^[a-zA-Z]$")
    private String name;

    @Min(value = 18)
    private int age;

    @Min(value = 130)
    private int tall;

    public User(String name, int age, int tall) {
        this.name = name;
        this.age = age;
        this.tall = tall;
    }
}

- name에 null이 들어와도 에러가 발생하지 않는다. @size, @pattern 동작 안 함

- 클래스 내 필드에서 정합성 오류가 여러 건 발생하였는데, 오류 결과가 클래스에서 선언한 필드 순서와 달랐다.

 

이 내용에 대해서 정리를 해보았다.

 

1) null은 검증 대상에서 무시된다.

첫 번째로 먼저 name에 null이 들어왔을 때 동작이 안 하는 건 쉽게 알 수 있었다. 이유는 JRS 303 스펙에 "Null references are ignored."이라고 명시되어 있다.

 

The @Valid annotation on a given association (i.e. object reference or collection, array, Iterable of objects), dictates the Bean Validator implementation to apply recursively the Bean Validation routine on (each of) the associated object(s). This mechanism is recursive: an associated object can itself contain cascaded references.
Null references are ignored.

 

 

그래서 null을 체크하려면 @NotNull 또는 @NotBlank 등의 어노테이션을 추가하면 된다.

@Size(min = 1, max = 50)
@Pattern(regexp = "^[a-zA-Z]$")
@NotBlank
private String name;

 

 

2) 검증은 클래스에 선언한 필드 및 어노테이션 순서대로 수행하지 않는다.

User 클래스 필드에서 모든 필드 값이 오류일 경우, name, age, tall 순서로 에러를 반환하지 않는다.

public static void main(String[] args) {
    ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
    Validator validator = factory.getValidator();

    User user = new User("", 8, 100);

    Set<ConstraintViolation<User>> violations = validator.validate(user);
    for (ConstraintViolation<User> violation : violations) {
        System.out.println(violation.getPropertyPath() + ": " + violation.getMessage());
    }
}

 

name, age, tall에 "", 8, 100을 입력하면 아래처럼 에러를 반환한다. 결과 순서가 클래스 내 필드 순서와 어노테이션 순서와 맞지 않는다.

name: size must be between 1 and 50
name: must match "^[a-zA-Z]$"
age: must be greater than or equal to 18
name: must not be blank
tall: must be greater than or equal to 130

 

그리고 결과 순서는 수행할 때마다 바뀐다.

public static void main(String[] args) {
    ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
    Validator validator = factory.getValidator();

    for (int i=0; i<3; i++) {
        User user = new User("", 8, 100);
        Set<ConstraintViolation<User>> violations = validator.validate(user);
        for (ConstraintViolation<User> violation : violations) {
            System.out.println(violation.getPropertyPath() + ": " + violation.getMessage());
        }
        System.out.println("========================================");
    }

}
name: size must be between 1 and 50
name: must match "^[a-zA-Z]$"
age: must be greater than or equal to 18
name: must not be blank
tall: must be greater than or equal to 130
========================================
name: must match "^[a-zA-Z]$"
age: must be greater than or equal to 18
name: must not be blank
name: size must be between 1 and 50
tall: must be greater than or equal to 130
========================================
name: must match "^[a-zA-Z]$"
name: size must be between 1 and 50
age: must be greater than or equal to 18
tall: must be greater than or equal to 130
name: must not be blank
========================================

 

하지만 User 객체를 매번 생성하지 않고, 이미 생성된 객체에 대해서 수행하면 순서는 동일하게 나온다.

User user = new User("", 8, 100);
for (int i=0; i<3; i++) {
    Set<ConstraintViolation<User>> violations = validator.validate(user);
    for (ConstraintViolation<User> violation : violations) {
        System.out.println(violation.getPropertyPath() + ": " + violation.getMessage());
    }
    System.out.println("========================================");
}
name: size must be between 1 and 50
name: must match "^[a-zA-Z]$"
age: must be greater than or equal to 18
name: must not be blank
tall: must be greater than or equal to 130
========================================
name: size must be between 1 and 50
name: must match "^[a-zA-Z]$"
age: must be greater than or equal to 18
name: must not be blank
tall: must be greater than or equal to 130
========================================
name: size must be between 1 and 50
name: must match "^[a-zA-Z]$"
age: must be greater than or equal to 18
name: must not be blank
tall: must be greater than or equal to 130
========================================

 

서론이 길었는데 결론부터 말하자면, 순서가 다른 이유는 validator는 리플렉션(reflection)을 사용하고 있고 리플렉션은 순서를 보장하지 않기 때문이다.

 

리플렉션은 자바에서 클래스, 인터페이스, 필드, 메서드 등에 접근하고 조작하기 위한 기술이다. 리플렉션을 사용하면 클래스의 정보를 가져오거나, 클래스의 인스턴스를 생성하고, 필드나 메서드 등의 정보를 동적으로 조작할 수 있다. 이를 통해, 자바에서는 런타임 시점에 클래스의 동적인 조작이 가능하다. 벨리데이터도 이를 활용하여 기능을 구현하고 있다.

 

하지만 리플렉션 API들은 순서를 보장하지 않는다. 예를 들어 아래 두 개 API를 보자. 각각 클래스에 선언된 필드와 메서드를 조회하는 API이다.

 

getDeclaredFields

Returns an array of Field objects reflecting all the fields declared by the class or interface represented by this Class object. This includes public, protected, default (package) access, and private fields, but excludes inherited fields. If this Class object represents a class or interface with no declared fields, then this method returns an array of length 0. If this Class object represents an array type, a primitive type, or void, then this method returns an array of length 0. The elements in the returned array are not sorted and are not in any particular order.

 

getDeclaredMethods

Returns an array containing Method objects reflecting all the declared methods of the class or interface represented by this Class object, including public, protected, default (package) access, and private methods, but excluding inherited methods. If this Class object represents a type that has multiple declared methods with the same name and parameter types, but different return types, then the returned array has a Method object for each such method. If this Class object represents a type that has a class initialization method <clinit>, then the returned array does not have a corresponding Method object. If this Class object represents a class or interface with no declared methods, then the returned array has length 0. If this Class object represents an array type, a primitive type, or void, then the returned array has length 0. The elements in the returned array are not sorted and are not in any particular order.

 

 

리플렉션이 순서를 보장하지 않는다는 건 알았는데, 그럼 왜 순서를 보장하지 않을(못할)까?

이 이유를 알기 위해선 자바의 메타데이터(Metadata)와 메타스페이스(Metaspace)와 에 대한 지식이 필요하다.

 

Metaspace(메타스페이스)

Metaspace는 자바 8 이후부터 도입된 개념으로 JVM의 메모리 영역 중 하나이다. JVM의 메모리 영역 중 Permgen(Permanent Generation) 대신 새로 생긴 메모리 영역이다. 가장 큰 차이는 Permgen은 Heap 영역에 있었지만 Metaspace는 Native Memory 영역에 위치한다는 것이다.

 

자바에서 클래스는 최초에 모두 로딩되는 것은 아니고 필요 시점에 로딩되는데, Metaspace는 클래스가 로드되고 런타임이 JVM에 준비될 때 클래스로더(loader)에 의해 할당된다. 그리고 Metaspace 릴리즈는 클래스 로더가 언로드(unload) 될 때 발생한다.

 

Metadata(메타데이터)

metadata(메타데이터)는 클래스의 정보를 기술하는 데이터이다. metadata는 클래스의 이름, 필드, 메서드, 생성자, 상위 클래스, 인터페이스 등과 같은 정보를 포함한다. 그리고 metadata는 heap이 아닌 native memory 영역에 위치하며, 이 영역이 바로 Metaspace이다. 클래스는 heap 영역 안에 있는 object이지만, metadata는 object가 아니며 heap 안에 위치하지 않고, native memory의 metaspace 영역에 위치한다. 그리고 JVM은 클래스 멤버를 내부적으로 저장하는 방법을 정의하지 않는다.

 

정리

- 자바 validator는 리플렉션을 사용한다.

- 리플렉션은 metadata를 사용한다.

- metadata는 metaspace에 위치한다.

- mestaspace는 클래스가 로드될 때 생성되고, 언로드될때 해지된다.

- metadata는 JVM이 클래스를 로드할 때 수집된다.

- JVM은 클래스 멤버를 내부적으로 저장하는 방법을 정의하지 않는다.

- 그래서 리플렉션을 사용하여 클래스 멤버를 가져오는 validator는 순서가 보장되지 않는다.

 

 

참고

- https://beanvalidation.org/1.0/spec/#constraintdeclarationvalidationprocess-validationroutine-graphvalidation

- https://docs.oracle.com/javase/8/docs/api/java/lang/Class.html

- https://stackoverflow.com/questions/1097807/java-reflection-is-the-order-of-class-fields-and-methods-standardized

- https://jaemunbro.medium.com/java-metaspace%EC%97%90-%EB%8C%80%ED%95%B4-%EC%95%8C%EC%95%84%EB%B3%B4%EC%9E%90-ac363816d35e

 

반응형