[자바 ORM 표준 JPA 프로그래밍] Chapter 07

YUSHIN KIM·2025년 11월 1일

Chapter 07. 고급 매핑

7.1 상속 관계 매핑

관계형 데이터베이스에는 객체지향 이론의 상속 개념이 없는 대신 슈퍼타입 서브타입 관계(Supter-Type Sub-Type Relationship)라는 유사한 모델링 기법이 있다. ORM의 상속 관계 매핑은 객체의 상속 구조와 데이터베이스의 슈퍼타입 서브타입 관계를 매핑하는 것이다.

슈퍼타입 서브타입 논리 모델을 물리적인 테이블로 구현할 때는 다음과 같은 세 가지 방법 중 하나를 선택할 수 있다.

  • 각각의 테이블로 변환: JPA의 조인 전략, 각각의 요소를 모두 테이블로 만들고 조회 시 조인을 사용한다.
  • 통합 테이블로 변환: JPA의 단일 테이블 전략, 테이블을 하나만 사용해서 통합한다.
  • 서브타입 테이블로 변환: JPA의 구현 클래스마다 테이블 전략, 서브타입마다 하나의 테이블을 만든다.

7.1.1 조인 전략

조인 전략(Joined Strategy)은 엔터티 각각을 모두 테이블로 만들고 자식 테이블이 부모 테이블과 식별자 관계를 맺는다. 정규화로 인해 조회 시 조인이 자주 발생하고 테이블은 타입의 개념이 없기 때문에 자식의 타입을 구분하기 위한 컬럼을 추가해야 한다.

예제 7.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;
	
    // getter, setter ...
}

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

    private String artist;
    
    // getter, setter ...
}

@Entity
@DiscriminatorValue("M")
public class Movie extends Item {
    
    private String director;
    private String actor;
    
    // getter, setter ...
}

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

    private String author;
    private String isbn;
    
    // getter, setter ...
}

매핑 애너테이션 정보는 다음과 같다.

  • @Inheritance(strategy = InheritanceType.JOINED): 부모 클래스에 상속 매핑을 사용하면서 매핑 전략을 조인 전략으로 지정한다.
  • @DiscriminatorColumn(name = "DTYPE"): 부모 클래스에 자식 클래스를 구분할 수 있는 컬럼을 지정한다. 기본 값은 DTYPE이다.
  • @DiscriminatorValue("M"): 엔터티 저장 시 구분 컬럼에 입력할 값을 지정한다. 만약 영화 엔터티를 저장하면 구분 컬럼인 DTYPE에 값 M이 저장된다.

자식 테이블은 기본적으로 부모 테이블의 ID 컬럼 이름을 그대로 사용하는데 만약 변경이 필요하다면 @PrimaryKeyJoinColumn 애너테이션을 사용하면 된다.

예제 7.2. ID 재정의

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

    private String author;
    private String isbn;
    
    // getter, setter ...
}

조인 전략을 정리하면 다음과 같다.

  • 장점
    • 테이블이 정규화된다.
    • 외래 키 참조 무결성 제약 조건을 활용할 수 있다.
    • 저장 공간을 효율적으로 사용한다.
  • 단점
    • 조회 시 조인이 많이 사용되므로 성능이 저하될 수 있다.
    • 조회 쿼리가 복잡하다.
    • 데이터 등록 시 INSERT SQL을 두 번 실행한다.
  • 특징
    • JPA 표준 명세는 구분 컬럼을 사용하도록 하지만 하이버네이트를 포함한 몇몇 구현체는 구분 컬럼(@DiscriminatorColumn) 없이도 동작한다.
  • 관련 애너테이션
    • @PrimaryKeyJoinColumn
    • @DiscriminatorColumn
    • @DiscriminatorValue

7.1.2 단일 테이블 전략

단일 테이블 전략(Single-Table Strategy)은 테이블을 하나만 사용하고 구분 컬럼(DTYPE)을 통해 자식 데이터를 구분한다. 반정규화로 조인을 사용하지 않으므로 일반적으로 성능이 가장 빠르다.

