JPA 프로그래밍 입문 - chapter 09. 값 컬렉션 매핑

이 글은 “JPA 프로그래밍 입문 (최범균 저)” 책 내용을 정리한 글입니다.

만약 저작권 관련 문제가 있다면 “gunjuko92@gmail.com”로 메일을 보내주시면, 바로 삭제하도록 하겠습니다.

1. 값 콜렉션

  • JPA는 String, Int와 같은 단순 값에 대한 콜렉션을 지원한다. 또한 @Embeddable로 설정한 밸류 값에 대한 콜렉션도 매핑할 수 있다.
  • JPA가 지원하는 콜렉션 타입은 다음과 같다.
    • List : 인덱스 기반의 순서가 있는 값 목록
    • Set : 중복을 허용하지 않는 집합
    • Map : (키, 값) 쌍을 갖는 맵
    • Collection : 중복을 허용하는 집합
  • 하이버네이트는 이 네 개 타입 외에 추가로 정렬된 Set과 Map을 지원한다.

2. 단순 값 List 매핑

@Entity
public class Itinerary  {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private String description;
    
    @ElementCollection
    @CollectionTable(
    	name = "itinerary_site",
        joinColumns = @JoinColumn(name = "itinerary_id")
    )
    @OrderColumn(name = "list_idx")
    @Column(name = "site")
    private List<String> sites;
}
  • @ElementCollection 애노테이션은 매핑 대상이 값 콜렉션임을 지정한다. 단순 값이나 @Embeddable 타입에 대한 콜렉션 매핑을 설정한다.
  • @CollectionTable 애노테이션은 콜렉션을 저장할 때 사용할 테이블을 지정한다.
    • name 속성 : 콜렉션 테이블 이름을 지정한다.
    • joinColumns 속성 : 콜렉션 테이블에서 엔티티 테이블을 참조할 때 사용할 칼럼 이름을 지정한다.
    • name 속성을 지정하지 않으면 “엔티티이름_속성이름”을 콜렉션 테이블 이름으로 사용한다.
    • JoinColumns 속성을 지정하지 않으면 “엔티티이름_주요키칼럼이름”을 사용한다.
  • @OrderColumn 애노테이션은 콜렉션 테이블에서 리스트의 인덱스 값을 지정할 칼럼 이름을 지정한다. @OrderColumn을 지정하지 않으면 “속성이름_index” 칼럼을 사용한다.
  • @Column(name = “site”) : 개별 String 값을 저장할 칼럼을 지정한다

itinerary_site 테이블은 Itinerary 클래스의 sites 속성에 포함된 문자열 리스트를 저장한다. 그리고 이 테이블의 세 칼럼은 아래와 같다.

  • itinerary_id : 값이 속할 엔티티의 식별자
  • list_idx : 리스트의 인덱스
  • site : 값

2.1 List의 저장과 조회

  • Itinerary 객체를 저장하면 콜렉션에 보관된 값을 @CollectionTable로 지정한 테이블에 저장한다.
    • 만약에 List에 5개의 엔티티가 저장되어 있으면 insert문을 5번 실행해서 지정한 테이블에 저장한다.
  • @ElementCollection 애노테이션의 fetch 속성은 기본값인 FetchType.LAZY이다.
    • fetch 속성을 EAGER로 설정하면 조인을 이용해서 한번의 쿼리 실행으로 두 테이블을 조회한다.

2.2 List 변경

  • 콜렉션을 변경하면 관련 테이블의 데이터도 함께 변경한다.
em.getTransaction().begin();

Itinerary itinerary = em.find(Itinerary.class, 1L);
List<String> sites = Arrays.asList("정림사지", "궁남지");
itinerary.changeSites(sites);

em.getTransaction().commit();

위의 코드는 커밋 시점에 아래와 같은 쿼리를 실행한다.

// 기존 콜렉션 데이터를 삭제
delete from itinerary_site where itinerary_id=?

// 새로운 콜렉션 데이터를 추가
insert into itinerary_site (itinerary_id, list_idx, site) values (?, ?, ?);
insert into itinerary_site (itinerary_id, list_idx, site) values (?, ?, ?);
  • 콜렉션에 있는 값을 변경하면 update 쿼리를 실행해서 콜렉션 테이블의 데이터를 변경한다.
  • 콜렉션에 새로운 값을 추가하면 insert 쿼리를 실행해서 콜렉션 테이블에 데이터를 추가한다.
  • 콜렉션에 한 요소를 제거하면 delete 쿼리를 실행해서 뒤에 위치한 데이터를 삭제하고, update 쿼리를 실행해서 기존 데이터의 값을 알맞게 변경한다.

