Lined Notebook

[Spring Data JPA] 18. JPA 상속 관계 매핑

by ymkim

✔ 상속 관계 매핑

객체는 상속을 지원하지만 DB는 상속을 지원하지 않는다. 이번 장에서는 JPA가 이러한 패러다임의 차이를 어떻게 극복했는지 알아보자.

✅ DB(DataBase)

  • 위 사진은 DataBase(슈퍼타입/서브타입)의 논리 모델링을 구상한 사진입니다.

✅ 객체(Entity)

  • 관계형 데이터베이스는 상속 관계 x
  • RDBMS의 슈퍼타입 서브타입 관계라는 모델링 기법이 객체 상속과 유사
  • 상속 관계 매핑 : 객체의 상속 구조와 DB의 슈퍼타입 서브타입 관계를 매핑

✅ 슈퍼타입과 서브타입

슈퍼타입 서브타입 논리 모델을 실제 물리 모델로 구현하는 방법은 아래와 같다.

  • 각각 테이블로 변환
    • 조인 전략
  • 통합 테이블로 변환
    • 단일 테이블 전략
  • 서브타입 테이블로 변환
    • 구현 클래스마다 테이블 전략

💡 조인 전략

  • 기존 SQL에서 수행하는 JOIN
  • 추상화를 통해 상위 테이블 하위 테이블을 구분한다
  • 상위 테이블에는 기본키(PK), 하위 테이블에는 외래키(FK)를 통해 조인
  • 데이터 삽입 시에는 상위, 하위 테이블에 모두 INSERT 수행

💡 단일 테이블 전략

  • 기존 DB의 논리 모델을 하나의 테이블로 만드는 것
  • 데이터를 구분하기 위해 추가적인 컬럼이 필요하다 (ex: DTYPE)

💡 구현 클래스마다 테이블 전략

  • 추상화 단계를 거치지 않고 각각의 테이블에 모든 컬럼을 생성하는 것

❓ 패러다임의 차이

JPA에서는 조인 전략, 단일 테이블 전략, 구현 클래스마다 테이블 전략이 존재한다. 여기서 아래와 같은 의문점이 들어야 하는 것 같다.

DB와 JPA를 어떻게 매핑 할까? 🤔 JPA는 DB 테이블 전략과는 상관없이 JPA에서는 상속 관계 매핑이 가능하다.

⚡ JPA의 상속 관계 매핑 주요 어노테이션

✅ Inheritance(strategy = InheritanceType.XXX)

@Inheritance(strategy = InheritanceType.JOINED)
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
  • JOINED
    • 조인 전략
  • SINGLE_TABLE
    • 단일 테이블 전략
  • TABLE_PER_CLASS
    • 구현 클래스마다 테이블 전략
  • @DiscriminatorColumn(name = “DTYPE명”)
    • 부모 엔티티에서 설정
    • DTYPE명을 지정하고 싶을 때 사용한다
  • @DiscriminatorValue(“엔티티명”)
    • 자식 엔티티에서 설정

⚡ 기본 상속 관계 매핑 실습

Item > Album, Movie, Book 엔티티 생성

✅ Item 엔티티 생성

@Entity
public class Item {

    @Id
    @GeneratedValue
    private Long id;

    private String name;
    private int price;
}

✅ Album 엔티티 생성

@Entity
public class Album extends Item {

    private String artist;
}

✅ Book 엔티티 생성

@Entity
public class Book extends Item {

    private String author;
    private String isbn;
}

✅ Moive 엔티티 생성

@Entity
public class Movie extends Item {

    private String director;
    private String actor;
}

🖨️ 결과 화면 출력

Hibernate:

    create table Item (
       DTYPE varchar(31) not null,
        id bigint not null,
        name varchar(255),
        price integer not null,
        actor varchar(255),
        director varchar(255),
        author varchar(255),
        isbn varchar(255),
        artist varchar(255),
        primary key (id)
    )

