
JPA 게시글은 대부분 인프런의 김영한님의 강의인 '자바 ORM 표준 JPA 프로그래밍' 기반으로 내용을 정리했습니다.
값 타입 컬렉션
- 값 타입을 컬렉션에 담아서 쓰는 걸 값 타입 컬렉션이라 한다.

값 타입 컬렉션에서 문제는 DB 테이블과 매핑할 때가 문제이다. DB 테이블에는 컬렉션을 담을 수 있는 방법이 없다. 컬렉션은 개념적으로 1대다이기 때문에 별도의 테이블로 뽑아줘야 한다.

예제
Member.class
@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<>();
Member 클래스에 값 타입 컬렉션 실습을 위해 Set과 List를 이용한 객체를 만들었다. favoriteFoods의 경우엔 @Column이 예외적으로 들어갔는데, 이는 Address의 경우엔 임베디드 타입 실습으로 인해 클래스가 따로 정의되어 있고, 필드의 이름이 다 정해져 있지만 favoriteFoods의 경우엔 제네릭에 String이 들어가 있으므로 필드의 이름을 정해주기 위해 @Column으로 필드의 이름을 지정해주었다.
그리고 값 타입 컬렉션을 사용하기 위한 애노테이션 @ElementCollection과 @CollectionTable을 달아주었다. @CollectionTable의 경우 컬렉션 테이블 이름과 그 테이블과 Member와의 외래키를 지정해준다. joinColumns 속성으로 Member의 컬럼 하나를 지정하면 그게 외래키가 된다.
위의 코드를 다 작성하고 나면 ADDRESS, FAVORITE_FOOD, MEMBER 테이블이 생성된다.
Hibernate:
create table FAVORITE_FOOD (
MEMBER_ID bigint not null,
FOOD_NAME varchar(255)
)
Hibernate:
create table Member (
MEMBER_ID bigint not null,
city varchar(255),
street varchar(255),
zipcode varchar(255),
USERNAME varchar(255),
endDate timestamp,
startDate timestamp,
primary key (MEMBER_ID)
)
Hibernate:
create table ADDRESS (
MEMBER_ID bigint not null,
city varchar(255),
street varchar(255),
zipcode varchar(255)
)
값 타입 컬렉션
- 값 타입을 하나 이상 저장할 때 사용한다.
- @ElementCollection, @CollectionTable 애노테이션을 사용한다.
- DB는 컬렉션을 같은 테이블에 저장할 수 없다.
- 컬렉션을 저장하기 위한 별도의 테이블이 필요하다.
저장
Member member = new Member();
member.setUsername("member1");
member.setHomeAddress(new Address("homeCity","street", "123"));
member.getFavoriteFoods().add("치킨");
member.getFavoriteFoods().add("족발");
member.getFavoriteFoods().add("라떼");
member.getAddressHistory().add(new Address("old_1","street", "123"));
member.getAddressHistory().add(new Address("old_2","street", "123"));
em.persist(member);
tx.commit();
위의 예제 코드에서 em.persist()로 인해 몇 개의 INSERT SQL가 날라갈까?
member 1개, favoritFood 3개, Address 2개로 총 6개의 INSERT 쿼리가 날라간다.

