Skip to content

Commit

Permalink
feat: add a n+1-issue.md
Browse files Browse the repository at this point in the history
  • Loading branch information
sungjindev committed Feb 5, 2024
1 parent 9993dba commit f5135a7
Show file tree
Hide file tree
Showing 14 changed files with 122 additions and 0 deletions.
122 changes: 122 additions & 0 deletions _posts/2024-02-06-n+1-issue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
---
title: Spring Data JPA N+1 문제 해결해서 수백번 나가던 쿼리를 단 1번에 처리하기!
categories: [Computer engineering, Backend engineering]
tags: [backend, spring, java, N+1, JPA, 백엔드, 스프링, 자바, N+1 문제]
---

이번 포스팅에서는 Spring JPA를 활용해서 프로젝트를 진행하다보면 한번쯤 마주치게 되는 N+1 문제에 대해 소개하고 이를 해결하는 방법을 공유드리려 합니다.

## N+1 문제란?
우선, N+1 문제가 무엇인지 간략하게 소개하고 넘어가겠습니다. 예를들어 Member라는 엔티티가 있고 주소를 나타내는 Address라는 엔티티가 별도로 있을 때 Member와 Address가 1:1 연관 관계를 가진다고 생각해보겠습니다.

이때 Member 테이블 안에 들어있는 모든 회원에 대한 정보를 조회하기 위해 "SELECT * FROM member"라는 쿼리를 실행시켰다고 했을 때 당연히 Member 테이블에 대한 저 쿼리 1개만 실행될 것이라고 생각되지만 그렇지 않습니다.

만약 Member 테이블 안에 들어있는 회원이 총 100명이면 "SELECT * FROM member" 쿼리 1개와 더불어 각각의 Member들과 연관되어 있는 Address까지 읽어들이기 위한 총 100개의 쿼리가 추가적으로 실행됩니다. 이게 바로 1+N 문제입니다. 지금은 Member 테이블 안에 100명 밖에 없다고 가정했지만 만약 실제 Production level의 Database라면 훨씬 더 많은 데이터들이 저장되어 있을테고 그와 비례해서 추가적인 쿼리가 무조건적으로 함께 실행되게 되는 것입니다. 이는 결국 불필요한 추가 쿼리로 인해 성능 저하를 일으킬 수 있습니다.

이러한 N+1 문제가 발생하는 근본적인 이유는 연관 관계 매핑을 해줄 때 사용하는 @OneToOne 혹은 @ManyToOne과 같은 @XToOne 형태의 애너테이션의 Default FetchType값이 EAGER이기 때문입니다.

![1](/assets/img/n+1-issue/1.png){: w="300" h="80" style="border:1px solid #eaeaea; border-radius: 7px; padding: 0px;"}

![2](/assets/img/n+1-issue/2.png){: w="300" h="80" style="border:1px solid #eaeaea; border-radius: 7px; padding: 0px;"}

위 이미지들을 보면 @XToOne 형태의 Default FetchType들이 모두 EAGER임을 확인할 수 있습니다. 이렇게 EAGER Type이 되어 있으면 우리가 조회하고자 하는 그 엔티티와 연관되어 있는 다른 엔티티들의 정보까지 모두 한번에 가져와버립니다. 그래서 아까 말씀드린 예시처럼 저희는 Member만을 조회했지만 Member와 연관 관계를 맺고 있는 Address까지 모두 조회하게 되어 추가적으로 100개의 쿼리가 더 나갔던 것입니다.

이런 중요한 결정을 개발자가 Optionally 선택할 수 있게 FetchType을 우선 LAZY로 바꾸고 별도로 처리해주는 것이 좋습니다. 그러기 위해선 우선 @XTOOne 형태의 애너테이션에 아래와 같이 모두 명시적으로 LAZY FetchType으로 수정해주도록 합시다.

```java
@ManyToOne(fetch = FetchType.LAZY)
@OneToOne(fetch = FetchType.LAZY)
```

## 실제 문제 상황
지금까지는 N+1 문제에 대해 살펴봤고 이번에는 제가 프로젝트를 진행하면서 관련해서 어떤 문제를 겪었는지 공유드리고자 합니다.

![3](/assets/img/n+1-issue/3.png){: w="600" h="160" style="border:1px solid #eaeaea; border-radius: 7px; padding: 0px;"}

