JPA 고급 맵핑

2020-06-30

이번 시간에는 JPA 맵핑 방법을 좀 더 깊숙하게 들어가 보겠습니다.

1. 상속 관계 맵핑

JPA는 데이터베이스를 객체로 다루기 위해서 만들어 졌습니다. 객체의 장점 중 하나는 상속을 통해 중복코드를 줄일 수 있다는 점이 있지만, 데이터베이스에서는 상속이라는 개념이 없습니다.

그럼 다음과 같은 중복이 있을 때는 어떻게 해야 할까요?

이름,가격 필드가 중복인 경우

1.1. 조인 전략

조인 전략은 엔티티를 모두 각각의 테이블로 만들고, 자식 테이블이 부모 테이블의 기본 키를 받아서, 기본 키 + 외래 키 로 사용하는 전략입니다. 따라서 조회를 할 때 조인을 자주 사용 합니다. 여기서 중요한 점은 자식 테이블을 구분하기 위한 컬럼을 부모 테이블에 지정해줘야 합니다.

1.1.1.조인 전략 Entity 맵핑

@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn(name = "DTYPE")
public abstract class Item {

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

    protected String name;
    protected int price;
}


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

    private String artist;

    public Album(String name, int price, String artist) {
        this.name = name;
        this.price = price;
        this.artist = artist;
    }
}

@Entity
@DiscriminatorValue("C")
class Computer extends Item {

    private String maker;

    public Computer(String name, int price, String maker) {
        this.name = name;
        this.price = price;
        this.maker = maker;
    }
}
@Entity
@DiscriminatorValue("F")
class Food extends Item {

    private int calory;

    public Food(String name, int price, int calory) {
        this.name = name;
        this.price = price;
        this.calory = calory;
    }
}
  • @Inheritance(strategy = InheritanceType.JOINED) : 상속 맵핑은 부모 클래스에 @Inheritance를 사용하며, 여기서는 조인 전략을 사용했으므로 InheritanceType.JOINED를 사용했습니다.
  • @DiscriminatorColumn(name = “DTYPE”) : 부모 클래스에 구분 컬럼을 만듭니다. 이 컬럼으로 자식 테이블을 구분할 수 있습니다. 기본 값은 DTYPE 입니다. @DiscriminatorValue(“M”) : 엔티티를 저장할 때 구분 컬럼으로 입력 될 값을 지정합니다. “M” 으로 지정해줬다면, 부모 테이블에는 DTYPE 값이 “M” 으로 저장 됩니다.

1.1.2.테스트 해보기

제가쓰는 컴퓨터와, 자주 즐겨먹는 샐러드를 넣어보았습니다.ㅋㅋ

@Test
public void test() {
  Album album = new Album("신나는 노래",500,"동현");
  Computer computer = new Computer("IMac",500000,"Apple");
  Food food = new Food("목살 스테이크 샐러드" , 6500,380);

  em.persist(album);
  em.persist(computer);
  em.persist(food);
}

1.1.3.결과

1.2.조인테이블의 장점

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

1.3.조인테이블의 단점

  • 조회할 때 조인이 많이 사용되므로 성능이 저하될 수 있습니다.
  • 조회 쿼리가 복잡합니다.

1.3.1.앨범 1개를 조회하는 SELECT 쿼리

select 
	album0_.item_id as item_id2_4_0_,
  album0_1_.name as name3_4_0_,
  album0_1_.price as price4_4_0_,
  album0_.artist as artist1_0_0_ 
from 
		album album0_ inner join item album0_1_ on album0_.item_id=album0_1_.item_id 
where 
		album0_.item_id=?
  • 데이터를 등록할 때 INSERT가 두 번 실행 됩니다.

1.3.2.Hibernate의 로그

Hibernate: insert into item (name, price, dtype, item_id) values (?, ?, 'A', ?)
Hibernate: insert into album (artist, item_id) values (?, ?)
Hibernate: insert into item (name, price, dtype, item_id) values (?, ?, 'C', ?)
Hibernate: insert into computer (maker, item_id) values (?, ?)
Hibernate: insert into item (name, price, dtype, item_id) values (?, ?, 'F', ?)
Hibernate: insert into food (calory, item_id) values (?, ?)

3개의 Entity를 넣었지만 총 6개의 Insert 문이 실행되었습니다.

1.2 단일 테이블 전략

단일 테이블은 말 그대로 테이블을 하나만 사용합니다. 그리고 구분 컬럼으로 자식 데이터가 저장되었는지 구분 합니다. 조회 할 때 조인을 사용하지 않으므로 일반적으로 가장 빠릅니다.

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "DTYPE")
public abstract class Item {

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

    protected String name;
    protected int price;
}


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

    private String artist;

    public Album(String name, int price, String artist) {
        this.name = name;
        this.price = price;
        this.artist = artist;
    }
}

