8 분 소요

📌 Topic

  • 💡 값 타입 컬렉션
  • 💡 실전 예제(실습으로 대체)

⚡ 01. 값 타입 컬렉션

2022_03_09_값타입컬렉션

값 타입 컬렉션이란 값 타입을 컬렉션에 넣어 사용 하는 것을 의미한다. 값 타입 컬렉션을 DB에서 구현하기 위해서는 MEMBER 테이블을 기준으로 별도의 테이블(FAVORITE_FOOD, ADDRESS)로 분리하여 사용을 해야 한다.

✅ 01-1. 값 타입 컬렉션이란?

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

@ElementCollection
@CollectionTable(name = "ADDRESS", joinColumns = @JoinColumn(name = "MEMBER_ID"))
List<Address> addressHistory = new ArrayList<Address>();
  • 값 타입을 하나 이상 저장할 때 사용한다.
  • @ElementCollection, @CollectionTable 사용한다.
  • DB는 컬렉션을 같은 테이블에 저장할 수 없다.
  • 컬렉션을 저장하기 위한 별도의 테이블이 필요하다.
    • 즉, 1 : N로 풀어서 별도의 테이블로 저장해 사용해야 한다.

⚡ 02. 값 타입 컬렉션 사용 해보기

값 타입 컬렉션은 영속성 전이와 고아 객체 제거 기능을 필수로 가진다.

  1. 값 타입 컬렉션 저장 예제
  2. 값 타입 컬렉션 조회 예제
  3. 값 타입 컬렉션 수정 예제

⚡ 03. 값 타입 컬렉션 저장 예제

🍃 03-1. 저장 예제

try {
    MemberTest member = new MemberTest();
    member.setUsername("youngminkim");
    member.setHomeAddress(new Address("homeCity", "street1", "10000"));

    // Set<String> 셋팅
    member.getFavoriteFoods().add("치킨");
    member.getFavoriteFoods().add("피자");
    member.getFavoriteFoods().add("햄버거");

    // List<Address> 셋팅
    member.getAddressHistory().add(new Address("old1", "street1", "14565"));
    member.getAddressHistory().add(new Address("old2", "street1", "14566"));

    em.persist(member);

    tx.commit();
} catch (Exception e) {
    tx.rollback();
    e.printStackTrace();
} finally {
    em.close();
}

1 : N 연관 관계에서 Cascade = ALL, orphanRemoval = true와 비슷하다.
영속성 전이와, 고아 객체는 해당 링크를 참고 해주세요.

우선 흥미로운 부분은 값 타입 컬렉션을 개별적으로 em.persist(member))를 해주지 않아도, 값이 저절로 셋팅 된다는 부분이다. 즉, 다른 테이블인데도 불구하고 member 객체를 셋팅 해주는 것만으로도 추가적으로 생성되는 테이블(ADDRESS, FAVORITE_FOOD)에 데이터가 들어간다는 부분이다.

이러한 부분이 가능한 이유는 '값 타입 컬렉션'도 '값 타입'이기 때문이다. 또한 값 타입은 본인 스스로의 라이프 사이클(Life Cycle)을 제어할 수 없기에 부모 엔티티에 의존한다.

🖨 03-2. 출력 예제

Hibernate:
    call next value for hibernate_sequence
Hibernate:
    /* insert com.hello.jpatest.MemberTest
        */ insert
        into
            MemberTest
            (city, street, ZIPCODE, USERNAME, MEMBER_ID)
        values
            (?, ?, ?, ?, ?)
Hibernate:
    /* insert collection
        row com.hello.jpatest.MemberTest.addressHistory */ insert
        into
            ADDRESS
            (MEMBER_ID, city, street, ZIPCODE)
        values
            (?, ?, ?, ?)
Hibernate:
    /* insert collection
        row com.hello.jpatest.MemberTest.addressHistory */ insert
        into
            ADDRESS
            (MEMBER_ID, city, street, ZIPCODE)
        values
            (?, ?, ?, ?)