이 전략을 사용할 시 자식 엔터티가 매핑한 컬럼은 모두 null을 허용해야 한다. 자식 엔터티의 타입에 따라 다른 타입에만 고유한 특정 컬럼은 사용하지 않을 수 있기 때문이다. 그리고 테이블 하나에 모든 것을 통합하므로 구분 컬럼을 필수로 사용해야 한다.

예제 7.3. 단일 테이블 전략 매핑

@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;
	
    // getter, setter ...
}

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

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

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

단일 테이블 전략을 정리하면 다음과 같다.

  • 장점
    • 조인이 필요 없으므로 일반적으로 조회 성능이 좋다.
    • 조회 쿼리가 단순하다.
  • 단점
    • 자식 엔터티가 매핑한 컬럼은 모두 null을 허용해야 한다.
    • 단일 테이블에 모든 것을 저장하므로 테이블이 커질 수 있다. 그러므로 상황에 따라서는 조회 성능이 오히려 나빠질 수 있다.
  • 특징
    • 구분 컬럼(@DiscriminatorColumn)을 반드시 사용해야 한다.
    • @DiscriminatorValue를 지정하지 않으면 기본 값으로 엔터티 이름을 사용한다.

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

구현 클래스마다 테이블 전략(Table-per-Concrete-Class Strategy)은 자식 엔터티마다 테이블을 만든다. 일반적으로 추천되는 전략은 아니다.

예제 7.4. 구현 클래스마다 테이블 전략 매핑

@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;
	
    // getter, setter ...
}

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

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

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

구현 클래스마다 테이블 전략을 정리하면 다음과 같다.

  • 장점
    • 서브 타입을 구분해서 처리할 때 효과적이다.
    • not null 제약 조건을 사용할 수 없다.
  • 단점
    • 여러 자식 테이블을 함께 조회할 때 UNION 쿼리가 필요하여 성능이 나쁘다.
    • 자식 테이블을 통합해서 쿼리를 구성하기 어렵다.
  • 특징
    • 구분 컬럼을 사용하지 않는다.

7.2 @MappedSuperclass

@MappedSuperclass 애너테이션은 부모 클래스는 테이블과 매핑되지 않고 상속받는 자식 클래스에게 매핑 정보만 제공하고자 할 때 사용한다. 매핑 정보를 상속할 목적으로만 사용되고 실제 테이블과 매핑되지 않는다는 점에서 추상 클래스와 사용 방식이 유사하다.

예를 들어 위 그림에서 Member, Seller 엔터티는 서로 관련이 없는 엔터티이지만 공통의 속성을 갖고 있다. 그러므로 다음과 같이 공통된 속성에 대한 매핑 정보를 제공하는 부모 엔터티를 정의할 수 있다.

예제 7.5. @MappedSuperclass 매핑

@MappedSuperclass
public abstract class BaseEntity {

    @Id
    @GeneratedValue
    private Long id;

    private String name;
    
    // getter, setter ...
}

@Entity
public class Member extends BaseEntity {

    private String email;
    
    // getter, setter ...
}

@Entity
public class Seller extends BaseEntity {

    private String shopName;
    
    // getter, setter ...
}

부모로부터 물려받은 매핑 정보를 재정의하려면 다음과 같이 @AttributeOverrides 또는 @AttributeOverride 애너테이션을 사용할 수 있다.

부모의 매핑 정보 재정의

@Entity
@AttributeOverride(name = "id", column = @Column(name = "MEMBER_ID"))
public class Member extends BaseEntity { ... }

@Entity
@AttributeOverrides({
	@AttributeOverride(name = "id", column = @Column(name = "MEMBER_ID")),
    @AttributeOverride(name = "name", column = @Column(name = "MEMBER_NAME"))
})
public class Member extends BaseEntity { ... }

