본문 바로가기

Spring/JPA, Hibernate

Entity Inheritance in Hibernate - (2) Single-Table 상속 전략

반응형

Overview

Single-Table 상속 전략은 상속 관계에 있는 Entity를 모두 묶어서 하나의 Table로 만드는 방식이다. 이전에 사용한 예제를 Single-Table 상속전략을 사용했을 경우를 개념적으로 도식화 하면 <pic 1> 과 같다. 최상위 Class인 Product Class가 모든 Child Class를 포함하는 상태로 Table이 만들어지며 다른 Entity Class와의 Relation도 이 Class에서 처리된다.

 

<pic 1> 모든 상속 관계가 한 Table로 모이는 Diagram

 

Single-Table 상속 전략이 가지는 장, 단점은 아래와 같다.

 

장점 

  • 상속 관계에 있는 Entity 관리 편의성과 높은 성능

  • 다른 Entity들과의 Relation 설정 용이

단점

  • 상속 관계에 있는 모든 Entity가 하나의 Table로 구성되면서 각 Entity들의 모든 Attribute들이 포함되어야 한다. 때문에 Entity들이 많을 경우 Table이 비대해진다.

  • 모든 Entity의 Attribute들이 포함되면서 특정 Entity와 관련 없는 Attribute들은 모두 null 값을 가지게 된다. 때문에 Not Null Constrain 사용에 제약이 존재힌디

구현

이제부터 언급되는 Entity 상속 전략들은 @Inheritance annotation을 통해 정의되고, Annotation 값들로 'SINGLE_TABLE', 'TABLE_PER_CLASS', 'JOINED' 중에 하나를 선택할 수 있다. 참고로 @Inheritance code에 포함된 주석문에서는 아래와 같이 설명한다.

 

Specifies the inheritance strategy to be used for an entity class hierarchy. It is specified on the entity class that is the root of the entity class hierarchy.

@Inheritance annotation을 통해 SINGLE Table 전략을 사용하는 경우에는 @DiscriminatorColumn annotation이 추가로 정의되어야 한다. @DiscriminatorColumn annotation에서 설정한 값은 각 Child Class를 분류할 때 사용되는 Column 명으로 사용되며, 이 Column의 값을 통해 Child Class들의 Type을 구분하게 된다. (<code 1> 참조)

@Getter @Setter
@Entity
@Inheritance(strategy=InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name="Product_Type")
public abstract class Product {
	
	@Id
	@GeneratedValue(strategy=GenerationType.AUTO)
	private long	id;
	
	String	name;
	
	int	price;

	String	manufacturer;
    
    ...

}
<code 1> Single-Table 전략에서 최상위 부모 Class 수정 

 

그리고 @DiscriminatorColumn으로 지정된 'Product_Type' column의 값들은 <code 2>와 같이 Child Entity Class에서 @DiscriminatorValue annotation을 통해 정의한다.

...
@Entity(name="Alcohol")
@DiscriminatorValue("AlcoholBeverage")
public class Alcohol extends Beverage {
...

@Entity(name="BulkProduct")
@DiscriminatorValue("BulkProduct")
public class Bulk extends Product {
...

@Entity(name="NonAlcohol")
@DiscriminatorValue("NonAlcoholBeverage")
public class NonAlcohol extends Beverage {
...

@Entity(name="SingleProduct")
@DiscriminatorValue("SingleProduct")
public class Single extends Product {
...
<code 2> Child Entity Class에서 @DiscriminatorValue 사용 예

 

참고로 최상위 부모 Class가 아닌 중간에 정의된 abstract Class는 <code 3>과 같이 별다르게 수정할 사항이 없다.

public abstract class Beverage extends Product {

	int	volume;
	
	@Enumerated(EnumType.STRING)
	private BeverageType	type;
	
	public enum BeverageType {
		Beer,
		Wine,
		Soju,
		Soda,
		Juice
	}
}
<code 3> 중간에 위치한 Abstract Class의 예

 

위와 같이 작성한 Code를 실행하면 Hibernate Library는 아래와 같이 Table Create Query를 생성해서 요청한다.

Hibernate: 
    
