자바 ORM 표준 JPA 프로그래밍 - 기본편 #7 고급 매핑

Lee Han Sol·2021년 10월 19일
0
post-thumbnail

자바 ORM 표준 JPA 프로그래밍 - 기본편

이 글은 김영한님의 Inflearn 강의를 학습한 내용을 정리하였습니다.
글에 포함된 그림의 출처는 자바 ORM 표준 JPA 프로그래밍 - 기본편 강의와 자바 ORM 표준 JPA 프로그래밍입니다.

목표

객체의 상속 관계를 데이터베이스에 어떻게 매핑하는 방법인 상속 관계 매핑과 데이터베이스의 식별자가 하나 이상일 때 매핑하는 방법인 복합 키와 식별 관계 매핑에 대해 알아본다.

상속 관계 매핑

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

슈퍼타입 서브타입 논리 모델을 실제 물리 모델인 테이블로 구현할 때는 3가지 방법을 선택할 수 있다.

  • 각각의 테이블로 변환
    모두 테이블로 만들고 조회할 때 조인을 사용한다. (JPA에서는 조인 전략이라 한다.)

  • 통합 테이블로 변환
    모든 칼럼을 하나의 테이블로 구성한다. (JPA에서는 단일 테이블 전략이라 한다.)

  • 서브타입 테이블로 변환
    서브 타입마다 하나의 테이블을 만든다. (JPA에서는 구현 클래스마다 테이블 전략이라 한다.)

예제와 함께 각각의 방법을 알아보자.

조인 전략

조인 전략은 엔티티 각각을 모두 테이블로 만들고 자식 테이블이 부모 테이블의 기본 키를 받아서 기본 키 + 외래 키로 사용하는 전략이다.

특징

  • 조회할 때 조인을 자주 사용한다.
    부모 테이블의 기본 키를 받아서 기본 키 + 외래 키를 사용하는 전략이기 때문에 조회할 때 조인을 자주 사용한다.

  • 타입을 구분하는 컬럼을 추가한다.
    객체는 타입으로 구분할 수 있지만 테이블은 타입의 개념이 없기 때문에 타입 구분을 위한 칼럼을 추가한다.

장점

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

단점

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

예제

예제 코드와 사용된 주요 어노테이션을 보자.

Item.java

Album.java

Movie.java

Book.java

예제 코드에서 사용된 주요 어노테이션을 알아보자.

  • @Inheritance(strategy = InherianceType.JOINED)
    상속 매핑은 부모 클래스에 @Inheritance를 사용해야 한다. 그리고 매핑 전략을 지정한다. 여기서는 조인 전략을 사용하므로 strategy 속성으로 InheritanceType.JOINED를 설정했다.

  • @DiscriminatorColumn(name = "DTYPE")
    부모 클래스에 구분 컬럼을 지정한다. 이 컬럼으로 저장된 자식 테이블을 구분할 수 있다. (기본값은 DTYPE이다.)

  • @Disciminator("M")
    엔티티를 저장할 때 구분 컬럼에 입력할 값을 지정한다. 만약 Movie 엔티티를 저장하면 구분 컬럼인 DTYPE에 M이 저장된다.

  • @PrimaryKeyJoinColumn(name = "book_id")
    자식 테이블의 기본 키 컬럼명을 지정한다. Book 엔티티에 기본 키 + 외래 키의 컬럼명은 book_id로 된다.

단일 테이블 전략

단일 테이블 전략은 슈퍼타입 서브타입의 모든 칼럼을 하나의 테이블로 구성한다.

특징

  • 구분 컬럼을 통해 저장된 타입을 구분한다.
    구분 컬럼(@DiscriminatorColumn)을 반드시 사용한다.

장점

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

단점

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

예제

조인 전략과 같이 이번 예제에서도 Item, Album, Book, Movie 엔티티를 사용한다. 그리고 조인 전략과 차이가 있는 Item 코드와 실행 결과 SQL을 확인해보자.

Item.java

  • @Inheritance(strategy = InherianceType.SINGLE_TABLE)
    단일 테이블 전략을 사용하므로 strategy 속성으로 InheritanceType.SINGLE_TABLE을 설정했다.