@MappedSuperclass의 특징을 요약하면 다음과 같다.

  • 테이블과 매핑되지 않고 자식 클래스에 엔터티의 매핑 정보를 상속하기 위해 사용한다.
  • @MappedSuperclass로 지정한 클래스는 엔터티가 아니므로 EntityManager나 JPQL에서 사용할 수 없다.
  • 클래스 객체를 직접 생성해서 사용할 일은 거의 없으므로 추상 클래스로 정의하는 것이 권장된다.
  • 등록 일자, 수정 일자, 등록자, 수정자와 같이 여러 엔터티에서 공통으로 사용하는 속성을 코드 중복을 줄이며 효과적으로 관리할 수 있다.

7.3 복합 키와 식별 관계 매핑

7.3.1 식별 관계 vs 비식별 관계

식별 관계(Identifying Relationship)

식별 관계는 부모 테이블의 기본 키가 자식 테이블의 기본 키 및 외래 키로 사용되는 관계이다.

비식별 관계(Non-Identifying Relationship)

비식별 관계는 부모 테이블의 기본 키가 자식 테이블의 외래 키로만 사용되는 관계이다.

  • 필수적 비식별 관계(Mandatory): 외래 키에 NULL을 허용하지 않는다.
  • 선택적 비식별 관계(Optional): 외래 키에 NULL을 허용한다.

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

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

기본 키가 단일 키일 경우 @Id 애너테이션만으로 매핑이 가능하지만, 복합 키일 경우 별도의 식별자 클래스를 정의해야 한다. JPA는 영속성 컨텍스트의 1차 캐시에 엔터티 저장 시 엔터티의 식별자를 키로 사용하고 식별자 간 동등성 비교를 위해 equals, hashCode 메서드를 사용한다. 기본 키가 단일 키일 경우 일반적으로 Java의 기본 타입을 사용하므로 equals, hashCode 메서드가 사전 정의되어 있지만 별도의 식별자 클래스 정의 시 사용자가 직접 해당 메서드들을 구현해야 한다.

JPA는 복합 키를 지원하기 위해 @IdClass, @EmbeddedId 애너테이션을 활용한 2가지 방법을 제공한다. 전자는 관계형 데이터베이스와 가까운 방법, 후자는 객체지향과 가까운 방법이다.

@IdClass

부모 클래스의 복합 키를 매핑하기 위해 별도의 식별자 클래스를 정의해야 한다.

예제 7.6. 부모 클래스

@Entity
@IdClass(ParnetId.class)
public class Parent {

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

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

    private String name;
    
    // getter, setter ...
}

부모 클래스 정의에서는 복합 기본 키에 포함되는 각각의 컬럼을 @Id 애너테이션으로 지정했다. 그리고 @IdClass 애너테이션으로 식별자 클래스를 지정했다.

예제 7.7. 식별자 클래스

public class ParentId implements Serializable {

    private String id1;
    private String id2;

    public ParentId() {}

    public ParentId(String id1, String id2) {
        this.id1 = id1;
        this.id2 = id2;
    }

    @Override
    public boolean equals(Object object) {
        if (!(object instanceof ParentId parentId)) return false;
        return Objects.equals(id1, parentId.id1) && Objects.equals(id2, parentId.id2);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id1, id2);
    }
}

@IdClass 애너테이션 사용 시 식별자 클래스는 다음 조건을 만족해야 한다.

  • 식별자 클래스의 속성명과 엔터티에서 사용하는 식별자의 속성명이 같아야 한다ㅏ. 예제에서는 id1, id2에 해당한다.
  • Serializable 마커 인터페이스를 구현해야 한다.
  • equals, hashCode 메서드를 구현해야 한다.
  • 기본 생성자가 있어야 한다.
  • 접근 제어자는 public이어야 한다.

다음과 같이 기본 키가 복합 키인 엔터티를 저장 및 조회할 수 있다.

식별자 클래스를 활용한 엔터티의 저장 및 조회

@Test
@Transactional
void compositeKeyValidation() {
    Parent parent = new Parent();
    parent.setId1("myId1");
    parent.setId2("myId2");
    parent.setName("parentName");
    em.persist(parent);

    ParentId parentId = new ParentId("myId1", "myId2");
    Parent foundParent = em.find(Parent.class, parentId);

    Assertions.assertEquals(foundParent, parent);
}

