자바 ORM 표준 JPA 프로그래밍

[자바 ORM 표준 JPA 프로그래밍] 7장 - 고급 매핑

jny0 2023. 6. 12. 19:58

 

 

자바 ORM 표준 JPA 프로그래밍 | 김영한 - 교보문고

자바 ORM 표준 JPA 프로그래밍 | 자바 ORM 표준 JPA는 SQL 작성 없이 객체를 데이터베이스에 직접 저장할 수 있게 도와주고, 객체와 관계형 데이터베이스의 차이도 중간에서 해결해준다. 이 책은 JPA

product.kyobobook.co.kr

 

1. 상속 관계 매핑

  • 관계형 데이터베이스에는 객체지향 언어에서 다루는 상속이라는 개념이 없고,
  • 대신 상속과 유사한 슈퍼타입 서브타입 관계라는 모델링 기법이 있다.
  • 상속 관계 매핑은 객체의 상속구조와 데이터베이스의 슈퍼타입 서브타입 관계를 매핑하는 것

  • 조인 전략 : 각각을 모두 테이블로 만들고 조회할 때 조인 사용
  • 단일 테이블 전략 : 테이블을 하나만 사용해 통합
  • 구현클래스마다 테이블 전략 : 서브타입마다 하나의 테이블을 만들어 사용

 

1.1 조인 전략

  • 엔티티 각각을 모두 테이블로 만들고 자식 테이블이 부모 테이블의 기본키를 받아서 기본키+외래키로 사용
  • 테이블은 타입의 개념이 없기 때문에 타입을 구분하는 컬럼을 추가해야 함
@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn(name = "DTYPE")
public abstract class Item {

    @Id @GeneratedValue
    @Column(name = "ITEM_ID")
    private Long id;
    private String name;
    private int price;
}

@Entity
@DiscriminatorValue("A")
public class Album extends Item {

    private String artist;
}

@Entity
@DiscriminatorValue("M")
public class Movie extends Item {

    private String director;
    private String actor;
}

@Entity
@DiscriminatorValue("B")
@PrimaryKeyJoinColumn(name = "BOOK_ID")
public class Book extends Item {

    private String author;
    private String isbn;
}
  • 상속매핑은 부모 클래스에 @Inheritance를 사용해야 한다
    • 조인 전략을 사용하므로 매핑 전략 strategy = InheritanceType.JOINED 를 지정
  • @DiscriminatorColumn 으로 부모 클래스에 구분 컬럼을 지정한다
    • 기본값 DTYPE
    • 하이버네이트를 포함한 몇 구현체는 구분 컬럼 없이도 동작
  • @DiscriminatorValue 로 엔티티를 저장할 때 구분 컬럼에 입력할 값을 저장한다.
  • 만약 자식 테이블의 기본 키 컬럼을 변경하고 싶으면 @PrimaryKeyJoinColumn 를 사용
    • 예 : @PrimaryKeyJoinColumn(name = "BOOK_ID")
    • name 속성을 통해 자식 테이블의 기본 키 컬럼명을 지정한다.
    • name 속성을 사용하지 않으면 부모 테이블의 컬럼명을 그대로 사용한다.

장점

  • 테이블이 정규화 됨
  • 외래 키 참조 무결성 제약조건을 활용할 수 있음
  • 저장공간을 효율적으로 사용

단점

  • 조회할 때 조인이 많이 사용되므로 성능이 저하될 수 있음
  • 조회 쿼리가 복잡해짐
  • 데이터를 등록할 INSERT SQL이 두 번 실행됨

 

1.2 단일 테이블 전략

  • 테이블을 하나만 사용하고 구분 컬럼으로 어떤 자식 데이터가 저장되었는지 구분한다.
  • 조회할 때 조인을 사용하지 않으므로 일반적으로 가장 빠르다
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "DTYPE")
public abstract class Item {

    @Id
    @GeneratedValue
    @Column(name = "ITEM_ID")
    private Long id;
    private String name;
    private int price;

}

@Entity
@DiscriminatorValue("A")
public class Album extends Item {
        ...
}

@Entity
@DiscriminatorValue("M")
public class Movie extends Item {
        ...
}

@Entity
@DiscriminatorValue("B")
public class Book extends Item {

