9. 값 타입 (값 타입 컬렉션)

HotFried·2023년 10월 2일
0

값 타입 컬렉션

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

  • DB는 컬렉션을 같은 테이블에 저장할 수 있는 방법이 없다.
    -> 컬렉션을 저장하기 위한 별도의 테이블을 생성해야한다.
    @ElementCollection, @CollectionTable 어노테이션을 이용한다.

@Entity
public class Member {

    @Id
    @GeneratedValue
    private Long id;

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

    @Embedded
    private Address homeAddress;

    
    @ElementCollection
    @CollectionTable(
            // 테이블 이름을 정의 
            name = "FAVORITE_FOOD",
            // 외래키를 명시
            joinColumns = @JoinColumn(name = "MEMBER_ID"))
    // addressHistory는 Address 타입 내부에 city, address 등 다양한 필드가 있지만
    // favoriteFoods는 String 하나이고 내가 정의한 타입이 아니기 때문에
    //예외적으로 칼럼 이름을 지정해줄 수 있다.
    @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<>();
}

값 타입 저장

public class JpaMain {

    public static void main(String[] args) {
        Member member = new Member();
        member.setUsername("member1");
        member.setHomeAddress(new Address("home city", "street", "12345"));

        // 값 타입 컬렉션
        member.getFavoriteFoods().add("치킨");
        member.getFavoriteFoods().add("족발");
        member.getFavoriteFoods().add("피자");

        member.getAddressHistory().add(new Address("old1", "street", "12345"));
        member.getAddressHistory().add(new Address("old2", "street", "12345"));

        // member만 영속
        em.persist(member);

        tx.commit();
    }
}

Member 테이블 생성 후 FavoriteFoods 데이터 3개, AddressHistory 데이터가 2개 insert 된다.


값 타입은 member에 의존하기 때문에 member가 변경되면 같이 변경된다.
-> member와 라이프사이클이 같다.
값 타입 컬렉션은 영속성 전이(Cascade) + 고아 객체 제거 기능을 필수로 가진다고 볼 수 있다.


값 타입 조회

public class JpaMain {

    public static void main(String[] args) {
        Member member = new Member();
        member.setUsername("member1");
        member.setHomeAddress(new Address("home city", "street", "12345"));

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

        member.getAddressHistory().add(new Address("old1", "street", "12345"));
        member.getAddressHistory().add(new Address("old2", "street", "12345"));

        em.persist(member);

        // DB에는 데이터가 insert되고 영속성 컨텍스트를 초기화
        em.flush();
        em.clear();

        // member를 다시 조회
        Member findMember = em.find(Member.class, member.getId());

        tx.commit();
    }
}

member를 조회하면 값 타입 컬렉션인 favoriteFoods와 addressHistory는 조회되지 않는다.
값 타입 컬렉션은 지연 로딩이 적용된다.

List<Address> addressHistory = findMember.getAddressHistory();
    for (Address address : addressHistory) {
        System.out.println("address = " + address.getCity());
    }

이 후 값 타입 컬렉션을 조회할 때, select Query가 나간다.


