7장. 고급 매핑 - 1

이윤설·2024년 7월 9일

목차

  1. 상속 매핑
  2. @MappedSuperClass
  3. 복합 키와 식별 관계 매핑
  4. 조인 테이블
  5. 엔티티 하나에 여러 테이블 매핑하기

7.1 상속 관계 매핑

RDB에는 객체지향 언어에서 다루는 "상속"이라는 개념이 없다.
그대신 "슈퍼타입 서브타입 관계"라는 모델링 기법이 상속과 가장 유사하다고 할 수 있다.

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

a. 각각의 테이블로 변환(=조인 전략)
b. 통합 테이블로 변환(=단일 테이블 전략)
c. 서브타입 테이블로 변환(=구현 클래스마다 테이블 전략)


7.2 조인 전략(Joined Strategy)


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

조인 전략을 사용할 때는 타입을 구분하는 컬럼을(DTYPE)꼭 추가해야한다.
왜냐하면 객체는 타입으로 구분할 수 있지만, 테이블은 타입의 개념이 없기 때문이다.

@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") // ID 재정의
public class Book {
    
    private String author;
    private String isbn;
}
  • @Inheritance(strategy = InheritanceType.JOINED): 상속 매핑은 부모 클래스에 @Inheritance를 선언한다.

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

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

  • @PrimaryKeyJoinColumn(name = "BOOK_ID"): 기본값으로 자식 테이블은 부모 테이블의 ID 컬럼명을 그대로 사용한다. 만약 자식 테이블의 기본 키 컬럼명을 바꾸고싶으면 이 어노테이션을 사용하자. 참고로 이 어노테이션은 오직 조인 전략을 사용할 때만 유효하다.

ITEM_IDNAMEPRICEDTYPE
1앨범A20000A
2영화B15000M
3책C30000B

ITEM_IDARTIST
1마이클 잭슨

ITEM_IDDIRECTORACTOR
2타란티노브래드 피트

BOOK_IDAUTHORISBN
3헤르만 헤세1234567890

우선 Item, Album, Movie, Book 테이블의 생김새는 위와 같다.
@DiscriminatorColumn으로 구분 컬럼을 DTYPE으로 설정하고,
각 자식 클래스에 @DiscriminatorValue()에 넣은 값이 DTYPE에 들어가게 된다.
이를 통해 부모 클래스가 DTYPE을 통해 자식 클래스를 구분할 수 있다.

눈여겨봐야 할 점은, Book 테이블만 @PrimaryKeyJoinColumn(name = "BOOK_ID") 덕분에 컬럼명이 BOOK_ID라는 점과 DTYPE 컬럼은 오로지 부모 클래스에 있다는 점이다.

@DiscriminatorValue가 @DiscriminatorColumn으로 매핑되는 과정

자식 클래스의 @DiscriminatorValue가 부모 클래스의 @DiscriminatorColumn의 값으로 매핑 되는 과정을 알아보자.

  1. 엔티티 메타데이터 분석
    JPA 구현체인 하이버네이트가 애플리케이션 시작 시 모든 엔티티 클래스를 스캔하고 분석한다. 이 과정에서 @Entity, @Inheritance, @DiscriminatorColumn, @DiscriminatorValue 등의 어노테이션을 읽고 처리한다.
    해당 클래스: Configuration

  2. 매핑 정보 생성
    분석된 정보를 바탕으로 JPA 구현체는 내부적으로 엔티티 매핑 정보를 생성한다.
    이 정보에는 각 엔티티의 상속 관계, 구분자 컬럼, 구분자 값 등이 포함된다.
    해당 클래스: PersistentClass

  3. SQL 생성:
    엔티티를 데이터베이스에 저장할 때, JPA 구현체는 매핑 정보를 사용하여 적절한 SQL을 생성한다. 이 때 @DiscriminatorValue의 값이 부모 테이블의 구분자 컬럼에 삽입된다.
    해당 클래스: AbstractEntityPersister, EntityMetaModel, Versioning

-- Item 테이블 생성 (부모 테이블)
CREATE TABLE Item (
    ITEM_ID BIGINT PRIMARY KEY AUTO_INCREMENT,
    NAME VARCHAR(255),
    PRICE INT,
    DTYPE VARCHAR(31)
);

