값 타입 컬렉션은 하나 이상의 값을 넣을 때 사용한다.
기존에 있었던 테이블 Member에 Address가 있는데, 주소에 대한 이력관리와 좋아하는 음식 정보를 넣고 싶다는 가정을 하면,
기존에 있었던 Address를 사용하여 주소 이력을 관리하기에는 한계가 있다.
또 좋아하는 음식이 여러 개라면?
여러 가지 갚을 넣어야 할 때 컬렉션을 이용해야 한다.
대략적인 설계는 아래의 그림과 같다.
그래서 컬렉션 프레임워크인 Set, List를 사용하였다,
값 타입에 컬렉션을 사용하려면..
- @ElementCollection, @CollectionTable을 사용하여야 한다.
- 데이터베이스는 컬렉션을 Member 테이블에 컬럼을 종속시켜 같은 테이블에 저장할 수 없다.
- 그래서 컬렉션을 저장하기 위한 별도의 테이블이 필요하다.
Member.java 에 다음을 추가한다.
@ElementCollection
@CollectionTable(name = "FAVORITE_FOOD"
,joinColumns = @JoinColumn(name = "MEMBER_ID")
)
@Column(name = "FOOD_NAME")
private Set<String> favoriteFoods = new HashSet<>();
public Set<String> getFavoriteFoods() {
return favoriteFoods;
}
public void setFavoriteFoods(Set<String> favoriteFoods) {
this.favoriteFoods = favoriteFoods;
}
@ElementCollection
@CollectionTable(name = "ADDRESS"
,joinColumns = @JoinColumn(name = "MEMBER_ID")
)
private List<Address> addressHistory = new ArrayList<>();
public List<Address> getAddressHistory() {
return addressHistory;
}
public void setAddressHistory(List<Address> addressHistory) {
this.addressHistory = addressHistory;
}
위의 코드를 실행하기 위해, 테스트 코드를 작성해보자.
JpaMain.java
Member member = new Member();
member.setUserName("member1");
member.setHomeAddress(new Address("HomeCity1", "street", "10000"));
member.getFavoriteFoods().add("치킨");
member.getFavoriteFoods().add("족발");
member.getFavoriteFoods().add("피자");
member.getAddressHistory().add(new Address("old1", "street", "10000"));
member.getAddressHistory().add(new Address("old2", "street", "10000"));
em.persist(member);
위 코드를 실행한 결과, 아래와 같다.
Hibernate:
call next value for hibernate_sequence
Hibernate:
/* insert hellojpa.Member
*/ insert
into
Member
(INSERT_MEMBER, createdDate, UPDATE_MEMBER, lastModifiedDate, city, street, zipCode, TEAM_ID, USERNAME, WORK_CITY, WORK_STREET, WORK_ZIP_CODE, endDate, startDate, MEMBER_ID)
values
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
Hibernate:
/* insert collection
row hellojpa.Member.addressHistory */ insert
into
ADDRESS
(MEMBER_ID, city, street, zipCode)
values
(?, ?, ?, ?)
Hibernate:
/* insert collection
row hellojpa.Member.addressHistory */ insert
into
ADDRESS
(MEMBER_ID, city, street, zipCode)
values
(?, ?, ?, ?)
Hibernate:
/* insert collection
row hellojpa.Member.favoriteFoods */ insert
into
FAVORITE_FOOD
(MEMBER_ID, FOOD_NAME)
values
(?, ?)
Hibernate:
/* insert collection
row hellojpa.Member.favoriteFoods */ insert
into
FAVORITE_FOOD
(MEMBER_ID, FOOD_NAME)
values
(?, ?)
Hibernate:
/* insert collection
row hellojpa.Member.favoriteFoods */ insert
into
FAVORITE_FOOD
(MEMBER_ID, FOOD_NAME)
values
(?, ?)
한 개의 Member, 두 개의 주소(old1, old2), 세 개의 선호 음식(치킨, 피자, 족발)이 들어갔다.
근데, 여기서 거주 도시를 HomeCity1 에서 newCity로 바꾸려고 한다고 가정할 때,
우선 객체를 불러오는데, em.find 로 불러올 때, 프록시 객체로 불러온 후,
프록시 객체의 값을 사용하려고 할 때 실제 DB를 조회해서 사용하게 된다.
JpaMain.java
em.flush();
em.clear();
System.out.println("========================== START====================");
Member findMember = em.find(Member.class, member.getId());
// 지연로딩이다. 종속된 데이터들은 프록시로 불러와진다.
System.out.println("===================================================");
System.out.println("findMember.city = " + findMember.getHomeAddress().getCity());
System.out.println("===================================================");
========================== START====================
Hibernate:
select
member0_.MEMBER_ID as MEMBER_I1_8_0_,
member0_.INSERT_MEMBER as INSERT_M2_8_0_,
member0_.createdDate as createdD3_8_0_,
member0_.UPDATE_MEMBER as UPDATE_M4_8_0_,
member0_.lastModifiedDate as lastModi5_8_0_,
member0_.city as city6_8_0_,
member0_.street as street7_8_0_,
member0_.zipCode as zipCode8_8_0_,
member0_.TEAM_ID as TEAM_ID15_8_0_,
member0_.USERNAME as USERNAME9_8_0_,
member0_.WORK_CITY as WORK_CI10_8_0_,
member0_.WORK_STREET as WORK_ST11_8_0_,
member0_.WORK_ZIP_CODE as WORK_ZI12_8_0_,
member0_.endDate as endDate13_8_0_,
member0_.startDate as startDa14_8_0_,
team1_.TEAM_ID as TEAM_ID1_14_1_,
team1_.INSERT_MEMBER as INSERT_M2_14_1_,
team1_.createdDate as createdD3_14_1_,
team1_.UPDATE_MEMBER as UPDATE_M4_14_1_,
team1_.lastModifiedDate as lastModi5_14_1_,
team1_.USERNAME as USERNAME6_14_1_
from
Member member0_
left outer join
Team team1_
on member0_.TEAM_ID=team1_.TEAM_ID
where
member0_.MEMBER_ID=?
===================================================
findMember.city = HomeCity1
===================================================
Hibernate:
select
favoritefo0_.MEMBER_ID as MEMBER_I1_5_0_,
favoritefo0_.FOOD_NAME as FOOD_NAM2_5_0_
from
FAVORITE_FOOD favoritefo0_
where
favoritefo0_.MEMBER_ID=?
그런 다음, 값을 바꾸려고 할 때,
findMember.getHomeAddress().setCity("새로 바꿀 도시");
이렇게 바꾸게 된다면, 값 참조로 인해서 모든 도시 데이터가 위에 작성한 내용대로 바뀌게 될 것이다.
따라서 아래와 같이 바꿔줘야 한다
Address oldAddr = findMember.getHomeAddress();
// 이렇게 교체를 해줘야함
findMember.setHomeAddress(new Address("newCity", oldAddr.getStreet(), oldAddr.getZipCode()));
새로운 생성자를 생성해서 값을 넣어주면 값 참조가 발생하지 않는다.
Hibernate:
/* update
hellojpa.Member */ update
Member
set
INSERT_MEMBER=?,
createdDate=?,
UPDATE_MEMBER=?,
lastModifiedDate=?,
city=?,
street=?,
zipCode=?,
TEAM_ID=?,
USERNAME=?,
WORK_CITY=?,
WORK_STREET=?,
WORK_ZIP_CODE=?,
endDate=?,
startDate=?
where
MEMBER_ID=?
만약 선호하는 음식을 바꾸고 싶을 때, List에서 해당 데이터를 제거하고, 새로 add를 해줘야한다.
// 치킨 → 한식
findMember.getFavoriteFoods().remove("치킨");
findMember.getFavoriteFoods().add("한식");
Hibernate:
/* delete collection row hellojpa.Member.favoriteFoods */ delete
from
FAVORITE_FOOD
where
MEMBER_ID=?
and FOOD_NAME=?
Hibernate:
/* insert collection
row hellojpa.Member.favoriteFoods */ insert
into
FAVORITE_FOOD
(MEMBER_ID, FOOD_NAME)
values
(?, ?)
주소 이력을 바꾸려면 위에서 음식을 바꾼 것처럼 동일한 방법으로 하면 된다.
// 주소 이력 중에 하나를 교체하려면 아래와 같이 해야 한다.
findMember.getAddressHistory().remove(new Address("old1", "street", "10000"));
findMember.getAddressHistory().add(new Address("newCity1", "street", "10000"));
그렇지 않으면 객체는 주소를 참조하고 있기 때문에, 위처럼 번거럽더라도 생성자를 선언해줘야한다.
값 타입 컬렉션의 제약사항이 있다.
- 값 타입은 엔티티와 다르게 식별자 개념이 없다.
- 값은 변경하면 추적이 어렵다.
- 값 타입 컬렉션에 변경 사항이 발생하면, 주인 엔티티와 연관된 모든 데이터를 삭제하고, 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장한다.
- 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본 키를 구성해야 함 : null 입력 X, 중복 저장 X
값 타입 컬렉션 대안
- 실무에서는 상황에 따라 값 타입 컬렉션 대신에 일대다 관계를 고려해야한다.
- 일대다 관계를 위한 엔티티를 만들고, 여기에서 값 타입을 사용한다.
- 영속성 전이(Cascade) + 고아 객체 제거를 사용해서 값 타입 컬렉션처럼 사용한다.
- ex) AddressEntity
AddressEntity.java
package hellojpa;
import javax.persistence.*;
@Entity
@Table(name = "ADDRESS")
public class AddressEntity {
@Id @GeneratedValue
private Long id;
public AddressEntity() {
}
public AddressEntity(String city, String street, String zipcde) {
Address address = new Address(city, street, zipcde);
this.address = address;
}
@Embedded
private Address address;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Address getAddress() {
return address;
}
public void setAddress(Address address) {
this.address = address;
}
@Override
public String toString() {
return "AddressEntity{" +
"id=" + id +
", address=" + address +
'}';
}
@Override
public boolean equals(Object obj) {
if(this == obj) return true;
if(obj == null || getClass() != obj.getClass()) return false;
AddressEntity ae = (AddressEntity) obj;
return Objects.equals(ae.address.getCity(), address.getCity())
&& Objects.equals(ae.address.getStreet(), address.getStreet())
&& Objects.equals(ae.address.getZipCode(), address.getZipCode());
}
}
equals 를 꼭 재정의해줘야 컬렉션에서 객체를 찾을 수 있다.
equals 재정의를 빠뜨리면 특정 객체를 찾아서 제거하기가 어렵다.
equals 재정의 없이 remove, contains 를 하면 false를 리턴하니 꼭 재정의 해야한다.
Member.java
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "MEMBER_ID")
private List<AddressEntity> addressHistory = new ArrayList<>();
public List<AddressEntity> getAddressHistory() {
return addressHistory;
}
public void setAddressHistory(List<AddressEntity> addressHistory) {
this.addressHistory = addressHistory;
}
기존에 있었던 List<Address> addressHistory는 주석처리하여 관리한다.
JpaMain.java
Member member = new Member();
member.setUserName("member1");
member.setHomeAddress(new Address("HomeCity1", "street", "10000"));
member.getFavoriteFoods().add("치킨");
member.getFavoriteFoods().add("족발");
member.getFavoriteFoods().add("피자");
member.getAddressHistory().add(new AddressEntity("old1", "street", "10000"));
member.getAddressHistory().add(new AddressEntity("old2", "street", "10000"));
em.persist(member);
em.flush();
em.clear();
System.out.println("========================== START====================");
Member findMember = em.find(Member.class, member.getId());
// 지연로딩이다. 종속된 데이터들은 프록시로 불러와진다.
System.out.println("===================================================");
System.out.println("findMember.city = " + findMember.getHomeAddress().getCity());
System.out.println("===================================================");
// homeCity → newCity
// findMember.getHomeAddress().setCity("이러면 객체 변수 참조로 일괄 변경된다.");
Address oldAddr = findMember.getHomeAddress();
// 이렇게 교체를 해줘야함
findMember.setHomeAddress(new Address("newCity", oldAddr.getStreet(), oldAddr.getZipCode()));
// 치킨 → 한식
findMember.getFavoriteFoods().remove("치킨");
findMember.getFavoriteFoods().add("한식");
// String은 이렇게 해줘야 한다.
// 주소 이력 중에 하나를 교체하려면 아래와 같이 해야 한다.
findMember.getAddressHistory().remove(new AddressEntity("old1", "street", "10000"));
findMember.getAddressHistory().add(new AddressEntity("newCity1", "street", "10000"));
List 타입 Address 대신 AddressEntity 타입을 사용하면 이와 같다.
다른 테이블에 일대다 연관관계가 들어가면 아래와 같이 update 쿼리가 어쩔 수 없이 실행되게 된다.
Hibernate:
/* create one-to-many row hellojpa.Member.addressHistory */ update
ADDRESS
set
MEMBER_ID=?
where
id=?
Hibernate:
/* create one-to-many row hellojpa.Member.addressHistory */ update
ADDRESS
set
MEMBER_ID=?
where
id=?
순서가 뒤죽박죽이긴 하지만, 실행결과를 보면 제대로 모든 코드가 실행됐음을 확인할 수 있다.
Hibernate:
select
favoritefo0_.MEMBER_ID as MEMBER_I1_4_0_,
favoritefo0_.FOOD_NAME as FOOD_NAM2_4_0_
from
FAVORITE_FOOD favoritefo0_
where
favoritefo0_.MEMBER_ID=?
Hibernate:
select
addresshis0_.MEMBER_ID as MEMBER_I5_0_0_,
addresshis0_.id as id1_0_0_,
addresshis0_.id as id1_0_1_,
addresshis0_.city as city2_0_1_,
addresshis0_.street as street3_0_1_,
addresshis0_.zipCode as zipCode4_0_1_
from
ADDRESS addresshis0_
where
addresshis0_.MEMBER_ID=?
Hibernate:
call next value for hibernate_sequence
Hibernate:
/* insert hellojpa.AddressEntity
*/ insert
into
ADDRESS
(city, street, zipCode, id)
values
(?, ?, ?, ?)
Hibernate:
/* update
hellojpa.Member */ update
Member
set
INSERT_MEMBER=?,
createdDate=?,
UPDATE_MEMBER=?,
lastModifiedDate=?,
city=?,
street=?,
zipCode=?,
TEAM_ID=?,
USERNAME=?,
WORK_CITY=?,
WORK_STREET=?,
WORK_ZIP_CODE=?,
endDate=?,
startDate=?
where
MEMBER_ID=?
Hibernate:
/* delete one-to-many row hellojpa.Member.addressHistory */ update
ADDRESS
set
MEMBER_ID=null
where
MEMBER_ID=?
and id=?
Hibernate:
/* create one-to-many row hellojpa.Member.addressHistory */ update
ADDRESS
set
MEMBER_ID=?
where
id=?
Hibernate:
/* delete collection row hellojpa.Member.favoriteFoods */ delete
from
FAVORITE_FOOD
where
MEMBER_ID=?
and FOOD_NAME=?
Hibernate:
/* insert collection
row hellojpa.Member.favoriteFoods */ insert
into
FAVORITE_FOOD
(MEMBER_ID, FOOD_NAME)
values
(?, ?)
Hibernate:
/* delete hellojpa.AddressEntity */ delete
from
ADDRESS
where
id=?
이번 예제로 많은 내용을 알 수 있었다.
그 중에 정리하면,
엔티티 타입의 특징은
- 식별자가 있고,
- 생명주기를 관리하고
- 공유가 가능하다.
값 타입의 특징은
- 식별자가 없고,
- 생명주기를 엔티티에 의존하고
- 복사해서 사용해야 공유되지 않고 안전하게 사용한다.
- 불변 객체로 만드는 것이 안전하다.
값 타입은 정말 값 타입이라 판단될 때만 사용하는 것이 좋다.
엔티티와 값 타입을 혼동해서 엔티티를 값 타입으로 만들면 안된다.
식별자가 필요하고, 지속해서 값을 추적, 변경해야 한다면 그것은 값 타입이 아닌 엔티티이다.
'자바 > JPA' 카테고리의 다른 글
[JPA/Java] 객체지향 쿼리 언어 종류 (0) | 2023.04.27 |
---|---|
[JPA/Java] For property-based access both setter and getter should be present 문제 해결 @Access or @Column (0) | 2023.03.13 |
[JPA/Java] 값 타입 2 (불변 객체) (0) | 2023.03.08 |
[JPA/Java] 값 타입 1 (기본 값, 임베디드(복합 값) 타입) (0) | 2023.03.06 |
[JPA/Java] 영속성 전이 : Cascade, 고아 객체 (0) | 2023.03.03 |
댓글