값 타입의 비교
값 타입은 인스턴스가 달라도 그 안에 값이 같으면 같은 것으로 봐야 한다.
// primitive type 비교
int a = 10;
int b = 10;
System.out.println(a == b);//true
// 임베디드 타입(인스턴스) 비교
Address a = new Address("서울", "AAA", 1000);
Address b = new Address("서울", "AAA", 1000);
System.out.println(a == b);//false
- 어째서 임베디드 타입의 ==비교는 false가 뜨는것인가?
- 당연하다. 인스턴스가 다르니 다른 객체이기 때문이다.
그럼 어떻게 해야할까?
- 동일성(identity) 비교: 인스턴스의 참조 값을 비교, == 사용
- 동등성(equivalence)비교: 인스턴스의 값을 비교, equals() 사용
- 값 타입은 a.equals(b)를 사용해서 동등성 비교를 해야 한다.
- 값 타입의 equals()메소드를 적절하게 재정의 해준다(주로 모든 필드 사용)
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Address address = (Address) o;
return Objects.equals(city, address.city) &&
Objects.equals(street, address.street) &&
Objects.equals(zipcode, address.zipcode);
}
@Override
public int hashCode() {
return Objects.hash(city, street, zipcode);
}
...
...
...
// 임베디드 타입(인스턴스) 비교
Address a = new Address("서울", "AAA", 1000);
Address b = new Address("서울", "AAA", 1000);
System.out.println(a.equals(b));//true 이제 true가 된다.
※ 참고
equals와 hashCode를 만들어 줄때 필드에 직접 접근하는 것보단 getter를 이용하는 것이 좋다.
getter를 호출해야 프록시일 때도 getter를 통해 프록시 객체가 진짜 객체로 접근하는 것이 가능하기 때문이다.
값 타입 컬렉션
값 타입 컬렉션이란 말 그대로 값 타입을 컬렉션에 담아서 사용하는 것이다.
아래 그림을 보면 Member는 값 타입 2개를 컬렉션으로 구현하고 있다.
그런데 문제는 이것들을 DB 테이블로 구성할 때 발생한다. 단순하게 값 타입이 1개일 때는 Member 테이블에 넣으면 될텐데 컬렉션을 DB에 넣으려고 하면 RDB 입장에서는 내부적으로 컬렉션을 담을 수 있는 구조가 없다.
(물론 요즘 DB들은 Json들 담을 수 있도록 발전했지만 그래도 기본적으론 안되는 방식이다.)
그러므로 Member 입장에서 address, food가 1:N 개념이므로 DB 입장에서는 별도의 테이블로 구성해야 한다.
※ 주의할 점은 Member_ID(PK, FK)처럼이 아닌 값 타입이 별도의 식별자를 갖게 된다면 해당 테이블은 엔티티가 되어버린다.
- 값 타입을 하나 이상 저장할 때 사용
- @ElementCollection, @CollectionTable 사용
- 데이터베이스는 컬렉션을 같은 테이블에 저장할 수 없다.
- 컬렉션을 저장하기 위한 별도의 테이블이 필요하다.
@ElementCollection
@CollectionTable(name = "FAVORITE_FOOD", joinColumns = @JoinColumn(name = "MEMBER_ID"))
@Column(name = "FOOD_NAME") // String은 값이 1개고 직접 정의한 것이 아니기 때문에 예외적으로 컬럼 이름 설정이 가능하다.
private Set<String> favoriteFoods = new HashSet<>();
@ElementCollection
@CollectionTable(name = "ADDRESS", joinColumns = @JoinColumn(name = "MEMBER_ID"))
private List<Address> addressHistory = new ArrayList<>();
※ 참고
임베디드 타입인 Address의 경우 타입에 정의된 필드명들이 테이블에 그대로 정의되겠지만 만약 변경이 필요하다면 @AttributeOverride를 사용하면 된다.
값 타입 컬렉션 사용
값 타입 저장 예제
- 결과를 보면 기대하던 대로 데이터들이 잘 들어가 있는 것을 확인할 수 있다.
- 근데 코드를 살펴보면 값 타입은 따로 persist() 하지 않았고 member만 persist했는데 데이터가 잘 들어갔다?
- 이유는 값 타입 컬렉션도 결국 값 타입이기 때문에 Member의 생명주기에 의존하고 있기 때문이다.
Member member = new Member();
member.setName("member1");
member.setHomwAddress(new Address("homeCity", "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);
tx.commit();
값 타입 조회 예제
- 값 타입 컬렉션도 지연 로딩 전략 사용
Member findMember = em.find(Member.class, member.getId());
쿼리 결과를 확인해보면 Member에 직접적으로 종속된 필드인 homeAddress 빼곤 컬렉션은 조회가 안되고 있다.
JPA는 값 타입 컬렉션도 지연 로딩 전략을 사용하고 있다는 뜻이다.
Member findMember = em.find(Member.class, member.getId());
List<Address> addressHistory = findMember.getAddressHistory();
for(Address address : addressHistory){
System.out.println("address = " + address.getCity());
}
Set<String> favoriteFoods = findMember.getFavoriteFoods();
for(String favoriteFood : favoriteFoods){
System.out.println("favoriteFood = " + favoriteFood);
}
값 타입 수정 예제
/*기본적인 임베디드 타입 변경*/
// 잘못된 수정. 값 타입은 참조 값 공유로 인해 Immutable 해야 한다.
findMember.getHomeAddress().setCity("newCity");
Address add = findMember.getHomeAddress();
findMember.setHomeAddress(new Address("newCity", add.getStreet(), add.getZipcode()));
/*값 타입 컬렉션 수정 예제 - 치킨을 김밥으로 변경*/
findMember.getFavoriteFoods().remove("치킨");
findMember.getFavoriteFoods().add("김밥");
findMember.getAddressHistory().remove(new Address("old1", "street", "10000"));
findMember.getAddressHistory().add(new Address("newCity", "street", "10000"));
- FavoriteFood의 컬렉션 타입은 String이기 때문에 String 자체가 값 타입이다. String은 update하는 방법 자체가 없기 때문에 갈아 끼우는 방법 뿐이다.
이때 신기한 점은 컬렉션 값을 바꿔줬을 뿐인데 JPA가 변경 감지를 통해 알아서 바꿔준다. 마치 영속성 전이가 되는 것처럼. - 우선 Address 컬렉션도 결국 값 타입이기 때문에 통으로 갈아끼워야 한다!!
그리고 기본적으로 컬렉션은 대상을 찾을 때 equals를 사용한다. 그래서 equals와 hashCode 오버라이딩이 중요한 것이다.
그런데 Address 컬렉션을 갈아끼우는 쿼리는 보면 뭔가 이상하다??
우선 값 타입 컬렉션들은 생명주기를 소유객체에 의존한다. 그래서 영속성 전이(cascade)처럼 따로 persis를 하지 않는다.
또한 여기서 값 타입 컬렉션 실행 쿼리를 보면 기존 데이터만 삭제하고 신규 데이터만 추가하는 것이 아닌 값타입 컬렉션 데이터 전체가 갈아끼워진다.
분명 코드 상으로는 컬렉션 요소 하나만 지우고 넣으면 되는 것처럼 되어 있는데 정작 쿼리는 해당 Member_ID 식별자에 해당하는 Address를 다 지우고 Insert가 두번 실행되고 있다... 우선 테이블을 확인해보면 결론적으로 원하는데로 값이 들어갔지만 insert 쿼리가 예상과 다르게 두번 실행되는게 너무 찝찝하다.
※ 참고
값 타입 컬렉션은 영속성 전이(Cascade) + 고아 객체 제거 기능을 필수로 가진다고 볼 수 있다.
값 타입 컬렉션의 제약사항
- 값 타입은 엔티티와 다르게 식별자 개념이 없다.
- 그래서 값을 변경하면 추적이 어렵다.
- (중요!) 값 타입 컬렉션에서 변경 사항이 발생하면, 주인 엔티티와 연관된 모든 데이터를 삭제하고 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장한다.
- 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본키를 구성해야한다.
- (null 입력x, 중복 저장x)
이러한 이유로 실무에선 사용 안하는걸 추천한다.
- 실무에서는 상황에 따라 값 타입 컬렉션 대신 일대다 관계를 고려한다.
- 일대다 관계를 위한 엔티티를 만들고, 여기에서 값 타입을 사용
- 영속성 전이(CASCADE) + 고아 객체 제거를 사용해서 값 타입 컬렉션처럼 사용
@Entity
@Table(name = "ADDRESS")
public class AddressEntity {
@Id @GeneratedValue
private Long id;
private Address address;
}
=== Member ===
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "MEMBER_ID")
private List<AddressEntity> addressHistory = new ArrayList<>();
=== 사용 예시 ===
member.getAddressHistory().add(new AddressEntity("old1", "street","10000"));
값 타입 컬렉션 사용 시기
- 정말 단순한 경우
Ex: 좋아하는 음식메뉴 다중 선택과 같이 심플한 자료들.
정리
1. 엔티티 타입의 특징
- 식별자가 있다
- 생명 주기 관리(값 타입은 생명주기 관리를 주도적으로 할 수 없다.)
- 공유
2. 값 타입의특징
- 식별자가 없다
- 생명 주기를 엔티티에 의존
- 공유하지 않는 것이 안전(복사해서 사용)
- 불가피하게 공유가 필요할 때 불변 객체로 만드는 것이 안전
※ 중요
값 타입은 정말 값 타입이라 판단될 때만 사용
엔티티와 값 타입을 혼동해서 엔티티를 값 타입으로 만들면 안됨
식별자가 필요하고, 지속해서 값을 추적, 변경해야 한다면 그것은 값 타입이 아닌 엔티티
실전 예제 6 - 값 타입 매핑
참고