-- Album 테이블 생성
CREATE TABLE Album (
    ITEM_ID BIGINT PRIMARY KEY,
    ARTIST VARCHAR(255),
    FOREIGN KEY (ITEM_ID) REFERENCES Item(ITEM_ID)
);

-- Movie 테이블 생성
CREATE TABLE Movie (
    ITEM_ID BIGINT PRIMARY KEY,
    DIRECTOR VARCHAR(255),
    ACTOR VARCHAR(255),
    FOREIGN KEY (ITEM_ID) REFERENCES Item(ITEM_ID)
);

-- Book 테이블 생성
CREATE TABLE Book (
    BOOK_ID BIGINT PRIMARY KEY,
    AUTHOR VARCHAR(255),
    ISBN VARCHAR(13),
    FOREIGN KEY (BOOK_ID) REFERENCES Item(ITEM_ID)
);
  1. 쿼리 실행:
    생성된 SQL이 실행되어 데이터베이스에 엔티티 정보가 저장된다.
    해당 클래스: AbstractEntityPersister

  2. 조회 시 처리:
    엔티티를 조회할 때, 구분자 컬럼의 값을 확인하고, 적절한 자식 클래스의 인스턴스를 생성한다. 만약 Book 테이블을 조회하면 인스턴스 book을 생성한다.

장단점

🧸 장점

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

🧸 단점

  • 조회할 때 Join이 많이 사용되어 성능 저하의 가능성이 올라간다.
  • 조회 쿼리가 복잡하다.
  • 데이터를 등록한 insert sql을 2번 실행한다.

7.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("B")
public class Book extends Item {

    private String author;
    private String isbn;
}

이하 생략

단일 테이블전략은 테이블을 하나만 사용하며, 구분 컬럼(DTYPE)으로 어떤 자식 데이터가 저장되었는지 구분한다.
이 전략은 일반적으로 검색속도가 가장 빠르다. 왜냐하면 JOIN을 안쓰기 때문이다.

주의할 점은, 자식 엔티티가 매핑한 컬럼은 모두 null을 허용해야 한다는 점이다. 예를 들어 Book 엔티티를 저장하면, Item 테이블의 Author, Isbn 컬럼만 사용하고 다른 엔티티와 매핑된 Artist, Director, Actor 컬럼은 사용하지 않으므로 null이 입력되기 때문이다.
따라서 특정 엔티티 타입에만 해당하는 컬럼은 다른 타입의 레코드에서는 null로 저장된다.

또 주의할 점은, Entity(Album,Movie,Book)는 Item에 포함되지 않으며, 오로지 JPA 상에만 존재한다는 점이다.

    create table Item (
       DTYPE varchar(31) not null,
        ITEM_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 (ITEM_ID)
    )

실제 실행된 쿼리와 만들어진 테이블이다.

장단점

🧸 장점

  • 조인이 필요없어서 조회 성능이 빠르다.
  • 조회 쿼리가 단순하다.

🧸 단점

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

🧸 장점

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

🧸 특징

  • 구분 칼럼(@DiscriminatorColumn)을 꼭 설정해야 한다.
  • @DiscriminatorValue를 지정하지 않으면 기본으로 엔티티 이름을 사용한다. 즉, DTYPE에 알파벳이 아니라 실제 엔티티 이름이 들어간다.

NULL과 데이터 무결성

단일 테이블 전략의 단점 중 "자식 엔티티가 매핑한 컬럼은 모두 NULL을 허용해야 한다는 것"이 있다고 하였다. 그런데 NULL을 허용하는 것이 왜 단점이 될까???
왜냐하면 NULL을 허용하는 것은 데이터 무결성을 약화시키기 때문이다.

데이터베이스 설계 원칙 중 하나는 가능한 한 NULL 값을 피하는 것이다.
NULL은 '알 수 없는 값' 또는 '적용되지 않는 값'을 의미하며, 이는 데이터의 명확성을 떨어뜨릴 수 있다.

또한 데이터베이스 레벨에서 NOT NULL 제약조건을 사용할 수 없게 된다.
이는 데이터베이스의 내장된 무결성 검사 기능을 활용하기 어렵게 만든다.

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