우선 문제가 발생한 연관 관계 테이블 구조는 위와 같습니다. Store와 Certification이 원래 N:M 연관 관계를 가져야 하는데 이러한 다대다 관계는 RDB에서 구현할 수 없으므로 위처럼 StoreCertification이라는 이름으로 마치 Join Table의 역할을 해줄 수 있는 엔티티를 별도로 만들어줬습니다.

이 상황에서 만약에 StoreCertification과 연관 관계를 맺고 있는 Store와 Certification에 대해서 FetchType을 별도로 설정해주지 않았다면 EAGER를 Default Type으로 가지고 있기 때문에 StoreCertification 데이터를 조회하는 "select * from store_certification"과 같은 쿼리를 실행시키게 되면 바로 N+1 문제가 발생하게 됩니다.

약 58000개 정도의 StoreCertification 데이터들을 가지고 있는 저희 Development DB에서 N+1 문제를 발생시켜보면 다음과 같은 상황이 펼쳐집니다.

![4](/assets/img/n+1-issue/4.png){: w="300" h="80" style="border:1px solid #eaeaea; border-radius: 7px; padding: 0px;"}

우선 가장 먼저 1에 해당되는 StoreCertification을 조회하는 쿼리가 나가게 됩니다. 하지만 현재 FetchType이 EAGER로 되어있기 때문에 이 쿼리로 끝나지 않습니다.

![5](/assets/img/n+1-issue/5.png){: w="600" h="160" style="border:1px solid #eaeaea; border-radius: 7px; padding: 0px;"}

![6](/assets/img/n+1-issue/6.png){: w="600" h="160" style="border:1px solid #eaeaea; border-radius: 7px; padding: 0px;"}

바로 그 뒤를 이어 위와 같이 Certification, Store 테이블들에 대해 무수히 많은 데이터를 조회하는 쿼리가 잇달아서 실행되게 됩니다. 지금은 각 1개씩의 쿼리만을 가져왔지만, 그 1개의 쿼리 조차도 Spring에서 최적화를 해줘서 WHERE IN 절로 처리된 것이지 이러한 쿼리가 정말 수십개가 뒤이어 함께 실행되게 됩니다.

![7](/assets/img/n+1-issue/7.png){: w="600" h="160" style="border:1px solid #eaeaea; border-radius: 7px; padding: 0px;"}

이렇게 WHERE IN 절 안에 수십개가 담긴 쿼리가 단순히 SELECT 키워드로 조회해봤을 때만해도 약 196번 정도 실행되는 것을 위처럼 확인할 수 있었습니다. 사실 저는 이 프로젝트를 진행하기 전부터 이미 N+1 문제를 알고 있었고 이에 대해 대비하기 위해 항상 모든 Fetch Type은 LAZY로 설정해주고 제가 필요할 때만 Fetch Join 해주는 방식으로 진행하고 있었기 때문에 위와 같은 문제를 겪진 않았습니다.

하지만, 제가 이 포스팅을 적게된 계기이자 비슷하지만 조금 다른 문제 상황을 겪게 됩니다. 그것은 바로 Store 엔티티 안에 List<> Collection type으로 @ElementCollection 이라는 애너테이션을 사용해서 총 두 개의 필드를 저장하고 있었는데 이 부분에서 N+1 문제가 발생한 것입니다.

## ElementCollection N+1 문제
![8](/assets/img/n+1-issue/8.png){: w="600" h="160" style="border:1px solid #eaeaea; border-radius: 7px; padding: 0px;"}

저는 위와 같이 Store 엔티티에서 localPhotos라는 ElementCollection을 사용하고 있었는데, ElementCollection도 결국 RDB에서 복수형의 데이터가 한 Row에서 표현이 안되기 때문에 내부적으로 1:N와 같은 관계로 풀어지게 됩니다. 그래서 결국 Store이 조회될 때 그 안에 있는 localPhotos라는 ElementCollection도 FetchType에 따라 함께 불러와진다던지 해서 N+1 문제가 발생할 수도 있습니다.

![9](/assets/img/n+1-issue/9.png){: w="300" h="80" style="border:1px solid #eaeaea; border-radius: 7px; padding: 0px;"}

