Engineering Note

[JPA] 지연로딩과 N+1문제해결, 실행 쿼리 비교 본문

Server/JPA ORM

[JPA] 지연로딩과 N+1문제해결, 실행 쿼리 비교

Software Engineer Kim 2025. 11. 9. 18:18

본 포스팅은 인프런 김영한님의 강의를 보고 정리한 글입니다.

 

강의의 목표는 지연로딩으로 발생하는 문제(N+1문제)의 원인을 알아보고 해결하는 것입니다. 

 

 

강의보고 흐름 그대로 정리한 글

 

Order Entity

package jpabook.jpashop.domain;

import static jakarta.persistence.FetchType.LAZY;

import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import lombok.Getter;
import lombok.Setter;

@Entity
@Table(name = "orders")
@Getter
@Setter
public class Order {

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

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

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

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

    private LocalDateTime orderDate;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;//주문상태 [ORDER, CANCEL]

    //==연관관계 메서드==//
    public void setMember(Member member) {
        this.member = member;
        member.getOrders().add(this);
    }

    public void addOrderItem(OrderItem orderItem) {
        orderItems.add(orderItem);
        orderItem.setOrder(this);
    }

    public void setDelivery(Delivery delivery) {
        this.delivery = delivery;
        delivery.setOrder(this);
    }

    //==생성 메서드==//
    public static Order createOrder(Member member, Delivery delivery, OrderItem... orderItems) {
        Order order = new Order();
        order.setMember(member);
        order.setDelivery(delivery);

        for (OrderItem orderItem : orderItems) {
            order.addOrderItem(orderItem);
        }

        order.setStatus(OrderStatus.ORDER);
        order.setOrderDate(LocalDateTime.now());
        return order;
    }

    //==비즈니스 로직==//

    /**
     * 주문 취소
     */
    public void cancel() {
        if (delivery.getStatus() == DeliveryStatus.COMP) {
            throw new IllegalStateException("이미 배송완료된 상품은 취소가 불가능합니다.");
        }

        this.setStatus(OrderStatus.CANCEL);
        for (OrderItem orderItem : orderItems) {
            orderItem.cancel();
        }
    }

    //==조회 로직==//

    /**
     * 전체 주문 가격 조회
     */
    public int getTotalPrice() {
        int totalPrice = 0;
        for (OrderItem orderItem : orderItems) {
            totalPrice += orderItem.getTotalPrice();
        }
        return totalPrice;
    }
}

 

 


Order는 Member와 다대일 관계입니다. Delivery(배송정보)와는 일대일 관계입니다. API 스펙에서 Entity를 직접 노출하는 건 좋지 않으므로, 반환하기 위한 DTO 클래스를 만들었습니다.

@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(); //Lazy 초기화
        orderDate = order.getOrderDate();
        orderStatus = order.getStatus();
        address = order.getDelivery().getAddress(); //Lazy 초기화
    }
}

 

 

아래 코드는 Order를 조회하고 DTO클래스로 변환해서 반환하는 API입니다.

@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> ordersV2() {
    List<Order> orders = orderRepository.findAllByString(new OrderSearch());
    List<SimpleOrderDto> result = orders.stream()
            .map(o -> new SimpleOrderDto(o))
            .collect(Collectors.toList());
    return result;
}

 

 

지연로딩은 연관관계에 있는 Entity를 필요할 때 조회하는 설정으로 처음 Order를 조회할 때는 함께 조회하지 않습니다. order.getMember.getName(); 같은 코드로 그래프 탐색을 할 때 조회를 시작합니다. delivery도 마찬가지입니다. 하지만 이렇게 되면 문제가 있습니다. 

Order가 N개 라면 N번 만큼 Meber 조회를 위한 쿼리가 실행되고, 다시 N번 만큼 Delivery를 조회하는 쿼리가 실행됩니다.

 

이러한 문제를 N+1문제라고 합니다.

 

XToOne 관계는 기본적으로 fetch Type이 EAGER이고, EAGER는 실행되는 쿼리를 예측할 수 없어 사용하지 않습니다. 그래서 LAZY로 설정을 지정해주어야합니다. 하지만 LAZY로딩으로 설정하면 초기에는 프록시객체로 세팅되고, 실제 필요할 때 쿼리가 개별적으로 실행되면서 예상한 것보다 쿼리가 많이 실행되는 문제가 있습니다. 

 

 

 

 

해결 방법과 실행쿼리 비교

 

쿼리를 비교하기 전에 DB에서 orders테이블을 조회해보면 2건의 데이터가 있다.

 

 

