오봉이와 함께하는 개발 블로그
JPA - 값 타입 컬렉션 본문
값 타입 컬렉션
값 타입을 컬렉션에 담아서 사용하는 것을 값 타입 컬렉션이라 한다.
객체로 구현하는 것에는 어려움이 없지만, 문제는 DB 테이블로 구현할 때다.
단순하게 값 타입이 하나일 때는 필드 속성으로 테이블에 넣으면 됐지만 컬렉션을 사용할 때는 얘기가 달라진다. DB는 컬렉션을 담을 수 있는 구조가 존재하지 않기 때문이다.
그래서 클래스 안에 컬렉션이 객체로 존재하면 별도의 테이블을 생성해 1:N 관계로 풀어내야 한다.
값 타입 컬렉션은 식별자를 하나 더 넣어서 PK로 사용하게 된다면 그건 더이상 값 타입이 아니라 엔티티가 되기 때문에 테이블을 생성하고 안에 모든 컬럼들을 다 묶어서 하나의 PK로 만들어내야 한다.
저장 예제
@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> addressesHistory = new ArrayList<>();
member.getFavoriteFoods().add("치킨1");
member.getFavoriteFoods().add("치킨2");
member.getFavoriteFoods().add("치킨3");
member.getAddressHistory().add(new Address("city1", "street1", "10001"));
member.getAddressHistory().add(new Address("city2", "street2", "10002"));
em.persist(member);
member만 persist() 해줘도 모든 값이 테이블에 들어가게 된다.
값 타입 컬렉션은 생명주기가 부모 클래스에 의존하게 된다.
값 타입 컬렉션은 영속성 전이(CASCADE) + 고아 객체 제거 기능을 필수로 가진다 볼 수 있다.
조회 예제
Member findMember = em.find(Member.class, member.getId());
코드를 실행하면 Member 테이블에 존재하는 컬럼들만 조회하게 된다.
List<Address> addressHistory = findMember.getAddressHistory();
for(Address address : addressHistory) {
System.out.println("address = " + address.getCity());
}
Set<String> favoriteFoods = findMember.getFavoriteFoods();
for(String favoriteFoods : favoriteFoods) {
System.out.println("favoriteFood = " + favoriteFood);
}
값 타입 컬렉션들은 기본적으로 지연 로딩이기 때문에 실제 값을 찾아올 때 조회 쿼리가 날아간다.
수정 예제
전에 봤듯 값 타입 업데이트는 다음과 같다.
findMember.getHomeAddress().setCity("new"); // 값 타입 수정(컬렉션 아님)은 이렇게 하면 사이드 이팩트가 발생할 수 있기 때문에 사용하면 안 된다.
Address oldAddress = findMember.getHomeAddress();
findMember.setHomeAddress(new Address("newCity", oldAddress.getStreet(), oldAddress.getZipcode()));
// 객체를 하나 만들어서 통으로 갈아 끼워야 한다.
값 타입 컬렉션 업데이트는 다음과 같이 해야 한다.
findMember.getFavoriteFoods().remove("치킨1");
findMember.getFavoriteFoods().add("피자1");
// FavoriteFoods도 값 타입이기 때문에 통으로 갈아 끼워야한다.
// 더군다나 String 타입이기 때문에 업데이트를 할 수도 없다.
findMember.getAddressHistory().remove(new Address("city1", "street1", "10001"));
// List 컬렉션은 remove() 할 때 equals를 사용한다.
// 때문에 삭제하려는 값과 완전히 똑같은 새로운 Address를 만들어서 object 파라미터로 넣어줘야 한다.
// 그래서 equals와 hashCode를 제대로 재정의하지 않으면 원하는 결과가 나오지 않으니 주의하자.
findMember.getAddressHistory().add(new Address("newCity1", "street1", "10001"));
위 코드를 실행하면 결론적으로 원하는 결과가 나오지만 쿼리문은 그렇지 않다.
findMember.getAddressHistory().remove()를 할 때 주인 엔티티와 관련된 모든 데이터를 삭제하고 현재 컬렉션에 있는 값을 모두 다시 저장하게 된다.
제약사항
- 값 타입은 엔티티와 다르게 식별자 개념이 없기 때문에 값은 변경하면 추적이 어렵다.
- 값 타입 컬렉션에 변경 사항이 발생하면, 주인 엔티티와 연관된 모든 데이터를 삭제하고 값 타입 컬렉션에 있는 모든 값을 모두 다시 저장한다.
- 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본키를 구성해야 한다.
- 기본키로 구성하면 null 입력 안 되는 문제 발생
- 기본키로 구성하면 중복 저장 안 되는 문제 발생
대안
- 실무에서는 상황에 따라 값 타입 컬렉션 대신 1:N 관계를 고려하는 것이 좋을 수 있다
- 1:N 관계를 위한 엔티티를 만들고, 여기에서 값 타입을 사용한다.
- 영속성 전이 + 고아 객체 제거를 사용해서 값 타입 컬렉션처럼 사용한다.
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "MEMBER_ID")
private List<AddressEntity> addressHistory = new ArrayList<>();
@Entity
@Getter @Setter
@Table(name = "ADDRESS")
public class AddressEntity {
@Id @GeneratedValue
private Long id;
private Address address;
public AddressEntity() {
}
public AddressEntity(Long id, Address address) {
this.id = id;
this.address = address;
}
public AddressEntity(String city, String street, String zipCode) {
this.address = new Address(city, street, zipCode);
}
}
member.getAddressHistory().add(new AddressEntity("city1", "street1", "10001"));
member.getAddressHistory().add(new AddressEntity("city2", "street2", "10002"));
정리
- 값 타입 컬렉션은 값을 하나 이상 저장할 때 사용한다.
- 가급적 정말 간단할 때만 사용하자.
- @ElementCollection(선언 어노테이션), @CollectionTable(테이블 이름 설정) 사용
- 데이터베이스는 컬렉션을 같은 테이블에 저장할 수 없다.
- 컬렉션을 저장하기 위한 별도의 테이블이 필요하다.
- 엔티티 타입의 특징
- 식별자가 있다.
- 생명주기가 관리된다.
- 공유할 수 있다.
- 값 타입의 특징
- 식별자가 없다.
- 생명 주기를 엔티티에 의존한다.
- 공유하지 않는 것이 안전하다.(복사해서 사용하자)
- 불변 객체로 만드는 것이 안전하다.
제약사항이 존재하기 때문에 옵션을 추가해서 사용할 수는 있으나 그마저도 완벽하게 동작하지 않는다.
그렇기 때문에 1:N 관계를 위한 엔티티를 만들고, 여기에서 값 타입을 사용하는 등 다른 방법으로 풀어내야 한다.
값 타입은 정말 값 타입이라 판단될 때만 사용하자.
엔티티와 값 타입을 혼동해서 엔티티를 값 타입으로 만들면 안된다.
식별자가 필요하고, 지속해서 값을 추적하거나 변경해야 한다면 그것은 값 타입이 아닌 엔티티다.
출처 : 인프런 김영한 지식공유자님의 스프링 부트와 JPA 실무 완전 정복 로드맵 강의
'BE > JPA' 카테고리의 다른 글
JPA - 객체 지향 쿼리 언어 소개 (0) | 2022.06.29 |
---|---|
JPA - 실전 예제 6 - 값 타입 매핑 (0) | 2022.06.29 |
JPA - 값 타입의 비교 (0) | 2022.06.29 |
JPA - 값 타입과 불변 객체 (0) | 2022.06.29 |
JPA - 임베디드 타입 (0) | 2022.06.29 |