JPA 사용할 때 많이 발생하고, 유명한 문제인 N+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,000,000 건
총 API 실행 횟수: 100번 실행하여 평균 지표 측정
테스트 툴: k6 스크립트 실행, 프로메테우스와 그라파나 이용하여 측정 및 시각화
결과
개선 전 성능 지표 | 개선 후 성능 지표 |
---|---|
![]() | ![]() |
기존 코드 | Fetch Join 적용 후 |
기존 코드
기존 로그
- 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을 동시에 쓰는 걸 막아뒀다고 한다.
수정된 코드
- 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=?
내가 사용한 방법
- 바이트코드 인핸스먼트(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=?
쿼리 개수 비교
페이지내이션 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개
‼️ 지표 비교 전, 제한된 부분에 대하여...
지표 비교: 기존 코드 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 적용 후 |