컬렉션 값 타입은 따로 persist를 해주지 않았는데도 불구하고 member가 persist 되자 그에 속한 값 타입의 테이블에게 까지 INSERT SQL가 날라간다. 즉, member와 생명 주기를 같이 하고 있는 것이다. 왜냐하면 컬렉션 값 타입은 본인만의 생명 주기가 없다. 그래서 Member에게 소속된 것이다. 쉽게 말하면 그냥 Member에 있는 username과 같이 Member에게 생명 주기를 맡겨 버린다.
컬렉션 값 타입의 생명 주기를 보면 CASCADE + 고아 객체 제거 기능을 설정한 객체와 똑같이 움직인다.
조회
위 예제에 이어서 코드를 작성해보자.
em.flush();
em.clear();
System.out.println("============= START ===============");
Member findMember = em.find(Member.class, member.getId());
영속성 컨텍스트를 비워주기 위해 em.flush(), em.clear()를 적어주고 em.find()로 Member를 찾아와 보면 다음과 같은 로그를 확인할 수 있다.
============= START ===============
Hibernate:
select
member0_.MEMBER_ID as MEMBER_I1_6_0_,
member0_.city as city2_6_0_,
member0_.street as street3_6_0_,
member0_.zipcode as zipcode4_6_0_,
member0_.USERNAME as USERNAME5_6_0_,
member0_.endDate as endDate6_6_0_,
member0_.startDate as startDat7_6_0_
from
Member member0_
where
member0_.MEMBER_ID=?
위 로그에서 알 수 있는 건 테이블 Address, favorite_food를 가져오지 않고 오직 Member만 가져왔다는 것이다. 이것으로 유추할 수 있는 점은 컬렉션 타입을 사용하면 기본 동작이 지연 로딩으로 동작한다는 것이다. 참고로 city, street, zipcode 데이터는 컬렉션 값 타입 외에도 임베디드 값 타입이 있어 Member 테이블에서 같이 조회가 되는 것이다.
그래서 컬렉션 값 타입 데이터를 가져와야 할 때는 favorite_food 테이블이나 Address 테이블에서 SELECT 하는 쿼리가 발생하면 가져온다. 이는 Address 뿐만 아니라 favorite_food 테이블에도 동일하게 적용된다.
List<Address> addressHistory = findMember.getAddressHistory(); // **
for(Address address : addressHistory) {
System.out.println("address = " + address.getCity());
}
수정
일반적으로 컬렉션을 수정할 때는 아래와 같이 수정하면 된다.
Address oldAdd = findMember.getHomeAddress();
findMember.setHomeAddress(new Address("newCity", oldAdd.getStreet(),oldAdd.getZipcode() ));
객체의 경우엔 객체 안에 있는 값 하나를 변경하는 방법으로 변경하는 것이 아니라, 아예 새로운 객체를 만들어서 넣어줘야 한다.
JPA 값 타입 - 값 타입과 불변 객체
JPA 게시글은 대부분 인프런의 김영한님의 강의인 '자바 ORM 표준 JPA 프로그래밍' 기반으로 내용을 정리했습니다. 값 타입과 불변 객체 값 타입 공유 참조 임베디드 타입 같은 값 타입을 여러 엔티
devhan.tistory.com
컬렉션을 String을 수정할 때는 별 다른 방법 없이 일반적으로 컬렉션을 사용하는 방법으로 수정해주면 된다.
// 치킨 -> 한식
// String이기 때문에 그냥 기존에 있던 치킨을 지우고 한식을 추가해주는 방법 밖에는 없다.
findMember.getFavoriteFoods().remove("치킨");
findMember.getFavoriteFoods().add("한식");
address의 경우에는 불변 타입인 String이 아니라 제네릭이 Address로 제한되어있다. 이 경우에는 전 글에의 '불변 객체'에 대해 잘 이해했으면 위와 비슷한 방법으로 진행되어야 함을 알 수 있을 것이다.
// old_1 -> newCity1로 변경
findMember.getAddressHistory().remove(new Address("old_1","street", "123"));
findMember.getAddressHistory().add(new Address("newCity1","street", "123"));
다만 String과는 다르게 새로운 객체를 만들어서 remove()시켰는데 이는 remove() 자체가 기본적으로 equals()로 동작하기 때문이다. 즉, 기존에 있는 요소를 찾기 위해 equals() 동작해 객체의 올바른 비교를 위해 Address.class 파일에 Override 된 hasCode가 꼭 존재해야 한다.
// Address.class
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Address address = (Address) o;
return Objects.equals(city, address.city) && Objects.equals(street, address.street) && Objects.equals(zipcode, address.zipcode);
}
@Override
public int hashCode() {
return Objects.hash(city, street, zipcode);
}
실행시켜보면 아래와 같은 쿼리를 볼 수 있다.
Hibernate:
/* delete collection hellojpa.Member.addressHistory */ delete
from
ADDRESS
where
MEMBER_ID=?
Hibernate:
/* insert collection
row hellojpa.Member.addressHistory */ insert
into
ADDRESS
(MEMBER_ID, city, street, zipcode)
values
(?, ?, ?, ?)
Hibernate:
/* insert collection
row hellojpa.Member.addressHistory */ insert
into
ADDRESS
(MEMBER_ID, city, street, zipcode)
values
(?, ?, ?, ?)
? 뭔가 싶다. 조회하고 주소 값을 변경했을 뿐인데 갑자기 현재의 MEMBER_ID로 저장된 ADDRESS 데이터를 모두 날려버린다. 그러고 나서 두 번의 INSERT 쿼리가 전송된다. INSERT 쿼리의 주인공들은 old_1의 데이터는 지워졌고, old_2가 남았다. 그리고 방금 새로 수정한 newCity1의 쿼리가 전송된 것이다. 그냥 아예 MBMER_ID와 연관된 주소 데이터를 다 지워버리고 남아있는 데이터들을 다시 넣는 것이다.
뭔가 결론적으로 의도대로 되긴 됐지만 찝-찝하다.
값 타입 컬렉션의 제약사항
- 값 타입은 엔티티와 다르게 식별자 개념이 없다.
- 값은 변경하면 추적이 어렵다.
- 값 타입 컬렉션에 변경 사항이 발생하면, 주인 엔티티와 연관된 모든 데이터를 삭제하고, 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장한다. 이게 기본 동작이다.
이 위험을 해결하는 방법이 @OrderColumn()을 사용하여 컬렉션 값 타입으로 생성된 테이블에 식별자와 기본키를 제대로 넣어주는 방법을 사용하면 INSERT 쿼리가 아닌 UPDATE 쿼리가 전송되긴 하는데 이 방법을 사용해도 원하는 대로 동작하지 않을 때가 있어 이렇게 사용할 거면 그냥 사용하지 않는 것이 좋다. - 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본키를 구성해야 한다.
null 입력 X, 중복 저장 X
값 타입 컬렉션 대안
- 실무에서는 상황에 따라 값 타입 컬렉션 대신에 일대다 관계를 고려한다.
- 일대다 관계를 위한 엔티티를 만들고, 여기에서 값 타입을 사용한다.
- 영속성 전이 + 고아 객체 제거를 사용해서 값 타입 컬렉션처럼 사용한다.
AddressEntity.class
@Entity
@Table(name = "ADDRESS")
public class AddressEntity {
@Id @GeneratedValue
private Long id;
private Address address;
public AddressEntity() {
}
public AddressEntity(Address address) {
this.address = address;
}
public AddressEntity(String city, String street, String zipcode) {
this.address = new Address(city, street, zipcode);
}
//...getter/setter 생략
}
Member.class 수정
// @ElementCollection
// @CollectionTable(name = "ADDRESS", joinColumns = @JoinColumn(name = "MEMBER_ID"))
// private List<Address> addressHistory = new ArrayList<>();
@OneToMany(cascade = ALL, orphanRemoval = true)
@JoinColumn(name = "MEMBER_ID")
private List<AddressEntity> addressHistory = new ArrayList<>();
코드를 수정하고 실행하면 아까 위에서 봤던 테이블과 달리 ID라는 컬럼이 생긴다.
