[JPA] N+1 문제

반응형

Introduction

N+1 문제는 JPA를 통해 서비스를 개발하다보면 마주칠 수 밖에 없는 문제입니다. 왜 이런 문제가 발생하고, 해결책은 무엇인지를 한 번 정리를 해놓으면 좋을 것 같아서 글을 쓰게 되었습니다.

(예제는 SpringBoot + Spring Data Jpa 으로 환경을 구성 했습니다. 제일 대중적인 조합으로 문제를 다뤄야 이해하기 쉽다고 생각 했습니다.)

 

N+1 문제

그래서 N+1 문제가 뭔데..?

 

'데이터 조회 쿼리를 1개 요청할 경우 N개의 쿼리가 추가적으로 발생하는 문제'

 

이 문제는 MyBatis 처럼 직접 쿼리문을 생성하는 것이 아니라, 쿼리가 자동화된 JPA를 사용하게되면 겪을 수 밖에 없는 문제였지 않나 싶습니다. 결국 객체를 대상으로 조회하는 것이기 직접 조회하는 것과 차이가 있을 수 밖에 없죠.

아래 예시들과 함께 문제를 살펴보죠. 😊

 

항상 단골로 등장하는 엔티티. '멤버' 와 '팀' 의 관계를 예시로 설명 드리겠습니다.

팀은 멤버 여러명을 가질 수 있고, 멤버는 하나의 팀에 속해 있다. 즉 N:1 연관관계를 맺고 있습니다. 이 연관관계 내에서 일어나는 N+1 문제를 보겠습니다.

DB에는 4명의 멤버와 2개의 팀이 존재합니다. 

 

지연로딩으로 설정하고 ID로 teamA를 조회하고, 멤버이름을 조회 해보겠습니다. 

팀을 조회 했고, 팀 안에는 멤버 목록이 존재 합니다. 팀을 조회한 경우 정상적으로 쿼리가 1회 발생하지만, 팀을 조회 하는 순간  지연로딩으로 설정 되어 있는 멤버 엔티티 목록을 프록시로 가져오고, 멤버 데이터를 사용할 때 쿼리를 발생시키기 때문에 N개의 쿼리가 추가적으로 발생합니다. 지연로딩에서의 N+1 문제죠. 

 

그러면 즉시로딩으로 하면 N+1 문제가 발생 안할까요 ?

 

 

 

즉시로딩으로 바꾸고서 동일한 로직을 진행 해보겠습니다.

확인 해보면  조회한 팀에 해당하는 멤버를 조인해서 의도한대로 쿼리가 1개만 발생하는 것을 확인할 수 있습니다.  이 결과는 JPA의 도움으로 단일 객체 조회(findById, find)에서는 팀과 멤버를 조인한 결과를 반환해주기 때문입니다. 하지만 서비스 로직을 개발하다보면 다수의 팀 데이터를 조회하고 싶은 경우가 생깁니다. 모든 팀을 조회 후 이름을 출력 해보겠습니다.

본래 서비스의 목적은 모든 팀의 이름을 조회하는 것이었지만, 각 팀에 해당하는 멤버를 추가적으로 조회하는 것을 확인할 수 있습니다. 의도를 벗어난 결과죠.

 

'즉시 로딩은 지양해야 합니다.'

 

로직에서 사용한 findAll()은  JPQL로 보면 "select t from Team" 과 같습니다.
JPQL의 findAll()은 Team 엔티티를 대상으로 진행이 되었고, 원하는대로 모든 팀들을 조회해준 것이죠.
그 후 멤버의 연관관계 로딩 타입이 Eager로 되어 있는 걸 확인하고, 쿼리를 발생시켜 멤버를 조회 해줍니다.
결국 팀 조회 1개 + N개의 쿼리가 추가적으로 발생하게 된 것이죠.

 

해결

대표적인 해결책으로 Fetch Join 이 있습니다. (@EntityGraph 라던지, BatchSize 옵션이라던지 여러가지 해결책이 있지만 본래 의도를 파악하기 힘든 코드가 되고, 유지보수에 문제가 있을 수 있기 때문에 다루지 않겠습니다.)

Fetch Join 을 사용해 팀 목록을 조회하고, 지연로딩 되어 있는 멤버 데이터에 접근해서 멤버이름을 출력 해보겠습니다.

팀과 멤버가 조인된 형태로 쿼리가 한 번만 발생하고, 지연로딩 설정된 멤버 목록에 접근하더라도 쿼리가 추가적으로 발생하지 않았습니다. 

 

'Join? 그러면 jpql로 Fetch Join 이 아니라 그냥 Join 해오면 어떻게 될까?"

 

모든 코드는 동일하게 두고, Fetch Join -> Left Join 으로 변경 후 실행 해보았습니다.

 데이터를 사용할 경우 처음 설명 했던 지연로딩에서의 N+1 문제와 동일하게 추가적인 쿼리가 발생한다.

 

'왜 ????'

 

'JPQL은 엔티티를 상대로 질의를 하고, Fetch Join 이 아닌 다른 Join들은 전부 엔티티 프록시를 조회한다.' 입니다. Left Join 으로 해놓은 쿼리는 팀을 조회 했고, 조인한 대상은 프록시로 가져옵니다. 지연로딩이기 때문에 사용하는 순간에 쿼리를 추가적으로 발생시킵니다. 결국은 전과 동일하게 N+1 문제를 발생시킵니다.

 

결론

사실상 해결책인 것처럼 Fetch Join을 얘기 했지만, 해결책이라고 할 수 있나 싶습니다. Pagination 문제, 2개 이상의 @OneToMany 관계를 Fetch Join 할 수 없는 문제(MultipleBagFetchException이 발생합니다.) 등등 경우의 수에 따라 여러가지 문제가 다시 발생할 수 밖에 없고, 결국 Fetch Join 도 Join을 통해 조회하기 때문에 다수의 중복데이터가 발생하고, 그렇기에 성능적으로 Fetch Join이 무조건 좋다! 라고 말할 수 있나 싶기도 하고...

 

결국은 설계를 잘하는게 중요하지 않나 싶습니다. 😅😅

 

반응형