각각의 엔티티를 상속 관계로 연결한 후 실행을 한 결과를 위와 같다. 위와 같은 결과를 통해 JPA에서는 기본적으로 단일 테이블 전략을 사용한다는 것을 알 수 있다.

⚡ 조인 전략으로 변경

기존 부모(상위)클래스에 @Inheritance 어노테이션을 추가한다.

✅ Item 엔티티 생성

@Entity
@Inheritance(strategy = InheritanceType.JOINED) // 상속 관계 매핑 추가
public class Item {

    @Id
    @GeneratedValue
    private Long id;

    private String name;
    private int price;
    //..중략 Getter Setter
}
  • 기본 전략을 JOIN 전략으로 변경 하였다.

🖨️ 엔티티 생성 결과 출력

Hibernate:

    create table Item (
       id bigint not null,
        name varchar(255),
        price integer not null,
        primary key (id)
    )

Hibernate:

    create table Album (
       artist varchar(255),
        id bigint not null,
        primary key (id)
    )

Hibernate:

    create table Book (
       author varchar(255),
        isbn varchar(255),
        id bigint not null,
        primary key (id)
    )

Hibernate:
    create table Movie (
       actor varchar(255),
        director varchar(255),
        id bigint not null,
        primary key (id)
    )

✅ 영화 데이터 등록

public class JpaMainSample {

    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();

        EntityTransaction tx = em.getTransaction();
        tx.begin();

        try {
            /* movie 등록 */
            Movie movie = new Movie();
            movie.setDirector("영화감독");
            movie.setActor("이준기");
            movie.setName("바람과 함께 사라지다");
            movie.setPrice(10000);
            em.persist(movie);

            /* 영속성 컨텍스트에 존재하는 데이터 DB 전송 */
            em.flush();

            /* 1차 캐시 비우기 */
            em.clear();

            /* 조회 */
            Movie findMovie = em.find(Movie.class, movie.getId()); // 1번 id
            System.out.println("findMovie = " + findMovie);

            tx.commit();
        } catch (Exception e) {
            tx.rollback();
        } finally {
            em.close();
        }

        emf.close();
    }
}

🖨️ 조인 전략 결과 출력

Hibernate:
    call next value for hibernate_sequence
Hibernate:
    /* insert com.hello.jpasample.Movie
        */ insert
        into
            Item
            (name, price, id)
        values
            (?, ?, ?)
Hibernate:
    /* insert com.hello.jpasample.Movie
        */ insert
        into
            Movie
            (actor, director, id)
        values
            (?, ?, ?)
Hibernate:
    select
        movie0_.id as id1_6_0_,
        movie0_1_.name as name2_6_0_,
        movie0_1_.price as price3_6_0_,
        movie0_.actor as actor1_11_0_,
        movie0_.director as director2_11_0_
    from
        Movie movie0_
    inner join
        Item movie0_1_
            on movie0_.id=movie0_1_.id
    where
        movie0_.id=?
findMovie = com.hello.jpasample.Movie@4d74c3ba
  • Item 엔티티와 Movie 엔티티를 조인한 결과를 출력한다.

⚡ 조인 전략 DTYPE

🖨️ 조인 전략 결과 출력의 결과에서는 현재 DTYPE을 생성하지 않았다. 이번에는 테이블에 DTYPE이 생성되도록 Item 엔티티에 @DiscriminatorColumn 어노테이션을 추가 해보자.

✅ Item 엔티티에 해당 어노테이션 추가

@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn // 해당 어노테이션 추가
// @DiscriminatorColumn(name = "DIS_TYPE") 이름을 지정할 수 있다
public class Item {

    @Id
    @GeneratedValue
    private Long id;

    private String name;
    private int price;
    //..중략 Getter Setter
}
  • @DiscriminatorColumn 어노테이션을 추가한다.
  • 이름을 설정 할 수 있지만 가급적이면 관례를 따르는것이 좋다.

