본문 바로가기

DEVELOPER/Spring & JPA

[JPA] N+1 문제와 Query 성능 개선 (Fetch Join)

안녕하세요😎 백엔드 개발자 제임스입니다 :)
오늘은 JPA에서 발생하는 N+1 문제에 대해서 알아보도록 하겠습니다. 이 문제는 JPA를 사용할 때 데이터베이스 쿼리를 최적화하지 않아서 발생하는 성능 이슈입니다. 아마 우리는 자주 겪었을 텐데요. 인지하지 못한 채 지나쳤을 수도 있습니다.
한 가지 질문을 해보겠습니다. :)
웹 애플리케이션을 구현하면서, 콘솔에 SQL 쿼리가 본인 생각과 다르게 많은 양이 나간 적이 있을까요? 가령 하나의 데이터를 요청했지만, 2~3개 그 이상의 SQL 쿼리가 던져지는 것처럼 말입니다. (아니면 의문을 가져본 적이 없었나요? 🤣)
이 질문은 N+1 문제와 관련이 있습니다. 이제 이 문제가 무엇이며, 어떠한 해결 방법이 있는지 알아보겠습니다.

 

N+1 문제란?


JPA에서 N+1 문제는 연관된 엔티티를 조회할 때 발생하는 성능 문제인데요. 이름을 '1+N' 이라고 뒤집어서 생각하면 쉽게 이해할 수 있습니다. 즉, 1개의 쿼리로 처리 되길 기대했으나, N개의 추가 쿼리가 발생하는 현상입니다. 자세하게는 연관된 자식 데이터 수(N) 만큼 추가 쿼리가 발생하는 것입니다.

예를 들어서 게시글과 게시글의 댓글이 일대다의 관계로 있다고 가정하겠습니다. 이때 게시글 목록 댓글을 동시에 조회하려고 합니다. 만약 게시글이 5개 있다면, 게시글을 조회하는 쿼리 1개와 게시글에 대한 댓글을 조회하는 쿼리 5개가 추가로 발생하게 됩니다. 총 6개의 쿼리가 발생하는 것입니다.

만약 게시글 데이터가 N개의 무수히 많은 양이라면, N+1 개의 쿼리가 발생할 것입니다. 이로 인해서 N+1 문젠는 데이터가 많아질수록 더욱 큰 성능 저하를 야기합니다.
 

N+1 문제가 발생하는 상황


 N+1 문제는 다양한 연관관계에서 발생합니다. 따라서 해결 방법도 다양하게 있습니다. 대표적으로는 두 개의 엔티티가 1:N 관계를 가지고, JPQL로 객체를 조회할 때 발생합니다.

* JPQL이란?
JPQL(Java Persistence Query Language)이란, 객체 지향 프로그래밍 언어인 Java에서 관계형 데이터베이스를 다루기 위한 쿼리 언어입니다. 이는 JPA에서 사용되며, 객체를 대상으로 쿼리를 작성합니다.

 

Fetch 전략에 따른 N+1 문제

자세하게는 연관 엔티티 조회를 위한, 글로벌 패치 전략에 따라 N+1 문제가 발생하는데요. 먼저 글로벌 페치 전략에 대해서 간단하게 알아보겠습니다.

▶  Fetch 전략이란?

특정 엔티티를 조회할 때, 연관된 다른 엔티티를 [언제] 불러오는지에 대한 옵션입니다. 위와 같이 연관관계 매핑 어노테이션의 옵션을 통해 전략을 명시합니다. 옵션에는 두 가지의 전략이 있습니다. 

  • 즉시 로딩(Eager) 전략 : 현재 엔티티를 조회한 [직후], 연관된 엔티티까지 조회한다.
  • 지연 로딩(Lazy) 전략 : 현재 엔티티를 조회하고, [추후 필요할 때] 연관된 데이터를 조회하는 쿼리를 날린다.
지연로딩(Lazy) 추가 설명
엔티티를 조회할 시, 해당 엔티티를 사용할 때까지 조회 쿼리의 로딩을 미루는 현상입니다.

* 코드와 결과 예시를 통해 전략에 따른 N+1 문제 상황을 보도록 하겠습니다.

1️⃣ LAZY 전략으로 데이터를 가져오는 경우

게시글 엔티티

(1) 1개의 게시글의 댓글 수 조회

(좌) 테스트 코드, (우) 쿼리 결과

