본문 바로가기

Study/개발일지

[백엔드온라인TIL] java 학습 46일차

JPA를 사용하다 보면 바로 N+1의 문제에 마주치고 바로 Fetch Join을 접하게 됩니다.

처음 Fetch Join을 접했을 때 왜 일반 Join으로 해결하면 안되는지에 대해 명확히 정리가 안된 채로 Fetch Join을 사용했습니다.

 

 

Join, Fetch Join 차이점 요약

  • 일반 Join
    • Fetch Join과 달리 연관 Entity에 Join을 걸어도 실제 쿼리에서 SELECT 하는 Entity는
      오직 JPQL에서 조회하는 주체가 되는 Entity만 조회하여 영속화
    • 조회의 주체가 되는 Entity만 SELECT 해서 영속화하기 때문에 데이터는 필요하지 않지만 연관 Entity가 검색조건에는 필요한 경우에 주로 사용됨
  • Fetch Join
    • 조회의 주체가 되는 Entity 이외에 Fetch Join이 걸린 연관 Entity도 함께 SELECT 하여 모두 영속화
    • Fetch Join이 걸린 Entity 모두 영속화하기 때문에 FetchType이 Lazy인 Entity를 참조하더라도
      이미 영속성 컨텍스트에 들어있기 때문에 따로 쿼리가 실행되지 않은 채로 N+1문제가 해결됨

- JPA N+1 문제 및 해결 방법 -


JPA를 사용하다 보면 의도하지 않았지만 여러 번의 select 문이 순식간에 여러 개가 나가는 현상을 본 적이 있을 것이다. 이러한 현상을 N+1문제라고 부른다. 해당 포스트에서는 N+1 문제가 왜 발생하게 되는지와 해결 방법 및 실무에서 어떤 식으로 적용을 해야 하는지에 대하여 설명을 할 것이다.

 

N+1 문제란?

연관 관계가 설정된 엔티티를 조회할 경우에 조회된 데이터 갯수(n) 만큼 연관관계의 조회 쿼리가 추가로 발생하여 데이터를 읽어오는 현상

 

현상 재현

DB 구조는 유저(USER)는 한개의 팀(TEAM)에만 속할 수 있고 팀(TEAM) 하나는 여러 명의 유저(USER)가 가입할 수 있다. 테스트 데이터로는 4개의 팀당 유저 4명씩 총 20명의 유저를 추가했다.

DB 구조

 

Fetch 모드를 EAGER(즉시 로딩)으로 한 경우

@Entity
public class User {
    @Id
    @GeneratedValue
    private long id;
    private String firstName;
    private String lastName;

    @ManyToOne(fetch = FetchType.EAGER)		// 즉시 로딩
    @JoinColumn(name = "team_id", nullable = false)
    private Team team;
}
@Entity
public class Team {
    @Id
    @GeneratedValue
    private long id;
    private String name;

    @OneToMany(fetch = FetchType.EAGER)
    private List<User> users = new ArrayList<>();
}

JpaRepository를 extends 한 Interface 객체인 TeamRepository에서 findAll을 호출을 하면

teamRepository.findAll();
System.out.println("============== N+1 시점 확인용 ===================");

N + 1 문제가 발생한다.

Hibernate: select team0_.id as id1_0_, team0_.name as name2_0_ from team team0_
Hibernate: select users0_.team_id as team_id1_1_0_, users0_.users_id as users_id2_1_0_, user1_.id as id1_2_1_, user1_.first_name as first_na2_2_1_, user1_.last_name as last_nam3_2_1_, user1_.team_id as team_id4_2_1_ from team_users users0_ inner join user user1_ on users0_.users_id=user1_.id where users0_.team_id=?
Hibernate: select users0_.team_id as team_id1_1_0_, users0_.users_id as users_id2_1_0_, user1_.id as id1_2_1_, user1_.first_name as first_na2_2_1_, user1_.last_name as last_nam3_2_1_, user1_.team_id as team_id4_2_1_ from team_users users0_ inner join user user1_ on users0_.users_id=user1_.id where users0_.team_id=?
Hibernate: select users0_.team_id as team_id1_1_0_, users0_.users_id as users_id2_1_0_, user1_.id as id1_2_1_, user1_.first_name as first_na2_2_1_, user1_.last_name as last_nam3_2_1_, user1_.team_id as team_id4_2_1_ from team_users users0_ inner join user user1_ on users0_.users_id=user1_.id where users0_.team_id=?
Hibernate: select users0_.team_id as team_id1_1_0_, users0_.users_id as users_id2_1_0_, user1_.id as id1_2_1_, user1_.first_name as first_na2_2_1_, user1_.last_name as last_nam3_2_1_, user1_.team_id as team_id4_2_1_ from team_users users0_ inner join user user1_ on users0_.users_id=user1_.id where users0_.team_id=?
============== N+1 시점 확인용 ===================

 

 