🖨️ 조인 전략 결과 출력

Hibernate:
    /* insert com.hello.jpasample.Movie
        */ insert
        into
            Item
            (name, price, DTYPE, id)
        values
            (?, ?, 'Movie', ?)
  • @DiscriminatorColumn 어노테이션을 추가하였기에, 위와 같은 결과가 출력된다.
  • 부모 - 자식 관계에서 어떤 자식의 타입인지 구분하기 위해 사용.

⚡ DBA 요구사항이 있다고 가정

😀 하위 자식들 A(Album), B(Book), M(Movie) 값을 상위 엔티티 Item 필드에 DTYPE으로 넣어주면 좋을 것 같아요.

✅ 하위 엔티티 @DiscriminatorValue 추가

@Entity
@DiscriminatorValue("A") // 추가
public class Album extends Item {
    //..중략
}
@Entity
@DiscriminatorValue("B") // 추가
public class Book extends Item {
    //..중략
}
@Entity
@DiscriminatorValue("M") // 추가
public class Movie extends Item {
    //..중략
}

✅ @DiscriminatorValue 내부 구성

@Target({TYPE})
@Retention(RUNTIME)

public @interface DiscriminatorValue {

    /**
     * (Optional) The value that indicates that the
     * row is an entity of the annotated entity type.
     *
     * <p> If the <code>DiscriminatorValue</code> annotation is not
     * specified and a discriminator column is used, a
     * provider-specific function will be used to generate a value
     * representing the entity type.  If the <code>DiscriminatorType</code> is
     * <code>STRING</code>, the discriminator value default is the
     * entity name.
     */
    String value();
}
  • DiscriminatorValue default value는 엔티티명이다.

✅ DB 출력 화면

ITEMSAMPLE 테이블을 ITEM 테이블로 봐주시면 감사하겠습니다. 🤣

  • 위와 같이 적용 시 ITEM 테이블에 DTYPE이 M으로 들어간 것을 확인할 수 있다.

✔ 조인 전략 장 단점

👍 장점

  • 테이블 정규화
  • 외래 키 참조 무결성 제약조건 활용 가능
    • 기존 SQL의 기본키(PK), 외래키(FK)를 통해 조인 하는 방식
  • 저장 공간 효율화

👎 단점

  • 조회시 조인을 많이 사용, 성능 저하
  • 조회 쿼리 복잡
  • 데이터 저장 시 INSERT SQL 2번 호출

⚡ 단일 테이블 전략

✅ 단일 테이블 전략 적용

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE) // 단일 테이블 전략
@DiscriminatorColumn // 없어도 된다
public class Item {
    //..중략
}
  • 단일 테이블 전략은 에 사용을 하는것을 권장한다.
  • 테이블 구조가 복잡하지 않은 경우
  • 해당 엔티티의 strategy 속성을 위와 같이 적용 해주면 된다.

🖨️ 단일 테이블 전략 결과 출력

Hibernate:

    create table Item (
       DTYPE varchar(31) not null,
        id bigint not null,
        name varchar(255),
        price integer not null,
        actor varchar(255),
        director varchar(255),
        author varchar(255),
        isbn varchar(255),
        artist varchar(255),
        primary key (id)
    )
  • 하위 엔티티의 필드값이 Item 테이블에 모두 들어온 것을 확인할 수 있다.
  • 하위 엔티티의 테이블이 생성 되지 않는다.
  • @DiscriminatorColumn 어노테이션은 생략 되어도 상관 없다.

✅ DB 출력 화면

ITEMSAMPLE 테이블을 ITEM 테이블로 봐주시면 감사하겠습니다. 🤣

  • ITEM 테이블 하나에 모든 필드값이 초기화 된 것을 볼 수 있다.

✔ 단일 테이블 전략 장 단점

👍 장점

  • 조인이 필요 없으며, 조회 쿼리가 단순함