부모 클래스와 비식별 관계를 맺는 자식 클래스는 다음과 같이 정의한다.

예제 7.8. 자식 클래스

@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;
    
    // getter, setter ...
}

부모 테이블의 기본 키가 복합 키이므로 자식 테이블에서도 매핑 시 @JoinColumns 애너테이션을 사용해 각각의 외래 키 컬럼을 @JoinColumn 애너테이션으로 매핑해야 한다. 예제처럼 name 속성과 referencedColumnName 속성의 값이 동일할 경우 referencedColumnName 속성은 생략해도 된다.

@EmbeddedId

부모 클래스 정의

@Entity
public class Parent {

    @EmbeddedId
    private ParentId id;

    private String name;
   	
    // getter, setter ...
}

식별자 클래스 정의

@Embeddable
public class ParentId implements Serializable {

    @Column(name = "PARENT_ID1")
    private String id1;
    @Column(name = "PARENT_ID2")
    private String id2;
    
    // constructor, equals, hashCode implementations ...
}

@EmbeddedId 애너테이션을 적용 시 식별자 클래스에 기본 키가 직접 매핑된다. 이때 식별자 클래스는 다음 조건을 만족해야 한다.

  • @Embeddable 애너테이션으로 지정해야 한다.
  • Serializable 마커 인터페이스를 구현해야 한다.
  • equals, hashCode 메서드를 구현해야 한다.
  • 기본 생성자가 있어야 한다.
  • 접근 제어자는 public이어야 한다.

식별자 클래스를 활용한 엔터티의 저장 및 조회

@Test
@Transactional
void compositeKeyValidation() {
    Parent parent = new Parent();
    ParentId parentId = new ParentId("myId1", "myId2");
    parent.setId(parentId);
    parent.setName("parentName");
    em.persist(parent);

    ParentId newParentId = new ParentId("myId1", "myId2");
    Parent foundParent = em.find(Parent.class, parentId);

    Assertions.assertEquals(foundParent, parent);
}

복합 키와 equals(), hashCode()

복합 키는 equals(), hashCode() 메서드를 반드시 재정의해야 한다.

식별자 클래스 객체의 동등성 비교

ParentId id1 = new ParentId("myId1", "myId2");
ParentId id2 = new ParentId("myId1", "myId2");

Assertions.assertTrue(id1.equals(id2));

Java의 모든 클래스는 Object 클래스를 상속받는다. Object 클래스에 기본적으로 정의된 equals() 메서드는 참조 값을 기준으로 동등성 비교를 수행하기 때문에 위와 같은 코드를 수행할 때 equals() 메서드가 적절히 재정의되어 있지 않으면 id1, id2를 동등한 객체로 취급하려는 사용자의 의도대로 동작하지 않게 된다.

그리고 두 객체 간 equals() 메서드로 비교한 결과가 true라면 두 객체 각각의 hashCode() 메서드도 동일한 값을 반환해야 한다는 Java의 기본적인 계약을 준수하기 위해 hashCode() 메서드도 equals() 메서드와 같은 기준으로 재정의가 필요하다. 아니면 해시를 활용한 자료구조(대표적으로 1차 캐시) 사용 시 문제가 발생할 수 있다.

식별자 클래스는 보통 equals(), hashCode() 메서드 재정의 시 모든 필드를 사용한다.

@IdClass vs. @EmbeddedId

두 방식은 각각의 장단점이 있다. @EmbeddedId 방식이 객체지향적이고 코드의 중복도 없지만 특정한 상황에 JPQL이 더 길어질 수 있다.

식별자 클래스를 사용하는 두 방식의 JPQL 조회 쿼리

em.createQuery("SELECT p.id.id1, p.id.id2 FROM Parent p");	// @EmbeddedId
em.createQuery("SELECT p.id1, p.id2 FROM Parent p");		// @IdClass

7.3.3 복합 키: 식별 관계 매핑

