programming study/B-JPA

JPA - N+1 문제에 대해서

gu9gu 2023. 3. 27. 16:42

서론

JPA에서 대표적으로 겪는 이슈로 N+1 현상이 있다고 한다.

토이 프로젝트로 만들었던 곳에서도 이 현상이 일어나는지 확인해보고 수정을 해보고자 한다.

[10분 테코톡] 수달의 JPA N+1 문제 - YouTube

에서 설명한 내용이 이해하기에 좋은 것 같아서 정리해보고

내 프로젝트에 관련된 내용을 추가로 정리한다음 적용해보자.

 

 

본론

N+1 이란?

 요청이 한개의 쿼리만 실행하기를 기대했는데, N개의 쿼리가 추가로 발생하는 현상

따라서 1+N이라고 생각하면 쉽다.

@OneToOne, @oneToMany, @ManyToOne 등 여러 상황에서 발생할 수 있다.

 

@OneToMany 지연로딩일 때

1. findAll()로 크루 목록을 조회

 

참고) JPQL

Java Persistence Query Language

JPA는 엔티티를 중심으로 작성하기 때문에 쿼리를 작성할 때에도 JPQL이라는 기술을 사용해서 엔티티를 대상으로 쿼리를 작성합니다.

 

2. 크루_repository.findAll() 이 JPQL에 의해 쿼리로 변경해서 디비에 요청을 해서 데이터를 가져온다.

select * from crew;

 

3. List<할일> 목록 객체는 지연로딩을 사용했고 지금 사용하지 않기 때문에 사용하는 시점에 가져오려고 poxy 객체로 가지고 옵니다.

이 때 까지는 쿼리가 하나만 생성된 상태.

 

4. findAll()로 조회한 크루들에서 할일 목록에 대한 사이즈를 구하기 위해서

크루들의 할일목록부분을 사용하면

 

for(크루 크루 : 크루들) { 

  크루1.목록(proxy).getSize()

}

 

할일 목록이 프록시기 떄문에 JPA가 1차 캐시에 데이터가 있는지 확인합니다.

그런데 없어서 쿼리를 만들어서 데이터를 가져옵니다.

select * from 할일 where 크루_id = 1;

select * from 할일 where 크루_id = 2;

.

.

 

위와 같은 상황에서 findAll()로 한개의 쿼리만 사용해서 데이터를 가져오기를 기대했는데 관련된 엔티티에 대한 N개의 추가 쿼리가 발생하는 것을 N+1 현상이라고 합니다.

만약 연관 엔티티에서 데이터의 개수가 100만개라고 한다면 100만개의 추가쿼리가 발생할 수 있기 때문에 성능이 심각한 영향을 주는 문제입니다.

 

 

해결방법

1.Fetch join

<정의>

: 연관된 엔티티나 컬렉션을 한번에 같이 조회하는 기능

(연관된 엔티티까지 영속성 컨텍스트에 전부 올린다.)

* Spring Data JPA 기준 설명

 

< 사용방법 및 내부동작 >

사용방법은 쿼리문에 직접 fetch를 명시하는 방법과 @EntityGraph라는 어노테이션을 사용해서 가져오는 방법이 있다.

1) JPQL 쿼리에 fetch 적용

 

1-1) JPQL 쿼리에 fetch 적용

@Query("select c from 크루 c left join fetch c.목록")

List<크루> findAllJPQLFecth();

 

1-2) 똑같이 findALL()을 했을 때 join fetch가 붙은 쿼리가 만들어진다.

크루_repository.findAll() 

 => JPQL : select 크루.*, 할일.* from 크루 join fetch 할일;

 => 실제 쿼리 : select ....... from 크루 크루0_ left outer join 할일 목록1_ on  크루0_.id = 목록1_.크루_id

 

1-3) 이번에는 프록시 객체가 아니라 진짜 객체를 가지고 옵니다.

1-4) findAll()로 조회한 크루들에서 할일 목록을 사용하게 되면

최초에 관련된 데이터를 한꺼번에 가져와서 객체화 해줬기 때문에 DB를 거치지 않습니다.

1차 캐시에서 데이터를 꺼내서 DB를 거치지 않고 데이터를 꺼내서 바로 반환하게 됩니다.

한개의 쿼리로 문제가 해결 된 것이죠.

N+1 문제를 이렇게 해결하게 됐습니다.

 

 

<그렇다면 지연로딩이 아닌 즉시로딩을 사용하면 해결되는게 아닐까?>

