본문 바로가기

Development/Diary

[개발 일기] Hibernate의 UUID 변환과 PostgreSQL, H2 DB에 저장할 때 동작 + Foreign Key Constraint

개요

프로젝트 테스트 환경이 PostgreSQL의 서버를 필수적으로 켜져있어야 테스트가 가능했다.

Springboot는 h2 db를 내장하고 있어 PostgreSQL의 의존성을 없애기 위해 새로운 테스트 환경을 만들고 있었다.

 

문제는 데이터 타입을 UUID로 사용하고 있는 필드에 대해서 아래와 같은 에러가 발생했다.

Caused by: org.h2.message.DbException: Data conversion error converting "UUID requires 16 bytes, got 255" [22018-214]
	at org.h2.message.DbException.get(DbException.java:223)
	at org.h2.message.DbException.get(DbException.java:199)
	at org.h2.value.ValueUuid.get(ValueUuid.java:69)
	at org.h2.value.Value.convertToUuid(Value.java:2441)
	at org.h2.value.Value.convertTo(Value.java:1180)
	at org.h2.value.Value.convertTo(Value.java:1035)
	at org.h2.table.Column.convert(Column.java:181)
	... 270 more
Caused by: org.h2.jdbc.JdbcSQLDataException: Data conversion error converting "UUID requires 16 bytes, got 255" [22018-214]
	at org.h2.message.DbException.getJdbcSQLException(DbException.java:506)
	at org.h2.message.DbException.getJdbcSQLException(DbException.java:477)
	... 277 more

 

org.h2.jdbc.JdbcSQLDataException: Data conversion error converting 
"CAST(X'2c4a117857ef4a05a2901f3e088f6fd
500000000000000000000000000000000000000
000000000000000000000000000000000000000
000000000000000000000000000000000000000
000000000000000000000000000000000000000
000000000000000000000000000000000000000
000000000000000000000000000000000000000
000000000000000000000000000000000000000
000000000000000000000000000000000000000
000000000000000000000000000000000000000
000000000000000000000000000000000000000
000000000000000000000000000000000000000
0000000000000000000000000000000000000000
0000000000' AS BINARY(255)) (SURVEY: ""SURVEY_ID"" UUID NOT NULL)"; SQL statement:
insert into question (is_required, question_no, question_bank_id, survey_id) values (?, ?, ?, ?) [22018-214]

저 무수히 많은 000... 과 AS BINARY(255) 는 무엇인가..

왜 h2를 사용하니까 저런 쿼리가 발생했는지 찾아보았다.

해결한 방법

UUID 타입의 필드에 대해, columnDefinition = "uuid" 를 추가하여 DB에 저장할 때 uuid 데이터 타입으로 저장하도록 명시한다.

 

UUID를 사용하는 엔티티

@Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Column(name = "survey_id", columnDefinition = "uuid")
    private UUID surveyId;

 

UUID 엔티티를 외래키로 사용하고 있는 엔티티

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "survey_id", columnDefinition = "uuid")
public Survey survey;

그럼 PostgreSQL에서는 이럴 필요 없었는데.. H2 에서는 왜??

사용하고 있던 테스트 환경

  • Springboot 2.7.9
  • PostgreSQL14
  • hibernate core 5.6.15.Final
  • h2 2.1.214

PostgreSQL의 경우..

ORM 매핑을 담당하는 hibernate의 공식 문서를 읽어보았다.

https://docs.jboss.org/hibernate/orm/5.6/userguide/html_single/Hibernate_User_Guide.html#_postgresql_specific_uuid

 

Hibernate ORM 5.6.15.Final User Guide

Fetching, essentially, is the process of grabbing data from the database and making it available to the application. Tuning how an application does fetching is one of the biggest factors in determining how an application will perform. Fetching too much dat

docs.jboss.org

위 링크를 읽어보면 아래와 같이 써져있다.


When using one of the PostgreSQL Dialects, the PostgreSQL-specific UUID Hibernate type becomes the default UUID mapping.

PostgreSQL의 Dialect를 사용하면 Hibernate UUID type이 PostgreSQL의 UUID 로 매핑된다.

때문에 columnDefinition = "uuid"로 지정을 안 하더라도, 알아서 Hibernate의 UUID를 PostgreSQL의 UUID로 캐스팅 해주었던 것이다. 그래서 문제가 발생하지 않았다.

 

H2의 경우

https://docs.jboss.org/hibernate/orm/5.6/userguide/html_single/Hibernate_User_Guide.html#basic-uuid

 

Hibernate ORM 5.6.15.Final User Guide

Fetching, essentially, is the process of grabbing data from the database and making it available to the application. Tuning how an application does fetching is one of the biggest factors in determining how an application will perform. Fetching too much dat

docs.jboss.org

The default UUID mapping is the binary one because it uses a more efficient column storage.

 

Hibernate의 기본 UUID 매핑은 BINARY 방식이다.

즉, 사용자가 명시적으로 다른 설정을 지정하지 않으면 hibernate는 UUID를 byte[] 배열로 변환하여 BINARY 데이터 타입의 열에 저장한다. 

그리고 H2에서 이를 BINARY(255)로 캐스팅했다.

발생할 수 있는 문제 : Foreign Key Constraint 위반

쿼리를 실행할 때 테이블 제약 조건에 따라 검증하는 과정에서 문제가 발생할 수 있다.

 

예시를 들어보자면,

아래 Survey 엔티티와, QuestionTest 엔티티가 있다.

@Entity
@Table(name = "survey")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Survey extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Column(name = "survey_id", columnDefinition = "uuid")
    private UUID surveyId;
@Entity
@Table(name = "question_test")
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class QuestionTest {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "survey_id", columnDefinition = "BINARY(20)")
    public Survey survey;

 

