BOOK 3 - 자바 ORM 표준 JPA 프로그래밍(13)
컬렉션과 부가 기능
- JPA는 다음과 같은 컬렉션과 부가 기능들을 지원한다.
- 컬렉션
- 컨버터 : 엔티티의 데이터를 변화해서 데이터베이스에 저장한다.
- 리스너 : 엔티티에서 발생한 이벤트를 처리한다.
- 엔티티 그래프 : 엔티티를 조회할 때 연관된 엔티티들을 선택해서 함께 조회한다.
컬렉션
- JPA는 자바에서 기본으로 제공하는
Collection,List,Set,Map컬렉션을 지원한다.@OneToMany,@ManyToMany를 사용해서 일대다나 다대다 엔티티 관계를 매핑할 때@ElementCollection을 사용해서 값 타입을 하나 이상 보관할 때
Collection: 중복을 허용하고 순서를 보장하지 않는다.Set: 중복을 허용하지 않고 순서를 보장하지 않는다.List: 중복을 허용하고 순서를 보장한다.Map:Key-Value구조로 되어있는 특수한 컬렉션이다.
JPA와 컬렉션
- 하이버네이트는 엔티티를 영속 상태로 만들 때 컬렉션 필드를 하이버네이트의 내장 컬렉션으로 감싸서 사용한다.
Team team = new Team();
...
System.out.println("before persist : " + team.getMembers().getClass());
em.persist(team);
System.out.println("after persist : " + team.getMembers().getClass());
// output
// before persist : class java.util.ArrayList
// after persist : class org.hibernate.collection.internal.PersistenceBag
- 출력 결과를 보면 원래
ArrayList타입이었던 컬렉션이 엔티티를 영속상태로 만든 후에 하이버네이트가 제공하는PersistenceBag타입으로 변경되었다. - 하이버네이트는 컬렉션을 효율적으로 관리하기 위해 엔티티를 영속 상태로 만들 때 원본 컬렉션을 감싸는 내장 컬렉션을 생성해서 이 내장 컬렉션을 사용하도록 참조를 변경한다.
Collection, List
Collection,List인터페이스는 중복을 허용하는 컬렉션이고PersistenceBag을 래퍼 컬렉션으로 사용한다.ArrayList로 초기화하면 된다.
@Entity
public class Parent {
@Id
@GeneratedValue
private Long id;
@OneToMany
@JoinColumn
private Collection<CollectionChild> collection = new ArrayList<CollectionChild>();
@OneToMany
@JoinColumn
private List<ListChild> list = new ArrayList<ListChild>();
}
- 중복을 허용하므로 엔티티를 추가할 때 중복된 엔티티가 있는지 비교하지 않고 단순히 저장만 하면된다.
- 따라서 엔티티를 추가해도 지연 로딩이 설정된 컬렉션을 초기화하지 않는다.
Set
Set은 중복을 허용하지 않는 컬렉션이고PersistenceSet을 래퍼 컬렉션으로 사용한다.HashSet으로 초기화하면 된다.
@Entity
public class Parent {
...
@OneToMany
@JoinColumn
private Set<SetChild> set = new HashSet<SetChild>();
}
- 중복을 허용하지 않으므로
add()메소드로 객체를 추가할 때마다equals()메소드로 같은 객체가 있는지 비교한다.- 같은 객체가 없으면 객체를 추가하고
true를 반환하고, 같은 객체가 이미 있으면 추가하지 않고false를 반환한다.
- 같은 객체가 없으면 객체를 추가하고
- 엔티티를 추가할 때 중복된 엔티티가 있는지 비교해야 한다.
- 따라서 엔티티를 추가할 때 지연 로딩이 설정된 컬렉션을 초기화한다.
List + @OrderColumn
List인터페이스에@OrderColumn어노테이션을 추가하면 순서가 있는 특수한 컬렉션으로 인식한다.- 순서가 있다는 것은 데이터베이스에 순서 값을 저장해서 조회할 때 사용한다는 의미이다.
PersistenceList를 래퍼 컬렉션으로 사용한다.
@Entity
public class Board {
@Id
@GeneratedValue
private Long id;
private String title;
private String content;
@OneToMany(mappedBy="board")
@OrderColumn(name="POSITION")
private List<Comment> comments = new ArrayList<Comment>();
...
}
@Entity
public class Comment {
@Id
@GeneratedValue
private Long id;
private String comment;
@ManyToOne
@JoinColumn(name="BOARD_ID")
private Board board;
...
}
- 순서가 있는 컬렉션은 데이터베이스에 순서 값도 함께 관리한다.
- 위에서는
@OrderColumn의name속성에POSITION이라는 값을 주었다.- JPA는 위치 값을 테이블의
POSITION컬럼에 보관한다. Board.comments컬렉션은Board엔티티에 있지만 일대다 관계의 특성 상 위치 값은 다쪽에 저장해야 한다.- 실제
POSITION컬럼은COMMENT테이블에 매핑된다.
- 실제
- JPA는 위치 값을 테이블의
@OrderColumn의 단점
@OrderColumn은Board엔티티에서 매핑하기 때문에Comment는POSITION의 값을 알 수 없다.Comment를 저장할 때는POSITION값이 저장되지 않는다.POSITION은Board.comments의 위치 값이므로, 이 값을 사용해서POSITION의 값을 UPDATE하는 SQL이 추가로 수행된다.
List를 변경하면 연관된 많은 위치 값을 변경해야 한다.- 예를 들어, 댓글이 5개 있는 리스트에서 댓글2를 삭제하면 뒤에 있는 댓글들의
POSITION값을 하나씩 줄이는 UPDATE SQL이 추가로 실행되어야 한다.
- 예를 들어, 댓글이 5개 있는 리스트에서 댓글2를 삭제하면 뒤에 있는 댓글들의
- 중간에
POSITION값이 없으면 조회한List에는null이 보관된다.- 예를 들어, 댓글 2를 데이터베이스에서 강제로 삭제하고 뒤에 있는 댓글들의
POSITION값을 수정하지 않으면, 데이터베이스의POSITION값은[0,2,3,4]가 된다. 이 때List의 1번 위치 값을 조회하면NullPointerException예외가 발생한다.
- 예를 들어, 댓글 2를 데이터베이스에서 강제로 삭제하고 뒤에 있는 댓글들의
@OrderBy
@OrderBy는 데이터베이스의ORDER BY절을 사용해서 컬렉션을 정렬한다.@OrderBy는 모든 컬렉션에서 사용할 수 있다.
@Entity
public class Team {
@Id
@GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy="team")
@OrderBy("username desc, id asc")
private Set<Member> members = new HashSet<Member>();
...
}
@OrderBy를 사용해서username필드 내림차순,id오름차순 정렬을 했다.@OrderBy의 값은 JPQL의order by절처럼 엔티티의 필드를 대상으로 한다.
@Converter
- 컨버터를 사용하면 엔티티의 데이터를 변환해서 데이터베이스에 저장할 수 있다.
- 예를 들어 자바의
boolean타입은 데이터베이스에 저장될 때 0 또는 1로 저장되지만, 숫자 대신 문자 ‘Y’ 또는 ‘N’ 으로 저장하고 싶다면 컨버터를 사용하면 된다.
@Entity
public class Member {
@Id
private String id;
private String username;
@Convert(converter=BooleanToYNConverter.class)
private boolean vip;
...
}
@Convert를 적용해서 데이터베이스에 저장되기 직전에BooleanToYNConverter컨버터가 동작하도록 했다.
@Converter
public class BooleanToYNConverter implements AttributeConverter<Boolean, String> {
@Override
public String convertToDatabaseColumn(Boolean attribute) {
return (attribute != null && attribute) ? "Y" : "N";
}
@Override
public Boolean convertToEntityAttribute(String dbData) {
return "Y".equals(dbData);
}
}
- 컨버터 클래스는
@Converter어노테이션을 선언하고,AttributeConverter인터페이스를 구현해야 한다.- 제네릭에 현재 타입과 변환할 타입을 지정한다.
convertToDatabaseColumn(): 엔티티의 데이터를 데이터베이스 컬럼에 저장할 데이터로 변환한다.convertToEntityAttribute(): 데이터베이스에서 조회한 컬럼 데이터를 엔티티의 데이터로 변환한다.
글로벌 설정
- 모든
Boolean타입에 컨버터를 적용하려면 컨버터 클래스에autoApply = true를 설정하면 된다.
@Converter(autoApply = true)
public class BooleanToYNConverter implements AttributeConverter<Boolean, String> {
@Override
public String convertToDatabaseColumn(Boolean attribute) {
return (attribute != null && attribute) ? "Y" : "N";
}
@Override
public Boolean convertToEntityAttribute(String dbData) {
return "Y".equals(dbData);
}
}
- 글로벌 설정을 하면
@Convert를 지정하지 않아도 모든 엔티티의Boolean타입에 대해 자동으로 컨버터가 적용된다.
리스너
- JPA 리스너 기능을 사용하면 엔티티의 생명주기에 따른 이벤트를 처리할 수 있다.
이벤트 종류
PostLoad: 엔티티가 영속성 컨텍스트에 조회된 직후 또는refresh를 호출한 후PrePersist:persist()메소드를 호출해서 엔티티를 영속성 컨텍스트에 관리하기 직전에 호출된다.- 새로운 인스턴스를
merge할 때도 수행된다.
- 새로운 인스턴스를
preUpdate:flush나commit을 호출해서 엔티티 변경사항을 데이터베이스에 반영하기 직전에 호출된다.preRemove:remove()메소드를 호출해서 엔티티를 영속성 컨텍스트에서 삭제하기 직전에 호출된다.- 삭제 명령어로 영속성 전이가 일어날 때도 호출된다.
orphanRemoval에 대해서는flush나commit시에 호출된다.
PostPersist:flush나commit을 호출해서 엔티티를 데이터베이스에 저장한 직후에 호출된다.PostUpdate:flush나commit을 호출해서 엔티티를 데이터베이스에 수정한 직후에 호출된다.postRemove:flush나commit을 호출해서 엔티티를 데이터베이스에 삭제한 직후에 호출된다.
이벤트 적용 위치
- 이벤트는 엔티티에서 직접 받거나 별도의 리스너를 등록해서 받을 수 있다.
엔티티에 직접 적용
@Entity
public class Duck {
@Id
@GeneratedValue
public Long id;
private String name;
@PrePersist
public void prePersist() {
System.out.println("Duck.prePersist id = " + id);
}
@PostPersist
public void postPersist() {
System.out.println("Duck.postPersist id = " + id);
}
@PostLoad
public void postLoad() {
System.out.println("Duck.postLoad");
}
@PreRemove
public void preRemove() {
System.out.println("Duck.preRemove");
}
@PostRemove
public void postRemove() {
System.out.println("Duck.postRemove");
}
}
- 엔티티에 이벤트가 발생할 때마다 어노테이션으로 지정한 메소드가 실행된다.
별도의 리스너 등록
@Entity
@EntityListeners(DuckListener.class)
public class Duck {
...
}
public class DuckListener {
@PrePersist
private void prePersist(Object obj) {
System.out.println("DuckListener.prePersist obj = [" + obj + "]");
}
@PostPersist
private void postPersist(Object obj) {
System.out.println("DuckListener.postPersist obj = [" + obj + "]");
}
}
- 리스너는 대상 엔티티를 parameter로 받을 수 있다.
- 리스너의 메소드 반환 타입은
void로 설정해야 한다.
기본 리스너 사용
- 모든 엔티티의 이벤트를 처리하려면 기본 리스너를 등록하면 된다.
- 여러 리스너를 등록했을 때 이벤트 호출 순서는 다음과 같다.
- 기본 리스너
- 부모 클래스 리스너
- 리스너
- 엔티티
더 세밀한 설정
@ExcludeDefaultListeners: 기본 리스너 무시@ExcludeSuperClassListeners: 상위 클래스 이벤트 리스너 무시
@Entity
@EntityListeners(DuckListener.class)
@ExcludeDefaultListeners
@ExcludeSuperClassListeners
public class Duck extends BaseEntity {
...
}
엔티티 그래프
-
엔티티를 조회할 때 연관된 엔티티를 함께 조회하려면 JPQL의 페치 조인을 사용한다.
- 페치 조인을 사용하면 같은 엔티티를 조회하는 JPQL을 중복해서 작성해야 하는 경우가 생긴다.
select o from Order o where o.status = ? select o from Order o join fetch o.member where o.status = ? select o from Order o join fetch o.orderItems where o.status = ?- 위 세개의 JPQL은 모두 주문을 조회하는 JPQL이지만 함께 조회할 엔티티에 따라서 다른 JPQL을 사용해야 한다.
-
JPA의 엔티티 그래프 기능을 사용하면 엔티티를 조회하는 시점에 함께 조회할 연관된 엔티티를 선택할 수 있다.
- 즉, JPQL은 데이터를 조회하는 기능만 수행하고, 연관된 엔티티를 함께 조회하는 기능은 엔티티 그래프를 사용한다.
-
엔티티 그래프 기능은 엔티티 조회 시점에 연관된 엔티티들을 함께 조회하는 기능이다.
Named 엔티티 그래프
- Named 엔티티 그래프는
@NamedEntityGraph로 정의한다.
@NamedEntityGraph(name="Order.withMember", attributeNodes = {@NamedAttributeNode("member")})
@Entity
@Table(name="ORDERS")
public class Order {
@Id
@GeneratedValue
@Column(name="ORDER_ID")
private Long id;
@ManyToOne(fetch=FetchType.LAZY, optional = false)
@JoinColumn(name="MEMBER_ID")
private Member member;
...
}
name: 엔티티 그래프의 이름attributeNodes: 함께 조회할 속성 지정@NamedAttributeNode로 함께 조회할 속성을 선택하면 된다.
Order.member가 지연 로딩이 설정되어 있지만, 엔티티 그래프에서 함께 조회할 속성으로member를 선택했으므로 이 엔티티 그래프를 사용하면order조회시member도 함께 조회할 수 있다.
em.find()에서 엔티티 그래프 사용
- 엔티티 그래프는 JPA의 힌트 기능을 사용해서 동작한다.
- 힌트의 키로
javax.persistence.fetchgraph를 사용하고, 힌트의 값으로 엔티티 그래프 객체를 사용한다.
- 힌트의 키로
EntityGraph graph = em.getEntityGraph("Order.withMember");
Map hints = new HashMap();
hints.put("javax.persistence.fetchgraph", graph);
Order order = em.find(Order.class, orderId, hints);
em.getEntityGraph("Order.withMember"): 엔티티에 정의한 엔티티 그래프를 반환한다.- 위 코드를 실행하면 다음 SQL이 수행된다.
SELECT o.*, m.*
FROM ORDERS o
INNER JOIN MEMBER m
ON o.MEMBER_ID = m.MEMBER_ID
WHERE o.ORDER_ID = ?
subgraph
Order->OrderItem->Item까지 연관된 엔티티를 함께 조회하는 경우를 생각해보자.
@NamedEntityGraph(name="Order.withAll", attributeNodes={
@NamedAttributeNode("member"),
@NamedAttributeNode(value="orderItems", subgraph="orderItems")},
subgraphs = @NamedSubGraph(name="orderItems", attributeNodes={
@NamedAttributeNode("item")
}))
@Entity
@Table(name="ORDERS")
public class Order {
@Id
@GeneratedValue
@Column(name="ORDER_ID")
private Long id;
@ManyToOne(fetch=FetchType.LAZY, optional=false)
@JoinColumn(name="MEMBER_ID")
private Member member;
@OneToMany(mappedBy="order", cascade=CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<OrderItem>();
...
}
@Entity
@Table(name="ORDER_ITEM")
public class OrderItem {
@Id
@GeneratedValue
@Column(name="ORDER_ITEM_ID")
private Long id;
@ManyToOne(fetch=FetchType.LAZY)
@JoinColumn(name="ITEM_ID")
private Item item;
...
}
Order엔티티에Order.withAll이라는 Named 엔티티 그래프를 정의했다.Order->MemberOrder->OrderItem,OrderItem->Item
OrderItem->Item은Order의 객체 그래프가 아니므로subgraphs속성으로 정의한다.@NamedSubGraph: 서브 그래프 정의
- 해당 Named 엔티티 그래프를 사용해서
Order를 조회하면 다음 SQL이 실행된다.
SELECT o.*, m.*, oi.*, i.*
FROM ORDERS o
INNER JOIN MEMBER m ON m.MEMBER_ID = o.MEMBER_ID
LEFT OUTER JOIN ORDER_ITEM oi ON o.ORDER_ID = oi.ORDER_ID
LEFT OUTER JOIN ITEM i ON oi.ITEM_ID = i.ITEM_ID
WHERE o.ORDER_ID = ?
JPQL에서 엔티티 그래프 사용
em.find()와 동일하게 힌트를 추가해주면 된다.
List<Order> resultList = em.createQuery("select o from Order o where o.id = :orderId", Order.class)
.setParameter("orderId", orderId)
.setHint("javax.persistence.fetchgraph", em.getEntityGraph("Order.withAll"))
.getResultList();
-
em.find()에서 엔티티 그래프를 사용하면 필수 관계를 고려해서 SQL 내부 조인을 사용하지만, JPQL에서 엔티티 그래프를 사용할 때는 항상 SQL 외부 조인을 사용한다.-
따라서
Order.member가optional=false로 필수 관계로 설정되어 있어도 JPQL로 엔티티 그래프를 사용했을 때 다음과 같이 외부 조인이 실행된다.SELECT o.*, m.*, oi.*, i.* FROM ORDERS o LEFT OUTER JOIN MEMBER m ON m.MEMBER_ID = o.MEMBER_ID ...
-
동적 엔티티 그래프
- 엔티티 그래프를 동적으로 구성하려면
createEntityGraph()메소드를 사용한다.
EntityGraph<Order> graph = em.createEntityGraph(Order.class);
graph.addAttributeNodes("member");
Map hints = new HashMap();
hints.put("javax.persistence.fetchgraph", graph);
Order order = em.find(Order.class, orderId, hints);
addSubGraph()메소드를 사용하여 서브 그래프를 생성할 수도 있다.
EntityGraph<Order> graph = em.createEntityGraph(Order.class);
graph.addAttributeNodes("member");
Subgraph<OrderItem> orderItems = graph.addSubGraph("orderItems");
orderItems.addAttributeNodes("item");
Map hints = new HashMap();
hints.put("javax.persistence.fetchgraph", graph);
Order order = em.find(Order.class, orderId, hints);
엔티티 그래프 정리
-
Root에서 시작
- 엔티티 그래프는 항상 조회하는 엔티티의 Root에서 시작해야 한다.
-
이미 로딩된 엔티티
- 영속성 컨텍스트에 해당 엔티티가 이미 로딩되어 있으면 엔티티 그래프가 적용되지 않는다.
Order order1 = em.find(Order.class, orderId); hints.put("javax.persistence.fetchgraph", graph); Order order2 = em.find(Order.class, orderId, hints);- 이 경우 조회된
order2에는 엔티티 그래프가 적용되지 않고, 처음 조회한order1과 같은 인스턴스가 반환된다.
-
fetchgraph와loadgraph의 차이fetchgraph힌트를 사용하면 엔티티 그래프에 선택한 속성만 함께 조회한다.loadgraph힌트를 사용하면 엔티티 그래프에 선택한 속성뿐만 아니라 글로벌 페치 모드가FetchType.EAGER로 설정된 연관 엔티티도 포함해서 함께 조회한다.