왜 지연 로딩이 적용될까?
@Target({ElementType.METHOD, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ElementCollection{
Class targetClass () default void.class;

FetcyType fetch() default FetchType.LAZY;
}

@ElementCollectionLAZY(지연로딩)으로 선언되어있는 것을 확인할 수 있다.


값 타입 수정

public class JpaMain {

    public static void main(String[] args) {
        Member member = new Member();
        member.setUsername("member1");
        member.setHomeAddress(new Address("home city", "street", "12345"));

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

        member.getAddressHistory().add(new Address("old1", "street", "12345"));
        member.getAddressHistory().add(new Address("old2", "street", "12345"));

        em.persist(member);

        em.flush();
        em.clear();

        Member findMember = em.find(Member.class, member.getId());

        // 값 타입인 임베디드 타입은 불변객체이므로 set 메소드가 존재하지 않는다.
        // findMember.getHomeAddress().setCity("new city"); X

        // address 인스턴스 자체를 갈아끼워야 한다.
        Address a = findMember.getHomeAddress();
        findMember.setHomeAddress(new Address("new city", a.getStreet(), a.getZipcode()));

        // 컬렉션 값 타입도 불변객체이다.
        // 기존 값을 지우고 다시 넣는다.
        findMember.getFavoriteFoods().remove("치킨");
        findMember.getFavoriteFoods().add("한식");

        // 임베디드 타입과 컬렉션 값 타입을 영속화 하는 코드가 없지만 쿼리가 나간다.
        // 영속성 전이와 고아 객체 제거 기능을 필수로 가지기 때문이다.

        tx.commit();
    }
}

컬렉션의 값만 변경해도 어떤 값이 변경되었는지 알고 JPA가 DB에 쿼리를 날려준다.
-> 마치 영속성 전이가 된 것처럼 동작한다.
값 타입 컬렉션은 member 엔티티로 라이프 사이클이 관리되기 때문이다.


값 타입 컬렉션의, 원소 하나의 필드 하나가 아니라 원소 하나를 통째로 수정하고 싶다면?

Member findMember = em.find(Member.class, member.getId());

// address를 하나만 바꾸고 싶다면 지우고 싶은 값을 넣고 remove 한다.
// 컬렉션은 대부분 equals()를 사용해 찾고 싶은 값을 그대로 찾아준다.
// 따라서 equals()를 재정의 하지 않았다면 그냥 망하는 것이다. equals()를 꼭 재정의 해주자.
findMember.getAddressHistory().remove(new Address("old1", "street", "12345"));
// 지운 값 대신 새로운 값을 넣어준다.
findMember.getAddressHistory().add(new Address("new city", "street", "12345"));

tx.commit();

  • 쿼리를 확인해보니 insert문이 2개가 나간다.
  • 테이블에 있는 데이터를 완전히 갈아끼우는 것이기 때문이다.
    1. delete로 member_id에 해당하는 address 테이블 값을 통째로 삭제한다.
    2. 기존 값과, 수정된 값을 insert된다.

제약사항

  • 값 타입은 엔티티와 다르게 식별자 개념이 없다.
    -> 값은 변경하면 추적이 어렵다.

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

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


대안

  • 실무에서는 상황에 따라 값 타입 컬렉션 대신에 일대다 관계를 고려
  • 일대다 관계를 위한 엔티티를 만들고, 여기에서 값 타입을 사용
  • 영속성 전이(Cascade) + 고아 객체 제거를 이용해 값 타입 컬렉션 처럼 사용

Adress Entity : 엔티티를 만들고 Address값 타입을 필드로 이용한다.

@Entity
@Table(name = "ADDRESS")
public class AddressEntity {
    @Id
    @GeneratedValue
    private Long id;

    private Address address;
}

Member

@Entity
public class Member {
    ...

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

}

@OneToMany 어노테이션 +
영속성 전이 : Cascade = ALL, 고아 객체 제거 : orphanRemoval = true를 이용해 값 타입 컬렉션처럼 사용한다.

  • update Query가 나가는 것은 어쩔 수 없다.
    -> 일대다 단방향 매핑 : DB에서 외래키는 다른 테이블에서 관리가 되고 있기 때문에, 추가적으로 별도의 쿼리가 나간다.


    참고 : 일대다 매핑 글

활용

  • 업데이트, 추적이 필요 없는 단순한 상황일 때 사용
    ex) 셀렉트 박스에서 치킨, 피자, 족발 중 선택

  • 그런 경우가 아니라면 웬만하면 Entity로 사용한다.
    꼭 값을 변경할 일이 없더라도 쿼리 자체를 그 테이블에서 할 때가 많다면 Entity로 하는 게 좋다.
    ex) 주소는 입력만 하지만 조회할 일이 많으므로 Entity로 만든다.


정리

엔티티 타입의 특징

  • 식별자 O
  • 생명 주기 관리
  • 공유 가능

값 타입의 특징

  • 식별자 X
  • 생명 주기를 엔티티에 의존
  • 공유하지 않는 것이 안전하다 (복사해서 사용)
  • 불변 객체로 만드는 것이 안전하다

값 타입은 정말 값 타입이라 판단될 때만 사용하자 Entity와 값 타입을 혼동해서 Entity를 값 타입으로 만들면 안된다.

식별자가 필요하고, 지속해서 값을 추적, 변경해야 한다면 그것은 값 타입이 아니라 Entity이다.


참고 :

김영한. 『자바 ORM 표준 JPA 프로그래밍』. 에이콘, 2015.

자바 ORM 표준 JPA 프로그래밍 - 기본편

profile
꾸준하게

0개의 댓글