Hibernate/JPA(EntityManager)

[JPA] 값 타입 컬렉션 (@ElementCollection / @CollectionTable)

유혁스쿨 2023. 6. 29. 23:00
728x90
반응형

컬렉션 값 타입이란

값 타입을 컬렉션에 담아 사용하는것을 말한다.

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

예를들어,   내가 좋아하는 책 , 혹은 주소 이력을 보관한다고 하면 별도의 테이블을 생성해서 데이터를 관리해야한다.

개념적으로 보면 1:M 관계가 형성된다.

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

@ElementCollection 과 @CollectionTable 어노테이션을 사용한다.

 

 

    @ElementCollection
    @CollectionTable(name = "FAVORITE_BOOK",  //매핑 정보(컬렉션의 테이블명)
                    joinColumns = @JoinColumn(name = "USER_ID")) //USER_ID를 FK로 잡게된다.
    @Column(name = "BOOK_NAME")
    private Set<String> favoriteBooks = new HashSet<>();
    
	@ElementCollection
    @CollectionTable(name = "ADDRESS",
                    joinColumns = @JoinColumn(name = "USER_ID"))
    private List<Address> addressHistory = new ArrayList<>();

 

@Entity(name = "USERS")
@Setter
@Getter
@ToString
public class User {
    @Id @GeneratedValue
    @Column(name = "USER_ID")
    private Long id;
    private String username;
    
    @ElementCollection
    @CollectionTable(name = "FAVORITE_BOOK",  //매핑 정보(컬렉션의 테이블명)
                    joinColumns = @JoinColumn(name = "USER_ID")) //USER_ID를 FK로 잡게된다.
    @Column(name = "BOOK_NAME")
    private Set<String> favoriteBooks = new HashSet<>();
    
    @ElementCollection
    @CollectionTable(name = "ADDRESS",
                    joinColumns = @JoinColumn(name = "USER_ID"))
    private List<Address> addressHistory = new ArrayList<>();
    
}

 

[실행 후 콘솔 결과] CREATE (ADDRESS / FAVORITE_BOOK)

create table ADDRESS (  -- @CollectionTable에 name속성으로 지정한 테이블명이 설정된다.
      USER_ID bigint not null,  -- @JoinColumn에 지정한 값이 추가되고 추후 FK로 설정된다.
        CITY varchar(255),
        STREETvarchar(255),
        ZIPCODE varchar(255)
)
create table FAVORITE_BOOK (  -- @CollectionTable에 name속성으로 지정한 테이블명이 설정된다.
    USER_ID bigint not null,  -- @JoinColumn에 지정한 값이 추가되고 추후 FK로 설정된다.
       BOOK_NAME varchar(255)
)


값 타입 저장 테스트

@SpringBootTest
@Transactional
public class JpaTest {
    @PersistenceContext
    EntityManager em;
    
    @Test
    public void valueTypeCollectionTest() {

        User user = new User();
        user.setUsername("userA");
        
        //HashSet에 add()
        user.getFavoriteBooks().add("JPA");
        user.getFavoriteBooks().add("SPRINGBOOT");
        user.getFavoriteBooks().add("REACT");
        user.getFavoriteBooks().add("TYPESCRIPT");
        
        //List에 add()
        user.getAddress().add(new Address("city", "street", "12345"));
        user.getAddress().add(new Address("country", "load", "54321"));
        
        em.persist(user);

    }
    
}

[실행 후 콘솔 결과] - INSERT (ADDRESS / FAVORITE_BOOK)

Hibernate:
/* insert collection
     row hellojpa.USER.addressHistory */ insert
into
      ADDRESS
      (USER_ID, city, street, zipcode)
values (1, "city", "street", "12345")
Hibernate:
/* insert collection
     row hellojpa.USER.addressHistory */ insert
into
      ADDRESS
      (USER_ID, city, street, zipcode)
values (1, "city", "street", "54321")
Hibernate:
/* insert collection
     row hellojpa.USER.favoriteBooks*/ insert