구현 클래스마다 테이블 전략(Table-per-Concrete-Class Strategy)는 자식 엔티티마다 테이블을 만들고, 자식 테이블 각각에 필요한 컬럼(부모 클래스 + 본인 테이블 컬럼)이 모두 있다.
또한 부모 테이블이 DB에 생성되지 않는다.
일반적으로 추천되지 않는 전략이다.

@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;
}

7.2 @MappedSuperclass

지금까지 학습한 상속관계 매핑은 부모 클래스와 자식 클래스 모두 데이터베이스 테이블과 매핑했다.
부모 클래스는 테이블과 매핑하지 않고 부모 클래스를 상속받는 자식 클래스에게 매핑 정보만 제공하고 싶으면 @MappedSuperclass를 사용하면 된다.

@MappedSuperclass는 테이블과는 관계가 없고, 단순히 엔티티가 공통으로 사용하는 매핑정보를 모아주는 역할을 할 뿐이다. ORM에서 이야기하는 진정한 상속 매핑은 이전에 학습한 객체 상속을 데이터베이스의 슈퍼타입 서브타입관계와 매핑하는 것이다.


@MappedSuperclass는 추상 클래스와 비슷하다.
@Entity는 실제 테이블과 매핑되지만, @MappedSuperclass는 실제 테이블과 매핑되지 않는다. 단순히 매핑 정보(컬럼)를 상속할 목적으로만 사용된다.

@MappedSuperclass
public class BaseEntity {

    @Id
    @GeneratedValue
    private Long id;

    private String name;
}

@Entity
public class Member extends BaseEntity {

    // ID, name 상속
    private String email;
}

public class Seller extends BaseEntity {

    // ID, name 상속
    private String shopName;
}


실제 데이터베이스에도 BaseEntity라는 테이블은 생성되지 않았다.

하지만 부모 테이블인 BaseEntity의 컬럼은 상속받았음을 확인하 수 있다.

cf. 만약 부모에게 상속받은 id 속성의 컬럼명을 재정의하고 싶으면 @AttributeOverrides를 사용하면 된다.

🧸 @MappedSuperclass 특징 정리

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

7.3 복합 키와 식별 관계 매핑

복합 키

복합키는 두 개 이상의 열을 결합하여 하나의 기본 키를 구성하는 방식이다.
하나의 테이블에 두 개 이상의 PK를 만드는 것이 아니라, PK 1개가 여러 개의 컬럼으로 구성된 것이다.

예를 들어, 학생들의 수강 기록을 저장하는 Enrollments 테이블이 있다고 가정하자. 이 테이블에는 학생 ID(student_id)와 강좌 ID(course_id)가 포함되어 있다.
각 학생이 여러 강좌를 수강할 수 있으며, 각 강좌에 여러 학생이 등록될 수 있기 때문에, 단일 열만으로는 각 레코드를 고유하게 식별할 수 없다.
이 경우, student_id와 course_id를 결합하여 기본 키를 구성할 수 있다.

CREATE TABLE Enrollments (
    student_id INT,
    course_id INT,
    enrollment_date DATE,
    PRIMARY KEY (student_id, course_id)
);

INSERT INTO Enrollments (student_id, course_id, enrollment_date) VALUES (1, 101, '2024-01-10');
INSERT INTO Enrollments (student_id, course_id, enrollment_date) VALUES (1, 102, '2024-01-11');
INSERT INTO Enrollments (student_id, course_id, enrollment_date) VALUES (2, 101, '2024-01-12');

위의 데이터는 student_id와 course_id의 조합이 각각 고유하므로 삽입이 가능하다.
동일한 student_id와 course_id의 조합이 중복될 경우 삽입이 불가능하다.

INSERT INTO Enrollments (student_id, course_id, enrollment_date) VALUES (1, 101, '2024-01-10');
INSERT INTO Enrollments (student_id, course_id, enrollment_date) VALUES (1, 101, '2024-01-13');

이 시도는 student_id가 1이고 course_id가 101인 조합이 이미 존재하기 때문에(중복이기 때문에) 기본 키 위반으로 인해 실패한다.

식별 관계


식별관계란, 부모 테이블의 기본 키(Primary Key)가 자식 테이블의 기본 키(복합 키)의 일부로 사용되는 관계이다.
자식 테이블의 기본 키가 부모 테이블의 기본 키를 포함하는 구조다.
PARENT_ID, CHILD_ID가 한 쌍이 되어 PK가 된다.

