본문 바로가기

Spring/JPA

JPA- 값 타입 컬렉션

대략적인 개요

위의 Member 테이블을 구현하면 다음과 같다.

Member.class

@Entity
@Getter @Setter
public class Member /*extends BaseEntity*/ {

    @Id  //id 직접 할당 (member.setId()로 id 넣어줘야함)
    @GeneratedValue // id를 자동 생성해서 넣어줌(현재는 디폴트값: strategy = GenerationType.AUTO)
    @Column(name = "MEMBER_ID")
    private Long id;

    @Column(name = "USERNAME", nullable = false)  // 매핑할 컬럼명이 "USERNAME". + not null 설정
    private String userName;
	
    public Member(){}
    public Member(String userName) {
        this.userName = userName;
    }

    @ElementCollection // 값 타입 컬렉션을 사용하기 위한 어노테이션
    // DB는 컬렉션을 같은 테이블(MEMBER)에 저장할 수 없다. => @CollectionTable: 별도의 테이블 생성
    @CollectionTable(name = "FAVORITE_FOOD", // FAVORITE_FOOD 테이블을 따로 생성함
            joinColumns = @JoinColumn(name = "MEMBER_ID")
    )
    private Set<String> favoriteFoods = new HashSet<>();

    @ElementCollection // 값 타입 컬렉션을 사용하기 위한 어노테이션
    @CollectionTable(name = "ADDRESS",  //ADDRESS 테이블
            joinColumns = @JoinColumn(name = "MEMBER_ID")
    ) //DB는 컬렉션을 같은 테이블에 저장할 수 없다. => 별도의 테이블이 필요(ADDRESS 테이블)
    private List<Address> addressHistory = new ArrayList<>();
}

MEMBER 엔티티에는 Set<String>, List<address> 타입인 두개의 컬렉션이 존재한다. 근데 테이블에는 컬렉션이 존재할 수 없다. 따라서 지금 Member 객체 내부에는 컬렉션이 존재하나, 이를 MEMBER 테이블로 구현하게 되면 해당 테이블에는 컬렉션 필드가 존재하지 않게 된다. 

 

JpaMain.class

package hellojpa;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
import java.util.List;

public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();
        tx.begin();

        try {
            Member member = new Member("member1");
            Member member2 = new Member("member2");
            member.getFavoriteFoods().add("chicken");
            member.getFavoriteFoods().add("pizza");
            member.getFavoriteFoods().add("bulgogi");

            member.getAddressHistory().add(new Address("City1", "Street1", "ZipCode1"));
            member.getAddressHistory().add(new Address("City2", "Street2", "ZipCode2"));
            member.getAddressHistory().add(new Address("City3", "Street3", "ZipCode3"));

            member2.getFavoriteFoods().add("riceCakeSsamanko");
            member2.getAddressHistory().add(new Address("City4", "Street4", "ZipCode4"));
            em.persist(member);
            em.persist(member2);

            tx.commit();
        } catch (Exception e) {
            tx.rollback(); // 오류 발생 시 롤백
            e.printStackTrace(); // 에러 내용 출력
        } finally {
            //종료
            em.close();
        }
        emf.close();
    }
}

실행 후 MEMBER 테이블의 모습은 다음과 같다.

JpaMain 실행 결과. 뭔가 휑 하다...

지금 보면 main 함수에서 뭔가 겁나게 많이 넣긴한다. 보면 Set<Stinrg> favoritFood에 "pizza", "chicken"... 여러개의 String을 넣었고, List<Address> addressHistory에도 여러개의 address 객체를 넣었다. 근데 보면 위에서도 말했다시피 컬렉션의 경우 해당 테이블에 생성이 되지 않기 때문에 정작 MEMBER 테이블을 까보면 암것도 안 나온다. 그래서 생성된 테이블들을 살펴보자.

