[JPA] 값 타입의 컬렉션
24 Jul 2022해당 포스트는 인프런 김영한님의 자바 ORM 표준 JPA 프로그래밍 - 기본편 을 듣고 정리한 글입니다.
값 타입 컬렉션
Member에 favoriteFoods나 addressHistory와 같은 Set이나 List의 컬렉션이 들어갈 경우 해당 관계는 1:N의 구조이다. 요즘에는 JSON을 지원하는 DB가 있어서 용이 할 수 있으나, 보통은 컬렉션 객체를 별도의 테이블로 빼서 관리를 해야한다.
또한 값 타입은 필드들을 모두 PK로 묶어서 하나의 테이블로 관리해야 한다. 만약 별도의 식별자 ID를 PK를 관리하게 되면 해당 객체는 값타입 이 아닌 Entity가 되어버린다. 그렇게되면 설계의도에서 벗어난 설계가 되는것이다.
값 타입 컬렉션
- 값 타입을 하나 이상 저장할 때 사용
- @ElementCollection, @CollectionTable 사용
- 데이터베이스는 컬렉션을 같은 테이블에 저장할 수 없다.
- 컬렉션을 저장하기 위한 별도의 테이블이 필요함
값타입 컬렉션 예제
값타입 컬렉션 구조
값타입을 이용한 Entity 설계에 대해 예시를 들어보자
@Entity
@Getter @Setter
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private long id;
@Column(name = "USERNAME")
private String username;
@Embedded
private Address homeAddress;
@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 필드와 addressHistory 필드를 각각 Set과 List 컬렉션에 넣어준 뒤 @ElementCollection
, @CollectionTable
어노테이션을 통해 값 타입 컬렉션임을 선언 하였다.
여기서 favoriteFoods의 필드의 경우 별도의 임베디드 타입이 아니므로, @Column(name = "FOOD_NAME")
을 통해 column name을 지정해 줄 수 있다.
해당 Entity를 실행하여, 실행 값을 확인해 보자.
create table ADDRESS (
MEMBER_ID bigint not null,
city varchar(255),
street varchar(255),
zipcode varchar(255)
)
create table FAVORITE_FOOD (
MEMBER_ID bigint not null,
FOOD_NAME varchar(255)
)
create table Member (
MEMBER_ID bigint not null,
city varchar(255),
street varchar(255),
zipcode varchar(255),
USERNAME varchar(255),
primary key (MEMBER_ID)
)
alter table ADDRESS
add constraint FKsuulxb5rmrxvb83yr43ox86wn
foreign key (MEMBER_ID)
references Member
alter table FAVORITE_FOOD
add constraint FKjchfnr69biisfgjdpoe82rpa4
foreign key (MEMBER_ID)
references Member
각각의 값 타입 컬렉션으로 지정한 테이블들이 생성된 것을 확인 할 수 있다.
값타입 컬렉션 저장 예제
그렇다면 데이터를 넣을 때는 어떻게 해줄까?
Member member = new Member();
member.setUsername("mho");
/* homeAddress 필드 insert */
member.setHomeAddress(new Address("city1", "street1", "zipcode1"));
/* favoriteFoods insert */
member.getFavoriteFoods().add("버거킹");
member.getFavoriteFoods().add("케이크");
member.getFavoriteFoods().add("피자");
/* addressHistory insert */
member.getAddressHistory().add(new Address("city_old1", "street_old1", "zipcode_old1"));
member.getAddressHistory().add(new Address("city_old2", "street_old2", "zipcode_old2"));
entityManager.persist(member);
entityTransaction.commit();
/* insert com.dhaudgkr.jpa06.collector.domain.Member
*/ insert
into
Member
(city, street, zipcode, USERNAME, MEMBER_ID)
values
(?, ?, ?, ?, ?)
/* insert collection
row com.dhaudgkr.jpa06.collector.domain.Member.addressHistory */ insert
into
ADDRESS
(MEMBER_ID, city, street, zipcode)
values
(?, ?, ?, ?)
/* insert collection
row com.dhaudgkr.jpa06.collector.domain.Member.addressHistory */ insert
into
ADDRESS
(MEMBER_ID, city, street, zipcode)
values
(?, ?, ?, ?)
/* insert collection
row com.dhaudgkr.jpa06.collector.domain.Member.favoriteFoods */ insert
into
FAVORITE_FOOD
(MEMBER_ID, FOOD_NAME)
values
(?, ?)
/* insert collection
row com.dhaudgkr.jpa06.collector.domain.Member.favoriteFoods */ insert
into
FAVORITE_FOOD
(MEMBER_ID, FOOD_NAME)
values
(?, ?)
/* insert collection
row com.dhaudgkr.jpa06.collector.domain.Member.favoriteFoods */ insert
into
FAVORITE_FOOD
(MEMBER_ID, FOOD_NAME)
values
(?, ?)
순서대로, Memeber, homeAddress필드가 들어가고, addressHistory, favoriteFoods의 필드순으로 데이터가 들어간 것을 확인 할 수 있다.
addressHistory, favoriteFoods의 경우 값타입이기 때문에, entityManager.persist(member);
한번에 모든 테이블에 데이터가 들어간 것을 볼 수 있다.
값 타입 컬렉션은 Member Entity에 종속되어 라이프 사이클이 돌아가기 때문에 영속성 전이 + 고아 객체 제거 기능을 필수로 가진다고 볼 수 있다.
값타입 컬렉션 조회 예제
System.out.println("============START============");
Member findMember = entityManager.find(Member.class, member.getId());
해당 코드를 추가하여 저장한 Member ENtity를 조회해 보자.
select
member0_.MEMBER_ID as member_i1_2_0_,
member0_.city as city2_2_0_,
member0_.street as street3_2_0_,
member0_.zipcode as zipcode4_2_0_,
member0_.USERNAME as username5_2_0_
from
Member member0_
where
member0_.MEMBER_ID=?
homeAddress필드는 즉시로딩으로 데이터를 불러들어오고, 나머지 필드인 addressHistory, favoriteFoods의 경우 지연로딩으로 조회쿼리를 불러오지 않은 것을 확인 할 수 있다. 그렇다면 나머지 필드를 불러와 전체적인 조회 쿼리를 확인해 보도록 하자.
System.out.println("=============== START ===============");
Member findMember = entityManager.find(Member.class, member.getId());
/* addressHistory 호출 */
System.out.println("=============== addressHistory START ===============");
List<Address> addressHistory = findMember.getAddressHistory();
for (Address address : addressHistory)
System.out.println("addressHistory : " + address.getCity());
/* favoriteFoods 호출 */
System.out.println("=============== favoriteFoods START ===============");
Set<String> favoriteFoods = findMember.getFavoriteFoods();
for (String favoriteFood : favoriteFoods)
System.out.println("favoriteFood : " + favoriteFood);
=============== START ===============
select
member0_.MEMBER_ID as member_i1_2_0_,
member0_.city as city2_2_0_,
member0_.street as street3_2_0_,
member0_.zipcode as zipcode4_2_0_,
member0_.USERNAME as username5_2_0_
from
Member member0_
where
member0_.MEMBER_ID=?
=============== addressHistory START ===============
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=?
=============== favoriteFoods START ===============
select
favoritefo0_.MEMBER_ID as member_i1_1_0_,
favoritefo0_.FOOD_NAME as food_nam2_1_0_
from
FAVORITE_FOOD favoritefo0_
where
favoritefo0_.MEMBER_ID=?
각각 호출을 시도할시 그때마다 SQL 조회쿼리가 날라가는 것을 확인할 수 있다.
값타입 컬렉션은 기본적으로 지연 로딩 전략을 사용한다.
@Target( { METHOD, FIELD })
@Retention(RUNTIME)
public @interface ElementCollection {
Class targetClass() default void.class;
FetchType fetch() default LAZY;
}
해당 어노테이션을 들어가면 기본적으로 LAZY 전략을 사용하는 것을 확인할 수 있다.
값타입 컬렉션 수정 예제
값타입의 경우 기본적으로 불변객체여야 하므로, 아래의 방식대로 수정하면 안된다.
- 잘못된 예시
/* homeCity -> newCity */
findMember.getHomeAddress().setCity("newCity");
저번 포스팅처럼 불변객체이기 때문에, 아예 새로운 Address 인스턴스를 만들어서 넣어주어야 한다.
- 옳은 예시
Address oldAddress = findMember.getHomeAddress();
findMember.setHomeAddress(new Address("newCity", oldAddress.getStreet(), oldAddress.getZipcode()));
조회 후 정상적으로 update 쿼리가 나가는 것을 확인 할 수 있다.
=============== START ===============
select
member0_.MEMBER_ID as member_i1_2_0_,
member0_.city as city2_2_0_,
member0_.street as street3_2_0_,
member0_.zipcode as zipcode4_2_0_,
member0_.USERNAME as username5_2_0_
from
Member member0_
where
member0_.MEMBER_ID=?
/* update
com.dhaudgkr.jpa06.collector.domain.Member */ update
Member
set
city=?,
street=?,
zipcode=?,
USERNAME=?
where
MEMBER_ID=?
그렇다면 나머지 필드의 수정을 해보자. favoriteFoods
의 경우 String의 컬렉션이기 때문에, 해당 값을 지워주고 새로운 데이터를 넣어줘야 한다.
/* 피자 -> 치킨 */
findMember.getFavoriteFoods().remove("피자");
findMember.getFavoriteFoods().add("치킨");
favoriteFoods
의 필드의 데이터를 찾아 delete 해준 뒤 새로운 데이터를 insert 해주는것을 확인 할 수 있다.
/* delete collection row com.dhaudgkr.jpa06.collector.domain.Member.favoriteFoods */ delete
from
FAVORITE_FOOD
where
MEMBER_ID=?
and FOOD_NAME=?
/* insert collection
row com.dhaudgkr.jpa06.collector.domain.Member.favoriteFoods */ insert
into
FAVORITE_FOOD
(MEMBER_ID, FOOD_NAME)
values
(?, ?)
컬렉션의 값만 변경해도 실제 데이터 베이스 쿼리가 날라간다.
그렇다면 addressHistory
의 필드도 데이터를 수정해 보도록하자.
/* city_old1 -> city_new1 */
findMember.getAddressHistory().remove(new Address("city_old1", "street_old1", "zipcode_old1"));
findMember.getAddressHistory().add(new Address("city_new1","street_old1", "zipcode_old1"));
new Address()
를 통해 새로운 인스턴스를 addressHistory
의 List 컬렉션에서 동일한 값을 찾아 지우기 위해서는 저번 포스팅에서 equals
와 hashCode
가 재정의 되어있어야 한다.
또한 제대로 구현이 되어있지 않을 경우 의도된대로 기능이 작동하지 않을 수 있으니 주의해야 한다.
위의 코드를 실행하면 아래와 같이 쿼리문이 날라간다.
/* delete collection com.dhaudgkr.jpa06.collector.domain.Member.addressHistory */
delete
from
ADDRESS
where
MEMBER_ID=?
/* insert collection
row com.dhaudgkr.jpa06.collector.domain.Member.addressHistory */
insert
into
ADDRESS
(MEMBER_ID, city, street, zipcode)
values
(?, ?, ?, ?)
/* insert collection
row com.dhaudgkr.jpa06.collector.domain.Member.addressHistory */
insert
into
ADDRESS
(MEMBER_ID, city, street, zipcode)
values
(?, ?, ?, ?)
날라간 쿼리문을 보면 무언가 이상한 점을 찾을 수 있다. 분명히 수정한것은 addressHistory 데이터 하나지만 MEMBER_ID 외래키의 지정된 모든 값을 지우고 모든 데이터를 다시 insert하는 것을 확인 할 수 있다.
해당 현상은 값 타입은 내부 데이터에 대한 추적이 힘들기 때문에, 모든 데이터를 삭제하고 컬렉션에 남은 데이터를 insert 해주기 때문에 일어난다.
Member의 Entity에 종속되어 해당 Entity의 필드만 변경해도 update쿼리가 날라가도록 의존관계를 모두 Member에 맡기는 것이다.
단순하게 Member 내부의 값이라고 정리하면 된다.
값 타입 컬렉션의 제약사항
- 값 타입은 엔티티와 다르게 식별자 개념이 없다.
- 값은 변경하면 추적이 어렵다.
- 값 타입 컬렉션에 변경 사항이 발생하면, 주인 엔티티와 연관된 모든 데이터를 삭제하고, 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장한다. • 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본 키를 구성해야 함: null 입력X, 중복 저장X
값 타입 컬렉션 대안
- 실무에서는 상황에 따라 값 타입 컬렉션 대신에 일대다 관계를 고려
- 일대다 관계를 위한 엔티티를 만들고, 여기에서 값 타입을 사용
- 영속성 전이(Cascade) + 고아 객체 제거를 사용해서 값 타입 컬 렉션 처럼 사용 EX) AddressEntity
변경 예시
- 값타입
@ElementCollection
@CollectionTable(name = "ADDRESS", joinColumns =
@JoinColumn(name = "MEMBER_ID")
)
private List<Address> addressHistory = new ArrayList<>();
- OneToMany
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "MEMBER_ID")
private List<AddressEntity> addressHistory = new ArrayList<>();
@Entity
@Getter @Setter
@Table(name = "ADDRESS")
@NoArgsConstructor
@AllArgsConstructor
public class AddressEntity {
@Id @GeneratedValue
private Long id;
@Embedded
private Address address;
}
값타입에 해당하는 필드를 OneToMany로 바꾸고, 영속성 전이와, 고아 객체의 속성을 선언해주면, 동일한 기능으로 작동할 뿐만 아니라 활용할 수 있는 범위가 더 많아진다.
한번 실행시켜서 결과를 보도록 하자.
Member member = new Member();
member.setUsername("mho");
member.setHomeAddress(new Address("city1", "street1", "zipcode1"));
member.getFavoriteFoods().add("버거킹");
member.getFavoriteFoods().add("케이크");
member.getFavoriteFoods().add("피자");
member.getAddressHistory().add(new AddressEntity("city_old1", "street_old1", "zipcode_old1"));
member.getAddressHistory().add(new AddressEntity("city_old2", "street_old2", "zipcode_old2"));
entityManager.persist(member);
/* insert com.dhaudgkr.jpa06.collector.domain.Member
*/
insert
into
Member
(city, street, zipcode, USERNAME, MEMBER_ID)
values
(?, ?, ?, ?, ?)
/* insert com.dhaudgkr.jpa06.collector.domain.AddressEntity
*/
insert
into
ADDRESS
(city, street, zipcode, id)
values
(?, ?, ?, ?)
/* insert com.dhaudgkr.jpa06.collector.domain.AddressEntity
*/
insert
into
ADDRESS
(city, street, zipcode, id)
values
(?, ?, ?, ?)
/* create one-to-many row com.dhaudgkr.jpa06.collector.domain.Member.addressHistory */
update
ADDRESS
set
MEMBER_ID=?
where
id=?
/* create one-to-many row com.dhaudgkr.jpa06.collector.domain.Member.addressHistory */
update
ADDRESS
set
MEMBER_ID=?
where
id=?
/* insert collection
row com.dhaudgkr.jpa06.collector.domain.Member.favoriteFoods */
insert
into
FAVORITE_FOOD
(MEMBER_ID, FOOD_NAME)
values
(?, ?)
/* insert collection
row com.dhaudgkr.jpa06.collector.domain.Member.favoriteFoods */
insert
into
FAVORITE_FOOD
(MEMBER_ID, FOOD_NAME)
values
(?, ?)
/* insert collection
row com.dhaudgkr.jpa06.collector.domain.Member.favoriteFoods */
insert
into
FAVORITE_FOOD
(MEMBER_ID, FOOD_NAME)
values
(?, ?)
upadte문이 나가는 OneToMany 단방향 연관관계 매핑이기 때문에 나가는 것이다.
이렇게 되면 기존 값타입처럼 모두 지우고 추가하는 것이 아닌, 수정하고자 하는 Entity를 불러 수정해주기만 하면 된다.
강의 기준 실무에서는 정말 간단할 경우에만 사용을 한다. ex) select박스 같은 마음대로 값을 변경해도 되는 것들.
정리
- 엔티티 타입의 특징
- 식별자O
- 생명 주기 관리
- 공유
- 값 타입의 특징
- 식별자X
- 생명 주기를 엔티티에 의존
- 공유하지 않는 것이 안전(복사해서 사용)
- 불변 객체로 만드는 것이 안전
값 타입은 정말 값 타입이라 판단될 때만 사용 엔티티와 값 타입을 혼동해서 엔티티를 값 타입으로 만들면 안됨 식별자가 필요하고, 지속해서 값을 추적, 변경해야 한다면 그것은 값 타입이 아닌 엔티티