본문 바로가기
자바/JPA

[JPA/Java] 값 타입 3 (값 타입 컬렉션)

by drCode 2023. 3. 13.
728x90
반응형

값 타입 컬렉션은 하나 이상의 값을 넣을 때 사용한다.

 

기존에 있었던 테이블 Member에 Address가 있는데,  주소에 대한 이력관리와 좋아하는 음식 정보를 넣고 싶다는 가정을 하면,

기존에 있었던 Address를 사용하여 주소 이력을 관리하기에는 한계가 있다.

 

또 좋아하는 음식이 여러 개라면? 

 

여러 가지 갚을 넣어야 할 때 컬렉션을 이용해야 한다.

 

대략적인 설계는 아래의 그림과 같다.

 

Member안에 favoriteFoods, addressHistory를 가지고 있다

 

그래서 컬렉션 프레임워크인 Set, List를 사용하였다,

 

값 타입에 컬렉션을 사용하려면..

  • @ElementCollection, @CollectionTable을 사용하여야 한다.
  • 데이터베이스는 컬렉션을 Member 테이블에 컬럼을 종속시켜 같은 테이블에 저장할 수 없다.
  • 그래서 컬렉션을 저장하기 위한 별도의 테이블이 필요하다.

Member.java 에 다음을 추가한다.

@ElementCollection
@CollectionTable(name = "FAVORITE_FOOD"
                ,joinColumns = @JoinColumn(name = "MEMBER_ID")
                )
@Column(name = "FOOD_NAME")
private Set<String> favoriteFoods = new HashSet<>();

public Set<String> getFavoriteFoods() {
    return favoriteFoods;
}

public void setFavoriteFoods(Set<String> favoriteFoods) {
    this.favoriteFoods = favoriteFoods;
}

@ElementCollection
@CollectionTable(name = "ADDRESS"
                ,joinColumns = @JoinColumn(name = "MEMBER_ID")
                )
private List<Address> addressHistory = new ArrayList<>();

public List<Address> getAddressHistory() {
    return addressHistory;
}

public void setAddressHistory(List<Address> addressHistory) {
    this.addressHistory = addressHistory;
}

 

위의 코드를 실행하기 위해, 테스트 코드를 작성해보자.

 

JpaMain.java

Member member = new Member();
member.setUserName("member1");
member.setHomeAddress(new Address("HomeCity1", "street", "10000"));

member.getFavoriteFoods().add("치킨");
member.getFavoriteFoods().add("족발");
member.getFavoriteFoods().add("피자");

member.getAddressHistory().add(new Address("old1", "street", "10000"));
member.getAddressHistory().add(new Address("old2", "street", "10000"));

em.persist(member);

 

위 코드를 실행한 결과, 아래와 같다.

 

Hibernate: 
    call next value for hibernate_sequence