특징:

  • 자식 엔티티의 생명주기가 부모 엔티티에 강하게 의존한다.
  • 부모 레코드 없이 자식 레코드가 존재할 수 없다.

비식별 관계


비식별 관계란, 부모 테이블의 기본 키가 자식 테이블의 외래 키로만 사용되는 관계다.
자식 테이블의 기본 키가 부모 테이블의 기본 키를 포함하지 않는 구조다.

특징:

  • 부모 테이블의 기본 키는 자식 테이블에서 일반 속성(컬럼)으로 사용된다.
  • 자식 엔티티가 부모 엔티티와 독립적인 생명주기를 가질 수 있다.
  • 부모 레코드가 없어도 자식 레코드가 존재할 수 있다 (외래 키가 NULL 허용 시).

비식별 관계는 외래 키에 null 허용 여부에 따라 필수적 비식별 관계와 선택적 비식별 관계로 나뉜다.

  • 필수적 비식별 관계(Mandatory): 외래 키에 NULL을 허용하지 않음, 연관관계가 필수적. 이는 부모 테이블과 자식 테이블의 식별자가 강하게 결합되어 있음을 의미한다.
  • 선택적 비식별 관계(Optional): 외래 키에 NULL을 허용, 연관관계가 선택적
    자식 테이블의 기본 키는 부모 테이블의 기본 키와 독립적이다.

데이터베이스 테이블을 설계할 때 식별 관계와 비식별 관계 중 하나를 꼭 선택해야 한다.
비식별 관계를 주로 사용하고, 꼭 필요한 곳에만 식별 관계를 사용하는 것이 권장된다.

🧸 그럼 식별 관계를 사용하는 것이 좋은 때는 언제일까?
부모-자식 간의 관계가 매우 강하고, 자식 데이터가 부모 데이터 없이는 존재할 수 없는 경우에 사용하면 된다.
EX. Orders 테이블 - OrderItems 테이블

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

JPA에서 부모 클래스의 복합 키를 자식 클래스에 매핑하는 과정은 조금 귀찮다.
JPA는 영속성 컨텍스트에 엔티티를 보관할 때, 엔티티의 식별자를 key로 사용한다.
식별자를 구분하기 위해 equals, hashcode를 사용해서 동등성 비교를 한다.
그런데 식별자 필드가 1개 일때는 보통 자바의 기본 타입을 사용하므로 문제가 없지만, 식별자 필드가 2개 이상이면 별도의 식별자 클래스를 만들고 그곳에 equals, hashcode를 구현해야 한다.

cf. JPA에서 식별자란, RDB에서 PK와 동일하다.

... 말이 좀 어렵다. 그러니까 쉽게 얘기하자면,

  1. JPA의 엔티티 관리 방식:

    • JPA는 각 엔티티를 구별하기 위해 '식별자'를 사용한다.
      (Parent : id1 + id2 , ParentId : id1 + id2, Child : id)
    • 이 식별자는 마치 엔티티의 '주민등록번호' 같은 역할을 한다.
  2. 단일 식별자의 경우:

    • 예를 들어, 학생 ID가 하나의 숫자라면 아주 간단하다.
    • JPA는 이 숫자만으로 쉽게 학생들을 구분할 수 있다.
  3. 복합 식별자의 경우:

    • 하지만 때로는 여러 정보를 조합해야 식별자가 되는 경우가 있다.
    • 예를 들어, '학교 코드'와 '학생 번호'를 함께 써야 학생을 구분할 수 있다고 해보자.
  4. 복합 식별자의 문제:

    • JPA는 이런 여러 정보를 하나의 '꾸러미'로 다루고 싶어 한다.
    • 하지만 자바는 기본적으로 이런 '꾸러미'를 자동으로 비교하는 방법을 모른다.
  5. 해결책: 식별자 클래스

    • 그래서 우리가 직접 이 '꾸러미'를 만들어 줘야 한다. 이게 바로 식별자 클래스다.
    • 이 클래스에서 우리는 '꾸러미'들을 어떻게 비교할지 직접 알려준다 (equals 메서드).
    • 또, 이 '꾸러미'를 어떻게 빨리 찾을지도 알려준다 (hashCode 메서드).
  6. 결과:

    • 이렇게 하면 JPA는 우리가 만든 '꾸러미'를 사용해 복잡한 식별자도 쉽게 관리할 수 있게 된다.