2.3 List 전체 삭제

  • 콜렉션의 데이터를 삭제하려면 clear() 메소드를 사용하면 된다.
  • delete 쿼리를 실행해서 콜렉션 테이블에서 엔티티와 연관된 데이터를 전부 삭제한다.
  • 콜렉션 데이터를 삭제하는 또 다른 방법은 콜렉션에 null을 할당하는 것이다.

clear() 메서드가 삭제라는 의미를 더 잘 드러내므로 clear() 메소드 사용을 권장한다.

3. 밸류 객체 List 매핑

@Embeddable
public class SiteInfo {
    private String site;
    private int time;
    
    ...
}

public class Itinerary {
    
    @ElementCollection
    @CollectionTable(
    	name = "itinerary_site",
        joinColumns = @JoinColumn(name = "itinerary_id")
    )
    @OrderColumn(name = "list_idx")
    private List<SiteInfo> sites;
    
    ...
}
  • 값 타입 컬렉션 대신 밸류 객체 콜렉션을 갖는 경우에도 DB 테이블 구조는 동일하다.
  • 콜렉션에 저장할 밸류도 @Embeddable 애노테이션을 사용해서 매핑한다.
  • Itinerary 클래스의 매핑 설정도 이전과 거의 동일하다. (@Column이 없는것을 제외하면…)
  • @Embeddable로 매핑한 클래스의 칼럼 이름 대신 다른 칼럼 이름을 사용하고 싶다면 @AttributeOverride 애노테이션이나 @AttributeOverrides 애노테이션을 사용

값 리스트 매핑을 요약하면 아래와 같다.

  • 엔티티를 저장하면 커밋 시점에 리스트의 값을 저장하기 위한 insert 쿼리를 실행
  • @ElementCollection의 fetch 속성의 기본값은 LAZY이다.
  • 콜렉션에서 특정 항목을 변경하거나 삭제하면 알맞은 insert, delete, update 쿼리를 실행
  • 새로운 리스트 객체를 할당하면 delete 쿼리로 콜렉션 테이블에서 기존 데이터를 삭제하고, insert 쿼리를 실행해서 새로 할당한 콜렉션의 데이터를 추가
  • List의 clear() 메서드를 실행하거나 null을 할당하면 delete 쿼리를 이용해서 콜렉션 테이블에서 엔티티와 연관된 데이터를 삭제
  • 특정 인덱스에 해당하는 레코드가 존재하지 않으면 하이버네이트는 그 항목에 해당하는 값이 널인 리스트를 생성한다. 실제 리스트는 콜렉션 테이블에 보관된 인덱스 값 중 최대값을 기준으로 생성한다. 즉 특정 엔티티와 관련된 인덱스의 최대값이 3이면 길이가 4인 리스트를 생성한다.
  • 리스트의 특정 항목을 널로 설정하면 delete 쿼리를 실행해서 해당 데이터를 삭제한다. 단 remove()와 달리 update 쿼리를 이용해서 기존 데이터 값을 변경하진 않는다. (remove의 경우 update 쿼리를 실행해서 인덱스 값을 알맞게 변경한다.)

5. 단순 값 Set 매핑

public class User {   
    @Id
    private String email;
    private String name;
    
    @ElementCollection
    @CollectionTable(
    	name = "user_keyword",
        joinColumns = @JoinColumn(name = "user_email")
    )
    @Column(name = "keyword")
    private Set<String> keywords = new HashSet<>();
    
}
  • Set 타입의 단순 값 콜렉션을 저장하기 위한 콜렉션 테이블(user_keyword)은 user_email, keyword 칼럼을 갖는다.
    • user_email 칼럼은 엔티티의 식별자 값을 갖는다.
    • keyword : 집합에 포함된 값을 갖는다.
  • @OrderColumn 애노테이션을 사용하지 않는것을 제외하면 List 타입의 단순 값 매핑과 동일한 애노테이션을 사용한다.

5.1 Set의 저장과 조회

  • User 객체를 저장하면 @CollectionTable로 지정한 테이블에 Set에 보관된 값을 함께 저장한다.
  • @ElementCollection 애노테이션의 fetch 속성은 기본값이 FetchType.LAZY 이다. 조회 시점에 @ElementCollection으로 지정한 콜렉션도 함께 조회하고 싶다면 List의 경우와 마찬가지로 fetch 속성을 FetchType.EAGER로 설정하면 된다.
  • Set의 값을 삭제하거나 새로운 값을 추가하면 delete, insert 문을 이용해서 변경 내역을 DB에 반영한다.
    • Set의 경우 인덱스가 없기 때문에 List와 달리 값을 삭제할 때 update 문을 이용해서 기존 데이터의 인덱스 값을 알맞게 변경하진 않는다.
  • 전체 Set 값을 다시 설정하고 싶다면 새로운 Set을 할당한다.
    • delete 쿼리를 실행해서 엔티티와 연관된 데이터를 삭제하고 insert 쿼리를 실행해서 새로 할당한 Set에 포함된 값을 새롭게 추가한다.
  • clear() 메서드로 집합을 모두 지우고 add() 메서드로 집합에 새 데이터를 추가하는 경우에는 전체 Set을 삭제하기 위한 delete 쿼리를 실행하지 않는다. 대신에 기존 Set의 값과 비교해서 삭제된 요소만 delete 쿼리로 삭제하고 새로 추가된 요소만 insert 쿼리로 추가한다.
  • Set의 데이터를 삭제하고 싶으면 clear() 메소드를 실행하거나 빈 Set을 할당하거나 널을 할당하면 된다.

