2024. 6. 6. 08:04ㆍ스프링 (Spring)/스프링 데이터 (Spring Data)
Persistence Context
영속성 컨텍스트(persistence context)는 엔티티 인스턴스들이 관리되는 장소입니다.
그리고 엔티티 매니저(entity manager)는 영속성 컨텍스트를 방문해 상호작용 할 수 있는 인터페이스를 가지고 있고요.
쉽게 말해 엔티티 매니저는 우리가 어플리케이션에서 생성한 엔티티 인스턴스들을
영속성 컨텍스트에 방문해서 가져와, 이를 통해 실제 데이터베이스에 CRUD를 수행한다고 생각하면 됩니다.
그렇기에 당연히 엔티티 매니저는 쿼리(query)를 생성할 수 있습니다.
예를 들어 JPA(Jakarta Persistence API) - Entity 글에서 작성한 코드 중 하나를 실행해보면
val peach = entityManager.find(Peach::class.java, 1)
아래처럼 조회 쿼리가 만들어집니다.
[Hibernate]
select
p1_0.srl,
p1_1.height,
p1_1.width,
p1_0.color
from
peaches p1_0
join
peach_sizes p1_1
on p1_0.srl=p1_1.srl
where
p1_0.srl=?
참고로 본 글부터는 withTransaction 블록 안에서 실행되므로 begin(), commit() 과 같은 함수 호출이 생략됩니다.
fun withTransaction(block: (entityManager: EntityManager) -> Unit) {
val entityManagerFactory = Persistence.createEntityManagerFactory("oim")
val entityManager = entityManagerFactory.createEntityManager()
entityManager.transaction.begin()
block(entityManager) // <-- 여기에 예시 코드가 들어있음
entityManager.transaction.commit()
entityManager.close()
}
Life Cycle
엔티티 인스턴스(entity instance)는 신규(new), 관리(manage), 해제(detach), 삭제(remove)의 생애주기를 가지고 있습니다.
- 신규 : 엔티티 클래스로부터 새롭게 인스턴스를 만들 때 입니다. 아직 영속성 컨텍스트에 존재하지 않습니다.
- 관리 : 영속성 컨텍스트에 있는 인스턴스입니다.
- 해제 : 인스턴스가 영속성 컨텍스트에 있다가, 제거된 경우입니다. 데이터베이스에는 반영하지 않습니다.
- 삭제 : 인스턴스를 해제할 뿐만 아니라 데이터베이스에서도 삭제합니다.
여기서부터는 커밋(commit)이라는 개념이 중요한데, 이는 곧 데이터베이스에 온전히 반영되었다라고 쉽게 이해해도 됩니다.
커밋은 EntityTransaction.commit()으로 할 수 있습니다.
entityManager.transaction.begin()
val melon = Melon()
entityManager.persist(melon)
entityManager.transaction.commit()
만일 실컷 엔티티 인스턴스를 생성하고 변경, 삭제했더라도 커밋을 안 했다면 데이터베이스에 반영되지 않습니다.
entityManager.transaction.begin()
val melon = Melon()
entityManager.persist(melon)
// entityManager.transaction.commit() -> melon 저장 안 됨!
자세한 설명은 아래 멜론(melons) 테이블을 가지고 시작하겠습니다.
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table
@Entity
@Table(name = "melons")
class Melon(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val srl: Long = 0L,
var color: String = "green",
var perimeter: Int = 10
)
Creation
엔티티 인스턴스를 생성하기 위해, 먼저 아래처럼 엔티티 클래스로부터 객체를 새로 만듭니다.
다만 단순 객체일뿐, 영속성 컨텍스트에 있진 않습니다.
val melon = Melon()
Persist
위에서 만든 객체가 영속성 컨텍스트에서 관리되는 엔티티 인스턴스가 되려면, persist() 함수를 호출해줘야 합니다.
entityManager.persist(melon)
persist 했을 때,
- 새로 만든 인스턴스의 경우, 영속성 컨텍스트로 편입하여 관리되도록 합니다.
커밋 할 때 데이터베이스에 저장됩니다.
- 인스턴스에 관계되는 다른 엔티티 인스턴스들이 있고 CascadeType.PERSIST 가 설정되어있다면 해당 인스턴스들에 대해 persist 동작을 수행합니다.
- 인스턴스가 이미 영속성 컨텍스트에 있었으면, persist 동작은 무시됩니다.
바로 위에서 설명한 cascade는 정상적으로 수행됩니다.
- 인스턴스를 삭제(remove)했었다면, 이를 취소합니다.
만일 remove() 호출 후 커밋 전에 persist()을 호출하면 remove() 동작을 취소하는 것처럼이죠.
- 만약 인스턴스가 해제된 상태라면, 오류(EntityExistsException)가 발생합니다.
또는 PersistenceException이 발생할 수도 있습니다.
다음과 같은 코드를 보면, remove() 후 커밋되어 영속성 컨텍스트에서 제거되었음에도 - persist() 함수를 호출했습니다.
...
entityManager.remove(melon)
entityManager.flush()
entityManager.persist(melon)
entityManager.flush()
이와 같은 경우 다음과 같이 에러 메시지가 등장합니다.
Exception in thread "main" jakarta.persistence.EntityExistsException: detached entity passed to persist: chapter_2_entity_operation.lifecycle.Melon
at org.hibernate.internal.ExceptionConverterImpl.convert(ExceptionConverterImpl.java:126)
at org.hibernate.internal.ExceptionConverterImpl.convert(ExceptionConverterImpl.java:167)
at org.hibernate.internal.ExceptionConverterImpl.convert(ExceptionConverterImpl.java:173)
at org.hibernate.internal.SessionImpl.firePersist(SessionImpl.java:763)
at org.hibernate.internal.SessionImpl.persist(SessionImpl.java:741)
at chapter_2_entity_operation.lifecycle.MelonUsage$Companion$create$1.invoke(Main.kt:15)
at chapter_2_entity_operation.lifecycle.MelonUsage$Companion$create$1.invoke(Main.kt:8)
at utils.TransactionUtilsKt.withTransaction(TransactionUtils.kt:21)
at chapter_2_entity_operation.lifecycle.MelonUsage$Companion.create(Main.kt:8)
at chapter_2_entity_operation.lifecycle.MainKt.main(Main.kt:23)
at chapter_2_entity_operation.lifecycle.MainKt.main(Main.kt)
Caused by: org.hibernate.PersistentObjectException: detached entity passed to persist: chapter_2_entity_operation.lifecycle.Melon
at org.hibernate.event.internal.DefaultPersistEventListener.persist(DefaultPersistEventListener.java:88)
at org.hibernate.event.internal.DefaultPersistEventListener.onPersist(DefaultPersistEventListener.java:77)
at org.hibernate.event.internal.DefaultPersistEventListener.onPersist(DefaultPersistEventListener.java:54)
at org.hibernate.event.service.internal.EventListenerGroupImpl.fireEventOnEachListener(EventListenerGroupImpl.java:127)
at org.hibernate.internal.SessionImpl.firePersist(SessionImpl.java:757)
... 7 more
해제 상태에 대한 자세한 설명은 Detach 항목을 보면 됩니다.
Remove
엔티티 인스턴스를 데이터베이스 및 영속성 컨텍스트에서 제거하려면 remove() 함수를 호출하면 됩니다.
entityManager.remove(melon)
remove 했을 때,
- 인스턴스가 영속성 컨텍스트에 있었다면, 데이터베이스와 함께 제거합니다.
인스턴스에 관계되는 다른 엔티티 인스턴스들이 있고 CascadeType.REMOVE를 설정했다면 해당 인스턴스들에 대해 remove 동작을 수행합니다.
- 새로 만든 인스턴스의 경우, 무시됩니다.
다만 위에서 설명한 cascade는 정상적으로 수행됩니다.
- 이미 인스턴스를 삭제(remove)했었다면, 무시됩니다.
- 영속성 컨텍스트에 없었다면, 오류(IllegalArgumentException)가 발생합니다.
또는 트랜잭션이 실패할 수 있습니다.
Refresh
말 그대로 데이터베이스로부터 엔티티 인스턴스 상태값들을 갱신하는 것을 말합니다.
따라서 만일 상태값을 변경했었다면, 갱신된 값들로 덮어쓰이게(overwrite)됩니다.
아래는 melon을 만들고, 값을 변경한 후 refresh()를 호출한 예시입니다.
val melon = Melon()
entityManager.persist(melon)
entityManager.flush()
println("# persist : ${melon.color}")
melon.color = "red"
println("# and modify to ${melon.color}")
entityManager.refresh(melon)
println("# but refresh : ${melon.color}")
결과를 보면 persist 되었던 시점의 상태값을 그대로 가지고 있음을 알 수 있습니다.
[Hibernate]
insert
into
melons
(color, perimeter)
values
(?, ?)
# persist : green
# and modify to red
[Hibernate]
select
m1_0.srl,
m1_0.color,
m1_0.perimeter
from
melons m1_0
where
m1_0.srl=?
# but refresh : green
이 또한 관계되는 다른 엔티티 인스턴스들이 있고 CascadeType.REFRESH를 설정했다면
해당 인스턴스들에 대해서도 refresh가 동작합니다.
다만 엔티티 인스턴스가 영속성 컨텍스트에 없으면 오류(IllegalArgumentException)가 발생합니다.
아래는 오류가 발생하는 신규, 해제, 삭제에 대한 예시입니다.
val melon = Melon()
entityManager.refresh(melon)
val melon = Melon()
entityManager.persist(melon)
entityManager.flush()
entityManager.detach(melon)
entityManager.refresh(melon)
val melon = Melon()
entityManager.persist(melon)
entityManager.flush()
entityManager.remove(melon)
entityManager.refresh(melon)
Exception in thread "main" java.lang.IllegalArgumentException: Entity not managed
at org.hibernate.internal.SessionImpl.fireRefresh(SessionImpl.java:1285)
at org.hibernate.internal.SessionImpl.refresh(SessionImpl.java:1242)
at chapter_2_entity_operation.lifecycle.MelonUsage$Companion$refreshNewThrowError$1.invoke(Main.kt:30)
at chapter_2_entity_operation.lifecycle.MelonUsage$Companion$refreshNewThrowError$1.invoke(Main.kt:28)
at utils.TransactionUtilsKt.withTransaction(TransactionUtils.kt:21)
at chapter_2_entity_operation.lifecycle.MelonUsage$Companion.refreshNewThrowError(Main.kt:28)
at chapter_2_entity_operation.lifecycle.MainKt.main(Main.kt:57)
at chapter_2_entity_operation.lifecycle.MainKt.main(Main.kt)
Detach
엔티티 인스턴스를 영속성 컨텍스트에서 제거하고 싶으면 detach() 함수를 호출하면 됩니다.
entityManager.detach(melon)
영속성 컨텍스트에서 제거되었으므로, 해당 인스턴스는 무슨 짓을 하던 데이터베이스에 반영되지 않습니다.
따라서 detach 하기 전에 변경 사항을 데이터베이스에 잘 반영(커밋)하도록 주의해야 합니다.
detach 했을 때,
- 인스턴스가 영속성 컨텍스트에 있었다면, 해당 장소에서 제거됩니다.
인스턴스에 관계되는 다른 엔티티 인스턴스들이 있고 CascadeType.DETACH를 설정했다면 해당 인스턴스들에 대해 detach 동작을 수행합니다.
- 인스턴스를 이미 제거(remove)했다면, 어차피 detach 되었으므로 무시합니다.
하지만 위 cascade에 대해 여전히 동작하므로 주의해야 합니다.
- 신규 인스턴스이거나 이미 detach 되었다면 무시합니다.
엔티티 인스턴스가 영속성 컨텍스트에 있는 지 여부를 확인하고 싶다면 contain() 함수를 사용하면 됩니다.
val melon = Melon()
entityManager.persist(melon)
entityManager.flush()
println("melon is persisted? : ${entityManager.contains(melon)}") // true
entityManager.detach(melon)
println("melon is persisted? : ${entityManager.contains(melon)}") // false
Merge
merge()는 detach된 엔티티 인스턴스에 대해,
똑같은(identical) 인스턴스를 새로 만들어 다시 영속성 컨텍스트로 넣을 수 있도록 합니다.
The merge operation allows for the propagation of state from detached entities onto persistent entities managed by the entity manager.
다음 코드를 보면, melon이 detach 된 후, merge()를 통해 똑같은 mergedMelon을 생성했고
또한 영속성 컨텍스트에 있는 것도 확인할 수 있습니다.
val melon = Melon()
entityManager.persist(melon)
entityManager.flush()
entityManager.refresh(melon)
println("melon(${melon.srl}) is persisted? : ${entityManager.contains(melon)}") // true
entityManager.detach(melon)
println("melon(${melon.srl}) is persisted? : ${entityManager.contains(melon)}") // false
val mergedMelon = entityManager.merge(melon)
println("melon(${melon.srl}) is persisted? : ${entityManager.contains(melon)}") // false
println("mergedMelon(${melon.srl}) is persisted? : ${entityManager.contains(mergedMelon)}") // true
주의할 점은 기존 detach 된 melon은 다시 영속성 컨텍스트에 위치할 일이 없다는 것입니다.
merge 했을 때,
- 인스턴스가 detach 되었다면, 해당 인스턴스에 대해 똑같은 엔티티 인스턴스를 새로 만듭니다.
이때 새롭게 만든 인스턴스는 영속성 컨텍스트에 위치하게 됩니다.
- 신규 인스턴스라면, 똑같은 엔티티 인스턴스를 만들어 영속성 컨텍스트에 위치시킵니다.
다른 객체가 영속화될 뿐, 마치 persist() 한 것과 비슷합니다.
val melon = Melon()
println("melon is persisted? : ${entityManager.contains(melon)}") // false
val mergedMelon = entityManager.merge(melon)
println("melon is persisted? : ${entityManager.contains(melon)}") // false
println("mergedMelon is persisted? : ${entityManager.contains(mergedMelon)}") // true
- 인스턴스가 삭제되었었다면, 오류(IllegalArgumentException)이 발생합니다.
- 인스턴스에 관계되는 다른 엔티티 인스턴스들이 있을 때,
- CascadeType.MERGE 가 설정되어있지 않은 경우 그대로 가져옵니다.
- CascadeType.MERGE 가 설정되어있는 경우 해당 인스턴스들에 대해 merge 동작을 수행합니다.
이때 merge된 인스턴스들은 새로운 객체로 만들어집니다.
만일 관계된 인스턴스들이 FetchType.LAZY로 패치 전략이 되어있고, fetch되지 않았다면 해당 인스턴스들의 merge는 무시됩니다.
- 인스턴스에 관계되는 다른 엔티티 인스턴스들이 있고 CascadeType.MERGE 가 설정되어있다면 해당 인스턴스들에 대해 merge 동작을 수행합니다.
이때 해당 인스턴스들도 새로운 객체로 만들어집니다.
- 인스턴스가 이미 영속성 컨텍스트에 있었다면, 무시합니다.
인자로 제공한 엔티티 인스턴스를 그냥 리턴합니다. 다만 위 cascade에 대해 여전히 동작합니다.
다음과 같은 코드를 실행했을 때
val melon = Melon()
entityManager.persist(melon)
entityManager.flush()
println("melon is persisted? : ${entityManager.contains(melon)}")
val mergedMelon = entityManager.merge(melon)
println("melon : $melon")
println("mergedMelon : $mergedMelon")
아래 결과를 보면 melon과 mergedMelon이 서로 같은 객체임을 알 수 있습니다.
melon is persisted? : true
melon : chapter_2_entity_operation.lifecycle.Melon@37775bb1
mergedMelon : chapter_2_entity_operation.lifecycle.Melon@37775bb1
Fetch Type
패치(fetch)란 엔티티에 데이터를 적재(load)하는 것을 말합니다.
엔티티 인스턴스를 만들었을 때 상태값(멤버 변수)과 관계되는 인티티 인스턴스들에 대해
FetchType.EAGER, FetchType.LAZY 두 가지 패치 전략이 있습니다.
아래 오렌지 예시를 통해 자세히 설명해보겠습니다.
@Entity
@Table(name = "locations")
class Location(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val srl: Long = 0L,
var nation: String = "korea",
var longitude: Short = 126,
var latitude: Short = 36,
) {
@OneToMany(mappedBy = "location", fetch = FetchType.LAZY)
val oranges: MutableCollection<Orange> = mutableListOf()
}
@Entity
@Table(name = "oranges")
class Orange(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val srl: Long = 0L,
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "locationSrl")
var location: Location,
@OneToOne(fetch = FetchType.EAGER) // 명시하지 않아도 기본으로 fetch = FetchType.EAGER
@JoinColumn(name = "peelSrl")
var peel: Peel,
var perimeter: Int = 100
)
@Entity
@Table(name = "peels")
class Peel(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val srl: Long = 0L,
var color: String = "orange",
var thickness: String = "hard",
) {
@OneToOne(mappedBy = "peel")
lateinit var orange: Orange
}
EAGER
엔티티의 데이터를 조회할 때, 관계되는 엔티티를 포함시키는 전략으로
@ManyToOne, @OneToOne 일 때 기본 패치 전략입니다.
예를 들어 오렌지 클래스에 다음과 같이 fetch 전략을 만들었다면
@Entity
@Table(name = "oranges")
class Orange(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val srl: Long = 0L,
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "locationSrl")
var location: Location,
@OneToOne // 명시하지 않아도 기본으로 fetch = FetchType.EAGER
@JoinColumn(name = "peelSrl")
var peel: Peel,
var perimeter: Int = 100
)
오렌지를 조회해보면 쿼리에 위치와 껍질 테이블 모두 left join 해서 가져옴을 알 수 있습니다.
val orange = entityManager.find(Orange::class.java, 1L)
[Hibernate]
select
o1_0.srl,
l1_0.srl,
l1_0.latitude,
l1_0.longitude,
l1_0.nation,
p1_0.srl,
p1_0.color,
p1_0.thickness,
o1_0.perimeter
from
oranges o1_0
left join
locations l1_0
on l1_0.srl=o1_0.locationSrl
left join
peels p1_0
on p1_0.srl=o1_0.peelSrl
where
o1_0.srl=?
LAZY
엔티티의 데이터를 조회할 때, 관계되는 엔티티를 될 수 있으면 가능한 한 포함시키지 않는 전략입니다.
그리고 실제 (Id를 제외한 상태값을) 사용할 때 조회하는 것이죠.
이를 다른 말로 지연 로딩(delayed loading) 또는 지연 패치(delayed fetch)라고 합니다.
그리고 @OneToMany 일때 기본 패치 전략입니다.
EAGER 예시에서 FetchType.LAZY로 바꾸면 됩니다.
@Entity
@Table(name = "oranges")
class Orange(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val srl: Long = 0L,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "locationSrl")
var location: Location,
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "peelSrl")
var peel: Peel,
var perimeter: Int = 100
)
오렌지를 조회해보면 쿼리에 위치와 껍질 테이블이 빠져있음을 알 수 있습니다.
val orange = entityManager.find(Orange::class.java, 1L)
[Hibernate]
select
o1_0.srl,
o1_0.locationSrl,
o1_0.peelSrl,
o1_0.perimeter
from
oranges o1_0
where
o1_0.srl=?
이때 아래와 같이 위치(locations) 테이블의 국가를 조회하게되면, 다른 조회 쿼리가 실행되는 걸 확인할 수 있습니다.
println(orange.location.nation)
[Hibernate]
select
l1_0.srl,
l1_0.latitude,
l1_0.longitude,
l1_0.nation
from
locations l1_0
where
l1_0.srl=?
EAGER vs LAZY
두 패치 전략은 사실 대단히 중요합니다.
여기서 본인이 어떤 전략을 쓸 지가 중요한 게 아니라, 어떤 전략으로 데이터가 조회(패치)될 지 알아야 되는 게 중요합니다.
이와 관련해 수많은 엔티티들을 다루다보면 가장 흔하게 하는 실수가 N+1 문제이기 때문이죠. (N+1 문제는 향후 다룰 예정)
필자는 될 수 있으면 LAZY 전략을 우선 적용합니다.
이는 '관계되는 엔티티를 될 수 있으면 가능한 한 포함시키지 않는 전략'이라고 소개했었는데,
대표적으로 포함시키는 경우와 포함시키지 않는 경우를 소개해보겠습니다.
1. Collection in LAZY
컬렉션 타입은 LAZY 전략을 온전히 지원합니다.
빈 컬렉션 객체를 만들어놓고, add(), remove()와 같은 컬렉션 함수를 호출했을 때 실제 데이터를 조회하면 되기 때문이죠.
주로 @OneToMany 관계에 쓰이므로, 필자는 버릇처럼 다음과 같이 씁니다.
...
class Location(
...
@OneToMany(mappedBy = "location", fetch = FetchType.LAZY)
val oranges: MutableCollection<Orange> = mutableListOf()
)
2. Single in EAGER and LAZY
컬렉션이 아닌 1개 엔티티 클래스 타입을 가지는 경우, 존재하는 지에 대한 힌트를 제공하느냐에 따라 다릅니다.
예를 들어 아래 오렌지 클래스를 보면,
...
class Orange(
...
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "peelSrl")
var peel: Peel,
)
@JoinColumn으로 껍질(peel) 엔티티를 찾을 수 있는 키를 명시하고 있습니다.
이는 곧 orange.peel.color 와 같이 실제 껍질 엔티티 인스턴스의 상태값을 참조할 일이 있을 때
만일 peelSrl이 null이라면 테이블을 조회하지 않고, 아니라면 오렌지의 peelSrl로 껍질 테이블을 조회할 수 있죠.
따라서 LAZY 전략이 가능합니다.
반면 껍질 클래스를 보면,
...
class Peel(
...
) {
@OneToOne(mappedBy = "peel", fetch = FetchType.EAGER, optional = false)
lateinit var orange: Orange
}
오렌지를 가져올 키가 명시되어 있지 않습니다.
따라서 저 orange가 존재하는 지 알 수 없기 때문에,
peel.orange.perimeter 와 같이 오렌지의 상태값을 참조하면 필연적으로 오렌지 테이블을 조회할 수 밖에 없습니다.
따라서 LAZY 전략 대신 EAGER 전략만 사용할 수 있습니다.
The LAZY strategy is a hint to the persistence provider runtime that the associated entity should be fetched lazily when it is first accessed. The implementation is permitted to eagerly fetch associations for which the LAZY strategy hint has been specified.
여담으로 지연 로딩 시 만드는 프록시 객체는 id가 필수여서 그렇습니다.
이유는 엔티티를 식별(identify)하는 데 사용하기 때문인데, 자세한 내용은 Entity Identity 챕터를 확인해주세요.
Static Metamodel Generator
엔티티 관계에 대한 어노테이션 중, mappedBy 값을 매직 스트링으로 만드는 게 부담스럽다면,
hibernate에서 제공하는 metamodel을 사용하면 좋습니다.
다음과 같이 build.gradle을 설정하고, (kapt에 대한 설명은 추후 다루겠습니다)
plugins {
...
id 'org.jetbrains.kotlin.kapt' version "1.9.23"
}
...
dependencies {
...
kapt('org.hibernate.orm:hibernate-jpamodelgen:6.5.2.Final')
}
kaptKotlin을 실행해주면 (또는 build 해주면 됩니다)
아래처럼 메타모델 클래스가 만들어집니다.
@StaticMetamodel(Orange.class)
public abstract class Orange_ {
public static final String PERIMETER = "perimeter";
public static final String PEEL = "peel";
public static final String LOCATION = "location";
public static final String SRL = "srl";
/**
* @see chapter_2_entity_operation.fetch.Orange#perimeter
**/
public static volatile SingularAttribute<Orange, Integer> perimeter;
/**
* @see chapter_2_entity_operation.fetch.Orange#peel
**/
public static volatile SingularAttribute<Orange, Peel> peel;
/**
* @see chapter_2_entity_operation.fetch.Orange#location
**/
public static volatile SingularAttribute<Orange, Location> location;
/**
* @see chapter_2_entity_operation.fetch.Orange#srl
**/
public static volatile SingularAttribute<Orange, Long> srl;
/**
* @see chapter_2_entity_operation.fetch.Orange
**/
public static volatile EntityType<Orange> class_;
}
이제는 아래와 같이 Orange_.PEEL 처럼 작성하면 됩니다.
...
class Peel(
...
) {
@OneToOne(mappedBy = Orange_.PEEL, fetch = FetchType.LAZY, optional = false)
lateinit var orange: Orange
}
자세한 설명은 아래 가이드를 참고하시면 됩니다.
Locking
JPA(Jakarta Persistence API) - Entity : Version 글에서 잠금에 대해 간략히 소개했었죠.
이번 챕터에서는 더 자세하게 두 가지 잠금(locking) 방식을 설명해보겠습니다.
Optimistic Locking
어떤 트랜잭션이 엔티티를 읽고, 변경 사항들을 데이터베이스에 반영하는 모든 과정에 대해
다른 트랜잭션이 관여하지 않는다는 (영향이 없다는) 가정에서 출발합니다.
따라서 읽기 또는 쓰기 시 락(lock)을 획득하지 않습니다.
그리고 commit() 또는 flush() 호출 시 엔티티들을 비교하여 정말로 영향이 없었는 지 확인하는데,
만일 위반 사항(violated result)이 발견되었다면 오류(OptimisiticLockException)를 발생시키고 롤백 대상으로 지정합니다.
이때 '정말로 영향이 없었는 지 확인'하기 위해서 유용한 장치가 바로 버전(version)입니다.
예시에서 볼 수 있듯 데이터 변경 사항이 발생되면 버전을 1 증가시킵니다.
이때 다른 트랜잭션에서 데이터를 변경하고자 시도할 때, 버전을 비교해서 다르다면 실패하도록 만드는 원리입니다.
코틀린의 코루틴(coroutine)을 사용해서 위반 상황을 재현해보면,
먼저 엔티티 클래스를 만들고
@Entity
@Table(name = "figs")
class Fig(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val srl: Long = 0L,
var color: String = "green",
@Version
val version: Long = 0L
)
시간차를 두고 업데이트를 진행해봅니다.
suspend fun modifyByCoroutines(srl: Long) = coroutineScope {
launch {
val entityManager = ...
entityManager.transaction.begin()
val fig = entityManager.find(Fig::class.java, srl)
println("fastFig : ${fig.color}, version : ${fig.version}")
delay(500L) // 먼저 업데이트 진행
fig.color = "red"
entityManager.persist(fig)
entityManager.transaction.commit()
entityManager.close()
}
launch {
val entityManager = ...
entityManager.transaction.begin()
val fig = entityManager.find(Fig::class.java, srl)
println("slowFig : ${fig.color}, version : ${fig.version}")
delay(1000L) // 나중에 업데이트 진행
fig.color = "brown"
entityManager.persist(fig)
entityManager.transaction.commit()
entityManager.close()
}
}
함수를 실행하면 ... -> OptimisticLockException -> RollbackException 순으로 오류가 등장하는 걸 확인할 수 있습니다.
Exception in thread "main" jakarta.persistence.RollbackException: Error while committing the transaction
at org.hibernate.internal.ExceptionConverterImpl.convertCommitException(ExceptionConverterImpl.java:67)
at org.hibernate.engine.transaction.internal.TransactionImpl.commit(TransactionImpl.java:104)
at chapter_2_entity_operation.version.FigUsage$Companion$modifyByThread$2$2.invokeSuspend(Main.kt:44)
...
Caused by: jakarta.persistence.OptimisticLockException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect) : [chapter_2_entity_operation.version.Fig#7]at org.hibernate.internal.ExceptionConverterImpl.wrapStaleStateException(ExceptionConverterImpl.java:209)at org.hibernate.internal.ExceptionConverterImpl.convert(ExceptionConverterImpl.java:95)
...
Caused by: org.hibernate.StaleObjectStateException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect) : [chapter_2_entity_operation.version.Fig#7]at org.hibernate.engine.jdbc.mutation.internal.ModelMutationHelper.identifiedResultsCheck(ModelMutationHelper.java:75)at org.hibernate.persister.entity.mutation.UpdateCoordinatorStandard.lambda$doStaticUpdate$9(UpdateCoordinatorStandard.java:785)
Pessimistic Locking
이와 반대로 비관적 잠금(pessimistic locking) 방식은 읽기 또는 쓰기 시 락(lock)을 획득하는 것입니다.
따라서 해당 락을 가진 트랜잭션은 데이터 변경에 따른 다른 트랜잭션과의 충돌을 피할 수 있습니다.
특히 JPA(Jakarta Persistence API) - Entity : Inheritance에서 설명했었던 상속에 대해 연관된 엔티티들도 함께 락을 획득합니다.
Lock의 범위는 PessimisticLockScope를 통해 결정할 수 있습니다.
이때 EXTENDED의 경우, 관계되는 다른 엔티티들에 대해 add, remove와 같은 연산에 대한 락을 함께 획득합니다.
public enum PessimisticLockScope {
NORMAL,
EXTENDED
}
단, 상속이든 엔티티 관계든 상태값까지 락을 획득하지 않기 때문에 주의해야 합니다.
Element collections and relationships owned by the entity that are contained in join tables will be locked if the
jakarta.persistence.lock.scope property is specified with a value of PessimisticLockScope.EXTENDED. The state of entities referenced by such relationships will not be locked (unless those entities are explicitly locked).
그리고 각 행(rows)에 대해서 락을 만들 뿐이므로, 여전히 범위에 대해서는 잠구지 않습니다.
Locking such a relationship or element collection generally locks only the rows in the join table or collection table for that relationship or collection. This means that phantoms will be possible.
Lock Mode
위에서 설명했던 잠금(locking)을 사용하기 위한 잠금 모드가 있습니다.
package jakarta.persistence;
public enum LockModeType {
READ,
WRITE,
OPTIMISTIC,
OPTIMISTIC_FORCE_INCREMENT,
PESSIMISTIC_READ,
PESSIMISTIC_WRITE,
PESSIMISTIC_FORCE_INCREMENT,
NONE
}
참고로 READ는 OPTIMISTIC, WRITE는 OPTIMISTIC_FORCE_INCREMENT와 동일합니다.
설명을 보니 후자 용어를 쓰는 것을 권장하고 있네요.
The lock mode type values READ and WRITE are synonyms of OPTIMISTIC and OPTIMISTIC_FORCE_INCREMENT respectively. The latter are to be preferred for new applications.
예시로 쓰일 엔티티 클래스를 미리 소개합니다.
Versioned Entity
@Entity
@Table(name = "vanillas")
class Vanilla(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val srl: Long = 0L,
var brix: Int = 0,
@Version
var version: Long = 0L
) {
fun show() = println("# [ Brix $brix ] [ Version $version ]")
}
Non-versioned Entity
@Entity
@Table(name = "beans")
class Bean(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val srl: Long = 0L,
var length: Int = 0
) {
fun show(title: String) = println("$title : [ Length $length ]")
}
READ, WRITE, OPTIMISTIC, OPTIMISTIC_FORCE_INCREMENT
모두 낙관적 잠금(optimistic locking)에 대한 모드들입니다.
OPTIMISTIC_FORCE_INCREMENT는 엔티티의 상태값을 업데이트 했을 때,
버전(version)을 증가시킨다는 점만 OPTIMISTIC과 차이가 있습니다.
해당 모드들에서 엔티티 매니저는 다음 2개 읽기 현상을 방지해야 합니다.
Prevent : Dirty Read
T1이 row를 읽고 수정했을 때, T2가 동일 row를 읽었을 때 수정된 값을 읽습니다.
이후에는 T1이든 T2든 커밋 및 롤백이 모두 성공합니다.
왼쪽 상황을 보면 T1이 brix를 15로 업데이트하고, 커밋이나 롤백 전에 T2가 해당 15를 읽습니다.
그리고 T1이 롤백했음에도, T2는 무시하고 버전 2로 brix를 23으로 업데이트하죠.
이는 오른쪽 상황처럼 dirty read를 방지할 수 있습니다.
이를 코드로 구현해보면,
Transaction 1
val vanilla: Vanilla = entityManager.find(Vanilla::class.java, srl, LockModeType.OPTIMISTIC)
vanilla.show("#1 T1")
vanilla.brix += 15
entityManager.persist(vanilla)
entityManager.flush()
vanilla.show("#2 T1")
delay(2000)
entityManager.transaction.rollback()
print("(managed ${entityManager.contains(bean)}) ")
vanilla.show("#4 T1")
Transaction 2
delay(1000)
val vanilla: Vanilla = entityManager.find(Vanilla::class.java, srl, LockModeType.OPTIMISTIC)
vanilla.show("#3 T2")
delay(2000)
vanilla.brix += 23
entityManager.persist(vanilla)
entityManager.flush()
vanilla.show("#5 T2")
entityManager.transaction.commit()
vanilla.show("#6 T2")
#1 T1 : [ Brix 0 ] [ Version 0 ]
#2 T1 : [ Brix 15 ] [ Version 1 ]
#3 T2 : [ Brix 0 ] [ Version 0 ]
(managed false) #4 T1 : [ Brix 15 ] [ Version 1 ]
#5 T2 : [ Brix 23 ] [ Version 1 ]
#6 T2 : [ Brix 23 ] [ Version 1 ]
버전이 없다면 T2가 커밋을 할 때 오류가 발생합니다.
Transaction 1
val bean: Bean = entityManager.find(Bean::class.java, srl, LockModeType.OPTIMISTIC)
bean.show("#1 T1")
bean.length += 15
entityManager.persist(bean)
entityManager.flush()
bean.show("#2 T1")
delay(2000)
entityManager.transaction.rollback()
print("(managed ${entityManager.contains(bean)}) ")
bean.show("#4 T1")
Transaction 2
delay(1000)
val bean: Bean = entityManager.find(Bean::class.java, srl, LockModeType.OPTIMISTIC)
bean.show("#3 T2")
delay(2000)
bean.length += 23
entityManager.persist(bean)
entityManager.flush()
bean.show("#5 T2")
entityManager.transaction.commit()
bean.show("#6 T2")
#1 T1 : [ Length 0 ]
#2 T1 : [ Length 15 ]
#3 T2 : [ Length 0 ]
(managed false) #4 T1 : [ Length 15 ]
#5 T2 : [ Length 23 ]
Exception in thread "main" jakarta.persistence.RollbackException: Error while committing the transaction
...
Caused by: org.hibernate.HibernateException: Unable to perform beforeTransactionCompletion callback: Cannot invoke "Object.equals(Object)" because the return value of "org.hibernate.engine.spi.EntityEntry.getVersion()" is null
...
Caused by: java.lang.NullPointerException: Cannot invoke "Object.equals(Object)" because the return value of "org.hibernate.engine.spi.EntityEntry.getVersion()" is null
...
Prevent : Non-repeatable Read
T1이 row를 읽고 난 후, T2도 row를 읽어 수정합니다.
T1도 이어서 수정하고, 둘 다 커밋하는 것이 성공합니다.
왼쪽 상황을 보면 T1과 T2가 brix = 0을 읽고, brix를 업데이트하고 커밋합니다.
두 트랜잭션이 모두 커밋에 성공하므로 데이터가 일관되지 않는다는 문제가 생기죠.
따라서 오른쪽 상황처럼 어느 한 쪽을 실패하도록 해야합니다.
이를 코드로 구현하면,
Transaction 1
val vanilla: Vanilla = entityManager.find(Vanilla::class.java, srl, LockModeType.OPTIMISTIC)
vanilla.show("#1 T1")
delay(2000)
vanilla.brix += 15
entityManager.persist(vanilla)
entityManager.flush()
vanilla.show("#4 T1")
entityManager.transaction.commit()
vanilla.show("#5 T1")
Transaction 2
delay(1000)
val vanilla: Vanilla = entityManager.find(Vanilla::class.java, srl, LockModeType.OPTIMISTIC)
vanilla.show("#1 T2")
vanilla.brix += 23
entityManager.persist(vanilla)
entityManager.flush()
vanilla.show("#3 T2")
delay(2000)
entityManager.transaction.commit()
vanilla.show("#6 T2")
#1 T1 : [ Brix 0 ] [ Version 0 ]
#2 T2 : [ Brix 0 ] [ Version 0 ]
#3 T2 : [ Brix 23 ] [ Version 1 ]
#6 T2 : [ Brix 23 ] [ Version 1 ]
Exception in thread "main" jakarta.persistence.OptimisticLockException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect) : [chapter_2_entity_operation.lock.Vanilla#32]
at org.hibernate.internal.ExceptionConverterImpl.wrapStaleStateException(ExceptionConverterImpl.java:209)
at org.hibernate.internal.ExceptionConverterImpl.convert(ExceptionConverterImpl.java:95)
at org.hibernate.internal.ExceptionConverterImpl.convert(ExceptionConverterImpl.java:167)
...
결과를 보면 T1이 값을 변경해서 업데이트하는 시점에,
이미 다른 트랜잭션이 업데이트했었다고 오류가 발생함을 알 수 있습니다.
버전이 없다면 T1이 커밋을 할 때 오류가 발생합니다.
Transaction 1
val bean: Bean = entityManager.find(Bean::class.java, srl, LockModeType.OPTIMISTIC)
bean.show("#1 T1")
delay(2000)
bean.length += 15
entityManager.persist(bean)
entityManager.flush()
bean.show("#4 T1")
entityManager.transaction.commit()
bean.show("#5 T1")
Transaction 2
delay(1000)
val bean: Bean = entityManager.find(Bean::class.java, srl, LockModeType.OPTIMISTIC)
bean.show("#2 T2")
bean.length += 23
entityManager.persist(bean)
entityManager.flush()
bean.show("#3 T2")
delay(2000)
entityManager.transaction.commit()
bean.show("#6 T2")
#1 T1 : [ Length 0 ]
#2 T2 : [ Length 0 ]
#3 T2 : [ Length 23 ]
#4 T1 : [ Length 15 ]
Exception in thread "main" jakarta.persistence.RollbackException: Error while committing the transaction
at chapter_2_entity_operation.lock.BeanUsage$Companion$p2NonRepeatableRead$2$2.invokeSuspend(Main.kt:156)
...
Caused by: org.hibernate.HibernateException: Unable to perform beforeTransactionCompletion callback: Cannot invoke "Object.equals(Object)" because the return value of "org.hibernate.engine.spi.EntityEntry.getVersion()" is null
...
PESSIMISTIC_READ, PESSIMISTIC_WRITE, PESSIMISTIC_FORCE_INCREMENT
모두 데이터베이스 수준의 잠금을 사용합니다.
이 중 PESSIMISTIC_FORCE_INCREMENT는 나머지 두 개를 포함하여 추가로 버전 컬럼을 업데이트합니다.
PESSIMISTIC_READ의 경우, 이전 OPTIMISTIC과 마찬가지로 dirty read 현상이 방지됩니다.
다만 non-repeatable read의 경우는,
#1 T1 : [ Brix 0 ] [ Version 0 ]
#2 T2 : [ Brix 0 ] [ Version 0 ]
#3 T2 : [ Brix 23 ] [ Version 1 ]
21:29:45.754 [DefaultDispatcher-worker-2] WARN o.h.e.jdbc.spi.SqlExceptionHelper -- SQL Error: 1213, SQLState: 40001
21:29:45.754 [DefaultDispatcher-worker-2] ERROR o.h.e.jdbc.spi.SqlExceptionHelper -- Deadlock found when trying to get lock; try restarting transaction
Exception in thread "main" jakarta.persistence.OptimisticLockException: org.hibernate.exception.LockAcquisitionException: could not execute statement [Deadlock found when trying to get lock; try restarting transaction] [update vanillas set brix=?,version=? where srl=? and version=?]
at org.hibernate.internal.ExceptionConverterImpl.wrapLockException(ExceptionConverterImpl.java:254)
...
둘 다 읽는 건 동시에 가능하나, T2가 업데이트 한 후 T1이 업데이트 하려고 했을 때 deadlock 오류가 발생합니다.
그리고 버전이 없다면, T2가 마지막에 커밋할 시 오류가 발생합니다.
Exception in thread "main" jakarta.persistence.RollbackException: Error while committing the transaction
...
Caused by: org.hibernate.HibernateException: Unable to perform beforeTransactionCompletion callback: Cannot invoke "Object.equals(Object)" because the return value of "org.hibernate.engine.spi.EntityEntry.getVersion()" is null
...
PESSIMISTIC_WRITE의 경우도 이전 OPTIMISTIC과 마찬가지로 dirty read 현상이 방지됩니다.
단, non-repeatable read는 조금 특이한데
#1 T1 : [ Brix 0 ] [ Version 0 ]
#4 T1 : [ Brix 15 ] [ Version 1 ]
#2 T2 : [ Brix 15 ] [ Version 1 ]
#5 T1 : [ Brix 15 ] [ Version 1 ]
#3 T2 : [ Brix 38 ] [ Version 2 ]
#6 T2 : [ Brix 38 ] [ Version 2 ]
T1이 값을 업데이트하고 flush()까지 호출한 뒤에야, T2이 row를 읽을 수 있었고
T1이 커밋한 결과까지 읽어서 T2가 값을 업데이트하고 (0 -> 23이 아닌 15 -> 38) 커밋이 정상적으로 수행됩니다.
버전이 없는 경우 PESSIMISTIC_READ와 마찬가지로 T1이 커밋할 때 오류가 발생합니다.
OptimisticLockException
시작하기 전, flush와 commit에 대해서 짚고 넘어가겠습니다.
일단 flush와 commit은 둘 다 데이터베이스에 변경 사항을 기록하는 건 맞습니다.
단, 기록이 한시적이냐 영구적이냐에 따라 다릅니다.
예를 들어 엔티티 하나를 만들어 flush만 했다고 했을 시
entityManager.transaction.begin()
val vanilla = Vanilla()
entityManager.persist(vanilla)
entityManager.flush()
트랜잭션이 종료되기 전까지는 기록된 변경 사항이 보존되지만, 종료되면 없어집니다.
따라서 영구적으로 보존하고 싶으면 commit을 해야합니다.
entityManager.transaction.begin()
val vanilla = Vanilla()
entityManager.persist(vanilla)
entityManager.transaction.commit()
그렇다면 commit만 쓰면 될 껄, flush는 왜 존재하는 것일까요?
두 가지 큰 장점이 있는데 하나는 롤백(rollback)이고 다른 하나는 메모리 절감입니다.
commit이 성공하면 영구적으로 보존되기 때문에, 해당 데이터는 절대 롤백할 수 없습니다.
즉, 트랜잭션 실행 중간에 실패했더라도 - commit한 내용들은 복구되지 않습니다.
entityManager.transaction.begin()
val vanillaOne = Vanilla()
entityManager.persist(vanillaOne)
entityManager.transaction.commit() // 영구적으로 보존됨
val vanillaTwo = Vanilla()
entityManager.persist(vanillaTwo)
throw RuntimeException()
반면 flush는 롤백이 됩니다.
entityManager.transaction.begin()
val vanillaOne = Vanilla()
entityManager.persist(vanillaOne)
entityManager.flush() // 롤백됨
val vanillaTwo = Vanilla()
entityManager.persist(vanillaTwo)
throw RuntimeException()
entityManager.transaction.commit()
그리고 많은 양의 데이터를 기록하고자 할 때,
한꺼번에 엔티티 인스턴스를 만들어 커밋하고자 한다면 쉽게 메모리 부족 문제를 겪을 수 있지만
flush로 작은 단위로 분할하여 진행하면 이를 피할 수 있습니다.
특히 롤백이 되므로, 한 번 원자적으로 커밋한 것과 동일하죠.
이제 본론으로 돌아와서 낙관적 잠금의 경우 - 읽기 현상으로 인한 문제는 데이터베이스에 변경 사항을 기록하면서 발견됩니다.
그리고 해당 문제에 대한 오류는 OptimisticLockException으로 귀결됩니다.
따라서 만일 트랜잭션 수행 중 해당 오류를 처리하고 싶다면, 중간중간 flush를 사용하는 것을 권장합니다.
OptimisticLockException must be caught or handled by the application, the flush method should be used by the application to force the database writes to occur.
Reference
본 글은 아래 Jakarta Persistence 문서를 참고하여 작성했습니다.
Copyright (c) 2019, 2024 Eclipse Foundation. This document includes material copied from or derived from https://jakartaee.github.io/persistence/latest/draft.html
Read Phenomena와 관련된 내용은 아래 위키를 참조했습니다.
'스프링 (Spring) > 스프링 데이터 (Spring Data)' 카테고리의 다른 글
JPA(Jakarta Persistence API) - Type Conversion (0) | 2024.08.02 |
---|---|
JPA(Jakarta Persistence API) - Entity Listener (0) | 2024.07.31 |
JPA(Jakarta Persistence API) - Entity (0) | 2024.05.22 |
JPA(Jakarta Persistence API) - Overview (0) | 2024.05.19 |
SpringBootTest에 HSQL 데이터베이스 적용기 (0) | 2024.05.03 |