Post

(JPA)값 타입 컬렉션

값 타입 컬렉션

값 타입 컬렉션?

  • 값 타입을 하나 이상 저장할 때 사용한다.
  • @ElementCollection, @CollectionTable 어노테이션을 사용
  • 테이터베이스는 컬렉션을 같은 테이블에 저장할 수 없어, 컬렉션 저장을 위한 별도의 테이블이 필요하다.

값 타입 컬렉션 예제

예제 엔티티 생성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Entity
public class Member {
    @Id
    @GeneratedValue
    @Column(name = "member_id")
    private Long id;
    
    @Column(name = "username")
    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> addressHistroy = new ArrayList<>();
}
  • addressHistoryfavoriteFoods 를 값 타입 컬렉션으로 생성
  • @ElementCollection: 값 타입 컬렉션으로 명시
  • @CollectionTable: 테이블 생성 설정
  • favoriteFoods 는 단 하나의 컬럼만 가지기 때문에 예외적으로 @Column 어노테이션을 사용할 수 있다.

값 타입 컬렉션 저장 예제

1
2
3
4
5
6
7
8
9
10
11
12
Member member = new Member();
member.setUsername("member1");
member.setHomeAddress(new Address("homeCity", "street", "12345"));

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

member.getAddressHistroy().add(new Address("old1", "street", "12345"));
member.getAddressHistroy().add(new Address("old2", "street", "12345"));

em.persist(member);
  • 값 타입 저장 예제
  • 값 타입 컬렉션은 따로 persist 할 필요없이, 값 타입을 가지고 있는 엔티티만 persist 하면 자동으로 persist 된다.
    • 값 타입 컬렉션은 영속성 전이(CASCADE)와 고아 객체 제거 기능을 필수로 가지고 있다고 볼 수 있다.

값 타입 컬렉션 조회 예제

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Member member = new Member();
member.setUsername("member1");
member.setHomeAddress(new Address("homeCity", "street", "12345"));

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

member.getAddressHistroy().add(new Address("old1", "street", "12345"));
member.getAddressHistroy().add(new Address("old2", "street", "12345"));

em.persist(member);

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

Member findMember = em.find(Member.class, member.getId());
  • 조회 코드
1
2
3
4
5
6
7
8
9
10
11
Hibernate:
    select
        member0_.member_id,
        member0_.city,
        member0_.street,
        member0_.zipcode,
        member0_.username
    from
        Member member0_
    where
        member0_.member_id=?
  • 조회 후 발생된 쿼리를 살펴보면, Member 만 가지고 오는걸 확인할 수 있다.
  • 위에 발생된 쿼리로 알 수 있는것은 값 타입 컬렉션은 전부 지연 로딩인걸 알 수 있다.
  • 임베디드 타입의 Address 의 경우 지연 로딩으로 얻을 수 있는 이점이 없으므로 즉시 로딩된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Member member = new Member();
member.setUsername("member1");
member.setHomeAddress(new Address("homeCity", "street", "12345"));

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

member.getAddressHistroy().add(new Address("old1", "street", "12345"));
member.getAddressHistroy().add(new Address("old2", "street", "12345"));

em.persist(member);

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

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

List<Address> addressHistroy = findMeber.getAddressHistroy();

for (Address address : addressHistroy) {
    System.out.println("address = " + address.getCity());
}
  • 값 컬렉션 조회 로직 추가
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Hibernate:
    select
        member0_.member_id,
        member0_.city,
        member0_.street,
        member0_.zipcode,
        member0_.username
    from
        Member member0_
    where
        member0_.member_id=?
        
Hibernate:
    select
        addresshis0_.member_id,
        addresshis0_.city,
        addresshis0_.street,
        addresshis0_.zipcode,
    from
        Address addresshis0_
    where
        addresshis0_.member_id=?
  • 조회 후 쿼리를 살펴보면 이제 addressHistory 를 조회하는 것을 확인 할 수 있다.

값 타입 컬렉션 수정 예제

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
Member member = new Member();
member.setUsername("member1");
member.setHomeAddress(new Address("homeCity", "street", "12345"));

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