6. 밸류 객체 Set 매핑

@Embeddable
public class RecItem {
    private String name;
    private String type;
}

public class Sight {
    
    private Set<RecItem> recItems;
}
  • 단순 값 타입 대신 밸류 객체를 Set으로 갖는 경우에도 DB 테이블 구조는 동일하다.
  • 밸류 객체를 Set에 저장하고 조회하고 변경하고 삭제하는 것은 앞서 String 값을 사용하는 경우와 동일하다.

6.1 Set에 저장할 밸류 클래스의 equals() 메소드와 hashCode() 메소드

  • Set에 저장할 밸류 타입인 RecItem 클래스는 @Embeddable 애노테이션을 이용해서 매핑한다.
  • Set은 두 값이 같은지 여부를 비교하기 위해 equals() 메소드를 이용한다. 따라서 Set에 보관할 객체는 equals() 메서드를 알맞게 구현해야 한다.
  • 하이버네이트가 Set 타입에 대해 HashSet을 사용하기 때문에 hashCode() 메서드를 알맞게 재정의해야한다. 하이버네이트는 콜렉션 테이블에서 데이터를 로딩한 뒤에 Set 객체를 생성할 때, Set의 구현 클래스로 HashSet을 사용한다. HashSet은 해시코드를 사용해서 데이터를 분류해서 저장하는데, 이 해시코드를 구할 때 hashCode() 메서드를 이용한다. 같은 값을 갖는 객체는 같은 해시코드를 리턴해야 HashSet이 올바르게 동작한다.

7. 단순 값 Map 매핑

  • 엔티티에 정해진 속성이 아니라 자유롭게 엔티티의 값을 설정하고 싶을 때 Map을 유용하게 사용할 수 있다.
@Entity
public class Hotel {
    
    @Id
    private String id;
    private String name;
    
    @ElementCollection
    @CollectionTable(
    	name="hotel_property",
        joinColumns = @JoinColumn(name = "hotel_id")
    )
    @MapKeyColumn(name = "prop_name")
    @Column(name = "prop_value")
    private Map<String, String> properties = new HashMap<>();
}
  • @MapKeyColumn 애노테이션은 콜렉션 테이블에서 Map의 키로 사용할 칼럼을 지정한다.
  • Map 타입의 단순 값 콜렉션을 저장하기 위한 콜렉션 테이블의 이름은 hotel_property가 되며 세 개의 칼럼을 갖는다.
    • hotel_id : 엔티티의 식별자
    • prop_name : Map의 키를 지정
    • prop_value : Map의 값을 지정

7.1 Map의 저장과 조회

  • List나 Set과 동일하게 Map 콜렉션도 엔티티를 저장할 때 Map을 저장하기 위한 알맞은 insert 문을 실행한다.
  • @ElementCollection의 fetch 속성의 기본값이 LAZY 이므로 콜렉션의 값에 접근할 때 select 쿼리를 실행한다.

7.2 Map의 변경

  • put(key, value) : 키에 대해 값을 추가하거나 변경한다.
    • 변경하는 경우 : update 쿼리를 실행한다.
    • 추가하는 경우 : insert 쿼리를 실행한다.
  • remove(key) : 키에 대한 값을 삭제한다.
    • delete 쿼리를 실행한다.
  • 새로운 Map 객체를 할당하면 트랜잭션 커밋 시점에 delete 쿼리를 이용해서 기존 데이터를 삭제한 뒤에 새로 할당된 Map의 데이터를 추가하기 위한 insert 쿼리를 실행한다.

7.3 Map의 전체 삭제

  • Map의 데이터 삭제는 clear() 메서드로 삭제하거나 데이터가 없는 빈 Map을 할당하거나 널을 할당하면 된다.

8. 밸류 객체 Map 매핑

  • @Embeddable 클래스를 만들고, @ElementCollection으로 매핑한 Map의 값 타입으로 밸류 클래스를 사용하면 된다.
  • 동작 방식은 단순 값을 사용하는 Map과 동일하다.

