티스토리 뷰
값 타입(value object) 컬렉션
은 제약 사항이 존재해, 실무에서 잘못된 방식으로 사용할 수 있을 것으로 보여 공부한 내용을 샘플 코드와 함께 정리해두려고 한다. 테스트 코드는 쿼리 확인을 위한 용도로 assert 구문은 제외하였다.
1. 개요
값 타입
을 하나 이상 저장하려면 컬렉션에 보관하고 @ElementCollection
, @CollectionTable
어노테이션을 사용하면 된다. 단, 식별자가 없는 값 타입
의 특성 상 제약사항이 존재하는데 이는 뒤에서 자세히 살펴볼 예정이다.
2. 어플리케이션 구성
entity
샘플 코드는 회원 객체가 여러 주소 이력(값 타입 컬렉션
)을 가지고 있는 형태로 되어 있다. 값 타입 컬렉션
을 사용하는 addressHistory에 @ElementCollection
지정한다.
문제는 RDB 테이블은 컬럼 안에 컬렉션을 포함할 수 없으므로 별도의 테이블을 추가하고, @CollectionTable
를 사용해서 추가한 테이블을 매핑해야 한다. → 사용되는 컬럼이 하나면 @Column을 사용해서 컬럼명 지정 가능
@Getter
@Entity
@NoArgsConstructor(access = PROTECTED)
public class Member {
@Id @GeneratedValue
private Long id;
@Embedded
private Address currentAddress;
@ElementCollection
@CollectionTable(name = "ADDRESS_HISTORY",
joinColumns = @JoinColumn(name = "MEMBER_ID"))
private List<Address> addressHistory = new ArrayList<>();
public Member(Address currentAddress) {
this.currentAddress = currentAddress;
}
public void addAddressHistory(Address address) {
this.addressHistory.add(address);
}
}
value object
참고로, 실무에서는 값 타입 컬렉션
을 매핑하는 테이블은 모든 컬럼을 묶어서 기본 키
를 구성해야 한다. 이를 통해, 컬럼에 null을 입력할 수 없고, 같은 값을 중복해서 저장할 수 없는 제약을 걸 수 있다.
@Embeddable
@NoArgsConstructor(access = PROTECTED)
@AllArgsConstructor
@Getter
@EqualsAndHashCode
public class Address {
private String city;
private String street;
private String zipcode;
}
repository
@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
}
3. 엔티티 영속화
테스트 코드를 살펴보면 member 엔티티만 영속화를 하지만, JPA는 member 엔티티의 값 타입
도 함께 저장한다. 값 타입 컬렉션
은 영속성 전이(Cascade
) + 고아 객체 제거(Orphan remove
) 기능을 필수로 가진다고 볼 수 있다.
test
@Test
void persist() {
Member member = new Member(new Address("서울", "여의공원로", "68"));
member.addAddressHistory(new Address("서울", "효자로", "12"));
member.addAddressHistory(new Address("제주", "공항로", "2"));
memberRepository.save(member);
}
ddl
값 타입 컬렉션
을 위한 별도의 테이블(address_history
)을 생성하는 것을 확인할 수 있다.
dml
4. 값 타입 컬렉션 조회
값 타입 컬렉션
조회 시, fetch 기본 전략은 LAZY
이다.
test
@Test
@Transactional
void find() {
Member member = new Member(new Address("서울", "여의공원로", "68"));
member.addAddressHistory(new Address("서울", "효자로", "12"));
member.addAddressHistory(new Address("제주", "공항로", "2"));
memberRepository.saveAndFlush(member);
entityManager.clear();
Optional<Member> foundMember = memberRepository.findById(member.getId());
System.out.println("==============================");
List<Address> addressHistory = foundMember.get().getAddressHistory();
}
dml
값 타입 컬렉션 조회 시, lazy loading
을 한다는 것을 확인할 수 있다.
-- member
select member0_.id as id1_1_0_,
member0_.city as city2_1_0_,
member0_.street as street3_1_0_,
member0_.zipcode as zipcode4_1_0_
from member member0_
where member0_.id=1;
-- address_history
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_history addresshis0_
where addresshis0_.member_id=1;
5. 값 타입 컬렉션 수정
test
값 타입
은 불변해야 하므로, 컬렉션에서 기존 주소를 삭제하고 새로운 주소를 등록했다.
@Test
void update() {
Member member = new Member(new Address("서울", "여의공원로", "68"));
member.addAddressHistory(new Address("서울", "효자로", "12"));
member.addAddressHistory(new Address("제주", "공항로", "2"));
memberRepository.saveAndFlush(member);
System.out.println("==========================");
member.getAddressHistory().remove(new Address("제주", "공항로", "2"));
member.getAddressHistory().add(new Address("판교", "분당내곡로", "131"));
memberRepository.saveAndFlush(member);
}
dml
값 타입 컬렉션의 제약 사항
위의 dml을 살펴보면 알 수 있듯이 값 타입 컬렉션
업데이트 시, 매핑된 테이블의 데이터를 수정하는 것이 아니라 연관된 모든 데이터를 삭제하고(→ member_id가 1인 레코드를 모두 삭제), 현재 값 타입 컬렉션 객체에 있는 모든 값을 DB에 다시 저장한다.
그 이유는 엔티티는 식별자가 있으므로 엔티티의 값을 변경해도 식별자로 DB에 저장된 원본 데이터를 쉽게 찾아서 변경할 수 있지만, 값 타입은 식별자라는 개념이 없기 때문에 원본 데이터를 찾기가 어렵기 때문이다.
따라서 실무에서 값 타입 컬렉션
이 매핑된 테이블에 데이터가 많다면 값 타입 컬렉션
대신에 일대다 관계를 사용한다. 즉, 새로운 엔티티를 만들어 일대다 관계
로 설정하고, 영속성 전이(Cascade
) + 고아 객체 제거(Orphan remove
) 기능을 적용하면 값 타입 컬렉션
처럼 사용할 수 있다.
6. 값 타입 컬렉션 대신 다른 엔티티와 일대다 관계
entity
AddressEntity를 새로 만들고, Member 엔티티는 값 객체(Address)를 의존하는 것이 아니라 AddressEntity와 일대다 관계
가 되도록 엔티티를 수정
@Getter
@Entity
@NoArgsConstructor
public class Member {
@Id @GeneratedValue
private Long id;
@OneToMany(cascade = ALL, orphanRemoval = true)
@JoinColumn(name = "MEMBER_ID")
private List<AddressEntity> addressHistory = new ArrayList<>();
public void addAddressHistory(AddressEntity addressEntity) {
addressHistory.add(addressEntity);
}
public void removeAddressHistory(AddressEntity addressEntity) {
addressHistory.remove(addressEntity);
}
}
@Entity
@Table(name = "ADDRESS")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EqualsAndHashCode(exclude = {"id"})
public class AddressEntity {
@Id @GeneratedValue
private Long id;
@Embedded
private Address address;
public AddressEntity(String city, String street, String zipcode) {
this.address = new Address(city, street, zipcode);
}
}
value object
@Embeddable
@NoArgsConstructor(access = PROTECTED)
@AllArgsConstructor
@Getter
@EqualsAndHashCode
public class Address {
private String city;
private String street;
private String zipcode;
}
test
@Test
void update() {
Member member = new Member();
member.addAddressHistory(new AddressEntity("서울", "효자로", "12"));
member.addAddressHistory(new AddressEntity("제주", "공항로", "2"));
memberRepository.save(member);
System.out.println("========================================");
member.removeAddressHistory(new AddressEntity("제주", "공항로", "2"));
member.addAddressHistory(new AddressEntity("판교", "분당내곡로", "131"));
memberRepository.save(member);
}
ddl
당연한 애기지만, AddressEntity 객체는 엔티티이므로 값 타입 컬렉션
과 달리 매핑한 테이블에 식별자 존재한다.
-- member
create table member (
id bigint not null,
primary key (id)
);
-- address
create table address (
id bigint not null,
city varchar(255),
street varchar(255),
zipcode varchar(255),
member_id bigint,
primary key (id)
);
dml
값 타입
과 달리 AdressEntity
엔티티는 식별자가 있으므로, 매핑된 데이터를 모두 삭제하고 저장하는 것이 아니라 수정 대상이 되는 필드만 추가하고 삭제하는 것을 확인할 수 있다.
샘플 코드 🤓
참고 자료 🙇♂️
'JPA' 카테고리의 다른 글
[JPA] 다대다 연관관계 (0) | 2023.06.18 |
---|