[QRworld] JPA N+1 풀어보기 with... Fetch Join, BatchSize, Hibernate Bytecode Enhancement

suhwani·2025년 6월 22일
0
post-thumbnail

JPA 사용할 때 많이 발생하고, 유명한 문제인 N+1 문제에 대해 해결법과 성능에 어느정도 영향을 미치는지에 대해서 공유하려고 한다.

해당 글에서는 N+1에 대한 개념적인 설명보다 내가 어떻게 프로젝트에 적용했는지를 설명하고자 한다.

1-1. 발생 지점 확인: 자신이 생성한 qrcode_event 불러오기

  • 한 명의 user에 대해서 자신이 생성한 qrcode_event를 불러올 때 1:1 관계인 qrcode_design과 qrcode_benefit까지 가져와서 응답한다.
  • 이 때, qrcode_event와 나머지 관계를 LAZY Loading을 사용해서 연관관계를 호출 시점에 가져오도록 설정해뒀는데 그러다보니 10개를 가져온다고 했을 때 qrcode_event를 호출하는 쿼리 1개와 각 qrcode_event마다 각 연관관계를 가져오는 쿼리 N개(여기서는 연관관계가 2개니까 2*N개)가 발생하게 된다.

1-2. 코드 변경

  • 1:1 관계에서 N+1문제가 발생했고, 해당 함수에서는 페이징 처리를 하는 상황이다.

기존 코드

기존 로그

2025-06-21 17:02:32     select
2025-06-21 17:02:32			*** 중략 *** 
2025-06-21 17:02:32         qd1_0.id,
2025-06-21 17:02:32     from
2025-06-21 17:02:32         qrcode_design qd1_0 
2025-06-21 17:02:32     where
2025-06-21 17:02:32         qd1_0.qrcode_event_id=?

							*** 위 과정을 N번 반복... ***

수정된 코드

  • Fetch Join 사용하여 처음 데이터를 가져올 때 내가 필요한 연관관계 데이터까지 모두 가져오기!

수정된 로그

2025-06-22 15:19:39 Hibernate: 
2025-06-22 15:19:39     select
2025-06-22 15:19:39         qe1_0.id,
2025-06-22 15:19:39         *** 중략 ***
2025-06-22 15:19:39     from
2025-06-22 15:19:39         qrcode_event qe1_0 
2025-06-22 15:19:39     left join
2025-06-22 15:19:39         qrcode_benefit qb1_0 
2025-06-22 15:19:39             on qe1_0.id=qb1_0.qrcode_event_id 
2025-06-22 15:19:39     left join
2025-06-22 15:19:39         qrcode_design qd1_0 
2025-06-22 15:19:39             on qe1_0.id=qd1_0.qrcode_event_id 
2025-06-22 15:19:39     where
2025-06-22 15:19:39         qe1_0.user_id=? 
2025-06-22 15:19:39         and qe1_0.is_deleted=false 
2025-06-22 15:19:39     order by
2025-06-22 15:19:39         qe1_0.created_at desc 
2025-06-22 15:19:39     offset
2025-06-22 15:19:39         ? rows 
2025-06-22 15:19:39     fetch
2025-06-22 15:19:39         first ? rows only

1-3. 성능 지표 개선

검색되는 데이터 수: 1,000,000 건
총 API 실행 횟수: 100번 실행하여 평균 지표 측정
테스트 툴: k6 스크립트 실행, 프로메테우스와 그라파나 이용하여 측정 및 시각화

결과

  • 평균 응답 시간: 338 ms ➡️ 259 ms
  • p99 응답 시간: 1100 ms ➡️ 893 ms
  • p95 응답 시간: 728 ms ➡️ 445 ms
개선 전 성능 지표개선 후 성능 지표
기존 코드Fetch Join 적용 후

2-1. 발생 지점 확인: 각 User의 qrcode event 불러오기

  • 최근 생성된 user와 해당 user가 생성한 qrcode event 갯수를 가져오는 상황이다.
  • 이 때 user - qrcode event는 1:N 관계이다. 각 user가 생성한 qrcode event를 가져올 때 user를 가져오는 쿼리 1번 + 각 qrcode event를 가져오는 쿼리 N번이 발생하게 된다.
  • 코드상으로 repository.findAll()를 통해 user 데이터를 최신순으로 가져오고, LAZY로딩 설정을 해뒀기에 각 user에 해당하는 qrcode event 정보를 호출 시점에 가져온다.

2-2. 코드 변경 (Fetch Join 해결 x)

  • 1:N 관계에서 N+1문제가 발생했고, 해당 함수에서는 페이징 처리를 하는 상황이다.

기존 코드

기존 로그

  • size 10으로 페이지내이션을 걸고 user 10명을 가져오는 쿼리 1번이 실행된 이후 각 user_id를 기준으로 qrcode_event를 찾는 쿼리가 10번 추가 발생한다. N+1이 발생하였다...
