Spring

[JPA] N+1 문제

leejunkim 2025. 7. 21. 10:00

WeeklyPaper: JPA에서 발생하는 N+1 문제의 발생 원인과 해결 방안에 대해 설명하세요.


N+1 문제란?

연관관계에서 발생하는 이슈로, 연관관계가 설정된 엔티티를 조회할 경우에 조회된 데이터 갯수(n) 만큼 연관관계의 조회 쿼리가 추가로 발생하여 데이터를 읽어오는 현상이다.

 

이 포스팅에서 사용할 예시를 보자:

 

 

Member (회원) 엔티티와 Order (주문) 엔티티가 1:N 관계이다. 한 명의 회원은 여러 개의 주문을 가질 수 있다.

 

N+1문제가 생기는 예시: 회원 10명을 조회했는데, 각 회원의 주문 번호를 또 조회하면,

  • 회원 10명을 불러오는 1번의 쿼리 + 각 회원의 주문을 불러오는 10(N)개의 쿼리 = 11 쿼리 발생

 

발생 이유

지연 로딩 (LAZY)전략으로 연관 관계가 설정된 엔티티를 조회할 때, 각각 개별적으로 추가 조회가 발생하기 때문이다.

Lazy Loading 구조:

Member (회원)
 └── Order (주문) (지연로딩 LAZY)

Member 조회 → Order는 proxy → Order 접근 시 실제 쿼리 발생

  1. LAZY 설정의 의미
    • Member를 조회할 때, Member에 연결된 Order 데이터는 일단 가져오지 말라고 설정한 것이다.
  2. Proxy의 역할
    • Member를 조회하는 쿼리(SELECT * FROM member WHERE id = 1;)를 실행하면, 시스템(ORM 프레임워크)은 Member 객체를 생성해서 반환하지만, 때 LAZY 설정 때문에 실제 Order 데이터는 가져오지 않는다.
    • 대신, Member 객체의 order 필드에는 가짜 Order 객체, 즉 프록시(Proxy) 객체를 넣어둔체 Member 객체를 돌려준다.
    • 이 프록시 객체는 껍데기일 뿐, 내부에는 실제 주문 데이터(주문번호, 주문일자 등)가 없다. 단지 나중에 진짜 데이터를 가져올 때 필요한 정보(예: member_id)만 알고 있다.
  3. Order 접근 시 실제 쿼리 발생
    • 코드에서 member.getOrder() 같은 메서드를 호출해서 프록시 객체에 접근하고, order.getOrderNumber()처럼 실제 데이터가 필요한 작업을 수행하는 바로 그 순간, 프록시 객체는 자신이 진짜 데이터가 아니라는 것을 알고 데이터베이스에 실제 Order 데이터를 조회하는 쿼리(SELECT * FROM order WHERE member_id = 1;)를 실행한다.
    • 그리고 그 결과를 가져와 비로소 진짜 Order 객체로 채워 넣는다.

Lazy Loading VS Eager Loading

  • Lazy Loading (지연 로딩)
    • 데이터 로딩(조회)를 지연시키는 전략
    • 어떤 데이터를 요청했을 때, 그 데이터와 연관된 모든 데이터를 한꺼번에 가져오지 않고, 그 연관된 데이터를 사용하는 시점에 가서야 데이터베이스에 조회를 요청 (쿼리 실행)하는 방식이다
  • Eager Loading (즉시 로딩)
    • 연관된 데이터를 처음부터 전부 다 가져오는 방식이다

참고로, Lazy가 디폴트다:

@Target({METHOD, FIELD}) 
@Retention(RUNTIME)
public @interface OneToMany {
    Class targetEntity() default void.class;
    CascadeType[] cascade() default {};
    FetchType fetch() default FetchType.LAZY;
    String mappedBy() default "";
    boolean orphanRemoval() default false;
}

@OneToMany 어노테이션을 살펴보면 FetchType fetch() default FetchType.LAZY;을 볼 수 있다.

코드 예시

Member

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    private String name;

    @OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
    private List<Order> orders = new ArrayList<>();

    // Getter, Setter
}

 

Order

