본문 바로가기

JPA

N+1 (나만 알아듣는)

기존의 JPA를 이용하여 개발할때 

잊어먹지않게 다시 작성... 이미 잊어먹음...가물가물하다.

 

n+1의 문제는 일단 1하나의 쿼리를 예상하였지만 결과의 개수만큼 계속 쿼리가 던져(?)지는 문제점....

 

 

책을 읽고 구글링을 해보고 보니까,

원인은 2가지로 크게 볼 수 있을 꺼 같다.

 

 

1. fetch 전략

2. Jpql

 

(기본적으로 fetch 와 Jqpl에 대해 안다고.. 가정하에 작성)

 

하나 씩 살펴보자.

 

(상상을 해보자 임의의 엔터티가 두개 있는데 서로 양방향 OneToMany ManyToOne의 관계이다.)

 

전략을 지연로딩(단일조회 기준) 으로 설정을 하였다고 하자.

임의의 하나의 엔터티에 조회를 하게 되면 (단일조회 기준) 지연로딩으로 인하여 하나의 엔터티에게만 조회를 하니까 n+1이 발생할리가 없다.(대신 이경우에 추가로 해당엔터티의 연관된 엔터티를 가져올려고 하면 추가로 조회쿼리가 일어난다.) 

 

다음으로는 지연로딩(여러개 조회기준)

애초에 OneTomany 기준으로 패치조인을 사용하면 데이터가 뻥튀기가 일어난다(그냥 참고로 알기, 페이징도 안됨.)

아무튼 one기준으로 여러개를 조회를 해보자.

one의 기준 엔터티가 5개 라고 findAll를 했다고 가정해보자.

일단 기본적으로 지연로딩이기때문에 하나의 쿼리가 발생하겠지만, one의 연관관계 many의 엔터티를 get를 하게되면,

one의 엔터티를 5개 조회 후, 추가로 5개의 쿼리가 더 날라간다.

지금은 5개지만 100개가 넘어가면 100개의 추가 쿼리가 날라간다...

 

?? 그러면 이 문제점을 즉시로딩으로 해결볼까?

 

 

두번째로 즉시로딩기준으로 보자

즉시로딩은 서로 연관관계에서 조인을 해줘서 바로 추가쿼리조회가 일어나지 않게 해주긴한다.

단일조회로 즉시로딩인 경우에는 멋지게 알아서 조인을 해줘서 단일조회를 해준다 

 

문제는 여러개의 조회일 경우에 보자.

대부분 최적화나 여러개의 조건으로 인하여 Jpql를 쓸 수 밖에 없을텐데...

이렇게 Jpql를 하다 보면 쿼리를 만들어서 던지게 된다... entityManeger.find() 이것이 아닌 직접 만들어서 쿼리를 던지게 된다.

그럼 일단 조회를 해온다. 조회를 한 다음 동시에 fetch 전략을 즉시로딩인 것을 보고 추가로 해당엔터티에 연관된 엔터티를 조회를 해온다.

 

 예를 들어 하나의 유저는 여러개의 책을 가지고 있다고 보자.

유저가 100명이다 . findAll로 조회를 일단 하면 하나의 쿼리 즉, 유저를 조회하는 쿼리가 날라가는데, 즉시로딩이라는 전략을 보고 100명에 대한 책의 정보를 추가로 또 100개를 쿼리를 날린다.

이런거 보면 JPA가 좋은점도 있는거 같은데 진절머리가 난다.

 

근데 이런 즉시로딩의 문제점을 보면 연관관계가 복잡할 경우에는 나도모르게 알아서 조인문이 나가던지,( 굳이 필요없는 엔터티까지 메모리가 올려서 가져오니까 단점 Or 뜬금없이 나도 모르게 n+1가 일어날 수가 있다.)

 

 

 

---->>>>> 그럼 이런 경우를 어떻게 해결을 할 수가 있나??

 

