JPA를 사용하다 보면 흔히 N+1 문제에 직면하게 된다.
이 문제는 성능 저하의 대표적인 원인 중 하나이며, SQL 쿼리가 불필요하게 반복적으로 실행되는 현상을 말한다.
✅ 정의
쿼리: 특정 엔티티 리스트를 조회
N 쿼리: 조회된 각 엔티티에 대해 연관된 엔티티를 가져오기 위해 각각 1번씩 추가로 쿼리 실행
즉, 총 1 + N 번의 쿼리가 발생함
예를 들어, 부서(Department)와 직원(Employee)이 1:N 관계일 때, 부서 목록을 조회하면 부서 하나당 직원 목록을 추가로 가져오므로 N개의 쿼리가 더 발생하는 구조
JPA는 연관 관계를 @ManyToOne, @OneToMany 등으로 매핑할 때, 기본으로 지연 로딩(LAZY) 전략을 사용함.
@Entity // 부서 엔티티
public class Department {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "department", fetch = FetchType.LAZY)
private List<Employee> employees = new ArrayList<>();
}
@Entity // 직원 엔티티
public class Employee {
@Id @GeneratedValue
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "department_id")
private Department department;
}
fetch = FetchType.LAZY일 경우, employees 컬렉션은 실제로 접근할 때 DB에서 쿼리를 실행하여 가져온다.
public void printDepartmentAndEmployees() {
List<Department> departments = departmentRepository.findAll(); // 1개의 쿼리
for (Department dept : departments) {
System.out.println("부서: " + dept.getName());
for (Employee emp : dept.getEmployees()) { // N개의 추가 쿼리
System.out.println(" - 직원: " + emp.getName());
}
}
}
SELECT d.id, d.name
FROM department d;
👉 부서 3개 있다고 가정하면, 여기까지는 단 1개의 쿼리
-- 첫 번째 부서
SELECT e.id, e.name, e.department_id
FROM employee e
WHERE e.department_id = 1;
-- 두 번째 부서
SELECT e.id, e.name, e.department_id
FROM employee e
WHERE e.department_id = 2;
-- 세 번째 부서
SELECT e.id, e.name, e.department_id
FROM employee e
WHERE e.department_id = 3;
👉 부서 수만큼 반복 → 총 1 (부서) + N (부서 개수) = 4개의 쿼리 발생
👉 즉, 쿼리가 루프 안에서 지연 로딩 시점마다 반복 실행되어 성능 저하
첫 번쨰 해결책은 JPQL의 fetch join을 사용하는 것이다.
@Query("SELECT d FROM Department d JOIN FETCH d.employees")
List<Department> findAllWithEmployees();
한 번의 쿼리로 Department와 Employee 데이터를 모두 가져옴.
결과적으로 1 + N이 아닌 1 쿼리만 실행됨.
결과가 중복될 수 있으므로 DISTINCT 키워드를 명시하는 것이 좋음.
@Query("SELECT DISTINCT d FROM Department d JOIN FETCH d.employees")
⚠️ 주의사항
JPQL은 기본적으로 @Query 내부의 쿼리문을 String으로 인식하기에 컴파일 단계에서 타입 안정성을 체크할 수 없다. 이는 곧 런타임 때, 문제가 생기고 나서야 오류가 생김을 알 수 있다는 뜻이다. 따라서 컴파일 단계에서 타입 안정성을 확보하기 위해서는 Query DSL을 권장한다.
JPA 2.1부터 제공하는 @EntityGraph를 통해 특정 쿼리에 대해 연관 엔티티를 미리 로딩할 수 있다.
@EntityGraph(attributePaths = "employees")
List<Department> findAll(); // 자동으로 fetch join 수행
쿼리 수정 없이 선언적으로 LAZY → EAGER처럼 동작하기에 JPQL보다 깔끔하고 재사용성 높음.
그러나 복잡한 조건이나 다중 조인을 표현하기 힘들다는 한계가 있다.
Batch Fetching은 LAZY 로딩을 유지하면서도 N+1 문제를 완화하는 전략.
한 번에 여러 개의 연관 엔티티를 IN 쿼리로 묶어서 가져온다.
@Entity
public class Department {
@OneToMany(mappedBy = "department", fetch = FetchType.LAZY)
@BatchSize(size = 10)
private List<Employee> employees;
}
또는 전역 설정 (application.yml):
spring:
jpa:
properties:
hibernate.default_batch_fetch_size: 10
List<Department> departments = departmentRepository.findAll();
for (Department dept : departments) {
System.out.println(dept.getName());
dept.getEmployees().size(); // LAZY 호출
}
부서 전체 조회 (1쿼리)
SELECT * FROM department;
직원 IN 조회 (1~2쿼리)
SELECT * FROM employee WHERE department_id IN (1,2,3,4,5,...);
1:N 관계의 N을 여러 개 묶어서 가져오기 때문에 N번이 아닌 1~2번 정도로 줄어듬
기존 LAZY 전략 유지 → 코드 수정 최소화
쿼리 수 대폭 감소 (1 + N → 1 + 1~2)
페이징/정렬과도 충돌 없음
도메인 설계 변경 없이 적용 가능
⚠️ 주의사항
IN 절이 너무 커질 경우 성능 저하가 우려되며, 연관 엔티티를 전부 미리 로딩하는 구조는 아니라는 점을 주의하여 사용해야 한다.
QueryDSL은 타입 세이프하고, 동적 쿼리, fetch join, 조건 조합 등 고급 쿼리 작성을 위한 도구이다.
QDepartment department = QDepartment.department;
QEmployee employee = QEmployee.employee;
List<Department> result = queryFactory
.selectFrom(department)
.leftJoin(department.employees, employee).fetchJoin()
.fetch()
단 1번의 쿼리로 Department + Employee 모두 조회 가능하며, 조회도 자유롭게 추가 가능하다.
SELECT d.*, e.*
FROM department d
LEFT JOIN employee e ON d.id = e.department_id;
타입 세이프: 컴파일 타임 오류 감지
복잡한 조건, 동적 쿼리 작성 가능
fetch join, where 절, 정렬, 서브쿼리 등 자유도 높음
중첩 관계, 필터링, DTO 직접 매핑 등도 지원
진입장벽 존재 (QueryDSL 설정 및 Q클래스 관리 필요)
코드가 길어질 수 있음
컬렉션 관계에서 페이징 불가능 (fetch join + @OneToMany + Pageable 조합 불가)
N+1 문제는 JPA의 지연 로딩 전략에 따라 자연스럽게 발생할 수 있는 문제이다.
하지만 이 문제를 인지하고 미리 대비하는 것이 중요하며, Query DSL, EntityGraph, Batch Size 등 다양한 방법으로 해결할 수 있다.
실제 개발에서는 데이터 크기, 페이지네이션, 복잡도 등을 고려해 상황에 맞는 전략을 선택하는 것이 핵심이다.
| 항목 | Batch Size (@BatchSize, hibernate.default_batch_fetch_size) | QueryDSL + fetchJoin | EntityGraph |
|---|---|---|---|
| 💡 핵심 개념 | LAZY 로딩 유지하며 연관 객체를 IN 쿼리로 일괄 조회 | JPQL을 타입세이프하게 작성하여 fetch join 수행 | 어노테이션으로 fetch join 선언 |
| ⚙️ 적용 방식 | 어노테이션 / 설정 파일 | 코드 기반 Query 작성 | 어노테이션 기반 |
| 🛠️ 타입 안전성 | ❌ (문자열 아님, 그러나 타입 체크 없음) | ✅ (컴파일 타임 오류 감지) | ❌ (문자열 기반 필드명, 런타임 오류 발생 가능) |
| 🔁 LAZY 유지 여부 | ✅ (지연 로딩 구조 유지) | ❌ (즉시 로딩으로 대체됨) | ❌ (즉시 로딩으로 대체됨) |
| 🧵 동적 조건 처리 | ❌ 불가능 | ✅ 매우 유연함 | ❌ 불가능 |
| 🧩 다중 연관 조인 | ❌ 어려움 | ✅ 다단계 fetch 가능 | ⚠️ 가능하지만 복잡할수록 어려움 |
| 📊 페이징(Pageable) 호환성 | ✅ 완벽 호환 | ❌ 컬렉션 조인 시 불가 | ❌ 컬렉션 조인 시 불가 |
| 📄 복잡 쿼리 작성 | ❌ 불가 | ✅ 자유롭게 가능 | ❌ 제한적 (단순 조회에 적합) |
| 🧪 디버깅 / 유지보수 | ✅ 간단 | ✅ IDE 자동완성, Q타입 리팩토링 쉬움 | ❌ 문자열 기반으로 실수 발생 우려 |
| 🧭 대표 사용 시점 | 페이징 + LAZY 유지 + 단순 조회 | 복잡 조건, 고급 쿼리, 타입 안정성 필요 | 단순 연관 조회 (1~2단계) |
잘 읽고 갑니다! 👍