쌩로그

자바 ORM 표준 JPA 프로그래밍 Ch.14. 컬렉션과 부가 기능 (2) 본문

Spring/JPA

자바 ORM 표준 JPA 프로그래밍 Ch.14. 컬렉션과 부가 기능 (2)

.쌩수. 2024. 2. 23. 06:48
반응형

목록

  1. 포스팅 개요
  2. 본론
      2-1. 리스너
      2-2. 엔티티 그래프
  3. 요약

1. 포스팅 개요

자바 ORM 표준 JPA 프로그래밍14. 컬렉션과 부가 기능를 학습하며 정리한 포스팅이다.
해당 포스팅은 이전 포스팅에서 다루지 않았던 리스너와 엔티티 그래프에 대해서 살펴본다.

참고로 이전 포스팅에서는 컬렉션@Converter에 대해 알아보았었다.

2. 본론

2-1. 리스너

모든 엔티티를 대상으로 언제 어떤 사용자가 삭제를 요청했는지 모두 로그로 남겨야 하는 요구사항이 있다고 가정하자.
이때 애플리케이션 삭제 로직을 하나씩 찾아서 로그를 남기는 것은 너무 비효율적이다.
JPA 리스너 기능을 사용하면 엔티티의 생명주기에 따른 이벤트를 처리할 수 있다.

이벤트 종류

이벤트의 종류와 발생 시점은 다음과 같다.

    1. PostLoad : 엔티티가 영속성 컨텍스트에 조회된 직후 또는 refresh를 호출한 후(2차 캐시에 저장되어 있어도 호출된다.)
    1. PrePersist : persist() 메소드를 호출해서 엔티티를 영속성 컨텍스트에 관리하기 직전에 호출된다. 식별자 생성 전략을 사용한 경우 엔티티에 식별자는 아직 존재하지 않는다. 새로운 인스턴스를 merge할 때도 수행된다.
    1. PreUpdate : flushcommit을 호출해서 엔티티를 데이터베이스에 수정하기 직전에 호출된다.
    1. PreRemove : remove() 메서드를 호출해서 엔티티를 영속성 컨텍스트에서 삭제하기 직전에 호출된다. 또한 삭제 명령어로 영속성 전이가 일어날 때도 호출된다. orphanRemoval에 대해서는 flushcommit시에 호출된다.
    1. PostPersist : flushcommit을 호출해서 엔티티를 데이터베이스에 저장한 직후에 호출된다. 식별자가 항상 존재한다. 참고로 식별자 생성 전략이 IDENTITY면 식별자를 생성하기 위해 persist()를 호출하면서 데이터베이스에 해당 엔티티를 저장하므로 이때는 persist()를 호출한 직후에 바로 PostPersist가 호출된다.
    1. PostUpdate : flushcommit을 호출해서 엔티티를 데이터베이스에 수정한 직후에 호출된다.
    1. PostRemove : flushcommit을 호출해서 엔티티를 데이터베이스에 삭제한 직후에 호출된다.

이벤트 적용 위치

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

  • 엔티티 직접 적용
  • 별도의 리스너 등록
  • 기본 리스너 사용

엔티티에 직접 적용

package spring.datajpa.entity;

import jakarta.persistence.*;

@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");
    }
}

위의 코드는 엔티티에 이벤트가 발생할 때마다 어노테이션으로 지정한 메서드가 실행된다.

다음은 필자가 엔티티를 저장하고 삭제까지의 코드를 작성한 후 나온 출력 결과다.

@DataJpaTest
class DuckTest {

    @Autowired
    EntityManager em;

    @Test
    @DisplayName("리스너 알아보기")
    void Test() {
        System.out.println("Duck 생성");
        Duck duck = new Duck();

        System.out.println("Duck DB에 저장 전");
        em.persist(duck);

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

        Duck findDuck = em.find(Duck.class, duck.id);

        System.out.println("삭제 전");
        em.remove(findDuck);

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

출력 결과는 다음과 같다.

Duck 생성
Duck DB에 저장 전
Duck.prePersist id=null

...
...

Duck.postLoad
삭제 전
Duck.preRemove

이렇게 엔티티에 이벤트를 직접 받을 수도 있지만 이벤트를 처리할 별도의 리스너를 등록하는 방법도 있다.

별도의 리스너 등록

다음은 별도의 리스너를 사용하는 예이다.

@Entity
@EntityListeners(DuckListener.class)
public class Duck {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    public Long id;

    private String name;

}
...
...

import jakarta.persistence.PostPersist;
import jakarta.persistence.PrePersist;

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.portPersist obj = [" + obj + "]");
    }
}