    create table product (
       product_type varchar(31) not null,
        id bigint not null,
        manufacturer varchar(255),
        name varchar(255),
        price integer not null,
        abv float,
        product_count integer,
        sugar_content float,
        weight integer,
        primary key (id)
    )

 

최상위 부모 Class로 정의했던 product class와 mapping되는 product table의 내용을 보면 product class에서 정의했던 id, name, price, manufacturer column외에, @DiscriminatorColumn annotation으로 정의했던 product_type column이 추가 되었고, 각 Child Class의 specific attribute인 abv, product_count, sugar_content, weight attribute들이 column으로 추가됐다.

Data Insert Review

실제 각 상품이 추가될 때 DB에 보내지는 Insert Request를 확인하기 위해 <code 4>와 같이 Application이 시작할 때 모든 Child Class의 Instance를 생성해 Insert하는 Code를 구현했다.

 

Code4
@Component
public class StartUpListener implements ApplicationListener<ApplicationReadyEvent> {

	@Autowired 	private SingleEntityRepository	singleEntityRepository;
	@Autowired	private	BulkEntityRepository	bulkEntityRepository;
	@Autowired	private AlcoholEntityRepository	alcoholEntityRepository;
	@Autowired	private NonAlcoholEntityRepository	nonAlcoholEntityRepository;
	
	@Override
	public void onApplicationEvent(ApplicationReadyEvent event) {

		// Create Entity
		Single	singleProduct = new Single();
		singleProduct.setName("SingleEx");
		singleProduct.setManufacturer("Moon");
		singleProduct.setPrice(1000);
		singleProduct.setWeight(10);
		
		Bulk	bulkProduct = new Bulk();
		bulkProduct.setName("Bulk");
		bulkProduct.setManufacturer("Mars");
		bulkProduct.setPrice(2000);
		bulkProduct.setProductCount(5);

		Alcohol	alcoholProduct = new Alcohol();
		alcoholProduct.setName("Tiger");
		alcoholProduct.setManufacturer("Tiger");
		alcoholProduct.setPrice(2500);
		alcoholProduct.setType(BeverageType.Beer);
		alcoholProduct.setAbv(3.6f);
		alcoholProduct.setVolume(360);

		NonAlcohol	nonAlcoholProduct = new NonAlcohol();
		nonAlcoholProduct.setName("Morning Juice");
		nonAlcoholProduct.setManufacturer("Bingrae");
		nonAlcoholProduct.setPrice(1000);
		nonAlcoholProduct.setType(BeverageType.Juice);
		nonAlcoholProduct.setVolume(180);
		nonAlcoholProduct.setSugarContent(2.0f);
		
		// Save
		singleEntityRepository.save(singleProduct);
		bulkEntityRepository.save(bulkProduct);
		alcoholEntityRepository.save(alcoholProduct);
		nonAlcoholEntityRepository.save(nonAlcoholProduct);
		
	}

}
<code 4> Application Ready 상태일 때 모든 Child Entity를 추가하는 Code

 

<code 4>를 추가한 후 실행하면 Hibernate library는 아래와 같은 Insert Request를 DB에 보낸다. product_type column에 Class Type이 명시되어 있고, 해당 Class만 가지고 있는 Column만 명시적으로 사용되고 있음을 주목하자.

Hibernate: 
    insert into product (manufacturer, name, price, weight, product_type, id) 
    values (?, ?, ?, ?, 'SingleProduct', ?)
...
Hibernate: 
    insert into product (manufacturer, name, price, product_count, product_type, id) 
    values (?, ?, ?, ?, 'BulkProduct', ?)
...
Hibernate: 
    insert into product (manufacturer, name, price, abv, product_type, id) 
    values (?, ?, ?, ?, 'AlcoholBeverage', ?)
...
Hibernate: 
    insert into product (manufacturer, name, price, sugar_content, product_type, id) 
    values (?, ?, ?, ?, ?, 'NonAlcoholBeverage', ?)

그 결과 <Table 1>과 같이 Data가 저장된다. 여기서 Single-Table 전략의 특징 2가지를 확인할 수 있다.

  1. product_type column의 값은 각 Child Entity Class에서 @DiscriminatorValue annotation으로 정의된 값로 설정.
  2. abv, product_type, sugar_content, weight과 같이 Child Entity Class에서만 정의되었던 Column들은 오직 해당 class일 경우에만 data가 입력되고, 그 외의 경우에는 null 값으로 설정.
PRODUCT_TYPE ID MANUFACTURER NAME PRICE ABV PRODUCT_COUNT SUGAR_CONTENT WEIGHT BASKET_ID
SingleProduct 1 Moon SingleEx 1000 null null null 10 null
BulkProduct 2 Mars Bulk 2000 null 5 null null null
AlcoholBeverage 3 Tiger Tiger 2500 3.5999999 null null null null
NonAlcoholBeverage 4 Bingrae Morning Juice 1000 null null 2 null null
<Table 1> Child Entity type 별로 Data를 입력한 예

 

Single Table 전략에서 Relation(One-to-Many, Bi-Direction) 생성

MappedSuperClass 전략을 제외한 나머지 전략에서는, 부모 Class에서 다른 entity class들의 Relation을 정의할 수 있다. (이 Relation은 모든 Child entity class들에 영향을 미친다) 이전 포스팅과 동일하게 하나의 장바구니에 몇 가지 물건을 담는다는 시나리오로 Entity간 Relation을 정의하면 <code 5>과 같다.

@Getter @Setter
@Entity
@Inheritance(strategy=InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name="Product_Type")
public abstract class Product {