Hibernate:
    /* insert collection
        row com.hello.jpatest.MemberTest.favoriteFoods */ insert
        into
            FAVORITE_FOOD
            (MEMBER_ID, FOOD_NAME)
        values
            (?, ?)
Hibernate:
    /* insert collection
        row com.hello.jpatest.MemberTest.favoriteFoods */ insert
        into
            FAVORITE_FOOD
            (MEMBER_ID, FOOD_NAME)
        values
            (?, ?)
Hibernate:
    /* insert collection
        row com.hello.jpatest.MemberTest.favoriteFoods */ insert
        into
            FAVORITE_FOOD
            (MEMBER_ID, FOOD_NAME)
        values
            (?, ?)
3 09, 2022 2:19:43 오후 org.hibernate.engine.jdbc.connections.internal.DriverManagerConnectionProviderImpl$PoolState stop
INFO: HHH10001008: Cleaning up connection pool [jdbc:h2:tcp://localhost/~/jpashop]

Process finished with exit code 0

✅ 03-3. 값 타입 컬렉션 저장 예제 DB

2022_03_09_값타입저장_데이터베이스

⚡ 04. 값 타입 컬렉션 조회 예제

🍃 04-1. 조회 예제

try {
    MemberTest member = new MemberTest();
    member.setUsername("youngminkim");
    member.setHomeAddress(new Address("homeCity", "street1", "10000"));

    // Set<String> : 중복 허용 x, 조회 잦은 경우, O(1)
    member.getFavoriteFoods().add("치킨");
    member.getFavoriteFoods().add("피자");
    member.getFavoriteFoods().add("햄버거");

    member.getAddressHistory().add(new Address("old1", "street1", "14565"));
    member.getAddressHistory().add(new Address("old2", "street1", "14566"));

    em.persist(member);

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

    // ⚡ : Member에 엮인 FAVORITE_FOOD, ADDRESS 테이블의 모든걸 가져오겠지?
    // 💡 : 안 가져온다.. 지연 로딩으로 실제 값이 사용되는 경우 가져온다.
    System.out.println("================================================");
    MemberTest findMember = em.find(MemberTest.class, member.getId());

    tx.commit();
} catch (Exception e) {
    tx.rollback();
    e.printStackTrace();
} finally {
    em.close();
}

em.find를 통해 MemberTest 객체를 조회하는 상황. 주석을 참고하자.

🖨 04-2. 출력 예제

================================================
Hibernate:
    select
        membertest0_.MEMBER_ID as member_i1_12_0_,
        membertest0_.city as city2_12_0_,
        membertest0_.street as street3_12_0_,
        membertest0_.ZIPCODE as zipcode4_12_0_,
        membertest0_.USERNAME as username5_12_0_
    from
        MemberTest membertest0_
    where
        membertest0_.MEMBER_ID=?

위에서 말했다시피 ADDRESS, FAVORITE_FOOD 테이블의 내용은 함께 가져오지 않는다. 지연 로딩으로 설정이 되어 있기 때문에 실제 값이 사용되는 경우 쿼리가 나가게 된다.

✅ 04-3. 값 타입 컬렉션 조회 소스 수정

System.out.println("================================================");
MemberTest findMember = em.find(MemberTest.class, member.getId());

// ⚡ : 추가된 부분
List<Address> addressHistory = findMember.getAddressHistory();
for (Address address : addressHistory) {
    System.out.println("address = " + address.getCity());
}

System.out.println();

// ⚡ : 추가된 부분
Set<String> favoriteFoods = findMember.getFavoriteFoods();
for (String food : favoriteFoods) {
    System.out.println("food = " + food);
}

위 처럼 findMember 참조 변수를 통해 addressHistory, favoriteFoods 데이터를 직접 가져오고 있다. 다음으로 아래 출력 소스를 살펴보자.

✅ 04-4. 값 타입 컬렉션 조회 내용 출력 수정본

================================================
Hibernate:
    select
        membertest0_.MEMBER_ID as member_i1_12_0_,
        membertest0_.city as city2_12_0_,
        membertest0_.street as street3_12_0_,
        membertest0_.ZIPCODE as zipcode4_12_0_,
        membertest0_.USERNAME as username5_12_0_
    from
        MemberTest membertest0_
    where
        membertest0_.MEMBER_ID=?
Hibernate:
    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 addresshis0_
    where
        addresshis0_.MEMBER_ID=?
address = old1
address = old2

Hibernate:
    select
        favoritefo0_.MEMBER_ID as member_i1_7_0_,
        favoritefo0_.FOOD_NAME as food_nam2_7_0_
    from
        FAVORITE_FOOD favoritefo0_
    where
        favoritefo0_.MEMBER_ID=?
food = 치킨
food = 햄버거
food = 피자
3 09, 2022 2:55:14 오후 org.hibernate.engine.jdbc.connections.internal.DriverManagerConnectionProviderImpl$PoolState stop
INFO: HHH10001008: Cleaning up connection pool [jdbc:h2:tcp://localhost/~/jpashop]

Process finished with exit code 0

값 타입 컬렉션은 지연 로딩을 사용하기 때문에 직접 호출을 하지 않으면 값을 가져오지 않는다. 즉, 값 타입 컬렉션을 조회하는 경우에는 직접 호출을 하여 사용하자.

⚡ 05. 값 타입 컬렉션 수정 예제

🍃 05-1. 수정 예제

try {
    MemberTest member = new MemberTest();
    member.setUsername("youngminkim");
    member.setHomeAddress(new Address("homeCity", "street1", "10000"));

    // Set<String> : 중복 허용 x, 조회 잦은 경우, O(1)
    member.getFavoriteFoods().add("치킨");
    member.getFavoriteFoods().add("피자");
    member.getFavoriteFoods().add("햄버거");

    member.getAddressHistory().add(new Address("old1", "street1", "14565"));
    member.getAddressHistory().add(new Address("old2", "street1", "14566"));

    em.persist(member);

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

    System.out.println("================================================");
    MemberTest findMember = em.find(MemberTest.class, member.getId());

    // 💡 : MemberTest가 가지고 있는 Address의 city 값을 변경한다.
    // ❌ : findMember.getHomeAddress().setCity("newCity");

    // ⚡ : 추가된 부분
    Address oldAddress = findMember.getHomeAddress();
    findMember.setHomeAddress(new Address("newCity", oldAddress.getStreet(), oldAddress.getZipcode())); // ⚡ 통으로 갈아 끼워야 한다.

     // 💡 : Collection 내에 존재하는 '치킨'을 '한식'으로 변경한다.
    findMember.getFavoriteFoods().remove("치킨");
    findMember.getFavoriteFoods().add("한식");

    tx.commit();
} catch (Exception e) {
    tx.rollback();
    e.printStackTrace();
} finally {
    em.close();
}
  1. em.find를 통해 특정 ID에 맞는 Member 객체를 획득한다.
  2. Address의 city 값 변경.
    • 이 때 통째로 갈아 끼워 넣어야 한다.
  3. FavoriteFood의 ‘치킨’을 ‘한식’으로 변경한다.
    • 값 타입은 Immutable 해야 하기 때문에 수정이라는 개념이 존재하지 않는다.
    • 값을 삭제한 후에, 다시 넣어주는 방식을 채택해야 한다.

✅ 05-2. 출력 예제

================================================
Hibernate:
    /* update
        com.hello.jpatest.MemberTest */ update
            MemberTest
        set
            city=?,
            street=?,
            ZIPCODE=?,
            USERNAME=?
        where
            MEMBER_ID=?
Hibernate:
    /* delete collection row com.hello.jpatest.MemberTest.favoriteFoods */ delete
        from
            FAVORITE_FOOD
        where
            MEMBER_ID=?
            and FOOD_NAME=?
Hibernate:
    /* insert collection
        row com.hello.jpatest.MemberTest.favoriteFoods */ insert
        into
            FAVORITE_FOOD
            (MEMBER_ID, FOOD_NAME)
        values
            (?, ?)

핵심은 update(ADDRESS)가 한번 나가고, 후에 delete와 insert(FAVORITE_FOOD)가 나갔다는 점이다. 또한 Collection의 값만 바꿔도 JPA가 알아서 변경 사항을 감지한 후에 DB에 쿼리를 날린다는 점.

✅ 05-3. 수정 예제(Address)

위에서는 FavoriteFood를 변경 하였고, 지금은 List< Address >의 내용을 변경한다.

MemberTest findMember = em.find(MemberTest.class, member.getId());

// ⭐ : 주소 'old1'을 삭제 후 변경 한다.
findMember.getAddressHistory().remove(new Address("old1", "street1", "14565"));
findMember.getAddressHistory().add(new Address("newCity1", "street1", "14565"));
  1. 위에서 말했다시피 값 타입의 경우 Immutable한 객체이기 때문에 삭제 필요.
  2. 후에 수정을 원하는 데이터를 객체에 다시 한번 넣어서 수정해야 한다.

✅ 05-4. 출력 예졔(Address)

Hibernate:
    /* delete collection com.hello.jpatest.MemberTest.addressHistory */ delete
        from
            ADDRESS
        where
            MEMBER_ID=?
Hibernate:
    /* insert collection
        row com.hello.jpatest.MemberTest.addressHistory */ insert
        into
            ADDRESS
            (MEMBER_ID, city, street, ZIPCODE)
        values
            (?, ?, ?, ?)
Hibernate:
    /* insert collection
        row com.hello.jpatest.MemberTest.addressHistory */ insert
        into
            ADDRESS
            (MEMBER_ID, city, street, ZIPCODE)
        values
            (?, ?, ?, ?)

  1. 이상한 부분이 존재한다. 분명히 add는 한번 했는데, insert 두 번 나간다.
  2. ADDRESS 테이블의 데이터를 싹 밀어버린다.
  3. 수정을 원한 데이터와 기존에 있던 데이터를 두 번 INSERT 한다.

⚡ 06. 값 타입 컬렉션의 제약사항

@Target( { METHOD, FIELD })
@Retention(RUNTIME)
public @interface ElementCollection {

    /**
     * (Optional) The basic or embeddable class that is the element
     * type of the collection.  This element is optional only if the
     * collection field or property is defined using Java generics,
     * and must be specified otherwise.  It defaults to the
     * paramterized type of the collection when defined using
     * generics.
     */
    Class targetClass() default void.class;

    /**
     *  (Optional) Whether the collection should be lazily loaded or must be
     *  eagerly fetched.  The EAGER strategy is a requirement on
     *  the persistence provider runtime that the collection elements
     *  must be eagerly fetched.  The LAZY strategy is a hint to the
     *  persistence provider runtime.
     */
    FetchType fetch() default LAZY;
}
  • @ElementCollection은 기본이 지연 로딩으로 설정됨.
@OrderColumn(name = "address_history_order") // 해당 어노테이션으로 어떻게 해결은 가능하다, 하지만 엄청 위험하다
@ElementCollection // 값 타입 컬렉션 지정
@CollectionTable(name = "ADDRESS", joinColumns  = @JoinColumn(name = "MEMBER_ID"))
List<Address> addressHistory = new List<Address>();

값 타입 컬렉션에 변경이 일어나면 연관된 모든 데이터를 삭제 후 다시 저장한다.

값 타입(컬렉션)은 엔티티와 다르게 식별자가 없으며, 값을 변경하면 추적이 상당히 어려워진다. 값 타입 컬렉션에 변경 사항이 발생하면, 주인 엔티티와 연관된 모든 데이터를 삭제하고, 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장한다.

-- MEMBE_ID, city, street, zipcode를 하나의 그룹(기본키)로 만들어서 사용해야 한다.
create table ADDRESS (
    MEMBER_ID bigint not null,
    city varchar(255),
    street varchar(255),
    zipcode varchar(255),
)

또한 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본키를 구성해야 한다. NULL 입력은 허용하지 않고 중복 저장 역시 허용하지 않는다.

⚡ 07. 값 타입 컬렉션 대안

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

✅ 07-1. 값 타입 컬렉션 대안 예제

AddressEntity를 생성하여 Address(임베디드 타입)을 핸들링 한다.

@Entity
@Table(name = "ADDRESS")
public class AddressEntity {

    @Id
    @GeneratedValue
    private Long id;

    @Embedded
    private Address address;

    public AddressEntity() {
    }

    public AddressEntity(String city, String street, String zipcode) {
        this.address = new Address(city, street, zipcode); // embedded 타입 초기화
    }
    //..Getter, Setter 중략
}

MemberTest 변경

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

호출부 변경

 try {
    MemberTest member = new MemberTest();
    member.setUsername("youngminkim");
    member.setHomeAddress(new Address("homeCity", "street1", "10000")); // embedded type

    // Set<String> favoriteFoods
    member.getFavoriteFoods().add("치킨");
    member.getFavoriteFoods().add("피자");
    member.getFavoriteFoods().add("햄버거");

    // ⚡ : 이 부분이 변경 됨
    member.getAddressHistory().add(new AddressEntity("old1", "street1", "14565"));
    member.getAddressHistory().add(new AddressEntity("old2", "street1", "14566"));

    em.persist(member);

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

    System.out.println("=======================START=====================");
    MemberTest findMember = em.find(MemberTest.class, member.getId());

    // 💡 : MemberTest가 가지고 있는 homeAddress 객체의 city 값을 변경한다.
    // ❌ : findMember.getHomeAddress().setCity("newCity");

    // Address oldAddress = findMember.getHomeAddress();
    // findMember.setHomeAddress(new Address("newCity", oldAddress.getStreet(), oldAddress.getZipcode())); // ⚡ 통으로 갈아 끼워야 한다.

    // 💡 : Collection 내에 존재하는 '치킨'을 '한식'으로 변경한다.
    // findMember.getFavoriteFoods().remove("치킨");
    // findMember.getFavoriteFoods().add("한식");

    // ⭐ : 주소 'old1'을 삭제 후 변경 한다.
    // findMember.getAddressHistory().remove(new Address("old1", "street1", "14565"));
    // findMember.getAddressHistory().add(new Address("newCity1", "street1", "14565"));

    tx.commit();
} catch (Exception e) {
    tx.rollback();
    e.printStackTrace();
} finally {
    em.close();
}
  • 기존에는 Address 타입을 넣어서 사용하였다.
  • 현재는 AddressEntity를 제네릭 타입으로 갖는다

💡 08. 값 타입 컬렉션을 언제 쓰는가?

  1. 추적할 필요도 없고 값이 바뀌어도 상관이 없는 경우.
    • 그렇지 않은 경우에는 엔티티로 묶어서 사용을 하는것을 권장.
  2. ex) 다중 선택 콤보 박스.

✍ 09. 정리

  • 엔티티 타입의 특징
    • 식별자 존재
    • 생명 주기 관리
    • 공유
  • 값 타입의 특징
    • 식별자 없음
    • 생명 주기를 엔티티에 의존
    • 공유하지 않는 것이 안전(복사해서 사용)
    • 불변 객체로 만드는 것이 안전
  • 값 타입은 정말 값 타입이라 판단될 때만 사용해야 한다.
  • 엔티티와 값 타입을 혼동해서 엔티티를 값 타입으로 만들면 안된다.

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

참고 자료

댓글남기기