[JPA] 값 타입 컬렉션

3Beom's 개발 블로그·2023년 6월 15일
0

SpringJPA

목록 보기
13/21
post-custom-banner

출처

본 글은 인프런의 김영한님 강의 자바 ORM 표준 JPA 프로그래밍 - 기본편 을 수강하며 기록한 필기 내용을 정리한 글입니다.

-> 인프런
-> 자바 ORM 표준 JPA 프로그래밍 - 기본편 강의


값 타입 컬렉션

  • 값 타입을 컬렉션에 담아서 쓰는 것.
  • 이전에 @OneToMany 어노테이션과 함께 엔티티 컬렉션을 활용했었지만, 본 내용에서는 값 타입을 컬렉션에 담아서 활용하는 방안에 대해 다룸.

  • 위와 같이 예제가 설정된다.
  • favoriteFoods에 Set 컬렉션이, addressHistroy에 List 컬렉션이 활용됨.
  • 여기서 중요한 것은, 이와 같은 컬렉션 형태가 DB에 저장되는 방식이다.
  • 하나의 엔티티 안에 여러 데이터들이 DB에 저장되어야 하는데, 관계형 DB에서는 테이블 내에 또다른 테이블이 있는 것과 같은 방식은 지원되지 않음.
    • 그냥 값들만 들어가는 것
  • JPA 상에서는 엔티티가 아닌 값 타입을 모아 컬렉션에 담아두었지만, 여러 데이터들이 저장될 수 있어야 하므로, FAVORITE_FOOD, ADDRESS와 같이 별도의 테이블로 나누어야 한다.
  • 따라서 위 사진과 같이 FAVORITE_FOOD, ADDRESS 테이블로 나누어져 DB에 저장됨.
  • 여기서 유의깊게 봐야하는 것은, 둘 다 MEMBER_ID를 PK, FK로 두고 있는 것이다.
    • FOOD_ID나 ADDRESS_ID 처럼 PK를 따로 두게되면 이는 값 타입이 아닌 그냥 엔티티가 되어버림.
    • 따라서 MEMBER 테이블에 완전히 종속시키는 방향으로 MEMBER_ID를 PK이자 FK로 쓰는 것.

값 타입 컬렉션 구현

  • 값 타입 컬렉션을 구현하기 위해서는 두가지 어노테이션이 활용된다.
    • @ElementCollection, @CollectionTable
@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<>();

...

}
  • @ElementCollection : 값 타입 컬렉션임을 설정
  • @CollectionTable : 값 타입 컬렉션 데이터를 저장할 테이블 관련 설정
    • name : 테이블 이름 설정
    • joinColumns : 값 타입 컬렉션을 갖고 있는 엔티티와의 다대일 연관관계에서 FK 컬럼 설정
      • ADDRESS : MEMBER = FAVORITE_FOOD : MEMBER = N : 1
      • FK : MEMBER_ID
  • @Column(name = "FOOD_NAME") : FAVORITE_FOOD 테이블의 컬럼은 어차피 하나이기 때문에 예외적으로 해당 컬럼에 대한 설정을 적용할 수 있음.
  • 만약 ADDRESS 테이블의 컬럼 관련 설정을 하려면 앞서 다루었던 AttributeOverrides 를 활용하면 됨.

값 타입 컬렉션 정리

  • 값 타입을 하나 이상 저장할 때 사용
  • @ElementCollection, @CollectionTable 사용
  • DB는 컬렉션을 같은 테이블에 저장할 수 없음.
    • 객체 안에 컬렉션을 넣듯이 테이블 안에 컬렉션을 넣을 수 없음.
  • 따라서 컬렉션을 저장하기 위한 별도의 테이블이 필요하다.

값 타입 컬렉션 저장 과정

