JPA 사실에 대한 오해

솔트커피·2021년 8월 27일
0

JPA

목록 보기
8/11
post-thumbnail

JPA에 대해서 흔히 잘못 할고 있는 사실 중 하나가 엔티티와의 연관 관계는 단방향이면 매핑이 끝나는 것은 맞지만 성능상 일대다 단방향 관계를 가질 때 자식 엔티티를 영속성 전이를 통해 저장하게 되면 트랜잭션 커밋 시점에 플러시가 호출되어 insert 쿼리가 발생 한 후 자식 엔티티에 대해서 update문이 자식 엔티티 수만큼 수행되기 때문에 성능상의 문제가 발생합니다.

다음은 OrderDetail → Order 단방향 연관 관계를 가질 때 발생하는 쿼리를 확인해 보겠습니다.

@ManyToOne 단방향 관계

Order.java

// Order(주문정보) 엔티티
@Getter
@Setter
@Entity
@Table(name = "Orders")
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    @Column(name = "order_id")
    private Long orderId;

    @Column(name = "order_dt")
    private LocalDateTime orderDate;
}

OrderDetails.java

// OrderDetails(주문내역) 엔티티
@Getter
@Setter
@Entity
@Table(name = "OrderDetails")
public class OrderDetail {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    @Column(name = "order_detail_id")
    private Long orderDetailId;

		//영속성 전이 설정
    @ManyToOne(cascade = CascadeType.ALL)
    @JoinColumn(name = "order_id")
    private Order order;

    private String type;

    private String description;
}

간단한 테스트를 위해 setter를 넣었지만 실환경에서 영속성을 이용한 수정 과정 중 공유 참조를 하여 실수로 값 변경이 잘못되면 큰 재앙을 초래하기 때문에 setter 메소드를 써야할지 고민해야 된다고 생각합니다.

// 실행 클래스
@Bean
CommandLineRunner onStartUp(OrderService orderService) {
    return args -> {
        orderService.createOrderWithDetails();
    };
}

@Service
public class OrderService {

    private final OrderDetailRepository orderDetailRepository;

    public OrderService(OrderDetailRepository orderDetailRepository) {
        this.orderDetailRepository = orderDetailRepository;
    }

    @Transactional
    public void createOrderWithDetails() {
        
        Order order = new Order();
        order.setOrderDate(LocalDateTime.now());

        OrderDetail orderDetail1 = new OrderDetail();
        orderDetail1.setOrder(order);
        orderDetail1.setType("type1");
        orderDetail1.setDescription("order1-type1");

        OrderDetail orderDetail2 = new OrderDetail();
        orderDetail2.setOrder(order);
        orderDetail2.setType("type2");
        orderDetail2.setDescription("order1-type2");

        orderDetailRepository.saveAll(Arrays.asList(orderDetail1, orderDetail2));
    }

CommandLineRunner를 스프링 빈으로 등록하여 웹 애플리케이션 실행 시에 Order 엔티티와 OrderDetail 엔티티의 영속성에 저장할 때 자식 엔티티인 OrderDetail에 영속성 전이를 설정했기 때문에 자식 엔티티를 영속성 컨텍스트에 저장하면 자동으로 부모 엔티티도 영속화 됩니다.

실제 트랜잭션이 커밋되는 시점에 플러시가 호출되어 insert 쿼리가 부모 1, 자식 2개에 대해서만 DB에 보내는 것을 알 수 있습니다.

실행결과

@OneToMany 단방향 관계

이제 @OneToMany 단방향 연관 관계일 때 영속성 컨텍스트에 저장 시 발생하는 쿼리 수를 확인해보겠습니다.

@Getter
@Setter
@Entity
@Table(name = "Orders")
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    @Column(name = "order_id")
    private Long orderId;

    @Column(name = "order_dt")
    private LocalDateTime orderDate;

