Back-End/JPA

9. 값 타입 (2)

wisdom11 2022. 6. 10. 14:06

 

5. 값 타입 컬렉션 (Collection Value Type)

값 타입을 하나 이상 저장하려면 컬렉션에 저장하면 된다.

이때 2가지 어노테이션이 필요하다.

  • @ElementCollection: 값 타입 컬렉션을 사용하는 속성에 표시한다.
  • @CollectionTable: 컬렉션을 위한 추가 테이블을 매핑한다. 생략 가능하며, 기본값으로 {엔티티 이름}_{컬렉션 속성 이름} 테이블과 매핑한다.

 

위의 다이어그램에서 favoriteFoods는 기본값 타입 컬렉션이고, addressHistory는 임베디드 타입 컬렉션이다.

관계형 데이터베이스 테이블의 컬럼 안에 컬렉션을 포함할 수 없기 때문에, 두 가지 값 타입 컬렉션 모두 별도의 테이블을 추가하고, 추가한 테이블을 매핑해야 한다

 

favoriteFoods 와 addressHistory 값 타입 컬렉션은 아래와 같이 구현할 수 있다.

예제 코드)

@Entity
public class Member {
    ...
    @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<>();
    ...
}

favoriteFoods 처럼 값으로 사용되는 컬럼이 하나인 경우, @Column을 사용해서 컬럼명을 지정할 수 있다.

 

5.1. 값 타입 컬렉션 사용

✔️ 등록

Member member = new Member();

// 임베디드 값 타입
member.setHomeAddress(new Address("통영", "몽돌해수욕장", "660-123"));

// 기본값 타입 컬렉션
member.getFavoriteFoods().add("짬뽕");
member.getFavoriteFoods().add("짜장");
member.getFavoriteFoods().add("탕수육");

// 임베디드 값 타입 컬렉션
member.getAddressHistory().add(new Address("서울", "강남", "123-123"));
member.getAddressHistory().add(new Address("서울", "강북", "000-000"));

em.persist(member);

마지막에 member 엔티티만 영속화하면, JPA 가 member 엔티티의 값 타입도 함께 저장한다.

실제 데이터베이스에 실행되는 INSERT SQL은 6개이고, 각각은 다음과 같다.

  • member: INSERT SQL 1번
  • member.homeAddress: 컬렉션이 아닌 임베디드 값 타입이므로 회원테이블을 저장하는 SQL에 포함된다.
  • member.favoriteFoods: INSERT SQL 3번
  • member.addressHistory: INSERT SQL 2번

 

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

 

✔️ 조회

값 타입 컬렉션도 조회할 때 페치 전략을 선택할 수 있고, 기본값은 LAZY 다.

@ElementCollection(fetch = FetchType.LAZY)

 

Member member = em.find(Member.class, 1L);

Address homeAddress = member.getHomeAddress();

Set<String> favoriteFoods = member.getFavoriteFoods();	// LAZY

/** 
* SQL
* SELECT MEMBER_ID, FOOD_NAME 
* FROM FAVORITE_FOODS
* WHERE MEMBER_ID = 1
*/
for (String favoriteFood : favoriteFoods) {
	System.out.println("favoriteFood = " + favoriteFood);
}

List<Address> addressHistory = member.getAddressHistory(); // LAZY

/** 
* SQL
* SELECT MEMBER_ID, CITY, STREET, ZIPCODE 
* FROM ADDRESS
* WHERE MEMBER_ID = 1
*/
addressHistory.get(0);

위의 예제를 실행할 때 실행되는 SELECT SQL은 다음과 같다.

  • member: 회원만 조회한다. 임베디드 값 타입인 homeAddress도 함께 조회한다. SELECT SQL을 1번 호출한다.
  • member.homeAddress: 회원을 조회할 때 같이 조회해둔다.
  • member.favoriteFoods: LAZY로 설정해서, 실제 컬렉션을 사용할 때 SELECT SQL을 1번 호출한다.
  • member.addressHistory: LAZY로 설정해서, 실제 컬렉션을 사용할 때 SELECT SQL을 1번 호출한다.



