BackEnd/JPA

[JPA] 값 타입 - 2

샤아이인 2022. 4. 7.

내가 공부한것을 올리며, 중요한 단원은 저 자신도 곱씹어 볼겸 상세히 기록하고 얕은부분들은 가겹게 포스팅 하겠습니다.

 

4. 값 타입의 비교

인스턴스가 달라도 그 안에 값이 같으면 같은것으로 봐야한다.

// primitive type 비교
int a = 10; 
int b = 10;
System.out.println(a == b); // true

// 임베디드 타입 비교
Address a = new Address("서울", "test", 7777);
Address b = new Address("서울", "test", 7777);
System.out.println(a == b); // false
 

임베디드 타입을 '==' 연산자로 비교할 경우 당연히 false나 나온다.

왜냐하면 임베디드 타입은 참조객체이기 때문이다. a와 b는 서로다른 참조값을 갖고있는 서로다른 객체이기 때문이다.

 

그럼 비교연산을 어떻게 해야할까?

 

- 동일성(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("서울", "test", 7777);
Address b = new Address("서울", "test", 7777);
System.out.println(a.equals(b)); // true
 

5. 값 타입 컬렉션

값 타입을 하나 이상 저장할때 사용한다.

출처 - 인프런 김영한 JPA

컬렉션을 DB에 집어넣어야 하다보니 문제가 생긴다. 관계형 데이터베이스는 내부적으로 컬렉션을 담을수 있는 구조가 없다.

=> 별도의 테이블로 따로 만들어야 한다. 위 그레프에서는 FAVORITE_FOOD, ADDRESS 와 같은 별도의 테이블이 만들어졌다.

 

값타입은 또한 테이블의 컬럼들을 함께 묶어서 PK로 사용한다.

따로 ID 값을 부여하면 이건 Entity가 되어버린다.

 

@ElementCollection, @CollectionTable 사용

- 데이터베이스는 컬렉션을 같은 테이블에 저장할 수 없다. 따라서 컬렉션을 저장하기 위한 별도의 테이블이 필요하다.

 

예를 들어 위에서 본 다이어그렘의 MEMBER는 다음과 같다.

@Entity
public class Member{

    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    @Column(name = "USERNAME")
    private String username;

    // 주소
    @Embedded
    private Address homeAddress;

    @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> addresssHistory = new ArrayList<>();
}
 

실행 결과는 다음과 같다.

MEMBER 테이블에 더불어 FAVORITE_FOOD, ADDRESS 테이블도 만들어 진다.

 

● 값 타입 컬렉션 사용 예제

우선 코드를 살펴보자.

 

- 값 타입 저장 예제

Member member = new Member();
member.setUsername("member1");
member.setHomeAddress(new Address("Seoul", "street", "123"));

member.getFavoriteFoods().add("치킨");
member.getFavoriteFoods().add("족발");
member.getFavoriteFoods().add("피자");

member.getAddresssHistory().add(new Address("old1", "oldStreet", "1"));
member.getAddresssHistory().add(new Address("old2", "oldStreet", "2"));

entityManager.persist(member);
 

실행 결과는 다음과 같다.

왼쪽을 보면 Member 테이블이 생성되고, ADDRESS 테이블에 insert쿼리가 2번 나가는것을 확인할수가 있다.

오른쪽을 보면 FAVORITE_FOOD에 3번 insert 쿼리가 나가는것을 확인할수가 있다.

 

테이블을 확인해보면 잘 들어가있는것을 확인할수가 있다.

코드를 보면 persist(member)만 적어줘도 DB에 잘 반영된것을 확인할수가 있었다.

이는 FAVORITE_FOOD, ADDRESS 모두 Member의 값타입 이기 때문이다.

값 타입은 엔티티와 생명주기를 함께 한다!!

 

- 값 타입 조회 예제

이번에는 DB와 동기화를 시킨 이후에 member를 찾아보자! 위 코드에 추가된 부분은 다음과 같다.

entityManager.flush();
entityManager.clear();

System.out.println("============");
Member findMember = entityManager.find(Member.class, member.getId());
 

실행 결과는 다음과 같다.

값 컬렉션들은 전부 지연로딩임을 알수있다. 쿼리가 나오지 않았다! 나머지 필드에 해당되는 부분들만 찾아온것을 확인할수가 있다.

이후 값 컬렉션에 저장된 값들이 필요할때 터치하면 그때 찾아오는 쿼리가 나가는것을 확인할수가 있다.

 

- 값 타입 수정 예제

우선 값 타임은 불변해야 한다. 따라서 값을 수정하려면 아예 새로운 객체를 전달해야 한다.

entityManager.flush();
entityManager.clear();

System.out.println("============");
Member findMember = entityManager.find(Member.class, member.getId());

Address a = findMember.getHomeAddress();
findMember.setHomeAddress(new Address("newCity", a.getStreet(), a.getZipcode()));

/* 값 타입 컬렉션 수정 예제 - 치킨을 한식으로 변경 */
findMember.getFavoriteFoods().remove("치킨");
findMember.getFavoriteFoods().add("한식");
 

실행 결과는 다음과 같다.

HomeAddress 객체또한 새로운 객체로 변경(update)되었고,

favoriteFood 테이블 에서는 기존의 "치킨"을 delete 한후 새로운 "한식"을 insert 하게 되었다.

 

좀더 심화된 내용인 주소변경 내용을 알아보자.

findMember.getAddresssHistory().remove(new Address("old1", "oldStreet", "1"));
findMember.getAddresssHistory().add(new Address("newCity1", "newStreet", "2"));
 

기존의 old1을 지우고, 새로운 객체로 newCity1을 넘겨준다.

 

실행해보면 쿼리는 다음과 같다.

delete 쿼리 1번에, insert 쿼리가 2번 나간것을 확인할수가 있다.

이는 테이블을 아예 싹다 지워버린 후에 다시 newCity1, old2 객체를 삽입하게 된것이다.

 

여기까지 글을 잘 읽었다면 한가지 의문이 들어야 정상이다.

 

위에서 "치킨"을 "한식"으로 변경할때는 해당 값타입만 지우고, 새롭게 값타입을 insert 했는데...

어째서 Address 라는 임베디드 타입은 테이블을 싹다 지우고 나서 처음부터 새롭게 전부를 insert를 할까?

=> 이는 사용한 Collection의 차이 때문이다. 다음은 영한님의 답변이다.

 

값 타입 컬렉션을 변경했을 때 JPA 구현체들은 테이블의 기본 키를 식별해서 변경된 내용만 반영하려고 노력합니다.

하지만 사용하는 컬렉션이나 여러 조건에 따라 기본 키를 식별할 수도 있고, 식별하지 못할 수 도 있습니다.

따라서 값 타입 컬렉션을 사용할 때는 모두 삭제하고 다시 저장하는 최악의 시나리오를 고려하면서 사용해야 합니다.

 

값 타입 컬렉션의 최적화는 구현체마다 다릅니다.

하이버네이트의 경우 값 타입 컬렉션에 Set을 사용하면 최적화가 됩니다.

Set이라는 자료구조 자체가 유일성을 보장하기 때문에 @ElementCollection으로 생성되는 모든 필드를 PK로 잡으면 PK에 딱 맞게 최적화가 가능합니다.

 

반면에 List 자료구조의 경우 최적화가 되지 않는데, List는 내부에 순서(index)가 있기 때문입니다.

따라서 모두 지우고 다시 시작합니다. List의 경우 @OrderColumn이라는 애노테이션을 사용해서, 순서 컬럼을 추가한 다음에, 순서 컬럼을 PK에 추가하면 최적화가 가능해집니다.

이 @OrderColumn을 추가하면 PK를 잡을 때 기본으로 id + order 컬럼을 PK로 잡습니다.

이렇게 하면 결과적으로 PK를 명확하게 구분할 수 있기 때문에 최적화가 가능합니다.

 

그런데 값 타입 컬렉션은 매우 간단한 곳에서만 사용할 수 있고실무에서는 컬렉션의 경우 주로 엔티티를 도입해서 사용하고, @OrderColumn도 거의 사용하지 않습니다.

 

따라서 이렇게 복잡하게 이해하면서 사용하는 것 보다는 값 타입 컬렉션은 단순하게 모두 삭제되고 다시 입력되는 시나리오로 생각하셔도 됩니다.

 

여하튼 문제점이 많은 방식이다. 권장하지 않는다!!

 

● 값 타입 컬렉션의 제약사항

- 값 타입 컬렉션에 변경 사항이 발생하면, 주인 엔티티와 연관된 모든 데이터를 삭제하고, 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장한다. => 권장하지 않는다.

 

- 값 타입은 엔티티와 다르게 식별자 개념이 없다.

- 값은 변경하면 추적이 어렵다.

- 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본 키를 구성해야 함: null 입력X, 중복 저장X

 

● 값 타입 컬렉션 대안

실무에서는 상황에 따라 값 타입 컬렉션 대신에 일대다 관계를 고려하자.

 

예를 들어 기존에 값 컬렉션을 사용하던 addresssHistory을 Entity 만들어 보자.

@Entity
public class AddressEntity {

    @Id @GeneratedValue
    private Long id;

    private Address address;
}
 

이후 이를 사용하도록 Member를 다음과 같이 수정한다.

@Entity
public class Member{

    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    @Column(name = "USERNAME")
    private String username;

    // 주소
    @Embedded
    private Address homeAddress;

    @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> addresssHistory = new ArrayList<>();

    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "MEMBER_ID")
    private List<AddressEntity> addresssHistory = new ArrayList<>();
}
 