요약하자면, 복합 식별자는 여러 정보를 하나로 묶는 '꾸러미'이고, 이 꾸러미를 JPA가 잘 사용할 수 있도록 우리가 직접 만들어주는 것이다.

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

@IdClass

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

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

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

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

    private String name;
}

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 o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        ParentId parentId = (ParentId) o;
        return Objects.equals(id1, parentId.id1) && Objects.equals(id2, parentId.id2);
    }

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

@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;
}

부모 테이블의 기본 키 컬럼이 복합 키이므로 자식 테이블의 외래 키도 복합키다.

@EmbeddedID

@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;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        ParentId parentId = (ParentId) o;
        return Objects.equals(id1, parentId.id1) && Objects.equals(id2, parentId.id2);
    }

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

@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;
}
@EmbeddedId
private ParentId id;

위 코드를 보면 @EmbeddedId를 적용한 식별자 클래스는 식별자 클래스에 기본 키를 직접 매핑함을 알 수 있다.

Parent parent = new Parent();
ParentId parentId = new parentId("myId1", "myId2");
parent.setID(parentId);
parent.setName("parentName");
em.persist(parent);

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

복합 키, equals(), hashcode()

복합 키는 equals(), hashcode()를 필수로 구현해야 한다.

  1. 복합 키: 엔티티가 두 개 이상의 필드로 구성된 기본 키를 가질 때, 이를 복합 키라고 한다.

  2. 기본 equals()와 hashCode(): 자바의 기본 equals() 메서드는 두 객체의 참조(메모리 주소)가 같은지를 비교한다. 즉, == 연산자를 사용한다.
    기본 hashCode() 메서드는 객체의 메모리 주소 기반의 해시 코드를 반환한다.

  3. 문제: 두 개의 복합 키 객체가 같은 값을 가지지만 서로 다른 인스턴스인 경우, 기본 equals()와 hashCode()를 사용하면 동일한 객체로 인식되지 않는다.

  4. 올바른 equals()와 hashCode(): 복합 키 클래스는 equals()와 hashCode()를 오버라이드하여 메모리 주소가 아닌 필드 값을 비교하도록 해야 한다. 이를 통해 값이 같은 두 객체가 동일한 것으로 인식된다.

🍒 나쁜 예

import java.io.Serializable;

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;
    }

    // getters and setters
}

public class Main {
    public static void main(String[] args) {
        ParentId id1 = new ParentId("myId1", "myId2");
        ParentId id2 = new ParentId("myId1", "myId2");

        System.out.println(id1.equals(id2)); // false
    }
}

🍒 좋은 예

import java.io.Serializable;
import java.util.Objects;

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 o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        ParentId parentId = (ParentId) o;
        return Objects.equals(id1, parentId.id1) && Objects.equals(id2, parentId.id2);
    }

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

    // getters and setters
}

public class Main {
    public static void main(String[] args) {
        ParentId id1 = new ParentId("myId1", "myId2");
        ParentId id2 = new ParentId("myId1", "myId2");

        System.out.println(id1.equals(id2)); // true
    }
}

둘 중 무엇을 사용하는 것이 좋을까?

둘 다 장단점이 있기 때문에 아무거나 사용해도 좋다.
@EmbeddedID가 객체지향적이여서 더 좋아보이기는 하지만, JPQL 길이가 더 길어질 수는 있다.

7.3.3 복합 키: 식별 관계 매핑


위 그림은 부모, 자식, 손자까지 계속 기본 키를 전달하는 식별 관계다.
식별 관계 역시 @IdClass 또는 @EmbeddedID를 사용하여 매핑한다.

@IdClass

@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;
}

public class ChildId implements Serializable {

    private String parent;
    private String childId;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        ChildId childId1 = (ChildId) o;
        return Objects.equals(parent, childId1.parent) && Objects.equals(childId, childId1.childId);
    }

    @Override
    public int hashCode() {
        return Objects.hash(parent, childId);
    }
}

@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;
}

public class GrandChildId implements Serializable {
    private ChildId child;
    private String id;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        GrandChildId that = (GrandChildId) o;
        return Objects.equals(child, that.child) && Objects.equals(id, that.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(child, id);
    }
}

