메이쁘

[Spring] JPA N+1 문제에 대한 고찰. (원인, 테스트, 해결방법) 본문

Technology/Web - Spring

[Spring] JPA N+1 문제에 대한 고찰. (원인, 테스트, 해결방법)

메이쁘 2021. 12. 29. 18:05

안녕하세요?

 

JPA를 사용하던 중 엔티티 간 연관관계가 있을 때,

 

해당 엔티티에 대해 SELECT 조회 시 발생하는 N+1 문제에 대해 알아보고

 

테스트해보고

 

해결해보는 시간을 가졌습니다.

 

이를 캡쳐하고 정리하면서 공부하려고 합니다.

 

 


0. JPA와 JPQL

JPA와 JPQL. 무슨 관계일까요?

 

JPQL은 Java Persistence Query Language의 약자로, DB 테이블이 아니라 엔티티의 객체를 대상으로 검색하는 객체 지향 쿼리입니다.

 

이름처럼 SQL과 비슷한 문법을 가지고 있습니다.

 

JPQL을 사용하기 전까지는 EntityManger의 find를 통해 Select를 했었는데요. 이러한 find 함수만 활용하기에는 조회가 복잡해지고 어려워질수록 사용하기가 까다로워집니다.

 

이를 해결하기 위해 JPQL이 나왔습니다.

JPA는 이러한 JPQL을 읽고 분석해서 SQL을 생성한 후 DB에 SQL을 실행하는 일을 합니다.

 

이덕분에 JPA는 특정 DB에 의존하지 않아 여러 DB에도 동일한 JPQL을 사용하면 DB에 접근할 수 있습니다. (Dialect - 방언만 변경하면 JPQL 수정 없이 DB 변경 가능)

 

그럼 JPA와 JPQL의 연관관계는?

  → JPA를 사용하기 위해 저희는 Repository 인터페이스를 만들고, JpaRepository를 상속받습니다. 그래서, 여기에서 정의한 인터페이스 메소드를 실행하여 DB 연동을 하는데요.

이 때, JPA는 인터페이스 메소드 이름을 분석해서 JPQL로 변환하고, 이를 가지고 SQL을 만들어서 DB에 SQL을 실행하는 과정을 합니다.

 

 

그럼 왜 갑자기 JPQL을 언급하냐?

 

가장 큰 이유는 이러한 JPA와 JPQL 동작 특성 때문에 N+1 문제가 발생하는 것이고,

이후에 언급할 Fetch Join과 매우 관련있기 때문입니다.

JPQL의 기능으로 Fetch Join(패치 조인)이 있는데, SQL의 성능 최적화에 대부분 1차적으로 사용됩니다. 

 

 


1. JPA N+1 문제?

그럼, JPA와 JPQL은 알겠는데, N+1 문제가 도대체 무엇이길래 알아둬야 할까요?

 

간단하게 설명드리자면, 1번 조회해야할 것을 N개 종류의 데이터 각각을 추가로 조회하게 되서 총 N+1번 DB조회를 하게 되는 문제입니다.

 

이는 서버와 DB에 큰 문제가 될 수 있는데요.

 

만약, 데이터 조회 시 1번만 조회할 것을 10만, 100만개 종류의 데이터가 있어 100,001번, 1,000,001번 DB조회하게 된다면.. 

이 데이터 조회가 100명, 1000명이 동시에 실행하게 된다면..

 

생각만해도 끔찍합니다.

 

 

 

When? 언제 발생하는건가요?

→ JPA Repository를 활용해 인터페이스 메소드를 호출 할 때(Read 시)

 

Who? 누가 발생시키나요?

→ 1:N 또는 N:1 관계를 가진 엔티티를 조회할 때 발생

 

How? 어떻게 하면 발생되나요?

→ JPA Fetch 전략(Fetch Type)이 EAGER 전략으로 데이터를 조회하는 경우

→ JPA Fetch 전략(Fetch Type)이 LAZY 전략으로, 전체 데이터를 가져온 이후 연관 관계인 하위 엔티티를 사용할 때 다시 조회하는 경우

 

Why? 왜 발생하나요?

