[JPA] 값 타입 컬렉션
📌 Topic
- 💡 값 타입 컬렉션
- 💡 실전 예제(실습으로 대체)
⚡ 01. 값 타입 컬렉션
값 타입 컬렉션이란 값 타입을 컬렉션에 넣어 사용 하는 것을 의미한다. 값 타입 컬렉션을 DB에서 구현하기 위해서는 MEMBER 테이블을 기준으로 별도의 테이블(FAVORITE_FOOD, ADDRESS)로 분리하여 사용을 해야 한다.
✅ 01-1. 값 타입 컬렉션이란?
@ElementCollection
@CollectionTable(name = "FAVORITE_FOOD", joinColumns = @JoinColumn(name = "MEMBER_ID"))
Set<String> favoriteFoods = new HashSet<String>();
@ElementCollection
@CollectionTable(name = "ADDRESS", joinColumns = @JoinColumn(name = "MEMBER_ID"))
List<Address> addressHistory = new ArrayList<Address>();
- 값 타입을 하나 이상 저장할 때 사용한다.
- @ElementCollection, @CollectionTable 사용한다.
- DB는 컬렉션을 같은 테이블에 저장할 수 없다.
- 컬렉션을 저장하기 위한 별도의 테이블이 필요하다.
- 즉, 1 : N로 풀어서 별도의 테이블로 저장해 사용해야 한다.
⚡ 02. 값 타입 컬렉션 사용 해보기
값 타입 컬렉션은 영속성 전이와 고아 객체 제거 기능을 필수로 가진다.
- 값 타입 컬렉션 저장 예제
- 값 타입 컬렉션 조회 예제
- 값 타입 컬렉션 수정 예제
⚡ 03. 값 타입 컬렉션 저장 예제
🍃 03-1. 저장 예제
try {
MemberTest member = new MemberTest();
member.setUsername("youngminkim");
member.setHomeAddress(new Address("homeCity", "street1", "10000"));
// Set<String> 셋팅
member.getFavoriteFoods().add("치킨");
member.getFavoriteFoods().add("피자");
member.getFavoriteFoods().add("햄버거");
// List<Address> 셋팅
member.getAddressHistory().add(new Address("old1", "street1", "14565"));
member.getAddressHistory().add(new Address("old2", "street1", "14566"));
em.persist(member);
tx.commit();
} catch (Exception e) {
tx.rollback();
e.printStackTrace();
} finally {
em.close();
}
1 : N 연관 관계에서 Cascade = ALL, orphanRemoval = true와 비슷하다.
영속성 전이와, 고아 객체는 해당 링크를 참고 해주세요.
우선 흥미로운 부분은 값 타입 컬렉션을 개별적으로 em.persist(member))를 해주지 않아도, 값이 저절로 셋팅 된다는 부분이다. 즉, 다른 테이블인데도 불구하고 member 객체를 셋팅 해주는 것만으로도 추가적으로 생성되는 테이블(ADDRESS, FAVORITE_FOOD)에 데이터가 들어간다는 부분이다.
이러한 부분이 가능한 이유는 '값 타입 컬렉션'도 '값 타입'이기 때문이다. 또한 값 타입은 본인 스스로의 라이프 사이클(Life Cycle)을 제어할 수 없기에 부모 엔티티에 의존한다.
🖨 03-2. 출력 예제
Hibernate:
call next value for hibernate_sequence
Hibernate:
/* insert com.hello.jpatest.MemberTest
*/ insert
into
MemberTest
(city, street, ZIPCODE, USERNAME, MEMBER_ID)
values
(?, ?, ?, ?, ?)
Hibernate:
/* insert collection
row com.hello.jpatest.MemberTest.addressHistory */ insert
into
ADDRESS
(MEMBER_ID, city, street, ZIPCODE)
values
(?, ?, ?, ?)
Hibernate:
/* insert collection
row com.hello.jpatest.MemberTest.addressHistory */ insert
into
ADDRESS
(MEMBER_ID, city, street, ZIPCODE)
values
(?, ?, ?, ?)
Hibernate:
/* insert collection
row com.hello.jpatest.MemberTest.favoriteFoods */ insert
into
FAVORITE_FOOD
(MEMBER_ID, FOOD_NAME)
values
(?, ?)
Hibernate:
/* insert collection
row com.hello.jpatest.MemberTest.favoriteFoods */ insert
into
FAVORITE_FOOD
(MEMBER_ID, FOOD_NAME)
values
(?, ?)
Hibernate:
/* insert collection
row com.hello.jpatest.MemberTest.favoriteFoods */ insert
into
FAVORITE_FOOD
(MEMBER_ID, FOOD_NAME)
values
(?, ?)
3월 09, 2022 2:19:43 오후 org.hibernate.engine.jdbc.connections.internal.DriverManagerConnectionProviderImpl$PoolState stop
INFO: HHH10001008: Cleaning up connection pool [jdbc:h2:tcp://localhost/~/jpashop]
Process finished with exit code 0
✅ 03-3. 값 타입 컬렉션 저장 예제 DB
⚡ 04. 값 타입 컬렉션 조회 예제
🍃 04-1. 조회 예제
try {
MemberTest member = new MemberTest();
member.setUsername("youngminkim");
member.setHomeAddress(new Address("homeCity", "street1", "10000"));
// Set<String> : 중복 허용 x, 조회 잦은 경우, O(1)
member.getFavoriteFoods().add("치킨");
member.getFavoriteFoods().add("피자");
member.getFavoriteFoods().add("햄버거");
member.getAddressHistory().add(new Address("old1", "street1", "14565"));
member.getAddressHistory().add(new Address("old2", "street1", "14566"));
em.persist(member);
em.flush();
em.clear();
// ⚡ : Member에 엮인 FAVORITE_FOOD, ADDRESS 테이블의 모든걸 가져오겠지?
// 💡 : 안 가져온다.. 지연 로딩으로 실제 값이 사용되는 경우 가져온다.
System.out.println("================================================");
MemberTest findMember = em.find(MemberTest.class, member.getId());
tx.commit();
} catch (Exception e) {
tx.rollback();
e.printStackTrace();
} finally {
em.close();
}
em.find를 통해 MemberTest 객체를 조회하는 상황. 주석을 참고하자.
🖨 04-2. 출력 예제
================================================
Hibernate:
select
membertest0_.MEMBER_ID as member_i1_12_0_,
membertest0_.city as city2_12_0_,
membertest0_.street as street3_12_0_,
membertest0_.ZIPCODE as zipcode4_12_0_,
membertest0_.USERNAME as username5_12_0_
from
MemberTest membertest0_
where
membertest0_.MEMBER_ID=?
위에서 말했다시피 ADDRESS, FAVORITE_FOOD 테이블의 내용은 함께 가져오지 않는다. 지연 로딩으로 설정이 되어 있기 때문에 실제 값이 사용되는 경우 쿼리가 나가게 된다.
✅ 04-3. 값 타입 컬렉션 조회 소스 수정
System.out.println("================================================");
MemberTest findMember = em.find(MemberTest.class, member.getId());
// ⚡ : 추가된 부분
List<Address> addressHistory = findMember.getAddressHistory();
for (Address address : addressHistory) {
System.out.println("address = " + address.getCity());
}
System.out.println();
// ⚡ : 추가된 부분
Set<String> favoriteFoods = findMember.getFavoriteFoods();
for (String food : favoriteFoods) {
System.out.println("food = " + food);
}
위 처럼 findMember 참조 변수를 통해 addressHistory, favoriteFoods 데이터를 직접 가져오고 있다. 다음으로 아래 출력 소스를 살펴보자.
✅ 04-4. 값 타입 컬렉션 조회 내용 출력 수정본
================================================
Hibernate:
select
membertest0_.MEMBER_ID as member_i1_12_0_,
membertest0_.city as city2_12_0_,
membertest0_.street as street3_12_0_,
membertest0_.ZIPCODE as zipcode4_12_0_,
membertest0_.USERNAME as username5_12_0_
from
MemberTest membertest0_
where
membertest0_.MEMBER_ID=?
Hibernate:
select
addresshis0_.MEMBER_ID as member_i1_0_0_,
addresshis0_.city as city2_0_0_,
addresshis0_.street as street3_0_0_,
addresshis0_.ZIPCODE as zipcode4_0_0_
from
ADDRESS addresshis0_
where
addresshis0_.MEMBER_ID=?
address = old1
address = old2
Hibernate:
select
favoritefo0_.MEMBER_ID as member_i1_7_0_,
favoritefo0_.FOOD_NAME as food_nam2_7_0_
from
FAVORITE_FOOD favoritefo0_
where
favoritefo0_.MEMBER_ID=?
food = 치킨
food = 햄버거
food = 피자
3월 09, 2022 2:55:14 오후 org.hibernate.engine.jdbc.connections.internal.DriverManagerConnectionProviderImpl$PoolState stop
INFO: HHH10001008: Cleaning up connection pool [jdbc:h2:tcp://localhost/~/jpashop]
Process finished with exit code 0
값 타입 컬렉션은 지연 로딩을 사용하기 때문에 직접 호출을 하지 않으면 값을 가져오지 않는다. 즉, 값 타입 컬렉션을 조회하는 경우에는 직접 호출을 하여 사용하자.
⚡ 05. 값 타입 컬렉션 수정 예제
🍃 05-1. 수정 예제
try {
MemberTest member = new MemberTest();
member.setUsername("youngminkim");
member.setHomeAddress(new Address("homeCity", "street1", "10000"));
// Set<String> : 중복 허용 x, 조회 잦은 경우, O(1)
member.getFavoriteFoods().add("치킨");
member.getFavoriteFoods().add("피자");
member.getFavoriteFoods().add("햄버거");
member.getAddressHistory().add(new Address("old1", "street1", "14565"));
member.getAddressHistory().add(new Address("old2", "street1", "14566"));
em.persist(member);
em.flush();
em.clear();
System.out.println("================================================");
MemberTest findMember = em.find(MemberTest.class, member.getId());
// 💡 : MemberTest가 가지고 있는 Address의 city 값을 변경한다.
// ❌ : findMember.getHomeAddress().setCity("newCity");
// ⚡ : 추가된 부분
Address oldAddress = findMember.getHomeAddress();
findMember.setHomeAddress(new Address("newCity", oldAddress.getStreet(), oldAddress.getZipcode())); // ⚡ 통으로 갈아 끼워야 한다.
// 💡 : Collection 내에 존재하는 '치킨'을 '한식'으로 변경한다.
findMember.getFavoriteFoods().remove("치킨");
findMember.getFavoriteFoods().add("한식");
tx.commit();
} catch (Exception e) {
tx.rollback();
e.printStackTrace();
} finally {
em.close();
}
- em.find를 통해 특정 ID에 맞는 Member 객체를 획득한다.
- Address의 city 값 변경.
- 이 때 통째로 갈아 끼워 넣어야 한다.
- FavoriteFood의 ‘치킨’을 ‘한식’으로 변경한다.
- 값 타입은 Immutable 해야 하기 때문에 수정이라는 개념이 존재하지 않는다.
- 값을 삭제한 후에, 다시 넣어주는 방식을 채택해야 한다.
✅ 05-2. 출력 예제
================================================
Hibernate:
/* update
com.hello.jpatest.MemberTest */ update
MemberTest
set
city=?,
street=?,
ZIPCODE=?,
USERNAME=?
where
MEMBER_ID=?
Hibernate:
/* delete collection row com.hello.jpatest.MemberTest.favoriteFoods */ delete
from
FAVORITE_FOOD
where
MEMBER_ID=?
and FOOD_NAME=?
Hibernate:
/* insert collection
row com.hello.jpatest.MemberTest.favoriteFoods */ insert
into
FAVORITE_FOOD
(MEMBER_ID, FOOD_NAME)
values
(?, ?)
핵심은 update(ADDRESS)가 한번 나가고, 후에 delete와 insert(FAVORITE_FOOD)가 나갔다는 점이다. 또한 Collection의 값만 바꿔도 JPA가 알아서 변경 사항을 감지한 후에 DB에 쿼리를 날린다는 점.
✅ 05-3. 수정 예제(Address)
위에서는 FavoriteFood를 변경 하였고, 지금은 List< Address >의 내용을 변경한다.
MemberTest findMember = em.find(MemberTest.class, member.getId());
// ⭐ : 주소 'old1'을 삭제 후 변경 한다.
findMember.getAddressHistory().remove(new Address("old1", "street1", "14565"));
findMember.getAddressHistory().add(new Address("newCity1", "street1", "14565"));
- 위에서 말했다시피 값 타입의 경우 Immutable한 객체이기 때문에 삭제 필요.
- 후에 수정을 원하는 데이터를 객체에 다시 한번 넣어서 수정해야 한다.
✅ 05-4. 출력 예졔(Address)
Hibernate:
/* delete collection com.hello.jpatest.MemberTest.addressHistory */ delete
from
ADDRESS
where
MEMBER_ID=?
Hibernate:
/* insert collection
row com.hello.jpatest.MemberTest.addressHistory */ insert
into
ADDRESS
(MEMBER_ID, city, street, ZIPCODE)
values
(?, ?, ?, ?)
Hibernate:
/* insert collection
row com.hello.jpatest.MemberTest.addressHistory */ insert
into
ADDRESS
(MEMBER_ID, city, street, ZIPCODE)
values
(?, ?, ?, ?)
- 이상한 부분이 존재한다. 분명히 add는 한번 했는데, insert 두 번 나간다.
- ADDRESS 테이블의 데이터를 싹 밀어버린다.
- 수정을 원한 데이터와 기존에 있던 데이터를 두 번 INSERT 한다.
⚡ 06. 값 타입 컬렉션의 제약사항
@Target( { METHOD, FIELD })
@Retention(RUNTIME)
public @interface ElementCollection {
/**
* (Optional) The basic or embeddable class that is the element
* type of the collection. This element is optional only if the
* collection field or property is defined using Java generics,
* and must be specified otherwise. It defaults to the
* paramterized type of the collection when defined using
* generics.
*/
Class targetClass() default void.class;
/**
* (Optional) Whether the collection should be lazily loaded or must be
* eagerly fetched. The EAGER strategy is a requirement on
* the persistence provider runtime that the collection elements
* must be eagerly fetched. The LAZY strategy is a hint to the
* persistence provider runtime.
*/
FetchType fetch() default LAZY;
}
- @ElementCollection은 기본이 지연 로딩으로 설정됨.
@OrderColumn(name = "address_history_order") // 해당 어노테이션으로 어떻게 해결은 가능하다, 하지만 엄청 위험하다
@ElementCollection // 값 타입 컬렉션 지정
@CollectionTable(name = "ADDRESS", joinColumns = @JoinColumn(name = "MEMBER_ID"))
List<Address> addressHistory = new List<Address>();
값 타입 컬렉션에 변경이 일어나면 연관된 모든 데이터를 삭제 후 다시 저장한다.
값 타입(컬렉션)은 엔티티와 다르게 식별자가 없으며, 값을 변경하면 추적이 상당히 어려워진다. 값 타입 컬렉션에 변경 사항이 발생하면, 주인 엔티티와 연관된 모든 데이터를 삭제하고, 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장한다.
-- MEMBE_ID, city, street, zipcode를 하나의 그룹(기본키)로 만들어서 사용해야 한다.
create table ADDRESS (
MEMBER_ID bigint not null,
city varchar(255),
street varchar(255),
zipcode varchar(255),
)
또한 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본키를 구성해야 한다. NULL 입력은 허용하지 않고 중복 저장 역시 허용하지 않는다.
⚡ 07. 값 타입 컬렉션 대안
- 실무에서는 상황에 따라 값 타입 컬렉션 대신 1 : N(일대다) 관계를 고려.
- 1 : N(일대다) 관계를 위한 엔티티를 만들고, 여기에서 값 타입을 사용.
- 영속성 전이(Cascade) + 고아 객체 제거를 사용해서 값 타입 컬렉션 처럼 사용.
- ex) AddressEntity
✅ 07-1. 값 타입 컬렉션 대안 예제
AddressEntity를 생성하여 Address(임베디드 타입)을 핸들링 한다.
@Entity
@Table(name = "ADDRESS")
public class AddressEntity {
@Id
@GeneratedValue
private Long id;
@Embedded
private Address address;
public AddressEntity() {
}
public AddressEntity(String city, String street, String zipcode) {
this.address = new Address(city, street, zipcode); // embedded 타입 초기화
}
//..Getter, Setter 중략
}
MemberTest 변경
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "MEMBER_ID")
private List<AddressEntity> addressHistory = new ArrayList<>();
호출부 변경
try {
MemberTest member = new MemberTest();
member.setUsername("youngminkim");
member.setHomeAddress(new Address("homeCity", "street1", "10000")); // embedded type
// Set<String> favoriteFoods
member.getFavoriteFoods().add("치킨");
member.getFavoriteFoods().add("피자");
member.getFavoriteFoods().add("햄버거");
// ⚡ : 이 부분이 변경 됨
member.getAddressHistory().add(new AddressEntity("old1", "street1", "14565"));
member.getAddressHistory().add(new AddressEntity("old2", "street1", "14566"));
em.persist(member);
em.flush();
em.clear();
System.out.println("=======================START=====================");
MemberTest findMember = em.find(MemberTest.class, member.getId());
// 💡 : MemberTest가 가지고 있는 homeAddress 객체의 city 값을 변경한다.
// ❌ : findMember.getHomeAddress().setCity("newCity");
// Address oldAddress = findMember.getHomeAddress();
// findMember.setHomeAddress(new Address("newCity", oldAddress.getStreet(), oldAddress.getZipcode())); // ⚡ 통으로 갈아 끼워야 한다.
// 💡 : Collection 내에 존재하는 '치킨'을 '한식'으로 변경한다.
// findMember.getFavoriteFoods().remove("치킨");
// findMember.getFavoriteFoods().add("한식");
// ⭐ : 주소 'old1'을 삭제 후 변경 한다.
// findMember.getAddressHistory().remove(new Address("old1", "street1", "14565"));
// findMember.getAddressHistory().add(new Address("newCity1", "street1", "14565"));
tx.commit();
} catch (Exception e) {
tx.rollback();
e.printStackTrace();
} finally {
em.close();
}
- 기존에는 Address 타입을 넣어서 사용하였다.
- 현재는 AddressEntity를 제네릭 타입으로 갖는다
💡 08. 값 타입 컬렉션을 언제 쓰는가?
- 추적할 필요도 없고 값이 바뀌어도 상관이 없는 경우.
- 그렇지 않은 경우에는 엔티티로 묶어서 사용을 하는것을 권장.
- ex) 다중 선택 콤보 박스.
✍ 09. 정리
- 엔티티 타입의 특징
- 식별자 존재
- 생명 주기 관리
- 공유
- 값 타입의 특징
- 식별자 없음
- 생명 주기를 엔티티에 의존
- 공유하지 않는 것이 안전(복사해서 사용)
- 불변 객체로 만드는 것이 안전
- 값 타입은 정말 값 타입이라 판단될 때만 사용해야 한다.
- 엔티티와 값 타입을 혼동해서 엔티티를 값 타입으로 만들면 안된다.
식별자가 필요하고, 지속해서 값을 추척, 변경해야 한다면 값 타입이 아닌 엔티티다.
댓글남기기