티스토리 뷰

ORM/JPA

[JPA] 조회 성능 최적화

mandykr 2023. 4. 28. 13:22

목차

1. 엔티티 단건 조회

2. 엔티티 목록 조회

3. 컬렉션 조회

 

 

N + 1

JPA의 N + 1 문제는 즉시로딩과 지연로딩 두 경우 모두 발생할 수 있다.

  • 즉시로딩은 JPQL 을 사용해 엔티티를 조회할 때 쿼리가 실행된 후 즉시로딩으로 연관 관계가 매핑된 엔티티를 조회하는 쿼리가 추가로 실행된다.
  • 지연로딩은 엔티티를 조회할 때 연관 관계가 매핑된 엔티티는 프록시로 조회하지만 프록시의 필드에 접근할 때 해당 엔티티를 조회하는 쿼리가 추가로 실행된다.

 

다음은 지연로딩으로 매핑된 엔티티들의 연관 관계 종류에 따라 발생하는 N + 1 문제와 최적화 과정이다.

지연로딩은 JPQL로 엔티티를 조회해도 연관된 엔티티를 추가로 조회하지 않기 때문에 N + 1 문제가 발생하지 않는다.

하지만 엔티티의 목록을 조회할 때 연관된 엔티티나 컬렉션에 대해서 N + 1 문제가 발생한다.

 

연관관계

@Entity
public class Mate {
    @Id
    private UUID id;

    @ManyToOne(optional = false, fetch = FetchType.LAZY)
    private Project project;

    @OneToOne(fetch = FetchType.LAZY)
    private Reviewer reviewer;

    @OneToMany(mappedBy = "mate")
    private List<Review> reviews = new ArrayList<>();
}

 

 

1. 엔티티(Mate) 단건 조회

Mate 단건을 조회하는 경우 Project, Reviewer, Review의 필드에 접근할 때 추가 쿼리가 실행된다.

 

Mate 조회

mateRepository.findById(mateId);

 

[Mate 조회 쿼리]

mateId 를 파라미터로 사용한다.

select *
from mate
where id = 'b061adb9-76a1-484b-beab-4f8cf4d7e798';

 

[Project 조회 쿼리 (ManyToOne)] - 추가 쿼리

Mate 조회 쿼리의 결과로 project_id 를 파라미터로 사용한다.

select *
from project
where id = [mate의 project_id 외래키];

 

[Reviewer 조회 쿼리 (OneToOne)] - 추가 쿼리

Mate 조회 쿼리의 결과로 reviewer_id 를 파라미터로 사용한다.

select *
from reviewer
where id = [mate의 reviewer_id 외래키];

 

[Review 목록 조회 쿼리 (OneToMany)] - 추가 쿼리

mateId로 review 목록을 조회한다.

select *
from review
where mate_id = [mate_id];

 

1개의 Mate를 조회하는데 추가로 3개의 쿼리가 실행되었다.

문제는 여러개의 Mate를 조회하는 경우 Mate의 갯수만큼 3개씩의 쿼리가 추가로 실행되게 된다.

 


2. 엔티티 목록 조회

Mate 목록을 조회하는 경우 Mate의 갯수만큼 Project, Reviewer, Review의 필드에 접근할 때 추가 쿼리가 실행된다.

모든 Mate를 조회한다.

mateRepository.findAll();
쿼리 실행 횟수 결과 row
Mate 조회 1 10
Project 조회 10 10
Reviewer 조회 10 10
Review 조회 10 0+

 

 

[ 최적화 방법 ① ] 페치 조인

JPQL 페치 조인을 사용하면 SQL JOIN을 사용해서 페치 조인 대상까지 함께 조회한다.

연관 관계 엔티티를 지연로딩으로 설정하고 필요한 경우에만 함께 조회하기 위해 페치 조인을 사용할 수 있다.

 

엔티티(Mate) 목록 조회

@Query("select distinct m" +
        " from Mate m" +
        " join fetch m.project p" +
        " join fetch m.reviewer r" +
        " left join fetch m.reviews s")
List<MateReviewResponse> findAllWithReviews();

ToOne 관계에서는 Mate의 중복이 발생하지 않지만 OneToMany 관계(Mate - Review)에서는 DB에서 조회할 때 Review 갯수만큼 Mate가 중복된다. 이때는 distinct 키워드를 사용해 중복을 제거해준다. DB에서는 중복된 상태로 가져오지만 메모리에서 Mate의 중복을 제거한다.

 

fetch 조인 쿼리

select distinct ...
from mate m
inner join project p
        on m.project_id = p.id 
inner join reviewer r
        on m.reviewer_id = r.id 
left outer join review w
        on m.id = w.mate_id;

 

