본문 바로가기
Back-end/Spring Boot + REST API

스프링부트 API JPA 최적화 ToOne 관계 (N+1 문제) , 페치 조인

by javapp 자바앱 2023. 12. 19.
728x90

 

ToOne 관계 (N+1 문제) , 페치 조인 

 

지연 로딩과 조회 성능 최적화

주문(Order) 내의 ToOne 관계인 member, delivery 지연로딩 조회

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

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;
    // = new ProxyMember()를 생성해서(하이버네이트에서) 넣어둔다.
    // = new ByteBuddyInterceptor() (프록시 기술)

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();

    @OneToOne(fetch = FetchType.LAZY , cascade = CascadeType.ALL)
    @JoinColumn(name = "delivery_id")
    private Delivery delivery;
    
	...

 

페치 조인 최적화

@GetMapping("/api/v2/simple-orders")
public ResponseEntity<Result> ordersV2() {
	// order 2개
	// 1 + N(2) -> 회원 N번 + 배송 N번 
    List<Order> orders = repository.findAllByString(new OrderSearch());

    List<SimpleOrderDto> result = orders.stream()
            .map(o -> new SimpleOrderDto(o))
            .collect(Collectors.toList());
    return ResponseEntity.ok(new Result(result.size(), result));
}

모든 주문 건을 조회

 

@Data
static class SimpleOrderDto {
    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;

    public SimpleOrderDto(Order order) {
        orderId = order.getId();
        name = order.getMember().getName(); // getMember 까지는 프록시객체(SQL 안날라감)
        orderDate = order.getOrderDate();
        orderStatus = order.getStatus();
        address = order.getDelivery().getAddress(); // getDelivery(): Lazy 강제 초기화
    }
}

값을 받는 DTO

 

 

결과

{
    "count": 2,
    "data": [
        {
            "orderId": 1,
            "name": "userA",
            "orderDate": "2023-10-02T11:39:00.157897",
            "orderStatus": "ORDER",
            "address": {
                "city": "서울",
                "street": "1",
                "zipcode": "15352"
            }
        },
        {
            "orderId": 2,
            "name": "userB",
            "orderDate": "2023-10-02T11:39:00.204814",
            "orderStatus": "ORDER",
            "address": {
                "city": "진주",
                "street": "2",
                "zipcode": "2222"
            }
        }
    ]
}

2개의 row 값 호출

한번에 잘 나온 것 같지만 콘솔창을 살펴보면..

 

Hibernate: select o1_0.order_id,o1_0.delivery_id,o1_0.member_id,o1_0.order_date,o1_0.status from orders o1_0 join member m1_0 on m1_0.member_id=o1_0.member_id limit ?

Hibernate: select m1_0.member_id,m1_0.city,m1_0.street,m1_0.zipcode,m1_0.name from member m1_0 where m1_0.member_id=?

Hibernate: select d1_0.delivery_id,d1_0.city,d1_0.street,d1_0.zipcode,d1_0.status from delivery d1_0 where d1_0.delivery_id=?

Hibernate: select o1_0.order_id,o1_0.delivery_id,o1_0.member_id,o1_0.order_date,o1_0.status from orders o1_0 where o1_0.delivery_id=?

Hibernate: select m1_0.member_id,m1_0.city,m1_0.street,m1_0.zipcode,m1_0.name from member m1_0 where m1_0.member_id=?

Hibernate: select d1_0.delivery_id,d1_0.city,d1_0.street,d1_0.zipcode,d1_0.status from delivery d1_0 where d1_0.delivery_id=?

각 row 마다 order -> member 1, delivery 1  -> member 2, delivery 2 호출이 됨

-> 1 + N(2) -> 회원 N번 + 배송 N번
지연로딩은 영속성 컨테스트에서 조회하므로, 이미 조회된 경우 쿼리 날리지 않음

 