단일 테이블 전략을 사용했을 때 SQL은 아래와 같다.

Item 테이블이 만들어지고 컬럼으로 Item, Album, Book, Movie 엔티티에 작성한 속성 값이 모두 들어간다.

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

구현 클래스마다 테이블 전략은 자식 엔티티마다 테이블을 만든다. (이때 슈퍼타입을 위한 테이블은 생성되지 않는다.) 그리고 자식 테이블 각각에 필요한 컬럼이 모두 있다.

특징

  • 구분 컬럼을 사용하지 않는다.

장점

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

단점

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

예제

이번 예제도 Item 엔티티만 살펴본다.

  • @Inheritance(strategy = InherianceType.TABLE_PER_CLASS)
    구현 클래스마다 테이블 전략을 사용하므로 strategy 속성으로 InheritanceType.TABLE_PER_CLASS 설정했다.

Item 엔티티에 매핑되는 테이블은 생성되지 않는다. 하지만 Item 엔티티의 속성들은 자식 엔티티의 속성에 추가되어 테이블 칼럼으로 추가된다.


@MappedSuperclass

@MappedSuperclass는 부모 클래스를 테이블과 매핑하지 않고 부모 클래스를 상속받는 자식 클래스에게 매핑 정보만 제공한다. (실제 테이블과 매핑되지 않는다.)

위 그림에 해당하는 코드와 결과를 확인해보자.

BaseEntity에는 객체들의 공통 매핑 정보를 정의했다. 그리고 상속을 통해 공통 매핑 정보를 물려받았다. 이때 BaseEntity에 해당하는 테이블은 생성되지 않고 상속받은 객체들에게 매핑 정보만 제공한다.

재정의는 어떻게하면 돼?

부모로부터 물려받은 매핑 정보를 그대로 사용할 수 있고, 재정의하여 사용할 수 있다.
특히 @Id@Column(name = "")을 통해 매핑 정보를 지정하는 경우가 흔하다.

매핑 정보를 재정의하려면 @AttributeOverrides 또는 @AttributeOverride를 사용하고, 연관관계를 재정의하려면 @AssociationOverrides 또는 @AssociationOverride를 사용한다.

코드를 보며 사용법을 익혀보자.

둘 이상을 재정의하려면 @AttributeOverrides를 사용하면 된다.

특징

  • 테이블과 매핑되지 않고 자식 클래스에 엔티티의 매핑 정보를 상속하기 위해 사용한다.
  • @MappedSuperclass로 지정한 클래스는 엔티티가 아니므로 em.find() 또는 JPQL에서 사용할 수 없다.
  • 이 클래스를 직접 생성해서 사용할 일은 거의 없으므로 추상 클래스로 만드는 것을 권장한다.

복합 키와 식별 관계 매핑

복합 키를 매핑하는 방법과 식별 관계, 비식별 관계를 매핑하는 방법을 알아보자.

식별 관계와 비식별관계를 알아볼까요?

데이터베이스 테이블 사이에 관계는 외래 키가 기본 키에 포함되는지 여부에 따라 식별 관계와 비식별 관계로 구분한다.

  • 식별 관계 (Identifying Relationship)
  • 비식별 관계 (Non-Identigying Relationship)

식별 관계

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

비식별 관계

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

비식별 관계 키에 NULL 허용 여부

비식별 관계는 외래 키에 NULL을 허용하는지에 따라 필수적 비식별 관계와 선택적 비식별 관계로 나눈다.

  • 필수적 비식별 관계 (Mandatory): 외래 키에 NULL을 허용하지 않는다. 연관관계를 필수적으로 맺어야 한다.
  • 선택적 비식별 관계 (Optional): 외래 키에 NULL을 허용한다. 연관관계를 맺을지 말지 선택할 수 있다.

최근 동향
데이터베이스 테이블을 설계할 때 식별 관계나 비식별 관계 중 하나를 선택해야 한다. 글이 작성된 시점에는 비식별 관계를 주로 사용하고 꼭 필요한 곳에만 식별 관계를 사용하는 추세다.

복합 키: 비식별 관계 매핑 사용법

기본 키 구성하는 컬럼이 하나일 때 매핑 방식