👎 단점

  • 자식 엔티티가 매핑한 컬럼은 모두 null 허용
  • 단일 테이블에 모든 데이터를 저장하므로 테이블의 사이즈가 커지는 경우 상황에 따라서 조회 성능이 오히려 더 느려질 수 있다.

⚡ 구현 클래스마다 테이블 전략

결론부터 말하면 구현 클래스마다 테이블 전략은 쓰면 안되는 전략이다.

  • Item 테이블을 제거하고 모든 테이블에 필드를 넣어주는 전략이다.

✅ 구현 클래스마다 테이블 전략

@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS) // 구현 클래스마다 테이블 전략
public abstract class Item {
    //..중략
}
  • 단독으로 해당 엔티티가 사용 될 수 있기에, abstract를 통해 추상클래스 선언.

🖨️ 구현 클래스마다 테이블 전략

Hibernate:

    create table Album (
       id bigint not null,
        name varchar(255),
        price integer not null,
        artist varchar(255),
        primary key (id)
    )
Hibernate:

    create table Book (
       id bigint not null,
        name varchar(255),
        price integer not null,
        author varchar(255),
        isbn varchar(255),
        primary key (id)
    )

Hibernate:

    create table Movie (
       id bigint not null,
        name varchar(255),
        price integer not null,
        actor varchar(255),
        director varchar(255),
        primary key (id)
    )
  • 위 전략 사용 시 Item 엔티티는 생성되지 않는다.

⚡ 해당 전략의 문제점

✅ 다형성을 통해 엔티티 조회

public class JpaMainSample {

    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();
            movie.setDirector("영화감독");
            movie.setActor("이준기");
            movie.setName("바람과 함께 사라지다");
            movie.setPrice(10000);

            em.persist(movie);

            em.flush();
            em.clear();

            Item item = em.find(Item.class, movie.getId()); // 여기가 문제
            System.out.println("item = " + item);

            tx.commit();
        } catch (Exception e) {
            tx.rollback();
        } finally {
            em.close();
        }

        emf.close();
    }
}
  • 다형성을 통해 Item 엔티티를 조회 한다.

🖨️ 출력 결과

Hibernate:
    select
        item0_.id as id1_6_0_,
        item0_.name as name2_6_0_,
        item0_.price as price3_6_0_,
        item0_.actor as actor1_11_0_,
        item0_.director as director2_11_0_,
        item0_.author as author1_1_0_,
        item0_.isbn as isbn2_1_0_,
        item0_.artist as artist1_0_0_,
        item0_.clazz_ as clazz_0_
    from
        ( select
            id,
            name,
            price,
            actor,
            director,
            null as author,
            null as isbn,
            null as artist,
            1 as clazz_
        from
            Movie
        union
        all select
            id,
            name,
            price,
            null as actor,
            null as director,
            author,
            isbn,
            null as artist,
            2 as clazz_
        from
            Book
        union
        all select
            id,
            name,
            price,
            null as actor,
            null as director,
            null as author,
            null as isbn,
            artist,
            3 as clazz_
        from
            Album
    ) item0_
where
    item0_.id=?
item = com.hello.jpasample.Movie@f8f56b9
  • 여기서 문제점은 JPA가 union all을 통해 모든 객체를 찾는다는 점이다.
  • 즉, 위에서도 언급 했지만 구현 클래스마다 테이블 전략 사용은 지양하자.

🔥 마지막으로 상속 관계 매핑의 장점

😀 DB 테이블 설계 변경 되니까 설계랑 로직 수정 해주세요.

  • JPA를 사용하지 않는다면 기존에 작성된 쿼리, 비즈니스 로직의 수정 필요.
  • 상속 관계 매핑 전략을 통해 위 같은 상황을 쉽게 방지 할 수 있다.

참고 자료

블로그의 정보

기록하고, 복기하고

ymkim

활동하기