JPA

JPA N+1 문제 해결

hs-archive 2023. 7. 4. 20:45

https://unsplash.com/photos/orWXObO27GI

 

N + 1 문제란?

엔티티를 가져오고 난 뒤 연관된 엔티티를 가져오기 위해 다시 쿼리를 날리는 것을 N + 1 문제라고 합니다.

 

예를 들어, Member와 Team이 N:1로 매핑되어 있을 때 'select m from Member m'이라는 JPQL문을 사용하면 우선, 한 번의 쿼리로 모든 Member를 가져옵니다. 그리고 Team 엔티티의 값에 접근하는 순간 추가적으로 쿼리가 나갑니다. 이때 Member row 하나당 한 번의 쿼리가 나가므로, Member의 row가 총 N개라면 N 번의 추가 쿼리가 나가게 됩니다. Member 엔티티를 가져올 때 1번 + Team 엔티티를 가져올 때 N 번의 쿼리가 발생하므로 총 N+1번의 쿼리가 발생합니다.

 

애초에 Member 엔티티를 가져올 때 Team 엔티티까지 함께 가져왔다면 단 1번의 쿼리로 처리되었텐데, 이를 N+1번의 쿼리로 처리한 것이므로 매우 비효율적인 방식입니다.

 

N + 1 이 발생하는 이유

N+1을 해결하기 전에 N+1이 왜 발생하는지 생각해 봅시다.

 

JPA는 엔티티를 조회할 때 겸사겸사 연관 엔티티까지 가져오는 방식을 채택하지 않고, 연관 엔티티가 필요한 순간에 추가로 조회하는 방식을 채택합니다. 즉, 연관된 엔티티를 가져오려면 추가 조회가 필요하도록 JPA가 설계되었기 때문에 필연적으로 N+1문제가 발생할 수 밖에 없는 것입니다.

 

그렇다면, 왜 JPA는 그렇게 만들어졌을까요? 애초에 N+1문제가 발생하지 않도록 연관 엔티티까지 한 번에 다 가져오면 더 좋았지 않았을까요? 

 

JPA를 이렇게 설계한 이유는 성능과 메모리 사이의 균형을 맞추기 위해서입니다. 모든 관련 엔티티를 한 번에 가져온다면 데이터베이스와 서버에 부하가 증가하고, 불필요한 데이터를 메모리에 로딩하게 될 수 있습니다. 생각해 보세요. 데이터베이스와 서버의 자원을 엄청나게 사용해서 연관 엔티티까지 전부 가져왔는데, 실상 연관 엔티티가 쓰이지 않는다면 엄청난 자원 낭비를 한 것입니다. JPA는 이러한 상황을 방지하고자 필요한 시점에만 하위 엔티티를 추가로 조회하도록 설계된 것이고 이 때문에 N+1 문제가 발생하는 것입니다.  

 

N + 1 문제 해결 방법

N+1이 발생하는 이유는 이제 알겠습니다. 충분히 납득할 만합니다. 하지만, 분명 연관 엔티티를 한 번에 가져와야 할 때가 있습니다. 연관 엔티티의 데이터가 필요할 때가 그러합니다. JPA는 이러한 상황을 위해 JPQL에서 fetch join이라는 키워드를 사용할 수 있도록 지원해 줍니다. fetch join 키워드를 사용하면 연관 엔티티까지 한 번에 조회할 수 있습니다. 이렇게 fetch join을 사용하여 연관된 컬렉션(리스트)를 한 번에 불러오는 것을 앞으로 컬렉션 페치 조인이라고 부르겠습니다.

 

* join과 fetch join은 서로 약간 다릅니다. fetch join은 조회되는 엔티티와 연관된 엔티티까지 함께 조회하는 반면 join은 조회 대상만 가져오고 연관된 엔티티는 프록시 컬렉션 래퍼로 매핑합니다. 다시 말해, join의 경우 조회 대상은 가져오지만, 연관된 엔티티는 가져오지 않고 대충 껍데기만 비슷하게 만들어 놓는 것입니다. 따라서 join의 경우 연관된 엔티티의 실제 값이 없으므로 실제 값을 사용하려는 순간 추가적인 쿼리가 발생합니다.

 

JPQL에서 혹은 QueryDSL에서 fetch join은 아래와 같이 작성할 수 있습니다.

 

JPQL

"select m from Member m join fetch m.team"

 

QueryDSL

queryFactory.selectFrom(user)
    .from(user)
    .innerJoin(user.team, team)
    .fetchJoin()
    .fetch();

 

fetch join 적용 전/후 소요 시간 차이

