[JPA] Bean Validation과 Hibernate apply-to-ddl
이 글은 이전에 작성한 포스팅에 기반을 두고 있다.
이전 글을 간단히 정리하면 다음과 같다.
- JPA에서는 DDL 자동 생성시, @NotNull 어노테이션을 쓴 컬럼을 not null로 설정해준다.
- 하지만 똑같이 null을 금지하는 @NotEmpty과 @NotBlank는 컬럼을 not null로 설정해 주지 않는다.
- 이것은 이상하다는 생각이 들었다.
이전 포스팅을 작성한 뒤에도, 대체 왜 이런 차이가 있을까 고민을 많이 했다.
그 과정에서 Bean Validation에 대해서도 많이 찾아볼 수 있었다.
Bean Validation이란
Jakarta Bean Validation은 값의 검증을 어노테이션으로 간단하게 도와주는 오픈소스이다.
이들이 제공하는 Bean Validation 2.0은 인터페이스로, 실제 구현체가 있어야 사용할 수 있다.
먼저 Java에는, javax(자바 확장 패키지)에 포함되는 Bean Validation의 구현체가 있다.
또한 공식 java 패키지에는 포함되지 않지만, Hibernate에서 만든 Hibernate Validator라는 구현체도 있다.
Hibernate Validator는 javax의 Bean Validation을 기반으로 만들어졌으며,
javax 버전이 제공하지 않는 ISBN 검증, URL 검증 등의 독특한 검증 로직을 포함하고 있다.
@NotEmpty와 @NotBlank의 기원
Jakarta Bean Validation 인터페이스는 1.1버전까지는 @NotNull만 가지고 있었고,
@NotEmpty와 @NotBlank를 가지지 않았다.
그래서 Hibernate Validator에서 구현한 @NotEmpty와 @NotBlank를 사람들이 사용했다고 한다.
그리고, 당시 Hibernate Validator의 @NotEmpty와 @NotBlank는 @NotNull을 코드에 포함하고 있었다.
(@NotNull은 @NotEmpty와 @NotBlank의 하위호환이므로 가능한 일이다)
Validation 2.0의 변경사항
그리고 2017년 3월, Bean Validation 공식 버전에 @NotEmpty와 @NotBlank, @Email 어노테이션이 포함된다.
해당 커밋 : 링크
관련 이슈 : 링크
Jira 이슈 : 링크
또한, 이로 인해 기존에 있던 Hibernate Validator의 해당 어노테이션들은 사용하지 못하도록 조정된다.
해당 커밋 : 링크
그런데 이 과정에서 문제가 생긴다.
공식 버전의 @NotBlank와 @NotEmpty는 @NotNull을 포함하지 않는다!!
Validation 코드 따라가보기
이게 무슨 의미인지, @NotBlank의 코드가 어떻게 바뀌었는지 한번 따라가보자.
먼저, Hibernate에 있는 @NotBlank Validation 코드이다.
/**
* Validate that the annotated string is not {@code null} or empty.
* The difference to {@code NotEmpty} is that trailing whitespaces are getting ignored.
*
* @author Hardy Ferentschik
*
* @deprecated use the standard {@link javax.validation.constraints.NotBlank} constraint instead
*/
@Documented
@Constraint(validatedBy = { })
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@ReportAsSingleViolation
@NotNull
@Repeatable(NotBlank.List.class)
@Deprecated
public @interface NotBlank {
String message() default "{org.hibernate.validator.constraints.NotBlank.message}";
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { };
/**
* Defines several {@code @NotBlank} annotations on the same element.
*/
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Documented
public @interface List {
NotBlank[] value();
}
}
@NotNull 어노테이션이 포함되어 상위호환 개념을 지켜주는게 인상적이다.
그 외에는 단순하게 구현되어 있으며, 실제 구현체는 별도로 있어 거기에서 로직을 처리한다.
다만 재미있는 부분은 @Deprecated가 붙어있다는 점. 저게 붙어 있으면 해당 코드 사용 시 경고 메시지를 표시한다.
다른 코드와의 충돌, 버전 이슈 등으로 해당 코드를 사용하지 말아야 하기 때문이다.
여기에서는, 공식적인 새 @NotBlank 어노테이션이 있으므로 이 어노테이션을 사용하지 않도록 붙여둔 것이다.
이제, Bean Validation 2.0 알파버전에 새로 추가된 @NotBlank의 모습을 보자.
/*
* Jakarta Bean Validation API
*
* License: Apache License, Version 2.0
* See the license.txt file in the root directory or <http://www.apache.org/licenses/LICENSE-2.0>.
*/
(import 생략)
/**
* The annotated element must not be {@code null} and must contain at least one
* non-whitespace character. Accepts {@code CharSequence}.
*
* @author Hardy Ferentschik
* @since 2.0
*
* @see Character#isWhitespace(char)
*/
@Documented
@Constraint(validatedBy = { })
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Repeatable(List.class)
public @interface NotBlank {
String message() default "{javax.validation.constraints.NotBlank.message}";
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { };
/**
* Defines several {@code @NotBlank} constraints on the same element.
*
* @see NotBlank
*/
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Documented
public @interface List {
NotBlank[] value();
}
}
@ReportAsSingleViolation과 @NotNull이 제거되었다!!
@ReportAsSingleViolation은 어떤 어노테이션이 다른 어노테이션을 상속해서 사용할 경우,
메시지를 하나만 띄우기 위해 사용하는 어노테이션이다.
@NotBlank가 @NotNull을 상속하는 구조이므로 들어갔던 것인데,
@NotNull이 빠졌으므로 함께 제거된 것이다.
두 코드의 구현체의 모습을 보자.
이 코드는 2.0에서 만들어진 새 @NotBlank의 구현체 NotBlankValidator이다.
/*
* Hibernate Validator, declare and validate application constraints
*
* License: Apache License, Version 2.0
* See the license.txt file in the root directory or <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package org.hibernate.validator.internal.constraintvalidators.bv;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import javax.validation.constraints.NotBlank;
/**
* Check that a character sequence is not {@code null} nor empty after removing any leading or trailing whitespace.
*
* @author Guillaume Smet
*/
public class NotBlankValidator implements ConstraintValidator<NotBlank, CharSequence> {
/**
* Checks that the character sequence is not {@code null} nor empty after removing any leading or trailing
* whitespace.
*
* @param charSequence the character sequence to validate
* @param constraintValidatorContext context in which the constraint is evaluated
* @return returns {@code true} if the string is not {@code null} and the length of the trimmed
* {@code charSequence} is strictly superior to 0, {@code false} otherwise
*/
@Override
public boolean isValid(CharSequence charSequence, ConstraintValidatorContext constraintValidatorContext) {
if ( charSequence == null ) {
return false;
}
return charSequence.toString().trim().length() > 0;
}
}
그렇다면 Bean Validation 2.0 이전에 사용하던 Hibernate의 @NotBlank 구현체는 어떨까?
놀랍게도, 코드가 같다.
@SuppressWarnings를 써서 경고를 제외한 것과 주석/경로가 바뀐 것 외엔 같은 코드!!
덧붙여서, 이 Validation 코드들은 어디서 실행되는 것일까?
application.properties에 있는 spring.jpa.properties.hibernate.validator.apply_to_ddl 옵션을 통해
Validation을 DDL에 반영하는 동작을 제어할 수 있다.
이를 추적하다 보니 해당 옵션이 영향을 주는 클래스 하나를 찾을 수 있었다.
org.hibernate.cfg.beanvalidation에 있는 TypeSafeActivator이다.
해당 코드에는 applyNotNull, applySize, applyLength 등의 제약조건 설정 메서드가 존재했다.
아래의 코드는 그 중 applyNotNull을 참고용으로 넣은 것이다.
@SuppressWarnings("unchecked")
private static boolean applyNotNull(Property property, ConstraintDescriptor<?> descriptor) {
boolean hasNotNull = false;
if ( NotNull.class.equals( descriptor.getAnnotation().annotationType() ) ) {
// single table inheritance should not be forced to null due to shared state
if ( !( property.getPersistentClass() instanceof SingleTableSubclass ) ) {
//composite should not add not-null on all columns
if ( !property.isComposite() ) {
final Iterator<Selectable> itr = property.getColumnIterator();
while ( itr.hasNext() ) {
final Selectable selectable = itr.next();
if ( Column.class.isInstance( selectable ) ) {
Column.class.cast( selectable ).setNullable( false );
}
else {
LOG.debugf(
"@NotNull was applied to attribute [%s] which is defined (at least partially) " +
"by formula(s); formula portions will be skipped",
property.getName()
);
}
}
}
}
hasNotNull = true;
}
property.setOptional( !hasNotNull );
return hasNotNull;
}
해당 코드에 있는 메서드를 바탕으로, 다음을 추론할 수 있었다.
- @Size, @Max, @Min, @Length 등을 사용할 경우, 이에 맞게 제약조건이 DDL에 추가된다.
(예를 들어, 숫자인 칼럼의 @Max의 값이 범위를 넘는다면 형식을 BIGINT로 바꾼다) - 같은 원리로, @NotNull을 사용할 경우 해당 칼럼에 "NOT NULL" 제약조건이 추가된다.
- 그러나, @NotEmpty나 @NotBlank에 대한 로직은 찾을 수 없었다.
이것이 올바른 동작인가
이렇게 새 버전에서 @NotNull에 대한 상위호환성이 제거되고,
이로 인해 DDL 생성 시 @NotBlank나 @NotEmpty를 써도
not null 제약조건이 자동으로 들어가지 않는 문제가 생긴다.
심지어 Bean Validation 2.0 이전에는(Hibernate Validator 버전을 사용할 때에는)
@NotBlank나 @NotEmpty를 붙여 칼럼 생성 시 not null이 들어갔을 것이므로,
이는 무언가 의도하지 않은 상황이라고 추론할 수 있었다.
다만, 공식 문서에서는 @NotNull과 달리 @NotEmpty와 @NotBlank가 DDL에 영향을 주지 않음을 명시하고 있다.
이 부분 때문에 해당 개발자들도 이 이슈를 알지 않을까? 라는 고민을 많이 했다.
그럼에도 나는 이것이 잘못된 상황이라고 생각했다.
위에서 언급하였듯, 이전까지 가능했던 기능이 버전업이 되면서 언급 없이 빠지는 것은 잘못된 일이다.
- 똑같이 null을 금지하는 검증 어노테이션인데 효과가 다르게 나오는 것은 잘못된 일이다.
- 이런 차이가 개발자에게 혼동을 주고 원하지 않은 에러를 일으킬 가능성이 존재한다.
Hibernate에 이슈를 전달하다
여기까지 이슈를 파악하고, 함께 검증 과정을 진행한 친구와 상의를 했다.
우리가 보기에 이 상황은 명확한 버그로 보였고, 이것을 해결하는 것이 더 자연스럽다고 생각했다.
그래서, Hibernate에 이슈를 전달하기로 결심했다.
또한 위의 이슈를 바탕으로 Hibernate ORM에 PR을 보냈다!!
https://github.com/hibernate/hibernate-orm/pull/4195#issuecomment-914781855
다행히 이에 대해 맴버들이 응답을 해주었다. 내용이 누적되면 추후 포스팅을 다시 남길 예정이다.
이번 기회에 Validation에 대해 찾아보며, 문서도 한참 읽어보고 코드도 뒤져보는 소중한 경험을 했다.
친구와 몇 시간을 학습에 몰두했고, 이후 토의를 거쳤음에도 이 현상이 부자연스럽다고 느껴 피드백을 전달했다.
정말 잘 되어서 Bean Validation 프로젝트에 기여한다면 좋을 것 같고,
그게 아니더라도 왜 저런 변화가 생겼는지, 어떤 이유가 있는지에 대해 안다면 마음이 놓일 것이다.
개발을 한지 꽤 오래됐는데 이게 첫 오픈소스 기여이다.
앞으로는 더 많은 PR과 기여를 하고 싶다고 생각하며 글을 맺는다.