        private String author;
    private String isbn;

}
  • 매핑전략 InheritanceType.SINGLE_TABLE 를 지정
  • 자식 엔티티가 매핑한 컬럼은 모두 null을 허용해야 한다.
    • 만약 Book 엔티티를 저장하면 Item 테이블의 AUTHOR, ISBN 컬럼만 사용하고 다른 엔티티와 매핑된 컬럼은 사용하지 않으므로 null 이 입력된다.
  • @DiscriminatorColumn 로 구분 컬럼을 꼭 지정해야 한다.
  • @DiscriminatorValue를 지정하지 않으면 기본으로 엔티티 이름을 사용한다.

 

장점

  • 조인이 필요 없으므로 일반적으로 조회 성능이 빠르다
  • 조회 쿼리가 단순하다

 

단점

  • 자식 엔티티가 매핑한 컬럼은 모두 null을 허용해야 한다.
  • 단일 테이블에 모든 것을 저장하므로 테이블이 커질 수 있다.
    • 상황에 따라서는 조회 성능이 오히려 느려질 수 있다.

 

 

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

  • 자식 엔티티마다 테이블을 만들고 테이블 각각에 필요한 컬럼이 모두 있다.
@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
public abstract class Item {

    @Id
    @GeneratedValue
    @Column(name = "ITEM_ID")
    private Long id;
    private String name;
    private int price;

}

@Entity
public class Album extends Item {
        ...
}

public class Movie extends Item {
        ...
}

@Entity
public class Book extends Item {
        ...
}
  • 매핑 전략 InheritanceType.TABLE_PER_CLASS 를 지정
  • 구분 컬럼을 사용하지 않는다.
  • 일반적으로 추천하지 않는 전략이므로 조인이나 단일 테이블 전략을 고려하는 것이 낫다.

 

장점

  • 서브 타입을 구분해서 처리할 때 효과적이다
  • not null 제약조건을 사용할 수 있다

 

단점

  • 여러 자식 테이블과 함께 조회할 때 성능이 느리다 (SQL에 UNION을 사용해야 함)
  • 자식 테이블을 통합해서 쿼리하기 어렵다.

 

 

2. @MappedSuperclass

지금까지의 상속 관계 매핑은 부모 클래스와 지식 클래스를 모두 데이터베이스 테이블과 매핑했다.

부모 클래스는 테이블과 매핑하지 않고, 상속받는 자식클래스에게 매핑 정보만 제공하고 싶으면 @MappedSuperclass 를 사용하면 된다.

@MappedSuperclass
public abstract class BaseEntity {

    @Id @GeneratedValue
    private Long id;
    private String name;
}

@Entity
public class Member extends BaseEntity {

    // ID 상속
    // NAME 상속
    private String email;
        ...
}

@Entity
public class Seller extends BaseEntity {

    // ID 상속
    // NAME 상속
    private String shopName;
        ...
}
  • Member와 Seller의 공통 속성 id, name을 부모 클래스인 BaseEntity에 모음
    • 자식 엔티티들이 상속을 통해 BaseEntity의 매핑 정보를 물려받음
    • BaseEntity는 테이블과 매핑할 필요가 없고 매핑 정보만 제공하면 되기 때문에 @MappedSuperclass를 사용
    • @MappedSuperclass로 지정한 클래스는 엔티티가 아니므로 em.find() 나 JPQL에서 사용할 수 없다.
    • 직접 생성해서 사용할 일은 없으므로 추상 클래스로 만드는 것을 권장
  • 자식 엔티티에서 재정의
    • 매핑 정보 재정의 : @AttributeOverride, @AttributeOverrides
    • 연관관계 재정의 : @AssociationOverride, @AssociationOverrides

 

3. 복합 키와 식별 관계 매핑

 

3.1 식별 관계 vs 비식별 관계

식별 관계 : 부모 테이블의 기본 키를 내려받아 자식 테이블의 기본키+외래키로 사용하는 관계

비식별 관계 : 부모 테이블의 기본 키를 받아서 자식 테이블의 외래 키로만 사용하는 관계

  • 필수적 비식별 관계 : 외래 키에 NULL을 허용하지 않음 (연관관계 필수)
  • 선택적 비식별 관계 : 외래 키에 NULL 허용

최근에는 비식별 관계를 주로 사용하고 필요한 곳에만 식별 관계를 사용하는 추세이다.

 

3.2 복합 키 : 비식별 관계 매핑

