Entity Inheritance in Hibernate - (1) MappedSuperClass 전략
ORM(Object-Relational Mapping), JPA(Java Persistence API), Hibernate
간단하게 3개에 대해 언급하자면...
- ORM : Class와 DB Table Mapping. 즉, 하나의 Class를 DB의 Table로 Mapping 해서 Code로 DB Table을 관리하고 Query할 수 있게 하는 기술이다.
- JPA : Java에서 ORM 기술을 표준화한 API 명세이다.
- Hibernate : JPA에서 정의한 API명세를 구현한 구현체이다. (Hibernate외에도 몇 가지 구현체가 더 있지만 최근에는 Hibernate가 defacto standard가 됨.)
Inheritence in JPA
Java의 Class가 가지는 특징 중에 하나는 '상속(Inheritance)'이다. 이런 Java의 상속 개념은 JPA/Hibernate에서 어떻게 적용되서 어떤 모양으로 Table에 Mapping될까? 예를 들어 <pic 1>과 같은 상속 관계를 가지는 5개의 Entity Class들이 있다고 가정해보자.
JPA에서는 이런 Entity Class 사이에 정의된 Inheritance(상속)에 대해 MappedSuperclass 전략, Single Table 전략, Table per Class 전략, Join 전략 등 4가지 방법을 제공하고 있다. 4가지 방법 중 이 포스팅에서는 MappedSuperClass 전략에 대해 다뤄보려고 한다.
Class 구현
MappedSuperClass전략은 부모 Class인 abstract class를 제외한 모든 Child Entity Class들과 Mapping되는 Table들이 생성되는 방식이다. 이 전략은 <code 1>과 같이 부모 abstract class 상단에 @MappedSuperClass annotation을 추가하면 구현이 끝나게 된다.
@MappedSuperclass
public abstract class Product {
@Id
@GeneratedValue(strategy=GenerationType.AUTO)
private long id;
String name;
int price;
String manufacturer;
}
...
@MappedSuperclass
public abstract class Beverage extends Product {
int volume;
@Enumerated(EnumType.STRING)
private BeverageType type;
public enum BeverageType {
Beer,
Wine,
Soju,
Soda,
Juice
}
}
그리고 abstract Class를 상속받은 나머지 Child Entity Class들은 기존과 동일하게 <code2> 같이 @Entity annotation만 추가하면 된다.
@Entity(name="SingleProduct")
public class Single extends Product {
int weight;
}
...
@Entity(name="BulkProduct")
public class Bulk extends Product {
int productCount;
}
...
@Entity(name="Alcohol")
public class Alcohol extends Beverage {
float abv;
}
...
@Entity(name="NonAlcohol")
public class NonAlcohol extends Beverage {
float sugarContent;
}
위와 같이 수정한 후 실행하면, Hibernate Library는 아래와 같이 abstract class들을 제외한 나머지 모든 Child Entity class들과 Mapping되는 Table들을 생성하게 된다. 생성된 Table에는 Product class에서 선언한 id, name, price, manufacturer attribut가 Column으로 추가되어 있고, Beverage class에서 선언한 volume과 type attribute도 alcohol table과 non_alcohol table에 Column으로 추가 되어있다.
Hibernate:
create table alcohol (
id bigint not null,
manufacturer varchar(255),
name varchar(255),
price integer not null,
type varchar(255),
volume integer not null,
abv float not null,
primary key (id)
)
Hibernate:
create table non_alcohol (
id bigint not null,
manufacturer varchar(255),
name varchar(255),
price integer not null,
type varchar(255),
volume integer not null,
sugar_content float not null,
primary key (id)
)
Hibernate:
create table bulk_product (
id bigint not null,
manufacturer varchar(255),
name varchar(255),
price integer not null,
product_count integer not null,
primary key (id)
)
Hibernate:
create table single_product (
id bigint not null,
manufacturer varchar(255),
name varchar(255),
price integer not null,
weight integer not null,
primary key (id)
)
MappedSuperClass의 장/단점
MappedSuperClass의 최대 장점은 간단하다는 점이다. Instance화 할 수 있는 Class와 Mapping되는 Table만 만들어지기 때문에 Table과 Class Mapping이 간단하고, 서로 연관되지 않지만 동일한 attribute들을 가지는 유사한 Table들을 생성하기도 쉽다.
하지만 Child entity class들이 다른 Entity class들과 공통으로 Relation을 가지는 경우 이 방법은 최악의 선택이 될 수 있다. 예를 들면 온라인 쇼핑몰의 장바구니를 생각해 보자. 하나의 장바구니(Basket)에 여러 물건(Product)이 담기는 시나리오를 구현한다면 대략 <pic 2>와 같은 UML로 표현할 수 있을 것이다. (하나의 상품은 하나의 장바구니에만 들어갈 수 있다고 가정한다.)
<pic 2>와 같이 1:N Relation을 가지는 경우, <code 3>와 <code 4>와 같이 Basket Class와 Product Class에 Relation을 추가하면 쉽게 구현할 수 있다고 생각할 수 있다.
public class Basket {
@Id
@GeneratedValue(strategy=GenerationType.AUTO)
private long id;
private String userName;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name="product_id")
private List<Product> product = new ArrayList<Product>();
}
@Getter @Setter
@MappedSuperclass
public abstract class Product {
@Id
@GeneratedValue(strategy=GenerationType.AUTO)
private long id;
String name;
int price;
String manufacturer;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "basket_id")
private Basket basket;
}
하지만 실행 후에는 Hibernate에서는 아래와 같은 Error를 발생한다.
org.hibernate.AnnotationException: Use of @OneToMany or @ManyToMany targeting an unmapped class
@MappedSuperClass로 선언된 부모 abstract Class는 Mapping된 Table이 없기 때문에 @OneToMany나 @ManyToMany와 같은 Relation설정이 안된다는 의미이다.
이와 같이 Child Entity Class들이 다른 Entity Class와 Relation을 맺는 시나리오가 많은 경우 MappedSuperClass 전략은 RDB의 장점을 살리지 못하고, Entity간의 Relation을 Business Layer에서 처리해야만 한다. 이 작업이 얼마나 손이 많이 가는 지를 보자.
이어지는 내용은 MappedSuperClass의 방식으로 위의 요구사항을 구현할 때 추가되는 Business Logic 구현의 예이다. Code 최적화에 대한 별 고려없이 작성한 Code이니 MappedSuperClass 전략을 사용할 때에 Entity 사이의 Relation을 처리하는 일이 얼마나 귀찮은 지만 참고하면 된다.
MappedSuperClass 전략 사용 시 Entity Relation 처리의 예
Step 1. Entity Relation 재설정
(큰 도움이 안될 경우가 많지만) 각 Child Class에서 다른 Entity로의 단방향 Relation 설정은 가능하다. 이를 활용해서 Business Logic을 구현하기 위해 <code 5>과 같이 Child Entity Class들이 자신이 담겨져 있는 장바구니(Basket)을 가리키는 단방향 Relation을 선언했다.
public class Alcohol extends Beverage {
float abv;
@ManyToOne(fetch = FetchType.LAZY, optional = true)
@JoinColumn(name = "basket_id", nullable=true)
private Basket basket;
}
...
public class NonAlcohol extends Beverage {
float sugarContent;
@ManyToOne(fetch = FetchType.LAZY, optional = true)
@JoinColumn(name = "basket_id" , nullable=true)
private Basket basket;
}
...
public class Single extends Product {
int weight;
@ManyToOne(fetch = FetchType.LAZY, optional = true)
@JoinColumn(name = "basket_id", nullable=true)
private Basket basket;
}
...
public class Bulk extends Product {
int productCount;
@ManyToOne(fetch = FetchType.LAZY, optional = true)
@JoinColumn(name = "basket_id", nullable=true)
private Basket basket;
}
<code 5>를 추가한 후 실행하면 아래와 같이 Backet Table의 id를 Foreign Key로 추가해서 Child Entity의 Mapping Table들이 만들어 진다. (Basket class에서 Relation을 가지려면 각각의 Child Class들과 모두 Relation을 가지게 구성해야 한다. 하지만 이는 Basket Table이 너무 커지는 문제를 야기하고, 전체 Child Class들을 묶어서 처리하기 어렵기 때문에 사용 가치가 낮다.)
Hibernate:
alter table alcohol
add constraint FKdwo1ph9nikk4n1qa09gmgo18
foreign key (basket_id)
references basket
Hibernate:
alter table bulk_product
add constraint FK5yjy7r7mbwe3mftryjun5k5kp
foreign key (basket_id)
references basket
Hibernate:
alter table non_alcohol
add constraint FKdsfcft2ktm4b3pmyn4e5nd51s
foreign key (basket_id)
references basket
Hibernate:
alter table single_product
add constraint FKnrtom4r2k7uyofmoqwcdt09q7
foreign key (basket_id)
references basket
Step 2. 상품 주문 Business Logic 구성
한 명의 사용자가 하나의 장바구니에 여러 물건을 담든다는 가정으로 (단, 다른 사용자가 구매한 물건은 고를 수 없다고 가정) 주문 내용 처리하기 위한 Class를 <code 6> 과 같이 정의했다.
public class OrderParam {
String userName;
List<Long> productIdList;
}
그리고 <code 7>과 같이 Step 1에서 정의한 Entity들을 대상으로한 JpaRepository Interface를 추가하고, 장바구니(Basket)에 여러 물건(Product)들을 담아 저장하는 Business Logic을 <code 8>과 같이 구현했다.
...
public interface AlcoholEntityRepository extends JpaRepository<Alcohol, Long> {}
...
public interface BulkEntityRepository extends JpaRepository<Bulk, Long> {}
...
public interface NonAlcoholEntityRepository extends JpaRepository<NonAlcohol, Long> {}
...
public interface SingleEntityRepository extends JpaRepository<Single, Long> {}
<code 8>에서는 Basket Instance를 생성해서 저장한 후에, 주문한 Product들과 생성된 Basket 사이에 Relation을 Update한 후에 저장했다.
@Service
public class OrderService {
@Autowired
AlcoholEntityRepository alcoholEntityRepository;
@Autowired
NonAlcoholEntityRepository nonAlcoholEntityRepository;
@Autowired
SingleEntityRepository singleEntityRepository;
@Autowired
BulkEntityRepository bulkEntityRepository;
@Autowired
BasketRepository basketRepository;
public Long registerBasket(OrderParam orderParam) {
Basket basket = new Basket();
// Basket 생성 후 저장.
basket.setUserName(orderParam.getUserName());
basket = basketRepository.save(basket);
for(long id : orderParam.getProductIdList()) {
try {
// 각 상품을 주문했는 지 확인한 후에 주문한 상품이 생성된 Basket과 Relation을 가지게 설정한 후 저장
if (alcoholEntityRepository.findById(id).isPresent()) {
Alcohol alcoholEntity = alcoholEntityRepository.findById(id).get();
alcoholEntity.setBasket(basket);
alcoholEntityRepository.save(alcoholEntity);
}else if (bulkEntityRepository.findById(id).isPresent()) {
Bulk bulkEntity = bulkEntityRepository.findById(id).get();
bulkEntity.setBasket(basket);
bulkEntityRepository.save(bulkEntity);
}else if (nonAlcoholEntityRepository.findById(id).isPresent()) {
NonAlcohol nonAlcoholEntity = nonAlcoholEntityRepository.findById(id).get();
nonAlcoholEntity.setBasket(basket);
nonAlcoholEntityRepository.save(nonAlcoholEntity);
}else {
Single singleEntity = singleEntityRepository.findById(id).get();
singleEntity.setBasket(basket);
singleEntityRepository.save(singleEntity);
}
}catch(NoResultException e) {
log.warn("There is no product whose id is {}", id);
continue;
}
}
return basket.getId();
}
...
}
사용자가 Bulk 상품과 Non-Alcohol 상품을 자기 장바구니(Basket)에 담는 시나리오를 실행하면, <Code 8>의 Business Logic은 Hibernate Library를 통해 DB에 아래와 같은 Query를 요청한다. (Basket을 저장하고, 각 상품들이 주문한 상품인지 확인한 후에 주문한 상품이 맞으면 Basket과 Relation을 정의한 후에 Table을 Update 한다.)
Hibernate:
insert into basket (user_name, id) values(?, ?)
Hibernate:
select alcohol0_.id as id1_0_0_, alcohol0_.manufacturer as manufact2_0_0_, alcohol0_.name as name3_0_0_, alcohol0_.price as price4_0_0_, alcohol0_.type as type5_0_0_,alcohol0_.volume as volume6_0_0_, alcohol0_.abv as abv7_0_0_, alcohol0_.basket_id as basket_i8_0_0_ from alcohol alcohol0_ where alcohol0_.id=?
Hibernate:
select bulk0_.id as id1_2_0_, bulk0_.manufacturer as manufact2_2_0_, bulk0_.name as name3_2_0_, bulk0_.price as price4_2_0_, bulk0_.basket_id as basket_i6_2_0_, bulk0_.product_count as product_5_2_0_ from bulk_product bulk0_ where bulk0_.id=?
Hibernate:
update bulk_product set manufacturer=?, name=?, price=?, basket_id=?, product_count=? where id=?
Hibernate:
select alcohol0_.id as id1_0_0_, alcohol0_.manufacturer as manufact2_0_0_, alcohol0_.name as name3_0_0_, alcohol0_.price as price4_0_0_, alcohol0_.type as type5_0_0_, alcohol0_.volume as volume6_0_0_, alcohol0_.abv as abv7_0_0_, alcohol0_.basket_id as basket_i8_0_0_ from alcohol alcohol0_ where alcohol0_.id=?
Hibernate:
select bulk0_.id as id1_2_0_, bulk0_.manufacturer as manufact2_2_0_, bulk0_.name as name3_2_0_, bulk0_.price as price4_2_0_, bulk0_.basket_id as basket_i6_2_0_, bulk0_.product_count as product_5_2_0_ from bulk_product bulk0_ where bulk0_.id=?
Hibernate:
select nonalcohol0_.id as id1_3_0_, nonalcohol0_.manufacturer as manufact2_3_0_, nonalcohol0_.name as name3_3_0_, nonalcohol0_.price as price4_3_0_, nonalcohol0_.type as type5_3_0_, nonalcohol0_.volume as volume6_3_0_, nonalcohol0_.basket_id as basket_i8_3_0_, nonalcohol0_.sugar_content as sugar_co7_3_0_ from non_alcohol nonalcohol0_ where nonalcohol0_.id=?
Hibernate:
update non_alcohol set manufacturer=?, name=?, price=?, type=?, volume=?, basket_id=?, sugar_content=? where id=?
위의 Log에서도 확인할 수 있듯이 요구사항 처리는 가능하지만 매우 비효율 적인 것을 알 수 있다. 만약 Child class의 종류가 N개이고 주문한 상품이 M개 이면 SELECT Query는 N개 UPDATE Query는 M개가 요청되는데, M에 비해 N이 극단적으로 클 경우 이 Business Logic은 더욱 비효율적으로 동작하게 된다.
Step 3. 주문 상품 전체 조회용 Business Logic
Step 2에서 등록한 주문 내용 전체를 조회하기 위해서 <code 9>과 같은 findBy function들을 JpaRepository Interface에 추가했다.
...
public interface AlcoholEntityRepository extends JpaRepository<Alcohol, Long> {
public Collection<Alcohol> findByBasket(Basket basket);
}
...
public interface BulkEntityRepository extends JpaRepository<Bulk, Long> {
public Collection<Bulk> findByBasket(Basket basket);
}
...
public interface NonAlcoholEntityRepository extends JpaRepository<NonAlcohol, Long> {
public Collection<NonAlcohol> findByBasket(Basket basket);
}
...
public interface BulkEntityRepository extends JpaRepository<Bulk, Long> {
public Collection<Bulk> findByBasket(Basket basket);
}
...
그리고 <code 10>과 같은 Business Logic을 추가하여 각 Child Entity Class들이 어떤 Basket에 들어 있는 지를 조회한 후 할 수 모두 Merge하게 했다. 하나의 Basket에 담긴 상품들을 찾아내는 려면, 위에서 추가한 모든 findBy function을 호출해 Product class의 모든 Child Entity Cleass들을 조회하고, 그 결과를 merge해야한다. 때문에 유사한 시나리오를 구현할 때 하나의 MappedSuperClass로 정의된 추상 Class에서 상속된 Child Class가 N개 있다고 가정하면, N번의 SELECT Query가 요청해야 하나의 결과를 얻을 수 있다.
@Service
public class OrderService {
@Autowired
AlcoholEntityRepository alcoholEntityRepository;
@Autowired
NonAlcoholEntityRepository nonAlcoholEntityRepository;
@Autowired
SingleEntityRepository singleEntityRepository;
@Autowired
BulkEntityRepository bulkEntityRepository;
@Autowired
BasketRepository basketRepository;
...
public List<OrderParam> getAllBasketInfo() {
List<OrderParam> ret = new ArrayList<OrderParam>();
// 모든 Basket을 대상으로 Loop 실행
basketRepository.findAll().forEach(basket->{
OrderParam order = new OrderParam();
order.setUserName(basket.getUserName());
List<Long> productList = new ArrayList<Long>();
// 해당 Basket으로 각 Product를 조회 후 Product Id를 추출해서 OrderParam객체의 productList에 추가
productList.addAll(alcoholEntityRepository.findByBasket(basket).stream().map(Product::getId).collect(Collectors.toList()));
productList.addAll(nonAlcoholEntityRepository.findByBasket(basket).stream().map(Product::getId).collect(Collectors.toList()));
productList.addAll(singleEntityRepository.findByBasket(basket).stream().map(Product::getId).collect(Collectors.toList()));
productList.addAll(bulkEntityRepository.findByBasket(basket).stream().map(Product::getId).collect(Collectors.toList()));
order.setProductIdList(productList);
ret.add(order);
});
return ret;
}
}
아래는 Basket이 하나 등록되어 있을 경우에 <code 10>이 DB에 요청하는 SELECT Query문이다. 위에서 설명한 것과 같이 하나의 Basket에 담긴 물건을 확인하기 위해서 모든 Child Class에 SELECT Query를 요청하고 있다.
Hibernate:
select basket0_.id as id1_1_, basket0_.user_name as user_nam2_1_ from basket basket0_
Hibernate:
select alcohol0_.id as id1_0_, alcohol0_.manufacturer as manufact2_0_, alcohol0_.name as name3_0_, alcohol0_.price as price4_0_, alcohol0_.type as type5_0_, alcohol0_.volume as volume6_0_, alcohol0_.abv as abv7_0_, alcohol0_.basket_id as basket_i8_0_ from alcohol alcohol0_ where alcohol0_.basket_id=?
Hibernate:
select nonalcohol0_.id as id1_3_, nonalcohol0_.manufacturer as manufact2_3_, nonalcohol0_.name as name3_3_, nonalcohol0_.price as price4_3_, nonalcohol0_.type as type5_3_, nonalcohol0_.volume as volume6_3_, nonalcohol0_.basket_id as basket_i8_3_, nonalcohol0_.sugar_content as sugar_co7_3_ from non_alcohol nonalcohol0_ where nonalcohol0_.basket_id=?
Hibernate:
select single0_.id as id1_4_, single0_.manufacturer as manufact2_4_, single0_.name as name3_4_, single0_.price as price4_4_, single0_.basket_id as basket_i6_4_, single0_.weight as weight5_4_ from single_product single0_ where single0_.basket_id=?
Hibernate:
select bulk0_.id as id1_2_, bulk0_.manufacturer as manufact2_2_, bulk0_.name as name3_2_, bulk0_.price as price4_2_, bulk0_.basket_id as basket_i6_2_, bulk0_.product_count as product_5_2_ from bulk_product bulk0_ where bulk0_.basket_id=?
결론(?)
Child Entity Class와 다른 Class들간에 Relation이 있을 경우에는 가급적 쓰지말라 이다. 하지만 때때로 모든 Child entity class와 Relation을 가지지 않고 선별적으로 Relation을 가지게 구현해야 하는 상황이 있다. 이 때는 MappedSuperClass 상속 전략을 고려할만 하다.
Reference
Inheritance Strategies with JPA and Hibernate – The Complete Guide