@Entity
@DiscriminatorValue("C")
class Computer extends Item {

    private String maker;

    public Computer(String name, int price, String maker) {
        this.name = name;
        this.price = price;
        this.maker = maker;
    }
}
@Entity
@DiscriminatorValue("F")
class Food extends Item {

    private int calory;

    public Food(String name, int price, int calory) {
        this.name = name;
        this.price = price;
        this.calory = calory;
    }
}

Item Entity의 @Inheritance의 strategy를 InheritanceType.SINGLE_TABLE 로만 변경해주시면 됩니다.

변경 후 똑같이 3개의 데이터를 넣게 되면, Item 테이블은 다음과 같은 상태가 됩니다.

단일 테이블 전략을 사용해서 데이터 3개를 넣은 경우

1.2.1.단일 테이블의 장점

  • 조인이 필요 없으므로 조회 성능이 빠릅니다.
  • 조회 쿼리가 단순합니다.
select 
	album0_.item_id as item_id2_1_0_,
  album0_.name as name3_1_0_,
  album0_.price as price4_1_0_,
  album0_.artist as artist5_1_0_ 
from item album0_ 
	where 
album0_.item_id=? and album0_.dtype='A'

1.2.2.단일 테이블의 단점

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

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

이 전략은 Item의 컬럼들을 자식 테이블마다 컬럼을 추가하는 방법 입니다.

@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
public abstract class Item {

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

    protected String name;
    protected int price;
}


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

@Entity
class Computer extends Item { ... }
@Entity
class Food extends Item { ... }

1.3.1.장점

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

1.3.2.단점

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

이 전략은 추천하지 않는 전략 입니다.

2.@MappedSuperclass

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

회원가입시, 회원가입 날짜를 나타내는 필드인 createdTime이나, 게시글을 작성할 때 작성일을 나타내는 필드인 createdTime 등 공통 속성을 부모에게 물려받아 사용할 수 있습니다.

2.1. @MappedSuperclass를 정의한 모습

@MappedSuperclass
public class BaseAuditingEntity {
    protected LocalDateTime createdTime;
}

2.2. Item 클래스가 상속받음

@Entity
public class Item extends BaseAuditingEntity {

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

    private String name;
    private int price;
}

다음과 같이 Entity에 상속을 통해서 사용합니다.

2.3.결과

2.4.부모 클래스의 필드를 재정의

그러나 부모로부터 물려받은 매핑 정보를 재정의하고 싶으면 @AttributeOverrides나 @AuttributeOverride를 사용하고, 연관관계를 재정의하려면 @AssociationOverrides나 @AssociationOverride를 사용합니다.

2.4.1.한개의 필드를 수정할 때

@Entity
@AttributeOverride(name = "createdTime" ,column = @Column(name="makeTime"))
public class Item extends BaseAuditingEntity {
	...
}

2.4.2.N개의 필드를 재정의 할 때

@AttributeOverrides({
		@AttributeOverride(name = "createdTime" ,column = @Column(name="makeTime")),
		@AttributeOverride(name = "origin" ,column = @Column(name="change"))
})
public class Item extends BaseAuditingEntity {
	...
}

현재 제가 사용중인 하이버네이트 버전에서는 카멜케이스를 스네이크 케이스로 자동으로 변환 해줍니다. 여기서 주의할 점은 @AttributeOverride의 name 속성에는 실제로 들어가는 DB 컬럼의 이름이 아니라, 클래스에서 선언해준 필드의 이름을 적어줘야 한다는 점 입니다.

2.5.생성될 때 마다 created_time 자동으로 넣어주는 방법

어떤 엔티티를 데이터베이스에 넣어줄 때 마다 create_time에 DB 함수인 now()를 사용해서 현재시간 기준으로 값을 넣어주는 방법을 그동안 많이 사용해보셨을겁니다.

JPA에서는 이런 귀찮은 작업을 @MappedSuperclass에서는 처리할 수 있습니다.

@Getter
@MappedSuperclass
@ToString
@EntityListeners(AuditingEntityListener.class)
public class BaseAuditingEntity {
    @Setter
    @CreatedDate
    public LocalDateTime createdTime;

    @LastModifiedDate
    private LocalDateTime modifiedTime;


}
  • @EntityListeners : 콜백 리스너 클래스를 지정하는 어노테이션 입니다.
    • AuditingEntityListener : Entity를 저장하거나 업데이트를 감지하는 JPA Entity 리스너 입니다.
  • @CreateDate : Entity가 persist 될 때 자동으로 현재시간을 넣어줍니다.
  • @LastModifiedDate : Entity가 update 될 때 자동으로 현재 시간을 넣어 줍니다.

