[Spring Boot] JPA와 N+1 문제, 지연 로딩, 해결 방법
📌 N+1 문제란?
N+1 문제
는 연관 관계가 있는 엔티티를 조회할 때 발생할 수 있는 문제이다. 하나의 쿼리를 기대했으나 의도치 않게 N개의 쿼리가 추가로 발생하는 문제를 말한다. 여기서 N개란 기대한 하나의 쿼리의 결과 수이다. 그래서 1+N으로 생각하는 것이 이해하기 쉽다.
보통 JPA를 사용할 때 많이 언급되는 문제이나, 다른 ORM 프레임워크를 사용하는 모든 환경에서 발생할 수 있는 문제이다.
📌 데이터 로딩 전략
N+1 문제를 살펴보기 전, 연관 관계가 있는 엔티티를 조회하는 두 가지 방법에 대해 살펴보자.
즉시 로딩
Eager Loading
(즉시 로딩)은 특정 엔티티 조회 시 연관 관계에 있는 모든 엔티티 데이터까지 한 번에 가져오는 방법이다. 쿼리를 수행할 때, 관련된 모든 데이터를 미리 로드한다고 생각하면 된다.
JPA에서 @ManyToOne
, @OnetoOne
관계에서 FetchType.EAGER
로 설정하며, 즉시 로딩이 기본 값이다.
관련 데이터를 사용할 예정이면 즉시 로딩을 고려할 법 하나, 그렇지 않다면 불필요한 데이터를 로드한 셈이므로, 메모리 성능 저하가 발생할 수 있다.
지연 로딩
Lazy Loading
(지연 로딩)은 엔티티를 로드할 때 실제로 필요한 시점까지 로딩을 지연시키는 방법이다. 엔티티에 대한 접근이 필요할 때 프록시 객체가 사용되며 엔티티의 속성 또는 메서드에 접근할 때 프록시 객체가 DB에 쿼리를 보내 데이터를 가져온다.
JPA에서 연관 관계를 설정할 때 FetchType.LAZY
로 설정한다.
필요한 데이터만 로딩하고, 순환 참조를 방지할 수 있으나 연관된 데이터에 접근을 원하는 경우 추가적인 쿼리가 발생할 수 있다.
많이 오해하는 부분이 지연 로딩을 사용하면 N+1 문제를 해결할 수 있다고 생각하는 것이다. 그러나 이는 잘못되었다. 두 방법 모두 N개의 추가 쿼리가 발생하며, 발생하는 시점만 다른 것이다. 즉시 로딩은 하나의 쿼리를 실행하는 시점에 N개의 쿼리 또한 발생하며, 지연 로딩은 하나의 쿼리만 실행하는 것으로 보이나 실제 데이터가 필요한 시점에 추가로 N개의 쿼리를 발생시키게 된다.
📌 발생 원인
N+1 문제의 근본적인 원인은 다음과 같다.
JPA가 생성하는
JPQL
은 연관 관계가 있어도 JOIN을 사용하지 않는다.
데이터 로딩 전략으로 N+1 문제를 해결할 수 없으며, 다른 방법을 사용해야 한다.
📌 해결 방법
Fetch Join
Fetch Join
와 일반적인 Join과 가장 큰 차이점은 SELECT 절에서 조회하는 속성이다. 일반적인 Join은 기준 엔티티에 대한 속성만 조회되는 반면, Fetch Join은 조인이 걸린 엔티티의 속성까지 조회된다.
1
2
3
4
5
6
// 일반 Join
String jpql = "SELECT m FROM Member m JOIN m.team t";
List<Member> members = em.createQuery(jpql, Member.class).getResultList();
// 실제 실행되는 SQL
// SELECT M.* FROM MEMBER M INNER JOIN TEAM T ON M.TEAM_ID = T.ID
1
2
3
4
5
6
// Fetch Join
String jpql = "SELECT m FROM Member m JOIN FETCH m.team";
List<Member> members = em.createQuery(jpql, Member.class).getResultList();
// 실제 실행되는 SQL
// SELECT M.*, T.* FROM MEMBER M INNER JOIN TEAM T ON M.TEAM_ID = T.ID
Fetch Join을 사용하면 두 엔티티(Member, Team)을 하나의 SQL 쿼리로 조회하는 것을 볼 수 있다. 지연 로딩을 사용하더라도 Fetch Join이 수행되기 때문에 가능하다. 단, 일대다 관계에서 Fetch Join을 사용하는 경우 중복이 발생할 수 있다. 부모 엔티티가 여러 자식 엔티티를 참조하는 경우 부모 엔티티가 중복으로 조회될 수 있기 때문이다. 이를 방지하기 위해 DISTINCT
키워드를 사용하는 것이 좋다.
단, 2개 이상의 일대다 자식은 Fetch Join을 사용할 수 없다. 또한 Fetch Join을 사용하면 JPA 메서드가 아닌 수작업으로 JPQL 쿼리문을 작성해줘야 한다.
중요한 점은 Fetch Join이 대부분의 N+1 문제를 해결할 수는 있어도, 모든 N+1 문제를 해결할 수는 없다. 쿼리가 복잡한 경우 일반 Join과 DTO를 사용하는 것이 좋다.
@EntityGraph
@EntityGraph
를 사용하면 JPQL에 일일히 Fetch Join을 사용하지 않아도 된다. 특정 엔티티 조회 시 함께 로딩할 연관 엔티티를 지정할 수 있기 때문이다.
1
2
3
4
public interface CourseRepository extends JpaRepository<Course, Long> {
@EntityGraph(attributePaths = {"students"}, type = EntityGraphType.FETCH)
List<Course> findAll();
}
attributePaths
는 함께 로딩할 연관 엔티티의 속성명이며, type에 EntityGraph의 속성을 지정할 수 있다. EntityGraphType.FETCH
로 지정하면 명시된 속성은 EAGER, 나머지는 LAZY로 로딩하며, EntityGraphType.LOAD
로 지정하면 명시된 속성은 EAGER, 나머지는 엔티티의 기존 로딩 전략을 따른다.
Batch Size
Batch Size
는 엔티티를 일정한 크기의 batch
로 묶어서 로드하는 방법이다. Fetch Join, @EntityGraph 방법이 N+1 문제를 하나의 쿼리를 수행하는 방법으로 해결했다면, Batch Size 방법은 여러 개의 쿼리를 사용하지만, 그 수를 줄여 N+1 문제를 해결하는 방법이다
Batch Size를 설정하는 방법은 크게 두 가지가 있는데, @BatchSize
를 사용하는 방법과 application.yml에서 전역적으로 batch size를 지정하는 방법이다.
1
2
3
4
5
6
7
8
9
// @BatchSize 어노테이션을 사용
@Entity
public class Team {
// ...
@BatchSize(size = 100)
@OneToMany(mappedBy = "team")
private List<Member> members;
}
1
2
3
4
5
6
# 전역적으로 설정하는 방법
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
일반적으로 batch size를 100 ~ 1000으로 설정하나, 데이터 크기에 맞게 유동적으로 설정해야 한다. 너무 큰 값으로 설정하면 데이터를 가져오는 메모리 사용량이 증가할 수 있다. 너무 작은 값으로 설정하면 여전히 많은 쿼리가 발생하여 N+1 문제를 완전히 해결하지 못할 수 있다.
batch size가 설정된 경우, 다음과 같이 동작한다.
1
2
3
4
5
6
7
8
9
10
11
-- Batch Size 설정 전 (N+1 문제)
SELECT * FROM member WHERE team_id = 1;
SELECT * FROM member WHERE team_id = 2;
SELECT * FROM member WHERE team_id = 3;
-- ... (N번 반복)
-- Batch Size 설정 후 (크기: M)
SELECT * FROM member WHERE team_id IN (1, 2, 3, ..., M);
SELECT * FROM member WHERE team_id IN (M+1, M+2, ..., 2M);
SELECT * FROM member WHERE team_id IN (2M+1, 2M+2, ..., 3M);
-- ... (이와 같은 방식으로 N개의 팀을 처리)
Hibernate
를 사용하는 경우 batch size를 최적화하기 위해 캐싱 전략을 사용한다. 설정된 batch size를 기준으로 다양한 배치를 미리 계산하여 캐싱한다.
예를 들어 batch size가 100인 경우, Hibernate는
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 25, 50, 100
와 같은 크기로 캐싱한다. size가 큰 경우 절반씩 나누어 생성한다.
실제로 데이터를 처리할 때, 데이터 크기에 따라 쿼리 수가 변할 수 있다.
- 데이터 크기가 캐싱된 값과 일치하는 경우 해당 데이터는 단일 쿼리로 처리된다.
- 데이터 크기가 캐싱된 값 사이에 존재하는 경우 가장 가까운 캐싱된 값을 사용한다. 예를 들어 데이터 크기가 30개라면, 25와 50 사이에 위치하므로 가장 가까운 25개로 처리 후, 남은 5개를 처리한다. 이 경우 두 번의 쿼리가 발생하게 된다.
- 데이터 크기가 batch size보다 크다면 여러 배치로 나누어 처리한다. 예를 들어 데이터의 크기가 120이라면, 100개로 먼저 나눈 후, 남은 20개를 처리한다. 이 경우 두 번의 쿼리가 발생하게 된다.
📌 참고
https://velog.io/@jsb100800/Spring-boot-JPA-N1