[Spring] N + 1 문제

 

1. 소개

N + 1 문제란?

JPA에서 연관 관계를 조회할 때 발생하는 성능 문제.

하나의 쿼리로 데이터를 가져왔는데, 연관된 엔티티를 조회할 때 추가로 N개의 쿼리가 발생하는 문제를 말한다.

Lazy Loading으로 인해 연관된 엔티티를 개별 조회하면서 발생한다.

 

LazyLoding 이란?

LazyLoding(지연 로딩)은 객체를 실제로 사용할 때까지 데이터를 로딩하지 않는 방식.

즉, JPA가 연관된 엔티티를 즉시 가져오지 않고, 필요한 순간 (ex) 메서드 호출) 에 쿼리를 실행하는 방식이다.

 

문제를 유발하는 이유

Lazy Loading을 사용하면 연관된 엔티티를 처음 조회할 때는 불러오지 않고, 필요할 때 추가적으로 조회하게 됨.
이 방식이 대규모 데이터에서는 N + 1 문제를 초래할 수 있다.

 

쓰지말아야하나?

Lazy Loading이 보통 기본값이다.

그 이유는 불필요한 데이터를 미리 로딩하지 않도록 하기 위해서이다.

하지만, 연관된 엔티티를 반복적으로 조회할 가능성이 높은 경우에는 문제가 될 수 있다.

 

Lazy Loading을 쓰면 좋은 경우

 

  • 연관된 엔티티가 자주 사용되지 않는 경우 (필요할 때만 가져오는 게 효율적).
  • 성능 최적화를 위해 불필요한 JOIN을 피하고 싶은 경우.

 

Lazy Loading이 문제를 일으키는 경우

 

  • N + 1 문제가 발생하는 경우 (대량의 데이터를 반복 조회).
  • 한 번의 조회에서 연관된 엔티티도 함께 가져와야 하는 경우.

 

 

2. 발생 예제

Company.class

@Entity
public class Company {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;

    @OneToMany(mappedBy = "company")
    private List<Employee> employees = new ArrayList<>();
}

 

Employee.class

@Entity
public class Employee {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "company_id")
    private Company company;
}

 

N + 1 문제가 발생하는 코드

List<Company> companies = companyRepository.findAll();
for (Company company : companies) {
    System.out.println(company.getEmployees().size()); // 직원 수 출력
}

 

실행되는 SQL Query

-- 1. 첫 번째 쿼리: 모든 회사 조회 (1개)
SELECT * FROM company;

-- 2. N개의 추가 쿼리: 각 회사의 직원 조회 (N개)
SELECT * FROM employee WHERE company_id = 1;
SELECT * FROM employee WHERE company_id = 2;
SELECT * FROM employee WHERE company_id = 3;
...

 

총 N + 1개의 쿼리가 실행된다.

회사가 10개라면? 1번의 회사 조회 + 10번의 직원 조회 -> 총 11번의 쿼리 실행

 

 

3. 해결 방법

1. Fetch Join 사용

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

 

실행되는 SQL Query

SELECT c.*, e.* FROM company c
JOIN employee e ON c.id = e.company_id;

 

모든 데이터를 Join 사요앻서 한 번의 쿼리로 가져올 수 있게 된다!

 

2. @EntityGraph 사용

@EntityGraph(attributePaths = {"employees"})
@Query("SELECT c FROM Company c")
List<Company> findAllWithEmployees();

 

위의 예시처럼 한 번의 쿼리로 가져올 수 있게 됨

 

그리고 FetchMode.SUBSELECT나 BatchSize를 조정해서 해결할 수도 있다.

 

4. 주의할 점

 

Fetch JoinEntityGraph는 JPQL을 사용하여 JOIN문을 호출한다는 공통점이 있다. 또한, company의 수만큼 employee이 중복 데이터가 존재할 수 있다. 그러므로 중복된 데이터가 컬렉션에 존재하지 않도록 주의해야 한다.

 

컬렉션을 Set을 사용하게 되면 중복이 허용되지 않기때문에 중복을 방지할 수 있다.

혹은 distinct를 사용하여 중복 데이터를 조회하지 않을 수도 있다.

 

5. 결론

 

  • Fetch Join (JOIN FETCH) → 가장 일반적인 방법
  • @EntityGraph → Spring Data JPA에서 Fetch Join을 쉽게 사용
  • @BatchSize → Batch Fetching을 통해 최적화

 

 

Lazy Loading은 성능 최적화하기 좋다.

근데 N + 1 문제가 일어날 수 있으므로 그런 경우는 Fetch Join같은 걸 활용해서 잘 해결하자.