[개인 프로젝트] n+1를 처음 마주친 사람
ERD를 또 수정했다.
기능을 생각하다보니, 내가 일대다(1:N)라고 생각했던 관계가 다대다(N:M)인 경우가 있었다.
구현을 위해 N:M을 1:N, 1:M으로 나눌 수 있도록 중간 테이블을 추가하였다.
createdDate와 modifiedDate 같은 기본 부가 정보는 김영한 강사님의 Auditing 파트 강의를 참고해 BaseEntity로 만들어 자동으로 생성도록 했다.
N+1 문제
드디어 마주한 유명한 문제. n+1 문제를 만나게 되었다.
1:n 연관관계에서의 @ManyToOne lazy loading 방식을 이용하면, 지연로딩의 특성상 연관된 데이터가 필요할 때가 되어서야 쿼리를 호출해서 부르기 때문에 연관관계에 엮어있는 필드 개수만큼 쿼리가 추가로 나간다고 하여 n+1 문제라고 이름지어졌다.
프로젝트를 진행하다보니, 자연스럽게 문제해결의 필요성을 느끼게 되었다. 전에 블로그 개발할 때는 JPA도, 성능도, SQL도 몰라서 쿼리 여러개 나가도 그냥 그런가보다 했었는데, 역시 뭘 알아야 보인다고.... 이번 프로젝트는 개발을 하다보니 테스트 데이터 2개 뿐이고 가져오려고 하는 로우가 하나일 뿐인데도 쿼리가 4~6개 나가는 걸 보면서 프로젝트 규모가 커지면 성능에 치명적인 영향을 주겠다는 생각이 안들 수가 없었다.
대표적인 해결 방법으로는
1. fetch join 사용하기
2. @EntityGraph 이용하기
가 있다. 어떤 것이 더 나을지는 때에 따라 다르다.
1. fetch join을 사용하는 것이 나은 경우
- 단순한 쿼리에서 연관 엔티티 한두개 가져오는 경우
- jpql 직접 작성하여 join을 다루고 싶은 경우
- 페이징이 필요 없는 경우
라고 정리할 수 있을 것 같다.
fetch join을 사용하면, 페이징이 불가능하다는 말을 많이 들었다. 연관 엔티티가 여러 개일 경우, 로우 수가 기하급수적으로 늘어나면서 페이징이 예상과 벗어나게 되어 불가능해진다. 이럴때는 dto로 중복을 제거하거나, batch size 설정을 같이 사용해야 한다.
2. @EntityGraph 를 사용하는 것이 나은 경우
- Spring Data Jpa에서 메서드 쿼리를 사용하는 경우
@EntityGraph(attributes = {"team"})
List<Member> findAll();
@Query를 이용해 쿼리를 직접 명시해서 처리하는 것이 아니라, 위 코드처럼 메서드명으로 처리할 때는 @EntityGraph가 낫다.
- 부가적으로 연관 엔티티를 조회하는 경우
동일한 repository인데, 어떤 요청에는 연관 엔티티가 필요하지만, 다른 요청에는 필요 없는 경우에도 도입할 수 있다.
- 페이징이 필요한 경우(XToOne 엔티티만 가능)
@ManyToOne, @OneToOne 관계일 때 EntityGraph 와 페이징을 사용할 수 있다.
@XToMany에서는 여전히 페이징이 문제가 된다.
정리
요약하자면
- 단순/성능 튜닝 => fetch join
- 재사용성/가독성 => @EntityGraph
를 사용하면 좋다.
한발 더 나아가, 내가 만난 DTO 변환 시의 N+1 문제
조회를 하는 경우, DTO에 담아 정보를 내보내는데, 이때 DTO에 담기 위해 getter를 부를 때도 쿼리가 나간다.
내 상황의 경우, MemberShelfDto 내부에 MemberDto, ShelfDto를 각각 넣어 반환해야 하는 구조였다. Dto 안에 Dto 를 넣어서 반환함으로써 해결하는 방식으로 구상하면서 Querydsl을 도입하게 되었다.
영한님 강의 30퍼센트 할 때 Querydsl까지 살걸 그랬다...고민하다가 돈없어서 안샀는데 흑
MemberShelfDto
public class MemberShelfDto {
private Long id;
private MemberDto member;
private ShelfDto shelf;
}
@QueryProjection
Querydsl에서는 생성자 위에 @QueryProjection 이라는 어노테이션을 붙이면, Querydsl이 다음 빌드 시에 해당 생성자를 인식하고 그에 알맞게 QxxxDto라는 클래스를 생성한다.
@Data
public class MemberShelfDto {
private Long id;
private MemberDto member;
private ShelfDto shelf;
@QueryProjection
public MemberShelfDto(Long id, MemberDto member, ShelfDto shelf) {
this.id = id;
this.member = member;
this.shelf = shelf;
}
}
이렇게 되면, querydsl 쿼리를 생성할 때 QMemberShelfDto 라는 키워드를 사용할 수 있게 된다.
MemberDto와 ShelfDto도 내부에 넣어주어야 하므로, 두 DTO의 생성자에도 @QueryProjection 어노테이션을 추가해주었다.
Querydsl을 이용해 dto안에 dto 생성하기
@Repository
@RequiredArgsConstructor
public class MemberShelfCustomRepositoryImpl implements MemberShelfCustomRepository{
private final JPAQueryFactory jpaQueryFactory;
@Override
public Page<MemberShelfDto> findAllOwnShelves(String username, Pageable pageable) {
QMemberShelf ms = memberShelf;
QMember m = QMember.member;
QShelf s = QShelf.shelf;
List<MemberShelfDto> results = jpaQueryFactory.select(
new QMemberShelfDto(
ms.id
, new QMemberDto(m.username)
, new QShelfDto(s.shelfName, s.shelfMemo, new QMemberDto(s.creator.username)))
).from(ms)
.where(ms.member.username.eq(username),
ms.shelf.creator.username.eq(username))
.join(ms.member, m)
.join(ms.shelf, s)
.fetch();
Long total = jpaQueryFactory
.select(ms.count())
.from(ms)
.where(ms.member.username.eq(username),
ms.shelf.creator.username.eq(username))
.join(ms.member, m)
.join(ms.shelf, s)
.fetchOne();
return new PageImpl<>(results, pageable, total);
}
}
다소 코드가 길어보이지만, 잘 뜯어보면 그냥 new 연산자 사용해서 Dto 생성하듯이 생성해주면 되는 것을 볼 수 있다. 정말 신기하다.
결과: MemberShelfDto 하나 당 쿼리 4개 -> 쿼리 2개
(@QueryProjection 도입 전)
MemberShelfDto하나 당 쿼리가 4개 나왔었다.
1. MemberShelf 조회하는 쿼리
2. MemberDto를 만들기 위한 Member 조회
3. ShelfDto를 만들기 위한 Shelf조회
4. count 쿼리
(@QueryProjection) 도입 후
MemberShelfDto를 얻기 위한 쿼리 단 2개.
1. MemberShelf 조회
2. count 쿼리
마무리
성능 최적화의 쾌감을 처음으로 느껴보게 되었다.
이것이..백엔드..?
어색했던 java와 친해지고 백엔드와 점점 깊어지는 사이가 되는 것 같아 기분이 좋다.
내 손으로 뚝딱 뚝딱 만드는 건 정말 재밌다.