2025-06-22 23:33:45 2025-06-22T14:33:45.168Z DEBUG 1 --- [nio-8080-exec-1] org.hibernate.SQL                        : 
2025-06-22 23:33:45     select
2025-06-22 23:33:45         u1_0.id,
							*** 중략 ***
2025-06-22 23:33:45     from
2025-06-22 23:33:45         users u1_0 
2025-06-22 23:33:45     order by
2025-06-22 23:33:45         u1_0.created_at desc 
2025-06-22 23:33:45     offset
2025-06-22 23:33:45         ? rows 
2025-06-22 23:33:45     fetch
2025-06-22 23:33:45         first ? rows only
2025-06-22T14:33:45.584Z DEBUG 1 --- [nio-8080-exec-1] org.hibernate.SQL                        : 
2025-06-22 23:33:45     select
2025-06-22 23:33:45         qe1_0.user_id,
							*** 중략 ***
                            *** 해당 쿼리 여러번 실행***
2025-06-22 23:33:45     from
2025-06-22 23:33:45         qrcode_event qe1_0 
2025-06-22 23:33:45     where
2025-06-22 23:33:45         qe1_0.user_id=?

수정된 코드 (최종 해결 x)

  • Fetch Join 사용하여 개선하려고 했으나, Fetch Join 적용 이후 메모리에 모든 데이터를 올리고 페이지내이션이 적용되기에 해결 x

수정된 로그 (최종 해결 x)

  • 실행 로그 중 WARN 로그 발생
    아직도.. JPA 페이징을 SQL(DB단) 수준에서 적용하는 것이 아닌 모든 데이터를 메모리에 올린 후 페이징을 적용하여 위험하다는 의미, 실제 응답 제대로 반환 안됨...
2025-06-22 22:57:06 2025-06-22T13:57:06.596Z  WARN 1 --- [io-8080-exec-10] org.hibernate.orm.query                  : 
HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory

2025-06-22 22:57:06 2025-06-22T13:57:06.610Z DEBUG 1 --- [io-8080-exec-10] org.hibernate.SQL                        : 
2025-06-22 22:57:06     select
2025-06-22 22:57:06         u1_0.id,
2025-06-22 22:57:06         *** 중략 *** 
2025-06-22 22:57:06     from
2025-06-22 22:57:06         users u1_0 
2025-06-22 22:57:06     left join
2025-06-22 22:57:06         qrcode_event qe1_0 
2025-06-22 22:57:06             on u1_0.id=qe1_0.user_id 
2025-06-22 22:57:06     order by
2025-06-22 22:57:06         u1_0.created_at desc

Fetch Join과 페이지내이션을 같이 쓰지 못하는 이유

  • 아래와 같이 의도는 3개의 데이터를 가져오는 것이지만, Fetch Join을 쓰게 되면 1:N관계에서 원본 데이터와는 다르게 많은 데이터를 가져오게 된다. 따라서 페이지내이션이 제대로 동작하기 어려워 이를 JPA에서 페이지내이션과 Fetch Join을 동시에 쓰는 걸 막아뒀다고 한다.

2-3. 코드 변경 (@BatchSize 아직 부족한 해결책)

수정된 코드

  • findAll()을 그대로 쓰고 Fetch Join 대신 Entity에서 @BatchSize를 설정하여 select *** from qrcode_event 쿼리에서 WHERE IN (user id 10개) 조건을 추가하여 가져온다.
  • 이로 인해 Fetch Join은 쿼리가 1번에 모든 걸 가져온다면, @BatchSize는 N/10+1번 쿼리를 실행합니다. 이 때 10은 설정한 BatchSize를 의미하며, 페이지내이션 size와 관계없이 설정할 수 있다.

아직 남은 문제 로그

  • 아래 쿼리가 BatchSize만큼 발생한다. user - qrcode event에서 발생하는 N+1은 Batch size로 해결하였으나, qrcode event와 1:1로 연결된 qrcode design, qrcode benefit이 select 쿼리를 계속 발생시킨다.
  • 이유는 1:1 관계에서 FK를 가지지 않은 쪽을 조회할 때 객체 입장에서는 연관관계가 있는데 FK가 없기 때문에 프록시 객체를 만들 수 없고 EAGER와 동일하게 동작한다.
  • 반면에 1:1 관계에서 FK를 가진 쪽(여기서는 qrcode design과 qrcode benefit)은 qrcode event를 추가 조회하지 않고 LAZY로딩이 제대로 동작한다.
2025-06-24 00:49:12     select
2025-06-24 00:49:12         qd1_0.id,
							*** 중략 ***
2025-06-24 00:49:12     from
2025-06-24 00:49:12         qrcode_design qd1_0 
2025-06-24 00:49:12     where
2025-06-24 00:49:12         qd1_0.qrcode_event_id=?
2025-06-24 00:49:12 2025-06-23T15:49:12.734Z DEBUG 1 --- [nio-8080-exec-1] org.hibernate.SQL                        : 
2025-06-24 00:49:12     select
2025-06-24 00:49:12         qb1_0.id,
							*** 중략 ***