Row가 약 1,000인 엔티티의 연관 엔티티를 조회할 때 fetch join을 사용하지 않았을 때와 fetch join을 사용했을 때의 소요 시간 차이를 나타내는 사진이 아래에 있습니다. Row가 1,000이므로 N이 1000인 것입니다. 즉, 1000+1번 쿼리를 실행하는 것과 1번 쿼리를 실행하는 것의 차이라고 할 수 있습니다. 테이블에 있는 데이터에 따라 결과는 달라지겠지만 분명 유의미한 차이를 보입니다.

 

fetch_join 미사용 (12초)

 

fetch_join 사용 (2초)

 

2초도 느리다.

fetch join을 사용하지 않았을 때 걸리던 12초를 생각하면 fetch join을 사용했을 때 걸리는 2초는 매우 개선된 속도입니다. 하지만, 사실 2초도 느립니다. 이러한 느린 속도를 해결하기 위해서 페이징(Pagination)을 사용할 수 있습니다. 페이징이란 모든 row를 한 번에 다 가져오는 것이 아닌 페이지 단위로 나누어 가져오는 방식을 뜻합니다. 예를 들어 한 페이지에 10개의 row를 보여준다면 한 번에 10개씩만 가져오면 되므로 한 번에 처리해야 할 데이터 양을 줄여 처리 속도를 향상시킬 수 있습니다.

 

컬렉션 페치 조인 시 페이징

User:할 일(1:N)

 

한 명의 User가 여러 개의 할 일을 가질 수 있는 1:N 관계를 생각해 봅시다. "수달"이라는 User가 ["테코톡 끝내기", "석촌호수 러닝", "플젝 리팩터링", "짜장면 먹기"]라는 "할 일"을 갖고 있습니다. 이를 fetch join하면 아래와 같은 테이블이 생성됩니다.

 

** 참고로 아래 테이블을 보면 알 수 있듯 DB는 총 4개의 row를 반환합니다. JPA는 해당 반환 데이터를 가지고 Mapping을 해줍니다. 그런데, DB가 총 4개의 row를 반환했기 때문에 동일한 Mapping된 데이터를 가리키는 4개의 Item을 갖는 list를 반환합니다. 애플리케이션에서 이러한 중복을 없애려면 Distinct 키워드를 사용하면 됩니다. "SELECT DISTINCT M ......"

 

OneToMany fetch join에서 Paging 문제

 

이러한 컨텍스트에서 User에 대한 페이징을 해야 한다고 생각해 봅시다. 페이지 사이즈는 5입니다. 우리가 예상하는 결괏값은 5명의 User와 각 유저가 갖고 있는 모든 할 일 데이터입니다. 그런데, 수달은 네 가지 할 일을 가지고 있고 할 일 하나 당 하나의 row를 차지하므로  5명의 User와 각 유저가 갖고 있는 모든 할 일을 가져오는 것이 아니라 [수달-테코톡 끝내기, 수달-석촌호수 러닝, 수달-플젝 리팩터링, 수달-짜장면 먹기, 그 다음 유저-해당 유저의 첫 번째 할 일] 이런 데이터를 가져오게 됩니다. 아래 gif를 참고해 주세요.

 

OneToMany일 때 fetch join을 사용한 테이블에 페이징을 적용하면 기존 우리가 원했던 [수달-[할 일 리스트], 꼬재-[할 일 리스트], 연로그-[할 일 리스트], 아놀드-[할 일 리스트], 유콩-[할 일 리스트]] 데이터를 가져오지 않고 예상치 못한 데이터가 들어옵니다. 게다가 잘 보시면 그나마 수달의 할 일 리스트는 전부 잘 들어왔지만 "꼬재"는 "아침 일찍 운동" 이라는 데이터가 누락되었음을 확인할 수 있습니다. JPA는 이러한 문제를 해결하기 위해 Page Limit을 DB에서 수행하지 않고 우선 모든 row를 전부 가져온 뒤 해당 row들을 메모리에 저장을 하고 페이징 로직을 실행하는 방법을 택합니다.

 

하지만 이렇게 되면 애초에 우리가 기대했던 페이징의 효과를 누리지 못하게 됩니다. 애초에 페이징을 사용하려던 이유는 모든 row를 가져오는 것이 부담이 되기 때문이었는데, 이렇게 되면 페이징을 사용하더라도 모든 row를 가져오므로 페이징을 사용하지 않았을 때와 사실상 달라지는 것이 없습니다. 게다가 서버의 "메모리"는 무한한 것이 아닙니다. 만약 테이블의 크기가 메모리에 저장하지 못할정도로 커다랗다면 Out Of Memory Erorr(OOM)가 발생할 수 있습니다. 

 

그리고 User 엔티티에서 할 일 엔티티도 fetch join을 하고 또 다른 컬렉션도 fetch join을 하려한다면 오류가 발생합니다. 컬렉션 fetch join(OneToMany에서 하는 fetch join)은 하나만 가능합니다. 왜냐하면 두 개의 컬렉션 fetch join을 사용하면 카테시안 곱이 발생하기 때문입니다.

 

