아무튼, 쓰기
[디프만, 밥토리] Hibernate 벡터, "묻고 double[]로 가!" 가 아니라.. float[]로 가! 본문
[개요]
디프만 프로젝트인 혼밥 식당 추천 서비스 밥토리에서 PostgreSQL의 pgvector 확장을 사용해 유사 상점 추천 기능을 구현했습니다.
초기 프로토타이핑은 네이티브 쿼리(@Query(..., nativeQuery = true))로 진행했고, 엔티티 필드를 double[]로 사용했음에도 쿼리는 잘 작동했습니다.
문제는 이 쿼리를 컴파일 시점의 안정성을 확보하기 위해 QueryDSL로 리팩토링하는 순간 발생했습니다. 잘 되던 double[] 타입이 갑자기 FunctionArgumentException을 일으켰습니다.
이 글은 이 FunctionArgumentException의 원인을 추적하고, 네이티브 쿼리와 HQL(QueryDSL)의 동작 방식 차이를 이해하며 문제를 해결한 과정을 담은 트러블 슈팅 기록입니다.

[요약]
문제 현상
- double[] 엔티티 필드 + 네이티브 쿼리 = 성공
- double[] 엔티티 필드 + QueryDSL/HQL = 실패 (FunctionArgumentException: ... requires a vector type, but argument is of type 'double[]')
핵심 원인
- 네이티브 쿼리 (성공 이유): Hibernate가 SQL을 파싱하거나 검증하지 않습니다. double[] 파라미터를 그대로 JDBC 드라이버에 전달하고, PostgreSQL이 double precision[] → vector로 암묵적 형변환(손실 발생)하여 쿼리가 성공합니다.
- QueryDSL/HQL (실패 이유): Hibernate가 HQL을 파싱하고 검증(VectorArgumentValidator)합니다. 이 검증기는 pgvector 표준에 따라 vector를 float[] 기준으로 구현했기 때문에, double[] 타입은 알 수 없는 벡터 타입으로 간주하여 예외를 발생시킵니다.
해결 방법
- 네이티브 쿼리의 성공에 속지 말고, Hibernate의 표준 구현인 float[]로 필드 타입을 변경합니다.
[해결 과정]
1. 문제의 초기 코드
처음에는 임베딩 벡터가 고정밀도일 필요는 없다고 생각했지만, float[] 말고 double[]로 선언하면, 후에 타입 변경 없이 더 폭 넓은 vector 값을 담을 수 있어 double[]을 사용했습니다.
@Entity
public class StoreEmbedding {
// ...
@Column
@JdbcTypeCode(SqlTypes.VECTOR)
@Array(length = 1024) // 1024차원
private double[] embedding; // 👈 문제의 시작
}
// Q-class는 ArrayPath<double[], Double>로 정상 생성됨
QStoreEmbedding se = new QStoreEmbedding("se");
QStoreEmbedding targetSe = new QStoreEmbedding("targetSe");
// "의미적 거리" (0에 가까울수록 유사함)
NumberExpression<Float> semanticDistance = Expressions.numberTemplate(
Double.class,
"cosine_distance({0}, {1})",
se.embedding, // 👈 double[] 타입
targetSe.embedding // 👈 double[] 타입
);
// ...
.orderBy(semanticDistance.asc()) // 👈 여기서 예외 발생
// ...
에러 발생: FunctionArgumentException
쿼리를 실행하자마자 에러 메시지가 출력되었습니다.
...requires a vector type, but argument is of type 'double[]'
cosine_distance라는 함수는 알지만, 인자로 double[]이 들어오는 것을 허용하지 않는다는 의미였습니다. 아니,, float[]이랑 double[] 지원한다며,,,
[가설 1: 버전 문제인가? (기각)]
네이티브 지원이 제대로 안 되나?라는 생각에 스택을 점검했습니다.
- Spring Boot: 3.3.0 (확인)
- Hibernate: 6.5.2 (확인)
- Hibernate-Vector: 6.5.2(확인)
- PostgreSQL JDBC: 42.7.3 (vector 지원을 위해 42.7.0 이상 필요)
스택 자체는 하이버네이트 vector 지원을 위한 조합이 갖춰져 있었습니다. 버전 문제는 아니었습니다.
[가설 2: Q-Class가 벡터가 아닌 배열을 반환해서? (기각)]
네이티브 쿼리는 단순히 SQL 문자열이지만, QueryDSL은 엔티티의 메타모델(Q-Class)을 기반으로 쿼리를 생성합니다. 그렇다면 Q-Class 생성 단계에서 문제가 발생했을 수도 있겠다는 가설을 세웠습니다.
에러 메시지 ... requires a vector type, but argument is of type 'double[]를 보고 가장 먼저 든 생각은 QueryDSL Q-Class 생성 문제였습니다.
엔티티에 double[]을 선언하니, QueryDSL은 ArrayPath<double[], Double>라는 Q-Class를 생성했습니다. 혹시 Hibernate는 VectorPath 같은 특별한 타입을 기대하는데, QueryDSL이 이를 벡터로 인식하지 못하고 단순 배열(ArrayPath)로 반환해서 문제가 생긴 것 아닐까?
즉, double[]을 감싸는 모종의 벡터 래퍼 객체가 필요하고, Q-Class가 그 래퍼 객체 타입으로 생성되어야 한다고 추측했습니다.
하지만 이 가설은 접었습니다.
@JdbcTypeCode(SqlTypes.VECTOR)는 Hibernate 6에서 도입된 런타임 JDBC 타입 힌트로, Hibernate가 실제로 SQL을 실행할 때 어떤 JDBC 타입으로 바인딩할지 결정하는 데 사용됩니다.
하지만 QueryDSL의 Q-Class 생성은 컴파일 타임에 일어나며, 이 시점에는:
- @JdbcTypeCode 애노테이션이 붙어 있어도, annotation processor는 이를 읽지 않습니다.
- 단순히 Java 필드 타입(double[])만 보고 ArrayPath<double[], Double>를 생성합니다.
- Hibernate가 런타임에 SqlTypes.VECTOR로 변환한다는 정보는 Q-Class 생성에 반영되지 않습니다.
왜 "벡터 타입"을 생성하지 않나?
QueryDSL은 Hibernate의 커스텀 타입 메타정보(@JdbcTypeCode, UserType 등)를 인식할 수 없기 때문입니다. QueryDSL은 JPA 표준만 따르는 범용 프레임워크입니다. Hibernate 6의 @JdbcTypeCode는 Hibernate 특화 기능이며, JPA 표준이 아닙니다.
따라서 QueryDSL은 double[]을 그대로 ArrayPath로 생성하고, 이것이 PostgreSQL의 vector 타입으로 매핑된다는 사실을 알 수 없습니다.
문제는 Q-Class 생성이 아니라, Q-Class가 전달한 double[] 타입을 Hibernate가 거부하고 있다는 것이었습니다.
[✅ 가설 3: 네이티브 쿼리와 QueryDSL의 동작 방식 차이]
가설 2가 기각되니 새로운 의문이 생겼습니다.
double[] 타입이 Q-Class에 정상적으로 반영되고 있는데, 왜 네이티브 쿼리에서는 작동하고 QueryDSL에서는 작동하지 않을까?
동일한 double[] 필드를 사용하는데 쿼리 방식에 따라 결과가 다르니, 둘의 동작 방식을 살펴볼 필요가 있었습니다.
QueryDSL (HQL) 플로우
QueryDSL 코드(개발자) → 컴파일 타임 타입/문법 검증(Java 컴파일러 + Q-Class) → JPQL/HQL 문자열 생성 + 쿼리 구조 검증(QueryDSL 라이브러리) → HQL 파싱/검증/SQL 번역(Hibernate)→ SQL 실행 요청(Hibernate) → DB 통신(JDBC) → SQL 실행 -> (DB) 결과 반환(JDBC) → 객체 매핑(Hibernate)
네이티브 쿼리 (Native SQL) 플로우
Native SQL 문자열(개발자) → [HQL 파싱/검증/SQL 번역 SKIP]→ SQL 실행 요청(Hibernate) → DB 통신(JDBC) → SQL 실행(DB) → 결과 반환(JDBC) → 객체 매핑(Hibernate)
가설2에서 QueryDSL 쪽을 살펴봤으니 하이버네이트 HQL 파싱 로직을 살펴보겠습니다.
Hibernate의 VectorArgumentValidator 클래스를 확인해보니, HQL 함수(cosine_distance, euclidean_distance 등)의 인자로 float[]만 허용하도록 구현되어 있었습니다.
왜 float[]만 허용할까요?
pgvector 깃허브와 스택오버플로우를 뒤져가며 조사해봤습니다.
pgvector의 vector 타입은 내부적으로 32비트 float(single-precision)만 지원합니다. 64비트 double vector 타입은 아직 추가되지 않았습니다. double[]로 벡터 연산 시 손실이 발생합니다.
Hibernate 개발팀은 pgvector 표준을 따라, vector 타입의 네이티브 지원 및 HQL 함수를 float[]을 기준으로 구현했습니다.
+) OpenAI 등 대부분의 임베딩 모델은 32비트 float을 표준으로 사용합니다.(Oracle 23AI만 double 지원)
[Native Query vs HQL/QueryDSL: 동작 방식]
Native Query (성공)
- Hibernate가 SQL을 파싱하거나 검증하지 않습니다. (nativeQuery = true)
- VectorArgumentValidator가 실행되지 않습니다.
- double[] 파라미터를 그대로 JDBC 드라이버에 전달합니다.
- PostgreSQL JDBC 드라이버는 이를 double precision[] (PostgreSQL의 double 배열 타입)으로 DB에 전송합니다.
- PostgreSQL 서버가 cosine_distance(double precision[], double precision[])를 만나면, cosine_distance 함수가 vector 타입을 요구하므로 double precision[] → vector로 암묵적 형변환을 시도합니다.(손실 발생)
- 형변환이 성공하고 쿼리가 정상 실행됩니다.
String sql = """
SELECT s.*
FROM store s
INNER JOIN store_embedding se ON s.id = se.store_id
INNER JOIN store_embedding target_se ON target_se.store_id = :storeId
WHERE s.id IN (:candidateIds)
AND se.embedding IS NOT NULL
AND target_se.embedding IS NOT NULL
AND se.embedding_status = :embeddingStatus
AND target_se.embedding_status = :embeddingStatus
ORDER BY se.embedding <=> target_se.embedding
LIMIT :limit
""";
HQL/QueryDSL (실패)
- Hibernate가 HQL을 파싱하고 VectorArgumentValidator로 함수 인자를 검증합니다.
- 이 검증기는 HQL 함수 인자로 float[]만 허용하도록 등록되어 있습니다. ← double[]로 벡터 변환 시 손실 발생 방지
- double[]이 들어오자, "등록되지 않은 벡터 타입"으로 간주하고 FunctionArgumentException을 발생시킵니다.
최종 수정 코드
1. 엔티티 수정 (double[] -> float[])
@Entity
public class StoreEmbedding {
// ...
@Column(columnDefinition = "vector(1024)")
private float[] embedding; // 👈 float[]로 변경
}
[결과]
엔티티 필드를 double[]에서 float[]로 변경하고 관련 코드를 수정한 뒤, QueryDSL 쿼리가 정상적으로 동작하는 것을 확인했습니다.
이번 트러블 슈팅의 가장 큰 성과는, cosine_distance 같은 HQL 함수 호출이 막혔을 때, 포기하지 않고 네이티브 쿼리로 우회하지 않았다는 점입니다. 만약 네이티브 쿼리를 사용했다면, double[]을 벡터를 계속 사용했을 것이고, 후에 손실이 발생하는 추천 결과를 반환했을 수도 있었을 겁니다.
[배운점]
네이티브 쿼리는 실행을 JDBC 드라이버와 DB에게 바로 위임하지만, QueryDSL(HQL)은 Hibernate라는 추상화 계층을 통과하며 검증 단계를 한 번 더 거친다는 차이를 알게되었습니다.
이번 기회에 그동안 트러블 슈팅을 해결하는 과정에서 기술의 동작방식을 명확히 규정하지 않고, 눈에 보이는 차이점부터 주먹구구식으로 가설을 세웠구나라는 생각이 들었습니다 . 처음부터 두 방식의 동작 원리를 명확히 구분했다면, Hibernate 예외를 QueryDSL 예외로 오해하는 일 없이 더 빨리 근본 원인을 찾았을 것입니다.
이번 경험을 바탕으로, 앞으로는 문제가 발생했을 때 눈앞의 현상에 매몰되지 않고, 각 기술 스택의 플로우에 기반한 체계적인 가설을 세우고 검증하는 개발자로 성장하겠습니다!
참고
https://docs.spring.io/spring-data/jpa/reference/4.0/repositories/vector-search.html
Vector Search :: Spring Data JPA
With the rise of Generative AI, Vector databases have gained strong traction in the world of databases. These databases enable efficient storage and querying of high-dimensional vectors, making them well-suited for tasks such as semantic search, recommenda
docs.spring.io
How to use criteria Api to build query with vector operation such as euclidean distance
Hi everyone, I am posting my issue here but please let me know if this is not the appropriate place to do so. I am trying to write a criteria query that order the results based on a vector distance. My entity field holding the vector (an embedding coming f
discourse.hibernate.org
https://docs.hibernate.org/orm/7.1/userguide/html_single/#vector-module
Hibernate ORM User Guide
Starting in 6.0, Hibernate allows to configure the default semantics of List without @OrderColumn via the hibernate.mapping.default_list_semantics setting. To switch to the more natural LIST semantics with an implicit order-column, set the setting to LIST.
docs.hibernate.org
https://github.com/pgvector/pgvector/tree/master/src
pgvector/src at master · pgvector/pgvector
Open-source vector similarity search for Postgres. Contribute to pgvector/pgvector development by creating an account on GitHub.
github.com
hibernate-orm/hibernate-vector/src/main/java/org/hibernate/vector/PGVectorTypeContributor.java at 7.1 · hibernate/hibernate-orm
Idiomatic persistence for Java and relational databases - hibernate/hibernate-orm
github.com
'스프링' 카테고리의 다른 글
| 실시간 랭킹 MySQL로 버티다가, 결국 Redis ZSET으로 갈아탄 이야기 (0) | 2025.11.28 |
|---|---|
| 왜 내 readOnly는 슬레이브로 가지 않는가? (0) | 2025.11.12 |
| 100명 동시 요청 25분 → 1분, Gemini API 병목 96% 개선기 (feat. Virtual Thread의 함정) (0) | 2025.11.06 |
| @Transactional에서 try-catch를 썼는데 500 에러? (0) | 2025.11.06 |
| Spring 이벤트, 혹시 이렇게 쓰고 계신가요? (Fat Event 피하기) (0) | 2025.10.23 |