    @OneToMany(cascade = CascadeType.ALL)
    @JoinColumn(name = "order_id")
    List<OrderDetail> orderDetails = new ArrayList<OrderDetail>();
}

@Getter
@Setter
@Entity
@Table(name = "OrderDetails")
public class OrderDetail {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    @Column(name = "order_detail_id")
    private Long orderDetailId;

    private String type;
    private String description;

}

@Transactional
public void createOrderWithDetails() {
   Order order = new Order();
    order.setOrderDate(LocalDateTime.now());
    orderRepository.save(order);

    OrderDetail orderDetail1 = new OrderDetail();
    orderDetail1.setType("type1");
    orderDetail1.setDescription("order1-type1");

    OrderDetail orderDetail2 = new OrderDetail();
    orderDetail2.setType("type2");
    orderDetail2.setDescription("order1-type2");

    order.getOrderDetails().add(orderDetail1);
    order.getOrderDetails().add(orderDetail2);
}

위의 코드를 보면 일대다 단방향 연관 관계를 가질 경우에는 Order 엔티티가 연관관계의 주인이지만 실제로 JPA에서 영속성 저장 후 insert 쿼리를 보면 외래 키에 해당하는 OrderDetail 테이블에 들어가는 것을 확인할 수 있습니다.

그렇기 때문에 플러시를 호출하면 OrderDetail 엔티티에 대한 update 쿼리가 발생하기 때문에 성능적으로 좋지 않습니다.

@OneToMany 단방향 연관 관계 실행 결과

다음과 같이 insert 쿼리 3개, update 쿼리 2개가 발생한 것을 확인 할 수 있습니다.

만약 자식 엔티티의 수가 더 많았다면 자식 엔티티 수 만큼 쿼리가 DB에 전송될 것 입니다.

@ManyToOne 양방향 관계

그럼 이제 오해를 풀기 위해 @ManyToOne 양방향 관계로 설정한 후 영속성 컨텍스트에 저장 시 발생하는 쿼리를 확인해 보겠습니다.

@Getter
@Setter
@Entity
@Table(name = "Orders")
public class Order {
  
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    @Column(name = "order_id")
    private Long orderId;

    @Column(name = "order_dt")
    private LocalDateTime orderDate;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    List<OrderDetail> orderDetails = new ArrayList<OrderDetail>();
}

@Getter
@Setter
@Entity
@Table(name = "OrderDetails")
public class OrderDetail {
    
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    @Column(name = "order_detail_id")
    private Long orderDetailId;

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

    private String type;
    private String description;

}

@Transactional
public void createOrderWithDetails() {
    Order order = new Order();
    order.setOrderDate(LocalDateTime.now());

    OrderDetail orderDetail1 = new OrderDetail();
    orderDetail1.setOrder(order);
    orderDetail1.setType("type1");
    orderDetail1.setDescription("order1-type1");

    OrderDetail orderDetail2 = new OrderDetail();
    orderDetail2.setOrder(order);
    orderDetail2.setType("type2");
    orderDetail2.setDescription("order1-type2");

    order.getOrderDetails().add(orderDetail1);
    order.getOrderDetails().add(orderDetail2);

    orderRepository.save(order);
}

일대다 양방향 연관 관계 매핑 시 영속성 컨텍스트에 저장을 하면 다대일 단방향 연관 관계처럼 insert 쿼리가 3개만 나가는 것을 확인 할 수 있습니다.

@ManyToOne 양방향 연관관계 실행 결과

위의 예제 코드를 통해ㅔ 양방향 매핑이 단방향 매핑보다는 조금 더 복잡하지만, 실환경에서 성능상의 이점을 누릴 수 있다는 사실을 깨닫게 되었습니다.

N+1 문제

N+1 문제는 JPA를 사용한 프로젝트에서 가장 많이 겪게되는 문제입니다.

엔티티에 대해 하나의 쿼리로 N개의 레코드를 가져왔을 때, 연관 관계 엔티티를 가져오기 위해 쿼리를 N번 추가적으로 수행하는 문제를 말합니다.

