Engineering Note

[JPA] 지연로딩(Lazy Loading)으로 인한 N+1 문제 본문

Server/JPA ORM

[JPA] 지연로딩(Lazy Loading)으로 인한 N+1 문제

Software Engineer Kim 2025. 8. 22. 17:29

N+1 문제

: 데이터베이스 쿼리가 예상보다 많이 실행돼서 생기는 문제
 
 

N+1 문제가 발생하는 이유

 

지연로딩으로 인한 N+1 문제 


JPA는 연관관계가 있는 엔티티를 매핑 하는 기술을 지원한다. 그리고 연관관계 엔티티에 대해 필요할 때 데이터를 조회할 지 즉시 데이터를 조회할 지 필요 할 때 조회할 지 설정할 수 있다. 필요할 때 조회하는 기능을 지연로딩이라고 하고 즉시 조회하는 기능을 즉시로딩이라고 한다.
쿼리가 어떻게 나갈지 예측하기 어려워 즉시조회는 실무에서 거의 사용 하지 않고 대부분 지연로딩으로 설정한다. 지연로딩의 설정 방법은 다음과 같다.
 
주문 시스템을 개발한다고 할 때 Order Entity가 있고,  Member Entity가 있을 것이다. 한 회원이 여러번 주문할 수 있을 경우, Order와 Member의 관계는 다대일 관계고, Order와 Member는 단방향 대다일 관계로 설정할 수 있다. 
 
예시

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

 
 
 

package jpabook.jpashop.domain;

import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;

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

@Entity
@Getter
@Setter
public class Member {

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

    private String name;

    @Embedded
    private Address address;

    @JsonIgnore
    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();

}

 
 
이렇게 Order와 Member가 다대일 관계를 의미하는 @ManyToOne 을 설정할 수 있고, Order를 조회할 때, Member를 즉시 조회할지, 필요한 시점에 조회할 지 설정할 수 있다. 위에서 얘기한 것처럼, 즉시조회(EAGER)는 거의 사용하지 않기 때문에 Lazy로 설정해주었다. 
 

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

 
 
그런데 여기서 쿼리가 N+1 문제가 생긴다. Order를
조회할 때 Member 객체는 proxy로 설정이 되고 실제 Member 객체의 필드 값을 필요로 할 때마다 DB에서 Member 테이블을 조회된 Order의 row 수만큼 최대로 질의한다. 여기서 최대라고 한 이유는 중복되는 row가 있으면 영속성 컨텍스트에서 조회한다.
아래가 바로 위에 상황이 발생하는 코드다. 주문 정보를 조회하는 API가 있고, 조회한 주문정보 Order Entity를 DTO로 변환하고, Lazy 초기화가 실행하도록 설정한다.

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

 
 

@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 조회 코드에서 Order를 조회하는 쿼리가 하나 실행되고, 조회된 주문 데이터가 N개 라면,

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

 
조회된 객체에 DTO로 변환할 때 Member와 Delivery 테이블을 N번 조회한다.(위에서 Member에서만 설명했지만, Delivery도 Order와 다대일 관계고 Lazy loading으로 설정했다.)
 

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

 
이게 그 유명한 JPA의 N+1 문제다.
 
JPA는 영속성 컨텍스트에서 엔티티를 조회후 없으면 데이터베이스에 쿼리를 실행하는데, 주문 데이터가 모두 다른 회원, 모드 다룬 배송정보였다면 Order 데이터 조회 커리 1번 이후 최대 N번의 쿼리가 실행된다.
 
 
 

Fetch Join을 이용해서 N+1 문제 해결

해결방법은 fetch join을 이용해서 쿼리 1번에 조회하는 방법이다. 위에서 사용한 'findAllByString()' 메서드 대신에(feth join을 사용하지 않은 메서드) fetch join을 사용해서 Order 데이터를 조회하는 메서드를 만들어서 사용하면 lazy loading과 상관없이 JPA가 Order 테이블을 조회할 때 member 테이블과 delivery 테이블을 조인해서 데이터베이스를 조회하고 Order 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();
}

'Server > JPA ORM' 카테고리의 다른 글

[JPA] @Enumerated 어노테이션  (0) 2025.09.14
[JPA] JPA 동작 방식  (0) 2025.09.08
[JPA] 지연로딩 개념  (0) 2025.08.22
[JPA] EntityManagerFactory, EntityManager  (0) 2025.07.10
[JPA] @Enumerated 어노테이션  (0) 2025.06.21
Comments