member.getAddressHistroy().add(new Address("old1", "street", "12345"));
member.getAddressHistroy().add(new Address("old2", "street", "12345"));

em.persist(member);

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

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

/*
값 타입은 불변해야 하기 때문에 이런식의 변경은 side-effect를 발생 시킬 가능성이 있다.
findMember.getHomeAddress().setCity("newCity"); 
*/

// 새로운 인스턴스로 통째로 갈아 끼워야 한다.
Address old = findMember.getHomeAddress();
findMember.setHomeAddress(new Address("newCity", old.getStreet(), old.getZipcode()));

// 치킨 -> 짜장면
findMember.getFavoriteFoods().remove("치킨");
findMember.getFavoriteFoods().add("짜장면");

// equals(), hashCode()가 제대로 구현되어 있어야 한다.
findGetAddressHistroy().remove(new Address("old1", "street", "12345"));
findGetAddressHistroy().add(new Address("newCity1", "street", "12345"));
  • 값 타입안에 특정 값만 변경하는 것이 아니라, 전체 값을 갈아 끼워야 side-effect 로 부터 안전하다.
  • favoriteFoods 값 타입 컬렉션은 변경이 아니라 삭제하고, 다시 저장해줘야 한다.
    • 컬렉션만 변경해도 JPA 알아서 DB 쿼리를 날려 변경시켜 준다.
    • 마치 영속성 전이가 된 것 처럼 동작한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Hibernate:
    delete
    from
        address
    where
        member_id=?
        
Hibernate:
    insert
    into
        address(member_id, city, street, zipcode)
    values
        (?, ?, ?, ?)
        
Hibernate:
    insert
    into
        address(member_id, city, street, zipcode)
    values
        (?, ?, ?, ?)

  • AddressHistory 값 타입 컬렉션을 수정할때 발생된 쿼리이다.
  • 예상하기로는 remove()old1 값을 가진 Addressdelete 되고, newCityinsert 될 줄 알았는데 쿼리를 살펴보니 Address 를 통째로 삭제하고 남아있는 old2newCityinsert 되는 것을 확인할 수 있다.
  • DB를 보면 원했던 대로 이뤄졌으나, 왜 이렇게 동작되는지 의문이 생길 수 있다.

값 타입 컬렉션의 제약 사항

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

값 타입 컬렉션 대안

  • 실무에서는 상황에 따라 값 타입 컬렉션 대신에 일대다 또는 다대일 양방향 관계를 고려해야 한다.
  • 일대다 관계를 위한 Entity 를 만들고, 여기에서 값 타입을 사용한다.
  • 영속성 전이 + 고아 객체 제거를 사용해서 값 타입 컬렉션 처럼 사용한다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    
    @Entity
    @Table(name = address)
    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);   
      }
    }
    
  • 일대다 관계를 위한 별도의 엔티티 생성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Entity
public class Member {
    @Id
    @GeneratedValue
    @Column(name = "member_id")
    private Long id;
    
    @Column(name = "username")
    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> addressHistroy = new ArrayList<>();
    */
    
    @OneToMany(cascae = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "member_id")
    private List<AddressEntity> addressEntity = new ArrayList<>();
}
  • 기본 멤버 Entity 코드 수정
  • 일대다 매핑(1 쪽(Member)에 외래키가 있다.)

정리

  • 값 타입 컬렉션은 정말 단순한 상황에서만 사용하는 것이 좋다.(지속 추적 및 변경이 필요 없어, 식별자가 필요하지 않는 상황)

  • 거의 왠만한건 다 Entity 이다.

  • 엔티티 타입의 특징
    • 식별자가 존재한다.
    • 생명 주기 관리
    • 공유 가능하다.
  • 값 타입의 특징
    • 식별자가 없다.
    • 생명주기를 엔티티에 의존한다.
    • 공유하지 않는것이 안전하다.(불변 객체로 만들고, 복사해서 사용)
  • 엔티티와 값 타입 컬렉션을 혼동해서 사용하면 큰일난다.

REFERENCE


#JPA_값_타입_컬렉션

This post is licensed under CC BY 4.0 by the author.