2024. 5. 22. 23:45ㆍ스프링 (Spring)/스프링 데이터 (Spring Data)
Entity
엔티티(entity)란 가벼운 영속성 도메인 객체를 의미합니다.
An entity is a lightweight persistent domain object.
가볍다라는 건, 최소한의 노력과 자원을 써야된다는 말과 같습니다.
Jakarta에서 가장 쉽게 엔티티를 표현할 수 있는 건 그저 클래스(class)입니다.
그리고 영속성(persistent)이라는 건 없어지지 않는 특성을 생각하면 됩니다.
쉽게 이해하면 우리가 파일을 저장했을 때 컴퓨터를 껐다 켜도 파일 내용이 사라지지 않는 것처럼이죠.
바로 뒤에 도메인(domain)이라는 용어를 썼는데,
이는 곧 엔티티를 수단으로써 데이터의 변경 사항을 어딘가에 기록해둔다는 것입니다.
즉 엔티티 자체가 데이터를 영속화하지 않고, 무언가가 엔티티를 통해서 데이터를 영속화한다는 것이죠.
그러므로 완성된 엔티티는 실제 데이터 모델을 온전히 투영할 수 있어야 합니다.
상식적으로 엔티티와 실제 테이블을 비교했을 때 어떤 컬럼이 없거나 더 많거나 하면, 영속화할 수 없겠죠.
Entity Class
엔티티를 만드는 가장 대표적인 방법은 클래스(class)입니다.
엔티티 클래스를 만드는 방법은 아래 모델을 엔티티로 바꾸면서 설명하겠습니다.
1. 먼저 탑 레벨(top-level) 클래스를 생성합니다. (static inner class도 가능합니다)
class Apple
2. 상속 가능하며 파라미터가 없는 생성자가 1개 존재해야 합니다.
이는 런타임 때 해당 클래스를 상속받아 JPA에서 사용할 엔티티 객체를 생성하기 때문입니다.
open class Apple()
3. 모델을 충분히 반영하여 멤버 변수들을 작성합니다. 이때 파라미터가 없는 기본 생성자는 꼭 필요합니다.
import java.time.LocalDateTime
open class Apple(
val srl: Long,
var color: String,
var gram: Int,
var seededAt: LocalDateTime
) {
constructor(): this(
srl = 0L,
color = "<unset>",
gram = 0,
seededAt = LocalDateTime.now(),
)
}
4. 마지막으로 @Entity 어노테이션을 붙여줍니다.
파라미터 없는 생성자를 자동 생성하고 open 키워드를 생략하려면 All-open, No-arg 플러그인을 사용하면 됩니다.
(참고 : https://kotlinlang.org/docs/no-arg-plugin.html#gradle)
import jakarta.persistence.Entity
import java.time.LocalDateTime
@Entity
class Apple(
val srl: Long,
var color: String,
var gram: Int,
var seededAt: LocalDateTime
)
plugins {
id 'java'
id 'org.jetbrains.kotlin.jvm' version "1.9.23"
id 'org.jetbrains.kotlin.plugin.allopen' version "1.9.23"
id 'org.jetbrains.kotlin.plugin.noarg' version "1.9.23"
}
...
allOpen {
annotation('jakarta.persistence.Entity')
annotation('jakarta.persistence.Embeddable')
annotation('jakarta.persistence.MappedSuperclass')
}
noArg {
annotation('jakarta.persistence.Entity')
annotation('jakarta.persistence.Embeddable')
annotation('jakarta.persistence.MappedSuperclass')
}
Field and Property
엔티티의 상태값, 즉 어떤 변수의 값이 변경되었다면 조속히 실제 데이터베이스와 동기화를 해야합니다.
여기서 가장 중요한 건 이 '상태값'이 변경되었냐를 알아야 되고,
이를 가장 쉽게 아는 법은 단순히 해당 객체의 변수를 접근해서 보면 됩니다.
JPA는 접근 방식을 필드 접근(field access)와 프로퍼티 접근(property access)으로 구분해두고 있습니다.
코틀린에 넘어와서는 용어 구분이 모호해서, 확실히 정리하겠습니다.
Field Access
멤버 변수에 직접 접근하는 것을 의미합니다.
어차피 엔티티 클래스의 멤버 변수들은,
private, protected, package visibility 접근 제한자를 가져야 하므로 리플렉션(reflection)을 사용하겠네요.
코틀린에서 리플렉션 예시를 소개하자면,
// build.gradle.kts
dependencies {
implementation(kotlin("reflect"))
}
val apple = Apple()
Apple::class.memberProperties.map {
if (it.name == "color") {
println(it.get(apple))
}
}
와 같습니다.
Property Access
JavaBeans 스타일의 접근 함수를 사용합니다. 쉽게 말해서 getter() 함수죠.
참고로 코틀린은 일반적으로 멤버 변수에 getter 함수를 자동으로 제공하고, 알아서 사용하니 편리합니다.
클래스 상단에 @Access 어노테이션을 붙여서 어떤 방식을 사용할 지 결정할 수 있습니다.
그리고 멤버 변수별로 붙여서 별도로 결정할 수도 있고요.
다만 명시하지 않는다면, 경험적으로는 필드 접근(field access) 방식인 것을 확인했습니다.
import jakarta.persistence.Access
import jakarta.persistence.AccessType
import jakarta.persistence.Entity
import jakarta.persistence.Transient
import java.time.LocalDateTime
@Entity
@Access(value = AccessType.FIELD)
class Apple(
val srl: Long,
@Access(value = AccessType.PROPERTY) // use getter!
var color: String = "red",
var gram: Int = 100,
var seededAt: LocalDateTime = LocalDateTime.now()
)
이 경우 color를 제외한 나머지 멤버 변수들은 리플렉션으로 직접 접근하고, color만 getter()를 통해 접근합니다.
참고로 @Access 어노테이션은 최상위 클래스(superclass)를 따릅니다.
The Access annotation does not affect the access type of other entity classes or mapped superclasses in the entity hierarchy. In particular, persistent state inherited from a superclass is always accessed according to the access type of that superclass.
Entity Identity
모든 엔티티들은 기본 키(primary key)를 가지고 있습니다. 뭐 사실 테이블도 마찬가지이죠.
기본 키로 설정한 멤버 변수는 엔티티를 식별(identify)하는 데 사용하기 때문에 중요합니다.
기본 키가 위치해야 하는 규칙이 존재하는데,
- 최상위(루트) 엔티티 클래스에 있어야 하고
- 엔티티 클래스 계층을 통틀어서 1개만 있어야 합니다.
어떤 멤버 변수를 기본 키로 사용하려면 간단히 @Id 어노테이션을 붙여주면 됩니다.
...
import jakarta.persistence.Id
@Entity
@Access(value = AccessType.FIELD)
class Apple(
@Id
val srl: Long,
...
)
테이블에서 기본 키 자료형으로 주로 bigint나 string을 쓰기에, 이에 맞춰 Long 또는 String으로 만들어주면 됩니다.
다만 필자는 Spring Envers를 통해 복합 기본 키(composite primary key)를 사용할 때가 있는데,
이때는 @EmbeddedId를 사용하면 됩니다.
import jakarta.persistence.Embeddable
@Embeddable
data class AppleHistoryId(
val srl: Long,
val rev: Long
)
...
import jakarta.persistence.EmbeddedId
@Entity
@Access(value = AccessType.FIELD)
class AppleHistory (
@EmbeddedId
val id: AppleHistoryId
...
)
복합 키 클래스의 경우 일반적인 클래스(추상 클래스는 안 됨)여야 하고,
엔티티와 마찬가지로 파라미터가 없는 public 또는 protected 생성자가 필요합니다.
그리고 당연히 엔티티 간의 비교를 위해 equals와 hashCode가 적절히 구현되어 있어야 합니다.
코틀린에서는 편리하게 data class를 사용해도 됩니다.
(참고 : Kotlin - Data Class #Properties declared in the class body)
기본 키를 다른 엔티티에 비롯(derived)되게끔 할 수 있는데,
필자는 굳이 그런식으로 만들것 같진 않을 것 같아서 참고 링크만 달아두겠습니다.
몇 가지 유용한 팁을 서술하자면,
1. @Id는 @GeneratedValue 어노테이션과 함께 씁니다.
GeneratedValue 어노테이션은 기본 키를 어떻게 만들 지 전략을 지정합니다.
자세한 이야기는 후술하는 것으로 하고, 일단 IDENTITY 전략을 쓰도록 합니다. (데이터베이스의 전략을 따름)
...
import jakarta.persistence.Id
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
@Entity
@Access(value = AccessType.FIELD)
class Apple(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val srl: Long,
...
)
2. Id는 not null로 만드는 것이 좋습니다.
null은 곧 없음을 의미합니다. 모든 엔티티들은 Id를 가지고 있으므로 null인 Id는 논리적으로 타당하지 않죠.
특히 코틀린에 와서는, null을 확인해주는 것이 매우 파워풀하므로 이를 활용하는 것이 좋죠.
Id는 몇 가지 타입으로 쓸 수 있는데, 이 중 필자는 Long을 선호합니다.
The field or property to which the Id annotation is applied should be one of the following types: any Java primitive type; any primitive wrapper type; java.lang.String; java.util.UUID; java.util.Date; java.sql.Date; java.math.BigDecimal; _java.math.BigInteger_
다만 아래처럼 쓰면
...
class Apple(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val srl: Long,
...
)
Apple을 생성할 때 srl을 인자로 제공해야 하죠.
val apple = Apple(srl = 1L, ...)
위에 소개한 대로 GenerationType.IDENTITY 전략을 사용하면 자동으로 아이디가 만들어지기 때문에,
굳이 제공할 필요는 없습니다. 따라서 기본값으로 0L을 사용합니다.
...
class Apple(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val srl: Long = 0L,
...
)
hibernate에서 기본값으로 0L이 아닌 다른 값으로 쓰면,
인스턴스를 생성했을 때 신규 엔티티 인스턴스가 아닌 detach된 엔티티 인스턴스로 인식합니다.
이는 나중에 설명할 엔티티 관계(entity relationship)를 구현하는 데 어려움이 있을 수 있습니다.
3. AccessType.FIELD로 접근합니다.
특히 코틀린은 PROPERTY로 접근하면 꽤 어렵습니다.
class Apple(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Access(value = AccessType.PROPERTY) // use getter!
@get:Id
@set:Id
var srl: Long = Long.MIN_VALUE,
...
필자는 value가 아닌 variable로 만들어야 하는 것이 가장 불편하긴 합니다.
따라서 편하게 아래처럼 FIELD로 접근하도록 하면 깔끔합니다.
class Apple(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val srl: Long = Long.MIN_VALUE,
...
마지막으로 너무나 당연한 이야기지만
엔티티가 영속화된 이후, 즉 데이터베이스에 반영된 이후에는 기본 키를 변경하면 안됩니다.
그럼에도 불구하고 변경했다고 한들, 데이터베이스에는 반영되지 않기도 하고요.
If the application does change the value of a primary key of an entity instance after the entity instance is made persistent, the behavior is undefined. [The implementation may, but is not required to, throw an exception. Portable applications must not rely on any such specific behavior.]
Table
엔티티 클래스는 어떤 테이블을 바라볼 지 명시할 수 있습니다.
아래처럼 @Table(name = "테이블 이름") 어노테이션을 엔티티 클래스 상단에 작성하면 됩니다.
...
import jakarta.persistence.Table
...
@Table(name = "apples")
class Apple(
...
Version
엔티티는 버전 정보를 가질 수 있습니다.
이는 낙관적 잠금(optimistic locking; 한글화는 어렵네요)을 먼저 이해하면 좋은데,
원래는 optimistic concurrency control 이라고 부릅니다.
쉽게 이해하면, 여러 개의 트랜잭션이 동시에 실행될 때
- 각 트랜잭션은 본인이 다른 트랜잭션에 영향을 주지 않을 것이라고 생각하는 것입니다.
따라서 처음부터 락(lock)을 얻지 않고,
마지막 커밋(commit) 단계에서 해당 데이터가 처음 읽었던 것과 다른 지 확인합니다.
만일 변경되었다면, 해당 트랜잭션이 진행했던 모든 데이터 변경 사항들을 롤백(roll back)할 수 있습니다.
여기서 '해당 데이터가 처음 읽었던 것과 다른 지 확인'하는 비교 대상이 바로 버전입니다.
엔티티의 상태값이 변경되서 영속화할 때마다 버전이 달라지는 데, 이를 이용한 것이죠.
버전은 한 가지 경우를 제외하고는, 자동으로 바꿔주므로 직접 바꾸면 안됩니다.
이 한 가지는 bulk update(다수의 엔티티를 한 번에 업데이트)인데, 이 경우 필요하다면 직접 다뤄야 합니다.
Bulk update maps directly to a database update operation, bypassing optimistic locking checks. Portable applications must manually update the value of the version column, if desired, and/or manually validate the value of the version column.
마지막으로 버전을 사용하면서 주의할 점은,
- 엔티티 클래스는 1개의 버전 멤버 변수만을 가져야 합니다.
- 최상위 클래스에서 버전 멤버 변수를 이미 가졌다면, 서브 클래스에서 명시하지 않습니다.
Basic Types
코틀린 기준으로 엔티티의 멤버 변수가 가질 수 있는 타입을 소개해보겠습니다.
java.util, java.sql, byte[] and char[]는 강하게 비권장하므로 제외했습니다. (참고)
- 당연하게도 Arrays를 뺀 기본 자료형
- java.util.UUID
- BigIntegr, BigDecimal from java.math
- LocalDate, LocalTime, LocalDateTime, OffsetTime, OffsetDateTime, Instant, Year from java.time
- enum 클래스
- 등 직렬화가 가능한 기타 타입들
Entity Relationships
자연스럽게도 Embeddable 클래스에 대한 Collection은 건너뛰었습니다. (ElementCollection 같은 것...)
데이터 모델 구조를 표현하기는 엔티티 관계가 더 명확하다고 생각하기 때문이죠.
필자는 두 엔티티 간 관계(relationship)란, 함께 짝지었을 때 의미로 이해하고 있습니다.
예를 들어 책과 출판사를 생각해보았을 때, A라는 책은 B 출판사에서 출판(publish)했다고 해봅니다.
이때 (A, B)는 출판이라는 의미를 가지고 있습니다.
E가 엔티티고, R이 관계라면 다음과 같이 표현할 수 있습니다.
필자는 관계를 만드는 것을 주로 맵핑(mapping)이라고 표현합니다.
엄밀히 찾아보니 함수의 다른 이름이었네요. (참고)
A와 B를 서로 맵핑했기 때문에, A 또는 B는 서로를 찾을 수 있습니다.
예를 들어 A 책이 어디서 출판되었는가? 를 물어본다면 B 출판사라고 대답할 수 있어야 하고
B 출판사가 어떤 책들을 출판했는가? 를 물어본다면 정답 속에 A 책이 분명히 존재해야 합니다.
이를 양방향(bidirectional) 관계라고 합니다.
반면 단방향(unidirectional) 관계는 어느 한 쪽만 다른 한 쪽을 찾을 수 있는 것을 의미합니다.
다만 - 필자는 단방향 관계를 지양하고 있는데, 논리적으로 관계를 제대로 정의할 수 없을 뿐더러
나중에 소개하겠지만 엔티티의 변경 사항을 제대로 반영하지 못하는 문제가 있습니다.
따라서 필자는 보통 양방향 맵핑만을 사용합니다.
다만 정말 드물게 단방향 맵핑을 사용하는 경우도 있는데, 이때는 다음 체크리스트를 모두 통과해야 되었습니다.
- 두 엔티티 중 어느 한 쪽만 새로 추가되었는가?
- 기존 엔티티 클래스 또는 테이블을 변경했을 때 성능에 크게 영향을 주는가?
- 기존 엔티티 클래스 또는 테이블을 변경했을 때 코드를 수정하면 문제가 생길 수 있는가?
- 어떤 한 엔티티의 변경 사항을 다른 엔티티에 영향을 주지 않아도 되는가? 또는 주더라도 잘 대처할 수 있는가?
실무에서도 단방향 맵핑을 사용한 적이 있는데,
비용 문제로 서로 다른 도메인 프로젝트들을 1개 서버에 운용했을 때였네요.
용어들을 유의어와 함께 잘 정리해서 검색을 지원했던 A라는 프로젝트가 있었는데,
해당 용어 정보를 응용해서 비즈니스를 지원한 전혀 다른 프로젝트 B를 개발한 일이었습니다.
당시 A 프로젝트에 어떤 종속 프로젝트가 오더라도 절대 영향을 주면 안된다는 방침을 세웠기에,
엔티티 측면에서도 거대한 경계가 필요했었습니다.
굳이 양방향 맵핑을 쓸 경우, A라는 테이블에는 외부키를 둘 수 없으므로 필연적으로 fetch-join을 써야만 했기에
성능에 영향을 줄 수 있었을 뿐더러, 기존 코드를 수정하는 것도 난감했었죠.
따라서 A가 B로 맵핑되는 부분을 모두 삭제한, 단방향 관계로 개발했었습니다.
양방향 관계를 만들기 위한 규칙은 다음과 같습니다.
- 한 엔티티는 다른 엔티티를 찾을 수 있는 외부 키(foreign key)를 가지고 있어야 합니다.
- 해당 외부 키는 @JoinColumn 또는 @JoinTable로 명시되어야 합니다.
- 외부 키를 가진 쪽이 관계의 주인(owner of relationship)이 됩니다.
- 관계의 주인이 아닌 엔티티는 주인의 어떤 필드에 맵핑되었는 지 명시해야 됩니다.
Entity Mapping
JPA에서 서로 다른 두 엔티티를 맵핑하는 방법은 다음 4가지가 있습니다.
- OneToOne
- OneToMany
- ManyToOne
- ManyToMany
일단 다대다(many-to-many) 맵핑은 지양해왔으므로 (이유는 향후 글에서 다룰 예정)
다음 OneToOne, OneToMany, ManyToOne 3개에 대해서만 살펴보겠습니다.
OneToOne
아래처럼 사과 테이블(apples)에 영양 정보가 필요해서 영양 테이블(nutritions)을 일대일로 맵핑했다고 해봅니다.
일대일 맵핑을 통해 양방향 관계를 가지려면 아래처럼 @OneToOne을 만들어주면 됩니다.
...
class Apple(
...
@OneToOne
@JoinColumn(name = "nutritionSrl")
var nutrition: Nutrition
)
...
class Nutrition(
...
) {
@OneToOne(mappedBy = "nutrition")
lateinit var apple: Apple
}
하나씩 살펴보면,
- 사과가 영양을 찾을 수 있는 키인 nutritionSrl 컬럼을 가지고 있습니다.
- nutritionSrl를 @JoinColumn으로 명시했습니다.
- 따라서 사과는 관계의 주인이 됩니다.
- 영양은 mappedBy를 통해 사과의 nutrition 필드에 맵핑되었음을 명시했습니다.
그리고 persist 예시는 다음과 같습니다.
val nutrition = Nutrition()
entityManager.persist(nutrition)
val apple = Apple(nutrition = nutrition)
apple.nutrition = nutrition
entityManager.persist(apple)
entityManager.transaction.commit()
이때 호출 순서도 중요한데 - 만일 apple을 먼저 persist() 할 경우,
아래와 같이 nutritionSrl이 null 일 수 없다는 오류 메시지가 등장합니다.
22:02:34.297 [main] ERROR o.h.e.jdbc.spi.SqlExceptionHelper -- Column 'nutritionSrl' cannot be null
Exception in thread "main" org.hibernate.exception.ConstraintViolationException: could not execute statement [Column 'nutritionSrl' cannot be null] [insert into apples (color,gram,nutritionSrl,seededAt,treeSrl) values (?,?,?,?,?)]
at org.hibernate.exception.internal.SQLExceptionTypeDelegate.convert(SQLExceptionTypeDelegate.java:62)
...
팁으로 일대일 맵핑에서 관계의 주인 반대인 Nutrition 클래스를 보면
apple이 생성자에 없고 중괄호 내부에 lateinit var 로 있는 걸 확인할 수 있습니다.
이에 대해 생각해볼만한 다른 옵션을 말해보면,
1. Apple 쪽을 nullable로 바꿔주고 Nutrition 쪽을 생성자로 편입시키기
...
class Apple(
...
@OneToOne
@JoinColumn(name = "nutritionSrl")
var nutrition: Nutrition? = null
)
...
class Nutrition(
...
@OneToOne(mappedBy = "nutrition")
val apple: Apple
)
val apple = Apple()
val nutrition = Nutrition(apple = apple)
apple.nutrition = nutrition
entityManager.persist(nutrition)
entityManager.persist(apple)
entityManager.transaction.commit()
이 방식이 가진 문제는, 사과의 nutritionSrl이 not null 일 경우 코드와 데이터 모델 간 차이가 발생하다는 점입니다.
즉, nutrition 프로퍼티가 nullable 하기 때문에, 읽을 때 null 인지 확인해야하죠.
2. Nutrition 쪽만 생성자로 편입시키면서 nullable 시키기
...
class Apple(
...
@OneToOne
@JoinColumn(name = "nutritionSrl")
var nutrition: Nutrition
)
...
class Nutrition(
...
@OneToOne(mappedBy = "nutrition")
val apple: Apple? = null
)
val nutrition = Nutrition()
val apple = Apple(tree = tree, nutrition = nutrition)
apple.nutrition = nutrition
entityManager.persist(nutrition)
entityManager.persist(apple)
entityManager.transaction.commit()
이 경우도 영양 입장에서 apple을 사용할 때 null 인지 확인해야 하므로 데이터 모델을 반영하지 못합니다.
OneToMany, ManyToOne
아래처럼 사과 테이블(apples)에 어떤 나무로부터 나왔는 지 알기 위해 나무 테이블(trees)을 맵핑했다고 해봅니다.
나무와 사과는 일대다로 맵핑되었습니다. (= 사과와 나무는 다대일로 맵핑)
한 개 나무는 여러 개 사과를 맺을 수 있기 때문이죠.
일대다, 다대일 맵핑을 통해 양방향 관계를 가지려면 아래처럼 @OneToMany, @ManyToOne을 만들어주면 됩니다.
...
class Tree(
...
@OneToMany(mappedBy = "tree")
val apples: MutableCollection<Apple> = mutableListOf()
)
class Apple(
...
@ManyToOne
@JoinColumn(name = "treeSrl")
var tree: Tree
)
하나씩 살펴보면,
- 사과가 나무를 찾을 수 있는 키인 treeSrl 컬럼을 가지고 있습니다.
- treeSrl를 @JoinColumn으로 명시했습니다.
- 따라서 사과는 관계의 주인이 됩니다.
- 나무는 mappedBy를 통해 사과의 tree 필드에 맵핑되었음을 명시했습니다.
팁으로 두 맵핑 모두 생성자 프로퍼티로 만드는 것이 좋습니다.
일대다(one-to-many) 맵핑에서는 null 대신 빈 컬렉션으로 만들기 때문에 일대일 맵핑 때처럼 null 문제는 없습니다.
그리고 사과들을 읽기 전용으로 만들고 싶다면 아래처럼 value + collection 조합으로 만듭니다.
class Tree(
...
@OneToMany(mappedBy = "tree")
val apples: Collection<Apple> = listOf()
)
이도저도 아니게 variable + collection이면 아래와 같이 에러메시지를 볼 수 있습니다.
Hibernate에서는 Collection, MutableCollection과 같이
순서(order)가 없고, 중복 제거를 하지 않아도 되는 걸 bag이라고 표현합니다.
BAG - A collection that may contain duplicate entries and has no defined ordering.
반대로 순서가 있지만 중복 제거를 안해도 되는 건 list, 순서가 없지만 중복 제거해야 되는 건 set이라고 합니다.
각 타입이 가지는 특징은 후술하기로 하고, 일단 bag을 사용하는 것을 추천합니다.
Inheritance
엔티티 클래스는 다른 엔티티 클래스를 상속받을 수 있습니다.
당연히 상속 받은 엔티티 클래스의 객체는, 상위 클래스의 멤버를 접근하고 사용할 수 있죠.
필자는 보통 중복으로 작성한 공통 멤버 변수들을 제거하는 데 사용합니다.
아래 다이어그램을 보면 모든 테이블이 srl을 가지고 있는데,
아래처럼 기본 엔티티를 만들어놓고
@MappedSuperclass
abstract class BaseEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val srl: Long = 0L
)
각 엔티티에 상속시켜놓으면 srl을 명시 안해도 됩니다.
...
class Tree(
// srl은 상위 클래스에 위치됨
var age: Int = 0,
var height: Int = 0,
@OneToMany(mappedBy = "tree")
val apples: MutableCollection<Apple> = mutableListOf()
) :
BaseEntity()
이 방법은 createAt, createdBy, ... 와 같이 테이블 스키마 컨벤션을 맞추는데 대단히 편리합니다.
그 밖에 상속을 다른 용도로 사용할 수 있습니다.
Non-entity superclass
일전에 보여준 예시는 상위 클래스에 있는 멤버 변수까지 모두 영속화되는데,
오직 하위 클래스만 영속화 하고 싶다면 @MappedSuperclass를 제거해주면 됩니다.
The non-entity superclass serves for inheritance of behavior only. The state of a non-entity superclass is not persistent. Any state inherited from non-entity superclasses is non-persistent in an inheriting entity class. This non-persistent state is not managed by the entity manager
Abstract Entity class
@Inheritance 어노테이션을 사용한 3가지 상속 전략 중 JOINED가 해당됩니다.
아래 예시로 복숭아와 그 크기를 가리키는 두 테이블이 있습니다.
서로 기본 키(primary key)로 참조하고 있죠.
이를 상속을 통해 구현해보면,
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Inheritance
import jakarta.persistence.InheritanceType
import jakarta.persistence.Table
@Entity
@Table(name = "peach_sizes")
@Inheritance(strategy = InheritanceType.JOINED)
abstract class PeachSize(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val srl: Long = 0L,
var width: Int = 0,
var height: Int = 0
)
import jakarta.persistence.Entity
import jakarta.persistence.PrimaryKeyJoinColumn
import jakarta.persistence.Table
@Entity
@Table(name = "peaches")
@PrimaryKeyJoinColumn(name = "srl")
class Peach(
var color: String = "pink"
) :
PeachSize()
이때 Peach의 srl은 외부 키로써 PeachSize의 기본 키를 그대로 사용합니다.
CRUD에 대해 실행되는 쿼리를 살펴보려면 아래를 펼쳐주세요.
[Create]
val peach = Peach()
entityManager.persist(peach)
[Hibernate]
insert
into
peach_sizes
(height, width)
values
(?, ?)
[Hibernate]
insert
into
peaches
(color, srl)
values
(?, ?)
[Read]
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=?
[Update]
peach.color = "green"
entityManager.transaction.commit()
[Hibernate]
update
peaches
set
color=?
where
srl=?
peach.width = 123
entityManager.transaction.commit()
[Hibernate]
update
peach_sizes
set
height=?,
width=?
where
srl=?
[Delete]
val peach = entityManager.find(Peach::class.java, 1)
entityManager.remove(peach)
entityManager.transaction.commit()
[Hibernate]
delete
from
peaches
where
srl=?
[Hibernate]
delete
from
peach_sizes
where
srl=?
쿼리를 보면 볼 수 있듯, 필연적으로 이 전략을 사용하면 두 테이블 간 join문을 발생시킵니다.
그리고 덤으로 생성, 삭제 시 두 개 쿼리가 만들어집니다.
Single Table per Class
아래는 바나나 테이블입니다.
다이어그램에서 볼 수 있듯, 서로 다른 3가지 엔티티가 한 개 테이블에서 서로 다른 컬럼만을 사용하려고 합니다.
구분하는 건 speciesType 컬럼으로 하고, 공통 컬럼으로는 srl과 ripened를 사용하고요.
이럴 땐 다음과 같이 클래스를 상속해서 만들 수 있습니다.
import jakarta.persistence.DiscriminatorColumn
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Inheritance
import jakarta.persistence.InheritanceType
import jakarta.persistence.Table
@Entity
@Table(name = "bananas")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "speciesType")
class Banana(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val srl: Long = 0L,
var ripened: Boolean = false
)
import jakarta.persistence.DiscriminatorValue
import jakarta.persistence.Entity
@Entity
@DiscriminatorValue(value = "weight")
class WeightBanana(
var weight: Int = 10
) :
Banana()
import jakarta.persistence.DiscriminatorValue
import jakarta.persistence.Entity
@Entity
@DiscriminatorValue(value = "height")
class HeightBanana(
var height: Int = 20
) :
Banana()
@Entity
@DiscriminatorValue(value = "color")
class ColorBanana(
var height: Int = 30,
var color: String = "red"
) :
Banana()
CRUD에 대해 실행되는 쿼리를 살펴보려면 아래를 펼쳐주세요.
[Create]
val weightBanana = WeightBanana()
entityManager.persist(weightBanana)
val heightBanana = HeightBanana()
entityManager.persist(heightBanana)
val colorBanana = ColorBanana()
entityManager.persist(colorBanana)
entityManager.transaction.commit()
[Hibernate]
insert
into
bananas
(ripened, weight, speciesType)
values
(?, ?, 'weight')
[Hibernate]
insert
into
bananas
(ripened, height, speciesType)
values
(?, ?, 'height')
[Hibernate]
insert
into
bananas
(ripened, color, height, speciesType)
values
(?, ?, ?, 'color')
[Read]
entityManager.find(WeightBanana::class.java, 1)
[Hibernate]
select
wb1_0.srl,
wb1_0.ripened,
wb1_0.weight
from
bananas wb1_0
where
wb1_0.speciesType='weight'
and wb1_0.srl=?
[Update]
weightBanana.weight = 100
weightBanana.ripened = true
entityManager.transaction.commit()
[Hibernate]
update
bananas
set
ripened=?,
weight=?
where
srl=?
[Save]
entityManager.remove(weightBanana)
entityManager.transaction.commit()
[Hibernate]
delete
from
bananas
where
srl=?
이 전략은 구분(discriminator)을 위한 컬럼이 추가로 필요하고,
또 공통 컬럼을 제외한 나머지는 모두 nullable해야 됩니다.
Table per Concreate Class
아래는 베리 테이블과 해당 스키마를 베낀 두 테이블입니다.
이럴 땐 다음과 같이 클래스를 상속해서 만들 수 있습니다.
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Inheritance
import jakarta.persistence.InheritanceType
import jakarta.persistence.Table
@Entity
@Table(name = "berries")
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
class Berry(
@Id
val srl: Long = 0L,
var ripened: Boolean = false
)
import jakarta.persistence.Entity
import jakarta.persistence.Table
@Entity
@Table(name = "financed_berries")
class FinancedBerry(
var price: Int = 0,
var exchangeRate: Double = 1.0,
override val srl: Long
) :
Berry()
import jakarta.persistence.Entity
import jakarta.persistence.Table
@Entity
@Table(name = "owned_berries")
class OwnedBerry(
var name: String = "<unset>",
var phoneNumber: Int = 12345678,
override val srl: Long
):
Berry()
CRUD에 대해 실행되는 쿼리를 살펴보려면 아래를 펼쳐주세요.
[Create]
val financedBerry = FinancedBerry(srl = 3L)
entityManager.persist(financedBerry)
val ownedBerry = OwnedBerry(srl = 2L)
entityManager.persist(ownedBerry)
val berry = Berry(srl = 1L)
entityManager.persist(berry)
entityManager.transaction.commit()
[Hibernate]
insert
into
financed_berries
(ripened, exchangeRate, price, srl)
values
(?, ?, ?, ?)
[Hibernate]
insert
into
owned_berries
(ripened, name, phoneNumber, srl)
values
(?, ?, ?, ?)
[Hibernate]
insert
into
berries
(ripened, srl)
values
(?, ?)
[Read]
entityManager.find(Berry::class.java, 1)
[Hibernate]
select
b1_0.srl,
b1_0.clazz_,
b1_0.ripened,
b1_0.exchangeRate,
b1_0.price,
b1_0.name,
b1_0.phoneNumber
from
(select
srl,
ripened,
null as exchangeRate,
null as price,
null as name,
null as phoneNumber,
0 as clazz_
from
berries
union
all select
srl,
ripened,
exchangeRate,
price,
null as name,
null as phoneNumber,
1 as clazz_
from
financed_berries
union
all select
srl,
ripened,
null as exchangeRate,
null as price,
name,
phoneNumber,
2 as clazz_
from
owned_berries
) b1_0
where
b1_0.srl=?
[Update]
berry.ripened = true
entityManager.transaction.commit()
[Hibernate]
update
berries
set
ripened=?
where
srl=?
[Delete]
entityManager.remove(berry)
[Hibernate]
delete
from
berries
where
srl=?
가장 큰 2가지 특징으로는
- @Id를 만드는 전략 중 @GeneratedValue(strategy = GenerationType.IDENTITY)를 사용하지 못하는 것과
- 상위 엔티티를 조회할 때, UNION 쿼리가 발생한다는 점입니다.
그리고 공통 컬럼을 제외한 나머지는 모두 nullable해야 됩니다.
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
Entity Mapping과 관련된 내용은 Java Persistence with Hibernate 도서를 일부 참고해서 작성했습니다.
그리고 Hibernate의 용어(semantic)와 관련된 인용문은 아래 문서에서 가져왔습니다.
'스프링 (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 Operation (1) | 2024.06.06 |
JPA(Jakarta Persistence API) - Overview (0) | 2024.05.19 |
SpringBootTest에 HSQL 데이터베이스 적용기 (0) | 2024.05.03 |