Posts [자바 ORM 표준 JPA 프로그래밍-기본편] 값 타입 컬렉션
Post
Cancel

[자바 ORM 표준 JPA 프로그래밍-기본편] 값 타입 컬렉션

본 포스팅은 인프러의 JPA 기본편을 수강하고 정리하는 내용입니다.


값 타입 컬렉션은 값 타입을 컬렉션에 담아서 쓰는 것을 말한다. 문제는 DB에 넣을때가 문제가 된다. 기본적으로 값 타입 컬렉션을 DB에 넣는 구조를 지원하지 않는다. 결론적으로 이러한 컬렉션들을 별도의 테이블로 뽑아야한다. (FAVORITE_FOOD 테이블, ADDRESS 테이블)

image

  • 값 타입을 하나 이상 저장할 때 사용
  • @ElementCollection, @CollectionTable 사용
  • 데이터베이스는 컬렉션을 같은 테이블에 저장할 수 없다.
    • DB안에 한 테이블에 컬렉션을 넣을 수 있는 방법은 없다.
  • 일대다로 풀어서 컬렉션을 저장하기 위한 별도의 테이블이 필요함
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
@Entity
public class Member {

    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    @Column(name = "USERNAME")
    private String username;

    // 기간 Period
    @Embedded
    private Period workPeriod;

    // 주소
    @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> addressHistory = new ArrayList<>();

    ...
}

값 타입 컬렉션 사용

1) 값 타입 저장 예제

1
2
3
4
5
6
7
8
9
10
11
12
13
Member member = new Member();
member.setUsername("member1");
member.setHomeAddress(new Address("homeCity", "street", "1000"));
member.getFavoriteFoods().add("치킨");
member.getFavoriteFoods().add("피자");
member.getFavoriteFoods().add("족발");

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

em.persist(member);

tx.commit();

2) 값 타입 조회 예제

  • 값 타입 컬렉션도 지연 로딩 전략 사용
  • 엔티티 테이블만 가져오고 값 타입 컬렉션은 프록시로 가져온다.
  • 나중에 실제 값 타입 객체(FAVORITE_FOOD 테이블, ADDRESS 가 호출될때 select 쿼리를 호출한다)
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
Member member = new Member();
member.setUsername("member1");
member.setHomeAddress(new Address("homeCity", "street", "1000"));
member.getFavoriteFoods().add("치킨");
member.getFavoriteFoods().add("피자");
member.getFavoriteFoods().add("족발");

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

em.persist(member);

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

System.out.println("===== START =====");
Member findMember = em.find(Member.class, member.getId());
List<Address> addressHistory = findMember.getAddressHistory();
for(Address address : addressHistory) {
   System.out.println("address = " + address.getCity());
}

Set<String> favoriteFoods = findMember.getFavoriteFoods();
for (String favoriteFood : favoriteFoods) {
   System.out.println("favoriteFood = " + favoriteFood);
}

tx.commit();

3) 값 타입 수정 예제

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
Member member = new Member();
member.setUsername("member1");
member.setHomeAddress(new Address("homeCity", "street", "1000"));
member.getFavoriteFoods().add("치킨");
member.getFavoriteFoods().add("피자");
member.getFavoriteFoods().add("족발");

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

em.persist(member);

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

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

// homeCity => newCity
Address a = findMember.getHomeAddress();
findMember.setHomeAddress(new Address("newCity", a.getStreet(), a.getZipcode()));

// 치킨 -> 한식 (String 자체가 값 타입이기에 제거하고 새로 추가해줘야함, 컬렉션의 값만 변경해도 JPA가 알아서 바꿔준다.)
findMember.getFavoriteFoods().remove("치킨");
findMember.getFavoriteFoods().add("한식");

// MEMBER_ID에 해당하는 값들을 전부 삭제하고 기존의 old2와 newCity1 두 개를 새로 insert수행한다.
// 마치 cascade에 고아 객체 제거 기능이 포함된 것처럼 동작한다.
findMember.getAddressHistory().remove(new Address("old1", "street", "1000")); // 내부에서 equals() 로 값을 찾아 삭제
findMember.getAddressHistory().add(new Address("newCity1", "street", "1000"));

tx.commit();
  • 참고: 값 타입 컬렉션은 영속성 전에(Cascade) + 고아 객체 제거 기능을 필수로 가진다고 볼 수 있다.
  • 값 타입은 별도의 라이프 사이클을 가지지 않고 엔티티에 의존한다. 엔티티의 값을 바뀌거나 하면 자동으로 업데이트 된다. (일대다 연관관계에서 cascade all 넣고 orphan true로 넣은것이랑 같다고 보면 된다)

