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 문제를 인식하고 쿼리 실행 횟수를 모니터링하여 최적화 할것
'개발인생 > Backend' 카테고리의 다른 글
| [Spring] MVC 구조 설명 | Controller, Service, Repository로 역할 분리하기 (0) | 2025.02.25 |
|---|---|
| [Spring] 핵심 기술 총정리 (0) | 2025.02.25 |
| [ORM] JPQL vs QueryDSL: 무엇이 더 나을까? (2) | 2025.02.21 |
| [JSP] Tomcat은 JSP와 Servlet 실행에 왜 필요할까? (1) | 2025.02.17 |
| [JSP] vs Servlet (0) | 2025.02.17 |