본 포스트는 김영한 님의 자바 ORM 표준 JPA 프로그래밍 강의를 토대로 작성하였습니다.
앞서서 기본적인 엔티티와 DB 테이블을 매핑하는 것을 알아보았다. 그러나 엔티티도 클래스인 만큼 상속받을 수 있다. 그렇다면 DB에서는 이러한 상속 관계를 어떻게 구현할까. 또한 JPA에서는 DB와 상속 관계를 어떻게 매핑할까.
DB에는 객체지향처럼 상속의 개념이 없기 때문에, 슈퍼타입과 서브타입 관계라는 것으로 상속을 대신한다. 또한 JPA에서는 이러한 테이블 구조를 보고 알아서 엔티티와 매핑해준다.
테이블로 상속관계를 구현하는 방법에는 크게 3가지 전략이 있는데,
이렇게 3개가 존재한다. 하나씩 알아보기 전 매핑할 때 사용하는 주요 어노테이션에 대해 알아보자.
먼저 @Inheritance 어노테이션을 부모 엔티티에 붙여주어야 한다.
속성으로는 JOINED, SINGLE_TABLE, TABLE_PER_CLASS를 선택할 수 있다.
@DiscriminatorColumn 어노테이션은 이후에 알아보겠지만, 자식 엔티티를 구분하는 컬럼을 말한다. 부모 엔티티에 붙이며 컬럼에 대한 이름 등을 설정할 수 있다. 디폴트는 "DTYPE"이다.
@DiscriminatorValue는 자식 엔티티에 붙이며, 부모 엔티티의 "DTYPE" 컬럼에 어떤 값을 넣을지 설정할 수 있다. 디폴트는 클래스 이름이다. 즉 Item이라는 클래스를 상속받은 Book이라는 클래스가 있으면 Item 테이블에 DTYPE = "Book" 이라는 컬럼이 생긴다.
다음 그림처럼 객체 지향에서 상속 관계를 나타내듯이 테이블 구조를 만드는 방법이다.
ITEM 테이블의 PK 값을 각 하위 테이블들이 PK 이자 FK로 갖고 있다. 코드로 알아보자.
// Item 클래스
@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;
//Book 클래스 다른 하위 클래스도 동일하다 여기서는 Book 클래스만 보자
@Entity
public class Book extends Item {
private String author;
private String isbn;
}
코드를 보면 그냥 부모 클래스에 필요한 어노테이션을 붙이고, 자식 클래스에서 상속받으면 끝이다. 또한 다른 전략을 사용할 때는 @Inheritance의 속성으로 지정하는 strategy의 값만 적절하게 바꿔주면 된다.
실행 결과
요렇게 Item 테이블과 각 하위 테이블들이 생성되며 ITEM_ID 를 외래키로 갖게 된다.
❗️참고
JOINED 전략에서는 DTYPE을 생략할 수 있다. 즉 @DiscriminatorColumn 어노테이션을 안 붙여도 되는데, DTYPE이 없더라도 서브 테이블에서 외래 키이자 PK로 슈퍼 테이블의 키 값을 갖고 있기 때문에 식별이 가능하다. 그러나 보통 붙이는 것이 좋다고 하니 웬만해서는 붙이자!
ITEM 테이블에 각 하위 클래스들의 컬럼을 전부 갖고 DTYPE으로 클래스를 구분하는 전략이다.
위와는 다르게 이 전략을 사용할 때는 @DiscriminatorColumn을 생략하더라도 DTYPE이 생긴다. 왜냐하면 이게 아니면 구분할 방법이 없기 때문이다.
코드로 알아보자.
///Item 클래스
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
public abstract class Item {
@Id
@GeneratedValue
@Column(name = "ITEM_ID")
private Long id;
private String name;
private int price;
private int stockQuantity;
바뀐 건 strategy 값과 @DiscriminatorColumn을 삭제한 것 밖에 없다.
실행 결과
이 외에 Book, Movie, Album 테이블은 생성되지 않고 ITEM 테이블에 모든 컬럼들이 모이게 된다. 또한 @DiscriminatorColumn을 따로 붙이지 않아도 자동으로 DTYPE 컬럼이 생성된 것을 알 수 있다.
각 ITEM 테이블의 id를 각 ALBUM, MOVIE, MOVIE가 자신의 id로 사용하는 전략이다.
따라서 Item 클래스를 추상 클래스로 만들면 Item 클래스는 테이블로 생성되지 않고 하위 테이블만 생성된다.
❗️참고
이 전략에서는 @DiscriminatorColumn을 사용해도 DTYPE 컬럼이 생기지 않는다. 이유는 필요가 없기 때문이다. 애초에 테이블이 다르기 때문에 DTYPE을 이용해 어떤 클래스(테이블)인지 구분할 필요가 없다. 따라서 사용해도 먹히지 않는다.
강의에서는 기본적으로 JOINED 전략을 사용하라고 말하고 있다. 또한 필요에 따라 정말 테이블이 단순하고, 더 이상 확장할 가능성이 없는 경우 단일 테이블 전략도 고려할 수 있다고 한다.
그러나..!
구현 클래스마다 테이블 전략은 그냥 사용하지 말라고 한다. 언뜻봐도 이 전략은 좀 아닌 것 같다. 테이블만 보고서는 상속 관계인지 알기도 힘들고 연결점도 없다. 따라서 웬만하면 조인, 상황에 따라 단일 테이블 전략을 사용하도록 하자.
위 예제를 보면 각 엔티티에 name, price 필드가 중복으로 사용되는 것을 볼 수 있다.
이를 Item이라는 부모 클래스를 이용해서 중복을 피할 수도 있지만 따로 Item이라는 엔티티를 만들지 않고 @MappedSuperClass 어노테이션을 이용해 해결할 수 있다.
코드로 알아보자.
//BaseEntity
@MappedSuperclass
public abstract class BaseEntity {
private String createdBy;
private LocalDateTime createdDate;
private String lastModifiedBy;
private LocalDateTime lastModifiedDate;
}
BaseEntity라는 추상 클래스를 만들었고 여기에 @MappedSuperclass 어노테이션을 붙이면 된다.
//Member 클래스
@Entity
public class Member extends BaseEntity{
@Id @GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "MEMBER_ID")
private Long id;
private String name;
private String city;
private String street;
private String zipcode;
//Team 클래스
@Entity
public class Team extends BaseEntity{
@Id
@GeneratedValue
@Column(name = "TEAM_ID")
private Long id;
private String name;
위의 Item 예시는 아니지만 좀 더 적절한 상황이다. Member와 Team이 어떤 하나의 부모를 갖기는 애매하지만 수정 시간과 수정한 유저 정보 등을 같이 사용하기 때문에 둘 다 해당 필드가 필요한 상황이다.
따라서 만들어둔 BaseEntity를 상속받기만 하면 BaseEntity에 있는 필드들이 Member, Team 클래스에 각각 들어간다.
실행 결과
다음처럼 잘 들어간 것을 확인할 수 있다.
또한 @Entity 클래스는 다른 엔티티나 @MappedSuperclass로 지정한 클래스만 상속이 가능하다.