1. 문제 상황
프로젝트 진행 중, 백오피스에서 사용하고 있는 정보수정요청(Suggestion) 조회 API를 개선하려고 한다.
페이징 처리가 되어있긴하지만, 4개의 연관 관계가 있는 구조라 JPA N+1(실제로는 3N+1..) 문제가 발생하고 있다.
10만개의 데이터를 기준으로, 사용자가 가장 만족스럽게 느끼는 응답 시간인 200~300ms까지 개선해볼 예정이고,
이번 글에서는 jpql의 성능 최적화 방법 중 하나인 Fetch Join을 사용할 것이다.
/* GET /suggestion?page=1&size=100 */
{
"suggestions": [
{
"suggestion_id": "04c6bcfc-9db0-11ef-bfa7-ddc133b90900",
"company_name": "삼성전자",
"member_name": "홍길동",
"status": "COMPLETED",
"created_date": "2024-11-08"
},
...
],
"total_elements": 100000,
"total_pages": 1000,
"current_page": 1,
"page_size": 100
}
}
spring data pageable을 사용한 pagination이 구현되어 있고, 연관 관계는 다음과 같다.
- Suggestion -> Member (N:1)
- Suggestion -> Position (N:1)
- Position -> Company (N:1)
2. FetchType 설정 (즉시로딩과 지연로딩)
2-1) EAGER LOADING (즉시 로딩)
모든 연관 관계가 ManyToOne으로, default fetchType은 EAGER loading(즉시 로딩)이다.
default 상태로 1개의 데이터를 조회하도록 요청을 보내보았다.
@Query("SELECT s FROM Suggestion s " +
"JOIN s.member m " +
"JOIN s.position p " +
"JOIN p.company c " +
"ORDER BY s.createdDate DESC")
Page<Suggestion> findAllWithMembersPositionsAndCompanies(Pageable pageable);
기본값이 즉시 로딩이어서 하나의 쿼리로 조회가 될 줄 알았지만, 위와 같이 3(1 + 2N)개의 쿼리가 날아가는 문제가 발생했다.
원인은 JPQL을 사용해서 그렇다.
JPA가 JPQL을 분석해서 SQL을 생성할 때는 글로벌 Fetch 전략과는 무관하게 JPQL 자체에 충실하게 쿼리를 만든다.
그럼 JPQL을 안 쓸때는 즉시 로딩이 더 효율적인가? 그건 아니라고 한다.
즉시 로딩보다는 지연 로딩을 사용하는 게 권장된다고 한다. (특히 실무에서는)
Why?
1. 연관 데이터를 필요없는 경우에도 항상 조회해서 성능 문제가 발생할 수 있다.
2. 즉시 로딩은 예상하지 못한 SQL이 발생할 수 있다.
3. 따라서 대부분의 연관 관계는 Lazy Loading 전략을 사용하는 것이 좋다.
2-1) LAZY LOADING (지연 로딩)
ManyToOne 관계는 default가 즉시 로딩이므로, 지연 로딩으로 설정해주기 위해 아래와 같이 명시해준다.
@ManyToOne(fetch = FetchType.LAZY)
동일하게 요청해보니 이번에는 4(1+3N)개의 쿼리가 날아간다. (position & company 조회에도 지연 로딩이 적용됨)
여전히 N+1(이 예시에서는 3N + 1)문제가 발생한다. 100,000개의 데이터면 전체조회 쿼리 한번에 400,001개의 쿼리가 날아가는 것이다.
이러한 문제를 LAZY LOADING + Fetch Join으로 하나의 쿼리로 처리하도록 개선할 것이다.
Paging과 Fetch Join을 같이 사용할 때 이슈가 있지만, 이 예시에서는 모든 관계가 ManyToOne이므로 Pagination과 Fetch Join 사용에 문제가 없다.
* XToMany 관계에서의 issue
Pagination과 Fetch Join을 함께 사용하는 경우, XToMany 관계라면 오류가 발생한다. XToOne 관계에서는 Join 이후 data의 row가 늘어나지 않기 때문에 상관없지만, 일 대 다 join관계에서는 join 대상 테이블의 data수가 많아짐에 따라 주요 엔티티의 data row 수도 같이 늘어나기 때문에 Database의 limit과 offset을 이용한 쿼리를 통해 pagination하는 것이 불가능하다.
(정확히는 가능하지만 Full Scan해서 전부 다 들고와 애플리케이션 단의 메모리 상에 올리고 페이지네이션 처리하여, out of memory의 가능성이 매우 크다.)
3. Fetch Join 사용
@Query("SELECT s FROM Suggestion s " +
"JOIN FETCH s.member m " +
"JOIN FETCH s.position p " +
"JOIN FETCH p.company c " +
"ORDER BY s.createdDate DESC")
Page<Suggestion> findAllWithMembersPositionsAndCompanies(Pageable pageable);
위처럼 간단하게 사용할 수 있다.
select
s1_0.id,
s1_0.created_date,
m1_0.id,
m1_0.al_platform,
...(생략)
s1_0.modified_date,
s1_0.position_id,
p1_0.id,
p1_0.company_id,
c1_0.id,
c1_0.company_name,
...(생략)
p1_0.created_date,
p1_0.exam_mode,
...(생략)
s1_0.suggestion_content,
s1_0.suggestion_status
from
suggestion s1_0
join
member m1_0
on m1_0.member_key=s1_0.member_key
join
position p1_0
on p1_0.id=s1_0.position_id
join
company c1_0
on c1_0.id=p1_0.company_id
order by
s1_0.created_date desc
limit
?, ?
결과를 확인해보면, 하나의 쿼리에 모든 연관 데이터를 함께 조회하여 필요한 데이터가 한 번에 로드되는 것을 볼 수 있다.
100,000의 데이터를 모두 조회하도록 해보아도, 쿼리는 한개만 날아간다.
그리고 맨 마지막에 있는 limit이 정상적으로 출력되고 있는데,
limit
?, ?
TRACE 24196 --- ... org.hibernate.orm.jdbc.bind : binding parameter (1:INTEGER) <- [0]
TRACE 24196 --- ... org.hibernate.orm.jdbc.bind : binding parameter (2:INTEGER) <- [100000]
위에서 언급했듯 이 예시는 XtoOne 관계이기 때문에 limit이 정상적으로 작동하는 것을 확인할 수 있었다.
XtoMany 관계인 경우 paging이 되는 것처럼 보여도, LIMIT 쿼리가 발생하지 않고 메모리 관련 경고가 발생하므로 주의해야 한다.
4. 응답 시간 테스트
1) pageSize = 100000
Fetch Join의 성능을 체감하기 위해 조금 극단적으로 전체 데이터를 한 번에 조회하도록 해보았다.
좌측은 일반 Join으로 조회한 것으로 약 42초의 시간이 걸렸고, Fetch Join으로 변경한 결과 약 4.4초가 걸렸다.
약 1/10 수준으로 성능이 개선된 것인데, 일반 Join은 400,001개의 쿼리를 보냈고 Fetch Join은 1개의 쿼리를 보냈기 때문이다.
2) pageSize = 100
일반적인 페이지 사이즈인 100으로 설정하여 테스트한 결과는 다음과 같다.
JOIN | FETCH JOIN | |
가장 빠른 응답 시간 | 543ms | 342ms |
가장 느린 응답 시간 | 947ms | 448ms |
10회 평균 응답 시간 | 662ms | 435ms |
6/10 수준으로 성능이 개선되었다. 그렇지만 평균 435ms의 응답 시간은 아주 만족스러운 수준은 아니다.
Fetch join을 사용하면 연관 엔티티 전체를 다 불러오는 방식이기 때문에 일부 컬럼만 조회하는 dto projection이 불가하다.
index를 추가해서 쿼리 성능을 좀 더 개선시켜야 할 것 같다.
'Spring' 카테고리의 다른 글
API 응답 속도 개선하기 (2) - 데이터베이스 인덱싱(indexing), explain (2) | 2024.11.27 |
---|