2024.10.30 업데이트
이제 상속관계를 매핑하는 법도 알아보자. 왜냐하면 객체는 상속을 받을 수 있으니까.
우선은 관계형 데이터베이스는 상속 관계가 없다. 대신 슈퍼타입 서브타입 관계라는 모델링 기법이 있고 그 기법이 객체 상속과 유사하다.
그래서, 상속관계 매핑을 객체의 상속 구조와 DB의 슈퍼타입 서브타입 관계를 매핑해 보는 것이 주제다.
위 그림에서 ITEM이라는 테이블이 있고 그 테이블이 가지는 컬럼을 모두 동일하게 가지는 3개의 테이블 Album, Movie, Book이 있다고 가정해 보자. 이 경우 데이터베이스에서는 3가지 전략으로 테이블을 구성할 수 있다.
- 조인 전략
- 단일 테이블 전략
- 구현 클래스마다 각 테이블 전략
조인 전략
조인 전략은 상위 테이블인 ITEM의 기본키를 각 테이블이 기본키이자 외래키로 가지는 방법이다. 다음 그림이 이를 설명한다.
ITEM 테이블에서 공통으로 사용되는 NAME, PRICE를 가지고 있으며 각 테이블마다 개인적으로 필요한 데이터는 각 테이블이 관리하는 방법이다. 그리고 이 때, ITEM 테이블에서는 어떤 테이블과 연결된 데이터인지를 구분 짓기 위해 DTYPE이라는 필드가 추가된다. 데이터를 가장 정교화된 방식으로 모델링을 깔끔하게 한 모델이다.
객체로 이를 구현해보면 다음과 같다.
Item
package org.example.entity.inheritance;
import javax.persistence.*;
@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn
public abstract class Item {
@Id @GeneratedValue
@Column(name = "ITEM_ID")
private Long id;
private String name;
private int price;
public Item() {}
public Item(String name, int price) {
this.name = name;
this.price = price;
}
}
가장 최상위인 아이템 클래스는 살펴볼 점이 두 가지가 있다.
- 첫 번째, @Inheritance는 이 클래스를 상속받을 클래스들과 어떤식으로 테이블이 구성될지를 알려주는 어노테이션이다. 이 전략의 기본값은 SINGLE_TABLE인데 이는 위에서 말한 단일 테이블 전략을 말한다. 그래서 JOINED로 변경했다.
- 두 번째, @DiscriminatorColumn은 위에서 표기한 DTYPE을 칼럼으로 추가한다는 어노테이션이다. 기본 칼럼명은 DTYPE이다.
참고로, 현재 시점(2024.11.01)에서는 JOIN 전략일 때, @DiscriminatiorColumn 애노테이션을 사용하지 않아도 된다. 아니 오히려 사용하면 WARNING 로깅이 찍힌다. 예전 방식이라고. 사실 JOIN 전략일땐 이 DTYPE이 필요가 없다. 어차피 조인해서 값을 가져올 수 있기 때문에. 그런데, SINGLE_TABLE에서는 반드시 필요하다. 당연한게 모든 필드를 하나의 테이블에 넣어서 관리하면 각 레코드가 어떤 데이터인지 DTYPE 말고는 구분할 방법이 없기에.
Album
package org.example.entity.inheritance;
import javax.persistence.DiscriminatorValue;
import javax.persistence.Entity;
@Entity
@DiscriminatorValue("Album")
public class Album extends Item {
private String artist;
public Album(String name, int price, String artist) {
super(name, price);
this.artist = artist;
}
public Album() {
super();
}
}
- Item 클래스를 상속받을 Album이다. 이 클래스는 @ID가 필요없다. 왜냐하면 Item의 Id를 PK이자 FK로 설정할 것.
- @DiscriminatorValue("Album")은 Item의 DTYPE값으로 들어갈 Value를 지정하는 것. 기본값은 엔티티명이 된다. 즉, 위 예시에서 굳이 값을 입력할 필요는 없었지만 저렇게 변경할 수 있다는 것을 기억하기 위해 "Album"을 입력했다.
Book
package org.example.entity.inheritance;
import javax.persistence.DiscriminatorValue;
import javax.persistence.Entity;
@Entity
@DiscriminatorValue("Book")
public class Book extends Item {
private String author;
private String isbn;
public Book() {
super();
}
public Book(String name, int price, String author, String isbn) {
super(name, price);
this.author = author;
this.isbn = isbn;
}
}
Movie
package org.example.entity.inheritance;
import javax.persistence.DiscriminatorValue;
import javax.persistence.Entity;
@Entity
@DiscriminatorValue("Movie")
public class Movie extends Item {
private String director;
private String actor;
public Movie() {
super();
}
public Movie(String name, int price, String director, String actor) {
super(name, price);
this.director = director;
this.actor = actor;
}
}
- Book과 Movie는 Album과 맥락이 동일하다. 각 테이블에서 필요한 필드값만 다를뿐이다.
Main
package org.example;
import org.example.entity.inheritance.Movie;
import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
public class Main {
public static void main(String[] args) {
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
Movie movie = new Movie("반지의 제왕", 50000, "감독", "배우");
em.persist(movie);
tx.commit();
} catch (Exception e) {
System.out.println(e.getMessage());
tx.rollback();
} finally {
em.close();
emf.close();
}
}
}
실행 결과
JOINED 전략에서 조회
Movie 데이터를 DB로부터 가져올 때 JPA는 당연하게도 조인을 사용한다. 이 방식 자체가 조인 전략이니까.
package org.example;
import org.example.entity.inheritance.Movie;
import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
public class Main {
public static void main(String[] args) {
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
Movie movie = new Movie("반지의 제왕", 50000, "감독", "배우");
em.persist(movie);
em.flush();
em.clear();
Movie findMovie = em.find(Movie.class, movie.getId());
System.out.println("findMovie = " + findMovie.getDirector());
tx.commit();
} catch (Exception e) {
System.out.println(e.getMessage());
tx.rollback();
} finally {
em.close();
emf.close();
}
}
}
실행 로그 및 SQL문:
Hibernate:
call next value for hibernate_sequence
Hibernate:
insert
into
Item
(name, price, DTYPE, ITEM_ID)
values
(?, ?, 'Movie', ?)
Hibernate:
insert
into
Movie
(actor, director, ITEM_ID)
values
(?, ?, ?)
Hibernate:
select
movie0_.ITEM_ID as item_id2_3_0_,
movie0_1_.name as name3_3_0_,
movie0_1_.price as price4_3_0_,
movie0_.actor as actor1_6_0_,
movie0_.director as director2_6_0_
from
Movie movie0_
inner join
Item movie0_1_
on movie0_.ITEM_ID=movie0_1_.ITEM_ID
where
movie0_.ITEM_ID=?
findMovie = 감독
보면 알겠지만, INSERT문이 두 번 들어가고 조회할 땐 조인을 해서 가져오기 때문에 아무래도 단일 테이블 전략에 비해 성능이 저하될 수 있다. 근데 사실 뭐 INSERT문 두번 한다고 해서 성능에 크게 영향을 주거나 그러지는 않는다.
단일 테이블 전략
단일 테이블 전략은 하나의 테이블에서 모든 데이터를 다 집어넣으면 된다. 다른 게 없다. 성능적인 이점을 가져갈 순 있다. 조인도 필요 없고 INSERT문도 한 번만 하면 되니까. 그 대신 NULL값이 지저분할 순 있겠지.
우선 그림으로 살펴보면 다음과 같다.
정말 한 테이블에 다 넣은 것. 얘는 조인 테이블과 달리 반드시 DTYPE 칼럼이 들어가야 한다. 조인 테이블은 DTYPE 컬럼이 없어도 가능하지만 이 테이블은 그렇게 되면 안 된다. 그래서, JPA가 @DiscriminatorColumn 어노테이션을 붙이지 않더라도 알아서 SINGLE_TABLE인 경우에 DTYPE을 추가해 준다. 반드시 필요한 데이터니까.
이를 코드상으로 구현해 보면 딱 하나만 변경해 주면 된다.
Item
package org.example.entity.inheritance;
import javax.persistence.*;
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE) // 이 부분
@DiscriminatorColumn
public abstract class Item {
@Id @GeneratedValue
@Column(name = "ITEM_ID")
private Long id;
private String name;
private int price;
public Item() {}
public Item(String name, int price) {
this.name = name;
this.price = price;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public int getPrice() {
return price;
}
}
- @Inheritance의 전략을 SINGLE_TABLE로 변경하면 된다.
실행 결과
Hibernate:
call next value for hibernate_sequence
Hibernate:
insert
into
Item
(name, price, actor, director, DTYPE, ITEM_ID)
values
(?, ?, ?, ?, 'Movie', ?)
Hibernate:
select
movie0_.ITEM_ID as item_id2_1_0_,
movie0_.name as name3_1_0_,
movie0_.price as price4_1_0_,
movie0_.actor as actor8_1_0_,
movie0_.director as director9_1_0_
from
Item movie0_
where
movie0_.ITEM_ID=?
and movie0_.DTYPE='Movie'
findMovie = 감독
INSERT문 한 번에 조회 시에도 조인이 필요 없어진다.
구현 클래스마다 테이블 전략
아이템 테이블을 아예 없애고, 아이템 테이블이 가지고 있는 필드들을 각 테이블 (Movie, Book, Album)이 모두 가지는 경우를 말한다.
그림으로 보면 다음과 같다.
각 테이블이 모두 NAME, PRICE라는 칼럼을 동일하게 가지고 있고, PK를 ITEM_ID로 가지고 있는 그림이다.
이 경우도 마찬가지로 딱 한 부분만 변경하면 된다.
Item
package org.example.entity.inheritance;
import javax.persistence.*;
@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
public abstract class Item {
@Id @GeneratedValue
@Column(name = "ITEM_ID")
private Long id;
private String name;
private int price;
public Item() {}
public Item(String name, int price) {
this.name = name;
this.price = price;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public int getPrice() {
return price;
}
}
- @Inheritance 어노테이션의 전략을 TABLE_PER_CLASS로 전략을 변경하고 실행해 보자. ITEM 테이블은 만들어지지 않는다.
실행 결과
Hibernate:
create table Album (
ITEM_ID bigint not null,
name varchar(255),
price integer not null,
artist varchar(255),
primary key (ITEM_ID)
)
Hibernate:
create table Book (
ITEM_ID bigint not null,
name varchar(255),
price integer not null,
author varchar(255),
isbn varchar(255),
primary key (ITEM_ID)
)
Hibernate:
create table Movie (
ITEM_ID bigint not null,
name varchar(255),
price integer not null,
actor varchar(255),
director varchar(255),
primary key (ITEM_ID)
)
Hibernate:
insert
into
Movie
(name, price, actor, director, ITEM_ID)
values
(?, ?, ?, ?, ?)
Hibernate:
select
movie0_.ITEM_ID as item_id1_3_0_,
movie0_.name as name2_3_0_,
movie0_.price as price3_3_0_,
movie0_.actor as actor1_6_0_,
movie0_.director as director2_6_0_
from
Movie movie0_
where
movie0_.ITEM_ID=?
findMovie = 감독
근데, 결론은 이 구현 클래스마다 테이블 전략은 사용하지 말자. 왜냐하면 비효율적이다. 우선 같은 칼럼이 다 테이블마다 들어가는 것도 그렇지만 가장 큰 문제는 ITEM으로 조회했을 때 일어나는 현상 때문이다.
다음 코드를 보자. Item이 부모인데 당연히 Item으로 조회가 가능할 거고, 그때 일어나는 현상을 Hibernate가 찍어주는 로그로 확인해 보자.
package org.example;
import org.example.entity.inheritance.Item;
import org.example.entity.inheritance.Movie;
import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
public class Main {
public static void main(String[] args) {
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
Movie movie = new Movie("반지의 제왕", 50000, "감독", "배우");
em.persist(movie);
em.flush();
em.clear();
Item item = em.find(Item.class, movie.getId());
System.out.println("Movie item = " + item.getName());
tx.commit();
} catch (Exception e) {
System.out.println(e.getMessage());
tx.rollback();
} finally {
em.close();
emf.close();
}
}
}
실행 결과
Hibernate:
insert
into
Movie
(name, price, actor, director, ITEM_ID)
values
(?, ?, ?, ?, ?)
Hibernate:
select
item0_.ITEM_ID as item_id1_3_0_,
item0_.name as name2_3_0_,
item0_.price as price3_3_0_,
item0_.author as author1_1_0_,
item0_.isbn as isbn2_1_0_,
item0_.artist as artist1_0_0_,
item0_.actor as actor1_6_0_,
item0_.director as director2_6_0_,
item0_.clazz_ as clazz_0_
from
( select
ITEM_ID,
name,
price,
author,
isbn,
null as artist,
null as actor,
null as director,
1 as clazz_
from
Book
union
all select
ITEM_ID,
name,
price,
null as author,
null as isbn,
artist,
null as actor,
null as director,
2 as clazz_
from
Album
union
all select
ITEM_ID,
name,
price,
null as author,
null as isbn,
null as artist,
actor,
director,
3 as clazz_
from
Movie
) item0_
where
item0_.ITEM_ID=?
Movie item = 반지의 제왕
조회 시 Album, Book, Movie 이 세 가지를 다 UNION으로 묶어서 조회하는 끔찍한 일이 일어난다. 그러니까 결론은 쓰지 말자.
마무리
그럼 조인 전략과 단일 테이블 전략 둘 중 사용하면 되는데, 각 장단점이 있다. 그때그때 필요한 더 적합한 방식을 사용하면 되는데 우선 조인 전략이 정규화된, 정석적인 방법이란 걸 알아 두자. 그러니까 기본은 조인 전략을 사용한다는 것을 전제하에 두고 시작하면 된다.
조인 전략
장점
- 테이블 정규화
- 정석적인 방식
- 객체와 상호보완적
- 무결성 보존의 장점 (단일 테이블과 비교해서 NULL값이 들어가지 않는다)
단점
- 조회 시 조인을 사용하니까 단일 테이블 전략에 비해 성능 저하 가능성
- 생성 시 INSERT문이 두 번 사용되니까 단일 테이블 전략에 비해 성능 저하 가능성
단일 테이블 전략
장점
- 간단함
- 조회 및 생성 시 조인을 사용하지 않고 INSERT문이 한 번으로 끝난다.
단점
- 불필요한 칼럼들에 대한 NULL값이 생긴다. (Movie 관련 데이터를 추가하면 Album, Book 관련 데이터는 모두 NULL)
'JPA(Java Persistence API)' 카테고리의 다른 글
[JPA] Part 10. 프록시 (0) | 2023.10.22 |
---|---|
[JPA] Part 9. @MappedSuperclass (0) | 2023.10.22 |
[JPA] Part 7. 다대일, 일대다, 일대일, 다대다 (0) | 2023.10.19 |
[JPA] Part 6. 객체 지향형 모델링, 단방향 양방향 연관관계 주인 (0) | 2023.10.18 |
[JPA] Part 5. 객체와 테이블 매핑 (4) | 2023.10.17 |