이 문제는 즉시로딩에서 발생한다고 많이들 알고 있지만 사실 지연로딩에서도 N+1은 존재합니다.

지연로딩은 조회하려는 엔티티를 가지고 올 때 연관된 엔티티는 조회하지 않고 실제로 객체 그래프 탐색으로 연관된 엔티티를 사용하는 시점에 프록시를 통해서 조회를 요청하는 fetch 전략입니다.

따라서 결국 영속성 컨텍스트에 해당 엔티티가 존재하지 않는다면 DB를 통해 쿼리를 발송해야 하기 때문에 피할 수 없습니다.

대표적인 해결 방법은 2가지가 있습니다.

  • Fetch Join
  • Entity Graph

JPA는 단건 조회(findOne())를 할 경우에는 외래 키에 null 허용 여부에 따라 선택적 관계 또는 필수적 관계를 가지게 됩니다.

이 때 JPA는 선택적 관계이면 null이 존재할 수 있다고 가정하여 left outer join으로 연관된 엔티티를 한번에 가져오게 됩니다.

필수적 관계일 경우에는 null을 허용하지 않기 때문에 최적의 성능으로 inner join을 사용하여 연관 관계를 가져옵니다.

하지만 JPQL로 작성한 findAll()과 같은 여러 건의 엔티티를 조회하는 메소드는 실제로 조회했을 때 DB에서 연관 관계를 가진 엔티티를 join 해서 가져오지만 영속 상태로 반환하지 않습니다.

그리고 스프링 Data JPA는 다시 글로벌 패치 전략을 보고 즉시로딩일 경우 DB에 연관 관계를 가진 엔티티를 다시 조회하게 됩니다. 이 때문에 N+1 문제가 발생하는 겁니다.

fetch join을 사용하여 N+1 문제를 해결할 수 있는지 확인해보겠습니다.

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("select o from Order o" +
    " join o.orderDetails od")
    public List<Order> getAllOrderWithDetails();

}

// 실행결과
@Bean
CommandLineRunner onStartUp(OrderService orderService) {
    return args -> {
        orderService.createOrderWithDetails();
        orderService.getAllOrderWithDetails();
    };
}

Hibernate: select order0_.order_id as order_id1_1_, order0_.order_dt as order_dt2_1_ from orders order0_ inner join order_details orderdetai1_ on order0_.order_id=orderdetai1_.order_id

Hibernate: select orderdetai0_.order_id as order_id4_0_0_, orderdetai0_.order_detail_id as order_de1_0_0_, orderdetai0_.order_detail_id as order_de1_0_1_, orderdetai0_.description as descript2_0_1_, orderdetai0_.order_id as order_id4_0_1_, orderdetai0_.type as type3_0_1_ from order_details orderdetai0_ where orderdetai0_.order_id=?

Hibernate: select orderdetai0_.order_id as order_id4_0_0_, orderdetai0_.order_detail_id as order_de1_0_0_, orderdetai0_.order_detail_id as order_de1_0_1_, orderdetai0_.description as descript2_0_1_, orderdetai0_.order_id as order_id4_0_1_, orderdetai0_.type as type3_0_1_ from order_details orderdetai0_ where orderdetai0_.order_id=?

getAllOrderWithDetails()는 Orderr 엔티티와 OrderDetail 엔티티를 조인하는 메소드이지만 실행 결과를 보면 select 쿼리문이 DB에 3번 전송되는 것이 확인됩니다.

만약 OrderDetail이 같은 Order를 참조하게 된다면 추가로 OrderDetail를 한번만 조회하겠지만 보통 다른 Order 엔티티를 참조하기 때문에 이미 처음에 조회된 Order 엔티티들의 수만큼 참조하는 OrderDetail 엔티티에 대한 조회가 N+1만큼 이루어집니다.

하지만 join fetch를 사용하면 아래와 같은 실행 결과가 나옵니다.