Survey 엔티티의 ID는 UUID, QuestionTest에서 SurveyID를 Foreign Key로 사용한다

이 때 Survey의 ID 타입을 columnDefinition = "BINARY(20)"으로 설정한다.

그리고 아래 테스트 코드를 실행한다.

@SpringBootTest
class QuestionTestRepositoryTest {
    @Autowired
    SurveyRepository surveyRepository;

    @Autowired
    QuestionTestRepository questionTestRepository;

    @Test
    void testCreate(){
        Survey survey = surveyRepository.save(Survey.builder().build());

        QuestionTest given = QuestionTest.builder()
                .survey(survey)
                .build();

        questionTestRepository.save(given);
    }

}

 

테스트는 간략하게 아래와 같은 흐름으로 실행된다.

  1.  Survey를 저장하고 UUID를 생성한다.
  2. QuestionTest를 저장하려는데, Foreign Key로 사용할 surveyId 타입을 BINARY(20)으로 정의했으므로 크기 20을 채우기 위해(UUID는 BINARY(16) 크기를 가지므로) hibernate는 UUID 끝에 "00000000"를 붙인다.
  3. 그 후 Foreign Key Constraint를 검증하는 단계에서, Foreign key의 Survey ID 타입이 BINARY(20)이기 때문에 BINARY(16)을 필요로 하는 Primary Key의 UUID 타입으로 CAST할 수 없어 DataIntegrityViolationException 예외가 발생한다.

디버깅 모드로 Flow 추적 해보기

  • 위 테스트 코드 실행 Flow를 추적한다.

1. UUID 데이터가 BINARY(20) 으로 바뀌는 과정

JdbcPreparedStatement 는 SQL 문을 생성한다.

 

 

아래 코드에서 org.h2.Value의 convertToBinary 메서드를 호출한다.

이 메서드는 데이터를 정의한 타입에 맞게 변환한다.

SURVEY_ID를 columnDefinition = "BINRAY(20)" 으로 했기 때문에, v = ValueBinary.getNoCopy(Arrays.copyOf(value, p)); 의 p는 20이다.

private ValueBinary convertToBinary(TypeInfo targetType, int conversionMode, Object column) {
    ValueBinary v;
    if (getValueType() == BINARY) {
        v = (ValueBinary) this;
    } else {
        try {
            v = ValueBinary.getNoCopy(getBytesNoCopy());
        } catch (DbException e) {
            if (e.getErrorCode() == ErrorCode.DATA_CONVERSION_ERROR_1) {
                throw getDataConversionError(BINARY);
            }
            throw e;
        }
    }
    if (conversionMode != CONVERT_TO) {
        byte[] value = v.getBytesNoCopy();
        int length = value.length;
        int p = MathUtils.convertLongToInt(targetType.getPrecision());
        if (length != p) {
            if (conversionMode == ASSIGN_TO && length > p) {
                throw v.getValueTooLongException(targetType, column);
            }
            v = ValueBinary.getNoCopy(Arrays.copyOf(value, p));
        }
    }
    return v;
}

v = ValueBinary.getNoCopy(Arrays.copyOf(value, p)); 호출
이 때 각 변수 별로 저장되어 있는 데이터들

Arrays.copyOf에서 newLength = 20이 된다. 그리고 byte[] original 배열에 길이 20을 맞추기 위해 0이 4개가 추가 된다.

그 결과 UUID를 BINARY(20)으로 바꾸면서 맨 끝에 0이 추가 되었고(right-padding), 아래와 같은 CAST SQL문이 생성된다.

 

2. Foreign Key Constraint 검증 과정

@joincolumn 에서 기본적으로 생성되는 constraint(@foreignkey(PROVIDER_DEFAULT)) 가 있다.

이는 Foreign key의 타입이나 @NotNull과 같은 Constraint 조건을 검증한다. 이에 따라 QuestionTest 에 설정되는 외래키(survey_id) 검증을 진행하게 된다.

 

ConstraintReferential 클래스의 checkRowOwnTable 메서드에서 Column 별로 Constraint를 검증한다.

refCol.convert가 이 SQL문을 실행하기 위해 Column Constraint를 검사한다.

 

convert 메서드를 보면 CastDataProvider를 파라미터로 받고, 위 코드에서는 SessionLocal 클래스를 넘기는데 이게 어떻게 호환이 되냐면 CastDataProvider는 SesstionLocal을 구현한 인터페이스이기 때문이다.

 

그리고 convert 메서드에서 convertTo를 호출한다.

CastDataProvider는 아래와 같은 정보를 가지고 있다.

 

v.convertTo를 따라가 보면 targetType이 UUID이다. 따라서 convertToUuid()로 들어간다.

아래 ValueUuid.get(getBytesNoCopty());에서 get 함수로 들어간다.

length = 20이기 때문에, if (length != 16) 이 true 이므로 DATA_CONVERSTION_ERROR가 발생하게 된다.

느낀점

  • hibernate의 이러한 데이터 타입 변환 과정이 있다는 것을 알게되었다...
  • 데이터 타입에 따른 ORM 과정과 DB에서 저장하는 방식의 차이에 대해 고려하여 가급적 columnDefinition 으로 타입 지정을 해주어야 겠다고 생각했다.

참고

https://github.com/h2database/h2database/issues/3523

 

Querying by UUID not working · Issue #3523 · h2database/h2database

I'm trying to select some entities based on a field of type java.util.UUID using hibernate in h2 version 2.1.212. This used to work in 1.4.200 and it does work for fields of other types. My example...

github.com

https://helloworld.kurly.com/blog/jpa-uuid-sapjil/

 

JPA 덕분에 DB에서 삽질한 이야기

DB에 저장을 했는데, 조회가 안 돼요

helloworld.kurly.com