9. 값 타입 (2)
5. 값 타입 컬렉션 (Collection Value Type)
값 타입을 하나 이상 저장하려면 컬렉션에 저장하면 된다.
이때 2가지 어노테이션이 필요하다.
@ElementCollection
: 값 타입 컬렉션을 사용하는 속성에 표시한다.@CollectionTable
: 컬렉션을 위한 추가 테이블을 매핑한다. 생략 가능하며, 기본값으로 {엔티티 이름}_{컬렉션 속성 이름} 테이블과 매핑한다.
위의 다이어그램에서 favoriteFoods는 기본값 타입 컬렉션이고, addressHistory는 임베디드 타입 컬렉션이다.
관계형 데이터베이스 테이블의 컬럼 안에 컬렉션을 포함할 수 없기 때문에, 두 가지 값 타입 컬렉션 모두 별도의 테이블을 추가하고, 추가한 테이블을 매핑해야 한다.
favoriteFoods 와 addressHistory 값 타입 컬렉션은 아래와 같이 구현할 수 있다.
예제 코드)
@Entity
public class Member {
...
@ElementCollection
@CollectionTable(
name = "FAVORITE_FOOD",
joinColumns = @JoinColumn(name = "MEMBER_ID"))
@Column(name = "FOOD_NAME") // 컬럼이 하나일 경우, 컬럼명 지정 가능
private Set<String> favoriteFoods = new HashSet<>();
@ElementCollection
@CollectionTable(
name = "ADDRESS",
joinColumns = @JoinColumn(name = "MEMBER_ID"))
private List<Address> addressHistory = new ArrayList<>();
...
}
favoriteFoods 처럼 값으로 사용되는 컬럼이 하나인 경우, @Column을 사용해서 컬럼명을 지정할 수 있다.
5.1. 값 타입 컬렉션 사용
✔️ 등록
Member member = new Member();
// 임베디드 값 타입
member.setHomeAddress(new Address("통영", "몽돌해수욕장", "660-123"));
// 기본값 타입 컬렉션
member.getFavoriteFoods().add("짬뽕");
member.getFavoriteFoods().add("짜장");
member.getFavoriteFoods().add("탕수육");
// 임베디드 값 타입 컬렉션
member.getAddressHistory().add(new Address("서울", "강남", "123-123"));
member.getAddressHistory().add(new Address("서울", "강북", "000-000"));
em.persist(member);
마지막에 member 엔티티만 영속화하면, JPA 가 member 엔티티의 값 타입도 함께 저장한다.
실제 데이터베이스에 실행되는 INSERT SQL은 6개이고, 각각은 다음과 같다.
- member: INSERT SQL 1번
- member.homeAddress: 컬렉션이 아닌 임베디드 값 타입이므로 회원테이블을 저장하는 SQL에 포함된다.
- member.favoriteFoods: INSERT SQL 3번
- member.addressHistory: INSERT SQL 2번
📌 값 타입 컬렉션은 영속성 전이 + 고아 객체 제거 기능을 필수로 가진다고 볼 수 있다.
✔️ 조회
값 타입 컬렉션도 조회할 때 페치 전략을 선택할 수 있고, 기본값은 LAZY 다.
@ElementCollection(fetch = FetchType.LAZY)
Member member = em.find(Member.class, 1L);
Address homeAddress = member.getHomeAddress();
Set<String> favoriteFoods = member.getFavoriteFoods(); // LAZY
/**
* SQL
* SELECT MEMBER_ID, FOOD_NAME
* FROM FAVORITE_FOODS
* WHERE MEMBER_ID = 1
*/
for (String favoriteFood : favoriteFoods) {
System.out.println("favoriteFood = " + favoriteFood);
}
List<Address> addressHistory = member.getAddressHistory(); // LAZY
/**
* SQL
* SELECT MEMBER_ID, CITY, STREET, ZIPCODE
* FROM ADDRESS
* WHERE MEMBER_ID = 1
*/
addressHistory.get(0);
위의 예제를 실행할 때 실행되는 SELECT SQL은 다음과 같다.
- member: 회원만 조회한다. 임베디드 값 타입인 homeAddress도 함께 조회한다. SELECT SQL을 1번 호출한다.
- member.homeAddress: 회원을 조회할 때 같이 조회해둔다.
- member.favoriteFoods: LAZY로 설정해서, 실제 컬렉션을 사용할 때 SELECT SQL을 1번 호출한다.
- member.addressHistory: LAZY로 설정해서, 실제 컬렉션을 사용할 때 SELECT SQL을 1번 호출한다.
✔️ 수정
Member member = em.find(Member.class, 1L);
// 임베디드 값 타입 수정
member.setHomeAddress("새로운도시", "신도시1", "123456");
// 기본값 타입 컬렉션 수정
Set<String> favoriteFoods = member.getFavoriteFoods();
favoriteFoods.remove("탕수육");
favoriteFoods.add("치킨");
// 임베디드 값 타입 컬렉션 수정
List<Address> addressHistory = member.getAddressHistory();
addressHistory.remove(new Address("서울", "기존 주소", "123-123"));
addressHistory.add(new Address("새로운도시", "새로운 주소", "123-456"));
임베디드 값 타입 수정
- MEMBER 테이블만 UPDATE 한다. 사실 Member 엔티티를 수정하는 것과 같다.
기본값 타입 컬렉션 수정
- 탕수육을 치킨으로 변경하려면, 탕수육을 제거하고 치킨을 추가해야 한다.
- 자바의 String 타입은 수정할 수 없다.
임베디드 값 타입 컬렉션 수정
- 값 타입은 불변해야 한다.
- 따라서 컬렉션에서 기존 주소를 삭제하고 새로운 주소를 등록해야 한다.
- 참고로 값 타입은 equals, hashCode를 꼭 구현해야 한다.
5.2. 제약사항
엔티티에 소속된 값 타입의 경우, 값이 변경되어도 소속된 엔티티를 데이터베이스에 찾고 값을 변경하면 된다.
그러나 값 타입 컬렉션의 경우, 보관된 값 타입들이 별도의 테이블에 보관된다. 따라서 보관된 값 타입의 값이 변경되면 데이터베이스에 있는 원본 데이터를 찾기 어렵다.
이런 문제로 인해, JPA 구현체들은 값 타입 컬렉션에 변경 사항이 발생하면 값 타입 컬렉션이 매핑된 테이블의 연관된 모든 데이터를 삭제하고, 현재 값 타입 컬렉션 객체 있는 모든 값을 데이터베이스에 다시 저장한다.
따라서 실무에서는 값 타입 컬렉션이 매핑된 테이블에 데이터가 많다면 값 타입 컬렉션 대신에 일대다 관계를 고려해야 한다.
참고) 일대다 관계로 설정하기
값 타입 컬렉션을 사용하는 대신에 새로운 엔티티를 생성하여 일대다 관계로 설정해보자.
추가적으로 이 엔티티에 영속성 전이 + 고아 객체 제거 기능을 적용하면 값 타입 컬렉션처럼 사용할 수 있다.
@Entity
public class Member {
...
@OneToMany(casecade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "MEMBER_ID")
private List<AddressEntity> addressHistory = new ArrayList<AddressEntity>();
}
@Entity
public class AddressEntity {
@Id @GeneratedValue
private Long id;
@Embedded Address address;
...
}
그리고 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본 키를 구성해야 한다. 이로 인해 (기본 키 제약조건), 컬럼에 null을 입력할 수 없고 같은 값을 중복해서 저장할 수 없다는 제약이 있다.
6. 정리
엔티티 타입 (Entity Type) | 값 타입 (Value Type) | |
식별자 | 식별자가 있다. | 식별자가 없다. |
생명 주기 | 생명 주기가 있다. | 생명 주기를 엔티티에 의존한다. |
공유 | 공유할 수 있다. | 공유하지 않는 것이 안전하다. |
값 타입은 정말 값 타입이라 판단될 때만 사용해야 한다.
식별자가 필요하고 지속해서 값을 추적하고 구분하고 변경해야 한다면, 그것은 값 타입이 아닌 엔티티다.