Hibernate는 @Column과 @Size 사용 시 길이를 어떻게 판단할까 / 주의할 점

@melonturtle · February 28, 2024 · 15 min read

@Column@Size에 대한 걸 살펴보기 전에 먼저 배경 지식을 잠깐 알아보자.

JPA에 관한 책에서 엔티티의 NOT NULL 컬럼을 구현하는 세 가지 방법으로,

  1. @Basic(optional = false) 어노테이션
  2. @Column(nullable = false) 어노테이션
  3. @NotNull 어노테이션

위 방법들을 소개하면서 @NotNull을 권장했다. 그 이유는 무엇일까?

@NotNull - Java(Jakarta) Bean Validation

우선 @NotNull은 JPA 명세에 포함된 어노테이션은 아니고, Java Bean Validation에 속한 어노테이션이다. 스프링부트는 이 validation을 위해 기본적으로 Hibernate Validator를 사용한다.

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

hibenate validator
hibenate validator

따라서 엔티티뿐만 아니라 다른 클래스에도 필드에 원하는 Bean Validation의 어노테이션을 추가하면, Hibernate validator가 검사를 해주고 조건에 맞지 않으면 exception을 던져준다.

Hibernate Validator와 JPA 연관성

그럼 JPA와 상관 없는 @NotNull 어노테이션이 갑자기 왜 나온걸까? 이는 Hibernate Validator docs의 Integrating with other frameworks 항목을 보면 알 수 있는데, Hibernate Validator는 Hibernate ORM이나 모든 Java Persistence provider와 통합되기 때문이다. 따라서 Hibernate ORM은 DDL을 생성할 때, 엔티티에 선언된 Bean Validation의 어노테이션을 보고 그에 맞는 조건을 추가해준다.

따라서 엔티티에 @NotNull이 선언되어 있으면 테이블 생성시 해당 컬럼에 not null 제약사항을 추가해준다.

만약 이렇게 DDL 생성 시 Bean Validation대로 조건이 추가되는게 싫다면, hibernate.validator.apply_to_ddlfalse로 설정하면 이 기능이 적용되지 않는다.

주의할 점은, Java Bean Validation의 모든 어노테이션이 DDL에 영향을 줄 수 있는 건 아니고, Hibernate Validator docs에 각 어노테이션의 Hibernate metadata impact 부분에 적힌 대로 특정 어노테이션들만 영향이 있다.

@NotNull
@NotNull

docs에서 @NotNull 부분을 살펴보면 impact에 컬럼이 nullable이 아니게 된다고 적혀있다. 즉 DDL에 영향을 주는 것.

JPA의 @Column 대신 Java Bean Validation의 @NotNull?

그렇다면 왜 저자는 @Column(nullable = false) 대신 @NotNull을 추천한걸까? 바로 데이터베이스에 실제 쿼리를 전송하기 전에, validator가 먼저 null인지 체크하고 exception을 발생시키기 때문이다.

  • @Column을 사용하면 DDL에 not null이 추가될 뿐, 런타임에 다른 액션은 일어나지 않는다. 그래서 해당 컬럼에 null이 있더라도, 실제 데이터베이스에 쿼리를 전송하고 그 때 데이터베이스에서 에러가 나야 잘못된 사실을 알 수 있다.
  • 반면 @NotNull을 사용하면 DDL에 not null이 추가될 뿐만 아니라 데이터베이스에 실제 쿼리를 전송하기 전에 validator가 검사해서 exception을 발생시킬 수 있다.

@Column(length=...) 대신 Java Bean Validation의 @Size?

@NotNull을 사용하자는 의견은 충분히 합리적인 의견이다. 실제 쿼리를 전송하고 나서야 결과를 알면, 네트워크를 통한 시간이나 이미 적합하지 않은 데이터인데도 db에 영향을 준다는 점에서 미리 exception을 발생하는 편이 낫다.

그래서 다른 부분에도 Java Bean Validation의 어노테이션을 적용시킬 수 있을지 찾아봤다. 가장 먼저 생각난게 바로 컬럼의 길이 관련된 부분이었다. 현재 프로젝트에선 @Columnlength 속성을 통해 길이를 설정하고, 직접 정의한 메서드 안에서 save하기 전에 길이를 검사하고 있다. 그럼 Java Bean Validation의 길이와 관련된 어노테이션을 활용하면 검사를 안해줘도 되지 않을까?