@EmbeddedId

@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")
    @ManyToOne
    @JoinColumn(name = "PARENT_ID")
    public Parent parent;

    private String name;
}

public class ChildId implements Serializable {

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

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

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        ChildId childId = (ChildId) o;
        return Objects.equals(parentId, childId.parentId) && Objects.equals(id, childId.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(parentId, id);
    }
}

@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;
}

@Embeddable
public class GrandChildId implements Serializable {
    private ChildId childId;

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

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        GrandChildId that = (GrandChildId) o;
        return Objects.equals(childId, that.childId) && Objects.equals(id, that.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(childId, id);
    }
}

🍕 @EmbeddedId의 역할

  • 복합 키 클래스(여기서는 ChildId)를 엔티티의 식별자로 사용한다고 JPA에 알린다.
  • 복합 키의 필드들을 엔티티의 테이블 컬럼에 매핑한다.
  • 복합 키 객체를 통해 엔티티를 식별할 수 있게 한다.
    이 경우, Child 엔티티는 ChildId 타입의 id 필드를 복합 키로 사용한다. 따라서 Child 엔티티는 parentId와 id의 조합으로 uniquely 식별된다.

🍕 @EmbeddedID를 사용할 땐 @MapsId를 사용한다.
@MapsId는 간단히 말해서 "이 필드는 내 ID의 일부이면서 동시에 다른 테이블을 가리키는 포인터야"라고 JPA에게 알려주는 역할을 한다.

  1. Child의 MapsId("parentId") 매핑 원리
    @MapsId는 엔티티의 기본 키(또는 복합 키의 일부)를 다른 엔티티의 기본 키와 매핑할 때 사용된다.
    Child 엔티티의 @MapsId("parentId")는 ChildId 클래스의 parentId 필드를 가리킨다. 동시에 이 값은 Parent 엔티티의 id와 연결된다.

  2. Parent의 id가 Child의 parent(@MapsId("parentId"))와 매핑되는 원리:
    이 매핑은 JPA의 관계 매핑 기능과 @MapsId의 동작 방식 덕분에 가능하다.
    a. @ManyToOne 어노테이션은 Child와 Parent 사이의 관계를 정의한다.
    b. @JoinColumn(name = "PARENT_ID")는 이 관계의 외래 키 컬럼을 지정한다.
    c. @MapsId("parentId")는 이 외래 키가 Child의 복합 키의 일부임을 나타낸다.
    d. JPA는 이 정보를 조합하여 Parent의 id를 Child의 복합 키의 일부로 사용한다.
    이름이 같지 않아도 되는 이유는 @MapsId와 @JoinColumn이 이 연결을 명시적으로 정의하기 때문이다.

  3. Parent의 id가 ChildId의 parentId와 매핑되는 원리:
    a. @MapsId("parentId")는 ChildId 클래스의 parentId 필드를 Parent 엔티티의 ID와 매핑하라고 JPA에 지시한다.
    b. @ManyToOne은 Child와 Parent 간의 다대일 관계를 정의한다 .
    c. @JoinColumn(name = "PARENT_ID")은 이 관계의 외래 키 컬럼을 지정한다.


    이 세 어노테이션의 조합으로 인해:
    a. Child.parent 필드는 Parent 엔티티와의 관계를 나타낸다.
    b. 이 관계의 외래 키는 "PARENT_ID" 컬럼이다.
    c. 이 외래 키 값은 ChildId.parentId 필드와 동일하다.

즉, Child.parent를 설정하면 JPA가 자동으로 해당 Parent의 ID를 가져와 ChildId.parentId에 설정한다. 이렇게 함으로써 객체 관계(Child.parent)와 데이터베이스 키 관계(ChildId.parentId)를 동시에 관리할 수 있게 된다.

7.3.4 비식별 관계로 구현


앞서 만들었던 테이블을 비식별 관계로 변경해보자.

@Entity
public class Parent {

    @Id
    @Column(name = "PARENT_ID")
    private String 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;
}

식별 관계와 비교하면 너무나 코드가 단순해졌다.
그리고 복합 키가 없기 때문에 복합 키 클래스를 만들지 않아도 된다.

7.3.5 일대일 식별관계


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

@Entity
public class Person {
    @Id
    @GeneratedValue
    @Column(name = "PERSON_ID")
    private Long id;
    
