Backend/Java

[Java] JPA에서 발생하는 N+1 이슈: 원인부터 해결까지

누구세연 2024. 11. 27. 20:27

JPA를 처음 사용할 때 많은 개발자들이 부딪히는 문제 중 하나가 바로 N+1 이슈입니다.

 

이번 글에서는 JPA 환경에서 N+1 이슈가 발생하는 원인을 알아보고, 이를 해결할 수 있는 방법을 정리해보겠습니다. 

 

 

N+1 이슈란?

N+1 이슈1개의 메인 쿼리로 데이터를 가져오려 했지만, 추가로 N개의 쿼리가 실행되는 문제를 뜻합니다.
다음은 간단한 예제를 통해 이를 이해해보겠습니다.

 

엔티티 구조

Member와 Order 간에 일대다 관계가 있다고 가정합니다.

@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

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

@Entity
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String productName;

    @ManyToOne(fetch = FetchType.LAZY)
    private Member member;
}

 

문제 상황

모든 Member와 관련된 Order를 조회하겠습니다.

List<Member> members = em.createQuery("SELECT m FROM Member m", Member.class)
                         .getResultList();
for (Member member : members) {
    System.out.println("Member: " + member.getName());
    for (Order order : member.getOrders()) {
        System.out.println("Order: " + order.getProductName());
    }
}

 

쿼리 실행 분석

1. 첫 번째 쿼리

SELECT * FROM Member;

모든 멤버 데이터를 조회합니다.

 

2. 추가 쿼리 (멤버 수만큼 실행)

SELECT * FROM Order WHERE member_id = ?;

각 멤버에 대한 주문 데이터를 가져옵니다.

 

만약 Member가 100명이라면 총 1개의 메인 쿼리 + 100개의 추가 쿼리가 실행됩니다.
이처럼 N+1 이슈는 불필요한 쿼리 호출을 증가시켜 애플리케이션 성능에 심각한 영향을 미칠 수 있습니다.🥲

 

 

N+1 이슈가 발생하는 원인: FetchType.LAZY

JPA에서는 연관된 엔티티를 로드할 때 FetchType.LAZY와 FetchType.EAGER를 설정할 수 있습니다.

  • LAZY: 실제로 엔티티를 사용할 때 데이터베이스에서 조회합니다.
  • EAGER: 연관된 엔티티를 즉시 로드합니다.

N+1 이슈는 주로 @OneToMany, @ManyToMany 관계에서 FetchType.LAZY를 사용할 때 발생합니다.

  • 메인 엔티티를 조회한 후, 연관된 데이터를 각각 별도로 조회하기 때문입니다.

 

JPA에서 N+1 이슈 해결 방법

1) Fetch Join 사용

JOIN FETCH를 사용해 연관된 엔티티를 한 번에 가져옵니다.

List<Member> members = em.createQuery(
    "SELECT m FROM Member m JOIN FETCH m.orders", Member.class)
    .getResultList();

 

실행 쿼리는 아래와 같습니다.

SELECT m.*, o.* 
FROM Member m 
INNER JOIN Order o ON m.id = o.member_id;

 

  • 장점: 추가 쿼리 없이 연관 데이터를 모두 로드합니다.
  • 주의점:
    • 페이징 (setFirstResult, setMaxResults)과 함께 사용 시 메모리에서 페이징 처리됩니다.
    • 데이터가 많을 경우 비효율적일 수 있으므로 적절히 설계해야 합니다.

2) EntityGraph 활용

Fetch Join을 선언형으로 간단히 설정할 수 있는 방법입니다.

@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
    @EntityGraph(attributePaths = {"orders"})
    List<Member> findAll();
}

 

  • 장점: 코드 가독성이 높아지고 유지보수가 쉬워집니다.

 

3) Batch Size 설정

Hibernate의 Batch Size를 활용해 Lazy 로딩 최적화를 적용할 수 있습니다.

엔티티별 설정

@Entity
@BatchSize(size = 10)
public class Member {
    // ...
}

글로벌 설정 (application.properties)

spring.jpa.properties.hibernate.default_batch_fetch_size=10

 

동작 방식

  1. 기본적으로 Lazy 로딩이지만, 한 번에 10개씩 데이터를 조회합니다.
  2. 100명의 멤버에 대해 N개의 쿼리가 실행되지 않고, 10번으로 제한됩니다.

 

실무에서의 N+1 이슈 대응 팁 

  1. 쿼리 로깅 활성화
    • JPA가 생성하는 쿼리를 주기적으로 점검하세요.
    • Hibernate show_sql이나 p6spy를 활용해 실행 쿼리를 분석하세요.
  2. 쿼리 최적화에 Fetch Join 남발하지 않기
    • 필요 이상의 데이터까지 조회하지 않도록 Fetch Join은 신중히 사용하세요.
  3. DTO를 사용한 명확한 데이터 설계
    • 필요한 데이터만 조회해 성능을 최적화하세요.
    • JPQL이나 QueryDSL로 DTO 매핑을 적용하면 효과적입니다.
  4. 테스트 환경에서 충분한 데이터로 점검
    • 데이터가 적을 때는 문제가 없더라도 대량 데이터 환경에서 N+1 이슈가 발생할 수 있으니, 충분히 테스트하세요.

 

 

💡 JPA에서 N+1 이슈는 초보 개발자가 쉽게 놓칠 수 있는 함정입니다.
하지만 Fetch Join, EntityGraph, Batch Size 등의 기술을 적절히 활용하면 성능 저하를 효과적으로 방지할 수 있습니다.
쿼리를 설계하는 것도 코드 작성만큼 중요하다는 점을 기억하며, 실행되는 쿼리를 항상 점검하고 최적화하세요!