Hibernate ORM 6.6 변경 사항

1. Oracle 암시적 배열 타입 이름 개선
주로 사용되는 상황
Oracle DB에서 Java 배열 타입 (
Array<T>)을 컬럼 타입으로 사용할 때예를 들어 Oracle의 사용자 정의 배열 타입을 사용하는 경우
ex) NUMBER[], VARCHAR2[], STRUCT[] 같은 Oracle의 Collection 타입
예시
CREATE OR REPLACE TYPE string_array AS TABLE OF VARCHAR2(100);
@Entity
@Table(name = "my_entity")
class MyEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "tags")
var tags: Array<String>? = null
)
이 때 Hibernate가 Oracle에 tags 컬럼을 매핑하려고 할 때, 내부적으로 StringArray 라는 이름의 Oracle 배열 타입이 필요함.
이때 이름 충돌과 명확성 문제를 해결하기 위해 Hibernate 6.6 부터 StringArray → StringVarcharArray OR StringArrayUsingMyConverter 처럼 더 명확한 이름을 자동으로 생성한다.
DB를 Array 타입으로 쓰는 것 VS Join Table VS Join 수동의 장단점
주로 MySQL을 쓰다보니 컬럼 자체에 Array를 사용하는 경우가 없어서 궁금해서 각 항목의 장단점에 대해서 찾아보았다.
일반적으로 Join을 많이 쓰나, 우리 회사 / 팀에서는 Join을 직접 사용하지 않고 수동으로 Join을 사용하는 편이라 해당 부분도 함께 비교해보았다.
- PersonArray / Person + Phone이 존재하는 경우 예시
| 항목 | ① 배열 컬럼 방식 (Oracle ARRAY) | ② 정규화 방식 (Join 사용) @OneToMany | ③ 정규화 방식 (Join 없이 수동 조회) |
| DB 구조 | PersonArray 하나의 테이블에 배열 컬럼 사용 | 두 개의 테이블 + 외래 키 | 두 개의 테이블 + 외래 키 |
| 쿼리 복잡도 | 단일 쿼리로 조회 가능 | Hibernate가 내부적으로 JOIN 수행 | 두 개의 명시적 쿼리 필요 (person, phone) |
| 읽기 성능 | ✔ 빠름 (전체 배열 읽기) | ❌ JOIN 비용 있음 (N+1 가능성 있음) | ✔ 효율적 (JOIN 최소화) |
| 쓰기 복잡도 | ❌ 전체 배열 갱신 필요 | ✔ 개별 엔티티 삽입/삭제 가능 | ❌ 로직을 수동으로 구현해야 함 |
| 검색 유연성 | ❌ 배열 내부 조건 검색 어려움 | ✔ phone.number 로 필터링 가능 | ✔ 명시적 조건 쿼리 작성 가능 |
| ORM 매핑 난이도 | ✔ 매우 단순 (필드만 선언) | ❌ 양방향 연관 설정 등 복잡 | ✔ 매핑은 단순, 조회 로직은 수동 |
| 엔티티 일관성 보장 | ❌ 약함 (영속성 전이 없음) | ✔ 전이, 고아 객체 제거 등 가능 | ❌ 일관성 수동 보장 필요 |
| 유지보수성 | 중간 (단순하지만 확장성 낮음) | 높음 (표현력, 유연성 우수) | 낮음 (직접 쿼리 관리 필요) |
| 메타데이터 확장 용이성 | ❌ 배열은 추가 필드 넣기 어려움 | ✔ 확장 용이 (예: 타입, 등록일 등) | ✔ 확장 용이 |
| 추천 사용 시점 | 단순 저장/조회, 값 수 적고 전체만 다룰 때 | 관계가 명확하고 확장이 필요한 경우 | JOIN 최소화 + 수동 튜닝이 필요한 경우 |
2. UserDefinedType 구조 변경
기존 문제점
Oracle UDT(ex. STRUCT, VARRAY, NESTED TABLE)을 매핑할 때 구조적 차이를 명확히 반영하지 못함.
ex) STRUCT는 필드 이름/타입을 가지고 있지만 ARRAY는 그렇지 않음
하지만 Hibernate 내부에서 모두 동일한 UserDefinedType 인터페이스로 처리됨
개선 목적
STRUCT, ARRAY를 내부적으로 명확히 구분
사용자 정의 타입 간 종속성도 표현할 수 있게 하기 위함.
(ex. STRUCT의 필드가 ARRAY인 경우)
AS-IS (Hibernate 6.5 이하)
interface UserDefinedType<T> {
fun getUserTypeName(): String
fun getJdbcType(): JdbcType
...
}
TO-BE (Hibernate 6.6)
interface UserDefinedType<T> {
fun getUserTypeName(): String
...
}
interface UserDefinedObjectType<T> : UserDefinedType<T> {
fun getAttributes(): List<...> // STRUCT 전용
}
interface UserDefinedArrayType<T> : UserDefinedType<T> {
fun getElementType(): JdbcType
}
즉, 상위 타입(UserDefinedType)은 공통 인터페이스, 하위에 ObjectType (STRUCT), ArrayType(VARRAY 등)이 명확하게 분리 됨.
어떤 상황에 사용할까?
- 고급 Oracle UDT 매핑시 사용 됨
-- 1. Oracle STRUCT 타입 예시
CREATE TYPE address_type AS OBJECT (
city VARCHAR2(100),
zipcode VARCHAR2(10)
);
-- 2. Oracle ARRAY of STRUCT
CREATE TYPE address_array AS TABLE OF address_type;
- Kotlin 매핑 예시
// Address 구조체
data class Address(
val city: String,
val zipcode: String
)
// 주소 목록 (Oracle ARRAY of STRUCT)
@Entity
class Customer(
@Id @GeneratedValue
val id: Long? = null,
@JdbcTypeCode(SqlTypes.ARRAY)
@Column(name = "addresses")
var addresses: Array<Address>? = null
)
Hibernate는 내부적으로 UserDefinedArrayType<Address> → UserDefinedObjectType<Address> 구조로 인식한다.
UserDefinedObjectType<T>
Oracle의 STRUCT 타입을 의미
ex) address_type(city VARCHAR2, zipcode VARCHAR2)
단일 구조체 하나.
data class Address(…)와 대응된다.
UserDefinedArrayType<T>
Oracle의 TABLE OF STRUCT, 즉 STRUCT의 배열을 의미함.
ex) adress_array AS TABLE OF address_type
여러 개의 구조체.
Array<Address>와 대응 된다.
Hibernate가 이해하는 내부 모델
UserDefinedArrayType<Address> {
elementType = UserDefinedObjectType<Address> {
attributes = ["city" → String, "zipcode" → String]
}
}
- 이 배열은 Address라는 구조체의 배열이고, 그 Address라는 구조체는 두 개의 필드를 가지고 있고 그건 각각 VARCHAR2 라는 것을 인식하고 매핑 정보를 구성한다.
이 구조의 장점
자동 SQL 생성 → Hibernate가 적절한 Oracle SQL 타입과 DDL을 정확히 생성함
중첩 타입 지원 → STRUCT 안에 또 다른 ARRAY가 있을 경우도 모델링 가능
명확한 타입 의존성 추적 → 어떤 배열이 어떤 구조체를 쓰는지 Hibernate가 구조적으로 추적
다형성 및 타입 안정성 강화 → 메타정보를 기반으로 런타임에 타입 충돌 방지 가능
매핑 예시
data class Address(val city: String, val zipcode: String)
@Entity
class Customer(
...
@JdbcTypeCode(SqlTypes.ARRAY)
var addresses: Array<Address>? = null
)
- addresses 필드는 내부적으로, UserDefinedArrayType<Address>로 인식되고, 그 안에 들어가는 Address는 UserDefinedObjectType<Address>로 인식된다.
그렇다면 Oracle UDT에 대한 DDL 자동 생성이 가능할까?
Hibernate 6.6에서도 Oracle의 UDT(STRUCT, VARRAY, NESTED TABLE)에 대한 DDL은 여전히 자동 생성되지 않는다. (ex hibernate.hbm2ddl.auto=create 설정 등)
- 하지만 Hibernate 6.6에서는 내부 구조 인식 및 타입 이름 생성이 더 정교해져서 매핑과 바인딩은 더 잘 동작한다.
Hibernate가 자동으로 생성하는 것 VS 안하는 것
| 항목 | 자동 생성 여부 | 설명 |
Oracle 테이블 (CREATE TABLE) | ✅ 가능 | 엔티티에 기반한 테이블 및 컬럼 생성 가능 |
Oracle STRUCT 타입 (CREATE TYPE ... AS OBJECT) | ❌ 불가능 | Oracle 전용 문법, Hibernate가 생성하지 않음 |
Oracle 배열 타입 (CREATE TYPE ... AS TABLE OF) | ❌ 불가능 | 배열 타입도 수동 생성 필요 |
| Hibernate 매핑 메타모델 | ✅ 가능 | STRUCT/ARRAY 타입의 매핑 메타정보 생성 |
| Oracle 배열 타입 이름 추론 | ✅ 가능 | Hibernate 6.6부터 더 정밀하고 충돌 방지됨 |
| Hibernate insert/update/delete SQL | ✅ 가능 | 생성된 UDT 타입을 참조하여 SQL 실행 가능 |
| Hibernate schema validator | ✅ 가능 | 수동 생성된 UDT와 매핑이 일치하는지 검증 가능 (validate) |
Hibernate가 CREATE TYPE을 지원하지 않는 이유
JPA 사양에 없음
@Entity,@Embeddable등은 테이블과 컬럼을 정의하지, Oracle의 TYPE을 정의하는 사양이 존재하지 않음
DBMS에 종속적인 문법
- CREATE TYPE은 각 DB마다 문법이 전혀 다름
Hibernate의 스키마 생성기는 ANSI SQL 기반
- Oracle 전용 UDT는 HIbernate가 인식은 하지만 직접 생성하지는 않음
Hibernate와 Oracle UDT를 함께 사용하려면 어떻게 해야할까?
- Oracle UDT 수동 생성
CREATE OR REPLACE TYPE address_type AS OBJECT (
city VARCHAR2(100),
zipcode VARCHAR2(10)
);
CREATE OR REPLACE TYPE address_array AS TABLE OF address_type;
- Hibernate는 해당 타입의 이름만 참조
@Column(columnDefinition = "address_array")
@JdbcTypeCode(SqlTypes.ARRAY)
var addresses: Array<Address>? = null
Hibernate 6.6의 역할
STRUCT/ARRAY를 구분하여 타입 메타정보 추적
UDT의 의존성 관계까지 추적 가능 (예. STRUCT 안에 ARRAY가 있을 경우)
자동 타입 이름 생성이 더 명확하고 충돌 없는 방식응로 개선됨
3. 배열 부분 포함 여부 검사 함수 변경
Hibernate 6.5 이하 버전의 문제점
where array_contains(tags, ARRAY['urgent', 'vip'])
Hibernate 6.5 이하에서는 다음과 같은 JPQL 문을 사용이 가능한데, 이 함수는 원래 “배열에 단일 값이 포함되는가?”를 의도한 것
배열 대 배열 비교에 사용되는 것은 비표준 사용
Hibernate 6.6에서 변경된 점
함수 방식
where array_includes(tags, ARRAY['urgent', 'vip'])
tags 배열이 [‘urgent’, ‘vip’]를 포함하는지 확인
내부적으로 PostgreSQL/Oracle의 @>, ANY, ALL 등에 매핑 됨
술어 방식
where tags INCLUDES ARRAY['urgent', 'vip']
주의사항
배열 타입 컬럼이어야 작동함 (
@JdbcTypeCode(SqlTypes.ARRAY))DB가 배열 연산을 지원해야 한다. (MySQL은 배열 타입 지원 X)
INCLUDES는 Hibernate가 만든 JPQL 술어로 JPA 표준에는 없음
4. 삭제된 Entity 병합 시 예외 처리 강화
JPA의
EntityManager.merge()사용 시 발생할 수 있는 비정상적인 insert 동작을 방지하기 위해 도입된 안정성 개선 기능Hibernate 6.6부터는
merge()호출 시 DB에 해당 행이 존재하지 않으면, 조건이 충족될 경우 OptimisticLockException 예외를 발생시킨다. (의도치 않은 INSERT 발생하지 않도록 함)
기존 문제점 (Hibernate 6.5 이하)
문제 상황
어떤 엔티티가 A 트랜잭션에서 삭제됨
B 트랜잭션에서는 해당 엔티티의 참조를 여전히 들고 있음 (detached 상태)
B 트랜잭션에서 em.merge(entity) 호출
기존 동작 (문제 O)
Hibernate는 해당 ID의 엔티티가 DB에 없으므로 새로 추가해야 한다고 판단해 INSERT 실행
실제로는 사용자가 원한건 병합(Update) 였지만, 이미 삭제된 엔티티가 다시 생성됨.
낙관적 락(Optimistic Locking)의 규칙 위반이며 데이터 무결성을 해치는 상황
Hibernate 6.6의 개선된 동작
DB에 해당 row가 없을 경우 OptimisticLockException을 던짐
예외 발생 조건
@Id가@GeneratedValue등으로 자동 생성된 값일 경우@Version필드가 nullable 래퍼 타입일 경우 (ex. Long?, Integer?)
이 두가지 중 하나의 조건이라도 만족하면 Hibernate는 이 객체가 원래 존재한 객체라고 판단할 수 있음. 따라서 DB에 없으면 삭제된 것으로 간주함.
예시
@Entity
class Product(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
var name: String,
@Version
var version: Long? = null
)
--
interface ProductRepository : JpaRepository<Product, Long>
--
@Service
class ProductService(
private val productRepository: ProductRepository
) {
@Transactional
fun simulateMergeAfterDelete() {
// 1. 저장
val saved = productRepository.save(Product(name = "Item"))
// 2. 삭제
productRepository.deleteById(saved.id!!)
// 3. 삭제된 객체를 들고 있다고 가정하고, 수정해서 다시 저장 시도
val detached = Product(id = saved.id, name = "Updated Item", version = saved.version)
// 4. 다시 저장 (이 시점에 Hibernate 내부적으로 merge 발생)
productRepository.save(detached) // Hibernate 6.6: OptimisticLockException 발생 가능
}
}
낙관적 락(Optimistic Lock)을 위반하는 이유
낙관적 락이란?
동시성 제어 기법 중 하나
“충돌이 거의 없을 것”이라고 낙관적으로 가정하고, 데이터 변경 시점에만 충돌을 감지함.
@Version필드를 통해 버전을 비교하여 충돌 여부를 판단함.
동시성 충돌의 예시
A 트랜잭션에서 Product를 조회한 후 삭제함
B 트랜잭션은 조회된 엔티티를 save()(=merge) 하려 함
이 때 DB에 이미 없는 상태일 때 Hibernate의 동작은?
Hibernate 6.6의 경우
객체를 읽을 때 version = 1이라면, 업데이트 시에도 여전이 version = 1이어야 함
만약 해당 row가 없거나 version이 다르면 → OptimisticLockException이 발생함
select version from product where id = ? -- id = 1
결과 없음 → 삭제된 것으로 간주
그리고
@Version이 nullable(Long?)이기에 “원래 존재하던 객체”로 판단 가능이후 삭제된 엔티티에 대한 병합 시도 → 예외가 발생함.
이전 Hibernate는?
merge 대상이 DB에 없으면 그냥 INSERT 진행
삭제된 줄도 모르고 새로 만들어 버리는 동작을 취함
동시성 충돌을 감지하지 않고 데이터가 새로 생기므로 낙관적 락의 철학을 어기게 됨.
예시) 실무에서 개선되는 것
이벤트 기반 시스템에서 오래된 엔티티 이벤트가 병합될 위험
관리자 화면 등에서 UI에 노출된 삭제된 데이터가 실수로 수정될 가능성
멀티 유저 환경에서 동시 삭제/수정이 충돌날 경우
데이터 무결성 보호에 큰 효과가 있음
5. 클래스 어노테이션 중복 사용 금지
한 클래스에 여러 개의 JPA 메타 어노테이션을 함께 사용하는 것이 허용되지 않음.
Hibernate 6.5 이하
@MappedSuperclass+@Entity@Embeddable+@Entity@Embeddable+@MappedSuperclass
위와 같은 어노테이션 조합이 가능했다.
각 어노테이션의 역할
@Entity→ 독립적인 테이블로 매핑되는 실제 엔티티 클래스@MappedSuperclass→ 테이블은 없지만 상속 시 필드가 하위 엔티티에 포함됨.@Embeddable→ 별도 테이블 없이, 다른 엔티티에 포함되는 값 객체 타입
섞어서 사용하는 경우, 내부적으로 매우 모호한 의미를 가짐
모호함을 제거하기 위해 Hibernate 6.6에서는 동시 사용 자체를 금지함.
Hibernate 6.6의 내부 변화
부트스트랩 시 엔티티 스캔 과정에서 클래스의 역할을 명확히 식별해야 하며,
org.hibernate.metamodel.model.domain.internal.ClassTypeDeterminer계층에서 중복 정의된 어노테이션을 검사한다.즉, 애매한 클래스가 프로젝트에 있으면 부팅 자체가 실패한다.
@MappedSuperclass VS @Emabeddable 비교
| 항목 | @MappedSuperclass | @Embeddable |
| 역할 | 엔티티가 상속받는 추상 부모 클래스 | 다른 엔티티 내부에 포함되는 값 객체 |
| 테이블 존재 여부 | ❌ 별도의 테이블 없음 | ❌ 별도의 테이블 없음 |
| 상속 여부 | 반드시 extends 또는 : 으로 상속받아야 함 | 포함(aggregation) 관계, @Embedded로 주입 |
| 데이터 포함 방식 | 자식 엔티티가 부모 필드들을 직접 포함함 | 포함된 필드 안에 값을 하나의 객체처럼 포함 |
| 상속된 필드 사용 | 자식 엔티티의 컬럼으로 직접 포함됨 | 객체 필드 안에 속성으로 포함됨 (audit.createdAt) |
| 엔티티로 인식됨? | ❌ 직접 @Entity 아님 | ❌ 직접 @Entity 아님 |
| 사용 예시 | 공통 필드를 여러 엔티티에 상속하고 싶을 때 | 필드 그룹을 구조화하고 재사용하고 싶을 때 |
| 쿼리에서의 표현 | SELECT o.createdAt FROM Order o | SELECT o.audit.createdAt FROM Order o |
실무에서 선택 기준
여러 엔티티에 공통 필드를 상속해야 함 →
@MappedSuperclass공통 필드가 하나의 개념 단위(객체)로 묶여야 함 →
@Embeddable단일 객체 안에서 서브 속성으로 표현하고 싶음 →
@EmbeddableJPA의 다형성 상속 전략과 함께 쓰고 싶음 →
@MappedSuperclass또는@Entity
6. @Embeddable 다형성 지원 (Discriminator 기반)
Hibernate 6.6부터는 값 타입(
@Embeddable)에서도 다형성을 사용할 수 있음
값 타입(Embeddable)이란?
- 식별자가 없고 생명 주기는 소유하는 엔티티에 종속되며, 여러 엔티티에서 공유 가능하고, 독립 테이블이 없는(컬럼으로 매핑됨) 타입
값 타입을 쓰는 상황
주소, 기간, 좌표, 금액, 통화 등 작은 의미 단위로 재사용 가능한 데이터 구조
공통 필드 구조화를 위해 (createdAt, updatedAt, createdBy 등)
하나의 논리적 단위로 묶어야 할 때 (ex. Money(currency, amount))
장/단점
장점
재사용성 → 여러 엔티티에서 공통 구조로 사용 가능
코드 간결성 → 중복 줄이고 유지보수 용이
의미 있는 모델링 → 필드를 객체 단위로 표현
변경 감지 → 소유 엔티티에서 값 타입 필드 변경 감지 가능 (dirty checking)
단점
식별자가 없음 → 개별로 조회/추적 불가능
관계 설정 불가 →
@ManyToOne,@OneToMany등 관계 설정 불가능변경 분리 어려움 → 값 타입 자체로 변경하려면 전체 객체를 교체해야 함.
상속/다형성 한계 → Hibernate 이전까지는 다형성 불가 (이제 지원됨)
값 타입이 변경 분리가 어려운 이유
값 타입의 특징에서 볼 수 있음
불변(immutable)하게 설계하는게 이상적임
엔티티의 필드로 포함되며, 참조가 아닌 값 자체로 비교되고 관리된다.
JPA는 값 타입을 “그 값 자체가 바뀌었는지”를 확인한다.
값 타입의 값 일부만 수정하고 싶은 경우
- 값 타입 필드 중 하나만 바꾸려고 해도, JPA는 내부적으로 전체 값이 달라졌는지 기준으로 변경 여부를 판단하기 때문에, 부분 수정이 아니라 전체 객체 교체가 되어야 한다.
예시
@Embeddable
data class Address(
val city: String,
val street: String
)
@Entity
class Customer(
@Id @GeneratedValue
val id: Long? = null,
var name: String,
@Embedded
var address: Address
)
위와 같은 상황일 때,
- 잘못된 방식 → 필드 직접 수정
val customer = customerRepository.findById(id).get()
// address는 data class라 내부 필드 직접 수정 불가
customer.address.city = "NewCity" // 컴파일 에러
- 올바른 방식 → 전체 교체
val customer = customerRepository.findById(id).get()
// 기존 address를 기반으로 새로운 객체 생성
val updatedAddress = customer.address.copy(city = "NewCity")
// 새 Address 객체로 교체
customer.address = updatedAddress
JPA는
@Embedded필드의 참조가 바뀌었음을 감지하고 전체 값 변경으로 간주하고 UPDATE를 수행한다.
필드를 직접 수정하면 더티 체킹이 안되거나 무시될 수 있다.
@DynamicUpdate등을 써도 완벽히 보장되지 않음
불변 객체(data class + copy)로 사용하는 것이 설계상 더 안정하고 JPA 철학에도 맞다.
Hibernate 6.5 이하에서의 값 타입
@Embeddable은 단일 구조로만 취급이 됨 (상속해도 하위 클래스 정보 유지 안됨)type() 또는 treat() JPQL에서 사용할 수 없음
Hibernate 6.6 부터
@Embeddable타입도 상속 구조를 지원Hibernate가 자동으로 Discriminator 컬럼을 추가 함
JPQL에서 type(), treat()으로 하위 타입 필터링 가능
예제
부모 클래스 (값 타입 상속 구조)
@Embeddable
@Inheritance(strategy = InheritanceType.SINGLE_TABLE) // Hibernate 6.6에서 자동 처리됨
abstract class Address(
open val city: String
)
하위 클래스 (다형성 구현)
@Embeddable
class KoreanAddress(
override val city: String,
val postalCode: String
) : Address(city)
--
@Embeddable
class ForeignAddress(
override val city: String,
val countryCode: String
) : Address(city)
엔티티에서 사용
@Entity
class Customer(
@Id @GeneratedValue
val id: Long? = null,
val name: String,
@Embedded
var address: Address
)
→ Hibernate는 address_type이라는 Discriminator 컬럼을 자동으로 생성하고, KoreanAddress, ForeignAddress 중 어떤 하위 타입인지 기록함.
생성되는 테이블 예시
| id | name | address_city | address_postal_code | address_country_code | address_type |
| 1 | Kim | Seoul | 12345 | NULL | KoreanAddress |
| 2 | John | London | NULL | GB | ForeignAddress |
실무 적용시
공통 구조 정의 → 공통 필드는 부모
@Embeddable클래스에 정의하위 구조 정의 → 고유 필드는 자식 클래스에 정의
조회시 유연성 → JPQL에서 하위 타입 구분 필터링 가능 (type())
쿼리 표현력 → treat() 사용 가능 → 값 타입에서도 다형성 조작 가능
쿼리 최적화 → 값 타입이지만 조건별 필터링이 가능해져 성능 향상 가능
쿼리 최적화
기존 (Hibernate 6.5 이하)
@Embeddable타입은 일반 객체처럼만 동작하고, 타입 구분이 안 됨.예를 들어 KoreanAddress, ForeignAddress를 모두 Address로 저장하면
쿼리에서는 둘을 구분할 수 없음
불필요한 전체 스캔이 발생하거나 어플리케이션 단에서 필터링을 해야 함.
Hibernate 6.6 이후
Discriminator 기반으로 쿼리 단에서 하위 타입을 정확히 필터링 가능
Hibernate가
@Embeddable타입에 대해 다형성을 지원하고 Discriminator 컬럼을 생성함따라서 DB 레벨에서 정확한 조건 필터링 가능
- address_type 컬럼에 인덱스를 걸거나 정확한 where 조건을 줄 수 있음
애플리케이션 단에서 타입 필터링 불필요
불필요한 조인 없이 값 타입 조건만으로 필터링
7. H2 Bulk Mutation 전략 변경
대량 삭제(DELETE), 대량 수정(UPDATE)를 실행할 때, H2 데이터베이스에서 발생할 수 있는 무결성 제약 위반 문제를 해결하려는 목적으로 변경됨.
변경 전 vs 후
변경 전 → Hibernate는 H2에서 inline 방식으로 bulk update/delete를 실행 함
변경 후 → H2에서는 기본적으로 temporary table 방식으로 실행되도록 변경 됨.
목적 → FK 제약 조건 위반 오류 방지 및 실제 RDBMS와 유사한 동작을 보장함.
Bulk Mutation 전략이란?
Hibernate에서 Bulk Mutation은 JPQL 또는 HQL을 이용한 대량 수정/삭제 구문을 실행할 때 사용하는 내부 전략
INLINE → SQL을 직접 DELETE, UPDATE로 실행함
TEMP TABLE → 먼저 삭제할/업데이트할 ID들을 임시 테이블에 저장 후 처리함.
CTE (WITH 문) → 공통 테이블 표현식(Common Table Expression)을 사용함.
H2 DB에서의 기존 문제 (INLINE 전략)
- Hibernate의 INLINE 방식은 순서 없이 삭제함 → FK 충돌 발생
H2의 FK 제약 조건
H2는 다른 RDBMS보다 외래 키 제약을 더 엄격하게 즉시 평가(eager evaluation)함.
대부분의 실제 RDBMS(ex. MySQL, Oracle 등)는
FK 제약을 트랜잭션 커밋 시점까지 defer(지연 평가)할 수도 있고
대량 삭제 시 내부적으로 순서를 조정하려 충돌 없이 처리할 수 있음
H2의 경우
삭제/수정 SQL 실행 시점에 바로 FK 무결성 조건을 검사함
트랜잭션 내에서라도 FK 충돌이 있으면 바로 예외를 던짐 (테스트 중 문제가 될 수 있음)
H2에서 자주 문제되는 상황
JPQL로 대량 삭제 시: Hibernate가 DELETE FROM Parent를 먼저 실행 → FK 오류 즉시 발생
@Transactional안에서 save → delete 시도: 저장이 되기 전에 delete가 트리거 되면 FK 검사 실패bulk 연산 순서가 부적절한 경우: 자식 → 부모 순이 아니면 오류
H2는 실제 RDBMS보다 더 즉시, 트랜잭션과 무관하게 외래키 무결성 검사를 수행하기 때문에 테스트 중 bulk 삭제나 수정이 예상보다 더 자주 실패할 수 있음.
Hibernate 6.6 변경 내용 (TEMP TABLE 전략)
삭제/수정 대상 ID를 임시 테이블 (hibernate_temp_ids)에 저장함
이후 이 임시 테이블을 이용해 DELETE, UPDATE 수행
FK가 걸려 있어도 안전하게 순서를 제어할 수 있음
설정 방법
hibernate.hql.bulk_id_strategy=org.hibernate.hql.spi.id.inline.InlineIdsInClauseBulkIdStrategy
hibernate.hql.bulk_id_strategy=org.hibernate.hql.spi.id.table.LocalTemporaryTableBulkIdStrategy // 기본값으로 변경됨
- Hibernate 6.6에서 H2 기본 전략을 TEMP TABLE로 바꿨지만, 원한다면 전략을 명시적으로 선택할 수 있다.
8. Expression#as(Class) → case() 명확화
Criteria API 에서 타입 변환 또는 조건 분기를 표현할 때 혼동을 줄이고, 명확하고 일관된 API 사용을 유도하기 위해 변경함.
- 개발자들이
as(class)를 타입 변환 / 조건 분기 / 다형성 처리로 혼동해서 사용함.
Hibernate 6.6의 개선 사항
타입 변환 → expression.as(Class) (그대로 사용)
조건 분기 (CASE WHEN) → criteriaBuilder.select(criteriaBuilder.<Type>selectCase()…)
다형성 캐스팅 → treat() 사용
Criteria API에서 혼동 되는 예시
-- JPQL
SELECT CASE WHEN o.amount > 100 THEN 'HIGH' ELSE 'LOW' END FROM Order o
criteriaBuilder.select(orderRoot.get("amount").as(String.class))
// as() 의미 불분명
// 자동으로 HIGH, LOW로 캐스팅 되는 것이 아니나, 오해해서 많이 사용 함.
- Hibernate 6.6에서는 다음 처럼 명확하게 작성할 수 있도록 유도함.
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<String> cq = cb.createQuery(String.class);
Root<Order> root = cq.from(Order.class);
cq.select(
cb.selectCase()
.when(cb.gt(root.get("amount"), 100), "HIGH")
.otherwise("LOW")
);
Criteria 란?
JPA에서 말하는 Criteria API는 SQL처럼 문자열로 쿼리를 작성하지 않고, Java 또는 Kotlin 코드로 쿼리를 동적으로 조립할 수 있도록 만든 표준 API
언제, 왜 쓸까?
동적 쿼리 조립 → 조건이 선택적으로 바뀔 경우 (if, for, switch로 조건 추가 가능)
타입 안정성 → 컴파일 타임에 컬럼 이름 오타나 타입 오류를 방지할 수 있음
IDE 자동 완성 → Kotlin/Java 구조 기반으로 IDE에서 코드 자동완성 가능
장점 → JPA 표준 (모든 구현체에서 사용 가능)
단점 → 코드가 장황하고 복잡해짐 → 실무에서는 다른 쿼리 기술을 많이 선호
Criteria API 와 다른 쿼리 기술들과의 비교
| 항목 | Criteria API | QueryDSL | Spring Data Specification | Kotlin DSL (Spring 3.0+) |
| 기본 성격 | JPA 표준 API | 외부 라이브러리 | Spring Data 확장 | Spring 공식 Kotlin DSL |
| 가독성 | ❌ 낮음 | ✔ 높음 | ◑ 중간 | ✔ 높음 |
| IDE 자동완성 | ◑ 보통 | ✔ 매우 좋음 | ❌ 없음 (String 기반) | ✔ Kotlin 친화적 |
| 설정 난이도 | 없음 | ⛔ 복잡 (APT, build 필요) | 간단 | 간단 |
| Kotlin 호환성 | ◑ 제한적 | ❌ Java 전용이 많음 | ✔ Kotlin 가능 | ✔ 최적화됨 |
| 조건 동적 조립 | ✔ 뛰어남 | ✔ 뛰어남 | ✔ 뛰어남 | ✔ 뛰어남 |
| 커뮤니티/문서 | 📘 많음 (JPA 표준) | 📚 많음 | 📄 간결 | 📘 Spring 3.0 이상 공식 지원 |
어떤 기준으로 선택해야할까?
| 개발 환경 | 추천 방식 |
| 스프링 + Kotlin | Spring Data Kotlin DSL, Specification |
| 스프링 + Java | QueryDSL 또는 Criteria |
| 동적 쿼리 조립이 많은 경우 | Criteria API 또는 QueryDSL |
| 단순 조건 + 빠른 개발 | Spring Data JPA 메서드 쿼리 or Specification |
| 복잡한 검색 API (관리자, 필터 등) | QueryDSL or Criteria API |
9. @Table + SINGLE_TABLE 상속 전략 충돌 검출
JPA에서 상속 전략을 잘못 사용하는 실수를 방지하기 위한 제약 추가
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
JPA의 상속 매핑 전략 중 하나
부모 클래스와 자식 클래스의 데이터를 한 테이블에 저장함.
Hibernate는 이를 위해 Discriminator 컬럼을 자동 생성해서 어떤 자식 타입인지 구분함
@Table(name = … )
엔티티가 매핑될 실제 테이블 이름을 지정하는 어노테이션
기본적으로 클래스 이름을 따르지만, 원하는 테이블명을 직접 지정할 수 있음
문제 상황 (Hibernate 6.5 이하)
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@Table(name = "animal") // 부모 테이블 지정
open class Animal(
@Id @GeneratedValue
val id: Long? = null
)
@Entity
@Table(name = "cat") // SINGLE_TABLE 전략인데 자식에게 다른 테이블 지정
class Cat(
val sound: String
) : Animal()
SINGLE_TABLE 전략은 부모 + 자식 모두 한 테이블에 저장해야 함.
그런데 자식 클래스 Cat에
@Table(name = “cat”)을 지정하면 이 자식 클래스는 별도 테이블을 쓰겠다는 뜻Hibernate의 매핑 전략과 충돌 발생
Hibernate 6.5 이하는 이걸 감지하지 않고 묵인하거나 런타임에 모호한 오류로 이어짐.
Hibernate 6.6에서의 변경점
부모가 SINGLE_TABLEl 전략을 사용하는 경우
자식 클래스에
@Table명시하면 즉시 예외 발생MappingException: A @Table annotation on a subclass of a SINGLE_TABLE inheritance hierarchy is not allowed
실무에서 이런 코드를 쓰는 이유
자식 엔티티마다 테이블을 따로 만들고 싶어서
- 전략 자체를 JOINED나 TABLE_PER_CLASS로 바꿨어야 함
SINGLE_TABLE 전략을 쓰면서도 테이블 명을 명확히 하기 위해 잘못 붙인 경우
JPA의 3가지 상속 전략
SINGLE_TABLE (단일 테이블 전략)
부모 클래스와 자식 클래스의 모든 데이터를 하나의 테이블에 저장한다.
구분을 위해 Hibernate가 자동으로 Discriminator 컬럼을 생성해 어떤 자식 타입인지 구분한다.
장점
성능 좋음 → 조인 없이 한 테이블만 조회
구현 단순 → 테이블이 하나뿐이라 쿼리가 빠르고 구조 단순
기본 전략 → JPA의 기본 상속 전략이라 설정 생략 시 사용됨
단점
NULL 컬럼 많아짐 → 자식마다 필드가 다르면 대부분 컬럼이 NULL이 됨
스키마 정규화 깨짐 → 테이블이 커지고 중복된 의미 없는 컬럼 증가
@Table충돌 주의 → Hibernate 6.6부터 자식에@Table사용 시 예외 발생
예시
-- 단일 테이블에 모든 클래스 정보 저장
id | dtype | name | sound | breed
---|-----------|--------|---------|-------
1 | Cat | nabi | meow | NULL
2 | Dog | choco | NULL | shiba
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype")
open class Animal(@Id @GeneratedValue val id: Long? = null, val name: String)
@Entity
@DiscriminatorValue("Cat")
class Cat(val sound: String) : Animal()
@Entity
@DiscriminatorValue("Dog")
class Dog(val breed: String) : Animal()
JOINED (조인 전략)
부모 클래스는 공통 필드만 테이블로 생성
자식 클래스는 자식 고유 필드만 별도 테이블로 생성
조회 시 부모-자식 테이블 간 JOIN 수행
장점
정규화 구조 → 테이블이 논리적으로 깔끔하고 중복 없음
자식 테이블에 고유 제약 가능 → 자식 테이블에 별도 제약 조건 설정 가능
NULL 컬럼 없음 → 자식 필드는 자식 테이블에만 존재
단점
성능 저하 → JOIN이 필요해 조회 성능 느릴 수 있음
쿼리 복잡 → 테이블 여러개를 JOIN 해야 함.
예시
-- 부모 테이블
id | name
---|-----
1 | nabi
2 | choco
-- 자식 테이블 (cat)
id | sound
---|-------
1 | meow
-- 자식 테이블 (dog)
id | breed
---|--------
2 | shiba
@Entity
@Inheritance(strategy = InheritanceType.JOINED)
open class Animal(@Id @GeneratedValue val id: Long? = null, val name: String)
@Entity
@Table(name = "cat")
class Cat(val sound: String) : Animal()
@Entity
@Table(name = "dog")
class Dog(val breed: String) : Animal()
TABLE_PER_CLASS (자식별 테이블 전략)
부모 클래스는 실제 테이블이 없음
자식 클래스마다 각자 테이블 생성, 부모의 필드도 모두 포함
즉, 각 자식 클래스는 자기 필드를 모두 갖는 독립 테이블
장점
JOIN 불필요 → 자식 테이블에 모든 필드가 존재
성능 빠름 (단일 조회 시) → 쿼리가 단순함
구조 명확 → 테이블 = 클래스 구조 그대로
단점
중복 데이터 → 부모 필드가 각 자식 테이블에 반복됨
전체 조회 어려움 → 자식마다 UNION이 필요함 (
SELECT * FROM cat UNION ALL SELECT * FROM dog)ID 중복 주의 → 각 테이블에서 ID 충돌 방지를 위해
@GeneratedValue(strategy = TABLE)권장
예시
-- cat 테이블
id | name | sound
---|-------|-------
1 | nabi | meow
-- dog 테이블
id | name | breed
---|-------|--------
2 | choco | shiba
@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
open class Animal(@Id @GeneratedValue(strategy = GenerationType.AUTO) val id: Long? = null, val name: String)
@Entity
class Cat(val sound: String) : Animal()
@Entity
class Dog(val breed: String) : Animal()
출처 : https://docs.jboss.org/hibernate/orm/6.6/migration-guide/migration-guide.html