ADDRESS, FAVORITE_FOOD 테이블이 생성된 모습

 모자이크는 걍 무시하자. 지금 보면 우리가 생성한 값타입 컬렉션이 MEMBER 테이블에 들어있는 것이 아닌 별도의 테이블로써 생성된 것을 확인 할 수 있다.

별도의 테이블에 우리가 넣어준 내용이 들어가 있다.

 지금 보면 우리가 넣어준 데이터(chicken, pizza, address 객체 등등...)이 각각 새롭게 만들어진 테이블에 들어가 있음을 확인 가능하다. 자 그럼 지금까지 대략적인 설명은 했으니 이제 코드를 통해 구체적으로 알아보자.

 

값 타입 컬렉션을 위한 어노테이션

 - @ElementCollection

@ElementCollection // 값 타입 컬렉션을 사용하기 위한 어노테이션
// DB는 컬렉션을 같은 테이블(MEMBER)에 저장할 수 없다. => @CollectionTable: 별도의 테이블 생성
@CollectionTable(name = "FAVORITE_FOOD", // FAVORITE_FOOD 테이블을 따로 생성함
        joinColumns = @JoinColumn(name = "MEMBER_ID")
)
private Set<String> favoriteFoods = new HashSet<>();

 값 타입 컬렉션을 이용하기 위해서는 @ElementCollection 어노테이션을 이용하여 먼저 값타입 컬렉션임을 선언 해준다.

 

- @CollectionTable

 @CollectionTable로는 구체적인 테이블 정보를 명시해주는데, name으로는 생성할 테이블 명을, joinColumns로는 매핑할 column을 명시해준다. 지금은 Member의 임베디드 타입이므로 MEMBER_ID에 매핑을 해준 상태이다.

 

 

  +α  칼럼 명 변경하기)

 추가적으로 Set<String> favoriteFoods에는 내부 요소로 String 하나만 들어있는 것을 볼 수 있다.(column이 하나만 존재)

반면 List<address> addressHistroy의 경우 address 객체가 들어있다 보니 테이블에 여러가지 요소가 들어간다. (column이 여러개 존재)

 FAVORITE_FOOD처럼 테이블처럼 column이 하나만 존재하는 경우, @Column 어노테이션으로 예외적으로 column명을 바꿔주는 것도 가능하다. 

@ElementCollection 
@CollectionTable(name = "FAVORITE_FOOD", 
        joinColumns = @JoinColumn(name = "MEMBER_ID")
)
@Column(name = "FOOD_NAME")  // 칼럼 명 변경
private Set<String> favoriteFoods = new HashSet<>();

칼럼명이 FAVORITEFOODS에서 FOOD_NAME으로 변경된 모습

값 타입 컬렉션 수정

Member.class

@Entity
@Getter @Setter
public class Member /*extends BaseEntity*/ {

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

    @Column(name = "USERNAME", nullable = false)
    private String userName;

    public Member() {}
    public Member(String userName) {this.userName = userName;}

    @Embedded // @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> addressHistory = new ArrayList<>();
		....
}

JpaMain.class