하지만 필요한 경우에만 페치 조인을 사용하는 방법이 결국 프리젠테이션 계층의 영향이 데이터 접근 계층에 까지 영향을 주게된다. 화면별로 조회 쿼리를 만들어 사용하면 화면에 맞춘 리포지토리의 메소드가 증가할 수 있다.

그리고 페치 조인으로 컬렉션을 조회할 때는 페이징이 불가능하다. 

 

3. 컬렉션 조회

OneToMany 관계(Mate - Review)가 있을때는 페치 조인을 이용해 페이징 조회가 불가능하다.

@Query(value = "select m" +
        " from Mate m" +
        " join fetch m.project p" +
        " join fetch m.reviewer r" +
        " left join fetch m.reviews s" +
        " order by p.id",
        countQuery = "select count(m.id) from Mate m")
Page<Mate> findAllWithReviews(PageRequest page);

 

Mate 하나에 Review가 여러개의 row 로 매핑되기 때문에 DB에서 조회하면 Review의 갯수 만큼 조회된다.

Mate 를 기준으로 페이징을 해야 하지만 Review 를 기준으로 데이터가 조회되기 때문에

컬렉션을 페치 조인 할 때 페이징을 사용하면 하이버네이트는 모든 데이터를 메모리에 올려서 페이징을 만들게 된다.

따라서 컬렉션 페치 조인은 메모리 문제가 발생할 수 있다.

QueryTranslatorImpl:HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!

 

 

[최적화 방법 2] 배치 사이즈로 지연로딩

ToOne, OneToMany(컬렉션) 관계가 함께 있을 때 페이징을 하기 위해 ToOne 은 페치 조인을 사용하고

컬렉션은 지연 로딩을 사용해야 한다.

// repository
@Query(value = "select m" +
        " from Mate m" +
        " join fetch m.project p" +
        " join fetch m.reviewer r" +
        " order by p.id",
        countQuery = "select count(m.id) from Mate m")
Page<Mate> findAllMates(PageRequest page);

// service
Page<MateReviewResponse> response = mateRepository.findAllMates(PageRequest.of(0, 3))
        .map(MateReviewResponse::of);

// dto
public static MateReviewResponse of(Mate mate) {
    return new MateReviewResponse(
            mate.getId(),
            mate.getProject().getCodeStore().getName(),
            mate.getReviewer().getName(),
            mate.getReviews().stream()
                    .map(ReviewResponse::of)
                    .toList()
    );
}

 

MateReviewResponse DTO에서 Review의 필드에 접근할 때 지연로딩이 발생하는데

컬렉션을 지연 로딩할 경우 Mate의 갯수만큼 Review 목록을 조회하는 쿼리가 추가로 실행되지만

Review 프록시 객체를 한꺼번에 설정한 size(hibernate.default_batch_fetch_size , @BatchSize) 만큼 IN 쿼리로 조회할 수 있다. 

select *
from review
where mate_id in (
    ?, ?, ?
);

 

글로벌 단위 설정

# application.properties
spring.jpa.properties.hibernate.default_batch_fetch_size=100

 

엔티티 단위 설정

@BatchSize(size = 100)
@OneToMany(mappedBy = "mate")
private List<Review> reviews = new ArrayList<>();

 

 

[최적화 방법 3] QueryDSL 사용

QueryDSL 라이브러리를 사용하면 페이징과  DTO 직접 조회를 편리하게 사용할 수 있다.

public Page<MateReviewResponse> findAllMates(Pageable page) {
    List<MateReviewResponse> content = queryFactory
            .from(mate)
            .join(mate.project, project)
            .join(mate.reviewer, reviewer)
            .leftJoin(mate.reviews, review)
            .offset(page.getOffset())
            .limit(page.getPageSize())
            .transform(groupBy(mate.id)
                    .list(bean(MateReviewResponse.class,
                            mate.id,
                            project.codeStore.name.as("projectName"),
                            reviewer.name.as("reviewerName"),
                            list(bean(ReviewResponse.class,
                                    review.id,
                                    review.status)
                            ).as("reviews")))
            );

    JPAQuery<Long> countQuery = queryFactory
            .select(mate.count())
            .from(mate);

    return PageableExecutionUtils.getPage(content, page, countQuery::fetchFirst);
}

 

 

728x90

'ORM > JPA' 카테고리의 다른 글

[JPA] 8. JPQL(2)  (0) 2022.04.14
[JPA] 8. JPQL(1)  (0) 2022.03.29
[JPA] 7. 값 타입  (0) 2022.03.02
[JPA] 6. 프록시와 연관관계 관리  (0) 2021.12.14
[JPA] 5. 고급 매핑  (0) 2021.12.01