Fetch 모드를 LAZY(지연 로딩)으로 한 경우

Team과 User Entity 객체에서 Fetch 모드만 LAZY로 변경한 후 똑같이 findAll을 호출 하면

teamRepository.findAll();

이번에는 N + 1 문제가 발생하지 않는 것처럼 보인다.

Hibernate: select team0_.id as id1_0_, team0_.name as name2_0_ from team team0_

하지만 아래와 같이 users를 사용하려고 하면 N+1문제가 발생하게 된다.

List<Team> all = teamRepository.findAll();
System.out.println("============== N+1 시점 확인용 ===================");
all.stream().forEach(team -> {
    team.getUsers().size();
});

 

Hibernate: select team0_.id as id1_0_, team0_.name as name2_0_ from team team0_
============== N+1 시점 확인용 ===================
Hibernate: select users0_.team_id as team_id1_1_0_, users0_.users_id as users_id2_1_0_, user1_.id as id1_2_1_, user1_.first_name as first_na2_2_1_, user1_.last_name as last_nam3_2_1_, user1_.team_id as team_id4_2_1_ from team_users users0_ inner join user user1_ on users0_.users_id=user1_.id where users0_.team_id=?
Hibernate: select users0_.team_id as team_id1_1_0_, users0_.users_id as users_id2_1_0_, user1_.id as id1_2_1_, user1_.first_name as first_na2_2_1_, user1_.last_name as last_nam3_2_1_, user1_.team_id as team_id4_2_1_ from team_users users0_ inner join user user1_ on users0_.users_id=user1_.id where users0_.team_id=?
Hibernate: select users0_.team_id as team_id1_1_0_, users0_.users_id as users_id2_1_0_, user1_.id as id1_2_1_, user1_.first_name as first_na2_2_1_, user1_.last_name as last_nam3_2_1_, user1_.team_id as team_id4_2_1_ from team_users users0_ inner join user user1_ on users0_.users_id=user1_.id where users0_.team_id=?
Hibernate: select users0_.team_id as team_id1_1_0_, users0_.users_id as users_id2_1_0_, user1_.id as id1_2_1_, user1_.first_name as first_na2_2_1_, user1_.last_name as last_nam3_2_1_, user1_.team_id as team_id4_2_1_ from team_users users0_ inner join user user1_ on users0_.users_id=user1_.id where users0_.team_id=?


즉, 지연 로딩에서는 N+1 문제가 발생하지 않는 것처럼 보였지만 막상 객체를 탐색하려고 하면 N+1 문제가 발생되어 N+1문제가 발생되는 시점만 즉시 로딩과 다를 뿐이다.

 

발생 이유

N+1 문제가 발생하는 이유는 JPA가 JPQL을 분석해서 SQL을 생성할 때는 글로벌 Fetch 전략을 참고하지 않고 오직 JPQL 자체만을 사용한다. 즉, 아래와 같은 순서로 동작한다.

 


1. Fetch 전략이 즉시 로딩인 경우
1. findAll()을 한 순간 select t from Team t 이라는 JPQL 구문이 생성되고 해당 구문을 분석한 select * from team 이라는 SQL이 생성되어 실행된다. ( SQL 로그 중 Hibernate: select team0_.id as id1_0_, team0_.name as name2_0_ from team team0_ 부분 )
2. DB의 결과를 받아 team 엔티티의 인스턴스들을 생성한다.
3. team과 연관되어 있는 user 도 로딩을 해야 한다.
4. 영속성 컨텍스트에서 연관된 user가 있는지 확인한다.
5. 영속성 컨텍스트에 없다면 2에서 만들어진 team 인스턴스들 개수에 맞게 select * from user where team_id = ? 이라는 SQL 구문이 생성된다. ( N+1 발생 )

 


