[jpa] 값 타입

JPA의 데이터 타입은 크게 엔티티 타입값 타입이 있다.
엔티티 타입은 @Entity로 정의하는 객체이고, 값 타입은 int, Integer, String 처럼 단순 값으로 사용하는 자바 기본 타입이나 객체를 말한다.
(영한님은 여기서 엔티티 타입은 살아있는 생물이고, 값 타입은 단순한 수치 정보라고 표현했다.)

JPA는 값 타입을 표현하는 방법을 몇가지 더 제공한다.
아래는 JPA에서 제공하는 값 타입의 목록이다.

  • 기본값 타입(primitive, wrapper, String)
  • 임베디드 타입
  • 값 타입 컬렉션

값 타입은 기본적인 특징은 아래와 같다.

  • 식별자가 없다
  • 생명주기가 엔티티에 의존한다
  • 공유하면 안된다

값 타입

자바의 primitive 타입, Wrapper 클래스, String 클래스를 말한다.

1
2
3
4
5
6
7
8
@Entity
class Member{
@Id
private Long id;

private String name;
private int age;
}

임베디드 타입(복합 값 타입)

여러개의 값 타입을 묶어서 하나의 값 타입으로 정의하는 방법이다.
우선 값 타입을 적용하기 전의 코드는 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Entity
class Member{
@Id
private Long id;

// 근무기간
@Temporal(TemporalType.DATE)
private Date startDate;
@Temporal(TemporalType.DATE)
private Date endDate;

// 집주소
private String city;
private String street;
private String zipCode;
}

위 처럼 엔티티가 모든 속성을 flat 하게 가지는 것은 객체지향적이지 않다.
근무기간, 집주소로 묶을 수 있다면 더 좋을 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Entity
class Member{
@Id
private Long id;

@Embedded
private Period workPeriod;

@Embedded
private Address homeAddress;
}

@Embeddable
class Period{
@Temporal(TemporalType.DATE)
private Date startDate;
@Temporal(TemporalType.DATE)
private Date endDate;

public boolean isWork(Date date){
// 값 타입을 위한 메서드 또한 작성 가능
}
}

@Embeddable
class Address{
@Column(name = "city") // 매핑할 컬럼 지정 가능
private String city;
private String street;
private String zipcode;
}

작성한 값 타입은 다른 곳에서 재사용 될수도 있고, 값 타입만을 위한 메서드도 작성 가능하다.
엔티티가 더욱 의미있고 응집력있게 변했다

이러한 임베디드 타입을 정의하려면 아래의 2가지 어노테이션이 필요하다.

  • @Embeddable : 값 타입을 정의하는 곳에 표시
  • @Embedded : 값 타입을 사용하는 곳에 표시

임베디드 타입과 테이블 매핑

이렇게 작성한 임베디드 타입은 테이블에 아래와 같이 매핑된다.
임베디드 타입과 테이블 매핑

임베디드 타입은 엔티티의 값일 뿐이다. 그러므로 보다시피 임베디드 타입을 사용하기 전과 후에 매핑되는 테이블은 같다.

ORM을 사용하지 않았더라면 객체와 테이블은 대부분 1:1로 매핑되었을 것을,
ORM을 사용함으로써 객체와 테이블을 더 세밀하게 매핑할 수 있다.
(잘 설계한 ORM 어플리케이션은 매핑힌 클래스의 수가 테이블의 수보다 더 많다)

임베디드 타입의 포함과 연관관계

임베디드 타입은 다른 임베디드 타입을 포함할 수 있고, 다른 엔티티를 참조할 수도 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@Entity
class Member{
// ...
@Embedded
private Address address;

@Embedded
private PhoneNumber phoneNumber;
}

@Embeddable
class Address{
private String city;

private String street;

@Embedded // 포함 가능
private Zipcode zipcode;
}

@Embeddable
class Zipcode{
String zip;

Strign code;
}

class PhoneNumber{
String areaCode;

String localNumber;

@ManyToOne // 연관관계 가능
PhoneServiceProvider phoneServiceProvider;
}

@Entity
class PhoneServiceProvider{
@Id
private String name;
}

속성 재정의: @AttributeOverride

아래와 같이 정의하고 싶을 수 있다.

1
2
3
4
5
6
7
8
class Member{
// ...
@Embedded
private Address homeAddress;

@Embedded
private Address companyAddress;
}