@CreateDate와 @LastModifiedDate는 spring-data 라이브러리 입니다.

2.6.그래서 @MappedSuperclass 어노테이션은?

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

3. 식별 vs 비식별 관계

식별관계

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

식별관계

비식별 관계

비식별 관계

필수적 비식별 관계 : 외래 키에 NULL 허용하지 않음, 반드시 연관관계를 맺어야함.

선택적 비식별 관계 : 외래 키에 NULL 허용함. 연관관계 필수 아님.

4. 복합키 맵핑

회사에서 MyBatis로 짜여진 부분을 JPA로 변환하는 작업이 있었습니다. 큰 프로젝트 였기 때문에, 데이터베이스의 스키마가 변경되면, 어디에서 사이드이펙트가 나올지 파악하기 힘들었습니다. 그래서 기존의 스키마를 변경하지 않는 방법으로 진행했습니다.

그러나 JPA는 @Id 컬럼이 필수여서 PK가 되는 컬럼이 하나가 존재해야 하는데, 기존 MyBatis로 짜여진 스키마는 PK 컬럼이 없고, 테이블 데이터 자체가 PK가 되는 테이블이 존재했습니다.

스키마의 전체적인 구조를 변경하면 되지 않기 때문에 방법을 찾아야 했었는데, 바로 방법이 복합키 맵핑 입니다.

복합키 맵핑에는 두 가지가 있습니다.

@Idclass

@IdClass를 사용할 때 식별자 클래스는 다음 조건을 만족해야 합니다.

  • Serializable 인터페이스를 구현해야 합니다.
  • equals, hashCode를 구현해야 합니다.
  • 기본 생성자가 있어야 합니다.
  • 식별자 클래스는 public 이어야 합니다.

ItemId.class

public class ItemId implements Serializable {

    private Long id;
    private String itemName;

    public ItemId() {}

    public ItemId(Long id, String itemName) {
        this.id = id;
        this.itemName = itemName;
    }

    @Override
    public boolean equals(Object o) {...}

    @Override
    public int hashCode() {...}
}

Item.class

@Entity
@IdClass(ItemId.class)
public class Item{
    @Id
    @Column(name = "ITEM_ID")
    private Long id; // ItemId와 동일하게
    @Id
    private String itemName; //ItemId와 동일하게
    
    private String name;
    private int price;
}

Item 엔티티는 PK를 iditemName 두개를 조합해서 사용합니다.

//저장
Item item = new Item();
item.setId(1);
item.setItemName("아이템");
em.persist(item);

//조회
ItemId itemId = new ItemId(1,"아이템");
em.find(Item.class,itemId);

@EmbeddedId

@EmbeddedId는 @IdClass에 비해 좀 더 객체지향적 입니다.

@EmbeddedId를 사용할 땐 다음 조건을 만족해야 합니다.

  • @Embeddable 어노테이션을 붙여주어야 합니다.

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

  • equals, hashCode를 구현해야 합니다.

  • 기본 생성자가 있어야 합니다.

  • 식별자 클래스는 public 이어야 합니다.

ItemId.class

@Embeddable
public class ItemId implements Serializable {
    @Id
    @Column(name = "ITEM_ID")
    private Long id;
    @Id
    private String itemName;

    public ItemId() {
    }

    public ItemId(Long id, String itemName) {
        this.id = id;
        this.itemName = itemName;
    }

    @Override
    public boolean equals(Object o) {...}

    @Override
    public int hashCode() {...}
}

Item.class

@Entity
public class Item{
    @EmbeddedId
    private ItemId id;

    private String name;
    private int price;
}

@EmbeddedId는 엔티티안에 객체로 자리잡고 있습니다.

//저장
Item item = new Item();
ItemId itemId = new ItemId(1,"아이템");
item.setId(itemId);
em.persist(item);

//조회
ItemId itemId = new ItemId(1,"아이템");
em.find(Item.class,itemId);

equlas()와 hashCode()를 오버라이딩 해야 하는 이유

ItemId id1 = new ItemId(1,"아이템");
ItemId id2 = new ItemId(1,"아이템");

id1.equals(id2) ?  : 거짓 

마지막 줄의 id1.equals(id2) 는 거짓이 나오게 됩니다. 기본적으로 모든 클래스는 Object를 상속 받는데, 이 Object 클래스가 제공하는 기본 equals()는 동일성 비교인 == 비교만 하기 때문입니다.

JPA는 식별자를 키로 사용해서 엔티티를 관리하며, 식별자를 비교할 때 equals()와 hashCode()를 사용합니다. 따라서 동등성(equals 비고)가 제대로 되지 않으면, 예상과는 다른 엔티티가 조회 되거나 엔티티를 찾을 수 없는 심각한 문제가 발생할 수 있기 때문에, 반드시 오버라이딩 해야합니다.