위의 테스트 코드 출력결과이다.

Duck 생성
Duck DB에 저장 전
DuckListener.prePersist obj = [spring.datajpa.entity.Duck@424769f2]

...
...

insert into duck (name,id) values (?,default)
insert into duck (name,id) values (NULL,default);
DuckListener.portPersist obj = [spring.datajpa.entity.Duck@424769f2]

리스너는 대상 엔티티를 파라미터로 받을 수 있다.
반환 타입은 void로 설정해야 한다.

기본 리스너 사용

모든 엔티티의 이벤트를 처리하려면 다음 예와 같이 META-INF/orm.xml에 기본 리스터로 등록하면 된다.

<?xml version="1.0" encoding="UTF-8"?>
<entity-mappings xmlns="http://xmlns.jcp.org/xml/ns/persistence/orm"
                 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                 xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence/orm
http://xmlns.jcp.org/xml/ns/persistence/orm_2_2.xsd"
                 version="2.2">
  <persistence-unit-metadata>
    <persistence-unit-defaults>
      <entity-listeners>
        <entity-listener class="jpabook..jpashop.domain.test.listener.DefaultListener"/>
      </entity-listeners>
    </persistence-unit-defaults>
  </persistence-unit-metadata>
</entity-mappings>

여러 리스너를 등록했을 때 이벤트 호출 순서는 다음과 같다.

더 세밀한 설정

더 세밀한 설정을 위한 어노테이션도 있다.
다음은 그 사용예이다.

  • jakarta.persistence.ExcludeDefaultListeners : 기본 리스너 무시
  • jakarta.persistence.ExcludeSuperclassListeners : 상위 클래스 이벤트 리스너 무시
@Entity
@EntityListeners(DuckListener.class)
@ExcludeDefaultListeners
@ExcludeSuperclassListeners
public class Duck extends BaseEntity {

    ...

}

이벤트를 잘 활용하면 대부분의 엔티티에 공통으로 적용하는 등록 일자, 수정 일자 처리와 해당 엔티티를 누가 등록하고 수정했는지에 대한 기록을 리스너 하나로 처리할 수 있다.

2-2. 엔티티 그래프

엔티티를 조회할 때 연관된 엔티티들을 함께 조회하려면 다음처럼 글로벌 fetch 옵션FetchType.EAGER로 설정한다.

@Entity
class Order {

  @ManyToOne(fetch = FetchType.EAGER)
  Member member;

  ...
}

또는 다음처럼 JPQL에서 페치 조인을 사용하면 된다.

select o from Order o join fetch o.member

글로벌 fetch 옵션은 애플리케이션 전체에 영향을 주고 변경할 수 없는 단점이 있다.
그래서 일반적으로 글로벌 fetch 옵션은 FetchType.LAZY를 사용하고, 엔티티를 조회할 때 연관된 엔티티를 함께 조회할 필요가 있으면 JPQL의 페치 조인을 사용한다.

그런데 페치 조인을 사용하면 같은 JPQL을 중복해서 작성하는 경우가 많다.
예를 들어 주문 상태를 검색조건으로 주문(Order) 엔티티를 조회하는 JPQL을 작성해보자.

select o from Order o where o.status = ?

주문과 회원을 함께 조회할 필요가 있어서 다음 JPQL을 새로 추가했다.

select o from Order o
  join fetch o.member
where o.status = ?

주문과 주문상품을 함께 조회하는 기능이 필요해서 다음 JPQL을 새로 추가했다.

select o from Order o
  join fetch o.orderItems
where o.status = ?

3가지 JPQL 모두 주문을 조회하는 같은 JPQL이지만, 함께 조회할 엔티티에 따라서 다른 JPQL을 사용해야 한다. 이것은 JPQL이 데이터를 조회하는 기능뿐만 아니라 연관된 엔티티를 함께 조회하는 기능도 제공하기 때문인데, 결국 JPQL이 두 가지 역할을 모두 수행해서 발생하는 문제다.

JPA 2.1에 추가된 엔티티 그래프 기능을 사용하면 엔티티를 조회하는 시점에 함께 조회할 연관된 엔티티를 선택할 수 있다.
따라서 JPQL은 데이터를 조회하는 기능만 수행하면 되고 연관된 엔티티를 함께 조회하는 기능은 엔티티 그래프를 사용하면 된다.

