[JPA] N+1문제 마주하고 해결하기 (FetchJoin)
✅ 개요
프로젝트 중에 N+1문제가 나올것을 예상했었고,
데모일이 끝나자마자 N+1문제를 마주해 대표적인 해결방법을 보고 해결을 해봤지만...
요청 응답에 대해 시간이 비슷하게 나오고, (데이터가 적어서 그랬을거라 생각한다)
성능상 개선된게 확실한지 고민을 하다가
3주가 지난 이제서야 글을 적는다.
✅ 선행지식 : ORM, JPA, Hibernate
N+1문제를 제대로 이해하기 위해선 당연히 사용기술에 대한 이해가 선행되어야한다.
위의 한줄을 위해 내가 직접 ORM, JPA, Hibernate에 대한 글을 작성했다...
아래 글을 보면서 간단하게 이해해보자.
https://jie0025.tistory.com/517
✅ N+1문제
JPA를 사용하면서 개발자는 비즈니스 로직 개발에 집중할 수 있어졌지만
JPA의 특징으로 발생하는 대표적인 문제가 있다.
대부분의 JPA 구현체는 지연 로딩(lazy loading)을 default로 설정하고 있다.
🔎 지연로딩
연관된 엔티티에 대해 필요한 시점에 객체를 가져오는것을 말한다.
불필요한 DB 조회를 줄여 성능을 향상시킬수 있다.
( 지연로딩은 연관관계에 있는 엔티티를 Proxy객체로 조회한다. )
🔎 지연로딩으로 발생하는 N+1 문제
엔티티를 조회할 때 연관된 엔티티를 함께 조회하면서 발생하는 대표적인 성능 문제이다.
1번의 쿼리로 엔티티를 조회하는것을 원했으나, 결과로는 N번 이상의 쿼리가 추가로 발생한다.
🖥 Example
내가 발견한 N+1문제는 다음 요청에 대해서 벌어졌다.
✔️ 특정 장소가 태그된 게시물을 반환하기
http://생략생략/api/v1/amenities/6/bulletin-posts?page=1&size=10
⏺ ERD
게시물은 연관관계가 굉장히 많아서 N+1문제가 발생할 수 있는 최적의 상태였다.
거기에 장소까지 연관되어 있는 요청이니...
bulletin post (게시글) 테이블은 amenity(장소) 테이블과 N:1 관계이다.
bulletin post (게시글) 테이블은 comment(댓글) 테이블과 1:N 관계이다.
bulletin post (게시글) 테이블은 member(회원) 테이블과 N:1 관계이다.
@Getter
@Setter
@Entity
@NoArgsConstructor
public class BulletinPost extends Auditable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long bulletinPostId;
// bulletinPost, member N:1
@ManyToOne
@JoinColumn(name = "MEMBER_ID")
private Member member;
// bulletinPost, amenity N:1
@ManyToOne
@JoinColumn(name = "AMENITY_ID")
private Amenity amenity;
// bulletinPost, comment 1:N
@OneToMany(mappedBy = "bulletinPost", cascade = {CascadeType.PERSIST, CascadeType.REMOVE})
private List<Comment> comments = new ArrayList<>();
----- 생략 ------
}
🖥 기존 코드
QueryResults<BulletinPost> queryResults = queryFactory
.selectFrom(bulletinPost)
.where(bulletinPost.amenity.amenityId.eq(amenityId)
.and(bulletinPost.member.memberStatus.ne(Member.MemberStatus.DELETED)))
.offset(pageRequest.getOffset())
.limit(pageRequest.getPageSize())
.orderBy(bulletinPost.bulletinPostId.desc())
.fetchResults();
특정 장소에 대해 게시물을 반환하도록 요청을 보내고 로그를 확인했다.
🤔 발생하는 쿼리의 개수를 세어보자!
1) 장소 엔티티를 가져온다
2) 해당 장소가 태그된 게시물의 개수를 가져온다
3) 해당 장소가 태그된 8개의 게시물을 가져온다
그리고 반환값(Response)은 다음과 같다.
{
"code": "200",
"message": "장소가 태그된 게시물들 반환",
"data": [
{
"bulletinPostId": 58,
"photoUrl": "s3defaulturl.0.jpg",
"postContent": "안녕하세요!"
"createdAt": "2023-04-02T19:09:23",
"memberId": 17,
"nickname": "jungsus",
"dogName": "jungsudog",
"commentList": null,
"commentCount": 0
},
{
"bulletinPostId": 16,
"photoUrl": "s3defaulturl.1.jpg",
"postContent": "나오지",
"createdAt": "2023-03-21T11:54:58",
"memberId": 5,
"nickname": "aaa",
"dogName": "cookie",a
"commentList": null,
"commentCount": 7
},
{
"bulletinPostId": 15,
"photoUrl": "s3defaulturl.2.jpg",
"postContent": "얜 안나와",
"createdAt": "2023-03-21T11:51:55",
"memberId": 3,
"nickname": "강지은은",
"dogName": "왕밤톨",
"commentList": null,
"commentCount": 0
},
{
"bulletinPostId": 14,
"photoUrl": "s3defaulturl.3.jpg",
"postContent": "얜 안나와",
"createdAt": "2023-03-21T11:51:55",
"memberId": 3,
"nickname": "강지",
"dogName": "작은밤톨",
"commentList": null,
"commentCount": 0
},
{
"bulletinPostId": 13,
"photoUrl": "s3defaulturl.4.jpg",
"postContent": "얜 안나와",
"createdAt": "2023-03-21T11:51:54",
"memberId": 3,
"nickname": "강지",
"dogName": "작은밤톨",
"commentList": null,
"commentCount": 0
},
{
"bulletinPostId": 12,
"photoUrl": "s3defaulturl.5.jpg",
"postContent": "얜 안나와",
"createdAt": "2023-03-21T11:51:53",
"memberId": 3,
"nickname": "강지",
"dogName": "작은밤톨",
"commentList": null,
"commentCount": 0
},
{
"bulletinPostId": 11,
"photoUrl": "s3defaulturl.6.jpg",
"postContent": "얜 안나와",
"createdAt": "2023-03-21T11:51:52",
"memberId": 3,
"nickname": "강지",
"dogName": "작은밤톨",
"commentList": null,
"commentCount": 0
},
{
"bulletinPostId": 10,
"photoUrl": "s3defaulturl.7.jpg",
"postContent": "얜 안나와",
"createdAt": "2023-03-21T11:26:28",
"memberId": 4,
"nickname": "강지은은",
"dogName": "왕밤톨",
"commentList": null,
"commentCount": 0
}
],
"pageInfo": {
"page": 1,
"size": 10,
"totalElements": 8,
"totalPages": 1
}
}
위의 응답을 살펴보면 8개의 게시글에 존재하는 총 댓글은 7개이다.
4) 이 각각의 댓글에 대해서도 쿼리가 발생한다. (by 지연로딩)
select
comments0_.bulletin_post_id as bulletin5_2_0_,
comments0_.comment_id as comment_1_2_0_,
comments0_.comment_id as comment_1_2_1_,
comments0_.bulletin_post_id as bulletin5_2_1_,
comments0_.comment_content as comment_2_2_1_,
comments0_.created_at as created_3_2_1_,
comments0_.member_id as member_i6_2_1_,
comments0_.modified_at as modified4_2_1_,
member1_.member_id as member_i1_5_2_,
member1_.created_at as created_2_5_2_,
member1_.modified_at as modified3_5_2_,
member1_.about_me as about_me4_5_2_,
member1_.address as address5_5_2_,
member1_.dog_gender as dog_gend6_5_2_,
member1_.dog_name as dog_name7_5_2_,
member1_.email as email8_5_2_,
member1_.member_status as member_s9_5_2_,
member1_.nickname as nicknam10_5_2_,
member1_.password as passwor11_5_2_,
member1_.profile_url as profile12_5_2_
from
comment comments0_
left outer join
member member1_
on comments0_.member_id=member1_.member_id
where
comments0_.bulletin_post_id=?
게시글 8개는 총 4명의 회원이 작성했다.
5) 회원 4명 조회에 대한 쿼리가 4번 생성된다. (by 지연로딩)
select
member0_.member_id as member_i1_5_0_,
member0_.created_at as created_2_5_0_,
member0_.modified_at as modified3_5_0_,
member0_.about_me as about_me4_5_0_,
member0_.address as address5_5_0_,
member0_.dog_gender as dog_gend6_5_0_,
member0_.dog_name as dog_name7_5_0_,
member0_.email as email8_5_0_,
member0_.member_status as member_s9_5_0_,
member0_.nickname as nicknam10_5_0_,
member0_.password as passwor11_5_0_,
member0_.profile_url as profile12_5_0_,
roles1_.member_member_id as member_m1_6_1_,
roles1_.roles as roles2_6_1_
from
member member0_
left outer join
member_roles roles1_
on member0_.member_id=roles1_.member_member_id
where
member0_.member_id=?
정말 많은 쿼리가 생성되는것을 눈으로 확인할 수 있다.
추가적인 쿼리의 발생으로 DB와의 네트워크 트래픽 및 DB부하의 증가로
성능 저하에 치명적일 수 있다!!!
😆 N+1문제를 해결하자!
✔️ 엔티티 연관관계 매핑에서 지연로딩을 사용해야하는 이유
연관관계를 매핑할 때 무조건 함께 조회를 시켜버리면
필요없는 경우에도 데이터가 조회되므로 성능상 더욱 큰 문제가 발생할 수 있다.
따라서 Entity 상에서의 연관관계 매핑은 기본 전략인 OneToMany의 default 전략인 Lazy를 지켜야한다.
여러번 조회하는게 문제이면 처음부터 함께 가져오는 조인을 사용하는게 가장 간단하다!
👩💻 Fetch Join
JPQL에서 성능 최적화를 위해 제공하는 조인의 종류
일반 조인 : 연관된 엔티티를 함께 조회하지 않는다. (조인은 하지만 데이터가 조회되지 않음)
Fetch 조인 : 데이터를 함께 조회! 가져온다!!
QueryResults<BulletinPost> queryResults = queryFactory
.selectFrom(bulletinPost)
.leftJoin(bulletinPost.member, member).fetchJoin()
.leftJoin(bulletinPost.comments, comment).fetchJoin()
.where(bulletinPost.amenity.amenityId.eq(amenityId))
.offset(pageRequest.getOffset())
.limit(pageRequest.getPageSize())
.orderBy(bulletinPost.bulletinPostId.desc())
.fetchResults();
✔️ BulletinPost와 Member간 LeftJoin을 사용한 이유
bulletinPost와 member는 일대다 관계이고, member가 필수로 값이 있을것이다.
그래서 이경우 굳이 Left Join을 써야하나? 하는 생각이 들었었다.
만약 만약 진짜 만약 member에 대해 null데이터가 생기는 경우가 발생한다면..?
데이터의 일관성을 유지하는데 Left Join을 쓰는게 더 일반적이라고 한다.
(어디서 본건데 아직 잘 모르겠다... 고민은 계속 해봐야할듯)
✔️ BulletinPost와 Comment간 LeftJoin을 사용한 이유
게시물에서 댓글은 필수값이 아니여서 NULL이어도 되므로 LEFT JOIN을 선택했다.
FetchJoin을 사용함으로써 지연로딩을 피하고, 필요한 엔티티와 연관된 엔티티를 한번의 쿼리로 모두 조회할 수 있다.
같은 요청에 대해 쿼리가 1개만 발생하는것을 볼 수 있었다!
📣 FetchJoin을 사용할 때 주의할점 : MultipleBagFetchException의 발생 가능성
다대일(ManyToOne) 관계에서 여러 개의 일대다(OneToMany) 관계가 존재하는 경우에 MultipleBagFetchException이 발생할 수 있다.
https://jojoldu.tistory.com/m/457?category=637935
이 때는 일대다 관계 중에서 하나의 자식 테이블에 대해서만 Fetch Join을 사용할 수 있다.
내 코드에선, 일대다 관계가 1개만 존재하기 때문 (게시글과 댓글)에 문제가 발생하지 않았다.
📣 FetchJoin을 사용하면서 고려해볼 것 : hibernate default_batch_fetch_size
OneToMany 자식 테이블의 경우에는 데이터가 많을 경우 Fetch Join으로 인해 성능 저하가 발생할 수 있다.
이 경우에는 Hibernate의 default_batch_fetch_size 옵션을 활용하여 성능 개선을 할 수 있다고하는데 (.... 좀더 알아봐야겠다)
-> 한 번의 쿼리로 지정한개수의 엔티티를 조회하도록 설정
-> 설정값이 너무 크면 성능상 이슈가 발생할 수 있다고한다...
+ 연관된 엔티티 수가 많을 경우 Fetch Join으로 인해 성능 저하가 발생할 수 있기 때문에, 상황에 따라서는 Lazy Loading을 사용하는 것이 더 효율적일 수 있다.
항상 어떻게 문제를 해결할지, 어떤게 효율적인 방법인지, 무엇이 최선의 선택인지를 고민하며 개선하는 작업을 해야겠다.
다음에는 N+1문제를 해결하는 다른 방법도 찾아보고 가져오겠다!