→ JPA Repository로 find 시 실행하는 첫 쿼리에서 하위 엔티티까지 한 번에 가져오지 않고, 하위 엔티티를 사용할 때 추가로 조회하기 때문에.

→ JPQL은 기본적으로 글로벌 Fetch 전략을 무시하고 JPQL만 가지고 SQL을 생성하기 때문에.

 

 

그래서,

 

EAGER(즉시 로딩)인 경우

  1) JPQL에서 만든 SQL을 통해 데이터를 조회

  2) 이후 JPA에서 Fetch 전략을 가지고(여기서는 즉시 로딩) 해당 데이터의 연관 관계인 하위 엔티티들을 추가 조회(LAZY - 지연 로딩 발생)

  3) 2) 과정으로 N+1 문제 발생함

 

LAZY(지연 로딩)인 경우

  1) JPQL에서 만든 SQL을 통해 데이터를 조회

  2) JPA에서 Fetch 전략을 가지지만, 여기서는 지연 로딩이기 때문에 추가 조회는 하지 않음

  3) 하지만, 하위 엔티티를 가지고 작업하게 되면 추가 조회하기 때문에 결국 N+1 문제가 발생함

 

- 참고사항

@ManyToOne의 기본 FetchType은 EAGER이지만 특별한 경우가 아니라면 LAZY를 기본 세팅해서 사용하는게 좋습니다. LAZY로 사용하더라도 N+1이 발생할 수 있음을 인지하고 사용해야합니다.

 

 


2. 테스트를 위한 환경 구성

DB 테이블 정보

  • Members

 

  • Document

Document(문서)와 Member(사용자) 는 다대일 관계(사용자에서 문서를 확인할 수 없어 단방향)

→ 문서와 사용자가 있다.

→ 문서는 하나의 사용자에만 소속될 수 있다.

→ 여러 개의 문서가 하나의 사용자를 가질 수 있다.

 

 

테스트를 위한 데이터 SQL

  • 사용자 정보는 8개 생성
  • 문서는 7개 생성 → 연관된 사용자는 5가지

3. 테스트

  • N:1 연관관계인 엔티티를 가진 Document findAll 조회

만약, 위의 document 테이블의 데이터를 전체 조회할 때 DB 조회는 총 몇 번 발생할까?

 

결과 

(spring.jpa.properties.hibrnate.format_sql = false여서 한 줄로 보임)

→ Document 데이터 전체 조회(1번)

→ Document 각각의 하위 엔티티인 Member 조회(연관된 사용자는 5가지이므로, 5번 조회)

→ 총 6번 조회

 

이렇듯 Document 테이블에 있는 데이터를 모두 조회하기 위해 1번만 조회하는 것을 기대했지만, Document 안에 있는 Member 하위 엔티티 때문에 총 DB조회를 6번 하게 되었습니다.

(위에서 Member의 데이터는 총 8개이지만, 실제 Document 데이터에 연관된 Member 데이터는 5가지이므로, 5번 더 호출하게 되었습니다.)

 

5+1번의 N+1 문제 발생 확인!

 

 

 

  • 1:N 연관관계인 컬렉션을 가진 Member findAll 조회

→ 단방향 관계이지만, 테스트를 위해 양방향이라고 가정하고 Member Entity에 Document 컬렉션 추가

 

만약, 위의 Member 테이블의 데이터를 전체 조회할 때 DB 조회는 총 몇 번 발생할까?

(LAZY 이기 때문에, for loop를 활용해 Document 데이터를 활용하는 코드 추가)

 

결과 

→ Member 전체 조회(1번)

→ Member 각각이 가지고 있는 하위 엔티티(Document) 조회(Member가 5개이므로, 총 5번 조회)

default fetch Type이 @OnetoMany 인 경우에는 LAZY

 


4. 해결 방법

1) Fetch Join(패치 조인) 사용 - Inner join(카타시안 곱 적용됨)

→ 패치 조인이란, JPQL에서 성능 개선 및 최적화를 위해 제공하는 기능으로 연관된 엔티티나 컬렉션을 함께 한번에 조회하는 역할을 합니다. 이는 SQL 쿼리를 통해 객체 그래프를 한번에 조회할 수 있습니다.