JPA는 복합 키를 지원하기 위해 @IdClass@EmbeddedId 2가지 방법을 제공한다.

@IdClass 는 관계형 데이터베이스에 가까운 방법이고 @EmbeddedId 는 좀 더 객체지향에 가까운 방법이다.

 

@IdClass

  • PARENT 테이블은 복합 기본 키 사용 → 복합 키를 매핑하기 위해 식별자 클래스 필요
@IdClass(ParentId.class)
public class Parent {

    @Id
    @Column(name = "PARENT_ID1")
    private String id1; // ParentId.id1과 연결

    @Id
    @Column(name = "PARENT_ID2")
    private String id2; // ParentId.id2와 연결

    private String name;
        ...
}

@Entity
public class Child {

    @Id
    private String id;

    @ManyToOne
    @JoinColumns({
            @JoinColumn(name = "PARENT_ID1",
                referencedColumnName = "PARENT_ID1"),
            @JoinColumn(name = "PARENT_ID2",
                referencedColumnName = "PARENT_ID2")
    })
    private Parent parent;
}

public class ParentId implements Serializable {

    private String id1;
    private String id2;

        // 기본 생성자
        // equals
        // hashCode
}
  • 부모의 기본 키 컬럼이 복합키 이므로 자식 테이블의 외래 키도 복합키이다.
    • 여러 컬럼을 매핑해야하므로 @JoinColumns 사용
    • 각각의 외래 키 컬럼을 @JoinColumn로 매핑
  • 식별자 클래스의 속성 명과 엔티티에서 사용하는 식별자의 속성명이 같아야 한다.

 

@EmbeddedId

  • Parent 클래스에서 식별자 클래스를 직접 사용하고 @Embeddable를 적어주면 됨
@Entity
public class Parent {

    @EmbeddedId
    private ParentId id;
    private String name;
}

@Embeddable
public class ParentId implements Serializable {

    @Column(name = "PARENT_ID1")
    private String id1;

    @Column(name = "PARENT_ID2")
    private String id2;

        // 기본 생성자
        // equals
        // hashCode
}

 

@IdClass, @EmbeddedId 사용 시 식별자 클래스 조건

  • Serializable 인터페이스를 구현해야 한다.

Serializable (직렬화)
: 자바 시스템 내부에서 사용되는 객체 또는 데이터를 외부의 자바 시스템에서도 사용할 수 있도록 byte 형태의 데이터로 변환하는 기술

  • equals, hashCode를 구현해야 한다.
  • 기본 생성자가 있어야 한다.
  • 식별자 클래스는 public 이어야 한다.

 

복합 키와 equals(), hashCode()

  • 영속성 컨텍스트는 엔티티의 식별자를 키로 사용하여 관리한다.
  • equals()hashCode()를 통해 비교할 때 식별자 객체의 동등성이 지켜지지 않으면 문제가 발생한다.
  • 따라서 equals()hashCode() 를 적절히 오버라이딩해서 구현해야 한다.

 

3.3 복합 키 : 식별 관계 매핑

 

@IdClass와 식별 관계

  • 식별 관계는 기본키와 외래키를 같이 매핑해야 하기 때문에 식별자 매핑인 @Id와 연관관계 매핑인 @ManyToOne을 같이 사용
// 부모
@Entity
public class Parent { 

    @Id
    @Column(name = "PARENT_ID")
    private String id;
    private String name;
}

// 자식
@Entity
@IdClass(ChildId.class)
public class Child {

    @Id
    @ManyToOne
    @JoinColumn(name = "PARENT_ID")
    public Parent parent;

    @Id
    @Column(name = "CHILD_ID")
    private String childId;

    private String name;

}

// 자식 ID
public class ChildId implements Serializable {

    private String parent; // Child.parent 매핑
    private String childId; // Child.childId 매핑

    // 기본 생성자
        // equals
        // hashCode
}

// 손자
@Entity
@IdClass(GrandChildId.class)
public class GrandChild {

    @Id
    @ManyToOne
    @JoinColumns({
            @JoinColumn(name = "PARENT_ID")
            @JoinColumn(name = "CHILD_ID")
    })
    private Child child;

    @Id
    @Column(name = "GRANDCHILD_ID")
    private String id;

    private String name;

}

// 손자 ID
public class GrandChildId implements Serializable {