아닙니다!

 

JPQL 은 엔티티를 기준으로 쿼리를 만들기 때문에 가지고 있는 연관관계는 무시하고 첫 번째 쿼리를 만들 때에는 조회대상이 되는 엔티티 기준으로만 쿼리를 만듭니다.

그래서 처음 쿼리를 만들 때 크루에 연관관계가 있는 엔티티는 신경 안쓰고 조회 대상이 되는 Entity 기준으로만 쿼리를 만듭니다.

그리고 나서 연관된 엔티티가 잇는 것을 확인하고 글로벌 패치 전략을 확인 한 후에 즉시로딩( fetch = FetchType.EAGER ) 이라면 즉시 N번의 추가 쿼리가 발생하게 되므로 N+1 문제가 발생하게 됩니다.

 

<즉시 로딩 해결 방법>

즉시로딩을 최대한 사용하지 말고, 지연 로딩+fetch join을 쓰라고 권장하고 있습니다.

데이터가 한꺼번에 많이 필요할 시에만 fetch join을 함께 사용하라고 권장합니다.

 

<그렇다면 fetch join이 만능인가?>

fetch join 의 사이트 이펙트를 고려해야한다.

대표적으로 OneToMany에서 페이징 처리할 때 문제가 있다.

 

참고) 관계형데이터베이스(RDB)와 객체의 차이

1대N관계를 표현할 때 객체를 사용하면 한 객체에 표현이 가능하지만

RDB에서는 여러개의 row(행)를 사용하게 됩니다.

한 개의 엔티티인데, 한 줄로 표현이 불가능하다는 차이가 있습니다.

 

 

페이징 과정에서 문제

@EntityGraph(attributePaths = {목록}, type = EntityGraph.EntityGraphType.FETCH)

Page<크루> findAll(Pageable pageable)

 -> PageRequest.of(1.5)

 

페이징 과정에서 row를 원하는대로 가져오는데 어려움이 있다. ( 쿼리를 수정해서 하면 되겠지만..기본 형태로 가져왔을 때)

5명의 크루를 가져오기 위해 DB에서 LIMIT을 건다면?

id가 5번인 유콩까지 데이터가 반환되기를 기대하고 있다.

DB에서는 offset과 limit 을 사용해서 row 기준으로 데이터를 추출하기 때문에 수달과 꼬재만 반환됩니다.

5명을 기대했는데 2명만 반환되는 문제가 발생하게 됩니다.

 

또 5명을 조회한 데이터에서 수달과 꼬재에 대한 데이터를 만든다고 하면 꼬재에 대한 데이터 중 '아침 일찍 운동' 데이터는 누락 되는 문제가 발생하게 됩니다.

 

<페이징 처리 문제 해결 방법>

JPA가 알아서 해결?

fetch join이랑 페이징을 같이하게 되면 fetch join한 데이터를 일단 전부 다 가져와서

인메모리(RAM, Heap Area)에 넣어놓고 가공하는 작업을 거치게 됩니다.

 

이게 왜 문제인지??

 

일단 쿼리문으로 LIMIT이 안 걸립니다.

에러 문구도 발생합니다.

firstREsult/maxResults specified with collection fetch;

applying in memory!

 => 데이터 전체를 full scan 해서 가져오고, 메모리에서 페이지 처리를 했다는 것
데이터가 100만 건이라고 가정하면 100건을 전부 다 메모리에서 관리하게 되면 메모리 부하가 일어나가 됩니다.

 

이런 문제를 해결하기 위해

ManyToOne일 때 fetch join을 사용해라.

@BatchSize()를 사용하라는 해결방법이 있습니다.

 

 

FetchType을 사용할 수 없는 문제

- Fetch Join을 사용하게 되면 데이터 호출 시점에 모든 연관 관계의 데이터를 가져오기 때문에 FetchType을 Lazy로 해놓는 것이 무의미합니다.

 

 

 

 

 

참고

[Spring Data JPA Tutorial] 10. LazyInitializationException 해결하기 2. @OneToMany (jiniworld.me)

 

 

 

'programming study > B-JPA' 카테고리의 다른 글

JPA 관련 메모  (0) 2023.07.24
JPA fetch 전략 - 왜 LAZY가 성능상 이점인지?  (0) 2023.03.29
JPA란?  (0) 2023.01.17
JPA 기초지식  (1) 2023.01.04
Jackson으로 발생한 순환 참조 문제 해결(JPA Entity)  (0) 2023.01.03