우측에 쿼리 결과를 보겠습니다. 두 개의 쿼리가 발생한 것이 보이는데요. 확실히 게시글만 조회했을 때는 댓글에 대한 쿼리가 없습니다. 반면 댓글에 접근하는 시점에 댓글 조회 쿼리가 추가로 발생한 것을 알 수 있습니다. 이 상황만 봐도 N+1 문제라는 것을 알 수 있습니다. 하지만 조금 더 직관적으로 보기 위해 더 많은 게시글을 조회해보겠습니다.
(2) 5개의 게시글의 댓글 수 조회

쿼리 결과

위 쿼리 결과를 보면 전체 게시글을 조회할 때 1번, 게시글 각각의 댓글 조회 5번으로 총 6번의 쿼리가 나갔습니다.

다시 정리하면,
지연 로딩이기 때문에 당장 게시글을 조회할 때는 연관된 엔티티를 조회하는 쿼리가 발생하지 않습니다. 하지만 연관된 엔티티를 사용하려는 순간 부모 엔티티의 수 만큼 추가 조회 쿼리가 발생하게 됩니다.

2️⃣ EAGER 전략으로 데이터를 가져오는 경우

"그렇다면 FetchType을 즉시 로딩 전략으로 하면 되는 게 아닐까?"
라고 해결법을 생각할 수 있습니다. 하지만 지연로딩과 동일하게 N+1 문제가 발생합니다.

Lazy 로딩과 동일한 테스트 진행 (5개의 게시글의 댓글 수 조회)

쿼리 결과

위 결과를 보면, 쿼리가 발생하는 순서는 달라졌습니다. 지연 로딩 전략에서는 연관 엔티티에 접근할 때 쿼리가 발생했는데요. 반면에 즉시 로딩 전략에서는 게시글을 조회할 때 쿼리가 발생했습니다. 그럼에도 N+1 문제는 여전히 발생하고 있습니다.
또한 즉시 로딩은 연관 엔티티에 접근하지 않음에도 불구하고 모든 엔티티의 조회 쿼리가 발생하기 때문에, 지연 로딩보다 더 큰 성능 문제를 만듭니다.
 

왜 N+1 문제가 발생했을까요?


N+1 문제의 원인은 JPQL의 특징으로 발생합니다.
JPQL은 엔티티를 기준으로 쿼리를 만듭니다. 이때 연관 관계를 신경쓰지 않고, 해당 엔티티만 조회하는 첫 번째 쿼리로 만듭니다. 그 다음 연관된 객체와 명시된 Fetch 전략을 확인한 후 추가 쿼리를 만들게 됩니다.
 

문제 해결 방법


1) Join Fetch (@Query 어노테이션 사용)

SQL 쿼리에 직접 Join fetch를 명시하는 경우입니다. (Spring Data JPA 기준)

@Query 어노테이션 사용

위 코드는 Query 어노테이션을 사용하여 JPQL을 직접 작성한 것입니다. 이때, "JOIN FETCH p.comments" 구문을 통해서 게시글을 조회할 때 댓글 엔티티도 함께 조회합니다.

발생한 쿼리 결과

쿼리 결과를 보면 inner join을 통해서 1개의 쿼리만 발생한 것을 볼 수 있습니다. 이 방법으로 연관된 엔티티마다 즉시로딩인지, 지연로딩인지 직접 지정할 필요가 없습니다. 

더 나아가 여러 연관관계의 엔티티나 하위 엔티티의 연관관계의 엔티티까지 1개의 쿼리로 모두 갖고 올 수 있습니다.

* 만약 Spring Data JPA가 아닌 JPA에서 사용한다면,
EntityManager를 사용하여 JPQL을 실행하면서 동일하게 JOIN FETCH를 사용합니다.

List<Post> posts = em.createQuery("SELECT DISTINCT p FROM Post p JOIN FETCH p.comments", Post.class).getResultList();

 

2) @EntityGraph 어노테이션 사용

@EntityGraph 어노테이션을 사용하는 방법은 두 가지가 있습니다.

(1) 이름으로 정의

@EntityGraph 옵션 중 value는 쿼리의 이름입니다. 아래에서 우측 사진의 코드와 같이 부모 엔티티에 동일한 이름을 지정하고, 함께 가져올 연관 엔티티를 명시합니다. 