Fetch Join

  • 연관된 엔티티나 컬렉션을 한 번에 같이 조회
  • 연관된 엔티티는 프록시가 아닌 실제 엔티티
    • 연관된 엔티티를 사용해도 지연 로딩 발생 x
    • 엔티티가 영속성 컨텍스트에서 분리되어 준영속 상태가 되어도 연관된 엔티티 조회 가능
public List<Order> findAllWithMemberDelivery() {
    return em.createQuery(
            "SELECT o from Order o " +
                    " JOIN FETCH o.member m" +
                    " JOIN FETCH o.delivery d", Order.class)
            .getResultList();
}

 

하이버네이트

Hibernate: 
    select
        o1_0.order_id,
        d1_0.delivery_id,
        d1_0.city,
        d1_0.street,
        d1_0.zipcode,
        d1_0.status,
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipcode,
        m1_0.name,
        o1_0.order_date,
        o1_0.status 
    from
        orders o1_0 
    join
        member m1_0 
            on m1_0.member_id=o1_0.member_id 
    join
        delivery d1_0 
            on d1_0.delivery_id=o1_0.delivery_id

 

SELECT o FROM Order o

member, delivery로 나가는 쿼리가 많기 때문에

미리 fetch join 을 하는 것이 좋다 (권장)

 

 

DTO로 화면에 보낼 속성에 맞춰 바로 쿼리조회

public List<SimpleOrderQueryDto> findOrderDtos() {
    return em.createQuery(
            "SELECT NEW com.inflearn.optimization.domain.order.dto.SimpleOrderQueryDto(" +
                    "o.id, m.name, o.orderDate, o.status, d.address)" +
                    " FROM Order o" +
                    " JOIN o.member m" +
                    " JOIN o.delivery d", SimpleOrderQueryDto.class)
            .getResultList();
}

 

@Data
public class SimpleOrderQueryDto {
    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;

    public SimpleOrderQueryDto(Long orderId, String name, LocalDateTime
            orderDate, OrderStatus orderStatus, Address address) {
        this.orderId = orderId;
        this.name = name;
        this.orderDate = orderDate;
        this.orderStatus = orderStatus;
        this.address = address;
    }

    public SimpleOrderQueryDto(Order order) {
        orderId = order.getId();
        name = order.getMember().getName();
        orderDate = order.getOrderDate();
        orderStatus = order.getStatus();
        address = order.getDelivery().getAddress();
    }
}

new 명령어를 사용하여 DTO로 변환

페치 조인과 비교했을 때 크게 차이가 없음

API 스펙에 맞춰 DTO를 작성하기에 레포지토리 재사용성이 떨어짐

 

 

쿼리 방식 선택 권장 순서

  • 우선 엔티티를 DTO로 변환
  • 필요하면 페치 조인으로 성능을 최적화 - 대부분의 성능 이슈 해결
  • DTO로 직접 조회하는 방법 (new)
  • JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 사용

 

 

최적화를 위한 레포지토리 분리

OrderRepository - 순수한 엔티티 조회용도의 레포지토리

@Repository
public class OrderRepository {

재사용도 더 용이하게 가능

 

 

API 스펙용 레포지토리

@Repository
@RequiredArgsConstructor
public class SimpleOrderQueryRepository {
    private final EntityManager em;

    public List<SimpleOrderQueryDto> findOrderDtos() {
        return em.createQuery(
                        "SELECT NEW com.inflearn.optimization.domain.order.simpleOrderQuery.SimpleOrderQueryDto(" +
                                "o.id, m.name, o.orderDate, o.status, d.address)" +
                                " FROM Order o" +
                                " JOIN o.member m" +
                                " JOIN o.delivery d", SimpleOrderQueryDto.class)
                .getResultList();
    }
}

통계용, 전용, 복잡한 조인쿼리 등

쿼리 서비스 , 쿼리 레포지토리

화면에 디펜던시 

 

 

댓글