하지만 다행인건, 위처럼 ElementCollection 애너테이션 정의에 들어가 확인해봤을 때 Default FetchType의 값은 EAGER가 아니고 LAZY였습니다. 따라서 저희가 염려했던 EAGER로 인한 N+1 문제는 발생하지 않게 되는데 그렇다면 LAZY FetchType을 가지고 있을 때 JOIN을 통해서 Store에 대한 정보와 그와 관계를 맺고 있는 localPhotos까지 함께 불러오고 싶을 땐 어떻게 해야 될까요?

## N+1 문제 해결
이러한 상횡에서는 Fetch Join을 이용하면 됩니다. 그러니까 결국 개발자가 의도하지 않은대로 자동으로 수많은 쿼리가 나가지 않게 일단 Fetch Type은 모두 LAZY로 설정해준 뒤, JOIN이 필요한 상황에만 별도로 Fetch Join을 활용해서 데이터를 함께 불러오면 됩니다.

실제로 저는 StoreCertification 테이블에 속한 모든 데이터와 더불어 그와 연관된 Store, Certification 데이터까지 함께 가져오는 기능이 필요했습니다. 이를 만약에 그냥 일반적인 Join으로 가져오게 되면 앞서 설명드린 N+1 문제처럼 연관 관계로 매핑되어있는 다른 테이블들에 대한 데이터를 가져오기 위한 쿼리가 나뉘어 엄청 여러번 실행되게 됩니다. 이를 하나의 쿼리로 모아서 한번씩만 실행시키게 하기 위해서는 Fetch Join을 사용해야 합니다.

```java
TypedQuery<StoreCertification> query = em.createQuery(
"SELECT sc FROM StoreCertification sc " +
"JOIN FETCH sc.store s " +
"JOIN FETCH sc.certification c ",
StoreCertification.class);
```
그래서 저는 위와 같은 쿼리를 작성하여 실행하였습니다. 이렇게 되면 store의 데이터들을 불러오면서 @ElementCollection 애너테이션이 붙은 필드까지 모두 Fetch Join으로 불러와질 줄 알았습니다.

하지만 제 예상과 다르게 @ElementCollection 애너테이션이 붙은 필드는 Fetch Join이 아닌 단순 Join으로 불러와지고 그 이외의 필드들만 모두 Fetch Join으로 불러와지는 것을 확인할 수 있었습니다. 그래서 @ElementCollection 필드에도 Fetch Join을 적용시키는 법을 찾게 되었고 아래와 같이 별도로 명시해서 작성해줘야 된다는 것을 확인할 수 있었습니다.

```java
TypedQuery<StoreCertification> query = em.createQuery(
"SELECT sc FROM StoreCertification sc " +
"JOIN FETCH sc.store s " +
"JOIN FETCH sc.certification c " +
"JOIN FETCH s.localPhotos sl " + // localPhotos를 Fetch Join
"JOIN FETCH s.regularOpeningHours sr ", // regularOpeningHours를 Fetch Join
StoreCertification.class);
```

![10](/assets/img/n+1-issue/10.png){: w="900" h="300" style="border:1px solid #eaeaea; border-radius: 7px; padding: 0px;"}

이렇게 모든 문제가 해결되는 것처럼 보였으나, 위처럼 수정을 하니 또 다른 에러가 발생했습니다. "cannot simultaneously fetch multiple bags" 이게 에러의 주 요인인 것 같은데 관련해서 찾아보니 말 그대로 다수의 bags를 동시에 fetch할 수 없다는 게 내용인데 bags이 정확히 무엇인지 궁금했습니다. 그래서 구글링 후 알아낸 정의는 다음과 같습니다.

>A "bag" is an unordered collection, which can contain duplicated elements. That means if you persist a bag with some order of elements, you cannot expect the same order retains when the collection is retrieved. There is not a “bag” concept in Java collections framework, so we just use a java.util.List corresponds to a "bag".
{:.prompt-info}
>A "set" is similar to "bag" except that it can only store unique objects. That means no duplicate elements can be contained in a set. When you add the same element to a set for second time, it will replace the old one. A set is unordered by default but we can ask it to be sorted. The corresponding type of a in Java is java.util.Set.
{:.prompt-info}

