N+1 문제란?

  • N+1 문제는 ORM을 사용할 때 발생하는 대표적인 성능 문제로. 연관 관계가 설정된 엔티티를 조회할 때 조회된 데이터 갯수(N)만큼 연관된 엔티티를 조회하는 추가 쿼리가 발생하는 현상을 말한다.
  • 만약 100개의 팀을 조회하면 10개의 팀을 조회하는 쿼리가 발생하고 각 팀에 속한 멤버를 조회하는 쿼리가 10개 발생하는 것을 말한다.
  • 이게 문제가 되나? 라 생각이 들수 있지만 데이터가 많아진다면 문제가 된다.
  1. 팀 10개를 뽑아야지~~
    1. 팀 10개를 조회하는 쿼리 발생 (1번)
    2. 각 팀에 속한 멤버를 조회하는 쿼리 10개 발생 (10번)
    3. 총 11개의 쿼리가 발생
  2. 팀 100개를 뽑아야지~~
    1. 팀 100개를 조회하는 쿼리 발생 (1번)
    2. 각 팀에 속한 멤버를 조회하는 쿼리 100개 발생 (100번)
    3. 총 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)를 활용하는 방법도 있다.
  • 따라서 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 문제를 해결하는 방법

  1. fetch join 사용
  2. @EntityGraph 사용
  3. BatchSize 사용
  4. 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 캐싱으로 인해 벌어지는 일도 적어 보겠다.