그러므로 엔티티 그래프 기능을 적용하면 다음 JPQL만 사용된다.

select o from Order o
  where o.status = ?

엔티티 그래프 기능은 엔티티 조회시점에 연관된 엔티티들을 함께 조회하는 기능이다.
엔티티 그래프정적으로 정의하는 Named 엔티티 그래프동적으로 정의하는 엔티티 그래프가 있다.
먼저 Named 엔티티 그래프를 알아보자.

예제에 사용할 엔티티 모델은 다음과 같다.

Named 엔티티 그래프

다음 코드를 통해 주문(Order)을 조회할 때 연관된 회원(Member)도 함께 조회하는 엔티티 그래프를 사용해보자.

@NamedEntityGraph(name = "Order.withMember", attributeNodes = {
        @NamedAttributeNode("member")
})
@Entity
@Table(name = "ORDERS")
public class Order {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "order_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "member_id")
    private Member member; // 주문 회원
}

Named 엔티티 그래프는 @NamedENtityGraph로 정의한다.

  • name : 엔티티 그래프의 이름을 정의한다.
  • attributeNodes : 함께 조회할 속성을 선택한다. 이때 @NamedAttributeNode를 사용하고 그 값으로 함께 조회할 속성을 선택하면 된다.

위 예제 코드의 Order.member가 지연 로딩으로 설정되어 있지만, 엔티티 그래프에서 함께 조회할 속성으로 member를 선택했으므로 이 엔티티 그래프를 사용하면 Order를 조회할 때 연관된 member도 함께 조회할 수 있다.

참고로 둘 이상 정의하려면 @NamedEntityGraphs를 사용하면 된다.

em.find()에서 엔티티 그래프 사용

다음 예를 통해서 엔티티 그래프를 사용하는 코드를 보자.

@Test
@DisplayName("")
void Test() {
    Member member = new Member("member1", 20);
    em.persist(member);

    Order order = new Order(member);
    em.persist(order);

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

    EntityGraph<?> graph = em.getEntityGraph("Order.withMember");

    Map hints = new HashMap<>();
    hints.put("jakarta.persistence.fetchgraph", graph);

    Order findOrder = em.find(Order.class, order.getId(), hints);
}

Named 엔티티 그래프를 사용하려면 정의한 엔티티 그래프를 em.getEntityGraph("Order.withMember")를 통해서 찾아오면 된다.
엔티티 그래프는 JPA의 힌트 기능을 사용해서 동작하는데 힌트의 키로 jakarta.persistence.fetchgraph를 사용하고 힌트의 값으로 찾아온 엔티티 그래프를 사용하면 된다.
em.find(Order.class, order.getId(), hints)Order엔티티를 조회할 때 힌트 정보도 포함했다.

실행된 sql은 다음과 같다.

select
        o1_0.order_id,
        o1_0.member_id,
        m1_0.member_id,
        m1_0.age,
        m1_0.team_id,
        m1_0.username
    from
        orders o1_0
    join
        member m1_0
            on m1_0.member_id=o1_0.member_id
    where
        o1_0.order_id=?

subgraph

이번에는 Order -> OrderItem -> Item 까지 함께 조회해보자.

Order -> OrderItemOrder가 관리하는 필드지만 OrderItem -> ItemOrder가 관리하는 필드가 아니다. 이때는 다음과 같이 subgraph를 사용하면 된다.

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.ArrayList;
import java.util.List;

@NamedEntityGraph(name = "Order.withAll", attributeNodes = {
        @NamedAttributeNode("member"),
        @NamedAttributeNode(value = "orderItems", subgraph = "orderItems")
        },
        subgraphs = @NamedSubgraph(name = "orderItems", attributeNodes = {
                @NamedAttributeNode("item")
        })
)
@Entity
@Table(name = "ORDERS")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @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<>();

    public Order(Member member) {
        this.member = member;
    }

    ...
}


@Entity
@Table(name = "ORDER_ITEM")
public class OrderItem {

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

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id")
    private Order order;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "item_id")
    private Item item;

}

위의 예제에서는 Order.withAll이라는 Named 엔티티 그래프를 정의했다.
이 엔티티 그래프는 Order -> Member, Order -> OrderItem, OrderItem -> Item의 객체 그래프를 함께 조회한다.

