본문으로 건너뛰기

Hibernate NativeQuery HHH-14778 issue (with Postgres)

· 약 7분

Postgres 환경에서 Hibernate NativeQuery를 사용할 때 생기는 버그를 디버깅해본 나름의 결과(?)를 공유해봅니다.

버그 재현

우선, 다음과 같은 테이블이 존재한다고 가정해보자.

create table message (
id int8 generated by default as identity,
body varchar(255),
count int8,
primary key (id)
)

그리고 다음과 같은 hibernate 네이티브 쿼리를 실행하면 테스트는 실패합니다. 이 버그는 Postgres에서만 발생하는데요. 보다 자세한 내용을 아래서 설명하도록 하겠습니다.

class ApplicationTests {

@Autowired
private EntityManager entityManager;

@Test
void nativeQuery() {
assertThatThrownBy(() -> {
final Long id = 1L;
final Query query =
entityManager.createNativeQuery("UPDATE message SET count = :count WHERE id = :id")
.setParameter("count", null)
.setParameter("id", id);
query.executeUpdate();
})
.hasCauseInstanceOf(SQLGrammarException.class)
.hasRootCauseMessage(
"ERROR: column \"count\" is of type bigint but expression is of type bytea\n" +
" Hint: You will need to rewrite or cast the expression.\n" +
" Position: 28");
}
}

위 버그는 하이버네이트 이슈로 몇 달전에 리포팅되어 있으니 참고바랍니다. (참고: https://hibernate.atlassian.net/browse/HHH-14778)

위 에러는 query.executeUpdate() 시점에 발생합니다. 어디가 어떻게 문제일까요? 🤔

분석

ERROR: column \"count\" is of type bigint but expression is of type bytea

메시지를 보면 bytea으로 bigint를 표현할 수 없다(?)고 합니다. 결국 하이버네이트에서 count 파라미터를 bytea으로 인식하여 쿼리를 실행한다고 볼 수 있습니다. (PrepareStatement를 만들게 되는 것이지요.)

QueryParameterBindingsImpl

그렇게 하이버네이트를 분석해보면서 다음과 같은 코드를 찾았습니다.

Alt QueryParameterBindingsImpl

쿼리를 분석하고 적절한 타입을 바인딩하기 위해 추론하고 적절한 바인더를 만들기 위해 bindType을 정의하는데요. 결국 적절한 bindType을 찾지 못할 경우, SerializableType.INSTANCE로 초기화하게 됩니다. SerializableType.INSTANCE은 아래와 같은 타입을 갖게됩니다.

  • java type: Serializable
  • sql type: Types.VARBINARY (bytea)
VarbinaryTypeDescriptor (BasicBinder)

실제 쿼리에 파라미터들을 바인딩하기 위해 PrepareStatement에 다음과 같이 셋팅을 합니다. 인자로 넘어가는 sqlDescriptorVarbinaryTypeDescriptor인 것을 알 수 있습니다.

Alt VarbinaryTypeDescriptor

PgPrepareStatement

PgPrepareStatement는 전달받은 sqlType으로 케이스별로 파라미터에 반영합니다. 이러한 과정때문에 테스트가 깨진 것인데요.

public void setNull(int parameterIndex, int sqlType) throws SQLException {

int oid;
switch (sqlType) {
case Types.SQLXML:
oid = Oid.XML;
break;
case Types.INTEGER:
oid = Oid.INT4;
break;

// ...

그렇다면, mysql에서는 어떻게 처리하길래 문제가 되지 않을까요?

@Override
public void setNull(int parameterIndex, int sqlType) throws SQLException {
synchronized (checkClosed().getConnectionMutex()) {
((PreparedQuery<?>) this.query).getQueryBindings().setNull(getCoreParameterIndex(parameterIndex)); // MySQL ignores sqlType
}
}

mysql의 ClientPreparedStatement를 살펴보면 다음과 같이 sqlType을 무시하는 것을 알 수 있었습니다. 🤔

참고로 ServerPreparedStatementClientPreparedStatement를 상속받았기 때문에 동일한 동작을 합니다.

분석 정리

결국, 하이버네이트에서 각 드라이버의 호환성 문제로 인한 버그라고 볼 수 있을 것 같습니다. (개인적으로는 postgres가 더 정교한 것 같습니다만...) 그런데 한편으로는 HQL같은 엔티티로 쿼리를 작성하면 타입 추론이 가능하지만, 네이티브 쿼리는 어떻게 가능할까 라는 생각이 들기도 합니다.

이렇더라도 우리는 버그를 퇴치(?)를 해야하니 해결방법에 대해 알아보도록 하겠습니다.

해결방법

1. NamedQuery로 변경

우리는 다음과 같이 NamedQuery로 변경하여, 이를 해결할 수 있습니다.

@NamedQuery(
name = "fixedCount",
query = "UPDATE Message m SET m.count = :count WHERE m.id = :id"
)
public class Message { ...
final Long id = 1L;
final Query query = entityManager.createNamedQuery("fixedCount") // <--
.setParameter("count", null)
.setParameter("id", id);
query.executeUpdate();
2. 네이티브 쿼리를 사용하되, 파라미터를 TypedParameterValue를 사용하여 타입 추론이 가능하도록 변경

setParameter를 살펴보다가 TypedParameterValue를 사용하면 타입을 추론이 가능하다는 것을 알게되었습니다. (AbstractProducedQuery)

final Long id = 1L;
final Query query =
entityManager.createNativeQuery("UPDATE message SET count = :count WHERE id = :id")
.setParameter("count", new TypedParameterValue(LongType.INSTANCE, null)) // <--
.setParameter("id", id);
query.executeUpdate();
3. 네이티브 쿼리를 사용하되, 쿼리에서 강제 캐스팅을 한다.

이미 우리는 bytea로 바인딩되다는 것을 알고 있습니다. 이를 인지하고 있다면 cast()함수를 통해 해결할 수 있습니다. 하지만, 이는 버그가 수정되거나 하이버네이트 내부 로직에 의존하는 것이기 때문에 좋은 방법은 아닌 듯 합니다.

final Long id = 1L;
final Query query =
entityManager.createNativeQuery("UPDATE message SET count = cast(cast(:count as text) as bigint) WHERE id = :id")
.setParameter("count", new TypedParameterValue(LongType.INSTANCE, null))
.setParameter("id", id);
query.executeUpdate();
4. PrepareStatement에 직접 액세스하기

하이버네이트를 사용하는 경우, EntityManager에서 Session을 꺼내어 Connection을 직접 핸들링할 수 있습니다.

final Session session = entityManager.unwrap(Session.class);
session.doWork((connection) -> ...);

그러므로 우리는 PrepareStatement에 직접 타입을 기입할 수 있습니다.

session.doWork(connection -> {
try (final PreparedStatement ps = connection.prepareStatement("UPDATE message SET count = ? WHERE id = ?")) {
ps.setNull(1, Types.INTEGER);
ps.setLong(2, id);
ps.executeUpdate();
}
});

해당 코드들은 hibernate_postgres_HHH-14778에서 확인할 수 있습니다.

참고