Fetch Join

public interface OrderRepository extends JpaRepository<Order, Long> {
    @Query("select o from Order o" +
    " join fetch o.orderDetails od")
    public List<Order> getAllOrderWithDetails();
}

//실행 결과
Hibernate: select order0_.order_id as order_id1_1_0_, orderdetai1_.order_detail_id as order_de1_0_1_, order0_.order_dt as order_dt2_1_0_, orderdetai1_.description as descript2_0_1_, orderdetai1_.order_id as order_id4_0_1_, orderdetai1_.type as type3_0_1_, orderdetai1_.order_id as order_id4_0_0__, orderdetai1_.order_detail_id as order_de1_0_0__ from orders order0_ inner join order_details orderdetai1_ on order0_.order_id=orderdetai1_.order_id

각각의 Order를 OrderDetail이 참조하고 있지만 하나의 쿼리만 발생하는 것을 확인할 수 있습니다.

Entity Graph

객체 그래프 방식은 실제 엔티티와의 연관 관계가 복잡해질 때 어디까지 연관된 엔티티를 조회할지 개발자가 직접 정의해서 사용할 수 있는 방법입니다.

이 방법은 도메인에 @NamedEntityGraphs 어노테이션을 적용하여 Repository에서 도메인에 정의된 @NamedEntityGraphsname을 이용하여 @Query 메소드와 함께 사용할 수 있습니다.

Member와 MemberDetails가 @OneToMany 양방향 연관 관계를 가지고 있을 때 객체 그래프 방법으로 조인하는 예제 코드를 살펴봅니다.

@NamedEntityGraph(name = "memberWithDetails", attributeNodes = {
        @NamedAttributeNode("details")
})
@Getter
@Setter
@Entity
@Table(name = "Members")
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    @Column(name = "member_id")
    private Long memberId;

    private String name;

    @Column(name = "create_dt")
    private LocalDateTime createDate;

    @OneToMany(cascade = CascadeType.ALL, mappedBy = "member")
    private List<MemberDetail> details = new ArrayList<>();

}

@Getter
@Setter
@Entity
@Table(name = "MemberDetails")
public class MemberDetail {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    @Column(name = "member_detail_id")
    private Long memberDetailId;

    @ManyToOne
    @JoinColumn(name = "member_id")
    private Member member;

    private String type;

    private String description;

}

public interface MemberRepository extends JpaRepository<Member, Long> {
    // @EntityGraph로 설정한 Entity Graph를 이용
    @EntityGraph("memberWithDetails")
    @Query("select m from Member m")
    List<Member> getAllBy();
}

//실행 결과
Hibernate: select member0_.member_id as member_i1_3_0_, details1_.member_detail_id as member_d1_2_1_, member0_.create_dt as create_d2_3_0_, member0_.name as name3_3_0_, details1_.description as descript2_2_1_, details1_.member_id as member_i4_2_1_, details1_.type as type3_2_1_, details1_.member_id as member_i4_2_0__, details1_.member_detail_id as member_d1_2_0__ from members member0_ left outer join member_details details1_ on member0_.member_id=details1_.member_id

@NamedAttributeNode("details")는 Member가 참조하고 있는 MemberDetail컬렉션 객체의 참조 변수를 넣었습니다.

즉, 연관 관계를 조회할 때 MemberDetail 까지 조회하겠다는 뜻입니다.

그리고 MemberRepository에 @Query 어노테이션 안에 있는 쿼리는 Member만 조회하지만, @EntityGraph 어노테이션에서 Member 도메인에 정의되어 있는 memberWithDetails를 명시해주었기 때문에 실제로 getAllBy() 메소드를 호출하게 되면 Member와 MemberDetail을 조인한 쿼리문을 DB에 전송하여 조회하게 됩니다.

출처 : https://velog.io/@sa1341/JPA-사실에-대한-오해
자바 ORM 표준 JPA 프로그래밍 (김영한 저)

0개의 댓글