위 다이어그램은 부모, 자식, 손자까지 기본 키를 전달하는 식별 관계를 표현한다. 식별 관계에서 자식 테이블은 부모 테이블의 기본 키를 포함하여 복합 키인 기본 키를 구성해야 하므로 @IdClass 또는 @EmbeddedId 애너테이션을 활용하여 식별자를 매핑해야 한다.

@IdClass와 식별 관계

예제 7.9. @IdClass로 식별 관계 매핑하기

// 부모 엔터티
@Entity
public class Parent {

    @Id
    @Column(name = "PARENT_ID")
    private String id;
    private String name;
    
    // getter, setter ...
}

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

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

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

    private String name;
    
    // getter, setter ...
}

// 자식 ID
public class ChildId implements Serializable {

    private String parent;
    private String childId;

	// equals, hashCode implementations ...
}

// 손자 엔터티
@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;
	
    // getter, setter ...
}

// 손자 ID
public class GrandChildId implements Serializable {

    private ChildId child;
    private String parentId;
    
    // equals, hashCode implementations ...
}

식별 관계는 기본 키 매핑(Id), 연관 관계 매핑(@ManyToOne, @JoinColumn)이 함께 이루어진다.

@EmbeddedId와 식별 관계

@EmbeddedId 애너테이션으로 식별 관계를 구성할 때는 @MapsId 애너테이션을 활용해야 한다.

예제 7.10. @EmbeddedId 식별 관계 매핑하기

// 부모 엔터티
@Entity
public class Parent {

    @Id
    @Column(name = "PARENT_ID")
    private String id;
    private String name;
    
    // getter, setter ...
}

// 자식 엔터티
@Entity
public class Child {

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

    private String name;
    
    // getter, setter ...
}

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

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

    @Column(name = "CHILD_ID")
    private String id;
    
    // equals, hashCode implementations ...
}

// 자손 엔터티
@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;
    
    // getter, setter ...
}

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

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

    @Column(name = "GRANDCHILD_ID")
    private String id;
    
    // equals, hashCode implementations ...
}

식별 관계로 사용할 연관 관계의 필드를 @MapsId 애너테이션으로 지정하면 외래 키와 매핑한 연관 관계를 기본 키에도 매핑하겠다는 걸 나타낼 수 있다. 속성 값으로는 식별자 클래스의 기본 키 필드 이름을 지정하면 된다.

7.3.4 비식별 관계로 구현

예제 7.11. 비식별 관계 매핑하기

// 부모 엔터티
@Entity
public class Parent {

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

    private String name;
    
    // getter, setter ...
}

// 자식 엔터티
@Entity
public class Child {

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

    private String name;

    @ManyToOne
    @JoinColumn(name = "PARENT_ID")
    private Parent parent;
    
    // getter, setter ...
}

// 자손 엔터티
@Entity
public class GrandChild {

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

    private String name;

    @ManyToOne
    @JoinColumn(name = "CHILD_ID")
    private Child child;
    
    // getter, setter ...
}

비식별 관계의 경우 매핑도 쉽고 코드도 단순하다. 그리고 이 경우 복합 키가 없기에 복합 키 클래스를 정의하지 않아도 된다.

7.3.5 일대일 식별 관계

일대일 식별 관계는 자식 테이블의 기본 키 값으로 부모 테이블의 기본 키 값만 사용하기 때문에 부모 테이블의 기본 키가 복합 키가 아니면 자식 테이블의 기본 키도 복합 키로 구성하지 않아도 된다.

예제 7.12. 일대일 식별 관계 매핑하기

// 부모 엔터티
@Entity
public class Board {

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

    private String title;

    @OneToOne(mappedBy = "board")
    private BoardDetail boardDetail;
    
    // getter, setter ...
}

// 자식 엔터티
@Entity
public class BoardDetail {

    @Id
    private Long boardId;

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

    private String content;
    
    // getter, setter ...
}

BoardDetail과 같이 식별자가 단일 컬럼이라면 @MapsId 애너테이션을 사용하고 속성 값을 비워두면 된다. 그러면 @Id 애너테이션으로 식별자로 지정한 BoardDetail.boardId와 매핑된다.