ORM 에서만 객체로 묶을 뿐, 테이블 레벨에선 flat하게 펴지므로 위와 같이 정의하는 것은 불가능하다.
컬럼명이 중복되기 때문이다.
이럴땐 @AttributeOverride를 통해 컬럼명을 재정의해줘야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
class Member{
// ...
@Embedded
private Address homeAddress;

@Embedded
@AttributeOverrides({
@AttributeOverride(name = "city", column = @Column(name = "company_city")),
@AttributeOverride(name = "street", column = @Column(name = "company_street")),
@AttributeOverride(name = "zipcode", column = @Column(name = "company_zipcode"))
})
private Address companyAddress;
}

name에는 Address 내의 필드명을 써주고, column에는 @Column 어노테이션을 써서 재정의 해주면 된다.
어노테이션을 너무 많이 사용되서 지저분하긴 하지만, 다행히(?) 이렇게 한 엔티티에 중복해서 임베디드를 사용할 일이 많이 없다.

@AttributeOveride는 엔티티에 설정해야 한다. 임베디드 타입이 임베디드 타입을 가지고 있어도 엔티티에 설정해야 한다.

임베디드 타입과 null

임베디드 타입이 null 이면 매핑한 컬럼 값을 모두 null 이 된다(!!)

1
2
// city, street, zipcode가 모두 null이 됨  
member.setAddress(null);

임베디드 타입의 딜레마

을 다룰때는 기본적으로 참조가 아닌 복제의 형태를 따른다.
하지만 여기서 문제는, JPA에서는 임베디드 타입이 값 타입인데, 형태는 일반적인 클래스라 참조 방식으로 동작한다는 것이다.

불변성

참조 방식으로 동작하므로 아래와 같은 상황을 막을 수 없다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Address address1 = new Address("city", "street", "zipcode1");
Member member1 = Member.builder()
.name("joont1")
.homeAddress(address1)
.build();

Address address2 = address1;
address2.setZipcode("zipcode2");
Member member2 = Member.builder()
.name("joont2")
.homeAddress(address2)
.build();

em.persist(member1);
em.persist(member2);

기대하는 것은 member1에 zipcode1, member2에 zipcode2가 저장되어야 하는 것이지만(값 타입의 특성상),
당연히 그렇게 처리되지 않는다. 참조 방식으로 동작하기 때문이다.

때문에 JPA에서 값 타입을 사용할때는 setter 등을 모두 제거한 불변객체로 다루어야 하고,
(자바에서 불변 객체로 만드는 가장 간단한 방법은 setter 제거이다)
값을 재사용 할 때는 deep copy를 수행해서 절대 재사용 되는 일이 없도록 해야한다.

