더이상 n+1에 휘둘리지 않기 위한 몸부림
최근들어 가장 많이 사용하는 API 중 하나인 JPA, 항상 JPA를 쓰면 따라오는 이야기가 있습니다.
JPA를 쓰면 N+1 문제가 발생하는데 해결해보신 경험이 있나요?
면접에 들어갔을 때 JPA를 사용한다라고 하면 항상 받던 질문이었습니다. 그때마다 머릿속에 정리가 잘 되지 않아 당황했던 기억이 있는데, 이 기회에 JPA에서 N+1이 발생하는 일반적인 케이스들을 모두 정리해보고자 글을 쓰게 되었어요 :)
흔히 알고있는 즉시로딩, 이 해결책으로 지연로딩 + fetch join을 해도 N+1이 생기는데요. 같이 모든 케이스를 확인해보면서 이야기를 이어나갈까 합니다!
모든 예제는 spring boot + spring data jpa환경으로 구성되어있습니다. 가장 많이 사용하는 조합이고, 많은 사람들이 익숙한 코드를 작성하는 것이 좋다고 생각해서 hibernate보다는 위 환경으로 진행하였습니다 :)
현재 글은 기본적으로 JPA에서의 즉시로딩과 지연로딩의 개념을 알고 있다는 전제하에 작성하였습니다.
JPA를 마냥 처음 공부하던 시기에는 N+1이라는 이야기만 계속 듣지 정작 어떤 건지도, 왜 해결해야하는지도 사실 와닿지 않았어요. 사실 거창하게 N+1이지 간단하게 이야기하면 다음과 같습니다.
조회 시 1개의 쿼리를 생각하고 설계를 했으나 나오지 않아도 되는 조회의 쿼리가 N개가 더 발생하는 문제.
DBMS 툴을 이용해 직접 쿼리문을 만들어 조회할 때는 물론 하나의 쿼리가 발생하겠지만 mybatis, 넘어서는 JPA가 등장함에 따라 자동화된 쿼리문들이 생겨나면서 어쩔 수 없이 발생하는 문제입니다.
JPA의 경우에는 객체에 대해서 조회한다고 해도 다양한 연관관계들의 매핑에 의해서 관계가 맺어진 다른 객체가 함께 조회되는 경우에 N+1이 발생하게 됩니다.
케이스들에 대해서 아래에서 이야기하겠지만 지금 간단하게 예를 들면, 유저 한명이 쓴 게시글들을 조회할 때 유저-게시글을 join한 형태의 쿼리문을 원했지만 N개의 게시글을 또 조회하는 쿼리가 날아가는 경우가 있을 수 있겠네요.
그럼 지금부터 그 예들과 대처방안을 순서대로 알아가볼까 합니다.
가장 흔하게 볼 수 있는 다대일관계 입니다. 한명의 User 여러개의 Article을 가질 수 있는 구조(User : 1, Article : N)이죠.
Fetch type 같은 경우 현재 코드에서는 적용해두지 않았지만 즉시로딩, 지연로딩 각각에 들어가서는 명시하여 사용합니다.
(여담으로 Fetch type은 default로 ~ToMany에서는 Lazy, ~ToOne에서는 Eager로 지정되어있는데 이런 부분들은 default옵션을 사용한다고 하더라도 명시해주는 것이 협업하는 다른 개발자가 보기에도 좋습니다.)
실무에서 가장 쓰지말아야할, 모든 문제의 첫번째 원인이 되는 즉시로딩입니다.
User의 입장에서 즉시로딩을 사용한다고 했을 때, Article의 모든 List를 다 같이 조회하고 싶을 상황이 생길 수도 있는데 왜 즉시로딩이 문제가 될까요?
사실 일반적으로 findById에 대한 메소드는 EntityManager에서 PK 값을 찍어서 사용하기 때문에 JPA가 내부적으로 join문을 사용해서 최적화를 다음처럼 진행해줍니다.
내부적으로 inner join문 하나가 날아가서 User가 조회됨과 동시에 Article까지 즉시로딩되는 것을 확인할 수 있습니다.
findById, 즉 EntityManager에서 entityManager.find(); 같은 경우 jpa가 내부적으로 join문에 대한 쿼리를 만들어서 반환을 하기 때문에 즉시로딩으로는 문제가 없어보이기도 합니다.
다만 문제는 jpql에 있습니다. 우리는 findById만 사용하는 것이 아니라 직접 jpql문을 짜서 전달하기도하고, data jpa에서 findBy~의 쿼리메소드 같은 경우에도 data jpa 내부에서 jpql이 만들어져서 나갑니다.
아니 그럼 jpql은 뭐가 문제일까요?
일단 jpql이 sql로 그대로 번역이 됩니다. 만약 User의 findAll()을 요청하는 것이라면 select u from User u ;라는 쿼리가 발생하게 되는 것이겠죠. User를 찾는거는 문제가 없었지만 여기서 우리는 "즉시로딩"을 Article column에 걸어두었던 것을 잊으면 안됩니다.
User를 select 해왔지만 JPA는 Article에 대해서 EAGER가 걸려있는 것을 보고 select한 모든 User에 대해서 article이 있는지를 검색하게 됩니다.
즉, 모든 User에 대해서 검색하고 싶어서 select 쿼리를 하나 날렸지만(1), 즉시로딩이 걸려있기 때문에 각각의 User가 가진 Article을 모두 검색한다(N)라는 N+1 문제가 발생하는 것입니다.
지금이야 User 수가 적으니 N+1문제가 크게 와닿지 않지만 유저가 몇백만되는 서비스였다면 모든 유저를 검색하기 위해 1개의 쿼리만 날려도 되는데 Eager가 감지되어 몇백만의 추가 쿼리가 발생할 수 있는 것이죠.
정리하면 다음과 같습니다.
즉시로딩은 Jpql로 전달되는 과정에서 Jpql 후 Eager 감지로 인한 N쿼리가 추가로 발생하는 경우가 있기 때문에 사용해서는 안된다.
잘 아시다시피 프록시 객체를 로딩하는 과정에서 즉시로딩 말고 지연로딩이 있습니다. 연관된 객체를 "사용"하는 시점에 로딩을 해주는 방법이죠.
그럼 이제 문제였던 즉시 로딩을 바꿨으니 N+1은 더이상 발생하지 않을까요?
네 아닙니다. 지연 로딩은 해당 연결 entity에 대해서 프록시로 걸어두고, 사용할 때 쿼리문을 결국 날리기 때문에 처음 find할 때는 N+1이 발생하지 않지만 추가로 User 검색 후 User의 Article을 사용해야한다면 이미 캐싱된 User의 Article 프록시에 대한 쿼리가 또 발생하게 됩니다.
지연로딩을 했으니 지연로딩 대상인 Article을 나중에 조회해서 쿼리가 또 날아가는 것조차도 N+1 문제의 예 중 하나인 것이죠.
즉시로딩, 지연로딩으로 넘어오면서 우리는 근본적으로 N+1이 JPA에서 생기는 원인을 알 수 있었습니다.
JPA가 자동으로 먼저 생성해주는 Jpql을 통해서 우선적으로 쿼리를 만들다보니 연관관계가 걸려있어도 join이 바로 걸리지 않는다.
일단 즉시로딩에서는 우리가 커스텀할 수 있는 부분이 존재하지 않기 때문에 지연로딩 과정에서 우리는 바로 사용을 할 객체에 대해서는 join을 걸 수 있도록 조정해주어야 합니다. 그것이 fetch join인거죠.
먼저 fetch join을 고려하지 않았을 때 Jpql을 임의로 만들어 보도록 하겠습니다.
join이 들어가고 distinct가 들어가긴 했지만 사실 그렇게 복잡한 Jpql 구문은 아닙니다. article에 대해서 outer join을 했고, article이 list로 들어가기 때문에 User가 여러 개 분리되어 생성되는 것을 distinct로 막은 구문입니다.
그럼 이 쿼리를 실제로 돌려볼까요?
위에서 findAll에서 사실 join만 없다일 뿐이지 당연히 N+1문제는 동일하게 발생합니다. 아직까지는 지연로딩이 걸려있고, join을 했어도 프록시로 가져오는건 변함이 없으니까요.
여기서 해결책은 fetch join을 걸어버리면 됩니다.
join문에 fetch를 걸어주면 되는데요. fetch는 지연 로딩이 걸려있는 연관관계에 대해서 한번에 같이 즉시로딩해주는 구문입니다. 쿼리를 한번 볼까요?
구분선을 그릴 필요도없이 쿼리를 날릴 때 article을 한번에 모두 가져옴을 알 수 있습니다.
jpql에서 fetch join을 하게 된다면 하드코딩을 하게 된다는 단점이 있습니다. 이를 최소화하고싶다면 @EntityGraph를 사용하면 되는데요. 바로 예제 쿼리를 보여드리겠습니다.
Hibernate의 Jpql 구문에서의 fetch는 존재하지는 않지만 기존과 마찬가지로 fetch join을 통해 바로 조회할 수 있음을 확인할 수 있습니다.
fetch join을 사용하면 뭐든 다 이룰수 있는 것처럼 보이긴하지만 사실은 사용시 유의점이 있는 케이스들이 있습니다. 그 두가지를 지금 알아보겠습니다.
Paging처리를 JPA에서 할 때 가장 많이 겪는 이슈입니다. fetch join을 통해서 N+1을 개선한다고는 하지만 막상 Page를 반환하는 쿼리를 작성해보면 다음과 같은 에러가 발생하거든요.
Fetch join을 Paging처리해서 반환해볼까요?
0페이지의 총 2명의 유저를 반환하는 PageRequest 객체를 파라미터로 입력받았습니다.
과연 정상적으로 쿼리가 하나만 나갈까요?
하나만 나가기는 했습니다. (Count 쿼리는 Page 반환시 무조건 발생하는 쿼리이므로 제외하도록 하겠습니다.)
근데 쿼리를 자세히보면 Mysql에서 페이징 처리를 할 때 사용을 하는 Limit, Offset이 없습니다. 분명 limit은 size 2로, offset은 page 0으로 지정해줬는데 말이죠. 근데 또 반환 값은 2명의 유저 article size가 나왔는데 뭐가 어떻게 된걸까요?
사실 이미지에서 일부러 잘라서 보여드렸지만 == start == 밑에 어떠한 WARN 구문이 있습니다.
해석해보면 collection fetch에 대해서 paging처리가 나왔긴한데 applying in memory, 즉 인메모리를 적용해서 조인을 했다고 합니다.
실제 날아간 쿼리와 이 문구를 통합해서 이해를 해보면 일단 List의 모든 값을 select해서 인메모리에 저장하고, application 단에서 필요한 페이지만큼 반환을 알아서 해주었다는 이야기가 됩니다.
이러면 우리는 사실상 Paging을 한 이유가 없어지는 것과 마찬가지입니다. 100만건의 데이터가 있을 때 그 중 10건의 데이터만 paging하고 싶었으나 100만건을 다 가져온다? 그것도 메모리에? OOM(Out of Memory)이 발생할 확률이 매우 높습니다.
따라서 Pagination에서는 fetch join을 하고싶어서 한다고 하더라도 해결을 할 수 없습니다.
짧게 이야기를 하자면 fetch join에서 distinct를 쓰는 것과 연관이 있습니다. distinct를 쓰는 이유는 하나의 연관관계에 대해서 fetch join으로 가져온다고 했을 때 중복된 데이터가 많기 때문에 실제로 원하는 데이터의 양보다 중복되어 많이 들어오게 됩니다.
그 이유때문에 개발자가 직접 distinct를 통해서 jpa에게 중복 처리를 지시하게 되는 것이고, Paging처리는 쿼리를 날릴 때 진행되기 때문에 jpa에게 pagination 요청을 하여도 jpa는 distinct때와 마찬가지로 중복된 데이터가 있을 수 있으니 limit offset을 걸지 않고 일단 인메모리에 다 가져와서 application에서 처리하는 것이죠.
눈치가 빠르신 분이라면 눈치를 채셨을 것 같은데 한가지는 위에서 말한 distinct가 생기는 상황 자체를 없애는 것입니다.
사실 해결책이라기보다는 ~ToOne 관계라면 페이징 처리를 진행해도 괜찮다라는 내용입니다.
Article은 User에 대해서 ManyToOne 연관관계이기 때문에 지금처럼 Pagination을 진행한다고 해도 인메모리에서 모든 Article을 조회하는 것이 아닌 limit을 걸어 필요한 데이터만 가져올 수 있습니다.
따라서 사실 Pagination의 해결책이라는 부제목이 달려있지만 ~ToOne 관계에 있는 경우 fetch join을 걸어도 Pagination이 원하는대로 제공된다라는 것을 알면 좋을 것 같습니다.
다만 ~ToMany 관계, 즉 컬랙션 조인을 했을 경우 Many인 다객체들이 One에 매핑되어 fetch join된다면 Pagination에서 갯수를 판단히기 힘들기 때문에 fetch join을 사용할 경우 임의로 인메모리에서 조정한다고 이야기했었습니다.
따라서 컬랙션 조인을 하는 경우에는 fetch join을 아예 사용하지 않고 조회할 컬랙션 필드에 대해서 @BatchSize 를 걸어 해결합니다.
findAll() default메소드를 사용하겠습니다. 다만 달라지는 것은 필드에 @BatchSize를 걸게 될 것입니다.
이렇게 되었을 때 동일하게 테스트 코드를 날리면 articles만 따로 한번에 select하게 됩니다. 처음 지연로딩 default한 설정 + pagination은 어떻게 처리되는지 아래를 확인해보겠습니다.
어.... 분명 그냥 지연로딩했을 때랑은 다른거 같기는 합니다. 분명 User에 대해서 limit 쿼리가 나갔기 때문에 인메모리가 아닌 정상적인 pagination이 작동되었는데 밑에 article을 select하는 쿼리가 하나 등장했습니다.
어떻게 된것이냐면 지연로딩하는 객체에 대해서 Batch성 loading을 하는 것이라고 생각하면 됩니다.
기존의 지연로딩에 대해서는 객체를 조회할 때 그때그때 쿼리문을 날려서 N+1 문제가 발생한 반면 객체를 조회하는 시점에 쿼리를 하나만 날리는게 아니라 해당하는 Article에 대해서 쿼리를 batch size개를 날리는 것입니다. batch 쿼리에서 where부분만 확대해서 보겠습니다.
in (?, ?)가 결국 user id를 100개를 가져오는 쿼리문으로써 그때그때 조회하는 것이 아닌 조회할 때 그냥 batch size만큼 한번에 가져와서 뒤에 생길 지연로딩에 대해서 미연에 방지하는 것이라고 보면 좋을 것 같아요.(batch size는 Article이 아닌 User의 갯수가 기준입니다.)
다만, Batch Size는 연관관계에서의 데이터 사이즈를 확실하게 알 수 있다면 최적회된 size를 구할 수 있겠지만, 사실 일반적인 케이스에서 최적화된 데이터 사이즈를 알기 힘들고 일반적으로 100~1000을 쓴다이지 확실하게 알지 못한다면 안좋은 방법이 될 수 있습니다.
@BatchSize와 비슷하지만 다른 어노테이션입니다.
@BatchSize의 경우 사이즈 갯수 제한을 임의로 두어서 사용자가 최적화된 데이터 사이즈를 적용하게끔 도와준다면 이 어노테이션은 그냥 전부다 합니다..
코드 결과를 바로 보겠습니다.
다른 부분은 BatchSize와 동일하니 Collection을 따로 조회하는 쿼리만 가져왔습니다. 보면 where절 안에 in 문에서 현재 select할 User의 아이디가 들어가있어야하는데.... User를 그냥 싹다 조회하는 select all 쿼리가 들어있음을 알 수 있습니다.
즉, Batch Size의 경우 주어진 size만큼 User Id를 입력하여 그때그때 프록시 상태에 따라 지연로딩을 했다면, 지금은 그런거 없이 User Id를 싹다 조회하겠다는 것이죠. 마치 @BatchSize(size = 무한대)처럼 말이죠.
다만, 과연 한번에 모든 batch를 가져오는 것이 과연 좋은 판단인가에 대한 것은 의문이 있습니다. Batch Size의 경우 size가 100일때 100만명의 유저에서 100명의 user id에 대한 검색을 하는 반면, SUBSELECT는 100만명 모든 유저를 일단 select하게되거든요.
사실 이 부분은 성능적인 테스트가 필요하여 차후에 관련해서 테스트를 하게 된다면 포스팅하여 링크를 남기도록 하겠습니다 :) 현재로써는 Batch Size 이상으로 이 방법을 사용할 필요는 없다는 것이 개인적인 생각입니다.
fetch join은 앞서 batch size에서 이야기한대로 일단 하나의 collection fetch join에 대해서 인메모리에서 모든 값을 다 가져오기 때문에 pagination이 불가능했었습니다.
fetch join을 할 때 ToMany의 경우 한번에 fetch join을 가져오기 때문에 collection join이 2개이상이 될 경우 너무 많은 값이 메모리로 들어와 exception이 추가로 걸립니다. 그 exception이 MultipleBagFetchException인데요, 아래 사진에서 알 수 있다시피 2개 이상의 bags, 즉 collection join이 두개이상일 때 exception이 발생합니다.
음... 말만봐선 이해가 바로 가지 않을 수 있으니 바로 예를 들면 더 이해가 가기 쉬울 것 같아 바로 exception이 발생할 수 있는 entity를 가져와보겠습니다.
기존에 있었던 코드는 일단 뒤로하고, Article과 Question을 @OneToMany로 받는 User Entity에서 시작해볼게요.
당연하게도 User를 검색할 때 User만 사용한다면 지연로딩으로 인해서 아무런 문제가 발생하지 않겠지만, articles나 questions를 받아와야하는 상황이라면 N+1이 발생할 것입니다. 그럼 지금까지 했던대로
Repository에서 fetch join을 해볼까요?
이렇게 메소드로 테스트를 진행하면 Users는 ~ToMany가 두 개, 즉 collection fetch join이 두 개 이상 걸리기 때문에 바로 이야기했던 Exception이 발생합니다.
~ToOne은 얼마만큼 fetch join을 해도 괜찮지만 ~ToMany는 하나일 때는 인메모리에서 처리하고 두 개이상은 Exception으로 제한한다... 그럼 어떻게 해결해야할까요?
자료형을 Set으로 변경을 하면 해결되는 아마도 MultipleBag가 List로 되어있을 때 중복 자체를 허용하지 않는다면 복잡한 여러개의 collection fetch 관계를 해결할 수 있음이 아닐까 생각합니다.
전부다 Set자료구조로 바꾸고 해결되는지 볼까요?
MultipleBagFetchException이라는 둘이상의 collection fetch join을 막는 exception없이 정상적으로 모든 데이터를 가져옴을 알 수 있습니다.
Set을 사용하게 된다면 HashSet으로는 순서가 중요한 데이터에는 순서를 보장할 수 없기 때문에 LinkedHashSet을 사용해야합니다. (자료구조 상 List보다 손해가 있을 수 있겠네요.)
다만 Set을 사용한다고 해서, Pagination은 마찬가지로 해결이 불가능합니다.
Pagination은 근본적으로 몇개의 collection join 있던 간에 인메모리에서 가져오기 때문에 OOM을 발생시킬 수 있는 원인이 되어 해당 방법으로는 해결이 불가능합니다.
설령 Collection join이 한 개인 상황에서 Set 자료구조를 사용한다고 해도 인메모리에서 가져옵니다.
HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
앞서 Pagination의 해결책 중 하나로 나온 Batch Size입니다.
물론 이 방법이 Pagination의 해결책 중 하나로 나온 방법이긴 하지만 Collection join이 두 개 이상일 때 MultipleBagFetchException을 해결할 수 있는 방법이기도 합니다.
List 자료구조를 사용해야하는 상황이거나, Set을 사용한다고 Pagination에서 인메모리 로딩을 막을 수 없기 때문에 2개 이상의 Collection join을 사용하는데 Pagination을 사용해야할 경우도 인메모리를 사용하지 않고 사용할 수 있습니다.
정리하면 두 가지 경우로 간추릴 수 있겠네요.
List 자료구조를 꼭 사용해야하는 경우
2개 이상의 Collection join을 사용하는데 Pagination을 사용해야해서 인메모리 OOM을 방지하고자 하는 경우
Set, 혹은 List 위에 @BatchSize를 걸게 되면 동일하게 인메모리에 가져오는 것이 아닌 호출하는 당시에 한번에 모든 데이터를 가져오는 동작구조를 가집니다.
주의해야할 점은 batch size에 fetch join을 걸면 안됩니다.
fetch join이 우선시되어 적용되기 때문에 batch size가 무시되고 fetch join을 인메모리에서 먼저 진행하여 List가 MultipleBagFetchException가 발생하거나, Set을 사용한 경우에는 Pagination의 인메모리 로딩을 진행합니다.
필요한 article값을 출력하고자할 때 article만 따로 batch 쿼리를 날려 받음을 알 수 있습니다.
너무 많은 이야기를 해온 것 같아 마지막으로 모든 케이스를 항목화할 필요가 있을 것 같아 정리해보면 다음과 같습니다.
1. 즉시로딩
jpql을 우선적으로 select하기 때문에 즉시로딩을 이후에 보고 또다른 쿼리가 날아가 N+1
2. 지연로딩
지연로딩된 값을 select할 때 따로 쿼리가 날아가 N+1
3. fetch join
지연로딩의 해결책
사용될 때 확정된 값을 한번에 join에서 select해서 가져옴
Pagination이나 2개 이상의 collection join에서 문제가 발생
4. Pagination
fetch join 시 limit, offset을 통한 쿼리가 아닌 인메모리에 모두 가져와 application단에서 처리하여 OOM 발생
BatchSize를 통해 필요 시 배치쿼리로 원하는 만큼 쿼리를 날림 > 쿼리는 날아가지만 N번 만큼의 무수한 쿼리는 발생되지 않음
5. 2개 이상의 Collection join
List 자료구조의 2개 이상의 Collection join(~ToMany관계)에서 fetch join 할 경우 MultipleBagFetchException 예외 발생
Set자료구조를 사용한다면 해결가능 (Pagination은 여전히 발생)
BatchSize를 사용한다면 해결가능 (Pagination 해결)
정리를 해도 많은게 눈에 보이네요. 즉시로딩부터 fetch join까지 이야기하면서 fetch join의 문제점 역시 함께 알아가야 N+1문제에 대한 모든 해결방안을 알 수 있다고 생각해서 어쩔 수 없는 선택이었습니다. �
해결하고자하는 상황, 그리고 주어진 정책에 따라 여러가지의 판단을 할 수 있겠지만, 제가 생각하기에는 지연로딩은 항상 기본으로 깔고 들어가며 Pagination 상황이 가정되지 않는다면 Set 자료구조를 사용해서 MultipleBagFetchException를 예방하고 Paginatioin이 필수적으로 들어가는 상황이라면 Batch Size를 사용할 것 같습니다.