프로젝트를 진행하던 중 N+1에 대해서 발생하는지 체크를 해보게 되었다
하지만 모든 @ManyToOne 연관 관계를 가진 데이터에 LAZY(지연 로딩) 처리를 해두어서 발생되는 곳을 찾지 못했다
하지만 분명 언젠가는 맞이할 문제이고 또한 해결법에 대해서 알아야하기에
테스트 코드로 N+1 문제를 일으키고 그에 대한 해결법을 정리해보고자 한다
[ 참고 블로그 ]
https://dev-coco.tistory.com/165
[JPA] N+1 문제 원인 및 해결방법 알아보기
JPA를 사용하면 자주 만나게 되는 것이 N + 1 문제이다. N + 1 문제는 성능에 큰 영향을 줄 수 있기 때문에 N + 1 문제가 무엇이고 어떤 상황에 발생되는지, 어떻게 해결하면 되는지에 대해 알아보고
dev-coco.tistory.com
JPA N+1 이슈는 무엇이고, 해결책은 무엇인가요?
1번 조회해야할 것을 N개 종류의 데이터 각각을 추가로 조회하게 되서 총 N+1번 DB조회를 하게 되는 문제이다. 즉, JPA의 Entity 조회시 Query 한번 내부에 존재하는 다른 연관관계에 접근할 때 또 다시
velog.io
1. N+1이란 무엇인가 ?
N+1이란 말 그대로 1번의 쿼리를 조회하려고 하였지만 의도와는 다르게 연관 된 모든 N개의 쿼리가 추가 진행되는 것을 의미한다.
즉, 의도치 않은 N번의 쿼리 + 의도한 1번의 쿼리
[ N + 1 ]
2. N+1이 일어 나는 이유는 ?
N+1은 JPA에서 N+1은 1:N 혹은 N:1 조회를 할 때 일어난다.
그렇다면 발생하는 이유는 무엇일까?
예를 들어보자
가게(Shop) 엔티티에 유저(User) 데이터가 @ManyToOne 연관관계로 들어있다고 가정해보자.
JPA 로 만약 Shop을 조회할 때 안에 있는 User 데이터도 가져와야 하기에
Shop을 조회한 뒤 User도 가져오는 쿼리가 2번 발생하는 것을 확인할 수 있다.
이러한 것들이 N+1 문제이다
N+1이 일어나는 경우
(1) 연관 관계에 지연로딩(LAZY)이 아닌 즉시 로딩(EAGER)으로 조회하는 경우
(2) 지연 로딩으로 데이터를 조회한 뒤 연관 관계가 있는 데이터를 다시 조회할 때
발생하게 된다
3. 즉시 로딩(EAGER) / 지연 로딩(LAZY) 란?
(1) 즉시 로딩(EAGER)
- 즉시 로딩은 엔티티를 조회 할 때 연관된 엔티티를 전부 조회해 온다
1. 데이터를 조회
2. JPA에서 Fetch 전략을 가지고 해당 데이터의 연관 관계인 하위 엔티티들을 추가 조회
3. 위 과정으로 인해 N+1 문제가 발생
(2) 지연 로딩(LAZY)
- 지연 로딩은 엔티티를 조회 할 때 하위 엔티티들을 조회하지 않고 필요한 시점에 조회한다
1. 데이터를 조회
2. JPA에서 Fetch 전략을 가지지만, 지연 로딩이기에 추가 조회는 하지 않음
3. 하지만, 하위 엔티티를 가지고 추가 작업 시 추가 조회가 발생하여 결국 N + 1 문제가 발생 하게 됨
4. N + 1 예시
N + 1을 보기 전 현재 지연 로딩(LAZY) 되어 있는 상태에서 해당 엔티티를 조회해 보았다
[ 연관 관계 엔티티 상태 ]
@JsonIgnore
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
@JsonIgnore
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "shop_id")
private Shop shop;
[ 조회 쿼리문 ]
select
reservatio0_.reservation_id as reservat1_9_,
...
from
reservation reservatio0_
연관 관계의 fetch를 즉시 로딩(EAGER)로 변경한 뒤 조회 쿼리를 날려보자
[ 즉시 로딩(EAGER)로 변경 된 연관 관계 ]
@JsonIgnore
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "user_id")
private User user;
@JsonIgnore
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "shop_id")
private Shop shop;
[ 조회 쿼리문 ]
select
reservatio0_.reservation_id as reservat1_9_,
...
from
reservation reservatio0_
---------- reservation table
select
user0_.user_id as user_id1_13_0_,
...
from
user user0_
where
user0_.user_id=?
---------- user table
select
shop0_.shop_id as shop_id1_10_0_,
...
from
shop shop0_
where
shop0_.shop_id=?
---------- shop table
보다 시피 지연 로딩(LAZY)에서는 일단 실제 조회 쿼리만 조회하는 것을 확인 할 수 있다
- 1개의 조회 요청, 1개의 쿼리
하지만 즉시 로딩(EAGER)로 엔티티 연관관계의 fetch를 변경하자 모든 쿼리를 즉시 조회하는 것이 확인 된다
- 1개의 조회 요청, 연관 된 모든 테이블 조회 N + 1개의 쿼리
그렇다면 지연 로딩을 사용하면 무조건 안전한 것일까?
다음 예시를 보자.
현재 연관관계의 엔티티는 지연 로딩(LAZY) 상태이다.
@JsonIgnore
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
@JsonIgnore
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "shop_id")
private Shop shop;
위와 같이 Reservation 테이블을 조회했을 때는 한 건의 쿼리만 실행하지만,
만약 연관 관계를 가지고 있는 User 테이블을 조회하게 된다면 어떻게 될까 ?
List<Reservation> all =
reservationRepository.findAll();
System.out.println("============= > : " + all.get(0).getUser());
아래와 같이 Reservation 테이블을 조회 한 뒤 User 테이블을 조회하는 것을 알 수 있다.
select
reservatio0_.reservation_id as reservat1_9_,
...
from
reservation reservatio0_
-------------- reservation table
select
user0_.user_id as user_id1_13_0_,
...
from
user user0_
where
user0_.user_id=?
-------------- user table
즉시 로딩(EAGER)의 경우 모든 테이블을 한번에 조회하기에 처음부터 N + 1 쿼리를 조회하였지만,
지연 로딩(LAZY)의 경우 처음에 해당 테이블만 조회를 하지만 결국 User를 조회하면서 N + 1이 조회되는 모습을 확인할 수 있다
5. N + 1 해결 방법
(1) Fetch Join(패치 조인)
첫번 째 해결 방법에는 패치 조인을 사용하는 방법이 있다.
N + 1이 발생하는 이유는 간단하게 테이블을 조회할 때 연관 된 모든 테이블을 같이 조회해오기 때문에 생기는 문제이다.
그렇다면 해결 하기 위해서는 해당 테이블을 함께 가져오면 문제가 해결 될 것이고
패치 조인을 활용하면 JOIN으로 함께 조회해올 수 있다.
아래의 방법을 사용하면 패치 조인으로 N + 1 문제를 해결 할 수 있다.
[ join fetch Query ]
@Query("select DISTINCT o from Reservation o join fetch o.user")
List<Reservation> findAllJoinFetch();
[ 실제 쿼리 조회문 ]
select
distinct reservatio0_.reservation_id as reservat1_9_0_,
user1_.user_id as user_id1_13_1_,
reservatio0_.reservation_message as reservat2_9_0_,
...
user1_.city as city2_13_1_,
...
from
reservation reservatio0_
inner join
user user1_
on reservatio0_.user_id=user1_.user_id
쿼리에서 join fetch로 user를 같이 조회해오면
아래의 쿼리와 같이 join 문을 통해서 한번에 데이터를 가져오기에 N + 1 문제를 해결 할 수 있다
- Fetch Join의 단점
1. 페이징 사용 불가
2. 1:N 일때 사용 불가
3. Alias (별칭) 사용 불가
4. 매번 쿼리 작성 필
(2) @EntityGraph
[ @EntityGraph 사용법 ]
@EntityGraph(attributePaths = {"user"})
@Query("select DISTINCT o from Reservation o")
List<Reservation> findAllEntityGraph();
[ 실제 쿼리 조회문 ]
select
distinct reservatio0_.reservation_id as reservat1_9_0_,
user1_.user_id as user_id1_13_1_,
reservatio0_.reservation_message as reservat2_9_0_,
...
user1_.city as city2_13_1_,
...
from
reservation reservatio0_
left outer join
user user1_
on reservatio0_.user_id=user1_.user_id
Fetch Join과는 다르게 outer join으로 데이터를 조회하는 것을 볼 수 있다
(inner join이 outer join보다 성능 최적화에 유리하다)
- @EntityGraph 단점
1. 어노테이션 작성 필요 및 쿼리 작성 필요
6. Fetch Join 과 @EntityGraph 를 사용할 때 주의점
Fetch Join과 EntityGraph를 사용하게 되면 카테시안 곱(Cartesian Product)가 발생하여 중복이 생길 수 있다
* 카테시안 곱 : 두 테이블 사이에 유효 join 조건을 적지 않았을 때 해당 테이블에 대한 모든 데이터를 전부 결합하여 테이블에 존재하는 행 갯수를 곱한만큼의 결과 값이 반환되는 것
해결 방법은 2가지가 있다
(1) 중복 제거(DISTINCT)
카테시안 곱이 발생하는 이유는 결국 중복 데이터가 발생하는 것이다
@Query("select DISTINCT o from Reservation o join fetch o.user")
List<Reservation> findAllJoinFetch();
@EntityGraph(attributePaths = {"user"})
@Query("select DISTINCT o from Reservation o")
List<Reservation> findAllEntityGraph();
그렇기에 DISTINCT를 사용하여 중복을 제거해주면 이런한 문제를 해결할 수 있다
(2) Set 사용
Set은 기본적으로 중복을 허용하지 않는다
그렇기에 @OneToMany로 연관 관계를 설정한 경우 사용 가능하다
@OneToMany
Set<Reservation> reservationSet = new LinkedHashSet<>();
기본적으로 Set는 중복 허용 X, 순서 보장 X 이지만
LinkedHashSet을 사용하면 순서대로 데이터가 저장된다
TreeSet은 오름차순으로 Set을 저장한다
7. 결론
결과적으로 N + 1 이라는 문제가 발생하는 이유는 연관된 테이블의 데이터를 조회해오다보니
연관 테이블의 조회 쿼리가 추가적으로 실행되기에 생기는 문제이다
이러한 문제를 해결할 수 있는 방법으로는 Fetch Join과 @EntityGraph를 사용하면 innerJoin, outerJoin을 통해서
연관 테이블을 한번에 조회하는 쿼리를 실행함으로써 해결 가능하다
단, Fetch Join과 @EntityGraph를 사용할 때 주의점이 있다.
바로 카테시안 곱이 발생하여 중복된 데이터가 나올 수 있다.
그렇기에 DISTINCT를 통해서 중복을 제거해주거나, 1:N 관계의 경우 Set을 통해서 중복을 제거해주면 된다
개인적으로 N + 1은 결국 JPQL에 너무 의존적인 조회문을 아무 생각없이 날릴 때 올 수 있는 문제라고 생각한다. 설계를 조금 더 치밀하게하고, 조회를 할 때 QueryDsl을 사용하여 필요한 데이터만 조회하는 DTO를 생성하여 조회하는 방법도 좋은 것 같다.
'Spring' 카테고리의 다른 글
AOP란 무엇일까 ? (0) | 2024.01.22 |
---|---|
Redis가 무엇일까 ? 어떻게 사용할 수 있을까 ? (0) | 2024.01.14 |
Spring 로그인 / Session : JWT 차이점과 장단점 (0) | 2024.01.10 |
배치와 스케줄링에 대하여... (2) | 2024.01.07 |
Optional 바르게 사용해보자! (0) | 2023.12.03 |