@Entity
public class Order {
    @Id @GeneratedValue
    private Long id;

    private String itemName;

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

    // Getter, Setter
}

 

Service

@Service
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;

    @Transactional(readOnly = true)
    public void showMembersWithOrders() {
        List<Member> members = memberRepository.findAll(); // 1

        // N+1 발생 지점
        for (Member member : members) {
            System.out.println("member = " + member.getName());
            System.out.println("orders = " + member.getOrders().size()); // Lazy Loading → 쿼리 발생
        }
    }
}
  1. 첫 번째 쿼리 (1)
    • 회원 10명을 모두 조회하라는 요청을 보낸다 -> memberRepository.findAll()
    • SELECT * FROM member; 쿼리가 단 한 번 실행된다.
    • 결과: Member 객체 10개를 받지만, 이 10개의 객체 안에 있는 order 필드는 모두 실제 데이터가 아닌 프록시(가짜) 객체다.
  2. 추가 쿼리 (N)
    • member.getOrders().size()
      • 이제 코드에서 이 10명의 회원 목록을 가지고 반복 작업을 시작한다. (예: for (Member member : memberList))
    • 첫 번째 회원의 주문 정보를 사용하려고 member.getOrder()...를 호출 → 프록시가 동작하여 첫 번째 추가 쿼리가 실행 (SELECT * FROM order WHERE member_id = 1;)
    • 두 번째 회원의 주문 정보를 사용하려고 member.getOrder()...를 호출 → 프록시가 동작하여 두 번째 추가 쿼리가 실행 (SELECT * FROM order WHERE member_id = 2;)
    • ...
    • 열 번째 회원의 주문 정보를 사용하려고 member.getOrder()...를 호출 → 프록시가 동작하여 열 번째 추가 쿼리가 실행 (SELECT * FROM order WHERE member_id = 10;)

해결책

1. Fetch Join 사용하기

public interface MemberRepository extends JpaRepository<Member, Long> {
    
    @Query("SELECT m FROM Member m JOIN FETCH m.orders")
    List<Member> findAllWithOrders();
}

 

JPQL에서 JOIN FETCH 구문을 사용하면, 연관된 엔티티를 한 번에 조회한다 => Eager loading: 한 번의 쿼리로 연관 객체까지 즉시 로드

@Service
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;

    @Transactional(readOnly = true)
    public void showMembersWithOrders() {
        List<Member> members = memberRepository.findAllWithOrders();

        for (Member member : members) {
            System.out.println("member = " + member.getName());
            System.out.println("orders = " + member.getOrders().size());
        }
    }
}

 

발생시 쿼리

SELECT m.*, o.* 
FROM member m
JOIN orders o ON m.id = o.member_id;

 

OneToMany Fetch Join 시 중복이 발생할 경우, DISTINCT를 사용해면 된다. (페이징은 불가능하다).

@Query("SELECT DISTINCT m FROM Member m JOIN FETCH m.orders")

2. @EntityGraph 사용하기

  • 쿼리를 작성하지 않고 fetch 전략을 재정의할 수 있는 방법
  • 특정 쿼리에만 Eager처럼 작동하게 변경 가능
  • JPQL 없이 선언적으로 연관 엔티티 조회 가능
  • 특징
    • 다양한 쿼리에서 재사용 가능, 선언적 방식
    • 페이징 시에도 사용 가능
    • 복합 조합 가능: @NamedEntityGraph
    • 정적 타입 안전성 확보 가능
public interface MemberRepository extends JpaRepository<Member, Long> {

    @EntityGraph(attributePaths = {"orders"})
    @Query("SELECT m FROM Member m")
    List<Member> findAllWithOrders();
}
@Service
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;

    @Transactional(readOnly = true)
    public void showMembersWithOrders() {
        List<Member> members = memberRepository.findAllWithOrders();

        for (Member member : members) {
            System.out.println("member = " + member.getName());
            System.out.println("orders = " + member.getOrders().size());
        }
    }
}

 

발생시 쿼리

SELECT m.*, o.*
FROM member m
LEFT JOIN orders o ON m.id = o.member_id;

 

자료