	...
	
	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "basket_id")
	private Basket basket;
}

...

@Entity(name="Basket")
public class Basket {

	@Id
	@GeneratedValue(strategy=GenerationType.AUTO)
	private long	id;
	
	private String	userName;
	
	@OneToMany(mappedBy="basket", cascade = CascadeType.ALL, orphanRemoval = true) 
	private List<Product> productList = new ArrayList<Product>();		
	
	public void addProduct(final Product product) {
		productList.add(product);
		product.setBasket(this);
	}

	public void removeProduct(final Product product){
		productList.remove(product);
		product.setBasket(this);
	}	
}
<code 5> Single-Table 상속 전략에서 다른 Entity와의 Relation 설정의 예

 

그리고 <code 6> 과 같이 Product class와 Basket class에 대한 Repository Interface를 정의한다.

...
public interface ProductRepository extends JpaRepository<Product, Long> {}
...
public interface BasketRepository extends JpaRepository<Basket, Long> {}
...
<code 6> 추가된 JpaRepository Interface

 

사용자가 구입한 물건을 장바구니에 담고 저장하는 시나리오를 구현한 Business Logic은 <code 7>과 같이 구현했다.

@Service
public class OrderService {
	
    ...
    
	@Autowired
	ProductRepository	productRepository;
	
	@Autowired
	BasketRepository	basketRepository;

	public Long registerBasket(String userName, List<Long> productIDs) {
		
		Basket	basket = new Basket();

		basket.setUserName(userName);
		
		for(Long id : productIDs) {
			try {
				Product	product = productRepository.findById(id).orElseThrow(()->new NoResultException());
				basket.addProduct(product);
				
			}catch(NoResultException e) {
				log.warn("There is no product in product list ; id({})", id);
				continue;
			}
			
		}
		
		if (basket.getProductList().isEmpty()) {
			log.warn("There is no validate product in Order");
			return -1L;
		}
		
		basket = basketRepository.save(basket);
		
		return basket.getId();
	}
	
...
}
<Code 7> 상품들을 Basket에 담아 저장하는 Business Logic

 

위의 Business Logic을 이용하여 John이 낱개상품(Single Product)와 주류(Alcohol)를 구입하고 Jane이 묶음 상품(Bulk Product)와 비주류 음료(NonAlcohol)을 구입했을 경우, Hibernate Library는 아래와 같은 Request를 DB에 보낸다. 

 

John의 쇼핑에 의해 발생하는 Query

...
Hibernate: insert into basket (user_name, id) values (?, ?)
Hibernate: update product set basket_id=?, manufacturer=?, name=?, price=?, weight=? where id=?
Hibernate: update product set basket_id=?, manufacturer=?, name=?, price=?, abv=? where id=?
...

 

Jane의 쇼핑에 의해 발생하는 Query

...
Hibernate: insert into basket (user_name, id) values (?, ?)
Hibernate: update product set basket_id=?, manufacturer=?, name=?, price=?, product_count=? where id=?
Hibernate: update product set basket_id=?, manufacturer=?, name=?, price=?, sugar_content=? where id=?
...

 

Result

그리고 그 결과 DB에는 <table 2>와 같이 Data가 구성된다. Relation을 정의하는 과정에서 Product Table에 basket_id column이 추가되었다. Column의 Data는 초기에는 Null 값을 가지다가 basket에 해당 Product 가 담겨질 때 값이 Update된다.

 

product_type id manufacturer price name abv product_count sugar_content weight basket_id
SingleProduct 1 Moon SingleEx 1000       10 5
AlcoholBeverage 3 Tiger Tiger 2500 3.6       5
BulkProduct 2 Mars Bulk 2000   5     6
NonAlcoholBeverage 4 Bingrae Morning Juice 1000     2   6
'product' Table
id user_name
5 John
6 Jane
'basket' Table
<Table 2> Entity간 Relation이 생겼을 떄의 Data

 

Polymorphic Queries

Polymorphic Query는 Java Class에서의 다형성의 특성을 Query에 반영하는 것을 의미하는데, Child Entity Class의 Data들을 조회하기 위해 최상위 부모 Class를 이용해서 만들어지는 Query 정도로 이해하면 될 것 같다.(정확하게 어떻게 한국어로 설명할 수 있을 지 잘 모르겠다.) 이 Polymorphic Query가 Single Table 전략에서는 어떻게 생성되는 지 살펴보자.

 

이전 포스팅과 마찬가지로 전체 장바구니를 조회하는 Business Logic도 <code 8>과 같이 작성하였다. 전체 Basket을 조회한 후에 Loop문으로 개별 Basket과 Relation 관계의 Product List를 가져온다. 지난 포스팅에서 소개했던 MappedSuperClass 전략과는 달리 RDB의 장점을 활용한 Business Logic 구성이 가능함을 확인할 수 있다.

 

<code 8>
@Service
public class OrderService {
	