예제 7.13. 일대일 식별 관계 저장

@Test
@Transactional
void saveBoard() {
    Board board = new Board();
    board.setTitle("제목");
    em.persist(board);

    BoardDetail boardDetail = new BoardDetail();
    boardDetail.setContent("내용");
    boardDetail.setBoard(board);
    em.persist(boardDetail);

    Assertions.assertEquals(board.getId(), boardDetail.getBoardId());
}

7.3.6 식별, 비식별 관계의 장단점

데이터베이스 설계 관점에서 식별 관계보다 비식별 관계가 선호되는 이유는 다음과 같다.

  • 식별 관계는 부모 테이블의 기본 키를 자식 테이블로 전파하면서 자식 테이블의 기본 키 컬럼이 점점 늘어난다. 그러므로 조인 시 SQL이 복잡해지고 기본 키 인덱스가 불필요하게 커질 수 있다.
  • 식별 관계는 2개 이상의 컬럼을 합해 복합 기본 키를 정의해야 하는 경우가 잦다.
  • 식별 관계를 사용할 때 기본 키로 비즈니스적인 의미가 있는 자연 키 컬럼을 조합하는 경우가 잦다. 반면 비식별 관계의 기본 키는 비즈니스와 전혀 관계 없는 인조 키를 주로 사용한다. 비즈니스 요구사항은 시간이 지남에 따라 언젠가 변하기 때문에 식별 관계의 자연 키 컬럼들이 자식 클래스로 널리 전파되면 변경하기 힘들어진다.
  • 식별 관계는 부모 테이블의 기본 키를 자식 테이블의 기본 키로 사용하므로 비식별 관계보다 테이블 구조가 유연하지 못하다.

객체 관계 매핑 관점에서 식별 관계보다 비식별 관계가 선호되는 이유는 다음과 같다.

  • 일대일 관계를 제외하고 식별 관계는 2개 이상의 컬럼을 묶은 복합 기본 키를 사용한다. JPA에서 복합 키는 별도의 식별자 클래스를 정의해야 하기 때문에 단일 컬럼 기본 키에 비해 과정이 번거롭다.
  • 비식별 관계의 기본 키는 주로 인조 키를 사용하는데 JPA는 @GeneratedValue 애너테이션과 같이 인조 키를 생성하기 위한 편리한 방법을 제공한다.

하지만 식별 관계가 가지는 장점도 있다. 클러스터링 인덱스를 활용하기 좋고, 상위 테이블들의 기본 키 컬럼을 자손 테이블들이 갖고 있으므로 특정 상황에 조인을 수행하지 않고도 조회를 수행할 수 있다.

부모 아이디가 A인 모든 자식 조회

SELECT * FROM CHILD
WHERE PARENT_ID = 'A'

부모 아이디가 A고 자식 아이디가 B인 자식 조회

SELECT * FROM CHILD
WHERE PARENT_ID = 'A' AND CHILD_ID = 'B'

ORM 신규 프로젝트 진행 시 추천되는 방법은 가능하면 비식별 관계를 사용하고 기본 키는 Long 타입의 인조 키를 사용하는 것이다. 그리고 선택적 비식별 관계보다는 필수적 비식별 관계를 사용하는 것이 조인 시 내부 조인 사용을 가능하게 해 주므로 더 유리하다.

7.4 조인 테이블

데이터베이스 테이블의 연관 관계를 설계하는 방법은 크게 2가지가 있다.

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

조인 컬럼 사용

테이블 간의 관계는 주로 조인 컬럼(외래 키)을 사용해 관리한다. 위 그림은 회원과 사물함 간 선택적 비식별 관계를 나타내며 JPA는 조인 시 외부 조인(OUTER JOIN)을 사용하게 되어 있다.

조인 테이블 사용

