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

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

발단: 저장했는데, 조회가 안 된다?

최근에 새로운 엔티티를 작성할 때 Id 컬럼의 타입을 UUID로 지정하면서 경험했던 이슈가 있다. DB에 저장을 했는데도, UUID를 이용하여 조회를 할 수 없는 이상한(?) 상황이었다.

문제를 재현해 보자

상황을 더 자세히 파악하기 위해 Test Case를 작성하고 문제를 재현해 보기로 했다.

  1. UUID 속성을 ID로 갖는 엔티티 작성
  2. 가장 작은 기능(1)을 구현하고, 대응하는 Test Case를 작성
  3. 다음 기능(2)을 구현하고, 마찬가지로 대응하는 Test Case를 작성

그런데, 2번 기능을 검증하는 Test Case를 통과하지 못하는 문제를 발견하였다.

2번 기능의 흐름을 요약하면 아래와 같다.

  1. 전달받은 UUID를 이용하여 조회를 요청한다.
    • 조회 결과가 null이면 예외로 처리하고 중단한다.
  2. 다음 로직을 실행한다.
final Repository repo;

SampleObject exampleMethod(UUID uuid) {
  Job job = repo.findById(uuid)
      .orElseThrow(() -> new SampleException(...));
  
  return new SampleObject(arg, ref1, ...);
}

테스트의 목적과 흐름을 정리해 보자

목적

컨텍스트별로 내가 기대하는 방향으로 동작하는지 확인한다.

테스트 흐름

  • 2번 기능을 테스트하기 위해 컨텍스트를 구성한다.
  • 2번 기능을 실행한다.
  • 기대와 같은 결과가 도출되는지 확인한다.

테스트에 실패한 케이스

  • 1번 기능을 이용하여 테스트를 위한 컨텍스트를 구성한다.
  • 컨텍스트에서 유효한 UUID를 이용하여 2번 기능을 호출한다.
  • 기대와 같은 결과가 도출되는지 확인한다.
    • 기대하는 결과는 정상 리턴이다.

두 테스트 환경의 차이점을 깨닫다

1번 기능을 이용하여 구성한 정상 컨텍스트에서는 정상 리턴을 확인해야 했지만, 이상하게 UUID값이 없다며 테스트에 통과하지 못하는 모습을 보여주었다.

이상하지만 당황하지 않고, 침착하게 원인을 파악하기 위해 1번과 2번 기능의 Test 코드를 다시 전체적으로 확인 해보았다.

역시 컴퓨터는 거짓말을 하지 않는다. 내가 구성한 1번과 2번의 테스트 환경에는 한 가지 차이점이 존재하고 있었다.

  • 1번 기능의 Test Case는 H2 인메모리 데이터베이스에 의존한다.
  • 2번 기능의 Test Case는 개발 데이터베이스에 의존한다.

나는 이 차이점이 원인이라 추측하고, 테스트 환경을 한쪽으로 동일하게 구성하고 몇가지 검증을 추가로 진행하면서 좀 더 확신을 갖게 되었다.

문제의 원인은 컬럼 속성의 길이?

앞에서 얻은 정보를 기반으로 자료조사를 해보니 이런 글을 확인할 수 있었고, 중간에 한 가지 문구가 눈에 들어왔다.

A single UUID needs 16 bytes.

내가 작성한 엔티티의 테이블 스키마를 확인해 보니, UUID를 저장하는 컬럼 속성이 BINARY(255)로 설정된 것을 확인할 수 있었다.

그래서, 일단 UUID 컬럼 속성의 길이를 16으로 변경해보기로 했다.

문제를 일단 해결한 방법: BINARY(16)

수정 전

@Entity
class Example {
  @Id
  @GeneratedValue(generator = "uuid2")
  @GenericGenerator(name = "uuid2", strategy = "uuid2")
  private UUID id;

  // ...
}

수정 후

@Entity
class Example {
  @Id
  @GeneratedValue(generator = "uuid2")
  @GenericGenerator(name = "uuid2", strategy = "uuid2")
  @Column(columnDefinition = "BINARY(16)")
  private UUID id;