Member member = new Member();
member.setUsername("member1");
member.setHomeAddress(new Address("homeCity", "street", "10000");

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

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

em.persist(member);
  • 위와 같이 저장하면 FAVORITE_FOOD, ADDRESS 테이블에 각각 저장되고, 연관관계 매핑까지 되는 것을 확인할 수 있다.

  • 여기서 유의깊게 봐야할 것은 FAVORITE_FOOD와 ADDRESS 테이블에 따로 persist 하지 않고 member만 persist 했는데 자동으로 DB에 저장된 것이다.
  • 즉, 값 타입 컬렉션으로 맺어진 다대일 연관관계에서는 자동으로 라이프 사이클이 상위 엔티티에 맞춰진다.
    • favoriteFoods와 addressHistory 값 타입 컬렉션의 라이프 사이클은 Member 엔티티에 맞춰진다.
  • ⭐️ 값 타입 컬렉션은 영속성 전이(Cascade) + 고아 객체 제거 기능을 필수로 가진다고 볼 수 있다. ⭐️
  • 사실 있는 그대로 보면, 임베디드 값 타입 필드나 값 타입 컬렉션이나 primitive 타입이나 모두 Member의 필드인 것이다.
    • 필드들은 해당 엔티티의 라이프 사이클에 의존하므로, 이처럼 값 타입 컬렉션이 엔티티의 라이프 사이클에 맞춰지는 것은 당연한 것이다.

값 타입 컬렉션 조회 과정

Member member = new Member();
member.setUsername("member1");
member.setHomeAddress(new Address("homeCity", "street", "10000");

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

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

em.persist(member);

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

System.out.println("=============== START ==============="
Member findMember = em.find(Member.class, member.getId());
  • 이렇게 조회할 때, DB로 전달되는 쿼리를 살펴보면

  • 이렇게 MEMBER 테이블로만 쿼리가 나가는 것을 볼 수 있다.
  • 즉, 값 타입 컬렉션은 기본적으로 지연 로딩이 적용된다.
  • 여기서 다음과 같이 favoriteFoods와 addressHistory에 접근해보면
Member member = new Member();
member.setUsername("member1");
member.setHomeAddress(new Address("homeCity", "street", "10000");

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

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

em.persist(member);

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

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

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

Set<String> favoriteFoods = findMember.getFavoriteFoods();
for (String food : favoriteFoods) {
  System.out.println(food);
}
=============== START ===============
Hibernate: 
    select
        member0_.MEMBER_ID as member_i1_7_0_,
        member0_.city as city2_7_0_,
        member0_.street as street3_7_0_,
        member0_.zipcode as zipcode4_7_0_,
        member0_.USERNAME as username5_7_0_,
        member0_.endDate as enddate6_7_0_,
        member0_.startDate as startdat7_7_0_ 
    from
        Member member0_ 
    where
        member0_.MEMBER_ID=?
Hibernate: 
    select
        addresshis0_.MEMBER_ID as member_i1_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_.MEMBER_ID=?
old1
old2
Hibernate: 
    select
        favoritefo0_.MEMBER_ID as member_i1_4_0_,
        favoritefo0_.FOOD_NAME as food_nam2_4_0_ 
    from
        FAVORITE_FOOD favoritefo0_ 
    where
        favoritefo0_.MEMBER_ID=?
족발
치킨
피자
  • 이렇게 지연 로딩 방식으로 동작하는 것을 확인할 수 있다.

  • 이렇게 @ElementCollection 어노테이션의 fetch 속성을 들여다봐도 default로 FetchType.LAZY 가 설정되어 있는 것을 확인할 수 있다.

값 타입 컬렉션 수정 과정

  • 앞서 다루었듯, 값 타입 자체는 불변(immutable) 객체여야 한다.
...

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

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

// findMember.getHomeAddress.setCity("newCity"); // 절대 금지

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

tx.commit();
  • 값 타입 컬렉션 수정 또한 다음과 같이 수행되어야 한다.
findMember.getFavoriteFoods().remove("치킨");
findMember.getFavoriteFoods().add("한식");
  • 이렇게 아예 지우고 새로 넣어야 한다.

  • addressHistory 수정도 다음과 같이 아예 통으로 갈아끼워 줘야 한다.
findMember.getAddressHistory().remove(new Address("old1", "street", "10000"));
findMember.getAddressHistory().add(new Address("newCity", "street", "10000"));
  • 컬렉션의 remove() 메소드는 기본적으로 equals() 메소드를 기반으로 값 비교를 하기 때문에, Address 값 타입의 equals() 재정의는 필수이다.
  • 또는 만약 city 문자열만 알고 있을 경우, 다음과 같이 stream으로 구현할 수도 있을 것이다.
Address old = findMember.getAddressHistory().stream()
					.filter(a -> a.getCity().equals("old1"))
					.collect(Collectors.toList())
					.get(0);
			
findMember.getAddressHistory().remove(old);
findMember.getAddressHistory().add(new Address("new1", old.getStreet(), old.getZipcode()));
  • 그런데 여기서 유의깊게 봐야하는 점이 있다.
  • 여기서 우리가 시도한 방식은 old1 을 지우고 new1을 추가한 것이다.
  • 그렇다면 쿼리도 delete old1 → insert new1 으로 나가야 할 것이다.

  • 하지만 위 사진을 보면 ADDRESS 테이블에서 해당 MEMBER_ID의 데이터를 통째로 지웠다가 old2와 newCity를 다시 insert 하는 것을 볼 수 있다.
  • 즉, findMember가 갖는 모든 데이터들을 싹다 지웠다가 다시 통째로 집어넣고 있는 것이다.

값 타입 컬렉션의 제약 사항

  • 값 타입은 엔티티와 달리 식별자 개념이 없다.
  • 따라서 값을 변경하면 추적이 어렵다.
  • 값 타입 컬렉션에 변경 사항이 발생하면, 주인 엔티티와 연관된 모든 데이터를 삭제하고, 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장한다.
    • 본 방식이 Default이다.
    • addressHistory 에 변경이 생기면, 테이블에서 해당 member의 addressHistory 데이터들을 싹다 지우고, 현재 갖고 있는 데이터를 다시 넣는 방식으로 변경 사항을 적용한다.
  • 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본 키를 구성해야 한다.
    • PK가 없으므로 모든 컬럼들을 종합적으로 보면서 각 데이터들을 구분해야함.
    • null 입력 X, 중복 저장 X
  • 이를 극복할 수 있는 다음과 같은 방안도 있지만, 매우 복잡하다.
    • @OrderColumn(name = "address_history_order") 이와 같이 @OrderColumn 어노테이션을 활용함
      • 순서 값을 저장하는 컬럼을 추가하는 어노테이션이다. 해당 순서 값이 pk역할도 함.
      • 하지만 본 방법은 의도하지 않게 동작하거나 중간에 순서가 끊기면 null로 들어가는 등 문제가 많음.
      • 단점도 많고 복잡함.
  • 애초에 PK 없는 테이블은 불안정할 수 밖에 없다.

⇒ ⭐️ 이렇게 쿼리를 어마무시하게 보내거나, 혹은 복잡하게 쓸 바에는 쓰지 않는 것이 좋다. ⭐️

값 타입 컬렉션 대안

  • 실무에서는 값 타입 컬렉션의 문제 때문에 다음과 같은 대안을 주로 활용한다. ⇒ 일대다 관계 엔티티 생성 후, 해당 엔티티에서 값 타입을 사용 + 영속성 전이 + 고아 객체 제거 추가해서 → 이를 “값 타입 컬렉션에서 엔티티로 승격시킨다”고 한다.
  • 즉, 다음과 같이 AddressEntity를 생성하고, 이 안에서 Address 값 타입을 사용하는 것이다.
@Entity
@Table(name = "ADDRESS")
public class AddressEntity {

	@Id @GeneratedValue
	private Long id;

	private Address address;

	public AddressEntity(String city, String street, String zipcode) {
		address = new Address(city, street, zipcode);
	}

	public AddressEntity() {

	}
}
  • 그리고 Member 엔티티에서 Address 값 타입 컬렉션을 쓰는게 아니라, 해당 AddressEntity 와 일대다 연관관계 매핑을 시키는 것이다.
    • @JoinColumn 어노테이션으로 연관관계 주인을 설정하는 것은 상황에 따라 적절히 설정하면 된다.
    • 중요한건 영속성 전이 + 고아 객체 제거 를 꼭 추가해서 주인 엔티티가 라이프 사이클을 가져가도록 설정하는 것이다.
...

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

...
  • 그리고 이렇게 설정할 때, remove → add 순으로 수정해야 하는데, 그러기 위해서는 AddressEntityequlas() 메소드도 적절히 잘 설정해야 할 것이다.
@Override
	public boolean equals(Object o) {
		if (this == o)
			return true;
		if (o == null || getClass() != o.getClass())
			return false;
		AddressEntity that = (AddressEntity)o;
		return Objects.equals(id, that.id) && Objects.equals(address, that.address);
	}

	@Override
	public int hashCode() {
		return Objects.hash(id, address);
	}
  • 그냥 이렇게만 해두면, equals() 메소드에서 기준으로 여기는 필드가 AddressEntity 의 id값과 address 필드가 된다.
  • 상황에 맞추어 적절히 기준을 설정하면 될 것이다.

값 타입 컬렉션 용도

  • 이렇게 문제가 보이는 값 타입 컬렉션은 언제 써야할까?

  • 진짜 단순하고 간단할 때 쓰일 수 있다.

    • 예를 들어 좋아하는 음식을 선택하는 체크박스 기능 같은 것들
      • 치킨
      • 피자
    • 추적할 필요도 없고, 값을 수정할 필요가 없을 때
  • 그렇지 않은 이상, 웬만하면 엔티티로 승격해서 쓰는게 낫다.

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

profile
경험과 기록으로 성장하기
post-custom-banner

0개의 댓글