@Size

@Size
@Size

찾아본 결과 @Size를 활용하면 DDL에도 영향을 주고 validator가 검사를 해주는 것으로 보였다. 또 지금 프로젝트에선 String인 필드의 길이를 검사하고 있었는데, StringCharSequence이므로 문제 없이 적용되는 부분이다. 다만 한가지 의문점이 떠올랐다. validator가 length, 즉 길이 검사를 어떻게 해주는 지에 대한 의문이었다.

MySQL의 VARCHAR 컬럼 길이 기준과 Java의 String 길이

우선 이 의문이 떠오른 이유는 MySQL의 컬럼 길이 판단과 Java에서의 String 길이 판단 방식 때문이었다. 그럼 우선 MySQL부터 살펴보자. 현재 프로젝트에선 MySQL을 사용하고 있고, String을 VARCHAR로 매핑시키기 때문에 해당 부분을 살펴보았다.

MySQL docs의 VARCHAR 타입을 살펴보면 아래와 같이 나와있다.

The CHAR and VARCHAR types are declared with a length that indicates the maximum number of characters you want to store. For example, CHAR(30) can hold up to 30 characters.

따라서 VARCHAR(30)으로 선언하면, 30은 30개의 문자까지 허용한다는 의미가 된다.

그럼 Java의 String 길이는 어떨까? String::length()의 javadoc을 보면 아래와 같이 나와있다.

Returns the length of this string. The length is equal to the number of Unicode code units in the string.

length()는 string에 있는 Unicode code unit의 개수와 같다고 한다.

여기서 MySQL이 문자 개수를 어떻게 셀 지, 위에서 말한 Unicode code unit이 어떻게 되는지 설명하기 위해 좀 더 들어가자면, MySQL의 문자 인코딩 방식과 Java의 문자 인코딩 방식을 살펴봐야 한다.

MySQL의 문자 인코딩 방식

이 방식을 알아보기 전에 쉽게 떠오르는 것으로 Character Set이 있다. MySQL을 사용하다 보면 이모지 사용을 위해 character set을 utf8mb4로 세팅한 적이 있을 것이다. 현재 프로젝트도 이 character set을 쓰고 있는데, 이름대로 UTF-8 기반이다.

UTF-8 인코딩은 유니코드 인코딩 방식 중 하나로, 한 유니코드 코드 포인트가 1~4바이트로 인코딩된다. 이 때 길이는 코드값 범위에 따라 다르게 된다.

UTF-8
UTF-8

물론 실제 몇 바이트가 사용되는지는 컬럼의 길이를 세는 측면에선 상관이 없는 내부적인 구현이다. 어쨌든 중요한 건 어떤 유니코드 포인트든 하나의 문자로 표현된다는 점이다.

Java의 문자 인코딩 방식

Java docs의 Unicode Character Representations를 보면, 자바에서 사용되는 인코딩 방식을 알 수 있다.

The Java platform uses the UTF-16 representation in char arrays and in the String and StringBuffer classes.

위 문장대로, 자바 플랫폼은 MySQL의 utf8mb4과 달리 UTF-16 방식을 쓴다.

UTF-16은 한 유니코드 코드 포인트가 2~4개의 바이트로 인코딩되는데, BMP(U+0000-U+FFFF)까지는 2개의 바이트로 그 외에는 4개의 바이트로 인코딩된다. 따라서 char 는 2바이트기 때문에 BMP에 있는 문자까지는 하나의 char로 표현되지만, 그 외에는 2개의 char 가 필요하게 된다.

문자 인코딩의 차이

그럼 이제 내가 떠오른 의문을 설명해보자면, 같은 문자여도 BMP 외에 있는 문자의 경우 MySQL의 UTF-8 기반 방식에선 하나의 문자가 되지만 Java에선 길이가 1~2인 String이 될 수 있다는 점이다.

실제 다른지 살펴보자.

이모지 😀는 유니코드 코드포인트로 U+1F600으로, BMP가 아닌 SMP에 속해있다. 따라서 MySQL에선 길이가 1이지만 Java에선 길이가 2인 String이 될 것이다.

MySQL에선 CHAR_LENGTH를 통해 문자열의 길이를 구할 수 있다.