Hibernate: 
    /* insert hellojpa.Member
        */ insert 
        into
            Member
            (INSERT_MEMBER, createdDate, UPDATE_MEMBER, lastModifiedDate, city, street, zipCode, TEAM_ID, USERNAME, WORK_CITY, WORK_STREET, WORK_ZIP_CODE, endDate, startDate, MEMBER_ID) 
        values
            (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
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
            (?, ?, ?, ?)
Hibernate: 
    /* insert collection
        row hellojpa.Member.favoriteFoods */ insert 
        into
            FAVORITE_FOOD
            (MEMBER_ID, FOOD_NAME) 
        values
            (?, ?)
Hibernate: 
    /* insert collection
        row hellojpa.Member.favoriteFoods */ insert 
        into
            FAVORITE_FOOD
            (MEMBER_ID, FOOD_NAME) 
        values
            (?, ?)
Hibernate: 
    /* insert collection
        row hellojpa.Member.favoriteFoods */ insert 
        into
            FAVORITE_FOOD
            (MEMBER_ID, FOOD_NAME) 
        values
            (?, ?)

한 개의 Member, 두 개의 주소(old1, old2), 세 개의 선호 음식(치킨, 피자, 족발)이 들어갔다.

 

실제 DB를 조회해보면 위처럼 데이터가 알맞게 들어간 것을 확인할 수 있다,

근데, 여기서 거주 도시를 HomeCity1 에서 newCity로 바꾸려고 한다고 가정할 때,

 

우선 객체를 불러오는데, em.find 로 불러올 때, 프록시 객체로 불러온 후, 

 

프록시 객체의 값을 사용하려고 할 때 실제 DB를 조회해서 사용하게 된다.

 

JpaMain.java

em.flush();
em.clear();

System.out.println("========================== START====================");
Member findMember = em.find(Member.class, member.getId());
// 지연로딩이다. 종속된 데이터들은 프록시로 불러와진다.

System.out.println("===================================================");
System.out.println("findMember.city = " + findMember.getHomeAddress().getCity());
System.out.println("===================================================");
========================== START====================
Hibernate: 
    select
        member0_.MEMBER_ID as MEMBER_I1_8_0_,
        member0_.INSERT_MEMBER as INSERT_M2_8_0_,
        member0_.createdDate as createdD3_8_0_,
        member0_.UPDATE_MEMBER as UPDATE_M4_8_0_,
        member0_.lastModifiedDate as lastModi5_8_0_,
        member0_.city as city6_8_0_,
        member0_.street as street7_8_0_,
        member0_.zipCode as zipCode8_8_0_,
        member0_.TEAM_ID as TEAM_ID15_8_0_,
        member0_.USERNAME as USERNAME9_8_0_,
        member0_.WORK_CITY as WORK_CI10_8_0_,
        member0_.WORK_STREET as WORK_ST11_8_0_,
        member0_.WORK_ZIP_CODE as WORK_ZI12_8_0_,
        member0_.endDate as endDate13_8_0_,
        member0_.startDate as startDa14_8_0_,
        team1_.TEAM_ID as TEAM_ID1_14_1_,
        team1_.INSERT_MEMBER as INSERT_M2_14_1_,
        team1_.createdDate as createdD3_14_1_,
        team1_.UPDATE_MEMBER as UPDATE_M4_14_1_,
        team1_.lastModifiedDate as lastModi5_14_1_,
        team1_.USERNAME as USERNAME6_14_1_ 
    from
        Member member0_ 
    left outer join
        Team team1_ 
            on member0_.TEAM_ID=team1_.TEAM_ID 
    where
        member0_.MEMBER_ID=?
===================================================
findMember.city = HomeCity1
===================================================
Hibernate: 
    select
        favoritefo0_.MEMBER_ID as MEMBER_I1_5_0_,
        favoritefo0_.FOOD_NAME as FOOD_NAM2_5_0_ 
    from
        FAVORITE_FOOD favoritefo0_ 
    where
        favoritefo0_.MEMBER_ID=?

 

그런 다음, 값을 바꾸려고 할 때, 

 

findMember.getHomeAddress().setCity("새로 바꿀 도시");

이렇게 바꾸게 된다면, 값 참조로 인해서 모든 도시 데이터가 위에 작성한 내용대로 바뀌게 될 것이다.

 

따라서 아래와 같이 바꿔줘야 한다

Address oldAddr = findMember.getHomeAddress();
// 이렇게 교체를 해줘야함
findMember.setHomeAddress(new Address("newCity", oldAddr.getStreet(), oldAddr.getZipCode()));

새로운 생성자를 생성해서 값을 넣어주면 값 참조가 발생하지 않는다.

Hibernate: 
    /* update
        hellojpa.Member */ update
            Member 
        set
            INSERT_MEMBER=?,
            createdDate=?,
            UPDATE_MEMBER=?,
            lastModifiedDate=?,
            city=?,
            street=?,
            zipCode=?,
            TEAM_ID=?,
            USERNAME=?,
            WORK_CITY=?,
            WORK_STREET=?,
            WORK_ZIP_CODE=?,
            endDate=?,
            startDate=? 
        where
            MEMBER_ID=?

 

만약 선호하는 음식을 바꾸고 싶을 때, List에서 해당 데이터를 제거하고, 새로 add를 해줘야한다.

// 치킨 → 한식
findMember.getFavoriteFoods().remove("치킨");
findMember.getFavoriteFoods().add("한식");
Hibernate: 
    /* delete collection row hellojpa.Member.favoriteFoods */ delete 
        from
            FAVORITE_FOOD 
        where
            MEMBER_ID=? 
            and FOOD_NAME=?
Hibernate: 
    /* insert collection
        row hellojpa.Member.favoriteFoods */ insert 
        into
            FAVORITE_FOOD
            (MEMBER_ID, FOOD_NAME) 
        values
            (?, ?)

 

주소 이력을 바꾸려면 위에서 음식을 바꾼 것처럼 동일한 방법으로 하면 된다.

// 주소 이력 중에 하나를 교체하려면 아래와 같이 해야 한다.
findMember.getAddressHistory().remove(new Address("old1", "street", "10000"));
findMember.getAddressHistory().add(new Address("newCity1", "street", "10000"));

 

그렇지 않으면 객체는 주소를 참조하고 있기 때문에, 위처럼 번거럽더라도 생성자를 선언해줘야한다.

 

값 타입 컬렉션의 제약사항이 있다.

  • 값 타입은 엔티티와 다르게 식별자 개념이 없다.
  • 값은 변경하면 추적이 어렵다.
  • 값 타입 컬렉션에 변경 사항이 발생하면, 주인 엔티티와 연관된 모든 데이터를 삭제하고, 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장한다.
  • 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본 키를 구성해야 함 : null 입력 X, 중복 저장 X

값 타입 컬렉션 대안

  • 실무에서는 상황에 따라 값 타입 컬렉션 대신에 일대다 관계를 고려해야한다.
  • 일대다 관계를 위한 엔티티를 만들고, 여기에서 값 타입을 사용한다.
  • 영속성 전이(Cascade) + 고아 객체 제거를 사용해서 값 타입 컬렉션처럼 사용한다.
  • ex) AddressEntity

 

