0. 들어가기 전
현재 진행중인 프로젝트에서 쿼리를 카운트해본 결과, JPA의 N+1 문제가 발생하고 있었습니다.
N+1 문제에 대한 소개, 고찰은 이전에 포스팅했었기 때문에 링크만 남기고 넘어가도록 하겠습니다!
https://ksh-coding.tistory.com/146
그래서 이번에는 N+1 문제를 해결한 과정과 전후 성능 비교를 포스팅해보도록 하겠습니다!
1. N+1 발생 배경
먼저 어떤 엔티티에서 N+1 문제가 발생하고 있는지 살펴보도록 하겠습니다.
N+1 문제가 발생한 엔티티는 서비스에서 핵심적인 도메인인 동선(Route) 엔티티였습니다.
'동선' 엔티티는 '태그' 엔티티, '장소' 엔티티와 복잡한 연관관계를 맺고 있습니다.
- Route : 동선 / RouteTag : 태그 / Place : 장소
- 동선 - 태그 M : N
- 동선 - 장소 M : N
이처럼 다대다 관계로 설계되었기 때문에, 동선 엔티티에서는 다음과 같이 태그, 장소의 중간 엔티티를 가지는 형태였습니다.
public class Route extends BaseEntity {
...
@OneToMany(mappedBy = "route", orphanRemoval = true)
private List<SelectedTag> tags = new ArrayList<>();
@OneToMany(mappedBy = "route", orphanRemoval = true)
private List<RoutePlace> routePlaces = new ArrayList<>();
...
}
- SelectedTag : 동선 - 태그의 다대다 중간 엔티티
- RoutePlace : 동선 - 장소의 다대다 중간 엔티티
또, 장소 엔티티에서는 값 타입 컬렉션 @ElementCollection을 통해 장소의 사진 링크들을 다음과 같이 저장하고 있습니다.
public class Place extends BaseEntity {
...
@ElementCollection
@Column(unique = true)
private List<String> photoReferences = new ArrayList<>();
...
}
- photoReferences : 값 타입 컬렉션을 사용하여 별도의 사진 링크 테이블 생성
이러한 관계들을 ERD로 나타내면 다음과 같습니다.
여기서 Route를 중심으로 살펴보면, Route를 조회할 때 다음과 같은 엔티티에서 N+1 문제가 발생합니다.
- SelectedTag : 조회한 동선의 개수만큼 조회 쿼리 발생
- RouteTag : 각 동선별로 가진 태그의 개수만큼 조회 쿼리 발생
- RoutePlace : 조회한 동선의 개수만큼 조회 쿼리 발생
- Place : 각 동선별로 가진 장소의 개수만큼 조회 쿼리 발생
- PlacePhotoReferences : 각 동선별로 가진 장소의 개수만큼 조회 쿼리 발생(장소당 사진 조회 쿼리 나가므로)
이렇게 Route가 핵심 도메인인만큼 다른 엔티티들과 복잡한 관계를 맺고 있기 때문에 여러 N+1 조회 쿼리가 발생합니다.
2. 실제로 발생할 수 있는 최악의 쿼리 경우
이제 N+1 문제가 발생했을 때 현재 서비스에서 최악의 경우 몇 개의 쿼리가 발생하는지 알아보도록 하겠습니다.
기본적으로 현재 상황에서 Route 엔티티를 조회할 때 N+1 문제가 발생합니다.
그리고 해당 쿼리는 조회할 Route 엔티티 개수에 따라서 조회 쿼리가 증가하게 됩니다.
따라서, 최악의 쿼리 경우는 '동선을 여러 개 조회할 때'라고 할 수 있습니다.
현재 서비스에서는 다음과 같이 최악의 경우를 가정해볼 수 있습니다.
- 한 달의 모든 날에 동선이 생성되어 있는 사용자가 서비스의 '동선 월별 조회' 기능을 사용하는 경우
- 한 달의 마지막 날은 30일로 가정(동선 30개)
- 계산의 편의성을 위해서 각 동선별로 태그는 3개, 장소는 4개로 가정
- 각 동선은 겹치는 태그, 장소 없이 모두 새로운 태그와 장소라고 가정
이 경우를 테스트하기 위해 위와 같이 동선 데이터를 넣어 놓고 쿼리가 몇 개 발생하는지 테스트해봤습니다.
결과는 다음과 같았습니다.
쿼리 개수는 총 392개로 엄청난 조회 쿼리가 발생하는 것을 알 수 있었습니다.
여기서 1개의 쿼리는 인증에 사용되는 쿼리이므로 실질적으로 '동선 월별 조회'에 사용된 쿼리는 391개입니다.
391개의 쿼리가 어떻게 도출되었는지 분석해보겠습니다.
- Route : 사용자의 동선 조회 쿼리 발생 -> 1개 쿼리 (1번에 해당 달의 모든 동선 가져옴)
- SelectedTag : 조회한 동선(Route)의 개수만큼 조회 쿼리 발생 -> 30개 쿼리
- RouteTag : 각 동선별로 가진 태그의 개수만큼 조회 쿼리 발생 -> 동선 당 태그 3개를 가지므로 30 * 3 = 90개 쿼리
- RoutePlace : 조회한 동선(Route)의 개수만큼 조회 쿼리 발생 -> 30개 쿼리
- Place : 각 동선별로 가진 장소의 개수만큼 조회 쿼리 발생 -> 동선 당 장소 4개를 가지므로 30 * 4 = 120개 쿼리
- PlacePhotoReferences : 각 동선별로 가진 장소의 개수만큼 조회 쿼리 발생(장소당 사진 조회 쿼리 나가므로) -> 동선 당 장소 4개이고, 그만큼 사진 조회 쿼리가 나가므로 30 * 4 = 120개 쿼리
따라서, 위와 같이 1 + 30 + 90 + 30 + 120 + 120 = 391개가 도출되는 것을 알 수 있습니다.
만약 사용자가 모든 날에 동선을 생성했다면, 달력을 넘길 때마다 392개의 쿼리가 발생할 것입니다.
이는 DB 관점에서나 UX 관점에서 상당한 문제가 될 수 있습니다.
3. N+1 문제 해결하기 - Batch Size 설정
이러한 N+1 문제를 해결하는 방법은 크게 다음과 같은 방법들이 있습니다.
이 또한 이전 포스팅에서 자세하게 소개했기 때문에 자세할 설명은 생략하도록 하겠습니다.
- N+1 문제에서 발생하는 추가 조회 쿼리를 없애는 방법(Join으로 한 번에 가져오기)
- fetch join
- EntityGraph
- N+1 문제에서 발생하는 추가 조회 쿼리를 1개의 쿼리로 줄이는 방법
- Batch Size 설정
- Sub Query 사용
Hibernate에서는 전자의 방법인 N+1 문제에서 발생하는 추가 조회 쿼리를 없애는 방법을 권장하고 있습니다.
그러나, 현재의 상황에서 fetch join을 사용하면 MultipleBagFetchException이라는 에러를 마주하게 됩니다.
※ MultipleBagFetchException란?
MultipleBagFetchExcetion은 다음과 같이 소개하고 있습니다.
Exception used to indicate that a query is attempting to simultaneously fetch multiple bags
이를 살펴보면, 동시에 여러 bag을 fetch로 가져오기 때문에 MultipleBagFetchExcetion이 발생하는 것을 알 수 있습니다.
- bag : JPA에서 사용하는 Collection Type, 일반적으로 List 타입의 자식 컬렉션
앞서 살펴봤던 Route 엔티티를 다시 한번 살펴봅시다.
public class Route extends BaseEntity {
...
@OneToMany(mappedBy = "route", orphanRemoval = true)
private List<SelectedTag> tags = new ArrayList<>();
@OneToMany(mappedBy = "route", orphanRemoval = true)
private List<RoutePlace> routePlaces = new ArrayList<>();
...
}
Route 엔티티는 SelectedTag와 RoutePlace 자식 엔티티를 List로 가지고 있습니다.
이때, fetch join을 사용해서 Route를 가져오게 되면 1개가 아닌 여러개의 bag를 가져오게 되면서
MultipleBagFetchExcetion이 발생하게 됩니다!
그렇다면 왜 MultipleBagFetchExcetion이 발생하는 걸까요?
2개 이상의 컬렉션을 fetch join을 통해 가져오는 쿼리를 생각해봅시다.
여러 엔티티가 Join을 통해 합쳐지므로 카테시안 곱이 발생하고, 엔티티별로 중복되는 데이터들이 존재합니다.
예를 들어 다음과 같이 동선 1개에 2개의 태그, 3개의 장소가 있다고 가정해봅시다.
- 태그 : '데이트', '맛집'
- 장소 : 'A 놀이공원', 'B 카페', 'C 음식점'
이 경우에 fetch join을 통해 가져오는 raw 데이터는 어떻게 될지 생각해봅시다.
route_id | tag_name | place_name |
1 | 데이트 | A 놀이공원 |
1 | 데이트 | B 카페 |
1 | 데이트 | C 음식점 |
1 | 맛집 | A 놀이공원 |
1 | 맛집 | B 카페 |
1 | 맛집 | C 음식점 |
이렇게 raw 데이터가 나올텐데, 태그나 장소 엔티티 입장에서 중복되는 데이터들이 존재하는 것을 알 수 있습니다.
이 상황에서 Route 엔티티에 해당 데이터들을 맵핑하게 되면 중복되는 데이터들이 그대로 맵핑될 것입니다.
왜냐하면, 맵핑 시 사용되는 JPA의 Bag 컬렉션 타입은 중복을 허용하는 List 형태로 맵핑되기 때문입니다.
따라서, Hibernate에서는 이러한 중복된 데이터들이 맵핑되는 것을 막기 위해
2개 이상의 @OneToMany 컬렉션을 fetch join할 때 MultipleBagFetchExcetion을 발생시켜 중복 데이터 맵핑을 방지합니다.
그렇다면 2개 이상의 컬렉션을 맵핑시켜야하는 이 상황에서 N+1 문제를 어떻게 해결할 수 있을까요?
결론적으로 저는 앞서 말씀드린 방법 중 N+1 문제에서 발생하는 추가 조회 쿼리를 1개의 쿼리로 줄이는 방법인
Batch Size 설정을 통해 해결했습니다.
※ Batch Size?
JPA에서 Batch Size를 설정하면 N개의 추가 조회 쿼리를 in절을 통해 1개로 가져오면서 N+1 문제를 해결합니다.
따라서, 해당 방법으로 현재 N+1 문제를 해결했습니다.
Batch Size를 전역적으로 100으로 다음과 같이 설정했습니다.
application.yml
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
...
※ Batch Size 결정 기준
Batch Size대로 in절의 최대 데이터 개수가 정해집니다.
100개라면, in절에 최대 100개의 데이터를 담을 수 있습니다.
Batch Size를 100개로 설정한 이유는 다음과 같습니다.
- 일반적으로 설정하는 사이즈 100~1000의 최소인 100으로 설정
- 작게 설정했을 시 배치 사이즈를 넘는 많은 양의 데이터 조회 시 효율이 적어지는데, 많은 양의 데이터 조회는 빈번하지 않을 것이라고 판단
※ Batch Size 전역 설정 이유
Batch Size를 필요한 곳에만 @BatchSize 어노테이션을 통해 지정할 수도 있습니다.
그러나 제가 전역적으로 Batch Size를 설정한 이유는 다음과 같았습니다.
- Batch Size를 전역으로 설정하면, 공통 배치 사이즈를 가져서 공통으로 관리해서 문제가 발생할 수 있다.
- 다른 N+1 부분의 추적이 힘들 수 있다. -> 추적은 힘들더라도 성능이 개선되므로 괜찮다고 판단
- 다른 N+1이 발생하는 쿼리 개수와 다르면 비효율적일 수 있다. -> Route가 핵심 도메인으로, 가장 많이 쿼리가 발생할 것이기 때문에 이보다 적은 개수의 N+1이 발생한다면 괜찮다고 판단
- 따라서, 위의 이유로 전역 설정
N+1 문제를 Batch Size를 통해 해결한 결과, 다음과 같이 392개 -> 9개로 훨씬 줄은 쿼리 개수를 확인할 수 있었습니다.
- Route : 사용자의 동선 조회 쿼리 발생 -> 1개 쿼리 (1번에 해당 달의 모든 동선 가져옴)
- SelectedTag : 조회한 동선(Route)의 개수만큼의 30개를 in절로 모두 조회 -> 1개 쿼리
- RouteTag : 각 동선별로 가진 태그의 개수만큼의 90개를 in절로 모두 조회 -> 1개 쿼리
- RoutePlace : 조회한 동선(Route)의 개수만큼의 30개를 in절로 모두 조회 -> 1개 쿼리
- Place : 각 동선별로 가진 장소의 개수만큼의 120개를 in절로 2번 조회(100 + 20) -> 2개 쿼리
- PlacePhotoReferences : 각 동선별로 가진 장소의 개수만큼의 120개를 in절로 2번 조회(100 + 20) -> 2개 쿼리
따라서, 위와 같이 1 + 1 + 1 + 1 + 2 + 2= 8개가 도출되는 것을 알 수 있습니다. (1개는 인증 쿼리)
4. N+1 개선 전후 성능 비교
N+1 개선 전후의 성능 비교는 성능테스트 툴을 사용하지 않고 Postman을 사용해서 간단하게 1번의 응답 속도로 비교했습니다.
그 이유는 다음과 같습니다.
- 동선 월별 조회는 캐싱이 적용되어 있는 상태로, 첫 요청 이후의 요청은 캐싱된 데이터를 가져오는 상황
- 따라서 여러 번 요청을 보내서 성능을 비교하는 성능 테스트는 필요없다고 판단
- 그래서 수동으로 Postman 요청을 1번 보냈을 때의 응답 속도 비교
개선 전 응답 속도
- 약 1.2초 응답
개선 후 응답 속도
- 약 0.3초 응답
5. 결론 및 요약
이번에는 흔하게 발생하는 N+1 문제를 Batch Size를 통해 해결하고 성능 비교를 진행해봤습니다.
개선 전후 결과는 다음과 같이 요약할 수 있습니다.
- 쿼리 수 : 392 -> 9개로 개선
- 응답 속도 : 1.2초 -> 0.3초로 개선
N+1 문제는 흔한 문제이고 많이 접해볼 수 있지만,
2개 이상의 컬렉션이 존재할 때 N+1 문제를 fetch join 대신 Batch Size를 통해 해결해봤다는 점에서 의의가 있는 것 같습니다!
'이슈 & 트러블 슈팅' 카테고리의 다른 글
[이슈] Redis 캐싱을 통해 조회 성능 개선하기 (0) | 2024.07.01 |
---|---|
[이슈 & 트러블 슈팅] 동시성 보장하기 (feat. 비관적 락, Redis 분산 락) (3) | 2024.06.11 |
[이슈] 사용자의 중복 요청 방지하기(feat. 멱등키, Redis) (1) | 2024.06.07 |
[트러블 슈팅] 멀티 스레드 테스트 시 초기 데이터 오류(feat. 트랜잭션 격리 수준) (0) | 2024.06.06 |
[트러블 슈팅] SpringBoot에서 application.yml 이외의 yml 프로퍼티 적용하기 (0) | 2022.08.26 |