public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();
        tx.begin();

        try {
            Member member = new Member("member1");
            member.getFavoriteFoods().add("chicken");
            member.getFavoriteFoods().add("pizza");
            member.getFavoriteFoods().add("bulgogi");

            member.getAddressHistory().add(new Address("City1", "Street1", "ZipCode1"));
            member.getAddressHistory().add(new Address("City2", "Street2", "ZipCode2"));
            member.getAddressHistory().add(new Address("City3", "Street3", "ZipCode3"));

            member.setHomeAddress(new Address("City1", "Street1", "ZipCode1"));
            em.persist(member);
			// 쿼리 날리고, 영속성 컨텍스트 비움
            em.flush();
            em.clear();
			
            // 엔티티 조회
            Member findMember = em.find(Member.class, member.getId());
            Address a = findMember.getHomeAddress();

            System.out.println("=============수정시작===============");
            // 값 타입 수정: homeCity -> newCity
            findMember.setHomeAddress(new Address("newCity", a.getStreet(), a.getZipCode()));

            // 값 타입 컬렉션 수정: chicken -> 밥
            findMember.getFavoriteFoods().remove("chicken");
            findMember.getFavoriteFoods().add("밥");

            // 값 타입 컬렉션 수정: Address("City1", "Street1", "ZipCode1") -> Address("City4", "Street4", "ZipCode4")
            findMember.getAddressHistory().remove(new Address("City1", "Street1", "ZipCode1"));
            findMember.getAddressHistory().add(new Address("City4", "Street4", "ZipCode4"));

            tx.commit();
        } catch (Exception e) {
            tx.rollback(); // 오류 발생 시 롤백
            e.printStackTrace(); // 에러 내용 출력
        } finally {
            //종료
            em.close();
        }
        emf.close();
    }
}

 전에도 말했듯이 값 타입은 사이드 이펙트를 방지하기 위해 불변 객체로 만들기 때문에 값 타입의

필드(예를 들어, findMember.homeAddress.city)를 수정을 함에 있어 setter를 이용할 수 없다.

그러므로 값 타입을 수정하기 위해선 아예 새로운 객체를 만들어서 넣어준다.

 따라서 지금보면 그냥 값 타입인 homeAddress, 값 타입 컬렉션 addressHistory는 새로운 Address 객체를 새롭게 생성해서 넣어주었고 (addressHistory는 컬렉션이라 기존의 객체를 삭제함), 또다른 값 타입 컬렉션 getFavoriteFoods는 기존 컬렉션의 요소 "chicken"을 삭제하고 "밥"이라는 새로운 String 객체를 넣어주었다.

 

 자 그렇다면 코드가 너무 기니까 일부분만 다시 보자.

public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();
        tx.begin();

        try {
            Member member = new Member("member1");
			
            Address addr1 = new Address("City1", "Street1", "ZipCode1");
            Address addr2 = new Address("City2", "Street2", "ZipCode2");
            Address addr3 = new Address("City3", "Street3", "ZipCode3");
            
            member.getAddressHistory().add(addr1);
            member.getAddressHistory().add(addr2);
            member.getAddressHistory().add(addr3);

            member.setHomeAddress(new Address("City1", "Street1", "ZipCode1"));
            em.persist(member);

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

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

            // Address("City1", "Street1", "ZipCode1") -> Address("City4", "Street4", "ZipCode4")
            findMember.getAddressHistory().remove(addr1);
            findMember.getAddressHistory().add(new Address("City4", "Street4", "ZipCode4"));

            tx.commit();
        } catch (Exception e) {
            tx.rollback(); // 오류 발생 시 롤백
            e.printStackTrace(); // 에러 내용 출력
        } finally {
            //종료
            em.close();
        }
        emf.close();
    }
}

 위의 코드는...

  1. addressHistory에 address 객체 3개 넣어주고 persist
  2. flush, clear로 쿼리 날리고 DB 저장
  3. find로 조회하여 findMember로 가져옴
  4. remove하여 객체 제거 후 새로운 객체 add 함.

그럼 여기서 4번의 쿼리만 봐보자.

Hibernate: 
    /* delete collection hellojpa.Member.addressHistory */ delete 
        from
            ADDRESS 
        where
            MEMBER_ID=?
Hibernate: 
    /* insert collection
        row hellojpa.Member.addressHistory */ insert 
        into
            ADDRESS
            (MEMBER_ID, city, street, ZIPCODE) 
        values
            (?, ?, ?, ?)
Hibernate: 
    /* insert collection
        row hellojpa.Member.addressHistory */ insert 
        into
            ADDRESS
            (MEMBER_ID, city, street, ZIPCODE) 
        values
            (?, ?, ?, ?)