AddressEntity.java

package hellojpa;

import javax.persistence.*;

@Entity
@Table(name = "ADDRESS")
public class AddressEntity {
    @Id @GeneratedValue
    private Long id;

    public AddressEntity() {
    }

    public AddressEntity(String city, String street, String zipcde) {
        Address address = new Address(city, street, zipcde);
        this.address = address;
    }

    @Embedded
    private Address address;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public Address getAddress() {
        return address;
    }

    public void setAddress(Address address) {
        this.address = address;
    }
    
    @Override
    public String toString() {
        return "AddressEntity{" +
                "id=" + id +
                ", address=" + address +
                '}';
    }

    @Override
    public boolean equals(Object obj) {
        if(this == obj) return true;
        if(obj == null || getClass() != obj.getClass()) return false;
        AddressEntity ae = (AddressEntity) obj;
        return Objects.equals(ae.address.getCity(), address.getCity())
                && Objects.equals(ae.address.getStreet(), address.getStreet())
                && Objects.equals(ae.address.getZipCode(), address.getZipCode());
    }
}

 

equals 를 꼭 재정의해줘야 컬렉션에서 객체를 찾을 수 있다.

equals 재정의를 빠뜨리면 특정 객체를 찾아서 제거하기가 어렵다.

equals 재정의 없이 remove, contains 를 하면 false를 리턴하니 꼭 재정의 해야한다.

 

Member.java

@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "MEMBER_ID")
private List<AddressEntity> addressHistory = new ArrayList<>();

public List<AddressEntity> getAddressHistory() {
    return addressHistory;
}

public void setAddressHistory(List<AddressEntity> addressHistory) {
    this.addressHistory = addressHistory;
}

기존에 있었던 List<Address> addressHistory는 주석처리하여 관리한다.

 

JpaMain.java

Member member = new Member();
member.setUserName("member1");
member.setHomeAddress(new Address("HomeCity1", "street", "10000"));

member.getFavoriteFoods().add("치킨");
member.getFavoriteFoods().add("족발");
member.getFavoriteFoods().add("피자");

member.getAddressHistory().add(new AddressEntity("old1", "street", "10000"));
member.getAddressHistory().add(new AddressEntity("old2", "street", "10000"));

em.persist(member);

em.flush();
em.clear();

System.out.println("========================== START====================");
Member findMember = em.find(Member.class, member.getId());
// 지연로딩이다. 종속된 데이터들은 프록시로 불러와진다.

System.out.println("===================================================");
System.out.println("findMember.city = " + findMember.getHomeAddress().getCity());
System.out.println("===================================================");

// homeCity → newCity
// findMember.getHomeAddress().setCity("이러면 객체 변수 참조로 일괄 변경된다.");
Address oldAddr = findMember.getHomeAddress();
// 이렇게 교체를 해줘야함
findMember.setHomeAddress(new Address("newCity", oldAddr.getStreet(), oldAddr.getZipCode()));

// 치킨 → 한식
findMember.getFavoriteFoods().remove("치킨");
findMember.getFavoriteFoods().add("한식");
// String은 이렇게 해줘야 한다.