→ 일반 조인은 패치 조인과는 다르게 오직 지정된 SELECT 결과만 가져올 수 있고, 연관 엔티티나 컬렉션을 함께 가져오지 못합니다.

 

 

엔티티 패치 조인(N:1)

→ 별도 @Query를 활용해 SQL문 안에 join fetch 사용하기

 

→ 테스트케이스 새로 작성(Repository에서 새로 만든 함수 호출)

 

실행 결과(1번 호출로 하위 엔티티 Member까지 조회)

 

 

컬렉션 패치 조인(1:N)

실행 결과(처음 1번 호출 뒤 Fetch type이 LAZY이지만 추가 조회 X)

 

 

 

패치 조인 주의사항

패치 조인은 카디션 곱이 되기 때문에, banner4라는 Member는 1개 존재하지만, 관련된 Document는 3개이기 때문에 Member List안에 banner4라는 Member 데이터는 3개가 존재하게 되었습니다.

 

 

해결방법

 

  1) JPQL에 distinct 추가 설정하기

→ 위의 결과와는 다르게 banner4라는 Member 데이터는 1개만 가져오게 되었다.

→ SQL에서는 전체 컬럼값이 동일하지 않기 때문에 distinct 추가 시 중복 제거가 되지 않지만, JPA에서는 SQL의 distinct 기능 뿐 아니라 중복된 엔티티 제거까지 가능합니다.

 

  2) List 대신 Set 사용하기(순서 보장을 위해서 LinkedHashSet 사용도 가능)

 

 

하지만, 이러한 패치 조인은 단점이 존재합니다.

  • 번거롭게 쿼리문을 작성해야 함
  • JPA가 제공하는 Paging API 사용 불가능(Pageable 사용 불가)
  • 1:N 관계 컬렉션이 두 개 이상인 경우 사용 불가
  • 패치 조인 대상에게 별칭을 부여 불가능

엔티티 패치 조인 : N:1(ManyToOne) 관계일 때, fetch join

컬렉션 패치 조인 : 1:N(OneToMany) 관계일 때, fetch join

 

 

** 1:N관계 컬렉션이 두 개 이상인 경우

→ 임시로 Temp Table 및 Entity 생성

 

→ @OneToMany 컬렉션 추가(총 2개)

 

MultipleBagFetchException 발생(두 개 이상의 1:N 컬렉션 fetch join 불가능)

 

 

 

2) Batch Size 설정

→ @OneToMany에 @BatchSize 추가

 

→ LAZY 설정 시 for loop로 확인할 때 Batch Size 단위만큼 끊어서 하나의 SELECT로 조회한다.

→ EAGER 설정 시 첫 전체 조회할 때 추가적으로 Batch Size 단위만큼 SELECT 조회한다.

 

→ 테스트 결과, Fetch 전략은 상관이 없었습니다. EAGER인 경우 첫 조회 시 BatchSize만큼 끊어서 하나의 쿼리로 조회하게 되고, LAZY인 경우 추후 사용할 때 BatchSize만큼 끊어서 하나의 쿼리로 조회하게 됩니다.

하지만, 되도록이면 EAGER를 사용하는게 나은 이유는 for loop를 통해 전체 데이터를 순회하면서 하위 엔티티를 사용하는 경우가 아니라 불특정 시점의 일부 데이터만 사용하게 되면 BatchSize가 의미없어지기 때문입니다.

 

 

*** 그 외 @EntityGraph 방법도 존재하지만, 추후 작성하겠습니다.

 

 

 

이상입니다.

 

감사합니다.

 

 

 

 

참고

https://jojoldu.tistory.com/165

https://wwlee94.github.io/category/blog/spring-jpa-n+1-query/#n1-%EC%BF%BC%EB%A6%AC-%EB%AC%B8%EC%A0%9C%EC%9D%98-%EC%9B%90%EC%9D%B8- 

https://blog.advenoh.pe.kr/database/JPA-N1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%EB%B0%A9%EB%B2%95/

https://blog.naver.com/PostView.nhn?blogId=qjawnswkd&logNo=222078705093 

https://velog.io/@ljinsk3/JPA-JPQL-%ED%8E%98%EC%B9%98%EC%A1%B0%EC%9D%B8

 

Comments