✔️ 수정

Member member = em.find(Member.class, 1L);

// 임베디드 값 타입 수정
member.setHomeAddress("새로운도시", "신도시1", "123456");

// 기본값 타입 컬렉션 수정
Set<String> favoriteFoods = member.getFavoriteFoods();
favoriteFoods.remove("탕수육");
favoriteFoods.add("치킨");

// 임베디드 값 타입 컬렉션 수정
List<Address> addressHistory = member.getAddressHistory();
addressHistory.remove(new Address("서울", "기존 주소", "123-123"));
addressHistory.add(new Address("새로운도시", "새로운 주소", "123-456"));

임베디드 값 타입 수정

  • MEMBER 테이블만 UPDATE 한다. 사실 Member 엔티티를 수정하는 것과 같다.


기본값 타입 컬렉션 수정

  • 탕수육을 치킨으로 변경하려면, 탕수육을 제거하고 치킨을 추가해야 한다.
  • 자바의 String 타입은 수정할 수 없다.


임베디드 값 타입 컬렉션 수정

  • 값 타입은 불변해야 한다.
  • 따라서 컬렉션에서 기존 주소를 삭제하고 새로운 주소를 등록해야 한다.
  • 참고로 값 타입은 equals, hashCode를 꼭 구현해야 한다.



5.2. 제약사항

엔티티에 소속된 값 타입의 경우, 값이 변경되어도 소속된 엔티티를 데이터베이스에 찾고 값을 변경하면 된다.
그러나 값 타입 컬렉션의 경우, 보관된 값 타입들이 별도의 테이블에 보관된다. 따라서 보관된 값 타입의 값이 변경되면 데이터베이스에 있는 원본 데이터를 찾기 어렵다.

이런 문제로 인해, JPA 구현체들은 값 타입 컬렉션에 변경 사항이 발생하면 값 타입 컬렉션이 매핑된 테이블의 연관된 모든 데이터를 삭제하고, 현재 값 타입 컬렉션 객체 있는 모든 값을 데이터베이스에 다시 저장한다.

따라서 실무에서는 값 타입 컬렉션이 매핑된 테이블에 데이터가 많다면 값 타입 컬렉션 대신에 일대다 관계를 고려해야 한다.

참고) 일대다 관계로 설정하기

더보기

값 타입 컬렉션을 사용하는 대신에 새로운 엔티티를 생성하여 일대다 관계로 설정해보자.

추가적으로 이 엔티티에 영속성 전이 + 고아 객체 제거 기능을 적용하면 값 타입 컬렉션처럼 사용할 수 있다.

@Entity
public class Member {
	...
    @OneToMany(casecade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "MEMBER_ID")
    private List<AddressEntity> addressHistory = new ArrayList<AddressEntity>();
}

@Entity
public class AddressEntity {
	
    @Id @GeneratedValue
    private Long id;
    
    @Embedded Address address;
    ...
}

 

그리고 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본 키를 구성해야 한다. 이로 인해 (기본 키 제약조건), 컬럼에 null을 입력할 수 없고 같은 값을 중복해서 저장할 수 없다는 제약이 있다. 

 

6. 정리

  엔티티 타입 (Entity Type) 값 타입 (Value Type)
식별자 식별자가 있다. 식별자가 없다.
생명 주기 생명 주기가 있다. 생명 주기를 엔티티에 의존한다.
공유 공유할 수 있다. 공유하지 않는 것이 안전하다.

값 타입은 정말 값 타입이라 판단될 때만 사용해야 한다.

식별자가 필요하고 지속해서 값을 추적하고 구분하고 변경해야 한다면, 그것은 값 타입이 아닌 엔티티다.

 

 

 

 

728x90