// 주소 이력 중에 하나를 교체하려면 아래와 같이 해야 한다.
findMember.getAddressHistory().remove(new AddressEntity("old1", "street", "10000"));
findMember.getAddressHistory().add(new AddressEntity("newCity1", "street", "10000"));

 

List 타입 Address 대신 AddressEntity 타입을 사용하면 이와 같다.

 

다른 테이블에 일대다 연관관계가 들어가면 아래와 같이 update 쿼리가 어쩔 수 없이 실행되게 된다.

Hibernate: 
    /* create one-to-many row hellojpa.Member.addressHistory */ update
        ADDRESS 
    set
        MEMBER_ID=? 
    where
        id=?
Hibernate: 
    /* create one-to-many row hellojpa.Member.addressHistory */ update
        ADDRESS 
    set
        MEMBER_ID=? 
    where
        id=?

 

순서가 뒤죽박죽이긴 하지만, 실행결과를 보면 제대로 모든 코드가 실행됐음을 확인할 수 있다.

Hibernate: 
    select
        favoritefo0_.MEMBER_ID as MEMBER_I1_4_0_,
        favoritefo0_.FOOD_NAME as FOOD_NAM2_4_0_ 
    from
        FAVORITE_FOOD favoritefo0_ 
    where
        favoritefo0_.MEMBER_ID=?
Hibernate: 
    select
        addresshis0_.MEMBER_ID as MEMBER_I5_0_0_,
        addresshis0_.id as id1_0_0_,
        addresshis0_.id as id1_0_1_,
        addresshis0_.city as city2_0_1_,
        addresshis0_.street as street3_0_1_,
        addresshis0_.zipCode as zipCode4_0_1_ 
    from
        ADDRESS addresshis0_ 
    where
        addresshis0_.MEMBER_ID=?
Hibernate: 
    call next value for hibernate_sequence
Hibernate: 
    /* insert hellojpa.AddressEntity
        */ insert 
        into
            ADDRESS
            (city, street, zipCode, id) 
        values
            (?, ?, ?, ?)
Hibernate: 
    /* update
        hellojpa.Member */ update
            Member 
        set
            INSERT_MEMBER=?,
            createdDate=?,
            UPDATE_MEMBER=?,
            lastModifiedDate=?,
            city=?,
            street=?,
            zipCode=?,
            TEAM_ID=?,
            USERNAME=?,
            WORK_CITY=?,
            WORK_STREET=?,
            WORK_ZIP_CODE=?,
            endDate=?,
            startDate=? 
        where
            MEMBER_ID=?
Hibernate: 
    /* delete one-to-many row hellojpa.Member.addressHistory */ update
            ADDRESS 
        set
            MEMBER_ID=null 
        where
            MEMBER_ID=? 
            and id=?
Hibernate: 
    /* create one-to-many row hellojpa.Member.addressHistory */ update
        ADDRESS 
    set
        MEMBER_ID=? 
    where
        id=?
Hibernate: 
    /* delete collection row hellojpa.Member.favoriteFoods */ delete 
        from
            FAVORITE_FOOD 
        where
            MEMBER_ID=? 
            and FOOD_NAME=?
Hibernate: 
    /* insert collection
        row hellojpa.Member.favoriteFoods */ insert 
        into
            FAVORITE_FOOD
            (MEMBER_ID, FOOD_NAME) 
        values
            (?, ?)
Hibernate: 
    /* delete hellojpa.AddressEntity */ delete 
        from
            ADDRESS 
        where
            id=?

 

쿼리 실행 결과

 

 

이번 예제로 많은 내용을 알 수 있었다.

 

그 중에 정리하면,

엔티티 타입의 특징은

  • 식별자가 있고,
  • 생명주기를 관리하고
  • 공유가 가능하다.

 

값 타입의 특징은

  • 식별자가 없고,
  • 생명주기를 엔티티에 의존하고
  • 복사해서 사용해야 공유되지 않고 안전하게 사용한다.
  • 불변 객체로 만드는 것이 안전하다.

값 타입은 정말 값 타입이라 판단될 때만 사용하는 것이 좋다.

 

엔티티와 값 타입을 혼동해서 엔티티를 값 타입으로 만들면 안된다.

 

식별자가 필요하고, 지속해서 값을 추적, 변경해야 한다면 그것은 값 타입이 아닌 엔티티이다.

728x90
반응형

댓글