2025-06-24 00:49:12     from
2025-06-24 00:49:12         qrcode_benefit qb1_0 
2025-06-24 00:49:12     where
2025-06-24 00:49:12         qb1_0.qrcode_event_id=?

2-4. 패지키 추가 (OneToOne 관계에서 LAZY로딩 해결)

내가 사용한 방법

  • 바이트코드 인핸스먼트(bytecode enhancement): 바이너리(.class) 파일 자체를 1차적으로 조작해서 런타임에 성능 기능을 넣어주는 기법 ➡️ Hibernate 플러그인을 통해 Lazy Initialization 기능을 추가.
  • 위 설정을 통해 OneToOne관계에서 필드 접근 시점에 쿼리를 날릴 수 있도록 해줘서, 기존에 Hibernate에서 해당 필드가 null 또는 프록시인지 고민하지 않게 된다.
  • 정리하면 Hibernate Bytecode Enhancement를 사용하여 빌드 타임에 엔티티 클래스의 .class 파일에 추가 코드를 주입하여 연관관계를 실제로 쓸 때 DB를 호출하도록 하는 LAZY로딩을 가능하게 만들었다.

추가 검증

  • 추가적으로 qrcode_design, qrcode_event 둘을 각각 조회하여 둘 다 LAZY로딩이 정상적으로 동작하는지 확인했다.
  • 둘 다 추가 쿼리 없이 깔끔하게 의도대로 동작하는 모습이다.

2025-06-24 01:27:02 2025-06-23T16:27:02.285Z  INFO 1 --- [io-8080-exec-10] c.e.d.d.s.service.StatisticService       : QRCODE_DESIGN--------------------------------
2025-06-24 01:27:02 2025-06-23T16:27:02.311Z DEBUG 1 --- [io-8080-exec-10] org.hibernate.SQL                        : 
2025-06-24 01:27:02     select
2025-06-24 01:27:02         qd1_0.id,
							*** 중략 *** 
2025-06-24 01:27:02     from
2025-06-24 01:27:02         qrcode_design qd1_0 
2025-06-24 01:27:02     where
2025-06-24 01:27:02         qd1_0.id=?
2025-06-24 01:27:02 2025-06-23T16:27:02.325Z  INFO 1 --- [io-8080-exec-10] c.e.d.d.s.service.StatisticService       : QRCODE_EVENT--------------------------------
2025-06-24 01:27:02 2025-06-23T16:27:02.328Z DEBUG 1 --- [io-8080-exec-10] org.hibernate.SQL                        : 
2025-06-24 01:27:02     select
2025-06-24 01:27:02         qe1_0.id,
							*** 중략 ***
2025-06-24 01:27:02     from
2025-06-24 01:27:02         qrcode_event qe1_0 
2025-06-24 01:27:02     where
2025-06-24 01:27:02         qe1_0.id=?

2-5. 성능 지표 개선

쿼리 개수 비교
페이지내이션 size: "N", BatchSize size: "M", 연관된 qrcode_event 개수: "K" 라고 가정
단. N/M 개는 올림을 적용하며, 올림을 표시하지 않은 이유는 식을 단순하게 표현하기 위함이다.

  • 기존 코드: user 쿼리 1개 + qrcode_event 쿼리 N개 + qrcode_benefit, qrcode_design 쿼리 2K개
  • Fetch Join 적용: 페이지내이션과 함께 적용할 수 없어 비교 불가.
  • @BatchSize 적용: user 쿼리 1개 + qrcode_event 쿼리 N/M개 + qrcode_benefit, qrcode_design 2K개
  • Hibernate Bytecode Enhancement 적용: user 쿼리 1개 + qrcode_event 쿼리 N/M개

‼️ 지표 비교 전, 제한된 부분에 대하여...

  • 기존 코드에서 변경된 이후 코드에서 가장 큰 변경점은 쿼리 개수(DB I/O)이다. 이는 네트워크 속도에 많은 영향을 받는 부분으로, 현재 테스트가 진행되는 로컬에서는 네트워크 병목이 매우 적기 때문에 최대한 차이를 극명하게 하고자 페이지내이션 개수인 N값을 매우 올려 테스트를 진행하였다.
  • 클라우드 환경에서 성능을 비교한다면, N값이 크지 않더라도 쿼리 개수에 따른 성능 차이로 인해 API 성능이 많이 차이날 것으로 예상한다.

지표 비교: 기존 코드 vs Hibernate Bytecode Enhancement 적용 이후
조건: 페이지내이션 1000개, Batchsize 10개, total user: 1,000,000개
총 API 실행 횟수: 100번 실행하여 평균 지표 측정
테스트 툴: k6 스크립트 실행, 프로메테우스와 그라파나 이용하여 측정 및 시각화

  • 평균: 886 ms ➡️ 282 ms
  • p99: 5680 ms ➡️ 688 ms
  • p95: 1700 ms ➡️ 551 ms
개선 전 성능 지표개선 후 성능 지표
기존 코드Hibernate Bytecode Enhancement 적용 후
profile
Backend-Developer

0개의 댓글