    private ChildId child; // GrandChild.child 매핑
    private String id; // GrandChild.id 매핑

}

 

@EmbeddedId와 식별 관계

  • @EmbeddedId 로 식별 관계를 구성할 때는 @Id 대신 @MapsId 를 사용
    • @MapsId는 외래 키와 매핑한 연관관계를 기본 키에도 매핑하겠다는 뜻
// 부모
@Entity
public class Parent {

    @Id
    @Column(name = "PARENT_ID")
    private String id;
    private String name;
}

// 자식
@Entity
public class Child {

    @EmbeddedId
    private ChildId id;

    @MapsId("parentId") // ChildId.parentId 매핑
    @ManyToOne
    @JoinColumn(name = "PARENT_ID")
    public Parent parent;

    private String name;
}

// 자식 ID
@Embeddable
public class ChildId implements Serializable {

    private String parentid; // @MapsId("parentId")로 매핑

    @Column(name = "CHILD_ID")
    private String id;

        // 기본 생성자
        // equals
        // hashCode
}

// 손자
@Entity
public class GrandChild {

    @EmbeddedId
    private GrandChildId id;

    @MapsId("childId") // GrandChildId.childId 매핑
    @ManyToOne
    @JoinColumns({
            @JoinColumn(name = "PARENT_ID"),
            @JoinColumn(name = "CHILD_ID")
    })
    private Child child;

    private String name;
}

// 손자 ID
@Embeddable
public class GrandChildId implements Serializable {

    private ChildId childid; // @MapsId("childId")로 매핑

    @Column(name = "GRANDCHILD_ID")
    private String id;

        // 기본 생성자
        // equals
        // hashCode
}

 

3.4 비식별 관계로 구현

@Entity
public class Parent {

    @Id
    @GeneratedValue
    @Column(name = "PARENT_ID")
    private Long id;

    private String name;
}

@Entity
public class Child {

    @Id
    @GeneratedValue
    @Column(name = "CHILD_ID")
    private Long id;

    private String name;

    @ManyToOne
    @JoinColumn(name = "PARENT_ID")
    private Parent parent;
}

@Entity
public class GrandChild {

    @Id
    @GeneratedValue
    @Column(name = "GRANDCHILD_ID")
    private Long id;

    private String name;

    @ManyToOne
    @JoinColumn(name = "CHILD_ID")
    private Child child;
}
  • 식별 관계보다 매핑도 쉽고 코드도 간단하다

 

3.5 일대일 식별 관계

@Entity
public class Board {

    @Id
    @GeneratedValue
    @Column(name = "BOARD_ID")
    private Long id;

    private String title;

    @OneToOne(mappedBy = "board")
    private BoardDetail boardDetail;
}

@Entity
public class BoardDetail {

    @Id
    private Long boardId;

    @MapsId // BoardDetail.boardId 매핑
    @OneToOne
    @JoinColumn(name = "BOARD_ID")
    private Board board;

    private String content;
}
  • 자식 테이블의 기본 키 값으로 부모 테이블의 기본 키 값만 사용
    • 부모 테이블의 기본 키가 복합 키가 아니면 자식 테이블도 복합 키 X
  • 식별자가 단순히 컬럼 하나면 @MapsId를 사용하고 속성값은 비워두면 된다.

 

3.6 식별관계와 비식별관계의 장단점

식별 관계는 기본 키 인덱스를 활용하기 좋고, 특정 상황에 조인 없이 하위 테이블만으로 검색할 수 있는 장점이 있지만 아래와 같은 단점들로 인해 식별 관계보다 비식별 관계를 선호한다

  • 데이터베이스 설계 관점
    • 부모 테이블의 기본 키를 자식 테이블로 전파하면서 자식 테이블의 기본 키 컬럼이 점점 늘어난다. 조인할 때 SQL이 복잡해지고 기본 키 인덱스가 불필요하게 커질 수 있다
    • 식별 관계는 2개 이상의 컬럼을 합해서 복합 기본 키를 만들어야하는 경우가 많다.
    • 식별 관계에서 기본 키로 사용하는 자연 키 컬럼들이 자식에 손자까지 전파되면 나중에 비지니스 요구사항이 변경되었을 때 대처가 어렵다
  • 객체 관계 매핑의 관점
    • 식별관계는 일대일 매핑을 제외하고는 복합 키를 사용한다. JPA에서 복합 키는 컬럼이 하나인 기본 키를 매핑하는 것보다 많은 노력이 필요하다
    • 비식별 관계의 기본 키는 주로 대리 키를 사용하는데, JPA는 대리 키를 생성하기 위한 편리한 방법을 제공한다.

