현재 사이드 프로젝트로 진행중인 픽플이 베타서비스 중에 있는데 동작을 확인하기 위해
jpa:show_sql: true
로 설정이 되어있다! 그러던 와중 서버에 이상이 생길 때 마다 로그를 확인하는데 Hibernte가 생성한 sql 문이 너무 많이 찍혀있어서 확인이 어려웠다.(exception만 안잡고 docker logs 하면 백만년..) 아무튼 그래서 해당 문제의 원인을 파악하던 중 N+1 문제가 확인되었고 자세히 알아보려고 한다.
N+1문제 분석, 해결방법, 프로젝트 적용
객체를 DB에서 불러올 때 (JPA Repository를 활용해 인터페이스 메소드를 호출할 때) 1개의 쿼리가 아닌 연관관계 객체를 불러오기 위한 N개의
추가적인 쿼리
가 생성되는 문제
즉, JPA의 Entity 조회시 Query 한번 내부에 존재하는 다른 연관관계에 접근할 때 또 다시 한번 쿼리가 발생하는 비효율적인 상황
예시
게시글이랑 댓글이랑 1대N
으로 연관관계 및 JPA Fetch 전략을 LAZY 전략
으로 해놓은 상황에서 게시글에 해당하는 댓글을 조회할 때 하나의 게시글에 3개의 댓글이 들어있으면 (db에서) article.getComment().getTitle() 상황에서 3번의 추가 쿼리가 발생한다.
⇒ 성능저하 이슈
발생!!
언제?
즉시 로딩
으로 데이터를 가져오는 경우 JPA에서 Fetch 전략(즉시 로딩)을 가지고 해당 데이터의 연관 관계인 하위 엔티티들을 추가 조회 ⇒ N + 1
문제 발생지연 로딩
으로 데이터를 가져온 이후에 가져온 데이터에서 하위 엔티티를 다시 조회하는 경우N + 1
문제 발생왜?
자동화된 쿼리
들이 날아가고 (픽플의 경우 JPA사용) select 문을 통해서만 연관 객체에 접근할 수 있는 RDB
와 연관관계를 통해 레퍼런스를 가지고있으면 메모리 내에서 Random Access를 통해 접근할 수 있는 객체지향 언어
간의 패러다임으로 인해 발생한다.SELECT * FROM article
쿼리만 실행하게 된다. 그렇기 때문에 연관된 엔티티 데이터가 필요한 경우, FetchType으로 지정한 시점에 조회를 추가적으로 호출하게 되는것! ( 즉시로딩일 경우 바로 호출, 지연 로딩일 경우 하위 엔티티에서 호출 시 호출)FETCH JOIN
사실 N+1 자체가 발생하는 이유는 두 테이블이 연결되어 있을 때 한쪽 테이블을 조회하고 다른 테이블을 따로 조회하기 때문에 발생하는 문제다. 이를 해결하기 위해 직접 최적회된 쿼리, 즉 FETCH JOIN
를 직접 작성해주면 된다!
SELECT a FROM article c JOIN FETCH a.comment
fetch join이 무엇인가요?
SQL에서 사용하는 조인의 종류는 아니고 JPQL에서 성능 최적화를 위해 제공하는 기능으로 기본
JOIN
은 연관된 엔티티의 정보를 가져오지 않고, 단순히 조건에 맞는 데이터를 필터링하는 역할을 한다. 반면,JOIN FETCH
는 관련된 엔티티도 함께 조회(Fetching) 하여 데이터베이스에서 한 번에 불러온다.
항상 fetch join으로 해결하면 되나요? NO
아니다! fetch join에도 단점이 존재하기 때문에 특정 상황에선 사용이 불가능
하다
우리가 설정해놓은 FetchType을 사용할 수 없다.
데이터 호출 시점에 모든 연관 관계 데이터를 가져오기 때문에 FetchType을 lazy로 해놓은 것이 무의미
Pagination이 불가능하다
하나의 쿼리문으로 가져오다보니 페이징 단위로 데이터를 가져오는 것이 불가능
실제로 구현하게되면 하나의 쿼리가 나가긴하지만 모든 값을 select해와서 인메모리에 저장하고 application단에서 필요한 페이지만큼만 변환해줌
WARN 79170 --- [ Test worker] o.h.h.internal.ast.QueryTranslatorImpl : HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
@Entity Graph
@EntityGraph 의 attributePaths에 같이 조회할 연관 엔티티명을 적어서 사용한다. ,(콤마)를 통해 여러 개를 줄 수도 있다! Fetch join과 동일하게 JPQL을 사용해 Query문을 작성하고 필요한 연관관계를 EntityGraph에 설정한다.
@EntityGraph(attributePaths = {"comments"})
@Query("select DISTINCT a from Article a")
List<Member> findAllEntityGraph();
이렇게 해주고 결과를 보면 쿼리가 1번만 발생하고 미리 comment와 Order를 조인(outer Join)해서 가져오는 것을 볼 수 있다.
SELECT DISTINCT a.* from article a LEFT JOIN comments c ON a.id = c.article_id
❓ DISTINCT는 무엇인가요?
Fetch Join
과EntityGraph
는 공통적으로 카테시안 곱(Cartesian Product)을 하기 때문에 결과가 늘어나서 중복된 결과가 나타날 수 있다. 이때 해결할 수 있는 방법으로 함께 사용해준다!
카테시안 곱을 한다 ?
: 두 개 이상의 테이블을 조인할 때, 각각의 행이 서로 조합된 모든 경우의 수를 반환하는 현상으로 일대다 관계에서 조인된 테이블의 데이터가 여러 개일 경우, 부모 엔티티의 데이터가 중복되어 나타날 수 있음
DISTINCT
를 추가하여 중복 제거@Query("SELECT DISTINCT a FROM article a JOIN FETCH a.comment")
List<Member> findAllJoinFetch();
////////////////////////////////////////////////////////////////
@EntityGraph(attributePaths = {"comments"})
@Query("select DISTINCT a from article a")
List<Member> findAllEntityGraph();
Set
으로 선언하여 중복 제거@Entity
public class Article {
@Id @GeneratedValue
private Long id;
@OneToMany(mappedBy = "article", fetch = FetchType.LAZY)
private Set<Comments> comments = new LinkedHashSet<>();
}
이때 Set을 사용하게 된다면 HashSet으로는 순서가 중요한 데이터에는 순서를 보장할 수 없기 때문에 LinkedHashSet
을 사용해야한다!!
@BatchSize
JPA 의 성능 개선을 위해 하이버네이트가 제공하는 옵션 중 하나로 org.hibernate.annotations.BatchSize 어노테이션을 사용하면 연관된 엔티티를 조회할 때 지정한 size만큼 SQL의 IN 절을 사용해서 조회한다.
@Entity
public class Article {
@Id @GeneratedValue
private Long id;
@BatchSize(size = 100)
@OneToMany(mappedBy = "article", fetch = FetchType.LAZY) // 지연 로딩
private List<Comment> comment = new ArrayList<Comment>();
}
앞선
FETCH JOIN
의 페이징이 불가능한 문제를 해결할 수 있다.
하지만 주의해야할 점
은 batch size에 fetch join을 걸면 안된다.
fetch join이 우선시되어 적용되기 때문에 batch size가 무시되고 fetch join을 인메모리에서 먼저 진행하여 List가 MultipleBagFetchException가 발생하거나, Set을 사용한 경우에는 Pagination의 인메모리 로딩을 진행한다.
MultipleBagFetchException
fetch join을 할 때 ToMany의 경우 한번에 fetch join을 가져오기 때문에 collection join이 2개이상이 될 경우 너무 많은 값이 메모리로 들어와 exception 발생
collection join이 2개이상?
여러 개의 자식 엔티티를 담고 있는 필드가 또 여러개의 자식 엔티티를 담고 있는 경우
예를 들어 User가 여러 개의 Post와 여러 개의 Comment를 가질 수 있는 경우
모임에 해당하는 공지사항 전체 조회 API에 적용하여 N+1문제를 직접 해결해보겠다
public List<NoticeListGetByMoimResponse> getNoticeListByMoimId(Long moimId, Long guestId) {
boolean isAppliedUser = isUserAppliedToMoim(moimId, guestId);
return noticeList.stream()
.filter(notice -> canAccessNotice(notice, isAppliedUser))
.map(oneNotice -> NoticeListGetByMoimResponse.builder()
.noticeId(oneNotice.getId())
.hostNickName(moim.getHost().getNickname())
.hostImageUrl(moim.getHost().getImageUrl())
.title(oneNotice.getTitle())
.content(oneNotice.getContent())
.date(DateTimeUtil.refineDateAndTime(oneNotice.getCreatedAt()))
.noticeImageUrl(oneNotice.getImageUrl())
.hostId(moim.getHost().getId())
.commentNumber(oneNotice.getComments().size())
.isPrivate(oneNotice.isPrivate())
.build())
.collect(Collectors.toList());
}
List<Notice> findNoticesByMoimIdOrderByCreatedAtDesc(Long moimId);
이렇게 구현을 진행했고 요청 시 아래와 같은 SQL문이 작성됐다.
Hibernate:
select
g1_0.id,
g1_0.created_at,
g1_0.image_url,
g1_0.nickname,
g1_0.updated_at,
g1_0.user_id
from
guests g1_0
where
g1_0.user_id=?
Hibernate:
select
n1_0.id,
n1_0.content,
n1_0.created_at,
n1_0.image_url,
n1_0.is_private,
n1_0.moim_id,
n1_0.title,
n1_0.updated_at
from
notices n1_0
where
n1_0.moim_id=?
Hibernate:
select
m1_0.id,
m1_0.account_list,
m1_0.category_list,
m1_0.created_at,
m1_0.date_list,
m1_0.description,
m1_0.fee,
m1_0.host_id,
m1_0.image_list,
m1_0.is_offline,
m1_0.max_guest,
m1_0.moim_state,
m1_0.question_list,
m1_0.spot,
m1_0.title,
m1_0.updated_at
from
moims m1_0
where
m1_0.id=?
Hibernate:
select
h1_0.id,
h1_0.created_at,
h1_0.description,
h1_0.image_url,
h1_0.link,
h1_0.nickname,
h1_0.updated_at,
h1_0.user_id,
h1_0.user_keyword
from
hosts h1_0
where
h1_0.id=?
Hibernate:
select
g1_0.id,
g1_0.created_at,
g1_0.image_url,
g1_0.nickname,
g1_0.updated_at,
g1_0.user_id
from
guests g1_0
where
g1_0.id=?
Hibernate:
select
ms1_0.id
from
moim_submissions ms1_0
where
ms1_0.moim_id=?
and ms1_0.guest_id=?
fetch
first ? rows only
Hibernate:
select
ms1_0.id,
ms1_0.account_list,
ms1_0.answer_list,
ms1_0.created_at,
ms1_0.guest_id,
ms1_0.moim_id,
ms1_0.moim_submission_state,
ms1_0.updated_at
from
moim_submissions ms1_0
where
ms1_0.moim_id=?
and ms1_0.guest_id=?
Hibernate:
select
c1_0.notice_id,
c1_0.id,
c1_0.comment_content,
c1_0.user_id,
c1_0.created_at,
c1_0.updated_at
from
comments c1_0
where
c1_0.notice_id=?
Hibernate:
select
c1_0.notice_id,
c1_0.id,
c1_0.comment_content,
c1_0.user_id,
c1_0.created_at,
c1_0.updated_at
from
comments c1_0
where
c1_0.notice_id=?
Hibernate:
select
c1_0.notice_id,
c1_0.id,
c1_0.comment_content,
c1_0.user_id,
c1_0.created_at,
c1_0.updated_at
from
comments c1_0
where
c1_0.notice_id=?
Hibernate:
select
c1_0.notice_id,
c1_0.id,
c1_0.comment_content,
c1_0.user_id,
c1_0.created_at,
c1_0.updated_at
from
comments c1_0
where
c1_0.notice_id=?
Hibernate:
select
c1_0.notice_id,
c1_0.id,
c1_0.comment_content,
c1_0.user_id,
c1_0.created_at,
c1_0.updated_at
from
comments c1_0
where
c1_0.notice_id=?
guests
테이블 조회 (1회)
notices
테이블에서 공지사항 조회 (1회)
moims
테이블에서 모임 정보 재조회 (1회)
hosts
테이블 조회 (1회)
guests
테이블에서 다른 게스트 정보 재조회 (1회)
moim_submissions
테이블 조회 (1회)
moim_submissions
테이블에서 다시 조회 (1회)
comments
테이블에서 공지사항에 대한 댓글 수 조회 (5회)
호스트 정보 조회 (
oneNotice.getMoim().getHost().getNickname()
및moim.getHost().getImageUrl()
):
& 댓글 수 조회 (oneNotice.getComments().size()
):각 공지사항(Notice)에 대해 호스트, 댓글 정보를 가져오는데, 이 과정에서 호스트, 댓글 테이블에 대해 쿼리가 여러 번 실행됐다. 이는 각 공지사항에 대해 매번 정보가 개별적으로 조회되기 때문이다
즉, N+1 발생!
근데 왜 HOST는 1번의 쿼리가 나가나요?
호스트(Host)와 모임(Moim)의 관계:
- 모임(Moim)과 호스트(Host)는1:1
관계다. 즉, 하나의 모임에는 하나의 호스트만 존재한다.
-JOIN FETCH m.host
로 모임의 호스트 정보를 한 번만 가져오면 된다. 모임과 관련된 공지사항이 여러 개라 하더라도, 동일한 모임에 속한 공지사항이므로 동일한 호스트 정보가 공유된다.
- 따라서Host
에 대한 쿼리는 한 번만 실행된다.댓글(Comment)과 공지(Notice)의 관계:
- 공지사항 하나에 여러 개의 댓글이 달릴 수 있으므로1:N
관계다.
-LEFT JOIN FETCH n.comments
는 각 공지사항에 달린 댓글을 모두 조회한다. 이 경우 공지사항마다 여러 댓글이 있을 수 있어, 공지사항마다 중복된 댓글 조회가 발생할 수 있다.
- 따라서 공지사항마다 댓글에 대해 여러 쿼리가 발생하여 공지사항 수만큼(N번) 쿼리가 발생한다.
JOIN FETCH
적용 @Query("SELECT DISTINCT n FROM Notice n JOIN FETCH n.moim m JOIN FETCH m.host " +
"LEFT JOIN FETCH n.comments WHERE m.id = :moimId ORDER BY n.createdAt DESC")
List<Notice> findNoticesByMoimId(Long moimId);
위와같이 최적화를 위해 JOIN FETCH
를 적용해준다.
Hibernate:
select
g1_0.id,
g1_0.created_at,
g1_0.image_url,
g1_0.nickname,
g1_0.updated_at,
g1_0.user_id
from
guests g1_0
where
g1_0.user_id=?
Hibernate:
select
distinct n1_0.id,
c1_0.notice_id,
c1_0.id,
c1_0.comment_content,
c1_0.user_id,
c1_0.created_at,
c1_0.updated_at,
n1_0.content,
n1_0.created_at,
n1_0.image_url,
n1_0.is_private,
m1_0.id,
m1_0.account_list,
m1_0.category_list,
m1_0.created_at,
m1_0.date_list,
m1_0.description,
m1_0.fee,
h1_0.id,
h1_0.created_at,
h1_0.description,
h1_0.image_url,
h1_0.link,
h1_0.nickname,
h1_0.updated_at,
h1_0.user_id,
h1_0.user_keyword,
m1_0.image_list,
m1_0.is_offline,
m1_0.max_guest,
m1_0.moim_state,
m1_0.question_list,
m1_0.spot,
m1_0.title,
m1_0.updated_at,
n1_0.title,
n1_0.updated_at
from
notices n1_0
join
moims m1_0
on m1_0.id=n1_0.moim_id
join
hosts h1_0
on h1_0.id=m1_0.host_id
left join
comments c1_0
on n1_0.id=c1_0.notice_id
where
n1_0.moim_id=?
order by
n1_0.created_at desc
Hibernate:
select
m1_0.id,
m1_0.account_list,
m1_0.category_list,
m1_0.created_at,
m1_0.date_list,
m1_0.description,
m1_0.fee,
m1_0.host_id,
m1_0.image_list,
m1_0.is_offline,
m1_0.max_guest,
m1_0.moim_state,
m1_0.question_list,
m1_0.spot,
m1_0.title,
m1_0.updated_at
from
moims m1_0
where
m1_0.id=?
Hibernate:
select
g1_0.id,
g1_0.created_at,
g1_0.image_url,
g1_0.nickname,
g1_0.updated_at,
g1_0.user_id
from
guests g1_0
where
g1_0.id=?
Hibernate:
select
ms1_0.id
from
moim_submissions ms1_0
where
ms1_0.moim_id=?
and ms1_0.guest_id=?
fetch
first ? rows only
Hibernate:
select
ms1_0.id,
ms1_0.account_list,
ms1_0.answer_list,
ms1_0.created_at,
ms1_0.guest_id,
ms1_0.moim_id,
ms1_0.moim_submission_state,
ms1_0.updated_at
from
moim_submissions ms1_0
where
ms1_0.moim_id=?
and ms1_0.guest_id=?
Hibernate:
select
h1_0.id,
h1_0.created_at,
h1_0.description,
h1_0.image_url,
h1_0.link,
h1_0.nickname,
h1_0.updated_at,
h1_0.user_id,
h1_0.user_keyword
from
hosts h1_0
where
h1_0.id=?
Hibernate:
select
c1_0.notice_id,
c1_0.id,
c1_0.comment_content,
c1_0.user_id,
c1_0.created_at,
c1_0.updated_at
from
comments c1_0
where
c1_0.notice_id=?
Guests
테이블 조회 (1회)Notices
, Moims
, Hosts
, Comments
를 조회 (1회)Moims
테이블 재조회 (1회)Guests
테이블에서 ID로 조회 (1회)Moim Submissions
테이블 조회 (1회)Moim Submissions
테이블 재조회 (1회)JOIN FETCH
vs LEFT JOIN FETCH
JOIN FETCH
JOIN FETCH
의 동작: 연관된 엔티티가 반드시 존재해야 한다. 즉, JOIN FETCH
는 조인된 테이블에 일치하는 데이터가 있어야만 결과가 반환된다.LEFT JOIN FETCH
LEFT JOIN
과의 차이: LEFT JOIN FETCH
는 조인된 테이블에 데이터가 없더라도 주 테이블의 데이터를 반환한다. 즉, 왼쪽 테이블(Notice
)의 데이터는 모두 반환하고, 관련된 엔티티(Comments
등)가 없는 경우에는 null
값을 반환한다.LEFT JOIN FETCH
의 동작: 연관된 엔티티가 없어도 상관없이 주 엔티티의 데이터를 가져오고, 관련된 엔티티는 null
로 채워질 수 있다.없어도 되는 관계가 허용될 경우(공지사항에 댓글이 안달려도 될 경우)엔 무조건
LEFT JOIN FETCH
로 N+1을 해결하자!
N+1문제는 데이터가 많이 쌓일수록 성능에 많은 차이를 발생시킬 것이다.
안정성 있는 서버를 위해 신경써서 구현하자.