into
      FAVORITE_BOOK
      (USER_ID, BOOK_NAME
values (1, "JPA")
Hibernate:
/* insert collection
     row hellojpa.USER.favoriteBooks*/ insert
into
      FAVORITE_BOOK
      (USER_ID, BOOK_NAME
values (1, "SPRINGBOOT")
Hibernate:
/* insert collection
     row hellojpa.USER.favoriteBooks*/ insert
into
      FAVORITE_BOOK
      (USER_ID, BOOK_NAME
values (1, "REACT")
Hibernate:
/* insert collection
     row hellojpa.USER.favoriteBooks*/ insert
into
      FAVORITE_BOOK
      (USER_ID, BOOK_NAME
values (1, "TYPESCRIPT")

 

값 타입은 선언한 엔터티에 종속적이게 되므로, 따로 저장하거나 조회하지 않고 엔터티를 기준으로 접근해서 저장,조회를 해야한다.

값 타입 컬렉션은 영속성 전에 (Cascade) + 고아객체 제거 기능을 필수로 가진다고 볼 수 있다.

 

값 타입 조회 테스트

@SpringBootTest
@Transactional
public class JpaTest {
    @PersistenceContext
    EntityManager em;
    
    @Test
    public void valueTypeCollectionTest() {

        User user = new User();
        user.setUsername("userA");
        
        //HashSet에 add()
        user.getFavoriteBooks().add("JPA");
        user.getFavoriteBooks().add("SPRINGBOOT");
        user.getFavoriteBooks().add("REACT");
        user.getFavoriteBooks().add("TYPESCRIPT");
        
        //List에 add()
        user.getAddressHistory().add(new Address("city", "street", "12345"));
        user.getAddressHistory().add(new Address("country", "load", "54321"));
        
        em.persist(user);
        em.flush();
        em.clear();
        
        User findUser  = em.find(User.class, user.getId());
        System.out.println(findUser.getAddressHistory());
        System.out.println(findUser.getFavoriteBooks());

    }
    
}

[콘솔 결과 출력]

/* ...User Select 생략 ...*/

/*  === ===  address Select === ===  */
select
        addresshis0_.user_id as user_id1_0_0_,
        addresshis0_.city as city2_0_0_,
        addresshis0_.street as street3_0_0_,
        addresshis0_.zipcode as zipcode4_0_0_
from
        address addresshis0_
where
        addresshis0_.user_id=1

address = Address(city=city, street=street, zipcode=12345)
address = Address(city=country, street=load, zipcode=54321)

/*  ===   favorite_book  Select   ===  */
select
        favoritebo0_.user_id as user_id1_1_0_,
        favoritebo0_.book_name as book_nam2_1_0_
from
        favorite_book favoritebo0_
where
        favoritebo0_.user_id=1

favoriteFood = SPRINGBOOT
favoriteFood = JPA
favoriteFood = REACT favoriteFood = TYPESCRIPT

 

값 타입 수정 테스트

@SpringBootTest
@Transactional
public class JpaTest {
    @PersistenceContext
    EntityManager em;
    
    @Test
    public void valueTypeCollectionTest() {

		/* === 저장 === */
        User user = new User();
        user.setUsername("userA");
        
        //HashSet에 add()
        user.getFavoriteBooks().add("JPA");
        user.getFavoriteBooks().add("SPRINGBOOT");
        user.getFavoriteBooks().add("REACT");
        user.getFavoriteBooks().add("TYPESCRIPT");
        
        //List에 add()
        user.getAddressHistory().add(new Address("city", "street", "12345"));
        user.getAddressHistory().add(new Address("country", "load", "54321"));
        
        em.persist(user);
        em.flush();
        em.clear();
        
        /* === 조회 === */
        User findUser  = em.find(User.class, user.getId());
        System.out.println(findUser.getAddressHistory()); //DELETE 쿼리 호출
        System.out.println(findUser.getFavoriteBooks()); //INSERT 쿼리 호출
        
        /* === 수정 === */
         //[FavoriteBooks] JPA -> 나는 미남이다
        findUser.getFavoriteBooks().remove("JPA");
        findUser.getFavoriteBooks().add("나는 미남이다");
         
         //[AddressHistory] city -> universe
        findUser.getAddressHistory().remove(new Address("city", "street", "12345")); //DELETE 쿼리 호출
        findUser.getAddressHistory().add(new Address("universe", "street", "12345")); //INSERT 쿼리 호출
        
    }
    
}

위 코드를 실행하게 되면 수정 부분에서 DELETE한 뒤 INSERT로 새로 추가하게 된다.

컬렉션에서 remove를 할때는 들어있는 값과 동일한 객체를 매개변수로 넣어주면 해당 데이터를 찾아 지워준다.

 

값 타입 컬렉션에서 값을 수정할 때는 UPDATE쿼리가 아닌 DELETE 후 INSERT하는 방식으로 매커니즘으로 동작한다.

이때 전체를 DELETE한 뒤 원래 남아있던 데이터를 INSERT하고 새로운 데이터를 INSER하게된다.

 

결론적으로는 값이 변경이 되었다.

이것은 마치 CASCADE의 고아객체 제거기능이 작동된것 처럼 동작 한 것이다?

 

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

(저장할때는 컬렉션에 남아 있는 데이터를 저장한다.)

 

값 타입은 엔터티와 다르게 식별자 개념이 없기 때문에 값을 변경하면 추적이 어렵다.

PK와 같은 ID 값이 존재하지 않기 때문에 중간에 변경되면 추적이 어렵기 때문에 사용하지 않는게 바람직하다.

 

@OrderColumn(name = "address_history_order")
@ElementCollection
@CollectionTable(name = "ADDRESS",
joinColumns = @JoinColumn(name = "USER_ID"))
private List<Address> addressHistory = new ArrayList<>();

위와 같이 @OrderColumn 어노테이션으로 지정하게되면 자동 증가 컬럼이 추가되지만, 의도하지 않은대로 동작한다.

 

값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본 키를 구성해야 한다.

(null 불가, 중복 불가 - @ColumnDefinition?)

 


값 타입 컬렉션 대안

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

 

일대다 관계를 위한 엔티티를 만들고, 값타입을 사용한다.

@Entity
@Getter @Setter
public class AddressHistory {
    @Id @GeneratedValue
    @Column(name = "address_history_id")
    private Long id;
    
    @Embedded
    private Address address; // Address 엔터티를 매핑
    
    public AddressHistory() {

    }

    public AddressHistory(String city, String street, String zipcode) {
        this.address = new Address(city, street, zipcode);
    }
}
@Entity
@Getter
@Setter
@ToString
public class FavoriteFood {

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

    private String bookName;

    public FavoriteFood(String bookName) {
        this.bookName = bookName;
    }

    public FavoriteFood() {
        
    }
}

 

User엔터티에 아래와 같이 1:M 매핑을 걸어준다.

@Entity(name = "USERS")
@Setter
@Getter
@ToString
public class User {
    @Id @GeneratedValue
    @Column(name = "USER_ID")
    private Long id;
    private String username;
    
    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "user_id")
    private Set<FavoriteFood> favoriteBooks = new HashSet<>();
    
    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "user_id")
    private List<AddressHistory> addressHistory = new ArrayList<>(); //AddressHistory로 변경
    
}

이때 cascade옵션과 orphanRemoval옵션을 설정해줘야한다.

(추가 정리 필요)

 

[CREATE 쿼리 발생]

create table address_history (
      address_history_id bigint not null,
       city varchar(255),
       street varchar(255), zipcode varchar(255),
       user_id bigint,
       primary key (address_history_id)
)

create table favorite_food (
     favorite_food_id bigint not null,
      book_name varchar(255),
      user_id bigint,
      primary key (favorite_food_id)
)

[테스트 코드]

@SpringBootTest
@Transactional
public class JpaTest {
    @PersistenceContext
    EntityManager em;
    
    @Test
    public void valueTypeCollectionTest() {

		/* === 저장 === */
        User user = new User();
        user.setUsername("userA");
        
        //HashSet에 add()
        user.getFavoriteBooks().add("JPA");
        user.getFavoriteBooks().add("SPRINGBOOT");
        user.getFavoriteBooks().add("REACT");
        user.getFavoriteBooks().add("TYPESCRIPT");
        
        //List에 add()
        user.getAddressHistory().add(new AddressHistory("city", "street", "12345"));
		user.getAddressHistory().add(new AddressHistory("country", "load", "54321"));

		em.persist(user);
		em.flush();
		em.clear();

		/* === 조회 === */
		User findUser  = em.find(User.class, user.getId());
		System.out.println(findUser.getAddressHistory()); //DELETE 쿼리 호출
		System.out.println(findUser.getFavoriteBooks()); //INSERT 쿼리 호출

		/* === 수정 === */
		//[FavoriteBooks] JPA -> 나는 미남이다
		findUser.getFavoriteBooks().remove("JPA");
		findUser.getFavoriteBooks().add("나는 미남이다");

		//[AddressHistory] city -> universe
		findUser.getAddressHistory().remove(new AddressHistory("city", "street", "12345")); //DELETE 쿼리 호출
		findUser.getAddressHistory().add(new AddressHistory("universe", "street", "12345")); //INSERT 쿼리 호출
    }
    
}

[UPDATE 쿼리 발생]

/*        address_history        */

update
        address_history
set
        user_id=1
where
        address_history_id=2

update
        address_history
set
        user_id=1
where
        address_history_id=3

/*         favorite_food         */
update
        favorite_food
set
        user_id=1
where
        favorite_food_id=4

update
        favorite_food
set
        user_id=1
where
        favorite_food_id=5

update
        favorite_food
set
        user_id=1
where
        favorite_food_id=6

update
        favorite_food
set
        user_id=1
where
        favorite_food_id=7

 

728x90
반응형