Hibernate: 
    /* insert collection
        row hellojpa.Member.addressHistory */ insert 
        into
            ADDRESS
            (MEMBER_ID, city, street, ZIPCODE) 
        values
            (?, ?, ?, ?)

  뭔가 이상하다. 분명 한번 제거 후 객체 하나만 넣었는데 insert 쿼리가 3번 나갔다. 이는 값 타입 컬렉션이 다음과 같은 제약사항을 따르기 떄문이다.

 

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

 그렇다. addressHistory에 값이 삭제(addr1 제거), 추가(new Address() 인스턴스 add())되는 과정에서 변경사항이 발생하여 모든 데이터가 삭제된 후, 제거된 addr1을 제외한 addr2, addr3, Address("city4","street4","zipcode4")가 다시 저장되어 쿼리가 세번 나간 것이다.

 

 자 그렇다면 이런 생각이 들 것이다.

"만일 컬렉션의 데이터가 10000개라면, 하나 고칠려고 했더니 10000번의 쿼리가 나간다. 이건 배보다 배꼽이 더 큰데?"

 그래서 지금껏 열심히 공부한 방법대로는 안쓴다.

 

값 타입 컬렉션의 대안

잘못하면 엄청난 양의 쿼리 폭탄을 맞을 수 있는 문제를 해결하기 위해서, 실무에서는 상황에 따라 값 타입 컬렉션 대신에 일대다 관계를 고려한다.

AddressEntity.class

@Entity
@Getter @Setter
@Table(name = "ADDRESS")
public class AddressEntity {

    @Id @GeneratedValue
    private Long ID;

    private Address address;
}

Member.class

@Entity
@Getter @Setter
public class Member /*extends BaseEntity*/ {

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

    @Column(name = "USERNAME", nullable = false)
    private String userName;

    public Member() {}
    public Member(String userName) {
        this.userName = 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<>();

    //값 타입 컬렉션대신 일대다 관계 엔티티를 사용해 매핑
    //CascadeType.ALL, orphanRemoval = true로 설정해 값 타입 컬렉션처럼 사용
    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "MEMBER_ID")
    private List<AddressEntity> addressesHistory = new ArrayList<>();

		....
}

 값 타입 컬렉션을 사용하는 대신 위처럼 아예 컬렉션으로 만들고자 하는 엔티티를 일대다 매핑으로 생성하여 사용하면, 이는 엔티티지 더이상 값 타입 컬렉션이 아니므로 변경시 마다 모든 데이터가 새로 저장되는 문제가 발생하지 않는다.

Hibernate: 
    select
        addresshis0_.MEMBER_ID as member_i5_0_0_,
        addresshis0_.ID as id1_0_0_,
        addresshis0_.ID as id1_0_1_,
        addresshis0_.city as city2_0_1_,
        addresshis0_.street as street3_0_1_,
        addresshis0_.ZIPCODE as zipcode4_0_1_ 
    from
        ADDRESS addresshis0_ 
    where
        addresshis0_.MEMBER_ID=?
Hibernate: 
    call next value for hibernate_sequence
Hibernate: 
    /* insert hellojpa.AddressEntity
        */ insert 
        into
            ADDRESS
            (city, street, ZIPCODE, ID) 
        values
            (?, ?, ?, ?)
Hibernate: 
    /* create one-to-many row hellojpa.Member.addressHistory */ update
        ADDRESS 
    set
        MEMBER_ID=? 
    where
        ID=?

보면 insert 쿼리가 계속 나가는 대신 update 쿼리 한번으로 퉁친다. 훨씬 효율적이다.

'Spring > JPA' 카테고리의 다른 글

JPA- 프로젝션  (0) 2023.05.12
JPA- 기본 문법과 쿼리 API  (0) 2023.05.07
JPA- 값 타입의 비교  (0) 2023.04.30
JPA- 값 타입과 불변 객체  (0) 2023.03.23
JPA- 임베디드 타입  (0) 2023.03.22