@Entity
public class Hello {
  @Id
  private String id;
}

기본 키 구성하는 컬럼이 하나 이상일 때 매핑 방식

@Entity
public class Hello {
  @Id
  private String id1;
  @Id
  private String id2; //실행 시점에 매핑 예외 발생
}

예외 발생 이유

JPA는 영속성 컨텍스트에 엔티티를 보관할 때 엔티티의 식별자를 키로 사용한다. 그리고 식별자를 구분하기 위해 equals와 hashCode를 사용해서 동등성 비교를 한다. 식별자 필드가 하나일 때는 보통 자바의 기본 타입을 사용하므로 문제가 없지만, 식별자 필드가 2개 이상이면 별도의 식별자 클래스를 만들고 그곳에 equals와 hashCode를 구현해야 한다.

JPA는 복합 키를 지원하기 위해 @IdClass와 @EmbeddedId 2가지 방법을 제공하는데 @IdClass는 관계형 데이터베이스에 가까운 방법이고, @EmbeddedId는 객체지향에 가까운 방법이다.

@IdClass 방식

위 그림에서 PARENT 테이블을 보면 기본 키를 PARENT_ID1, PARENT_ID2로 묶은 복합 키로 구성했다. 따라서 복합 키를 매핑하기 위해 식별자 클래스를 별도로 만들어야 한다.

@IdClass를 사용할 때 식별자 클래스의 조건
1. 식별자 클래스의 속성명과 엔티티에서 사용하는 속성명이 같아야한다.
2. Serializable 인터페이스를 구현해야 한다.
3. equals, hashCode를 구현해야 한다.
4. 기본 생성자가 있어야 한다.
5. 식별자 클래스는 public이어야 한다.

아래는 저장과 조회에 대한 코드와 SQL이다.

저장 코드를 보면 식별자 클래스인 ParentId가 보이지 않는데, em.persist()를 호출하면 영속성 컨텍스트에 엔티티를 등록하기 직전에 내부에서 Parent.id1, Parent.id2 값을 사용해서 식별자 클래스인 ParentId를 생성하고 영속성 컨텍스트의 키로 사용한다.

Parent의 복합키를 Child에 비식별 관계 매핑해보자.

부모 테이블의 기본 키 컬럼이 복합 키이므로 자식 테이블의 외래 키도 복합 키다. 따라서 외래 키 매핑 시 여러 컬럼을 매핑해야 하므로 @JoinColumns 어노테이션을 사용하고 각각의 외래 키 컬럼을 @JoinColumn으로 매핑한다.

@EmbeddedId 방식

@EmbeddedId@IdClass보다 객체지향적인 방법이다.

예제로 알아보자.

@EmbeddedId를 사용할 때 식별자 클래스의 조건
1. @Embeddable 어노테이션을 붙여주어야 한다.
2. Serializable 인터페이스를 구현해야 한다.
3. equals, hashCode를 구현해야 한다.
4. 기본 생성자가 있어야 한다.
5. 식별자 클래스는 public이어야 한다.

@EmbeddedId를 사용하는 코드를 보자.

  • 저장

    저장하는 코드를 보면 식별자 클래스 parentId를 직접 생성해서 사용한다.

  • 조회

    조회 코드도 식별자 클래스 parentId를 직접 사용한다.

JPQL과 @IdClass, @EmbeddedId

@IdClass@EmbeddedId를 사용했을 때 JPQL은 아래와 같다.

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

주의!
복합 키에는 @GenerateValue를 사용할 수 없다. 복합 키를 구성하는 여러 컬럼 중 하나에도 사용할 수 없다.

복합 키: 식별 관계 매핑 사용법

아래의 복합 키 방식의 식별 관계 매핑의 예제를 알아보자.

위 그림을 보면 각 테이블의 기본 키는 관계 테이블의 기본 키 + a 의 형태이다.
식별 관계에서 자식 테이블은 부모 테이블의 기본 키를 포함해서 복합 키를 구성해야 하므로 @IdClass 또는 @EmbeddedId를 사용해서 식별자를 매핑해야 한다.

먼저 @IdClass로 식별 관계를 매핑해보자.

