Backend/JPA

값 타입 컬렉션

olsohee 2024. 1. 13. 21:45

JPA의 데이터 타입

JPA의 데이터 타입을 크게 분류하면 엔티티 타입과 값 타입으로 나눌 수 있다.

  • 엔티티 타입
    • @Entity로 정의하는 객체
    • 데이터가 변해도 식별자를 통해 지속적으로 추적이 가능하다.
  • 값 타입
    • 종류
      • 기본 값 타입: 기본 타입, 래퍼 클래스 , String처럼 자바가 제공하는 기본 데이터 타입
      • 임베디드 타입: JPA에서 사용자가 정의한 값 타입
      • 컬렉션 값 타입: 두개 이상의 값 타입을 저장할 때 사용
    • 식별자가 없으므로 변경시 추적이 불가능하며 생명주기를 엔티티에 의존한다.

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

값 타입을 두개 이상 저장하려면 컬렉션에 보관하고, @ElementCollection과 @CollectionTable을 사용하면 된다.

@Entity
public class Member {

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

    @Embedded
    private Address homeAddress;

    @ElementCollection
    @CollectionTable(name = "favorite_foods",
                     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 어노테이션을 사용하고 @JoinColumn을 통해 외래키를 매핑한다. 만약 @CollectionTable을 생략하면 기본 값(엔티티 이름_컬렉션 속성 이름)을 사용해서 매핑한다. (ex, Member 엔티티의 addressHistory는 Member_addressHistory 테이블로 매핑된다.)
  • favoriteFoods처럼 값으로 사용하는 컬럼이 하나이면 @Column을 사용하여 컬럼명을 지정할 수 있다.
  • 데이터베이스 테이블은 하나의 컬럼 안에 컬렉션을 포함할 수 없다. 따라서 값 타입 컬렉션은 데이터베이스에서 별도의 테이블을 만들고 일대다 관계가 된다.
  • 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본 키를 구성한다. 따라서 데이터베이스의 기본 키 제약 조건으로 인해 컬럼에 null을 입력할 수 없고, 같은 값을 저장할 수 없다는 한계가 있다.

값 타입 컬렉션 사용: 조회

Member member = new Member();

// 임베디드 값 타입
member.setHomeAddress(new Address("city", "street", "10000"));

// 기본값 타입 컬렉션
member.getFavoriteFoods().add("치킨");
member.getFavoriteFoods().add("피자");
member.getFavoriteFoods().add("고기");

// 임베디드 값 타입 컬렉션
member.getAddressHistory().add(new Address("oldCity1", "oldStreet1", "12010"));
member.getAddressHistory().add(new Address("oldCity2", "oldStreet2", "12121"));

em.persist(member);
  • 위 예제에서 em.persist(member)를 통해 데이터베이스에 실행되는 insert sql은 다음과 같다.
    • member: insert sql 1번
    • member.homeAddress: 컬렉션이 아닌 임베디드 값 타입이므로 회원 테이블에 member를 저장하는 sql에 포함된다.
    • member.favoriteFood: insert sql 3번
    • member.addressHistory: insert sql 2번
  • 따라서 em.persist(member) 한 번 호출로 영속성 컨텍스트를 플러시할 때 총 6번의 insert sql이 실행된다.
  • 이때 Member 테이블 뿐만 아니라 favorite_foods 테이블과 address 테이블에도 sql이 실행된다. 즉 값 타입 컬렉션은 그 생명주기를 엔티티가 관리한다. 즉 값 타입 컬렉션은 영속성 전이와 고아 객체 제거 기능을 필수로 가진다고 볼 수 있다.

값 타입 컬렉션의 지연 로딩

Member findMember = em.find(Member.class, 1L);
  • 값 타입 컬렉션도 조회할 때 패치 전략을 사용할 수 있는데 기본이 LAZY이다.
  • 따라서 위 예제 코드에서 회원을 조회할 때 select sql은 member 테이블에만 실행되고, favorite_foods 테이블과 address 테이블에는 회원의 favoriteFood와 addressHistory가 사용될 때 sql이 실행된다.

값 타입 컬렉션 사용: 수정

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

// 임베디드 값 타입 수정
member.setHomeAddress(new Address("newCity", "newStreet", "newZipcode"));

// 기본값 타입 컬렉션 수정
member.getFavoriteFoods().remove("치킨"); // 기존 값 제거
member.getFavoriteFoods().add("탕수육"); // 새로운 값 추가

// 임베디드 값 타입 컬렉션 수정
member.getAddressHistory().remove(new Address("oldCity1", "oldStreet1", "12010")); // 기존 값 제거
member.getAddressHistory().add(new Address("oldCity3", "oldStreet3", "12111")); // 새로운 값 추가
  • 임베디드 값 타입 수정: 임베디드 값 타입을 수정할 때는 setter를 사용하지 않고, 아예 새로운 인스턴스를 생성하고 갈아끼워야 한다. 그리고 homeAddress 임베디드 값 타입은 member 테이블과 매핑했으므로 member 테이블만 update sql이 실행된다.
  • 기본값 타입 컬렉션 수정: 기존 값을 remove()로 제거하고 새로운 값을 추가한다.
  • 임베디드 값 타입 컬렉션 수정: 기존 값을 remove()로 제거하고 새로운 값을 추가한다. 이때 기존 값을 제거하기 위해 값 타입의 인스턴스 참조 값 비교가 아닌 값 비교로 equals()를 반드시 구현해야 한다.

값 타입 컬렉션의 제약사항

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

 

따라서 이런 문제로 JPA 구현체들은 값 타입 컬렉션에 변경사항이 있을시, 값 타입 컬렉션이 매핑된 테이블에서 주인 엔티티와 연관된 모든 데이터를 삭제하고, 값 타입 컬렉션 객체에 있는 모든 값들을 데이터베이스에 다시 저장한다.

 

예를 들어 식별자가 100번인 회원이 관리하는 주소 값 타입 컬렉션을 변경하면, 값 타입 컬렉션이 매핑된 테이블에서 member_id가 100인 모든 칼럼을 삭제하고, 회원의 값 타입 컬렉션 객체에 있는 모든 값들을 다시 저장한다.

// 관련된(member_id가 100인) 모든 칼럼 delete
delete from address where member_id=100;
 
// member의 값 타입 컬렉션 객체의 값들 insert 
insert into address (member_id, city, street, zipcode) values (100, "city1", ...);
insert into address (member_id, city, street, zipcode) values (100, "city2", ...);

 

따라서 값 타입 컬렉션이 매핑된 테이블에 데이터가 많다면 값 타입 컬렉션 대신에, 새로운 엔티티를 만들고 일대다 관계로 설정하는 것이 좋다. 여기에 추가로 영속성 전이(cascade) + 고아 객체 제거(orphan remove)를 적용하면 값 타입 컬렉션처럼 사용할 수 있다.

@Entity
public class AddressEntity {
 
    @Id @GeneratedValue
    private Long id;
    
    @Embedded
    private Address address;
}
@Entity
public class Member {
 
 	...
    
    // 일대다 단방향 매핑
    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "memder_id")
    private List<AddressEntity> addressHistory = new ArrayList<>(); 
}

Reference

  • 자바 ORM 표준 JPA 프로그래밍, 김영한