type 옵션의 EntityGraphType은 Fetch와 Load 전략이 있습니다.
- FETCH: entity graph에 명시한 attribute는 EAGER로 패치하고, 나머지 attribute는 LAZY로 패치
- LOAD: entity graph에 명시한 attribute는 EAGER로 패치하고, 나머지 attribute는 entity에 명시한 fetch type이나 디폴트 FetchType으로 패치
(e.g. @OneToMany는 LAZY, @ManyToOne은 EAGER 등이 디폴트입니다.)

아래와 같이 여러 개의 연관 엔티티를 join하거나
subgraph를 활용해서 하위 엔티티의 연관 엔티티도 join 할 수 있습니다.

 

(2) 간략하게 Fetch Join 하는 방법

사실 간단하게 EntityGraph를 사용할 수 있습니다. 아래 코드와 같이 Join할 연관 엔티티 이름만 명시해도 동일하게 작동합니다. 이때 EntityGraphType은 default인 Fetch가 적용됩니다.

(좌) 1개 연관 엔티티 Join, (우) 하위 엔티티의 연관 엔티티까지 Join
결과


 

문제 해결 이후 성능 개선 결과


임의로 100,000개의 게시글을 조회했습니다.

[AS-IS] N+1 문제가 발생할 때

N+1 문제의 쿼리 결과

* 소요 시간 : 1,940 ms

 

[TO-BE] 문제 해결 이후

* 소요 시간 : 903 ms

확실히 눈에 띄게 시간을 단축한 것을 볼 수 있습니다.
Big-O로 나타내면, N+1 문제가 발생할 때는 O(N), 문제를 해결한 이후에는 O(1)이 됩니다.
JPA를 사용하는 개발자라면 정말 중요하게 생각해야할 문제인 것 같습니다.
 

@Query 와 @EntityGraph를 사용시 주의사항 !


(좌) @Query (우) @EntityGraph 사용

@Query 와 @EntityGraph의 결과를 보면 @Query는 Innter Join, @EntityGraph는 Outer Join이라는 차이점을 알 수 있습니다.
여기서 @Query는 중복 조인이 발생하여 Comment 수만큼 Post를 추가로 만듭니다. 아래의 테스트 예시를 통해서 자세하게 알아보겠습니다.

▶  중복 조인 테스트

먼저 5개의 게시글마다 2개의 댓글을 추가했습니다.

(1) @EntityGraph를 통한 join 결과

각 게시글에 2개의 댓글이 들어있고, 총 5개의 게시글이 정상적으로 조회되었습니다.

(2) @Query를 통한 join 결과

게시글이 댓글의 수 만큼 배로 증가했습니다.

▶  해결 방안

해당 문제는 카테시안 곱(Cartesian Product)이 발생한 것입니다. 자세하게는 Cross join 이 발생하여 N*M 만큼의 데이터가 조회되었습니다. 만약 데이터가 방대할 경우 CPU 과부화가 발생할 수도 있습니다. 뿐만 아니라 결과도 달라지기 때문에 큰 문제가 될 것입니다.
(1) Set 자료구조를 사용

일대다 필드의 타입을 Set으로 선언합니다. Set 자료구조는 중복을 허용하지 않기 때문에 문제를 해결할 수 있습니다. 추가로 LinkedHashSet을 통해서 순서를 보장할 수 있습니다.
(2) distinct를 사용하여 중복을 제거


참고 링크

  • [JPA N+1 문제 및 해결방안]

https://jojoldu.tistory.com/165

 

JPA N+1 문제 및 해결방안

안녕하세요? 이번 시간엔 JPA의 N+1 문제에 대해 이야기 해보려고 합니다. 모든 코드는 Github에 있기 때문에 함께 보시면 더 이해하기 쉬우실 것 같습니다. (공부한 내용을 정리하는 Github와 세미나+

jojoldu.tistory.com

 

  • 카티시안 곱 (Cartesian Product)

https://byul91oh.tistory.com/26

 

카티션 곱 / 카티시안 곱 / 카테시안 곱 /Cartesian product)

Cartesian Product 카티션 곱 -설명 1 카티션 곱(cartesian product)은 엄밀히 말하면 조인이라고 할 수 없는 조인으로, WHERE 절에 조인 조건을 주지 않는 것을 말합니다. 두 테이블을 기준으로 FROM 절에는 두

byul91oh.tistory.com

반응형