조인 테이블을 사용하는 방법은 연관 관계를 관리하는 조인 테이블(MEMBER_LOCKER)을 추가하고 이 테이블에서 두 테이블의 외래 키로 연관 관계를 관리한다. 조인 테이블을 사용하면 관리해야 하는 테이블이 늘어나고 조인 대상 테이블이 추가된다는 문제점이 있다.

  • 객체와 테이블 매핑 시 조인 컬럼은 @JoinColumn 애너테이션, 조인 테이블은 @JoinTable 애너테이션으로 매핑한다.
  • 조인 테이블은 주로 다대다 관계를 일대다, 다대일 관계로 풀어내기 위해 사용한다. 하지만 일대일, 일대다, 다대일 관계에서도 사용한다.

7.4.1 일대일 조인 테이블

일대일 관계를 조인 테이블으로 매핑하려면 조인 테이블의 외래 키가 될 두 컬럼에 유니크 제약 조건이 설정되어야 한다.

예제 7.14. 일대일 조인 테이블 매핑

// 부모 엔터티
@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;
    
    // getter, setter ...
}

// 자식 엔터티
@Entity
public class Child {

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

    private String name;
    
    // getter, setter ...
}

@JoinTable 애너테이션의 속성은 다음과 같다.

  • name: 매핑할 조인 테이블 이름
  • joinColumns: 현재 엔터티를 참조하는 외래 키
  • inverseJoinColumns: 반대 방향 엔터티를 참조하는 외래 키

양방향 매핑 방법

public class Child {
	...
    @OneToOne(mappedBy="child")
    private Parent parent;
}

7.4.2 일대다 조인 테이블

일대다 관계를 조인 테이블로 매핑할 때는 Many-side의 연관 컬럼인 CHILD_ID에 유니크 제약 조건을 걸어야 한다.

예제 7.15. 일대다 단방향 조인 테이블 매핑

// 부모 엔터티
@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> child;
    
    // getter, setter ...
}

// 자식 엔터티
@Entity
public class Child {

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

    private String name;
    
    // getter, setter ...
}

7.4.3 다대일 조인 테이블

다대일은 일대다와 방향만 반대일 뿐이므로 일대다와 동일한 방식이다. 이번에는 양방향 관계로 매핑한다.

예제 7.16. 다대일 양방향 조인 테이블 매핑

// 부모 엔터티
@Entity
public class Parent {

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

    private String name;

    @OneToMany(mappedBy = "parent")
    private List<Child> child;
    
    // getter, setter ...
}

// 자식 엔터티
@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;
    
    // getter, setter ...
}

7.4.4 다대다 조인 테이블

다대다 관계를 조인 테이블로 매핑하려면 조인 테이블의 두 컬럼으로 구성된 하나의 복합 유니크 제약 조건을 걸어야 한다. 만약 조인 테이블에 컬럼을 추가해야 한다면 새로운 엔터티를 정의하여 다대다 관계를 두 개의 다대일 관계로 풀어내야 한다.

예제 7.17. 다대다 조인 테이블 매핑

// 부모 엔터티
@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> child;
    
    // getter, setter ...
}

// 자식 엔터티
@Entity
public class Child {
    
    @Id
    @GeneratedValue
    @Column(name = "CHILD_ID")
    private Long id;
    
    private String name;
	
    // getter, setter ...
}

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

@SecondaryTable 애너테이션을 사용하면 하나의 엔터티에 여러 테이블을 매핑할 수 있다.

예제 7.18. 하나의 엔터티에 여러 테이블 매핑

@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;
    
    // getter, setter ...
}

Board 엔터티는 @Table 애너테이션으로 BOARD 테이블과, @SecondaryTable 애너테이션으로 BOARD_DETAIL 테이블과 매핑된다.

@SecondaryTable 애너테이션의 속성은 다음과 같다.

  • name: 매핑할 다른 테이블의 이름
  • pkJoinColumns: 매핑할 다른 테이블의 기본 키 컬럼

@Column(table = "BOARD_DETAIL") 애너테이션으로 지정한 필드만 다른 테이블로 매핑되고 나머지는 모두 기본 테이블과 매핑된다.

더 많은 테이블을 매핑하려면 @SecondaryTables 애너테이션을 사용할 수 있다.

profile
안녕하세요

0개의 댓글