아래는 '/api/v2/simple-orders' 요청했을 때 실행된 쿼리다.

2025-11-09T18:38:26.984+09:00 DEBUG 76411 --- [nio-8080-exec-6] org.hibernate.SQL                        : 
    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 
    fetch
        first ? rows only
2025-11-09T18:38:26.990+09:00 DEBUG 76411 --- [nio-8080-exec-6] org.hibernate.SQL                        : 
    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=?
2025-11-09T18:38:26.992+09:00 DEBUG 76411 --- [nio-8080-exec-6] org.hibernate.SQL                        : 
    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=?
2025-11-09T18:38:26.992+09:00 DEBUG 76411 --- [nio-8080-exec-6] org.hibernate.SQL                        : 
    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=?
2025-11-09T18:38:26.993+09:00 DEBUG 76411 --- [nio-8080-exec-6] org.hibernate.SQL                        : 
    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=?

 

 

List<Order> orders = orderRepository.findAllByString(new OrderSearch());

여기서 실행된 쿼리가 아래 쿼리이다.

 

  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 
    fetch
        first ? rows only

 

그리고 조회한 Order 리스트에서 SimpleOrderDto로 변환하는 과정에서 

List<SimpleOrderDto> result = orders.stream()
        .map(o -> new SimpleOrderDto(o))
        .collect(Collectors.toList());

 

 

public SimpleOrderDto(Order order) {
    orderId = order.getId();
    name = order.getMember().getName(); //Lazy 초기화
    orderDate = order.getOrderDate();
    orderStatus = order.getStatus();
    address = order.getDelivery().getAddress(); //Lazy 초기화
}

 

 

Order 리스트의 Order 요소 하나마다 Member, Deliver가 하나씩 호출되면서 전체적으로 4개의 쿼리가 추가로 호출되었다.

 

 

이러한 문제를 해결하기 위해서는 Order를 조회할 때 지연로딩으로 설정된 Entity도 한 번에 조회해서 해결할 수 있습니다. 그렇다고 EAGER로 설정하면 안되고, fetch join으로 Entity를 한 번에 조회하는 방법을 사용하면 됩니다. 아래 코드는 문제를 해결한 코드입니다.

 

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();
}

 

@GetMapping("/api/v3/simple-orders")
public List<SimpleOrderDto> ordersV3() {
    List<Order> orders = orderRepository.findAllWithMemberDelivery();

    List<SimpleOrderDto> result = orders.stream()
            .map(o -> new SimpleOrderDto(o))
            .collect(Collectors.toList());

    return result;
}

 

fetch join으로 Order Entity와 연관된 Member, Deliver를 한 번에 조회한 결과 당연히 쿼리도 한 번만 수행되었다.

 

2025-11-09T19:47:01.731+09:00 DEBUG 76411 --- [nio-8080-exec-1] org.hibernate.SQL                        : 
    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

 

 

이번 강의에서는 컬렉션이 아니면서 Lazy로딩으로 설정된 ManyToOne과 OneToOne 연관관계에 있는 Entity를 조회할 때 실제 어떤 문제가 있는지 알아보고 해결하는 과정을 살펴보았다. 이번 강의를 통해 ToOne 관계에 대해 Lazy 로딩이 필수라는 걸 알았고, 이렇게 설정된 Lazy 로딩의 문제를 해결하기 위해 fetch join을 어떻게 사용해야 하는지도 알 수 있었다.

 

 

배운 내용

ManyToOne, OneToOne 연관관계는 기본값이 EAGER라서 LAZY로 설정을 해주어야 하고, 양방향 연관관계에 있다면 JsonIgnore를 통해 순환 참조를 막아주어야 한다. 대표적인 예로 Order와 Member는 다대일 관계이다. 회원은 여러 주문을 할 수 있기 때문이다. 그래서 Order와 Member는 ManyToOne 관계로 설정할 수 있다. 

그런데 이 Order를 조회하고 Membe가 필요 없다면 상관이 없지만 Member 데이터도 필요하다면, order.getMember().getName();으로 접근할 수 있고, 이때 Lazy 초기화가 적용된다. 그리고 초기화를 위해 추가 쿼리가 수행되면서 JPA에서 유명한 N+1문제가 생긴다.

다시 정리하면 N+1문제는 지연로딩으로 설정된 엔티티를 조회할 때 추가로 쿼리가 수행되는 문제라고 할 수 있다.

 

참고자료 : JPA 활용2 11강(간단한 주문조회V2), 12강 간단한 주문 조회 V3

Comments