본문 바로가기

TIL(Today I Learned)

TIL-231117(JPA N+1 문제)

📝오늘 공부한 것

  • 'N+1 문제' 공부하기
  • 커리어톤 참여
  • 프로그래머스 문제풀기

📌  N + 1

며칠전봤던 면접에서 N+1문제에 관해서 여쭤보셨다.
그런데 N+1이 내가 원했던 만큼의 쿼리 조회보다 N번 더 발생하는 것이라는 것만 알고, 정확히는 알지못해서 면접 때 대답하지 못했었다ㅠㅠ(내가 알고 있는것에 확신이 없어서 아예 '잘 모르겠습니다'라고 대답하였다.
N+1도 공부하려고 했던 것들 중에 하나였는데, 늦었지만 오늘이라도 정리를 해보려고 한다!!

 

  • 연관관계에서 발생하는 이슈로 연관관계가 설정된 엔티티를 조회할 경우에 조회된 데이터 갯수(n)만큼 연관관계의 조회 쿼리가 추가로 발생하여 데이터를 읽어오게 된다.
    -> 1번 조회해야 할 것을 N개 종류의 데이터 각각을 추가로 조회하게 되서 총 N+1번 DB 조회를 하게되는 문제이다.
  • 쿼리 1번으로 N건을 가져왔는데, 관련 컬럼을 얻기 위해 쿼리를 N번 추가 수행하는 문제
  • 쿼리결과 건수마다 참조 정보를 얻기 위해 건수만큼 반복해서 쿼리를 수행하게 되는 문제
  • DB쿼리 수행비용(횟수)이 크기 때문에, eager로딩 등의 방법으로 해결하는 것이 권장된다.
  • 주로, @ManyToOne 연관관계를 가진 엔티티에서 주로 발생한다.

 

📍 언제 발생하는지

✔ 데이터 조회 시 즉시로딩으로 데이터를 가져오는 경우 - N+1 문제가 바로 발생

✔ 데이터 조회 시 지연로딩으로 데이터를 가져온 이후에 가져온 데이터에서 하위 엔티티를 다시 조회하는 경우 - 하위 엔티티를 조회하는 시점에 발생

 

📍 발생 이유

jpaRepository에 정의한 인터페이스 메서드를 실행하면 JPA는 메서드 이름을 분석해서 JPQL을 생성하여 실행하게 된다. JPQL은 SQL을 추상화한 객체지향 쿼리 언어로서 특정 SQL에 종속되지 않고 엔티티 객체와 필드 이름을 가지고 쿼리를 한다. 그렇기 때문에 JPQL은 findAll()이란 메소드를 수행하였을 때 해당 엔티티를 조회하는 select * from table1 쿼리만 실행하게 되는것이다. JPQL 입장에서는 연관관계 데이터를 무시하고 해당 엔티티 기준으로 쿼리를 조회하기 때문이다. 그렇기 때문에 연관된 엔티티 데이터가 필요한 경우, FetchType으로 지정한 시점에 조회를 별도로 호출하게 된다.

 

즉시로딩 :

예를 들자면, User(사용자)와 Order(주문)은 현재 일대다 관계로 연관관계가 존재한다. User 엔티티를 findAll()를 이용해 모든 사용자들을 조회하려고 할때, 원래는 사용자들에 관한 내용만 조회되어야 하는데 User 엔티티를 조회할 때 연관된 모든 Order 엔티티도 함께 조회되기 때문에, 사용자가 5명일 때, 사용자 조회에 대한 쿼리(1)와 사용자 각각에 연관된 모든 주문 엔티티를 조회하는 쿼리(1)으로 총 2번의 조회가 이루어진다.

즉, 사용자가 5명일때, 사용자 조회에 대한 내용(1) + 각 5명의 사용자가 가지고 있는 주문 Entity에 대한 내용(5)으로 1+5 로 조회가 되어 1+N이 발생하는 것이다. 따라서, 연관관계까지 데이터를 모두 조회하는 즉시로딩일 경우 N+1 문제가 발생하는 것이다.

 

지연로딩 :

필요할때만 연관데이터를 가져오기 때문에 단순히 User 엔티티를 조회할때는 N+1 문제가 발생하지 않지만, 하위 엔티티인 Order를 조회하는 경우, Order엔티티는 프록시 객체이기 때문에 User를 조회하고 나서 Order를 조회하는 쿼리가 나가기 때문에 즉시로딩과 같이 N+1문제가 발생한다.

 

📍 해결방안

1. Fetch Join

  • 조회 시 바로 가져오고 싶은 Entity 필드를 지정하는 것
@Query("select u from User u join fetch u.order")
List<User> findAll();
  • Inner Join
  • 장점 :
    단 한번의 쿼리만 발생하도록 설계할 수 있다.
    Fetch Join을 이용해 특정 엔티티의 하위 엔티티의 하위엔티티까지 가져올 수 있도록 할 수 있다.
  • 단점 :
    불필요한 쿼리문이 추가된다.
    JPA가 제공하는 Pageable 사용불가

 

2. @EntityGraph

@EntityGraph(attributePaths = {"order"})
List<User> findAllEntityGraph();
  • attributePaths에 쿼리 수행 시 바로 가져올 필드명을 지정하면 Lazy가 아닌 Eager 조회로 가져오게 된다.
  • Outer Join
  • 장점 : Fetch Join의 문제인 매번 쿼리를 작성하고 확인하는 문제 해결
  • 단점 : 중복 데이터 발생

 

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,2번 둘 다 카테시안 곱(Cartesian Product)이 발생하여 table1의 수만큼 table2(table1과 연관관계)가 중복 발생하게 된다.

 

📍 중복된 데이터 해결방법

1. 일대다 필드의 타입을 set으로 선언

    @OneToMany(cascade = CascadeType.ALL)
    @JoinColumn(name="academy_id")
    private Set<Subject> subjects = new LinkedHashSet<>();

중복 방지와 순서 보장을 위해 LinkedHashSet 사용

 

2. distinct를 사용하여 중복 제거

@Query("select DISTINCT a from Academy a join fetch a.subjects s join fetch s.teacher")
List<Academy> findAllWithTeacher();
@EntityGraph(attributePaths = {"subjects", "subjects.teacher"})
@Query("select DISTINCT a from Academy a")
List<Academy> findAllEntityGraphWithTeacher();

 

 

 

N+1문제에 대한 해결은 성능 최적화에 도움이 된다는 것을 알게되었다.
이전에는 N+1이 예상치 못한 에러를 발생시킬 수 있다고 생각했었는데, 이 문제를 해결했을 경우에는 성능을 최적화할 수 있다는 것을 배웠다. 연관된 엔티티를 처리할 때 어떤 방법이 최선인지를 파악해서 Fetch Join, Entity Graph, Batch Size 등의 방법을 적절하게 활용하여 성능을 최적화해봐야겠다.

 

 

 

 

 

 

 

 

 

 

References :

https://jojoldu.tistory.com/165

https://velog.io/@sweet_sumin/JPA-N1-%EC%9D%B4%EC%8A%88%EB%8A%94-%EB%AC%B4%EC%97%87%EC%9D%B4%EA%B3%A0-%ED%95%B4%EA%B2%B0%EC%B1%85%EC%9D%80-%EB%AC%B4%EC%97%87%EC%9D%B8%EA%B0%80%EC%9A%94

https://programmer93.tistory.com/83

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