티스토리 뷰

JPA

[JPA] 값 타입(value object) 컬렉션

시리어스강 2023. 6. 6. 15:41

값 타입(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
댓글