지난 글에서는 N+1 문제에 대해 해결해보는 시간을 가졌습니다. 하지만 문제 해결 도중 신기한 현상이 일어났었는데요?
바로 N+1 해결을 위하여 첫번째 시도에서 FetchType을 EAGER로 시도하다가 일어났던 문제였습니다.
"심지어 더 충격적인 현상이 일어났는데요. 실제 실행된 SQL 쿼리문을 확인해본 결과, EAGER 로딩임에도 불구하고 select문이 한번에 발생하지 않았습니다. 즉, 신기하게도 LAZY 로딩일 때와 완전히 동일한 쿼리가 수행되었습니다."
나의 N+1 쿼리 개선기

문제의 EAGER 로딩 성능 테스트 결과
요약하자면, N+1 문제를 해결해보고자 첫번째 시도로 FetchType을 EAGER로 변경해보았으나 성능적으로도 실행속도가 2배 이상 증가하였으며, 무엇보다도 실제 SQL문 실행 결과 확인 결과 LAZY 로딩일 때와 동일한 쿼리가 수행되었습니다. 즉, 똑같이 추가 쿼리가 발생하였었습니다.
무슨 이유일까요? 저희가 알고 있던 EAGER 로딩은 허상일까요?
JPA에서는 FetchType이라는 기능을 통해 연관된 엔티티의 로드 시점을 사용자가 통제할 수 있도록 합니다.
일반적으로 통용되는 지식은 다음과 같습니다.
FetchType.EAGER를 사용하면 join을 통해 한 번에 데이터를 쿼리하여 가져온다.FetchType.LAZY를 사용하면 루트 엔티티만 쿼리로 로드하고, 나머지 객체는 PROXY 객체로 로드한다. PROXY 객체에 대한 호출이 있을 때 다시 한번 로드한다.이것에 대해 얘기를 해보려고 합니다. 지난 글에서 N+1 문제가 발생했었던 장바구니와 상품을 예시로 실험을 해보겠습니다.
먼저 Item 엔티티를 LAZY 로딩으로 실행해보겠습니다.

실행 결과, 예상대로 Cart 엔티티만 먼저 가져온 뒤, Item 엔티티를 별도의 select문으로 가져오는 N+1 문제가 발생하고 있습니다.
그렇다면 FetchType을 EAGER로 바꿔서 다시 실행해보겠습니다. EAGER로 설정하면 한 번의 쿼리로 가져와지지 않을까요?

놀랍게도... 같은 사진이 아닌가 싶을 정도로 동일한 쿼리가 발생합니다.
제가 알고 있는 EAGER 로딩은 join을 통해 한 번에 데이터를 쿼리하여 가져오는 것인데, 왜 추가 쿼리가 발생하는 것일까요?
EAGER 로딩도 LAZY 로딩과 동일하게 프록시 객체를 로드하는 걸까요?
도저히 이해가 가지 않아 디버깅을 수행해보았습니다.
먼저 LAZY 로딩일 때, 디버깅을 수행하면 다음과 같이 Item을 가져올 때 프록시 객체를 로드하는 것을 확인할 수 있습니다.

EAGER 로딩도 디버깅을 수행해보도록 하겠습니다.
장바구니를 가져오는 부분에서 추가 쿼리가 발생하는데, 혹시 EAGER 로딩도 프록시 객체를 로드할까요?

디버깅 결과, 그렇지 않다는 것을 알 수 있었습니다. LAZY 로딩과는 다르게 Item 객체가 실제 Item을 가지고 있는 것을 볼 수 있습니다. 무엇이 문제일까요?
혹시 @ManyToOne은 EAGER를 사용하더라도 join이 아예 되지 않았던 것일까요?

코드를 다시 보여드리자면, 제 프로젝트에서 N+1 문제가 발생하였던 장바구니 조회 메서드입니다. cartRepository에서 findAllByMember() 메서드로 회원에 해당하는 장바구니 전체를 가져옵니다.
혹시나 하는 마음으로 저 findAllByMember() 메서드에 다른 점이 있을까 싶어서
조회 api에서 findAllByMember() 메서드 대신 findById() 메서드를 사용하여 실행해보았습니다.

놀랍게도! findById() 메서드로 변경하여 쿼리를 실행했을 경우에는 쿼리가 한번에 실행되는 것을 확인하였습니다.
왜일까요? 이렇게 메서드 별로 다른 쿼리가 수행되는 이유는 메서드를 수행하는 주체가 다르기 때문입니다.
이에 해당하는 의문은 findById() 메서드를 몇 시간동안 디버깅 해보면서 정답을 찾을 수 있게 되었습니다.
findById() 메서드를 타고 들어가면 SimpleJpaRepository 에서 findById를 호출하는 것을 확인할 수 있습니다.

여기서 em(= EntityManager). find()를 사용하여 조회하게 되는데요.
entityManager는 Hibernate의 SessionImpl로 구현되어 있어, Hibernate가 쿼리 생성 시 fetch를 추가하여 EAGER 로딩 필드를 한 번의 쿼리로 가져오게 됩니다.
하지만 findAllByMember()는 SimpleJpaRepository에 정의되어 있지 않고, RepositoryQueryMethodInvoker를 통해 사전에 정의된 AbstractJpaQuery가 실행되게 됩니다.

여기서 AbstractJpaQuery는 쿼리를 생성할 때 criteria API를 사용하지만, 이 쿼리에는 EAGER 로딩 필드를 위한 fetch가 포함되지 않습니다.

결과적으로, 부모 객체(Cart 엔티티)를 조회한 후
다시 EAGER 로딩 필드를 별도로 조회하는 추가 쿼리가 발생하게 됩니다.
이러한 차이는 entityManager와 AbstractJpaQuery가 쿼리를 생성하는 방식의 차이에서 비롯됩니다.
entityManager는 Hibernate의 EAGER 로딩을 지원하는 반면, AbstractJpaQuery는 Criteria API를 사용할 때 EAGER 로딩 필드를 포함하지 않아 추가 쿼리가 발생했었던 것이었습니다!
이번 시간에는 지난 시간에 N+1 문제를 해결하던 도중,
EAGER 로딩이 실제 SQL 쿼리문 실행 결과 LAZY 로딩과 동일한 쿼리가 생긴다는 점에서 의문이 들어 디버깅을 해보며 궁금증을 해결하는 시간을 가졌습니다.
비록 저번 시간에 N+1 문제를 해결했긴 했지만 LAZY 로딩과 EAGER 로딩이 동일한 쿼리가 수행된다는 점에서 무언가 해결하지 못한 찜찜한 기분이 들었고, 몇 시간의 디버깅 결과 실마리를 알았다는 점에서 뜻 깊은 경험이었습니다.
물론 JPA의 파싱 로직은 이보다 더 복잡하기에 여기서 더 나아가 조금 더 디버깅해나가며 자세히 공부를 해볼 생각입니다.
이번 기회로 단순히 코드를 변경하고, 성능을 테스트한 것에서 나아가 코드를 하나하나 뜯어보았던 경험이 즐거웠고, 앞으로 더 공부할 것들이 많다고 생각이 듭니다.