값 타입 컬렉션의 제약사항

  • 값 타입은 엔티티와 다르게 식별자 개념이 없다.
  • 값은 변경하면 추적이 어렵다.
  • 값 타입 컬렉션에 변경 사항이 발생하면, 주인 엔티티와 연관된 모든 데이터를 삭제하고, 값 타입 컬렉션에 있는 현재 값을 모두 다시 저장한다.
    • 값 타입 컬렉션에서 하나를 지우고 새로 하나를 추가한다면 기존에 있는 것들을 전부 삭제 기존의 남은 것들을 전부 insert쿼리로 추가한다.
    • @OrderColumn(name = "address_history_order")을 사용하여 컬렉션 순서 값이 들어가서 해결할 수 있긴하다. 하지만 이것도 엄청 위험하다. 원하는대로 의도하지 동작하는 것도 되게 많고 컬렉션이 0, 1, 2, 3인데 중간에 2번을 빼먹으면 0, 1하고 2번이 null로 들어오기도 한다.
      1
      2
      3
      4
      5
      6
      
      @OrderColumn(name = "address_history_order")
      @ElementCollection
      @CollectionTable(name = "ADDRESS", joinColumns =
          @JoinColumn(name = "MEMBER_ID")
      )
      private List<Address> addressHistory = new ArrayList<>();
      
    • 결론적으로 이렇게 복잡하게 쓸거면 완전히 다르게 풀어야한다.
  • 값 타입 컬렉션을 매핑하는 테이블은 모든 컬럼을 묶어서 기본키를 구성해야 함: null 입력X, 중복 저장X

값 타입 컬렉션 대안

  • 실무에서는 상황에 따라 값 타입 컬렉션 대신에 일대다 관계를 고려
    • 값 타입을 엔티티로 승격한다
  • 일대다 관계를 위한 엔티티를 만들고, 여기에서 값 타입을 사용
  • 영속성 전이(Cascade) + 고아 객체 제거를 사용해서 값 타입 컬렉션 처럼 사용
  • EX) AddressEntity
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
@Embeddable
public class Address {
    private String city;
    private String street;
    private String zipcode;

    public Address() {
    }

    public Address(String city, String street, String zipcode) {
        this.city = city;
        this.street = street;
        this.zipcode = zipcode;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Address address = (Address) o;
        return Objects.equals(city, address.city) &&
                Objects.equals(street, address.street) &&
                Objects.equals(zipcode, address.zipcode);
    }

    @Override
    public int hashCode() {
        return Objects.hash(city, street, zipcode);
    }

    ...
}

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

    @Id
    @GeneratedValue
    private Long id;

    private Address address;

    public AddressEntity() {

    }

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

    ...
}

@Entity
public class Member {

    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    @Column(name = "USERNAME")
    private String username;

    // 기간 Period
    @Embedded
    private Period workPeriod;

    // 주소
    @Embedded
    private Address homeAddress;

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

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

public class JpaMain {
    public static void main(String[] args) {
        ...

        try {

            Member member = new Member();
            member.setUsername("member1");
            member.setHomeAddress(new Address("homeCity", "street", "1000"));
            member.getFavoriteFoods().add("치킨");
            member.getFavoriteFoods().add("피자");
            member.getFavoriteFoods().add("족발");

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

            em.persist(member);

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

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

            // MEMBER_ID에 해당하는 값들을 전부 삭제하고 기존의 old2와 newCity1 두 개를 새로 insert수행한다.
            // 마치 cascade에 고아 객체 제거 기능이 포함된 것처럼 동작한다.
            findMember.getAddressHistory().remove(new AddressEntity("old1", "street", "1000")); // 내부에서 equals() 로 값을 찾아 삭제
            findMember.getAddressHistory().add(new AddressEntity("newCity1", "street", "1000"));

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

        emf.close();
    }

}

image

값 타입 컬렉션은 언제쓰는가?

예를 들어, 셀렉트 박스에 치킨이나 피자를 멀티로(다중) 체크할 수 있다. 이렇게 추적할 필요가 없고 값이 바껴도 업데이트칠 필요가 없을때 값 타입컬렉션을 쓴다.

그게 아닌 이상은 왠만하면 엔티티다. 주소 이력도 엔티티다. 주소 이력만 조회할때 다 엔티티로 해야한다. 값을 변경하지 않는다 해도 DB쿼리 자체를 그쪽에서 시작해서 가져와야 하는 것들은 다 엔티티로 보면된다. 주소가 다 사라져도 이력이 남아야할때가 있을텐데 그럴때도 엔티티를 써야한다. 물론 정말 단순한건 값 타입을 써도 된다.

정리

  • 엔티티 타입의 특징
    • 식별자O
    • 생명 주기 관리
    • 공유
  • 값 타입의 특징
    • 식별자X
    • 생명 주기를 엔티티에 의존
    • 공유하지 않는 것이 안전(복사해서 사용)
    • 불변 객체로 만드는 것이 안전

값 타입은 정말 값 타입이라 판단될 때만 사용해야 한다.

엔티티와 값 타입을 혼동해서 엔티티를 값 타입으로 만들면 안된다. 식별자가 필요하고, 지속해서 값을 추적, 변경해야 한다면 그것은 값 타입이 아닌 엔티티다.

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

[자바 ORM 표준 JPA 프로그래밍-기본편] 값 타입의 비교

[자바 ORM 표준 JPA 프로그래밍-기본편] 실전 예제6 - 값 타입 매핑