백엔드

[자바/JAVA] JPA N+1 문제란?

Newbie Developer 2025. 2. 5. 11:02

안녕하세요. 새내기 개발자입니다. 공부하면서 정리하는 글로 틀린 부분은 언제나 댓글로 환영입니다!

 

JPA에 이어시 JPA 사용시 발생할 수 있는 N+1 문제에 대해서 정리했습니다.

N+1 문제는 JPA에서 연관된 엔티티를 조회할 때 발생하는 불필요한 추가 쿼리 문제를 의미합니다.
즉, 하나의 메인 엔티티(1)를 조회할 때, 연관된 N개의 엔티티를 각각 추가로 조회하면서 총 N+1개의 쿼리가 발생하는 문제입니다.


 

📌 N+1 문제 발생 예시

예를 들어, User와 Order가 1:N 관계를 가지고 있다고 가정해봅시다.

@Entity
public class User {
    @Id @GeneratedValue
    private Long id;
    private String name;

    @OneToMany(mappedBy = "user")
    private List<Order> orders = new ArrayList<>();
}
@Entity
public class Order {
    @Id @GeneratedValue
    private Long id;
    private String product;

    @ManyToOne
    @JoinColumn(name = "user_id")
    private User user;
}

❌ N+1 문제 발생 코드

List<User> users = entityManager.createQuery("SELECT u FROM User u", User.class)
                                .getResultList();

for (User user : users) {
    System.out.println(user.getOrders());  // 연관된 Order 조회
}

📌 실행되는 SQL 쿼리

-- 1. User 조회 (1개의 쿼리)
SELECT * FROM User;

-- 2. 각 User의 Order 조회 (N개의 쿼리)
SELECT * FROM Order WHERE user_id = ?;
SELECT * FROM Order WHERE user_id = ?;
SELECT * FROM Order WHERE user_id = ?;
...

문제점: User가 100명이라면, User를 가져오는 1개의 쿼리 + Order를 가져오는 100개의 쿼리 = 총 101개의 쿼리가 실행됩니다.


JPA N+1 문제 해결 방법

1️⃣ Fetch Join 사용하기 ✅ (가장 많이 쓰이는 방법)

JOIN FETCH를 사용하면 한 번의 SQL 쿼리로 연관된 데이터를 즉시 로딩(Eager Loading) 할 수 있습니다.

@Query("SELECT u FROM User u JOIN FETCH u.orders WHERE u.id = :id")
User findUserWithOrders(@Param("id") Long id);

📌 실행되는 SQL

SELECT u.*, o.* 
FROM User u 
JOIN Order o ON u.id = o.user_id
WHERE u.id = ?;

장점: 한 번의 쿼리로 User와 Order를 모두 가져올 수 있음
단점: 여러 개의 Fetch Join이 필요할 경우, 복잡한 쿼리가 발생할 수 있음


2️⃣ EntityGraph 사용하기

Spring Data JPA의 @EntityGraph를 사용하면, Fetch Join과 유사한 방식으로 한 번의 쿼리로 데이터를 가져올 수 있습니다.

@EntityGraph(attributePaths = {"orders"})
@Query("SELECT u FROM User u WHERE u.id = :id")
User findUserWithOrders(@Param("id") Long id);

장점: 특정 조회 메서드에서만 Fetch Join을 적용할 수 있음
단점: Fetch Join과 마찬가지로 복잡한 관계에서는 사용이 어려울 수 있음


3️⃣ Batch Size 설정 (IN 절 활용)

@BatchSize 어노테이션을 사용하거나 JPA의 글로벌 설정을 활용하면 IN 절을 사용하여 여러 개의 데이터를 한 번에 가져올 수 있습니다.

방법 1: @BatchSize 어노테이션 사용

@Entity
public class User {
    @OneToMany(mappedBy = "user")
    @BatchSize(size = 10)  // Order 데이터를 10개씩 가져옴
    private List<Order> orders = new ArrayList<>();
}

방법 2: 글로벌 설정

spring.jpa.properties.hibernate.default_batch_fetch_size=100

📌 실행되는 SQL

SELECT * FROM User;

SELECT * FROM Order WHERE user_id IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);

장점: 모든 연관된 엔티티를 한 번의 IN 절로 가져와서 쿼리 개수를 줄일 수 있음
단점: Batch Size를 너무 크게 설정하면 한 번의 쿼리로 가져오는 데이터가 많아져 성능 저하 가능


4️⃣ Lazy Loading 전략을 유지하면서 @Query로 직접 조회하기

JPA의 기본 Lazy Loading 전략을 유지하면서 필요한 데이터만 조회하는 방법입니다.

@Query("SELECT u.id, u.name, o.product FROM User u LEFT JOIN u.orders o WHERE u.id = :id")
List<Object[]> findUsersWithOrders(@Param("id") Long id);

📌 실행되는 SQL

SELECT u.id, u.name, o.product 
FROM User u 
LEFT JOIN Order o ON u.id = o.user_id 
WHERE u.id = ?;

장점: 성능 최적화 가능, 불필요한 컬럼 조회 방지
단점: 엔티티가 아닌 Object[] 형태로 반환되므로, DTO 변환 과정 필요


🔥 결론 (최적의 해결 방법)

  • 간단한 Fetch Join(JOIN FETCH): 단순한 관계에서는 Fetch Join이 가장 효과적
  • @EntityGraph 활용: Spring Data JPA를 사용하면 Fetch Join보다 가독성이 좋음
  • Batch Size 설정: Lazy Loading을 유지하면서 N+1 문제를 해결할 때 사용
  • DTO 조회(@Query): 필요 데이터만 가져와 성능 최적화

🚀 프로젝트의 요구사항에 따라 적절한 방식으로 해결하는 것이 중요합니다!