변경된 main코드는 다음과 같다.

Member member = new Member();
member.setUsername("member1");
member.setHomeAddress(new Address("Seoul", "street", "123"));

member.getFavoriteFoods().add("치킨");
member.getFavoriteFoods().add("족발");
member.getFavoriteFoods().add("피자");

member.getAddresssHistory().add(new AddressEntity("old1", "oldStreet", "1"));
member.getAddresssHistory().add(new AddressEntity("old2", "oldStreet", "2"));

entityManager.persist(member);
 

AddressEntity를 추가해서 넘기고 있음을 확인할수가 있다.

결과로 2번의 update 쿼리가 나가는것을 확인할수가 있다.

이는 1 : N 의 관계에서 N 쪽에 FK가 있기 때문이다. @OneToMany는 자신이 관리하는 쪽에 FK가 없기 때문에 update 쿼리가 나가게 된다.

 

이런식으로 값타입을 Entity로 승급하여 사용해도 된다.

 

● 정리

1. 엔티티 타입의 특징

- 식별자가 있다

- 생명 주기 관리(값 타입은 생명주기 관리를 주도적으로 할 수 없다.)

- 공유 가능

 

2. 값 타입의특징

- 식별자가 없다

- 생명 주기를 엔티티에 의존한다.

- 공유하지 않는 것이 안전(복사해서 사용)

- 불변 객체로 만드는 것이 안전

댓글