JPA N+1 문제

bbookng·2023년 6월 1일
1

CCPP

목록 보기
4/4

✨ JPA N+1

📌 N+1 이란?

  • 연관 관계가 설정된 엔티티를 조회할 경우에 조회된 데이터 갯수 (N) 만큼의 연관관계의 조회 쿼리가 추가로 발생하여 데이터를 읽어오는 현상
  • 보통 @OneToMany 에서 많이 일어난다.

📌 상황

@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<>();
}
teamRepository.findAll();
// team 
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=?
  • JPA 가 JPQL을 분석해서 SQL을 생성할 때는 글로벌 Fetch 전략을 참고하지 않고 오직 JPQL 자체만을 사용한다.

1. 즉시(Eager) 로딩

  1. findAll()을 한 순간 select t from Team t 라는 JPQL 구문이 생성되고 해당 구문을 분석한 select * from team 이라는 SQL이 생성되어 실행된다.

  2. DB 의 결과를 받아 team Entity의 인스턴스들을 생성한다.

  3. team과 연관되어 있는 user도 로딩을 해야 한다.

  4. 영속성 컨텍스트에서 연관된 user가 있는지 확인한다.

  5. 영속성 컨텍스트에 없다면 2에서 만들어진 team인스턴스들 개수에 맞게 select * from user where team_id = ? 이라는 SQL 구문이 생성된다. (N+1 발생)

  • 즉, Team을 검색하고 싶어서 select 쿼리를 하나 날렸지만, 즉시 로딩이 걸려있기 때문에 각 Team이 가진 User 도 모두 검색한다 라는 N+1 문제가 발생

2. 지연(Lazy) 로딩

  • 연관된 객체를 사용하는 시점에 로딩을 해주는 방법
  • 지연 로딩은 해당 연결 Entity에 대해 프록시로 걸어두고, 사용할 때 결국 날리기 때문에 처음 find할 때는 N+1이 발생하지 않지만 추가로 Team을 검색 후, Team의 User를 찾아야 한다면 또 다시 프록시에 대한 쿼리 발생.
  • Team 검색 후 필드를 검색할 때 N+1 문제 발생
  1. findAll() 을 한 순간 select t from Team t 이라는 JPQL 구문이 생성되고 해당 구문을 분석한 select * from team 이라는 SQL이 생성되어 실행된다.
  2. DB 의 결과를 받아 team Entity의 인스턴스들을 생성한다.
  3. 코드 중에서 team 의 user 객체를 사용하려고 하는 시점에 영속성 컨텍스트에서 연관된 user가 있는지 확인한다.
  4. 영속성 컨텍스트에 없다면 2 에서 만들어진 team 인스턴스들 개수에 맞게 select * from user where team_id = ? 이라는 SQL 구문이 생성된다. (N+1 발생)
  • JPA가 자동으로 먼저 생성해주는 Jpql을 통해서 우선적으로 쿼리를 만들다보니 연관관계가 걸려있어도 join이 바로 걸리지 않는다.

📌 해결방법

1. Fetch Join

  • N+1 자체가 발생하는 이유는 한쪽 테이블만 조회하고 연결된 다른 테이블은 따로 조회하기 때문
  • 미리 두 테이블을 Join 하여 한 번에 모든 데이터를 가져올 수 있다면 N+1 문제가 발생하지 않을 것이라 생각하여 나온 방법
public interface TeamRepository extends JpaRepository<Team, Long> {
    @Query("select t from Team t join fetch t.users")
    List<Team> findAllFetchJoin();
}
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
  • INNER JOIN 으로 호출되는 것 확인 가능
  • 하지만 Fetch Join을 하게 되면 데이터 호출 시점에 모든 연관관계의 데이터를 가져오기 때문에 연관관계 설정해놓은 Fetch Type이 무의미 해짐
  • 페이징 쿼리를 사용할 수 없음. 하나의 쿼리문으로 가져오므로 페이징 단위로 데이터를 가져오는 것 불가능

2. @Entitygraph

  • @EntitygraphattributePaths 에 쿼리 수행 시 바로 가져올 필드명을 지정하면 Lazy가 아닌 Eager 조회로 가져오게 됨.

  • Fetch Join 과 동일하게 JPQL 을 사용하여 쿼리문을 작성하고 필요한 연관관계를 Entitygraph 에 설정하면 된다.

  • Fetch Join 과는 다르게 Outer Join 으로 실행된다.

@EntityGraph(attributePaths = "users")
@Query("select t from Team t")
List<Team> findAllEntityGraph();

- Fetch Join과 EntityGraph 주의점

  • 카테시안 곱이 발생하여 Team의 수 만큼 user가 중복 데이터가 존재할 수 있다.
  • Set 자료구조를 사용하게 되면 중복을 허용하지 않는 자료구조 이므로 중복된 데이터를 제거할 수 있다.

3. Batch Size

  • 지정된 엔티티를 조회할 때 지정된 size 만큼 SQL 의 IN 절을 사용해서 조회
  • row 갯수만큼 추가 SQL을 날리지 않고, 조회한 Team의 id들을 모아서 SQL IN 절을 날린다.
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 1000
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 (?, ?, ?, ?)
  • EAGER인 경우 첫 조회 시 BatchSize만큼 끊어서 하나의 쿼리로 조회하게 되고, LAZY인 경우 추후 사용할 때 BatchSize만큼 끊어서 하나의 쿼리로 조회

📌 참고

https://velog.io/@jinyoungchoi95/JPA-%EB%AA%A8%EB%93%A0-N1-%EB%B0%9C%EC%83%9D-%EC%BC%80%EC%9D%B4%EC%8A%A4%EA%B3%BC-%ED%95%B4%EA%B2%B0%EC%B1%85

https://dev-coco.tistory.com/165

https://incheol-jung.gitbook.io/docs/q-and-a/spring/n+1

0개의 댓글