구글링을 하거나 JPA에 대한 책을 보면 그냥 다 기본적으로 지연로딩을 설정해놔라라는 말이 많다.

틀린말은 아닌 거 같다. 근데 이제 진짜로 해당엔터티를 조회 시 무조건 연관관계 엔터티를 가져오거나 그런 경우에는 

즉시로딩을 해줘도 나쁘지는 않을 꺼같다.

 

암튼 그럼 어떻게 해결을 할 수 가 있을까?

 

대부분 패치조인을 많이 이용한다.

한 번에 그냥 연관된 엔터티까지 다 가져와버린다. 그럼 추가로 엔터티에 대해 조회를 해도 추가로 엔터티 쿼리 조회가 일어 나지 않는다.

 

패치조인이 만능은 아니다.

기본적으로 oneToMany일때 패치조인을 사용을 하면 페이징이 안된다. 기준점을 몰라서 안된다.

10개씩 페이징을 한다고 보자. 

10개의 엔터티에 그 엔터티의 해당 관련엔터티까지 가져오는건데, 1개당 연관된 엔터티가 100개 이상 있다고 치면 

중복되어 조회가 되고 그럼 어디를 기준으로..? 나눠야할지 모를 것이다. one에 대한건 10개 인데 many쪽에 하나당 100개 라고 치면 row가 최소 1000개 일텐데 그럼 뭐로 나눠..? 이래서 몰라서 기본적으로 다 메모리에 올린다고 하고 거기서 limit으로 나눈다고 한다.

(그래서 BatchSize를 이용하여서 페이징을 한다고 한다 추가로 쿼리문이 날라가긴하지만 이정도면 괜찮을 듯...? 아니면 애초에 그냥 ManyToOne에 페이징을 하는것이다.)

 

그리고 oneToMany를 페이징 없이 조회를 할때는 distinct를 사용하자 one쪽이 중복으로 메모리에 올라가니까...

페이징을 안할경우에는 oneToMany써도 된다... 근데 이제 두 개 이상은 안된다.(너무나도 뻥튀기 될 듯..?)

 

그리고 패치조인을 하면 조회하는 컬럼들이 많아지는데, 순수 조회만 하는 경우에는 굳이 패치조인안하고,

Projection 즉 DTO로 타이트하게 조인해서 받아올 수도 있다.

 

 

 

 

정리를 하면 그냥 근본적으로 oneToMany에 문제가 많다.

  • 즉시로딩
    • jpql을 우선적으로 select하기 때문에 즉시로딩을 이후에 보고 또다른 쿼리가 날아가 N+1
  • 지연로딩
    • 지연로딩된 값을 select할 때 따로 쿼리가 날아가 N+1
  • fetch join
    • 지연로딩의 해결책
    • 사용될 때 확정된 값을 한번에 join에서 select해서 가져옴
    • Pagination이나 2개 이상의 collection join에서 문제가 발생한다 (데이터 넘침)
  • Pagination
    • fetch join 시 limit, offset을 통한 쿼리가 아닌 인메모리에 모두 가져와 애플리케이션에서 처리하여 OOM 발생한다.
    • BatchSize를 통해 필요 시 배치쿼리로 원하는 만큼 쿼리를 날림 > 쿼리는 날아가지만 N번 만큼의 무수한 쿼리는 발생되지 않음
  • 2개 이상의 Collection join
    • List 자료구조의 2개 이상의 Collection join(~ToMany관계)에서 fetch join 할 경우 MultipleBagFetchException 예외 발생
    • Set자료구조를 사용한다면 해결가능 (Pagination은 여전히 발생)
    • BatchSize를 사용한다면 해결가능 (Pagination 해결)

 

OneToMany 에서 페이징 시에는 BatchSize사용하기.

지연로딩에서는 FetchJoin 및 Dto로 값 타이트하게 받아도 되는 경우에는 join으로 사용.

 

 

 

최종... 어느정도 n+1의 경우를 알면 그때 그때 상황에 맞게 개발을 하자 

정답은 없는 것 같다.