  // ...
}

수정 후 2번 로직을 테스트해보니 예상하던 결과가 나오는 것을 확인할 수 있었다.
1번 로직을 구현하고 테스트하는 시점에 이 문제를 알았다면 더 좋았겠다는 생각이 든다.
아무튼 binary 타입을 처리하는 DBMS의 차이(H2와 MySQL)인 걸까? 라고 추측하고 퇴근했다.

원인을 분석해 보자

왜 조회를 못 했을까: UUID와 RPAD

해결은 했지만, 그냥 넘어가기엔 찝찝하다.

그래서 자료조사를 조금 더 해보았고, MySQL 오피셜 문서에서 원인을 찾아낼 수 있었다.

image

When BINARY values are stored, they are right-padded with the pad value to the specified length.

요약하면 저장할 때 남는 길이는 오른쪽으로 패딩 처리하여 저장한다고 명시하고 있다.
아! 이래서 조회를 할 수 없었구나?

다음은 이 문제를 더 정확하게 파악하기 위해 조사한 공식적인 자료들이다.

직접 검증해보자

  1. 검증을 위해, 먼저 아래와 같은 테이블을 생성한다.
    create table temp (
      id BINARY(255) PRIMARY KEY
    )
    
  2. 테스트용 데이터를 생성.
    select uuid();
    

    8f886d50-70ff-11ea-b498-02dd0a2dce82라는 UUID를 생성했다.

  3. UUID의 길이를 확인한다.
    UUID의 길이는 16바이트의 길이를 요구한다고 하였다. 눈으로 직접 확인해보자.
    select length(unhex(replace('8f886d50-70ff-11ea-b498-02dd0a2dce82','-','')))
    

    image

    약속대로 16이라는 길이가 나온다.

  4. 데이터를 저장해보자.
    MySQL에서 안내하는 대로 -를 공백으로 치환하고 binary 타입으로 변경하여 데이터를 밀어 넣자.
    insert into temp
    values (unhex(replace('8f886d50-70ff-11ea-b498-02dd0a2dce82','-','')));
    
  5. 조회를 해보자.
    오피셜 문서에 따르면 남는 공간은 패딩처리 한다고 했다.
    그러니까 나는 16바이트의 길이를 가진 데이터를 저장했지만, 실제로는 255 길이를 소유한 데이터가 있어야 한다.

    그걸 눈으로 확인하기 위해 아래와 같은 쿼리를 날렸다.
     select length(id), hex(id)
       from temp;
    

    image

    레퍼런스에서 설명한 대로 우측에 패딩값이 들어간 것을 확인할 수 있다.
    미리 생성해둔 UUID 로 조회를 시도해보자.

    내가 경험한 이슈를 흉내 낸 것이다.

     select *
       from temp
     where id = unhex(replace('8f886d50-70ff-11ea-b498-02dd0a2dce82','-',''));
    

    조회가 안 된다.
    패딩값 때문에 당연한 결과일 것이다.
    그렇다면 조회조건에 패딩값을 포함하면 조회가 되겠지?

     select hex(SUBSTR(id, 1, 16)) AS origin_uuid
       from temp
     where id = rpad(unhex(replace('8f886d50-70ff-11ea-b498-02dd0a2dce82','-','')), 255, '\0');
    

    예상대로 조회가 되는 것을 확인할 수 있다.
    image

후기

이 글은 내가 업무 중에 우연히 던져진 떡밥 덕분에 재밌는 경험을 했다는 사실을 잊지 않고 간직하고 싶어서 의식의 흐름에 따라 개인 블로그에 작성했던 글이다.

사실 회사 블로그에 공유를 할까, 말까 굉장히 많은 고민을 했다. 너무 날것의 글이고, 누군가에게는 대수롭지 않은 내용일 수도 있겠다는 생각이 들어서 겁이 났었기 때문에다. 그런데도 이 글을 공개적인 곳에 공개하게 된 계기는 동료인 이종립 님의 많은 격려와 도움이 있었기에 가능했다고 생각한다.