💡추천 : 비식별 관계를 사용하고 기본 키는 Long 타입의 대리키를 사용하는 것

 

4. 조인 테이블

데이터베이스 테이블의 연관관계를 설계하는 방법

  • 조인 컬럼 (외래키 사용)
  • 조인 테이블 (테이블 사용)

 

4.1 일대일 조인 테이블

  • 조인 테이블의 외래 키 컬럼 각각에 총 2개의 유니크 제약 조건을 걸어야 함
@Entity
public class Parent {

    @Id
    @GeneratedValue
    @Column(name = "PARENT_ID")
    private Long id;

    private String name;

    @OneToOne
    @JoinTable(name = "PARENT_CHILD",
            joincolumns = @JoinColumn(name = "PARENT_ID"),
            inverseJoinColumns = @JoinColumn(name = "CHILD_ID")
    )
    private Child child;
}

@Entity
public class Child {

    @Id
    @GeneratedValue
    @Column(name = "CHILD_ID")
    private Long id;
    private String name;
}
  • @JoinTable 사용
    • name : 매핑할 조인 테이블 이름
    • joincolumns : 현재 엔티티를 참조하는 외래 키
    • inverseJoinColumns : 반대방향 엔티티를 참조하는 외래 키

 

4.2 일대다 조인 테이블

@Entity
public class Parent {

    @Id @GeneratedValue
    @Column(name = "PARENT_ID")
    private Long id;

    private String name;

    @OneToMany
    @JoinTable(name = "PARENT_CHILD",
            joincolumns = @JoinColumn(name = "PARENT_ID"),
            inverseJoinColumns = @JoinColumn(name = "CHILD_ID")
    )
    private List<Child> children = new ArrayList<>();
}

@Entity
public class Child {

    @Id @GeneratedValue
    @Column(name = "CHILD_ID")
    private Long id;
    private String name;
}

 

4.3 다대일 조인 테이블

  • 일대다에서 방향만 반대
@Entity
public class Parent {

    @Id @GeneratedValue
    @Column(name = "PARENT_ID")
    private Long id;

    private String name;

    @OneToMany(mappedBy = "parent")
    private List<Child> children = new ArrayList<>();

}

@Entity
public class Child {

    @Id @GeneratedValue
    @Column(name = "CHILD_ID")
    private Long id;

    private String name;

    @ManyToOne(optional = false)
    @JoinTable(name = "PARENT_CHILD",
            joincolumns = @JoinColumn(name = "CHILD_ID"),
            inverseJoinColumns = @JoinColumn(name = "PARENT_ID")
    )
    private Parent parent;
}

 

4.4 다대다 조인 테이블

  • 조인 테이블의 두 컬럼을 합해서 하나의 복합 유니크 제약 조건을 걸어야 함
@Entity
public class Parent {

    @Id
    @GeneratedValue
    @Column(name = "PARENT_ID")
    private Long id;

    private String name;

    @ManyToMany
    @JoinTable(name = "PARENT_CHILD",
            joincolumns = @JoinColumn(name = "PARENT_ID"),
            inverseJoinColumns = @JoinColumn(name = "CHILD_ID")
    )
    private List<Child> children = new ArrayList<>();

}

@Entity
public class Child {

    @Id
    @GeneratedValue
    @Column(name = "CHILD_ID")
    private Long id;

    private String name;

}

 

4.5 엔티티 하나에 여러 테이블 매핑

@SecondaryTable을 사용하면 한 엔티티에 여러 테이블을 매핑할 수 있다.

@Entity
@Table(name = "BOARD")
@SecondaryTable(name = "BOARD_DETAIL",
    pkJoinColumns = @PrimaryKeyJoinColumn(name = "BOARD_DETAIL_ID"))
public class Board {

    @Id
    @GeneratedValue
    @Column(name = "BOARD_ID")
    private Long id;

    private String title;

    @Column(table = "BOARD_DETAIL")
    private String content;

}
  • @SecondaryTable
    • name : 매핑할 다른 테이블의 이름
    • pkJoinColumns : 매핑할 다른 테이블의 기본 키 컬럼 속성