이때 OrderItem -> ItemOrder의 객체 그래프가 아니므로 subgraphs 속성으로 정의해야 한다.

이 속성은 @NamedSubgraph를 사용해서 서브 그래프를 정의한다.
여기서는 orderItems라는 이름의 서브 그래프가 item을 함께 조회하도록 정의했다.
사용하는 코드를 보자.


@DataJpaTest
class OrderTest {

    @Autowired
    EntityManager em;

    @Test
    @DisplayName("")
    void Test() {
        Member member = new Member("member1", 20);
        em.persist(member);

        Order order = new Order(member);
        em.persist(order);

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


        Map hints = new HashMap<>();
        hints.put("jakarta.persistence.fetchgraph", em.getEntityGraph("Order.withAll"));

        Order findOrder = em.find(Order.class, order.getId(), hints);
    }

}

Order.withAll이라는 Named 엔티티 그래프를 사용해서 Order 엔티티를 조회했다.

실행된 쿼리는 다음과 같다.
실행된 SQL을 보면 엔티티 그래프에서 지정한 엔티티들을 함께 조회한다.

select
        o1_0.order_id,
        o1_0.member_id,
        m1_0.member_id,
        m1_0.age,
        m1_0.team_id,
        m1_0.username,
        oi1_0.order_id,
        oi1_0.order_item_id,
        i1_0.item_id
    from
        orders o1_0
    join
        member m1_0
            on m1_0.member_id=o1_0.member_id
    left join
        order_item oi1_0
            on o1_0.order_id=oi1_0.order_id
    left join
        item i1_0
            on i1_0.item_id=oi1_0.item_id
    where
        o1_0.order_id=?

JPQL에서 엔티티 그래프 사용

JPQL에서 엔티티 그래프를 사용하는 방법em.find()와 동이랗게 힌트만 추가하면 된다.
다음 예를 보자.

@Test
@DisplayName("jpql에서 엔티티그래프 사용")
void entityGraphWithJpql() {
    Member member = new Member("member1", 20);
    em.persist(member);

    Order order = new Order(member);
    em.persist(order);

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

    Order findOrder = em.find(Order.class, order.getId());

    em.clear();

    List<Order> resultList = em.createQuery("select o from Order o where o.id = :orderId",
                    Order.class)
            .setParameter("orderId", findOrder.getId())
            .setHint("jakarta.persistence.fetchgraph", em.getEntityGraph("Order.withAll"))
            .getResultList();
}

실행된 SQL을 보면 연관된 엔티티도 함께 조회한다.

select
    o1_0.order_id,
    o1_0.member_id,
    m1_0.member_id,
    m1_0.age,
    m1_0.team_id,
    m1_0.username,
    oi1_0.order_id,
    oi1_0.order_item_id,
    i1_0.item_id
from
    orders o1_0
join
    member m1_0
        on m1_0.member_id=o1_0.member_id
left join
    order_item oi1_0
        on o1_0.order_id=oi1_0.order_id
left join
    item i1_0
        on i1_0.item_id=oi1_0.item_id
where
    o1_0.order_id=?

참고

다음 코드 같이 Order.member는 필수 관게로 설정되어 있다.

@ManyToOne(fetch = FetchType.LAZY, optional = false) // 필수 관계로 설정
@JoinColumn(name = "member_id")
private Member member; // 주문 회원

em.find()에서 엔티티 그래프를 사용하면 하이버네이트는 필수 관계를 고려해서 SQL 내부조인을 사용하지만 JPQL에서 엔티티 그래프를 사용할 때는 항상 SQL 외부 조인을 사용한다.
만약 SQL 내부 조인을 사용하려면 다음처럼 내부 조인을 명시하면 된다.

select o from Order o join fetch o.member where o.id = :orderId

동적 엔티티 그래프

엔티티 그래프를 동적으로 구성하려면 createEntityGraph()메서드를 사용하면 된다.

public <T> EntityGraph<T> createEntityGraph(Class<T> rootType);

처음에 사용한 Named 엔티티 그래프를 다음 예에서 동적으로 구성해보자.

@Test
void entityGraphTest() {
    Member member = new Member("member1", 20);
    em.persist(member);

    Order order = new Order(member);
    em.persist(order);

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

    EntityGraph<Order> graph = em.createEntityGraph(Order.class);
    graph.addAttributeNodes("member");

    Map hints = new HashMap<>();
    hints.put("jakarta.persistence.fetchgraph", graph);

    Order findOrder = em.find(Order.class, order.getId(), hints);
}