그러니까 결국 즉, Bag(Multiset)은 Set과 같이 순서가 없고, List와 같이 중복을 허용하는 자료구조를 뜻합니다. 하지만 자바 컬렉션 프레임워크에서는 Bag이 없기 때문에 하이버네이트에서는 List를 Bag으로써 사용하고 있단 이야기입니다.

이제 제가 왜 위와 같은 Exception을 마주하게 됐는지 이해가 됩니다. 저는 @ElementCollection이 걸려있는 두 필드 localPhotos, regularOpeningHours에 대해서 동시에 Fetch Join을 시도했었는데 두 필드 모두 Bag에 해당하는 List<>로 이루어져 있었기 때문에 위와 같은 문제가 발생했던 것입니다. 따라서 이들의 Type을 Set<>으로 바꿔주면 모두 해결이 됩니다.

## 성능 비교
![11](/assets/img/n+1-issue/11.png){: w="300" h="60" style="border:1px solid #eaeaea; border-radius: 7px; padding: 0px;"}

이렇게 N+1 문제를 해결해주고 나니 위 사진처럼 수십 수백개의 쿼리로 나누어 나가던 쿼리가 단 1개로 Fetch Join되어 실행되는 것을 확인할 수 있었습니다. 앞선 예시에서는 그나마 스프링에서 자동으로 최적화를 잘 해줘서 엄청 긴 IN 절을 사용해 196개의 추가 쿼리가 나갔었던 것인데 이게 만약 IN 절 없이 모두 나뉘어 나갔더라면, 함께 조회하고자 하는 테이블의 모든 Row 수만큼 추가 쿼리가 나갔을 것입니다. 저희 테이블 기준으로는 대략 10만 개 이상의 데이터에 대해 모두 각각 쿼리가 나갔을텐데 이를 단 1번의 쿼리로 줄일 수 있습니다.

추가로 실행되던 195개의 쿼리가 1번의 쿼리로 줄어들었는데 그렇다면 Response time은 얼마나 빨라졌는지 궁금하실 겁니다. 그래서 비교해봤습니다.

![12](/assets/img/n+1-issue/12.png){: w="900" h="300" style="border:1px solid #eaeaea; border-radius: 7px; padding: 0px;"}

우선 처음 N+1 문제를 해결하기 전입니다. 총 6.22초가 걸렸습니다. 참고로 저희 DB 테이블들의 Schema가 노출되는 것을 방지하기 위해 Response Body의 일부분만 캡처한 점 양해 부탁드립니다.

![13](/assets/img/n+1-issue/13.png){: w="900" h="300" style="border:1px solid #eaeaea; border-radius: 7px; padding: 0px;"}

다음은, N+1 문제를 해결하고 모두 Fetch Join했을 때 Response time입니다. 총 4.48초가 걸렸습니다. 즉 6.22초에서 4.48초로 약 28%의 성능 향상을 얻을 수 있었습니다. 물론 이 성능 비교는 Database를 충분히 Warm Up시킨 이후에 재거나, 횟수를 늘려 더 많은 테스트를 하면 환경에 따라 그때 그때 결과 값이 조금 차이날 수는 있으니, 단순한 비교 결과로 참고해주시면 좋을 것 같습니다.

## 마무리
이번 포스팅에서는 Spring Project를 진행하며 흔히 처음 겪을 수 있는 문제인 N+1 문제에 대해 자세하게 다뤄봤습니다. 실제로 N+1을 운영 환경의 데이터베이스에서 맞닥뜨려보니 얼마나 큰 성능 저하 현상이 발생하는지 직접 체감할 수 있었고 이를 해결하는 과정 속에서 많은 것들을 배울 수 있었습니다. 이번 포스팅이 저와 비슷한 문제를 겪고 계신 분들에게 조금이나마 도움이 되었으면 하는 마음에 이렇게 제 Troubleshooting 과정을 공유드려봅니다. 감사합니다.
Binary file added assets/img/n+1-issue/1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/img/n+1-issue/10.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/img/n+1-issue/11.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/img/n+1-issue/12.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/img/n+1-issue/13.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/img/n+1-issue/2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/img/n+1-issue/3.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/img/n+1-issue/4.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/img/n+1-issue/5.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/img/n+1-issue/6.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/img/n+1-issue/7.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/img/n+1-issue/8.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/img/n+1-issue/9.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit f5135a7

Please sign in to comment.