엔티티에 컬렉션으로 관리하는 데이터는 흔히 있을 수 있는 일이지만 DB는 기본적으로는 컬렉션 데이터를 지원하지 않는다. 물론 요즘은 여러 방법으로 컬렉션을 테이블에서 관리할 수 있지만(JSON으로 데이터를 저장한다든지 등) 그러나 정석적인 방법은 컬렉션 데이터를 테이블 화해서 참조로 관리하는 것이다.
아래 그림을 보자.
멤버라는 엔티티가 관리하는 데이터 favoriteFoods와 addressHistory는 컬렉션 값 타입이다. 이런 엔티티를 테이블화 하기 위해서는 각 컬렉션 값 타입을 테이블로 분리해서 1:N 관계로 만드는 것이다. 이게 정석적인 방법이다.
위 그림을 보면 Address도 FavoriteFood도 모든 필드가 하나의 PK인데 이 이유는 컬렉션 '값 타입'이기 때문이다. 값 타입은 하나의 값 자체가 고유값이 되어야 하는 것이다. 만약, 여기서 어떤 구분할 수 있는 PK가 따로 있으면 그건 값 타입이 아니라 엔티티라고 불려야한다.
구현하는 방법도 간단하다. 코드를 보자
Address Class
package org.example.entity.collection;
import javax.persistence.Embeddable;
@Embeddable
public class Address {
private String city;
private String street;
private String zipcode;
public Address() {}
public Address(String city, String street, String zipcode) {
this.city = city;
this.street = street;
this.zipcode = zipcode;
}
}
Member Class
package org.example.entity.collection;
import javax.persistence.*;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
private String name;
@Embedded
private Address address;
@ElementCollection
@CollectionTable(
name = "FAVORITE_FOOD",
joinColumns = @JoinColumn(name = "MEMBER_ID"))
@Column(name = "FOOD_NAME")
private Set<String> favoriteFoods = new HashSet<>();
@ElementCollection
@CollectionTable(
name = "ADDRESS_HISTORY",
joinColumns = @JoinColumn(name = "MEMBER_ID")
)
private List<Address> addressHistory = new ArrayList<>();
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Set<String> getFavoriteFoods() {
return favoriteFoods;
}
public void setFavoriteFoods(Set<String> favoriteFoods) {
this.favoriteFoods = favoriteFoods;
}
public List<Address> getAddressHistory() {
return addressHistory;
}
public void setAddressHistory(List<Address> addressHistory) {
this.addressHistory = addressHistory;
}
}
멤버 엔티티가 관리하는 컬렉션 두 개가 있다. favoriteFoods, addressHistory.
이런 컬렉션 형태의 필드를 테이블화하기 위해 두 가지의 어노테이션이 필요하다. @ElementCollection, @CollectionTable. @CollectionTable 어노테이션에 name property는 테이블명을 의미한다.
joinColumns는 이 테이블이 어떤 테이블과 조인될지를 선정한다. 당연히 멤버와 일대다 매핑이 되어야하므로 멤버의 기본키로 조인했다.
favoriteFoods같은 경우엔 String으로 된 문자열 단일 값이기 때문에 컬럼명을 지정해주기 위해 @Column 어노테이션도 사용했다.
이렇게 두 개의 어노테이션만 있으면 자동으로 컬렉션 값 타입은 테이블로써 만들어지게 된다.
실행해보자.
실행 코드
package org.example;
import org.example.entity.collection.Address;
import org.example.entity.collection.Member;
import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
public class Main {
public static void main(String[] args) {
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
// ! Transaction 시작
tx.begin();
try {
Member member = new Member();
member.setName("member");
member.getFavoriteFoods().add("Pizza");
member.getFavoriteFoods().add("Chicken");
member.getFavoriteFoods().add("Beef");
member.getAddressHistory().add(new Address("city", "street", "zipcode"));
em.persist(member);
tx.commit();
} catch (Exception e) {
e.printStackTrace();
tx.rollback();
} finally {
em.close();
emf.close();
}
}
}
코드를 보면 멤버 객체를 하나 생성하고 멤버 객체의 favoriteFoods와 addressHistory를 가져와 추가했다. 저 두 개의 엔티티는 멤버라는 부모 엔티티에 의해 생명주기가 관리된다. 즉, 멤버 객체가 생성되고 소멸되는 주기가 곧 저 두 엔티티의 생명주기이고 멤버에 의해 관리 되기 때문에 영속 컨텍스트에는 멤버만을 추가해도 알아서 새로이 추가된 favoriteFoods와 addressHistory가 INSERT문으로 나간다.
결과를 보자.
Hibernate:
insert
into
Member
(city, street, zipcode, name, MEMBER_ID)
values
(?, ?, ?, ?, ?)
Hibernate:
insert
into
ADDRESS_HISTORY
(MEMBER_ID, city, street, zipcode)
values
(?, ?, ?, ?)
Hibernate:
insert
into
FAVORITE_FOOD
(MEMBER_ID, FOOD_NAME)
values
(?, ?)
Hibernate:
insert
into
FAVORITE_FOOD
(MEMBER_ID, FOOD_NAME)
values
(?, ?)
Hibernate:
insert
into
FAVORITE_FOOD
(MEMBER_ID, FOOD_NAME)
values
(?, ?)
Member, AddressHistory, FavoriteFood 모두 INSERT문이 실행됐음을 확인할 수 있다.
컬렉션 타입은 이렇게 테이블화해서 관계 매핑으로 다루어야 한다.
근데 만약 데이터를 수정해야할 때는 어떻게 동작할까?
아래 수정 코드를 보자.
값 타입 컬렉션 수정하기
package org.example;
import org.example.entity.collection.Address;
import org.example.entity.collection.Member;
import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
public class Main {
public static void main(String[] args) {
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
// ! Transaction 시작
tx.begin();
try {
Member member = new Member();
member.setName("member");
member.getFavoriteFoods().add("Pizza");
member.getFavoriteFoods().add("Chicken");
member.getFavoriteFoods().add("Beef");
member.getAddressHistory().add(new Address("city", "street", "zipcode"));
member.getAddressHistory().add(new Address("city2", "street2", "zipcode2"));
em.persist(member);
em.flush();
em.clear();
Member findMember = em.find(Member.class, member.getId());
findMember.getAddressHistory().removeIf(address -> address.getCity().equals("city"));
findMember.getAddressHistory().add(new Address("newCity", "newStreet", "newZipcode"));
tx.commit();
} catch (Exception e) {
e.printStackTrace();
tx.rollback();
} finally {
em.close();
emf.close();
}
}
}
새 멤버를 만들 때 addressHistory, favoriteFoods를 추가한 후 멤버만을 persist()해도 flush()를 호출하면 다음과 같이 데이터베이스에 Member, Address_History, Favorite_Food가 모두 추가된다. 그 이유는 값 타입은 어떤것이든 엔티티의 생명주기에 종속적이기 때문이다. 그러고 난 후 멤버를 데이터베이스에서 찾은 후 addressHistory를 하나 삭제하고 새로운 것 하나를 추가한다.
그러고 트랜잭션을 커밋을 하면 다음과 같은 SQL문이 나간다.
Hibernate:
delete
from
ADDRESS_HISTORY
where
MEMBER_ID=?
Hibernate:
insert
into
ADDRESS_HISTORY
(MEMBER_ID, city, street, zipcode)
values
(?, ?, ?, ?)
Hibernate:
insert
into
ADDRESS_HISTORY
(MEMBER_ID, city, street, zipcode)
values
(?, ?, ?, ?)
전체를 삭제해버린 후 기존에 남은 하나와 새로 추가한 하나를 나란히 INSERT한다. 이것이 값 타입 컬렉션을 테이블로 관리할 때 가장 큰 문제가 된다. 값 타입 컬렉션 테이블은 식별자가 따로 없기 때문에 JPA가 삭제할 때 어떤 데이터를 가져와 삭제해야 하는지 알지 못한다. 그렇기 때문에 전체를 삭제한 후 종속된 엔티티가 가지고 있는 모든 데이터를 다시 하나씩 추가한다.
결론은, 이 값 타입 컬렉션을 쓰면 안된다.
값 타입 컬렉션을 엔티티로 변경하라
값 타입 컬렉션의 가장 큰 문제는 식별자가 없기에 수정 쿼리를 수행할 때 JPA가 찾아내지 못한다는 것이다. 그리고 반대로 말하면 식별자가 있는 테이블은 엔티티라고 표현해야 한다.
그럼 위 코드의 문제점을 어떻게 수정하면 될까?
AddressEntity로 값 타입 컬렉션을 엔티티로 승격시켜 식별자를 가지게 하는것이다.
AddressEntity Class
package org.example.entity.collection;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
@Entity
public class AddressEntity {
@Id @GeneratedValue
@Column(name = "ADDRESS_ID")
private Long id;
private Address address;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Address getAddress() {
return address;
}
public void setAddress(Address address) {
this.address = address;
}
}
그리고, 멤버는 이 엔티티를 일대다 단방향으로 (또는 다대일 양방향도 좋은 방식) 변경한다.
package org.example.entity.collection;
import javax.persistence.*;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
private String name;
@Embedded
private Address address;
@ElementCollection
@CollectionTable(
name = "FAVORITE_FOOD",
joinColumns = @JoinColumn(name = "MEMBER_ID"))
@Column(name = "FOOD_NAME")
private Set<String> favoriteFoods = new HashSet<>();
// 변경전 코드
/*@ElementCollection
@CollectionTable(
name = "ADDRESS_HISTORY",
joinColumns = @JoinColumn(name = "MEMBER_ID")
)
private List<Address> addressHistory = new ArrayList<>();*/
// 변경후 코드
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "MEMBER_ID")
private List<AddressEntity> addressHistory = new ArrayList<>();
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Set<String> getFavoriteFoods() {
return favoriteFoods;
}
public void setFavoriteFoods(Set<String> favoriteFoods) {
this.favoriteFoods = favoriteFoods;
}
public List<AddressEntity> getAddressHistory() {
return addressHistory;
}
public void setAddressHistory(List<AddressEntity> addressHistory) {
this.addressHistory = addressHistory;
}
}
다대일 단방향, 양방향이 가장 좋은 방식이지만 이 경우 특별한 케이스이므로 일대다 단방향으로 설정해봤다. 이렇게 작성한 후 실행코드를 보자. 문제가 발생하는 부분이 생긴다.
아래 코드는 더 이상 사용할 수 없다. 왜냐하면 Member의 AddressHistory는 타입이 AddressEntity이기 때문에 Address 객체는 받을 수 없다.
member.getAddressHistory().add(new Address("city", "street", "zipcode"));
따라서, 아래 코드로 변경해야 한다.
member.getAddressHistory().add(new AddressEntity("city", "street", "zipcode"));
물론 AddressEntity에서 생성자를 만들어줘야 한다. 다음 코드가 그 부분이 추가된 상태이다.
package org.example.entity.collection;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
@Entity
public class AddressEntity {
@Id @GeneratedValue
@Column(name = "ADDRESS_ID")
private Long id;
private Address address;
public AddressEntity() {
}
public AddressEntity(String city, String street, String zipcode) {
this.address = new Address(city, street, zipcode);
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Address getAddress() {
return address;
}
public void setAddress(Address address) {
this.address = address;
}
}
이제 진짜 실행해볼 수 있다. 실행 코드는 다음과 같다.
package org.example;
import org.example.entity.collection.Address;
import org.example.entity.collection.AddressEntity;
import org.example.entity.collection.Member;
import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
public class Main {
public static void main(String[] args) {
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
// ! Transaction 시작
tx.begin();
try {
Member member = new Member();
member.setName("member");
member.getFavoriteFoods().add("Pizza");
member.getFavoriteFoods().add("Chicken");
member.getFavoriteFoods().add("Beef");
member.getAddressHistory().add(new AddressEntity("city", "street", "zipcode"));
member.getAddressHistory().add(new AddressEntity("city2", "street2", "zipcode2"));
em.persist(member);
em.flush();
em.clear();
Member findMember = em.find(Member.class, member.getId());
findMember.getAddressHistory().removeIf(address -> address.getAddress().getCity().equals("city"));
findMember.getAddressHistory().add(new AddressEntity("newCity", "newStreet", "newZipcode"));
tx.commit();
} catch (Exception e) {
e.printStackTrace();
tx.rollback();
} finally {
em.close();
emf.close();
}
}
}
이제 실행 코드를 보면 다음과 같이 SQL문이 출력된다.
Hibernate:
insert
into
AddressEntity
(city, street, zipcode, ADDRESS_ID)
values
(?, ?, ?, ?)
Hibernate:
update
AddressEntity
set
MEMBER_ID=null
where
MEMBER_ID=?
and ADDRESS_ID=?
Hibernate:
update
AddressEntity
set
MEMBER_ID=?
where
ADDRESS_ID=?
Hibernate:
delete
from
AddressEntity
where
ADDRESS_ID=?
더 이상 전체 Address 정보를 삭제하지 않고, 삭제하고자 하는 하나의 레코드만을 삭제하는 모습을 확인할 수 있다. 멤버로 수정 작업을 했지만 AddressEntity에 대한 UPDATE문이 실행되는 건 멤버가 OneToMany로 일대다 단방향 매핑을 했기 때문이다.
결론
이런식으로 데이터의 식별자가 필요하고 데이터의 추적이 필요한 경우에는 값 타입 컬렉션을 사용하지 말고 엔티티로 승격시켜 사용해야 한다.
'JPA(Java Persistence API)' 카테고리의 다른 글
[JPA] Part 16. Fetch JOIN (JPQL) (0) | 2023.10.30 |
---|---|
[JPA] Part 15. JPQL (0) | 2023.10.28 |
[JPA] Part 13. 임베디드 타입 (0) | 2023.10.23 |
[JPA] Part 12. CASCADE (2) | 2023.10.23 |
[JPA] Part 11. 지연로딩과 즉시로딩 (2) | 2023.10.23 |