다음은 실행된 쿼리다.

select
        o1_0.order_id,
        o1_0.member_id,
        m1_0.member_id,
        m1_0.age,
        m1_0.team_id,
        m1_0.username
    from
        orders o1_0
    join
        member m1_0
            on m1_0.member_id=o1_0.member_id
    where
        o1_0.order_id=?

위의 예에서 em.createEntityGraph(Order.class)를 사용해서 **동적으로 엔티티 그래프를 만들었다. 그리고 graph.addAttributeNodes("member")를 사용해서 Order.member 속성을 엔티티 그래프에 포함했다.

다음 예를 통해서 조금 더 복잡한 subgraph기능을 동적으로 구성해보자.

@Test
void entityGraphTest() {
    Member member = new Member("member1", 20);
    em.persist(member);

    Order order = new Order(member);
    em.persist(order);

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

    EntityGraph<Order> graph = em.createEntityGraph(Order.class);
    graph.addAttributeNodes("member");
    Subgraph<Object> orderItems = graph.addSubgraph("orderItems");
    orderItems.addAttributeNodes("item");

    Map hints = new HashMap<>();
    hints.put("jakarta.persistence.fetchgraph", graph);

    Order findOrder = em.find(Order.class, order.getId(), hints);
}

실행된 SQL은 다음과 같다.

select
        o1_0.order_id,
        o1_0.member_id,
        m1_0.member_id,
        m1_0.age,
        m1_0.team_id,
        m1_0.username,
        oi1_0.order_id,
        oi1_0.order_item_id,
        i1_0.item_id
    from
        orders o1_0
    join
        member m1_0
            on m1_0.member_id=o1_0.member_id
    left join
        order_item oi1_0
            on o1_0.order_id=oi1_0.order_id
    left join
        item i1_0
            on i1_0.item_id=oi1_0.item_id
    where
        o1_0.order_id=?

위의 예는 graph.addSubgraph("orderItems") 메서드를 사용해서 서브 그래프를 만들었다.
그리고 서브 그래프가 item 속성을 포함하도록 했다.

엔티티 그래프 정리

지금까지 엔티티 그래프를 알아보았는데 정리해보면,

ROOT에서 시작

엔티티 그래프는 항상 조회하는 엔티티의 ROOT에서 시작해야 한다.
당연한 이야기지만, Order 엔티티를 조회하는데 Member부터 시작하는 엔티티 그래프를 사용하면 안 된다.

이미 로딩된 엔티티

다음처럼 영속성 컨텍스트에 해당 엔티티가 이미 로딩되어 있으면 엔티티 그래프가 적용되지 않는다.(아직 초기화되지 않은 프록시에는 엔티티 그래프가 적용된다.)

Order order1 = em.find(Order.class, orderId); // 이미 조회
hints.put("jakarta.persistence.fetchgraph", em.getEntityGraph("Order.withMember"));
Order order2 = em.find(Order.clas, orderId, hints);

이 경우 조회된 order2에는 엔티티 그래프가 적용되지 않고 처음 조회한 order1과 같은 인스턴스가 반환된다.

fetchgraph, loadgraph

예제에서는 jakarta.persistence.fetchgraph 힌트를 사용해서 엔티티 그래프를 조회했다.
이것은 엔티티 그래프에 선택한 속성만 함께 조회한다.
반면에 jakarta.persistence.loadgraph속성은 엔티티 그래프에 선택한 속성뿐만 아니라 글로벌 fetch 모드가 FetchType.EAGER로 설정된 연관관계도 포함해서 함께 조회한다.

참고 (집필 당시의 참고)

하이버네이트 4.3.10.Final 버전에서는 loadgraph 기능이 em.find()를 사용할 때는 정상 동작하지만 JPQL을 사용할 때는 정상 동작하지 않고 fetchgraph와 같은 방식으로 동작한다.

3. 요약

  • 리스너를 사용하면 엔티티에서 발생한 이벤트를 받아서 처리할 수 있다.
  • 페치 조인은 객체지향 쿼리를 사용해야 하지만 엔티티 그래프를 사용하면 객체 지향 쿼리를 사용하지 않아도 원하는 객체 그래프를 한 번에 조회할 수 있다.
728x90
Comments