API 응답 속도 개선하기 (1) - Paging과 Fetch Join, JPA N + 1 문제

2024. 11. 13. 14:53·Backend&DB

1.  문제 상황

프로젝트 진행 중, 백오피스에서 사용하고 있는 정보수정요청(Suggestion) 조회 API를 개선하려고 한다.
페이징 처리가 되어있긴하지만, 4개의 연관 관계가 있는 구조라 JPA N+1(실제로는 3N+1..) 문제가 발생하고 있다.
10만개의 데이터를 기준으로, 사용자가 가장 만족스럽게 느끼는 응답 시간인 200~300ms까지 개선해볼 예정이고,
이번 글에서는 jpql의 성능 최적화 방법 중 하나인 Fetch Join을 사용할 것이다.

 

[SQL] 연관 관계가 있는 더미 데이터 생성하기

API 성능 테스트를 위한 더미 데이터를 생성하는 과정이다.처음에는 조회 대상인 suggestion만 10만개를 만들었는데,10만개의 suggestion에 동일한 FK(member_key, position_id)를 넣는 실수를 했다.그 결과 당

gyujh.tistory.com

 

/* 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

일반 Join / Fetch Join

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를 추가해서 쿼리 성능을 좀 더 개선시켜야 할 것 같다.

 

 

API 응답 속도 개선하기 (2) - 데이터베이스 인덱싱(indexing), explain

이전 글 [SQL] 연관 관계가 있는 더미 데이터 생성하기API 성능 테스트를 위한 더미 데이터를 생성하는 과정이다.처음에는 조회 대상인 suggestion만 10만개를 만들었는데,10만개의 suggestion에 동일한

gyujh.tistory.com

 

저작자표시 (새창열림)

'Backend&DB' 카테고리의 다른 글

API 응답 속도 개선하기 (2) - 데이터베이스 인덱싱(indexing), explain  (2) 2024.11.27
[SQL] 연관 관계가 있는 더미 데이터 생성하기  (2) 2024.11.13
'Backend&DB' 카테고리의 다른 글
  • API 응답 속도 개선하기 (2) - 데이터베이스 인덱싱(indexing), explain
  • [SQL] 연관 관계가 있는 더미 데이터 생성하기
gyujh
gyujh
개발 공부 블로그
  • gyujh
    규
    gyujh
  • 전체
    오늘
    어제
    • 분류 전체보기 (86)
      • Backend&DB (3)
      • CS (5)
        • 컴퓨터구조 (1)
        • 소프트웨어공학 (4)
      • JavaScript (2)
      • Git (2)
      • 알고리즘 (73)
        • 개념 (3)
        • 백준 (70)
      • Projects (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    재귀
    정렬
    숏코딩
    답
    시간초과
    스택
    정답
    너비우선탐색
    문자열
    algorithm
    백준
    에러
    구현
    BOJ
    프로그래머스
    런타임
    딕셔너리
    풀이
    답안
    알고리즘
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.1
gyujh
API 응답 속도 개선하기 (1) - Paging과 Fetch Join, JPA N + 1 문제
상단으로

티스토리툴바