2. Fetch 전략이 지연 로딩인 경우
1. findAll()을 한 순간 select t from Team t 이라는 JPQL 구문이 생성되고 해당 구문을 분석한 select * from team 이라는 SQL이 생성되어 실행된다. ( SQL 로그 중 Hibernate: select team0_.id as id1_0_, team0_.name as name2_0_ from team team0_ 부분 )
2. DB의 결과를 받아 team 엔티티의 인스턴스들을 생성한다.
3. 코드 중에서 team 의 user 객체를 사용하려고 하는 시점에 영속성 컨텍스트에서 연관된 user가 있는지 확인한다
4. 영속성 컨텍스트에 없다면 2에서 만들어진 team 인스턴스들 개수에 맞게 select * from user where team_id = ? 이라는 SQL 구문이 생성된다. ( N+1 발생 )

 

 

 

해결 방법

N+1 문제를 해결하는 방법에는 Fetch Join ,EntityGraph 어노테이션 , Batch Size 등의 방법이 있다.

 

1. Fetch Join

JPQL을 사용하여 DB에서 데이터를 가져올 때 처음부터 연관된 데이터까지 같이 가져오게 하는 방법이다. (SQL Join 문을 생각하면 된다. )

별도의 메소드를 만들어줘야 하며 @Query 어노테이션을 사용해서 "join fetch 엔티티.연관관계_엔티티" 구문을 만들어 주면 된다.

public interface TeamRepository extends JpaRepository<Team, Long> {
    @Query("select t from Team t join fetch t.users")
    List<Team> findAllFetchJoin();
}
        List<Team> all = teamRepository.findAllFetchJoin();
        System.out.println("============== N+1 시점 확인용 ===================");
        all.stream().forEach(team -> {
            team.getUsers().size();
        });
Hibernate: select team0_.id as id1_0_0_, user2_.id as id1_2_1_, team0_.name as name2_0_0_, user2_.first_name as first_na2_2_1_, user2_.last_name as last_nam3_2_1_, user2_.team_id as team_id4_2_1_, users1_.team_id as team_id1_1_0__, users1_.users_id as users_id2_1_0__ from team team0_ inner join team_users users1_ on team0_.id=users1_.team_id inner join user user2_ on users1_.users_id=user2_.id
============== N+1 시점 확인용 ===================

SQL 로그를 보면 별도의 지정을 안하면 JPQL에서 join fetch 구문은 SQL문의 inner join 구문으로 변경되어 실행된다.

 

2. EntityGraph 어노테이션

@EntityGraph 라는 어노테이션을 사용해서 fetch 조인을 하는 것인데 그냥 이런 게 있구나만 알아두고 사용하지 말자.
사용하는 순간 조금만 관계가 복잡해져도 헬게이트가 열린다.

 

3. Batch Size

이 옵션은 정확히는 N+1 문제를 안 일어나게 하는 방법은 아니고 N+1 문제가 발생하더라도 select * from user where team_id = ? 이 아닌 select * from user where team_id in (?, ?, ? ) 방식으로 N+1 문제가 발생하게 하는 방법이다.
이렇게 하면 100번 일어날 N+1 문제를 1번만 더 조회하는 방식으로 성능을 최적화할 수 있다.

application.yml

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 1000

application.properties

spring.jpa.properties.hibernate.default_batch_fetch_size=1000


간단하게 설정 하나 세팅해주면 아래와 같이 in 절로 쿼리가 나간다.

Hibernate: select team0_.id as id1_0_, team0_.name as name2_0_ from team team0_
Hibernate: select users0_.team_id as team_id1_1_1_, users0_.users_id as users_id2_1_1_, user1_.id as id1_2_0_, user1_.first_name as first_na2_2_0_, user1_.last_name as last_nam3_2_0_, user1_.team_id as team_id4_2_0_ from team_users users0_ inner join user user1_ on users0_.users_id=user1_.id where users0_.team_id in (?, ?, ?, ?)

 

실무에서 N+1문제로 DB가 죽어버리는 문제를 방지하기 위해서는 어떻게 해야 할까?

우선 연관관계에 대한 설정이 필요하다면 FetchType을 성능 최적화를 하기 어려운 즉시 로딩(EAGER)을 사용하는 게 아니라 지연 로딩 (LAZY) 모드로 사용을 하고 성능 최적화가 필요한 부분에서는 Fetch 조인을 사용한다.
또한 기본적으로 Batch Size의 값을 1000 이하로 설정한다. (대부분의 DB에서 IN절의 최대 개수 값 : 1000)

728x90