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;
  ...
}
  • 순서가 있는 컬렉션은 데이터베이스에 순서 값도 함께 관리한다.
  • 위에서는 @OrderColumnname 속성에 POSITION 이라는 값을 주었다.
    • JPA는 위치 값을 테이블의 POSITION 컬럼에 보관한다.
    • Board.comments 컬렉션은 Board 엔티티에 있지만 일대다 관계의 특성 상 위치 값은 다쪽에 저장해야 한다.
      • 실제 POSITION 컬럼은 COMMENT 테이블에 매핑된다.

@OrderColumn의 단점

  • @OrderColumnBoard 엔티티에서 매핑하기 때문에 CommentPOSITION 의 값을 알 수 없다.
    • Comment 를 저장할 때는 POSITION 값이 저장되지 않는다.
    • POSITIONBoard.comments 의 위치 값이므로, 이 값을 사용해서 POSITION 의 값을 UPDATE하는 SQL이 추가로 수행된다.
  • List 를 변경하면 연관된 많은 위치 값을 변경해야 한다.
    • 예를 들어, 댓글이 5개 있는 리스트에서 댓글2를 삭제하면 뒤에 있는 댓글들의 POSITION 값을 하나씩 줄이는 UPDATE SQL이 추가로 실행되어야 한다.
  • 중간에 POSITION 값이 없으면 조회한 List 에는 null 이 보관된다.
    • 예를 들어, 댓글 2를 데이터베이스에서 강제로 삭제하고 뒤에 있는 댓글들의 POSITION 값을 수정하지 않으면, 데이터베이스의 POSITION 값은 [0,2,3,4] 가 된다. 이 때 List 의 1번 위치 값을 조회하면 NullPointerException 예외가 발생한다.

@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 : flushcommit 을 호출해서 엔티티 변경사항을 데이터베이스에 반영하기 직전에 호출된다.
  • preRemove : remove() 메소드를 호출해서 엔티티를 영속성 컨텍스트에서 삭제하기 직전에 호출된다.
    • 삭제 명령어로 영속성 전이가 일어날 때도 호출된다.
    • orphanRemoval 에 대해서는 flushcommit 시에 호출된다.
  • PostPersist : flushcommit 을 호출해서 엔티티를 데이터베이스에 저장한 직후에 호출된다.
  • PostUpdate : flushcommit 을 호출해서 엔티티를 데이터베이스에 수정한 직후에 호출된다.
  • postRemove : flushcommit 을 호출해서 엔티티를 데이터베이스에 삭제한 직후에 호출된다.

이벤트 적용 위치

  • 이벤트는 엔티티에서 직접 받거나 별도의 리스너를 등록해서 받을 수 있다.

엔티티에 직접 적용

@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 로 설정해야 한다.

기본 리스너 사용

  • 모든 엔티티의 이벤트를 처리하려면 기본 리스너를 등록하면 된다.
  • 여러 리스너를 등록했을 때 이벤트 호출 순서는 다음과 같다.
    1. 기본 리스너
    2. 부모 클래스 리스너
    3. 리스너
    4. 엔티티

더 세밀한 설정

  • @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 -> Member
    • Order -> OrderItem , OrderItem -> Item
  • OrderItem -> ItemOrder 의 객체 그래프가 아니므로 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.memberoptional=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 과 같은 인스턴스가 반환된다.
  • fetchgraphloadgraph 의 차이

    • fetchgraph 힌트를 사용하면 엔티티 그래프에 선택한 속성만 함께 조회한다.
    • loadgraph 힌트를 사용하면 엔티티 그래프에 선택한 속성뿐만 아니라 글로벌 페치 모드가 FetchType.EAGER 로 설정된 연관 엔티티도 포함해서 함께 조회한다.