@IdClass와 식별 관계

위 그림에 해당하는 예제 코드를 통해 알아보자.


식별 관계는 기본 키와 외래 키를 같이 매핑해야 한다. 따라서 식별자 매핑인 @Id와 연관관계 매핑인 @ManyToOne을 같이 사용하면 된다.

@Id
@ManyToOne
@JoinColumn(name = "parent_id")
pulic Parent parent;

@EmbeddedId와 식별 관계

@EmbeddedId로 식별 관계를 구성할 때는 @MapsId를 사용해야 한다.

예제를 통해 알아보자.

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

데이터베이스 설계 관점에서 보면 다음과 같은 이유로 식별 관계보다는 비식별 관계를 선호한다.

  • 식별 관계는 부모 테이블의 기본 키를 자식 테이블로 전파하면서 자식 테이블의 기본 키 컬럼이 점점 늘어난다.
    예를 들어 부모 테이블은 기본 키 컬럼이 하나였지만 자식 테이블은 기본 키 컬럼이 2개, 손자 테이블은 기본 키 컬럼이 3개로 점점 늘어난다. 결국 조인할 때 SQL이 복잡해지고 기본 키 인덱스가 불필요하게 커질 수 있다.

  • 식별 관계는 2개 이상의 컬럼을 합해서 복합 기본 키를 만들어야 하는 경우가 많다.

  • 식별 관계를 사용할 때 기본 키로 비즈니스 의미가 있는 자연 키 컬럼을 조합하는 경우가 많다. 반면에 비식별 관계의 기본 키는 비즈니스와 전혀 관계없는 대리 키를 주로 사용한다.
    비즈니스 요구사항은 시간이 지남에 따라 언젠가는 변한다. 식별 관계의 자연 키 컬럼들이 자식에 손자까지 전파되면 변경하기 힘들다.

  • 식별 관계는 부모 테이블의 기본 키를 자식 테이블의 기본 키로 사용하므로 비식별 관계보다 테이블의 구조가 유연하지 못하다.

객체 관계 매핑의 관점에서 보면 다음과 같은 이유로 비식별 관계를 선호한다.

  • 일대일 관계를 제외하고 식별 관계는 2개 이상의 컬럼으 묶은 복합 기본 키를 사용한다. JPA에서 복합 키는 별도의 복합 키 클래스를 만들어서 사용해야 한다. 따라서 컬럼이 하나인 기본 키를 매핑하는 것보다 많은 노력이 필요하다.

  • 비식별 관계의 기본 키는 주로 대리 키를 사용하는데 JPA는 @GenerateValue처럼 대리 키를 생성하기 위한 편리한 방법을 제공한다.

물론 식별 관계가 갖는 장점도 있다.

  • 기본 키 인덱스를 활용하기 좋다.

  • 상위 테이블들의 기본 키 컬럼을 자식, 손자 테이블들이 가지고 있으므로 특정 상황에 조인 없이 하위 테이블만으로 검색을 완료할 수 있다.

이처럼 식별 관계가 가지는 장점도 있으므로 꼭 필요한 곳에는 적절하게 사용하는 것이 데이터베이스 테이블 설계의 묘를 살리는 방법이다.

정리
ORM 신규 프로젝트 진행시 추천하는 방법은 될 수 있으면 비식별 관계를 사용하고 기본 키는 Long 타입의 대리 키를 사용하는 것이다.
대리 키는 비즈니스와 아무 관련이 없다. 따라서 비즈니스가 변경되어도 유연한 대처가 가능하다는 장점이 있다.
JPA는 @GeneratedValue를 통해 간편하게 대리 키를 생성할 수 있다. 그리고 식별자 컬럼이 하나여서 쉽게 매핑할 수 있다.
그리고 선택적 비식별 관계보다는 필수적 비식별 관계를 사용하길 권장한다. 선택적인 비식별 관계는 NULL을 허용하므로 조인할 때에 외부 조인을 사용해야 한다. 반면에 필수적 관계는 NOT NULL로 항상 관계가 있다는 것을 보장하므로 내부 조인만 사용해도 된다.

profile
주 7일, 배움엔 끝이 없다

0개의 댓글