- 참조: 자바 ORM 표준 JPA 프로그래밍
- 값 타입 컬렉션
회원에서 좋아하는 음식들과 주소 내역들을 참조하고 있다고 하면 이런 값 타입을 2 개 이상 저장하기 위해서는 컬렉션에 보관해야 한다. JPA 에서는 값 타입 컬렉션을 맵핑할 수 있는 @ElementCollection 과 @CollectionTable 어노테이션을 제공한다.
@Entity
public class Member {
@Id
@Column(name = "USER_ID")
private String id;
@ElementCollection
@CollectionTable(name = "FAVORITE_FOODS",
joinColumns = @JoinColumn(name = "USER_ID"))
@Column(name = "FOOD_NAME")
private Set<String> favoriteFoods = new HashSet<>();
@ElementCollection
@CollectionTable(name = "ADDRESS",
joinColumns = @JoinColumn(name = "USER_ID"))
@AttributeOverrides({
@AttributeOverride(name = "city", column = @Column(name = "USER_CITY")),
@AttributeOverride(name = "street", column = @Column(name = "USER_STREET")),
@AttributeOverride(name = "zipcode", column = @Column(name = "USER_ZIPCODE"))})
private List<Address> addressHistories = new ArrayList<>();
favoriteFoods 부터 살펴보자. String 컬렉션 형인 favoriteFoods 의 경우 여러 속성을 가지는 값 타입은 아니다. 이렇게 1 개의 단일 형의 컬렉션인 경우 @Column 을 통해서 필드를 지정할 수 있다. @CollectionTable 은 @JoinTable 처럼 name 은 테이블의 이름을 joinColumns 은 외래키를 지정한다.
반면 addressHistory 의 경우 임베디드 타입인 Address 컬렉션 형이다. 만약 테이블의 맵핑정보를 변경하려면 @AttributeOverrides 어노테이션을 사용해주면 된다.
@ElementCollection
@CollectionTable(name = "ADDRESS",
joinColumns = @JoinColumn(name = "USER_ID"))
@AttributeOverrides({
@AttributeOverride(name = "city", column = @Column(name = "USER_CITY")),
@AttributeOverride(name = "street", column = @Column(name = "USER_STREET")),
@AttributeOverride(name = "zipcode", column = @Column(name = "USER_ZIPCODE"))})
private List<Address> addressHistories;
- 값 타입 컬렉션 사용
public static void save(EntityManager em) throws CloneNotSupportedException {
EntityTransaction tx = em.getTransaction();
tx.begin();
Member member = new Member();
member.setId("User#1");
member.getFavoriteFoods().add("짜장");
member.getFavoriteFoods().add("짬뽕");
member.getFavoriteFoods().add("탕수육");
member.getAddressHistory().add(new Address("City#1", "Street#1", "Zipcode#1"));
member.getAddressHistory().add(new Address("City#2", "Street#2", "Zipcode#2"));
em.persist(member);
tx.commit();
em.close();
}
위의 코드는 좋아하는 음식들과 주소내역을 영속하는 간단한 예제이다. Join 테이블을 사용하는 경우 Cascade 를 지정하지 않는다면 해당 엔티티들을 먼저 영속 시킨 후 member 에 추가 해줘야 하지만 값 타입 컬렉션의 경우 Member 엔티티만 영속시켜도 값 타입 들을 영속시킨다.
Hibernate:
/* insert com.example.demo.test1.Member
*/ insert
into
member
(user_id)
values
(?)
Hibernate:
/* insert collection
row com.example.demo.test1.Member.addressHistories */ insert
into
address
(user_id, user_city, user_street, user_zipcode)
values
(?, ?, ?, ?)
Hibernate:
/* insert collection
row com.example.demo.test1.Member.addressHistories */ insert
into
address
(user_id, user_city, user_street, user_zipcode)
values
(?, ?, ?, ?)
Hibernate:
/* insert collection
row com.example.demo.test1.Member.favoriteFoods */ insert
into
favorite_foods
(user_id, food_name)
values
(?, ?)
Hibernate:
/* insert collection
row com.example.demo.test1.Member.favoriteFoods */ insert
into
favorite_foods
(user_id, food_name)
values
(?, ?)
Hibernate:
/* insert collection
row com.example.demo.test1.Member.favoriteFoods */ insert
into
favorite_foods
(user_id, food_name)
values
(?, ?)
해당 코드를 수행해보면 favorite_foods 와 address 테이블에 Insert SQL 이 잘 수행된것을 확인할 수 있다. 또한 DB 에도 값이 잘 저장된다.
앞에서 1 : N 을 사용할 때 성능에 대해 언급한적이 있다. @OneToMany, @ManyToMany 의 기본 fetch 전략은 Lazy 라고 하였는데, 값 타입 컬렉션들도 여러 개의 값을 가지므로 기본 fetch 전략은 Lazy 이다. fetch 전략은 @ElementCollection 에서 설정할 수 있다.
public static void find(EntityManager em) {
EntityTransaction tx = em.getTransaction();
tx.begin();
Member member = em.find(Member.class, "User#1");
List<Address> memberAddress = member.getAddressHistory();
System.out.println("is Lazy Fetch Address");
Set<String> memberFavoriteFoods = member.getFavoriteFoods();
System.out.println("is Lazy Fetch Food");
memberAddress.forEach(address -> System.out.println(address.getCity()));
memberFavoriteFoods.forEach(System.out::println);
tx.commit();
em.close();
}
위의 코드를 작성하고 실행하면 결과가 아래와 같이 나온다.
Hibernate:
select
member0_.user_id as user_id1_5_0_
from
member member0_
where
member0_.user_id=?
is Lazy Fetch Address
is Lazy Fetch Food
Hibernate:
select
addresshis0_.user_id as user_id1_0_0_,
addresshis0_.user_city as user_cit2_0_0_,
addresshis0_.user_street as user_str3_0_0_,
addresshis0_.user_zipcode as user_zip4_0_0_
from
address addresshis0_
where
addresshis0_.user_id=?
City#1
City#2
Hibernate:
select
favoritefo0_.user_id as user_id1_4_0_,
favoritefo0_.food_name as food_nam2_4_0_
from
favorite_foods favoritefo0_
where
favoritefo0_.user_id=?
짜장
짬뽕
탕수육
순서를 유의해서 살펴보면 값 타입 컬렉션들을 단순 참조하는 시점이 아닌 직접 사용할 때 조회 SQL 이 수행되는것을 확인할 수 있다.
- 값 타입 컬렉션의 항목 수정
값 타입 컬렉션에서 특정 항목을 제거하고 다른 항목을 삽입하고 싶을 때에는 주의해서 사용해야 한다.
member.getAddressHistory().add(new Address("City#1", "Street#1", "Zipcode#1"));
member.getAddressHistory().add(new Address("City#2", "Street#2", "Zipcode#2"));
member.getAddressHistory().remove(new Address("City#1", "Street#1", "Zipcode#1"));
member.getAddressHistory().add(new Address("City#3", "Street#3", "Zipcode#3"));
위와 같이 City#1 의 주소를 삭제하고 City#3 의 신규 주소를 추가하여 실행하면 City#2, City#3 의 주소만 남기를 기대하겠지만 저장된 데이터를 조회하면 예상과는 다르게 조회된다.
만약 Java 에 대한 기본기가 탄탄하고, Value Object 와 Entity 에 대한 구분을 명확하게 할 줄 안다면 결과를 조회해보기도 전에 문제점을 알 수 있어야 한다.
equals 및 hashCode 를 오버라이드 하지 않았기 때문이다. 엔티티에서 객체를 식별할 때에는 식별자값이 있다. 하지만 주소에는 Id 를 별도로 지정하지 않았고, 이럴 경우 객체가 고유하다고 식별할 수 있는 기준은 메모리 주소이다. 하지만 Value Object 는 엔티티가 아니기 때문에 이렇게 고유성을 판별하면 안된다.
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + ((city == null) ? 0 : city.hashCode());
result = prime * result + ((street == null) ? 0 : street.hashCode());
result = prime * result + ((zipcode == null) ? 0 : zipcode.hashCode());
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
Address other = (Address) obj;
if (city == null) {
if (other.city != null)
return false;
} else if (!city.equals(other.city))
return false;
if (street == null) {
if (other.street != null)
return false;
} else if (!street.equals(other.street))
return false;
if (zipcode == null) {
if (other.zipcode != null)
return false;
} else if (!zipcode.equals(other.zipcode))
return false;
return true;
}
위처럼 equals와 hashCode 를 생성해주자. 이미 IDE 는 이런 중요성을 알고 있기 때문에 자동생성을 제공하고 있다. 위처럼 오버라이드 한 후 H2 에서 조회하면 결과가 기대한대로 나온다.
- 값 타입 컬렉션의 제약사항
만약 이미 저장된 값 타입을 영속성 컨텍스트로 불러온 후, 값 타입 컬렉션 요소 하나를 remove 하고 새로운 요소 하나를 추가하면 어떤식으로 동작할까? 해당 요소 하나만 지우고 다른 요소 하나를 추가할까?
Member member = em.find(Member.class, "User#1");
member.getAddressHistory().remove(new Address("City#1", "Street#1", "Zipcode#1"));
member.getAddressHistory().add(new Address("City#3", "Street#3", "Zipcode#3"));
위와 같이 코드를 작성하고 Log 를 보면 아래와 같이 결과가 나온다.
Hibernate:
/* delete collection com.example.demo.test1.Member.addressHistory */ delete
from
address
where
user_id=?
Hibernate:
/* insert collection
row com.example.demo.test1.Member.addressHistory */ insert
into
address
(user_id, user_city, user_street, user_zipcode)
values
(?, ?, ?, ?)
Hibernate:
/* insert collection
row com.example.demo.test1.Member.addressHistory */ insert
into
address
(user_id, user_city, user_street, user_zipcode)
values
(?, ?, ?, ?)
address 를 삭제할 때 user_id 와 더불어서 해당 요소만 삭제하려면 city, street, zipcode 조건이 where 절에 있어야 하는데 없다. 연관된 데이터를 Clear 하고 DB에 저장해야 할 address 들에 대해 insert SQL 을 수행한다.
만약 값 타입 컬렉션의 목록이 많고 빈번하게 수정이 일어난다면 1 : N 관계로 맵핑을 고려해야 한다. 값 타입을 만약 1 : N 관계처럼 사용하고 싶다면 식별자가 존재하지 않으므로 모든 컬럼들을 묶어서 복합 키로 구성해야하며, 이렇게 되면 null 값도 할당이 불가능하다.
따라서 위와 같은 상황을 맞이하게 되면 1 : N 관계로 전환하고, Cascade 및 고아 객체 제거 기능 적용을 검토해야 한다.
- 마치면서
이번 절에서는 JPA 에서 값 타입 컬렉션을 어떻게 맵핑하는지 살펴보았다. 사용법도 잘 숙지해야겠지만 오히려 가장 중요한것은 Value Object 와 Entity 를 구분할줄 아는것이다. JPA 에 아무리 익숙하고 맵핑을 잘한다고 해도 Entity 와 ValueObject 를 혼동해서 사용하면 아무런 소용이 없다.
'Framework and Tool > JPA' 카테고리의 다른 글
JPA - 객체지향 쿼리 언어 - 개요 (0) | 2021.07.28 |
---|---|
JPA - 값 타입 - 요구사항 분석과 맵핑5 (0) | 2021.07.27 |
JPA - 값 타입 - 불변객체 (0) | 2021.07.22 |
JPA - 값 타입 - 임베디드 타입 (0) | 2021.07.20 |
JPA - 고급맵핑 - 요구사항 분석과 맵핑4 (0) | 2021.07.18 |
댓글