9. 콜렉션 타입별 구현 클래스

  • 엔티티를 로딩할 때 하이버네이트는 다음 클래스를 이용해서 각 콜렉션 타입의 인스턴스를 생성한다.
    • List -> ArrayList
    • Set -> HashSet
    • Map -> HashMap
  • 위 타입은 엔티티를 로딩할 때 하이버네이트가 생성하는 타입이다. 만약에 엔티티의 List 필드를 LinkedList로 초기화 했다 하더라도 엔티티를 로딩하면 하이버네이트는 해당 필드에 해당하는 데이터를 ArrayList 객체에 보관한다.
    • 물론 객체를 생성하는 시점에는 LinkedList 타입으로 생성이되며, EntityManager#persist() 메서드로 저장해도 정상적으로 동작한다.

하이버네이트는 실제로 하이버네이트에 포함된 PersistentList 객체를 List 타입 필드에 할당하고, PersistentList가 내부적으로 ArrayList 객체를 생성한다. 비슷하게 Set이나 Map도 하이버네이트의 PersistentSet과 PersistentMap 타입 객체를 할당하고, 각 객체가 내부적으로 HashSet과 HashMap을 사용한다.

10. 조회할 때 정렬 Set과 정렬 Map 사용하기

  • 하이버네이트는 콜렉션 데이터를 조회해서 생성하는 시점에 Set의 데이터와 Map의 키를 정렬해서 읽어오는 방법을 제공하고 있다.
  • 정렬 방법은 크게 두가지가 있다.
    • order by를 사용
    • 메모리 상에서 정렬
  • Set의 경우 SortedSet과 자바의 Comparator를 사용해서 데이터를 정렬할 수 있다.
  • 아래 예제는 데이터 조회 시점에 값을 오름차순으로 정렬한다.
@Entity
public class User {
    
    @ElementCollection
    @CollectionTable(
    	name = "user_keyword",
        joinColumns = @JoinColumn(name ="user_email")
    )
    @Column(name = "keyword")
    @SortNatural
    private SortedSet<String> keywords = new TreeSet<>();
}
  • @SortNatural을 사용하면 Set에 보관된 객체가 Comparable 인터페이스를 구현했다고 가정하고 Comparable#compareTo() 메서드를 이용해서 정렬한다.
  • 하이버네이트는 SortedSet 타입에 대해 내부적으로 TreeSet 클래스를 사용해서 인스턴스를 생성한다.
  • Comparable을 구현하지 않았다면 @SortComparator를 사용해서 TreeSet이 값을 정렬할 때 사용할 Comparator 클래스를 지정할 수도 있다.
@Entity
public class User {
    
    @ElementCollection
    @CollectionTable(
    	name = "user_keyword",
        joinColumns = @JoinColumn(name ="user_email")
    )
    @Column(name = "keyword")
    @SortComparator(StringComparator.class)
    private SortedSet<String> keywords = new TreeSet<>();
}
  • SortedSet을 이용해서 메모리에서 값을 정렬하는 대신 order by절을 이용해서 데이터를 읽어온 순서대로 집합에 저장할 수도 있다. 이는 하이버네이트에서 제공하는 @OrderBy 애노테이션을 이용하면 된다.
@Entity
public class Sight {
    
    @ElementCollection
    @CollectionTable(
    	name = "sight_rec_item",
        joinColumns = @JoinColumn(name ="sight_id")
    )
    @OrderBy(clause = "name asc")
    private SortedSet<String> keywords = new LinkedHashSet<>();
}
  • 하이버네이트의 @OrderBy 애노테이션은 clause 속성 값으로 SQL의 order by 절에 들어갈 내용을 전달받는다.
  • JPA에서 제공하는 @OrderBy 애노테이션도 있다. 둘의 차이는 JPA에서 제공하는 @OrderBy는 정렬 대상 객체의 속성을 지정한다. 반면에 하이버네이트 @OrderBy 애노테이션은 콜렉션 테이블의 칼럼을 지정한다. (하이버네이트의 @OrderBy 애노테이션은 SQL 쿼리를 입력하는 것이다.)
  • @OrderBy를 사용하면 order by 절을 통해 정렬하고 결과를 LinkedHashSet에 저장한다.
  • Map의 경우도 Set과 동일하게 @SortNatural, @SortComparator, @OrderBy를 이용해서 키의 정렬 순서를 정할 수 있다.
  • @SortNatural, @SortComparator를 사용할 때에는 SortedMap 타입을 사용하고 구현 클래스로는 TreeMap을 사용한다. @OrderBy를 사용하면 LinkedHashMap을 사용한다.
@Entity
public class Hotel {
	@ElementCollection
	@CollectionTable(
		name = "hotel_property",
		joinColumns = @JoinColumn(name = "hotel_id")
	)
	@MapKeyColumn(name = "prop_name")
	@OrderBy(clause = "prop_name asc")
	private Map<String, PropValue> properties = new LinkedHashMap<>();
}