    private String name;
    private LocalDate birthDate;
    
    @OneToOne(mappedBy = "owner", cascade = CascadeType.ALL)
    private Passport passport;
}

@Entity
public class Passport {
    @Id
    @Column(name = "PASSPORT_NUMBER")
    private String passportNumber;
    
    // @MapsId를 사용함으로써, Passport의 ID가 항상 Person의 ID와 일치하도록 보장
    @MapsId
    @OneToOne
    @JoinColumn(name = "PASSPORT_NUMBER")
    private Person owner;
    
    private LocalDate issueDate;
    private LocalDate expiryDate;
}

사람과 여권으로 예시를 들 수 있다.
여권 번호(passportNumber)가 여권의 고유 식별자이면서 동시에 소유자를 가리키는 외래 키 역할을 한다.
여권은 소유자 한 명에게만 속하므로, 여권 번호만으로도 유일하게 식별 가능하다.
여권 테이블에 복합키(예: 여권번호 + 소유자ID)를 사용하면 불필요하게 복잡해진다.
그래서 이와 같은 관계에선 자식 테이블에 복합키를 사용하지 않는다.

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

DB 설계 관점에서 보면, 비식별 관계를 선호한다.

  1. 식별 관계는 기본 키 컬럼이 순차적으로 늘어난다. 결국 JOIN할 때 SQL이 복잡해진다.
  2. 식별 관계는 복합 기본 키를 만들어야 하는 경우가 지나치게 많다.
  3. 식별 관계는 기본 키로 비즈니스 의미가 있는 자연 키 컬럼을 조합하는 경우가 많다. 반면에 비식별 관계의 기본 키는 비즈니스와 상관없는 대리 키를 사용한다.
    만약 비즈니스 요구사항 변경 시, 자식에 손자까지 상속된 자연키 컬럼들을 바꾸기는 쉽지 않을 것이다.
  4. 그렇다고 식별 관계를 아예 쓰지 말라는 것이 아니다. 분명 장점이 있기 때문에 필요한 것을 권장한다.

비식별 관계를 사용하고 기본 키는 Long 타입의 대리 키를 사용하자.

  • 대리 키는 비즈니스와 아무 관련이 없다. 따라서 비즈니스가 변경되어도 유연한 대처가 가능하다. JPA에서는 @GenerateValue를 통해 기본 키(대리키를 만들 수 있다.
  • 자바에서 Integer는 20억, Long은 920경 정도의 데이터를 수용할 수 있다.
    서비스 크기가 크다면 Long을 사용하는 것이 좋다.
  • 선택적 비식별 관계보다는 필수적 비식별 관계를 사용하는 것이 좋다. 왜냐하면 전자는 NULL을 허용하므로 조인할 때에 외부 조인을 사용해야 한다. 반면 후자는 NOT NULL로 항상 관계가 있다는 것을 보장하므로 내부 조인만 사용해도 된다.

@GeneratedValue, 대리 키, 자연 키와의 관계

🔑 @GeneratedValue:
JPA에서 엔티티의 기본 키(primary key, PK)를 자동으로 생성하도록 하는 어노테이션

🔑 자연키(Natural Key):
개념: 비즈니스적으로 의미 있는 데이터를 키로 사용
예: 주민등록번호, ISBN, 이메일 주소

🔑 대리키(Surrogate Key 또는 인조 키):
개념: 비즈니스와 무관한 임의로 생성된 식별자
예: 자동 증가 정수, UUID

보통 대리키를 PK로 많이 사용한다.

// 대리키 사용 예시 (권장)
@Entity
public class Student {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String studentId; // 학생 ID (자연 키로 사용하지 않음)
    private String name;
    private String email;
    // 기타 필드
}

// 자연키 사용 예시 (권장안함)
@Entity
public class Student {
    @Id
    private String studentId; // 학생 ID를 자연 키로 사용

    private String name;
    private String email;
    // 기타 필드
}

대리 키를 사용하면, 데이터가 변경되어도 기본 키는 변하지 않기 때문에 데이터 무결성을 유지하기 쉽고, 성능상의 이점도 있다.


사진 출처 및 참고 도서 : 자바 orm 표준 jpa 프로그래밍

profile
화려한 외면이 아닌 단단한 내면

0개의 댓글