JPA - 값 타입 컬렉션 주의 사항
값 타입 컬렉션을 제대로 이해하고 있지 않아 설계 상 미스가 일어나게 되었다. 값 타입 컬렉션을 정리하고 프로젝트 리팩토링을 하려고 합니다.
목차
1. 값 타입 컬렉션
2. 값 타입 컬렉션 특징
- INSERT
- UPDATE
- 책에서 말하는 권장사항
값 타입 컬렉션
값 타입을 하나 이상 저장하려면 컬렉션에 보관하고 @ElementCollection, @CollectionTable 어노테이션을 사용하면 된다.
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Group {
@Id @GeneratedValue(strategy = IDENTITY)
@Column(name = "group_id")
private Long id;
@ElementCollection
@CollectionTable(name = "group_member", joinColumns = @JoinColumn(name = "group_id"))
private List<GroupMember> groupMembers = new ArrayList<>();
@ElementCollection
@CollectionTable(name = "group_task", joinColumns = @JoinColumn(name = "group_id"))
private List<Task> tasks = new ArrayList<>();
RDBMS는 컬럼안에 컬렉션을 포함할 수 없기 때문에 위처럼 @ElementCollection을 선언해 값 타입 컬렉션을 만들면 별도의 테이블을 추가한다. @CollectionTable은 테이블에 매핑하는 용도로 사용된다. 테이블은 아래와 같이 매핑됩니다.
값 타입 컬렉션 특징
1. INSERT
Group group = new Group();
group.getGroupMembers().add("그룹멤버1");
group.getGroupMembers().add("그룹멤버2");
group.getTasks().add("그룹일1");
group.getTasks().add("그룹일2");
group.getTasks().add("그룹일2");
group.persist(group);
그룹 1번 , 그룹 멤버 2번 , 그룹 일 3번, 총 6번의 INSERT 쿼리가 나가게 된다. 따라서 값 타입 컬렉션에 많은 데이터를 넣을 때면 다수의 INSERT가 발생하게 됩니다.
이로 인해 값 타입 컬렉션을 사용하면서 성능적인 부분을 고민하게 만들기도 했습니다. 저는 배치 사이즈를 설정해서 벌크 쿼리를 보내도록 했습니다.
spring.jpa.properties.hibernate.jdbc.batch_size = 20
해당 옵션을 주면 지정된 개수만큼 SQL 문을 배치로 그룹화해서 데이터베이스로 보냅니다. 더 자세한 설명은 Hibernate ORM 6.1.7 reference - batch 를 참고하시면 될 것 같습니다. 글로벌하게 설정하지 않고 각 테이블마다 설정을 하려면 @BatchSize 애노테이션을 이용하면 됩니다.
참고로 위 옵션과 유사한 fetch_size가 존재합니다. 이 옵션은 데이터베이스에서 가져올 때, 몇 개를 한 번에 가져올건지 관련된 옵션입니다. 쉽게 말해 SELECT와 관련있습니다.
spring.jpa.properties.hibernate.default_batch_fetch_size
2. UPDATE
엔티티는 식별자가 있기 때문에 원본 데이터를 쉽게 찾아서 변경할 수 있습니다. 반면 값 타입은 식별자라는 개념이 없기 때문에 값을 변경해버리면 데이터베이스에 저장된 원본 데이터를 찾기 어렵습니다.
특정 엔티티에 소속된 값 타입이라면 큰 문제가 되지 않지만 값 타입 컬렉션에 보관된 값 타입들은 별도의 테이블에 보관됩니다. 따라서 여기에 보관된 값 타입은 변경되면 데이터베이스에 있는 원본 데이터를 찾기 어렵다는 문제가 있습니다.
이러한 문제로 인해 JPA 구현체(Hibernate)는 값 타입 컬렉션이 변경 사항이 발생하면 값 타입 컬렉션이 매핑된 테이블의 연관된 모든 데이터를 삭제하고, 현재 값 타입 컬렉션 객체에 있는 모든 값을 데이터베이스에 다시 저장합니다.
실제로 한 번 확인해보겠습니다.
select * from group_member;
group_member 테이블은 값 타입 컬렉션 테이블입니다. 총 3개의 레코드를 가지고 있습니다.
이제 여기서 group_id = 1 그룹에 데이터를 하나 더 넣고 쿼리 로그를 확인해보겠습니다.
먼저 delete 쿼리가 나갑니다. group_id = 1 인 레코드를 모두 지웁니다.
이후 다시 넣는데요. 저는 batch size 옵션을 주었기 때문에 insert 쿼리가 한 번 만 나가지만 설정하지 않았다면 다수의 insert 쿼리가 발생하게 될 것입니다.
이러한 문제를 회피하기 위해서는 nativeQuery 를 날리는 방법을 생각해보았는데요. JPA 구현체들이 불변성을 보장하기 위해 이러한 조치를 취한 이유가 있을 것이라고 생각해요. 그걸 굳이 무시하면서 nativeQuery를 날리는게 맞을지 확신이 들지 않아 프로젝트에는 commit 하지 않았습니다. 애초에... 제가 설계를 잘못해서 일어난 일입니다. 해당 테이블 특성상 변경이 굉장히 잦습니다. 차라리 연관관계를 맺는게 좋겠다는 생각이 들었습니다.
책에서 말하는 권장사항
값 타입 컬렉션이 매핑된 테이블에 데이터가 많다면 값 타입 컬렉션 대신에 일대다 관계를 고려하라고 말씀하십니다. 여기에 cascade, orphanRemoval 옵션을 활용하면 값 타입 컬렉션처럼 활용할 수 있습니다.
마지막으로 값 타입의 특징을 정리하며 글을 마무리하겠습니다.
값 타입의 특징
1. 식별자가 없다.
2. 생명 주기를 엔티티에 의존한다.
3. 엔티티 타입과 다르게 공유하지 않는 것이 안전하다. 공유하게 된다면 의도치 않은 영역까지 함께 변경될 수 있다.
- 이로 인해서 데이터가 수정되면 관련된 레코드를 지우고 다시 INSERT 하는 것 같다.
4. 3번으로 인해 불변 객체롤 만드는 것이 안전하다.
- 변경이 필요하다면 Setter 보다 생성자를 통해 값을 아예 초기화 시키는 것을 권장한다.