본 글은 인프런 김영한님의 JPA 로드맵을 기반으로 정리했습니다.
객체지향에는 아래와 같이 클래스끼리 상속관계가 존재한다.
그러나 관계형 데이터베이스는 상속관계를 지원하지 않는다. 그 대신 데이터베이스의 슈퍼타입, 서브타입 관계라는 모델링 기법을 통해 객체의 상속관계를 매핑할 수 있다.
위의 그림은 Item을 슈퍼타입으로 Album, Movie, Book을 서브타입으로 모델링한것이다. 슈퍼타입과 서브타입이라는 논리 모델을 실제 물리 모델(테이블)로 구현하는 방법은 3가지다.
각각 테이블로 변환 -> 조인 전략
통합 테이블로 변환 -> 단일 테이블 전략
서브타입 테이블로 변환 -> 구현 클래스마다 테이블 전략
세 가지 방법 모두 JPA를 통해 구현할 수 있다. 먼저 주요 애노테이션을 살펴보자.
@Inheritance(strategy = InheritanceType.XXX)
JOINED: 조인 전략
SINGLE_TABLE: 단일 테이블 전략
TABLE_PER_CLASS: 구현 클래스마다 테이블 전략
@DiscriminatorColumn(name="DTYPE")
@DiscrminatorValue("XXX")
조인 전략은 슈퍼타입, 서브타입 논리모델을 각각 테이블로 옮긴 방식이다. 테이블이 구분되어 있기 때문에 데이터를 조회할 때 조인이 필요해서 조인 전략이라고 부른다.
코드로 살펴보자. 서브타입을 매핑하는 것은 비슷하기 때문에 Album 엔티티 하나만 살펴본다.
@Entity
@Getter @Setter
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn
public abstract class Item {
@Id @GeneratedValue
@Column(name = "ITEM_ID")
private Long id;
private String name;
private int price;
}
슈퍼타입 Item 엔티티다. 슈퍼타입이 혹시라도 객체화 되지 않기 위해 추상 클래스로 선언했다.
슈퍼타입에서 @Inheritance(strategy = InheritanceType.JOINED)
을 통해 매핑전략을 조인 전략으로 지정했다.
@DiscriminatorColumn
애노테이션은 구분자 컬럼을 말한다. 조인 전략과 단일 테이블 전략에서 구분자 컬럼은 필수다. 구분자 컬럼을 통해 어떤 서브타입의 데이터인지 구분한다. 구분자 컬럼이 필요할 때 생략하면 자동으로 추가하지만 명시적으로 적어주는것을 추천한다. 구분자 컬럼의 이름은 기본값으로 "DTYPE" 이다. 사용자가 원하는 값으로 지정해줄 수 있지만 특별한 이유가 없다면 기본값을 그대로 쓰는것을 추천한다.
@Entity
@Getter @Setter
@DiscriminatorValue("A")
public class Album extends Item {
private String artist;
}
서브타입 Album은 슈퍼타입 Item을 상속받는다. @DiscriminatorValue
를 통해 구분자 컬럼의 값을 정할 수 있다. 기본값은 엔티티 이름이다.
조인 전략의 장단점을 살펴보자.
장점
테이블이 정규화 된다.
외래키 참조 무결성 제약조건을 활용할 수 있다.
저장공간이 효율화된다.
단점
조회시 조인이 필요하다. 조인이 필요하기 때문에 단일 테이블 전략에 비해 성능이 떨어질 수 있다.
조인이 필요하기 때문에 단일 테이블 전략에 비해 조회 쿼리가 복잡하다.
테이블이 나뉘어져 있기 때문에 저장시 INSERT SQL이 2번 호출된다. 단일 테이블 전략에 비해 성능이 떨어진다.
단점으로 성능이 떨어질 수 있다고 했지만 사실 요즘 컴퓨터는 성능이 좋기 때문에 데이터가 엄청 많지 않은 이상 성능이 엄청 떨어지지는 않는다. 바로 다음에 살펴볼 단일 테이블 전략은 조인이 필요 없지만 null 데이터가 많아지기 때문에 상황에 따라 오히려 조인 전략보다 성능이 떨어질 수 있다.
조인 전략은 상속관계를 매핑하는 전략중 데이터베이스 관점에서 가장 정규화된 깔끔하고 정석적인 전략이다.
조인 전략이 슈퍼타입과 서브타입을 정규화해서 각각 다른 테이블에 넣었다면 단일 테이블 전략은 이름 그대로 하나의 테이블에 모든 데이터를 몰아넣는 전략이다.
조인 전략과 마찬가지로 서브타입을 구별하기위해 구분자 컬럼이 필수다. 코드로 살펴보자.
@Entity
@Getter @Setter
@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;
}
@Entity
@Getter @Setter
@DiscriminatorValue("A")
public class Album extends Item {
private String artist;
}
조인 전략에서 @Inheritance(strategy = InheritanceType.SINGLE_TABLE)
을 통해 전략을 바꿔준 것 말고는 바뀐 코드가 없다. 상속관계 매핑 전략만 바꿔줘도 JPA가 알아서 엔티티를 알맞은 테이블에 매핑해준다. 이것이 바로 ORM의 강력함이라고 볼 수 있다. ORM을 사용하지 않고 상속관계 매핑 전략을 바꿨다면 테이블 형태에 종속된 SQL을 엄청나게 수정해야될 것이다.
단일 테이블 전략의 장단점을 살펴보자.
장점
조인이 필요 없기 때문에 일반적인 상황에서 조회 성능이 빠르다.
조인이 필요 없기 때문에 조회 쿼리가 단순하다.
단점
자식 엔티티에 매핑한 컬럼은 모두 null을 허용해야한다.
테이블의 컬럼 길이가 커지기 때문에 상황에 따라 조회 성능이 오히려 느려질 수 있다.
단일 테이블 전략은 한 테이블에 서브타입의 모든 데이터가 들어가기 때문에 테이블이 커진다. 그 대신 한 테이블만 사용하기 때문에 다루기는 편리하다.
조인 전략은 테이블을 정규화한 정석적이고 이상적인 전략이다. 단일 테이블 전략은 테이블을 다루기 편리한 실용적인 전략이다. 상황에 따라 둘 중 하나를 선택하면 된다. 단일 테이블 전략은 정규화도 포기해야하고 null값도 많이 들어가야하는 문제가 있지만 큰 문제가 없다고 예상되면 사용해도 괜찮다. 조인 전략은 정규화로 테이블이 많아지고 관리가 힘들어질 수 있기 때문에 상황에 따라 단일 테이블 전략보다 안 좋을 수 있다.
슈퍼타입은 테이블로 매핑하지 않고 서브타입만 테이블로 매핑하는 방법이다.
이전 코드에서 슈퍼타입에 @Inheritance
애노테이션의 전략 설정만 수정하면 된다. 코드는 생략하고 바로 장단점을 알아보자.
장점
서브타입만 명확하게 구분해서 처리할 수 있다.
단일 테이블 전략에서는 불가능한 not null 제약조건을 사용할 수 있다.
단점
여러 자식 테이블을 함께 조회할 때 성능이 매우 느리다. (UNION 연산 발생)
자식 테이블을 통합해서 쿼리하기 어렵다.
결론을 말하자면, 이 전략은 실무에서 사용할 수 없다. 각 서브타입이 명확하게 각자의 테이블로 구분되긴 한다. 그러나 개념적으로 물품(Item)이라는 슈퍼타입에 종속되어 있는 각각의 서브타입 테이블들을 통합해서 관리하기가 매우 어려워진다. 예를 들어, 모든 물품의 가격의 평균을 구한다거나 하는 쿼리를 작성하기 매우 복잡하다. 상속관계 매핑은 조인 전략, 단일 테이블 전략 중 하나를 선택하도록 하자.
@MappedSuperclass
객체지향에서 상속을 단순히 속성을 재사용하기 위해 사용할 수 있다. 다음 그림처럼 말이다.
Member 클래스와 Seller 클래스는 공통 속성 id와 name을 가진다. 속성들을 각각의 클래스에 정의해도 되지만 공통 상위 클래스에 정의해서 속성을 물려받게 클래스를 설계할 수도 있다.
상속관계 매핑과 헷갈리면 안 된다. 상속관계 매핑처럼 상위 클래스 물품(Item) 타입에 하위 클래스 앨범(Album)을 종속시키는 것이 아니다. 단순히 공통 속성들만 재사용하기 위해 상속을 사용하는 것이다. 상위 클래스는 엔티티가 아니고 자식 클래스에 속성만 물려준다. 엔티티가 아니기 때문에 당연히 상위 클래스로 조회, 검색 할 수 없다. 상위 클래스는 공통 속성의 모음일 뿐 객체화할 일이 없기 때문에 추상 클래스로 만들 것을 권장한다.
Member, Seller 엔티티는 다음 그림의 테이블들과 매핑된다.
id, name이라는 같은 이름의 컬럼이 존재하지만 개념적으로 두 테이블은 서로 관련없는 독립적인 테이블이다.
예제로 알아보자.
@MappedSuperclass
@Getter @Setter
public abstract class BaseEntity {
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime lastModifiedBy;
private LocalDateTime lastModifiedAt;
}
실무에서는 주로 모든 엔티티에서 등록일, 수정일, 등록자, 수정자를 추적한다.
공통 속성들을 모은 BaseEntity 클래스를 만들었다. 실제 객체화 할 일이 없기 때문에 추상 클래스로 만들었다. 상위 클래스는 @MappedSuperclass
를 통해 단순히 속성 정보만 모은 클래스임을 명시해줘야한다.
참고로 @Entity
애노테이션을 통해 엔티티가된 클래스는 또 다른 엔티티 클래스나 @MappedSuperclass
로 지정한 클래스만 상속할 수 있다.
@Entity
@Getter @Setter
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn
public abstract class Item extends BaseEntity {
@Id @GeneratedValue
@Column(name = "ITEM_ID")
private Long id;
private String name;
private int price;
}
상속관계 매핑 예제에서 사용한 Item 클래스가 BaseEntity 클래스를 상속받도록했다. Item을 상속받는 Album, Book, Movie 도 모두 공통 속성을 물려받게 된다.