	@Autowired
	ProductRepository	productRepository;
	
	@Autowired
	BasketRepository	basketRepository;

	...

	public List<OrderParam> getAllBasketInfo() {
		List<OrderParam> ret = new ArrayList<OrderParam>();
		
		basketRepository.findAll().forEach(basket->{
			OrderParam	order =  new OrderParam();
			
			order.setUserName(basket.getUserName());
			order.setProductIdList(basket.getProductList().stream().map(p->p.getId()).collect(Collectors.toList()));
			
			ret.add(order);
		});
		
		return ret;
	}
}
<code 8> 전체 장바구니 내용을 조회하는 Business Logic

 

<table 2>와 같이 Data가 있을 때, <code 8>의 Business Logic이 실행되면 Hibernate Library는 아래와 같은 Query를 DB에 요청하게 된다. 맨처음에 전체 Basket List를 가져오고, 각 Basket마다 담고 있는 Product List를 조회하게 된다. Basket이 N개 있다고 가정하면 Child Entity class 수에 관계없이 항상 N+1번의 Query를 요청하게 된다.

...
Hibernate: select basket0_.id as id1_0_, basket0_.user_name as user_nam2_0_ from basket basket0_
Hibernate: select productlis0_.basket_id as basket_12_1_0_, productlis0_.id as id2_1_0_, productlis0_.id as id2_1_1_, productlis0_.basket_id as basket_12_1_1_, productlis0_.manufacturer as manufact3_1_1_, productlis0_.name as name4_1_1_, productlis0_.price as price5_1_1_, productlis0_.type as type6_1_1_, productlis0_.volume as volume7_1_1_, productlis0_.abv as abv8_1_1_, productlis0_.product_count as product_9_1_1_, productlis0_.sugar_content as sugar_c10_1_1_, productlis0_.weight as weight11_1_1_, productlis0_.product_type as product_1_1_1_ from product productlis0_ where productlis0_.basket_id=?
Hibernate: select productlis0_.basket_id as basket_12_1_0_, productlis0_.id as id2_1_0_, productlis0_.id as id2_1_1_, productlis0_.basket_id as basket_12_1_1_, productlis0_.manufacturer as manufact3_1_1_, productlis0_.name as name4_1_1_, productlis0_.price as price5_1_1_, productlis0_.type as type6_1_1_, productlis0_.volume as volume7_1_1_, productlis0_.abv as abv8_1_1_, productlis0_.product_count as product_9_1_1_, productlis0_.sugar_content as sugar_c10_1_1_, productlis0_.weight as weight11_1_1_, productlis0_.product_type as product_1_1_1_ from product productlis0_ where productlis0_.basket_id=?
...

 

결론(?)

하나의 Class에서 상속 받는 Child Class의 숫자가 많지 않고, 각 Child class가 고유로 가지고 있는 attribute들의 숫자가 많지 않을 경우, Single-Table 상속 전략은 Best Choice가 된다. Class와 Table Mapping 구조가 이해하기 쉽고, Relation 처리도 매우 쉽고, 실제 DB에 요청되는 Query도 단순하기 때문이다. 하지만 위의 조건을 만족하지 않을 때 저장 공간 낭비를 초래한다. 각 Child Class 마다 고유의 Attribute를 N개 가지고 있고, 전체 Child Entity Class의 숫자가 M개, 그리고 각 attribute의 Size가 평균 K byte일 때, SIngle-Table 상속 전략은 Record당 K x N x (M -1) byte를 낭비하게 된다. 특히 1개의 Record의 일정 크기가 이상으로 커지게 되면, MS SQL Server같이 한개의 Record가 8060 byte(SQL Server 2012 version 기준.)로 제약된 DB 같은 경우에는 아예 저장이 안되는 심각한 문제가 발생하게 된다.