SELECT CHAR_LENGTH("😀");

CHAR_LENGTH
CHAR_LENGTH

JAVA에선 String.length()

String emoji = "😀";
System.out.println("emoji.length() = " + emoji.length());

String::length()
String::length()

즉 실제로 실행해본 결과 역시 같은 문자지만 길이가 다른 걸 알 수 있다.

Hibernate Validator의 @Size validation 방식

그럼 Hibernate Validator는 길이를 어떻게 세고 있을까? 현재 사용하고 있는 데이터베이스 시스템 specific하게 길이를 세줄까?

근데 생각해보면, Hibernate Validator는 JPA와 상관 없이 Java Bean Validation의 구현체이고, 엔티티가 아닌 곳에서도 사용 가능하다. 그러니까 데이터베이스와 상관 없이 그냥 자바 기준에서 길이를 세지 않을까?

그래서 Hibernate Validator의 코드를 찾아봤다. Github의 Hibernate Validator Repository에서 @Size 관련 validator를 찾아보면 될 것이다.

  • SizeValidatorForCharSequence 클래스

    여기서 valid 여부를 확인하는 isValid 함수 코드를 살펴보자.

    @Override
    public boolean isValid(CharSequence charSequence, ConstraintValidatorContext constraintValidatorContext) {
      if ( charSequence == null ) {
        return true;
      }
      int length = charSequence.length();
      return length >= min && length <= max;
    }

우선 Hibernate Validator는 코드가 자바로 되어있고, CharSequence::length() 메서드를 이용한다. 즉 String을 사용할 땐 위에서 살펴본 자바의 String::length() 메서드를 이용하는 것이다.

@Column(length = ...)를 사용할 때와 @Size를 사용할 때의 불일치

위에서 살펴본 것들을 보면, 두 방식 간의 불일치가 발생할 수 있다. @Column(length = ...)만 사용할 때는 문제 없이 save되던 엔티티도 @Size를 쓸 때는 save되지 않고 exception이 발생할 수 있는 것이다.

위에서 살펴본 이모지의 예를 들어보면,

  • @Column(length = 1)가 어노테이션된 String 필드가 "😀"이면 문제 없이 save가 되지만,
  • @Size(max = 1)가 어노테이션된 String 필드가 "😀"이면 Validation이 실패해서 exception이 발생하게 된다.

    그럼 @Size(max = 1)@Column(length = 2)를 같이 사용하면 위 이모지도 save가 가능한지 궁금해서 해봤지만, @Size가 DDL에서 우선적으로 적용되는지 몰라도 실제 db 스키마를 살펴보면 컬럼이 VARCHAR(1)로 선언된 걸 확인했다.

결론

결론은 @Size validator는 자바 기준 길이를 사용하기 때문에 실제 데이터베이스에서 판단되는 문자열 길이와 상관 없이 exception이 발생할 수 있다는 점이다. 이 점에서 개인적으로는 @Size를 사용해서 Hibernate Validator한테 길이 체크를 위임하는 것보다는, 코드에서 명시적으로 자바의 String.length()를 이용해 체크를 하는게 더 나을 것이라고 생각된다. 어노테이션만 봤을 때는 DDL도 만들어주기 때문에 똑같은 기준으로 길이를 셀 것이라고 예상될 수도 있기 때문이다.

또, 필드의 요구사항이 길이 10이라고 주어져도, 이모지가 포함될 수 있는 필드라면 조합이모지같이 길이가 1을 넘는 경우도 있으므로, db에서도 길이 10보다 더 길게 설정해야 할 것이다. 그럼 @Column 으로 길이 체크를 할 때도 더 길게 설정해야할 것이다. VARCHAR는 가변 길이 문자열이므로 선언한 길이대로 공간을 차지하고 있는게 아니기 때문에, 다양한 상황을 고려해도 괜찮을 것 같다. @Column은 넉넉하게 설정해두고, 커스텀 어노테이션으로 이모지 길이도 grapheme cluster대로 측정하는 Bean Validatior를 정의해서 사용하는 것도 방법이 될 수 있을 것이다.

결론은 @Size를 엔티티의 프로퍼티에 사용한다면 이 점을 주의하자!

@melonturtle
공부하면서 남긴 기록입니다. 문제가 있다면 언제든지 알려주세요!