1
2
3
4
5
6
7
8
9
10
11
@AllArgsConstructor // 전체 프로퍼티를 받는 생성자
@Embeddable
class Address implements Cloneable{
private String city;
private String street;
private String zipcode;

public Object clone() throws CloneNotSupportedException{
return super.clone();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Address address1 = new Address("city", "street", "zipCode1");
Member member1 = Member.builder()
.name("joont1")
.homeAddress((Address)address1)
.build();

Address address2 = address1.clone(); // 복사
address2.setZipcode("zipcode2");
Member member2 = Member.builder()
.name("joont2")
.homeAddress((Address)address2)
.build();

em.persist(member1);
em.persist(member2);

현재는 Address 내부가 flat해서 간단히 Object.clone()의 호출만으로도 클로닝이 되지만,
다른 임베디드 타입을 사용하거나 배열을 사용하고 있었을 경우 해당 필드까지 전부 deep clone을 해줘야한다.
하지만 또 반대로 임베디드 타입이 엔티티를 가지고 있을 경우 deep clone 하면 안된다.
이렇듯이 골치아픈게 deep clone이기 때문에, 가급적이면 @Embeddable 내부는 flat 하게 유지해주는 것이 좋다.

비교

값 타입이라면 동일성 비교(==)나 동등성 비교(equals)가 동작해야 한다.
하지만 현재 Address는 그것이 보장되지 않으므로, equals 메서드를 재정의 해줘야한다.

1
2
3
4
5
6
7
8
@AllArgsConstructor
@EqualsAndHashCode // 전체 필드에 대해 equals와 hashCode 재정의
@Embeddable
class Address{
private String city;
private String street;
private String zipcode;
}

임베디드 타입의 equals 메서드를 재정의 할 때는 보통 모든 필드의 값을 비교하도록 구현한다.
그리고 equals 메서드를 재정의하면 hashCode까지 같이 재정의 해주는 것이 좋다.
그렇지 않으면 해시를 사용하는 컬렉션(HashSet, HashMap)에서 문제가 발생할 수 있기 때문이다.

값 타입 컬렉션

여러개의 값 타입을 저장할 떄 사용한다. 그러려면 컬렉션에 저장해야 하는데, RDB에서는 필드에 컬렉션을 저장할 수 없다.
그러므로 값 만을 저장하는 테이블을 따로 만들어서 사용해야 한다.
값 타입 컬렉션 ERD

위 ERD를 엔티티에서 매핑하면 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Entity
class Member{
// ...

@Embedded
private Address homeAddress;

@ElementCollection
@CollectionTable(
name = "FAVORITE_FOOD",
joinColumns = @JoinColumn(name = "member_id")
)
@Column(name = "food_name")
private List<String> favoriteFoodList = new ArrayList<>();

@ElementCollection
@CollcetionTable(
name = "ADDRESS_HISOTRY",
joinColumns = @JoinColumn(name = "member_id")
)
private List<Address> addressHistory = new ArrayList<>(); // Address는 위와 동일
}

@ElementCollection으로 값 타입 컬렉션 인것을 알려주고,
@CollectionTable로 해당 값들을 저장한 테이블을 알려주면 된다(외래키랑 같이).
favorite_food처럼 값으로 사용되는 컬럼이 하나일 경우 @Column을 사용해서 컬럼명을 지정할 수 있다.
값 타입 컬렉션은 무조건적으로 CascadeType.ALL, orphanRemoval = true가 붙은것 처럼 동작한다.

값 타입 컬렉션 사용

저장

1
2
3
4
5
6
7
8
9
10
11
12
Member member = new Member();

member.setHomeAddress(new Address("city", "street", "zipCode4"));

member.getFavoriteFoodList().add("pork");
member.getFavoriteFoodList().add("beef");

member.getAddressHistory().add(new Address("city1", "street1", "zipcode1"));
member.getAddressHistory().add(new Address("city2", "street2", "zipcode2"));
member.getAddressHistory().add(new Address("city3", "street3", "zipcode3"));

em.persist(member);

member : insert 1번
homeAddress : 임베디드 값 타입이므로 member에 포함됨
favoriteFoodList : insert 2번
addressHistory : insert 3번

조회

값 타입 컬렉션도 조히할 때 패치 전략을 사용할 수 있다. Default는 LAZY이다.

1
@ElementCollection(fetch = FetchType.LAZY)

조회 방식은 일반적인 @OneToMany 조회 할때와 동일하다.
직접 사용할 때 조회된다.

수정

1
2
3
4
5
6
7
Member member = em.find(Member.class, 1);
List<String> favoriteFoodList = member.getFavoriteFoodList();
favoriteFoodList.set(0, "changed pork");
favoriteFoodList.set(1, "changed beef");

List<Address> addressHisotry = member.getAddressHistory();
addressHisotry.get(0).setStreet("changed street");

값 타입은 식별자가 없는 단순한 값들의 모음으로 테이블에 저장된다.
그래서 여기에 저장된 값이 변경되면 데이터베이스에 저장된 원본 데이터의 값을 찾기 어렵게 된다.
이러한 문제로 인해 JPA는 값 타입 컬렉션에 변경사항이 발생하면,
값 타입 컬렉션에 매핑된 테이블의 모든 데이터를 삭제하고, 현재 값 타입 컬렉션에 있는 모든 값을 다시 데이터베이스에 저장한다.

즉, 위와 같은 코드에서는 아래와 같이 쿼리가 발생한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/** address_history **/
delete
from
ADDRESS_HISTORY
where
member_id=1

insert
into
ADDRESS_HISTORY
(member_id, city, street, zipcode)
values
(1, "city1", "street1", "zipcode1")
insert
into
ADDRESS_HISTORY
(member_id, city, street, zipcode)
values
(1, "city2", "changed street", "zipcode2") -- insert modifired data
insert
into
ADDRESS_HISTORY
(member_id, city, street, zipcode)
values
(1, "city3", "street3", "zipcode3")

/** favorite_food **/
delete
from
FAVORITE_FOOD
where
member_id=1

insert
into
FAVORITE_FOOD
(member_id, food_name)
values
(1, "changed pork"); -- insert modifired data
insert
into
FAVORITE_FOOD
(member_id, food_name)
values
(1, "changed beef"); -- insert modifired data

이러한 비효율적인 특징이 있으므로 만약 값 타입 컬렉션에 매핑된 테이블에 데이터가 많다면 값 타입 컬렉션 대신 일대다 관계를 고려해보는 것이 좋다.
게다가 값 타입 컬렉션은 모든 컬럼을 묶어서 기본키를 구성하므로, 컬럼에 null을 입력할 수 없고, 중복된 값을 입력할 수 없는 제약조건도 있다.