N+1 문제란?
- N+1 문제는 ORM을 사용할 때 발생하는 대표적인 성능 문제로. 연관 관계가 설정된 엔티티를 조회할 때 조회된 데이터 갯수(N)만큼 연관된 엔티티를 조회하는 추가 쿼리가 발생하는 현상을 말한다.
- 만약 100개의 팀을 조회하면 10개의 팀을 조회하는 쿼리가 발생하고 각 팀에 속한 멤버를 조회하는 쿼리가 10개 발생하는 것을 말한다.
- 이게 문제가 되나? 라 생각이 들수 있지만 데이터가 많아진다면 문제가 된다.
- 팀 10개를 뽑아야지~~
- 팀 10개를 조회하는 쿼리 발생 (1번)
- 각 팀에 속한 멤버를 조회하는 쿼리 10개 발생 (10번)
- 총 11개의 쿼리가 발생
- 팀 100개를 뽑아야지~~
- 팀 100개를 조회하는 쿼리 발생 (1번)
- 각 팀에 속한 멤버를 조회하는 쿼리 100개 발생 (100번)
- 총 101개의 쿼리가 발생
미리보는 정리
-
@ManyToOne은 기본적으로 EAGER로 설정되어 있어, 연관된 엔티티를 즉시 로드하려고 하며, 이는 여러 개의 부모 엔티티를 조회할 때 N+1 문제가 발생할 수 있다.
-
@OneToMany는 기본적으로 LAZY로 설정되어 있어 초기 조회 시에는 연관 데이터를 가져오지 않는다. 하지만 LAZY로 설정하더라도 연관 데이터를 접근하는 시점마다 추가적인 쿼리가 발생할 수 있어, 잘못된 설계 시 여전히 N+1 문제가 발생할 수 있다.
-
@ManyToOne을 LAZY로 설정하면 기본적으로 즉시 로드를 하지 않기 때문에, 연관된 데이터 로딩으로 인한 N+1 문제를 피할 가능성이 높아진다.
- 하지만 LAZY로 설정할 경우 주의해야 한다. 트랜잭션이 끝난 후 LAZY 로딩된 데이터를 가져오려고 하면 LazyInitializationException이 발생한다.
- 이를 방지하기 위해, 데이터를 접근하는 시점까지 트랜잭션을 유지해야 한다. 예를 들어, 데이터 조회 메서드에 @Transactional(readOnly = true)를 사용하여 트랜잭션 범위를 명시적으로 설정할 수 있다.
- 또한, 필요한 연관 데이터를 조회 시점에 미리 로딩하려면 JPQL에서 JOIN FETCH를 사용하거나, Hibernate의 엔티티 그래프(EntityGraph)를 활용하는 방법도 있다.
- 하지만 LAZY로 설정할 경우 주의해야 한다. 트랜잭션이 끝난 후 LAZY 로딩된 데이터를 가져오려고 하면 LazyInitializationException이 발생한다.
-
따라서 N+1 문제를 해결하기 위해서는 연관 관계 설정 시 EAGER를 사용하지 않고, LAZY로 설정하기
-
JPQL에서 JOIN FETCH를 사용하거나, Hibernate의 엔티티 그래프(EntityGraph)를 활용하는거나 BatchSize를 사용하는 방법이 있다.
N+1 문제가 발생하는 상황 코드
- @ManyToOne, @OneToMany, @OneToOne, @ManyToMany 어노테이션을 사용하면 N+1 문제가 발생할 수 있다.
- 다음과 같은 엔티티가 있다고 가정하자
- Team
- Member
- Team과 Member는 1:N 관계이다.
- Team은 Member를 가지고 있지만 Member는 Team을 가지고 있지 않다.
- Team을 조회할 때 Member를 함께 조회하고 싶다고 가정하자.
@Entity
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "team")
private List<Member> members;
}
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToOne
private Team team;
각
다음과 같은 상황에서 N+1 문제가 발생함
@Repository
public class TeamRepository extends JpaRepository<Team, Long> {
@Query("SELECT t FROM Team t JOIN FETCH t.members")
List<Team> findAllWithMembers();
}
사용하는곳
public class TeamService {
@Autowired
private TeamRepository teamRepository;
List<Team> teams = teamRepository.findAllWithMembers();
for (Team team : teams) {
team.getMembers().size(); // 여기서 N+1 문제가 발생함
}
}
N+1 문제를 해결하는 방법
- fetch join 사용
- @EntityGraph 사용
- BatchSize 사용
- default_batch_fetch_size 사용
여기서 추천하는 방법은 3번, 4번 방법이다.
1번부터 차근 차근 설명하겠다.
1. fetch join 사용
- JPQL이나 QueryDSL을 사용할 때 fetch join을 사용하면 N+1 문제를 해결할 수 있다.
- 다음과 같이 fetch join을 사용하면 된다.
@Repository
public class TeamRepository extends JpaRepository<Team, Long> {
@Query("SELECT t FROM Team t JOIN FETCH t.members")
List<Team> findAllWithMembers();
}
2. @EntityGraph 사용
- @EntityGraph를 사용하면 fetch join을 사용하지 않고도 N+1 문제를 해결할 수 있다.
- 다음과 같이 @EntityGraph를 사용하면 된다.
@Repository
public class TeamRepository extends JpaRepository<Team, Long> {
@EntityGraph(attributePaths = "members")
List<Team> findAllWithMembers();
}
- 다만 @EntityGraph를 사용하면 유지보수가 어려울 수 있기 때문에 추천하지 않는다.
3. BatchSize 사용
- @OneToMany, @ManyToMany 어노테이션에 @BatchSize를 사용하면 N+1 문제를 해결할 수 있다.
- 다음과 같이 @BatchSize를 사용하면 된다.
@Entity
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@BatchSize(size = 100)
@OneToMany(mappedBy = "team")
private List<Member> members;
}
- @BatchSize를 사용하면 Team을 조회할 때 Member를 조회할 때 한 번에 100개씩 조회한다.
- 이 방법은 N+1 문제를 해결할 수 있지만 size 값이 너무 크면 성능 문제가 발생할 수 있다.
- 그래서 size 값을 적절하게 설정해야 한다.
- 보통 100~1000 사이의 값을 사용한다.
4. default_batch_fetch_size 사용
- 3번의 방법을 전체 엔티티에 적용하고 싶다면 hibernate.default_batch_fetch_size를 사용하면 된다.
- hibernate.default_batch_fetch_size를 사용하면 N+1 문제를 해결할 수 있다.
- application.properties에 다음과 같이 설정하면 된다.
spring.jpa.properties.hibernate.default_batch_fetch_size=100
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 1000
- hibernate.default_batch_fetch_size를 사용하면 모든 엔티티에 적용된다.
- 이 방법을 사용하면 N+1 문제를 해결할 수 있고 성능 문제도 해결할 수 있다.
- 그래서 3번 이나 4번 방법을 추천한다.
나중에 기회가 된가면 batch size 캐싱으로 인해 벌어지는 일도 적어 보겠다.