값 타입을 컬렉션에 담아서 사용하는 것을 말한다.
예를 들어, Member가 여러개의 주소 이력을 보관하거나 좋아하는 음식 목록을 저장하고 싶을 때 사용한다.

관계형 데이터베이스는 기본적으로 컬렉션을 테이블 내부에 저장할 수 있는 구조가 아니다.
Member가 주소 이력을 보관한다면 List<Address>처럼 1:N 구조가 된다.
이런 경우 컬렉션을 별도의 테이블로 분리해야 한다.

Address 테이블은 MEMBER_ID, CITY, STREET, ZIPCODE를 모두 PK로 지정해야 한다.
만약 별도의 식별자 ID를 하나 두고 PK를 지정하면 그건 값 타입이 아니라 Entity가 되어버린다.
왜 모든 컬럼을 PK로 지정하는가?
값 타입은 식별자가 없기 때문에 값 자체로 구분해야 한다. 따라서 모든 컬럼을 조합해서 기본 키를 만들어야 한다. 이렇게 하면 같은 값이 중복으로 저장되는 것을 방지할 수 있다.
값 타입을 하나 이상 저장할 때는 @ElementCollection과 @CollectionTable을 사용한다.
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
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<>();
}
@Column(name = "FOOD_NAME")을 사용하는 이유
favoriteFoods는 String이라는 단순 값 타입이고 컬럼이 하나뿐이다. 이런 경우 @Column으로 컬럼명을 직접 지정할 수 있다. 반면 Address는 여러 필드를 가진 임베디드 타입이라 별도로 컬럼명을 지정하지 않아도 각 필드가 자동으로 컬럼에 매핑된다.
위 코드는 다음과 같은 테이블을 생성한다.
MEMBER 테이블
CREATE TABLE MEMBER (
MEMBER_ID BIGINT PRIMARY KEY,
USERNAME VARCHAR(255),
CITY VARCHAR(255),
STREET VARCHAR(255),
ZIPCODE VARCHAR(255),
TEAM_ID BIGINT
);
FAVORITE_FOOD 테이블
CREATE TABLE FAVORITE_FOOD (
MEMBER_ID BIGINT,
FOOD_NAME VARCHAR(255),
PRIMARY KEY (MEMBER_ID, FOOD_NAME),
FOREIGN KEY (MEMBER_ID) REFERENCES MEMBER(MEMBER_ID)
);
ADDRESS 테이블
CREATE TABLE ADDRESS (
MEMBER_ID BIGINT,
CITY VARCHAR(255),
STREET VARCHAR(255),
ZIPCODE VARCHAR(255),
PRIMARY KEY (MEMBER_ID, CITY, STREET, ZIPCODE),
FOREIGN KEY (MEMBER_ID) REFERENCES MEMBER(MEMBER_ID)
);
위와 같이 테이블이 생성된다.
MEMBER 테이블에 homeAddress 필드가 포함되는 이유
@Embedded로 매핑된 값 타입은 Member 테이블에 직접 포함된다.
반면@ElementCollection으로 매핑된 컬렉션은 별도의 테이블로 분리된다.
favoriteFoods에 @Embedded를 사용하지 않는 이유
String은 임베디드 타입이 아니라 자바 기본 타입이다.
임베디드 타입은 직접 정의한 복합 값 타입(Address 같은)에만 사용한다.
Member member = new Member();
member.setUsername("member1");
member.setHomeAddress(new Address("city1", "street1", "11111"));
member.getFavoriteFoods().add("치킨");
member.getFavoriteFoods().add("족발");
member.getFavoriteFoods().add("피자");
member.getAddressHistory().add(new Address("old1", "street1", "12345"));
member.getAddressHistory().add(new Address("old2", "street2", "67890"));
em.persist(member);
이렇게 저장하면 다음과 같이 쿼리가 실행된다.
em.persist(member) 한 번으로 모든 데이터가 저장된다.
값 타입 컬렉션을 따로 persist하지 않았는데 자동으로 함께 저장된다.
값 타입 컬렉션은 자체적인 생명주기가 없고 Member에 완전히 의존한다.
Member의 값을 변경하면 자동으로 업데이트 된다.
참고
값 타입 컬렉션은 영속성 전이(Cascade) + 고아 객체 제거 기능을 필수로 가진다고 볼 수 있다.
값 타입 컬렉션은 지연 로딩 전략을 사용한다.
Member findMember = em.find(Member.class, member.getId());
여기까지만 실행하면 SELECT * FROM MEMBER 쿼리만 실행된다.
컬렉션들은 모두 지연 로딩 이다.
List<Address> addressHistory = findMember.getAddressHistory();
for (Address address : addressHistory) {
System.out.println(address.getCity());
}
Set<String> favoriteFoods = findMember.getFavoriteFoods();
for (String favoriteFood : favoriteFoods) {
System.out.println(favoriteFood);
}
이렇게 컬렉션을 실제로 사용하는 시점에 쿼리가 실행된다.
Member findMember = em.find(Member.class, member.getId());
// 잘못된 방법
findMember.getHomeAddress().setCity("newCity"); // 사이드 이펙트 발생 가능
사이드 이펙트
예상치 못한 곳에서 데이터가 변경되는 부작용을 말한다.
공유 참조로 인해 다른 엔티티의 값까지 변경될 수 있다.
// 올바른 방법
Address oldAddress = findMember.getHomeAddress();
findMember.setHomeAddress(new Address("newCity", oldAddress.getStreet(), oldAddress.getZipcode()));
인스턴스 자체를 통째로 교체해야 한다.
값 타입은 추적이 되지 않기 때문에 이런 식으로 완전히 교체해주어야 한다.
// 치킨을 떡볶이로 변경
findMember.getFavoriteFoods().remove("치킨");
findMember.getFavoriteFoods().add("떡볶이");
String 자체가 값 타입이라 수정할 수 없다. 삭제하고 새로 추가해야 한다.
컬렉션의 값만 변경해도 실제 데이터베이스에 쿼리가 자동으로 실행되면서 JPA가 알아서 변경사항을 반영한다.
// old1을 newCity1로 변경
findMember.getAddressHistory().remove(new Address("old1", "street1", "12345"));
findMember.getAddressHistory().add(new Address("newCity1", "street1", "12345"));
remove()를 사용하려면 equals()와 hashCode()가 제대로 구현되어 있어야 한다.
이것이 값 타입에서 두 메서드가 중요한 이유이다.
그런데 실행되는 쿼리를 보면 예상과 다르다.
DELETE FROM ADDRESS WHERE MEMBER_ID = ?
INSERT INTO ADDRESS (MEMBER_ID, CITY, STREET, ZIPCODE) VALUES (?, 'old2', 'street2', '67890')
INSERT INTO ADDRESS (MEMBER_ID, CITY, STREET, ZIPCODE) VALUES (?, 'newCity1', 'street1', '12345')
ADDRESS 테이블의 데이터를 통째로 삭제하고, 남아있어야 할 데이터를 다시 INSERT 한다.
기존에 old1, old2가 있었고 old1만 제거하고 newCity1를 추가했을 뿐인데, 전부 삭제된 후 old2와 newCity1이 다시 INSERT 되었다.
결과적으로는 원하는 대로 동작하지만, 쿼리 실행 방식이 비효율적이다.
실무에서는 상황에 따라 값 타입 컬렉션 대신에 일대다 관계를 고려해야 한다.
일대다 관계를 위한 엔티티를 만들고, 그 안에서 값 타입을 사용하는 방식이다.
영속성 전이(Cascade) + 고아 객체 제거를 사용하면 값 타입 컬렉션처럼 사용할 수 있다.
@Entity
@Table(name = "ADDRESS")
public class AddressEntity {
@Id @GeneratedValue
private Long id;
@Embedded
private Address address;
public AddressEntity() {
}
public AddressEntity(Address address) {
this.address = address;
}
}
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
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<>();
// 값 타입 컬렉션 대신 일대다 관계 사용
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "MEMBER_ID")
private List<AddressEntity> addressHistory = new ArrayList<>();
}
이렇게 매핑하면 값 타입으로 하는 것 보다 훨씬 더 많은 일을 할 수 있고 쿼리 최적화도 잘된다.
실무에서는 이런 방식을 많이 사용한다.
이를 "값 타입을 엔티티로 승급한다"고 표현한다.
일대다 단방향에서 UPDATE 쿼리가 발생하는 이유
일대다 단방향 관계에서는 연관관계의 주인이 일(1) 쪽에 있다.
그런데 외래키는 다(N)쪽 테이블에 있다.
따라서 Member를 저장한 후, AddressEntity의 외래키를 업데이트 하기 위해 추가로 UPDATE 쿼리가 실행된다.
진짜 단순한 값을 사용할 때는 값 타입 컬렉션을 사용해도 된다.
추적도 필요 없고 업데이트할 필요도 없는 경우다.
예를 들어, "좋아하는 음식을 선택하세요" 같은 체크박스에서 한식, 중식, 양식을 선택하는 정도의 중요하지 않은 데이터에 사용하면 된다.
실무 고민
알림 설정에서 문자, 이메일, 메신저 선택 기능이 있다면?
값 타입 컬렉션으로 별도 테이블을 만들어야 할까, 아니면 회원 테이블에notification컬럼을 두고"S,E,M"같은 형태로 문자열로 저장해야 할까?
이는 요구사항에 따라 달라진다. 단순 표시용이라면 문자열로 저장해도 되지만, 각 알림 설정별로 통계를 내거나 쿼리해야 한다면 별도 테이블이 나을 수 있다.
값 타입은 정말 값 타입이라고 판단될 때만 사용해야 한다.
엔티티와 값 타입을 혼동해서 엔티티를 값 타입으로 만들면 안 된다.
식별자가 필요하고, 지속해서 값을 추적하거나 변경해야 한다면 그것은 값 타입이 아닌 엔티티다.