개발인생/Backend

[ORM] JPA 성능 최적화: N+1 문제 해결하는 3가지 방법

forri 2025. 2. 22. 21:53

JPA를 사용할 때 가장 자주 접하는 성능 이슈 중 하나가 N+1 문제입니다. 이 문제를 이해하고 해결하지 않으면 DB에 불필요한 쿼리가 과도하게 실행되어 성능이 급격히 저하될 수 있습니다. N+1 문제의 개념, 원인, 해결 방법까지 쉽게 정리해보겠습니다.


📌 1. N+1 문제란?

N+1 문제는 연관된 데이터를 조회할 때, 불필요한 추가 쿼리가 N번 실행되는 현상을 말합니다.
즉, 1번의 조회 쿼리로 끝나야 할 작업이 N개의 추가 쿼리를 발생시켜 비효율적인 데이터 조회가 되는 것입니다.

📌 예제 상황 (연관된 엔티티 조회)

회사 시스템에서 부서(Department)와 직원(Employee)이 있다고 가정해봅시다.

  • 하나의 Department는 여러 Employee를 가질 수 있습니다. (1:N 관계)
  • Department를 조회할 때, 해당 부서의 직원들도 함께 가져오려 합니다.

🔹 잘못된 코드 (Lazy Loading 기본 설정)

@Entity
public class Department {
    @Id @GeneratedValue
    private Long id;
    
    private String name;
    
    @OneToMany(mappedBy = "department", fetch = FetchType.LAZY)  // 기본 설정 (지연 로딩)
    private List<Employee> employees;
}
List<Department> departments = em.createQuery("SELECT d FROM Department d", Department.class).getResultList();

 

👉 실행되는 쿼리

-- 1. 부서(Department) 목록을 조회하는 쿼리 (1번 실행)
SELECT * FROM department;

-- 2. 각 부서에 속한 직원(Employee) 조회 쿼리 (부서 개수 N번 실행)
SELECT * FROM employee WHERE department_id = 1;
SELECT * FROM employee WHERE department_id = 2;
SELECT * FROM employee WHERE department_id = 3;
...

 

🚨 문제 발생!

  • Department 10개를 조회하면? → 총 1 + 10(N) = 11번의 쿼리 발생
  • 부서 100개라면? → 총 101번 실행 😨
  • 연관된 엔티티가 많아질수록 쿼리 수가 기하급수적으로 증가하여 심각한 성능 저하를 유발!

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

JPA에서 연관된 엔티티를 조회할 때, 기본적으로 지연 로딩(Lazy Loading) 방식이 적용되기 때문입니다.

🔹 JPA 기본 설정 (Lazy Loading)

  • @OneToMany(fetch = FetchType.LAZY)
    부서를 먼저 조회한 후, 직원 정보는 실제 사용될 때 별도의 쿼리로 조회
  • 즉, department.getEmployees()를 호출할 때마다 각 부서별로 직원을 조회하는 추가 쿼리(N개)가 발생

📌 3. N+1 문제 해결 방법

1. Fetch Join 사용 (가장 많이 쓰이는 해결책)

👉 JOIN FETCH를 사용하면 한 번의 쿼리로 연관된 데이터를 모두 가져올 수 있음

@Query("SELECT d FROM Department d JOIN FETCH d.employees")
List<Department> findAllWithEmployees();

✔ 실행되는 쿼리 (1번만 실행됨!)

SELECT d.*, e.* FROM department d
LEFT JOIN employee e ON d.id = e.department_id;

🚀 결과: 모든 부서 + 직원 정보가 한 번에 조회되어 N+1 문제 해결!


2. EntityGraph 사용 (Fetch Join의 대안)

  • @EntityGraph를 활용하면 JPQL 없이도 Fetch Join과 같은 효과를 낼 수 있음
@EntityGraph(attributePaths = {"employees"})
@Query("SELECT d FROM Department d")
List<Department> findAllWithEmployees();
 

✔ 실행되는 SQL

SELECT d.*, e.* FROM department d
LEFT JOIN employee e ON d.id = e.department_id;

🚀 결과: Fetch Join과 동일한 성능 개선 효과


3. Batch Size 조정 (IN 절을 활용한 최적화)

  • @BatchSize를 사용하면 JPA가 지연 로딩을 하더라도 일정 개수만큼 한 번에 조회하여 성능을 최적화할 수 있음.
@OneToMany(mappedBy = "department")
@BatchSize(size = 10)
private List<Employee> employees;

✔ 실행되는 SQL

SELECT * FROM department;  -- 부서 조회 (1번 실행)
SELECT * FROM employee WHERE department_id IN (1,2,3,4,5,6,7,8,9,10);  -- 직원 조회 (1번 실행)

🚀 결과: 부서 10개씩 묶어서 조회하여 성능 최적화!


📌 4. 해결 방법별 비교

해결 장점 단점
Fetch Join 한 번의 쿼리로 모든 데이터를 가져와 성능 최적화 조인 대상이 많아질 경우 쿼리가 복잡해질 수 있음
EntityGraph JPQL 없이 Fetch Join 효과를 낼 수 있음 복잡한 조인 관계에는 다소 제한적
Batch Size 조정 N+1을 완전히 제거하지 않아도 성능 최적화 가능 Batch 크기 조절이 필요하며 데이터가 많으면 여전히 비효율적

📌 5. 정리

N+1 문제란?

  • 연관된 데이터를 조회할 때, 추가적인 쿼리가 N번 실행되어 성능이 저하되는 문제

N+1 문제 발생 원인

  • JPA의 기본 설정이 지연 로딩(Lazy Loading) 방식이기 때문

해결 방법
1️⃣ Fetch Join → JOIN FETCH를 사용하여 한 번의 쿼리로 조회
2️⃣ EntityGraph → 어노테이션으로 Fetch Join 효과 적용
3️⃣ Batch Size 조정 → @BatchSize(size = N)로 IN 절 최적화

🚀 실무에서 가장 많이 사용하는 방법은?

  • 대부분 Fetch Join을 가장 많이 사용!
  • 상황에 따라 EntityGraph, Batch Size도 적절히 활용하면 최적화 가능

📌 결론 – 실무에서 어떻게 적용할까?

  • 만약 단순 조회라면 Fetch Join이 가장 효과적
  • 서비스 로직이 동적 쿼리를 많이 사용한다면 EntityGraph 활용 가능
  • Batch Size는 대량 데이터 조회 시 효과적이며 메모리 최적화가 필요할 때 사용

🚀 JPA를 사용할 때는 반드시 N+1 문제를 인식하고 쿼리 실행 횟수를 모니터링하여 최적화 할것