JPA에서 발생하는 N+1 문제의 발생 원인과 해결 방안

Pure·2025년 6월 15일

Spring

목록 보기
5/9
post-thumbnail

JPA를 사용하다 보면 흔히 N+1 문제에 직면하게 된다.
이 문제는 성능 저하의 대표적인 원인 중 하나이며, SQL 쿼리가 불필요하게 반복적으로 실행되는 현상을 말한다.

✅ 정의
쿼리: 특정 엔티티 리스트를 조회

N 쿼리: 조회된 각 엔티티에 대해 연관된 엔티티를 가져오기 위해 각각 1번씩 추가로 쿼리 실행

즉, 총 1 + N 번의 쿼리가 발생함

예를 들어, 부서(Department)와 직원(Employee)이 1:N 관계일 때, 부서 목록을 조회하면 부서 하나당 직원 목록을 추가로 가져오므로 N개의 쿼리가 더 발생하는 구조

N+1 문제 발생 원인

지연 로딩(Lazy Loading)의 기본 전략

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());
        }
    }
}

생성되는 쿼리 흐름 (N+1 문제 발생 흐름)

1️⃣ departmentRepository.findAll() 실행 시:

SELECT d.id, d.name
FROM department d;

👉 부서 3개 있다고 가정하면, 여기까지는 단 1개의 쿼리

2️⃣ 각 부서에 대해 getEmployees() 호출될 때마다 다음 쿼리 발생:

-- 첫 번째 부서
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개의 쿼리 발생
👉 즉, 쿼리가 루프 안에서 지연 로딩 시점마다 반복 실행되어 성능 저하

N+1 문제 해결 방법

1. Fetch Join 사용 (비추천)

첫 번쨰 해결책은 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을 권장한다.

2. EntityGraph 사용

JPA 2.1부터 제공하는 @EntityGraph를 통해 특정 쿼리에 대해 연관 엔티티를 미리 로딩할 수 있다.

@EntityGraph(attributePaths = "employees")
List<Department> findAll(); // 자동으로 fetch join 수행

쿼리 수정 없이 선언적으로 LAZY → EAGER처럼 동작하기에 JPQL보다 깔끔하고 재사용성 높음.
그러나 복잡한 조건이나 다중 조인을 표현하기 힘들다는 한계가 있다.

3. Batch Size 설정 (추천)

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

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 + fetchJoinEntityGraph
💡 핵심 개념LAZY 로딩 유지하며 연관 객체를 IN 쿼리로 일괄 조회JPQL을 타입세이프하게 작성하여 fetch join 수행어노테이션으로 fetch join 선언
⚙️ 적용 방식어노테이션 / 설정 파일코드 기반 Query 작성어노테이션 기반
🛠️ 타입 안전성❌ (문자열 아님, 그러나 타입 체크 없음)✅ (컴파일 타임 오류 감지)❌ (문자열 기반 필드명, 런타임 오류 발생 가능)
🔁 LAZY 유지 여부✅ (지연 로딩 구조 유지)❌ (즉시 로딩으로 대체됨)❌ (즉시 로딩으로 대체됨)
🧵 동적 조건 처리❌ 불가능✅ 매우 유연함❌ 불가능
🧩 다중 연관 조인❌ 어려움✅ 다단계 fetch 가능⚠️ 가능하지만 복잡할수록 어려움
📊 페이징(Pageable) 호환성✅ 완벽 호환❌ 컬렉션 조인 시 불가❌ 컬렉션 조인 시 불가
📄 복잡 쿼리 작성❌ 불가✅ 자유롭게 가능❌ 제한적 (단순 조회에 적합)
🧪 디버깅 / 유지보수✅ 간단✅ IDE 자동완성, Q타입 리팩토링 쉬움❌ 문자열 기반으로 실수 발생 우려
🧭 대표 사용 시점페이징 + LAZY 유지 + 단순 조회복잡 조건, 고급 쿼리, 타입 안정성 필요단순 연관 조회 (1~2단계)
profile
Clean Code를 위한 한 걸음

1개의 댓글

comment-user-thumbnail
2025년 6월 16일

잘 읽고 갑니다! 👍

답글 달기