Skip to main content

Command Palette

Search for a command to run...

Hibernate ORM 6.6 변경 사항

Updated
•20 min read•View as Markdown
Hibernate ORM 6.6 변경 사항
�

Back-end Engineer, Clarity in code. Calm in process.

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을 지원하지 않는 이유

  1. JPA 사양에 없음

    • @Entity, @Embeddable 등은 테이블과 컬럼을 정의하지, Oracle의 TYPE을 정의하는 사양이 존재하지 않음
  2. DBMS에 종속적인 문법

    • CREATE TYPE은 각 DB마다 문법이 전혀 다름
  3. Hibernate의 스키마 생성기는 ANSI SQL 기반

    • Oracle 전용 UDT는 HIbernate가 인식은 하지만 직접 생성하지는 않음

Hibernate와 Oracle UDT를 함께 사용하려면 어떻게 해야할까?

  1. 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;
  1. 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 이하)

문제 상황

  1. 어떤 엔티티가 A 트랜잭션에서 삭제됨

  2. B 트랜잭션에서는 해당 엔티티의 참조를 여전히 들고 있음 (detached 상태)

  3. B 트랜잭션에서 em.merge(entity) 호출

기존 동작 (문제 O)

  • Hibernate는 해당 ID의 엔티티가 DB에 없으므로 새로 추가해야 한다고 판단해 INSERT 실행

  • 실제로는 사용자가 원한건 병합(Update) 였지만, 이미 삭제된 엔티티가 다시 생성됨.

  • 낙관적 락(Optimistic Locking)의 규칙 위반이며 데이터 무결성을 해치는 상황

Hibernate 6.6의 개선된 동작

DB에 해당 row가 없을 경우 OptimisticLockException을 던짐

예외 발생 조건

  1. @Id 가 @GeneratedValue 등으로 자동 생성된 값일 경우

  2. @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 oSELECT o.audit.createdAt FROM Order o

실무에서 선택 기준

  • 여러 엔티티에 공통 필드를 상속해야 함 → @MappedSuperclass

  • 공통 필드가 하나의 개념 단위(객체)로 묶여야 함 → @Embeddable

  • 단일 객체 안에서 서브 속성으로 표현하고 싶음 → @Embeddable

  • JPA의 다형성 상속 전략과 함께 쓰고 싶음 → @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 중 어떤 하위 타입인지 기록함.

생성되는 테이블 예시

idnameaddress_cityaddress_postal_codeaddress_country_codeaddress_type
1KimSeoul12345NULLKoreanAddress
2JohnLondonNULLGBForeignAddress

실무 적용시

  • 공통 구조 정의 → 공통 필드는 부모 @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 APIQueryDSLSpring Data SpecificationKotlin DSL (Spring 3.0+)
기본 성격JPA 표준 API외부 라이브러리Spring Data 확장Spring 공식 Kotlin DSL
가독성❌ 낮음✔ 높음◑ 중간✔ 높음
IDE 자동완성◑ 보통✔ 매우 좋음❌ 없음 (String 기반)✔ Kotlin 친화적
설정 난이도없음⛔ 복잡 (APT, build 필요)간단간단
Kotlin 호환성◑ 제한적❌ Java 전용이 많음✔ Kotlin 가능✔ 최적화됨
조건 동적 조립✔ 뛰어남✔ 뛰어남✔ 뛰어남✔ 뛰어남
커뮤니티/문서📘 많음 (JPA 표준)📚 많음📄 간결📘 Spring 3.0 이상 공식 지원

어떤 기준으로 선택해야할까?

개발 환경추천 방식
스프링 + KotlinSpring Data Kotlin DSL, Specification
스프링 + JavaQueryDSL 또는 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

56 views

More from this blog

2025 게으른 개발자 컨퍼런스 제 2회 후기

게으르다면서 세상 부지런한 개발자 컨퍼런스에 다녀왔다 🏃‍♀️ 2025 게으른 개발자 컨퍼런스는 테크살롱에서 진행되었다. 몰랐는데 우아한형제들이 만든 곳이라고.후원사가 한빛미디어와 넥스트스텝이었다. 백엔드 컨퍼런스가 많지 않아서인지(?) 티켓팅 경쟁이 꽤나 치열했는데, 운 좋게 아는 분에게 양도 받아서 다녀왔다. 컨퍼런스 순서는 아래와 같다. 24/7 끊기지 않는 잔고 서비스 개발기 (황보성우님) Go 서버 메모리 누수 버그 개선 (김환석...

Apr 20, 20255 min read27
2025 게으른 개발자 컨퍼런스 제 2회 후기

AWS Bedrock(생성형 AI) 관련 자료 모음

Bedrock이란? AWS에서 제공하는 생성형 AI 서비스 플랫폼으로 개발자가 다양한 Foundation Model (FMs)를 쉽게 사용하고 애플리케이션에 통합할 수 있도록 지원하는 서비스. 대표적인 생성형 모델(ex. 텍스트 생성, 요약, 이미지 생성 등)을 API 형태로 바로 호출 가능 머신러닝 모델을 직접 학습하거나 인프라 운영하지 않아도 됨 주요 특징 항목설명 멀티 모델 제공여러 AI 모델 제공사(예: Anthr...

Mar 30, 20255 min read44
AWS Bedrock(생성형 AI) 관련 자료 모음

2022 하반기~2024 상반기 까지의 회고글

어디서부터 글을 써야할까.그동안 정말 많은 일이 있었다. 새 회사 이 회고글 이후, 들어간 새 회사. 초반엔 정말 좋았었다.바쁘지만 열정 넘치는 회사 분위기, 성장을 추구하는 동료들, 나은 결과를 함께 만들어 나간다는 성취감..그러다 점점 상황이 어긋나기 시작한 것은 수습이 지나고, 2023년 상반기부터 였다. 감당할 수 없는 일 일이 감당할 수 없을 정도로 밀려들어왔다. 팀 대내외 적으로 계속 달리는 분위기였고, 리더는 그런 분위기를 조장하고...

Jul 8, 20246 min read712
2022 하반기~2024 상반기 까지의 회고글
L

let.it.log

11 posts

간단히 기록하는 블로그