컬렉션 페치 조인 시 페이징 문제 해결

컬렉션 페치 조인 시 페이징 문제는 보통 두 가지로 해결합니다. 첫 번째는 OneToMany 에서 조회하지 않고 ManyToOne에서 거꾸로 조회하는 것이고, 두 번째는 @BatchSize()를 활용하는 것입니다.

 

ManyToOne에서 페치 조인 사용

할 일 테이블 입장에서 데이터를 바라보았을 때, User와 join을 몇번을 하던 row가 늘어나지 않습니다.

 

User 테이블 입장에서는 할 일 테이블과 join을 했을 때 "수달" 하나였던 row가 4개의 row로 증가를 하였기 때문에 페이지네이션을 적용할 수 없었습니다. 하지만 할 일 테이블의 입장에서는 기존 row가 4개이고 join을 하더라도 똑같이 row가 4개 입니다. 따라서 ManyToOne에서 페치 조인을 한다면 데이터가 늘어나지 않으므로 페이지네이션을 적용할 수 있습니다.

 

@BatchSize() 사용

@BatchSize()은 해당 어노테이션이 붙은 데이터를 한 번에 여러 개씩 가져오도록 만드는 어노테이션입니다. 우선 fetch join을 사용하지 않습니다. 그러면 당연히 다시 N+1 문제가 발생하겠죠? 이때 @BatchSize() 어노테이션이 동작합니다. 기존 @BatchSize() 어노테이션을 사용하지 않았을 때에는 하나의 User당 하나의 할 일을 가져왔었습니다. 하지만 @BatchSize() 어노테이션을 사용하면 해당 어노테이션으로 넘겨준 값만큼 할 일을 가져옵니다. 예를 들어 @BatchSize(1000)으로 작성했다면 할 일을 천개 가져옵니다. 정확하게 말하면 @BatchSize(1000)에서의 1000은 IN 절에 들어갈 요소의 최대 갯수를 의미하는 것입니다.

 

SELECT * FROM USER WHERE USER.할 일 ID IN (? , ?, ...... ?) 
# 이 물음표가 최대 1000개 들어갈 수 있게 됩니다. 기존에는 단 하나만 들어갔었습니다.

 

다만 할 일 테이블에서 조회할 전체 row가 1000보다 많다면 두 번, 세 번 쿼리가 날라가게 됩니다. 어쨌든 N + 1에서 한 번의 쿼리에 단 한개의 row를 조회했을 때와 비교하면 매우 큰 성능 향상이 됩니다. 

 

정리

N+1 문제는 JPA가 연관된 엔티티를 해당 엔티티가 필요한 순간에 추가로 조회하기 때문에 발생합니다. 이는 조회하는 순간 연관된 엔티티를 모두 가져오는 fetch join을 사용하여 해결할 수 있습니다.

 

OneToMany 관계에서 fetch join을 사용하면 row의 수가 증가하기 때문에 페이지네이션이 제대로 동작하지 않습니다. 이를 해결하기 위해 JPA는 연관된 데이터를 전부 가져온 뒤 페이지네이션을 진행합니다. 하지만 이러한 방법은 OOM 문제를 발생시킬 수 있습니다.

 

해당 문제를 해결하려면 ManyToOne 방향에서 fetch join을 하거나 fetch join을 포기하고 @BatchSize() 어노테이션을 사용하여 한 번에 여러 줄의 연관된 row를 가져오는 방법을 사용하면 됩니다.

 

 

 

 

 


https://cornswrold.tistory.com/486

 

[JPA] N+1 문제 해결방법

JPA에서 골치아픈 N+1 문제 해결방법을 몇가지 정리한다. N+1 문제 => ORM에서 많이 발생하는 문제 Entity에 대해 하나의 쿼리로 N개의 레코드를 가져왔을 때, 연관관계 Entity를 가져오기 위해 쿼리를 N

cornswrold.tistory.com

https://soongjamm.tistory.com/151

 

N+1 문제란? 그리고 해결방법 (feat. fetch join)

N+1 문제란? 객체 조회(1회)의 결과로 n개의 결과가 나온다고 했을 때, 조회된 객체안에 또 다른 객체가 있을 수 있다. 그렇다면 객체안의 객체를 조회하기 위해 또 다른 쿼리가 발생하는 경우가

soongjamm.tistory.com

https://www.youtube.com/watch?v=ni92wUkAmQI 

'JPA' 카테고리의 다른 글

QueryDSL  (0) 2023.04.02
JPA 연관 관계 설정  (0) 2021.07.06
JPA의 개념과 이해  (0) 2021.07.04