본문 바로가기
IT/Spring boot

[Spring boot] JPA N+1 발생 케이스 및 해결 방법

by kyu-nahc 2024. 9. 22.
반응형

 

JPA FetchType

저번 포스트에서 Jpa의 FetchType인 지연 로딩과 즉시 로딩에 대해 정리하였다.

 

[Spring boot] JPA FetchType

FetchTypeFetchType이란 JPA에서 엔티티를 조회할 때 연관된 ‘엔티티 조회 방법을 결정하는 전략’을 의미한다.javax.persistence.FetchType 패키지 내에 열거형(Eunm) 형태로 제공된다.// javax.persistence.FetchType.

kyu-nahc.tistory.com

즉시 로딩은 예상 못하는 쿼리가 생기거나, JPQL에서 N+1 문제가 발생할 수 있다고 설명하였다.

다시 한번 N+1문제를 정리하면 조회 시 1개의 쿼리를 생각하고 설계를 했으나

나오지 않아도 되는 조회의 쿼리가 N개가 더 발생하는 문제이다.

하지만 이 N+1 문제는 즉시 로딩 시에만 발생하는 것이 아닌, 지연 로딩 시에도 발생할 수 있다.

DBMS 툴을 이용해 직접 쿼리문을 만들어 조회할 때는 하나의 쿼리가 발생하겠지만,

JPA가 등장함에 따라 자동화된 쿼리문들이 생겨나면서 어쩔 수 없이 발생하는 문제이다.

JPA의 경우에는 객체에 대해서 조회한다고 해도 다양한 연관관계들의 매핑에 의해서

관계가 맺어진 다른 객체가 함께 조회되는 경우에 N+1이 발생하게 된다.

해당 포스트에서는 지연 로딩 시에도 N+1 문제가 발생하는 케이스와 이에 대한 해결방법을 제시한다.

 

먼저 예시 Entity는 저번 포스트에서 사용한 Entity를 그대로 사용하고,

저번 포스트에서 진행한 테스트 코드에 대해서 더 상세하게 설명하면 다음과 같다.

@Entity
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Member extends BaseEntity{

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String userId; // User Id
    private String password; // User password
    private String email; // User Email
    private String name; // User name
    @OneToMany(fetch=FetchType.LAZY, mappedBy = "member")
    @JsonManagedReference
    private List<Post> posts = new ArrayList<>();
}

@Entity
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Post extends BaseEntity{

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String title;
    private String content;
    @ManyToOne(fetch=FetchType.EAGER)
    @JsonBackReference
    private Member member;
}

 

Member Entity에서 현재 Post에 대하여 지연 로딩으로 설정하였다.

하지만 이를 즉시 로딩으로 변형 후 테스트 해보면 다음과 같다.

@Entity
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Member extends BaseEntity{

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String userId; // User Id
    private String password; // User password
    private String email; // User Email
    private String name; // User name
    @OneToMany(fetch=FetchType.EAGER , mappedBy = "member")
    @JsonManagedReference
    private List<Post> posts = new ArrayList<>();
}

@Test
@Transactional
void userEagerType(){
    System.out.println("== start ==");
    Member member = memberRepository.findById(1L)
            .orElseThrow(RuntimeException::new);
    System.out.println("== end ==");
    System.out.println(member.getPosts());
}

 

Member의 입장에서 즉시로딩을 사용한다고 했을 때,

Post의 모든 List를 다 같이 조회하고 싶을 상황이 생길 수도 있는데 왜 즉시로딩이 문제일까?

사실 일반적으로 findById에 대한 메서드는 EntityManager에서 PK 값을 찍어서

사용하기 때문에 JPA가 내부적으로 join문을 사용해서 최적화를 다음처럼 진행해 준다.

== start ==
Hibernate: 
    select
        m1_0.id,
        m1_0.created_date_time,
        m1_0.email,
        m1_0.modified_date_time,
        m1_0.name,
        m1_0.password,
        m1_0.user_id,
        p1_0.member_id,
        p1_0.id,
        p1_0.content,
        p1_0.created_date_time,
        p1_0.modified_date_time,
        p1_0.title 
    from
        member m1_0 
    left join
        post p1_0 
            on m1_0.id=p1_0.member_id 
    where
        m1_0.id=?
== end ==

 

내부적으로 inner join문 하나가 날아가서 Member가 조회됨과 동시에

Post까지 즉시로딩되는 것을 확인할 수 있다.

findById(), 즉 EntityManager에서 entityManager.find() 같은 경우

jpa가 내부적으로 join문에 대한 쿼리를 만들어서 반환을 하기 때문에

즉시로딩으로는 문제가 없어 보이기도 한다. 다만 문제는 jpql에 존재한다.

 

우리는 findById만 사용하는 것이 아니라 직접 jpql문을 짜서 전달하기도 하고,

data jpa에서 findBy~의 쿼리메서드 같은 경우에도 data jpa 내부에서 jpql이 만들어져서 나간다.

EntityManager에서 제공하는 일부 메서드 같은 경우 join문에 대한

최적화를 진행해서 반환하기 때문에 문제가 되지 않지만 JPQL을 사용하는 경우에 문제가 된다.

findAll()을 사용하는 예시를 보면 다음과 같다.

@Test
void userEagerTypeAndAllMember(){
    System.out.println("== start ==");
    List<Member> member = memberRepository.findAll();
    System.out.println("== end ==");
}
== start ==
Hibernate: 
    select
        m1_0.id,
        m1_0.created_date_time,
        m1_0.email,
        m1_0.modified_date_time,
        m1_0.name,
        m1_0.password,
        m1_0.user_id 
    from
        member m1_0
Hibernate: 
    select
        p1_0.member_id,
        p1_0.id,
        p1_0.content,
        p1_0.created_date_time,
        p1_0.modified_date_time,
        p1_0.title 
    from
        post p1_0 
    where
        p1_0.member_id=?
== end ==

 

jpql은 sql로 그대로 번역이 된다. 만약 Member의 findAll()을 요청하는 것이라면 

select m from Member m;라는 쿼리가 발생하게 된다.

Member를 찾는 거는 문제가 없었지만, 여기서 즉시로딩을 Post column에 걸어두었기 때문에

Member를 select 해왔지만 JPA는 Post에 대해서 EAGER가 걸려있는 것을 보고

Select 한 모든 Member에 대해서 Post가 있는지를 검색하게 된다.

즉, 모든 Member에 대해서 검색하고 싶어서 select 쿼리를 하나 날렸지만,

즉시 로딩이기 때문에 각각의 Member가 가진 Post를 모두 검색하게 되는 N+1 문제가 발생한다.

따라서 N+1 문제를 방지하기 위해 지연 로딩을 많이 사용한다.

지연 로딩은 연관된 객체를 사용하는 시점에 로딩을 해주는 방법이다.

 

하지만 지연 로딩에서도 N+1 문제가 발생한다.

지연 로딩은 해당 연결 Entity에 대해서 프록시로 걸어두고,

사용할 때 쿼리문을 결국 날리기 때문에 처음 find 할 때는 N+1이 발생하지 않지만,

추가로 Member 검색 후 Member의 Post를 사용해야 한다면

이미 캐싱된 Member의 Post 프록시에 대한 쿼리가 또 발생하게 된다.

@OneToMany(fetch=FetchType.LAZY, mappedBy = "member")
@JsonManagedReference
private List<Post> posts = new ArrayList<>();

@Test
@Transactional
void userFindTest() {
    System.out.println("== start ==");
    List<Member> users = memberRepository.findAll();
    System.out.println("== find all ==");
    for(Member member : users){
        System.out.println(member.getPosts());
    }
}

== start ==
Hibernate: 
    select
        m1_0.id,
        m1_0.created_date_time,
        m1_0.email,
        m1_0.modified_date_time,
        m1_0.name,
        m1_0.password,
        m1_0.user_id 
    from
        member m1_0
Hibernate: 
    select
        p1_0.member_id,
        p1_0.id,
        p1_0.content,
        p1_0.created_date_time,
        p1_0.modified_date_time,
        p1_0.title 
    from
        post p1_0 
    where
        p1_0.member_id=?
== find all ==
[com.example.QueryDSLProject.entity.Post@6dae5562]

 

지연로딩을 했으니 지연로딩 대상인 Post를 나중에 조회해서

쿼리가 또 날아가는 것조차도 N+1 문제 중 하나라고 할 수 있다.

즉시로딩, 지연로딩으로 넘어오면서 우리는 근본적으로 N+1 문제가 JPA에서 생기게 된다.

JPA가 자동으로 먼저 생성해 주는 Jpql을 통해서 우선적으로 쿼리를 만들다 보니

연관관계가 걸려있어도 join이 바로 걸리지 않는다.

 

일단 즉시로딩에서는 우리가 커스텀할 수 있는 부분이 존재하지 않기 때문에

지연로딩 과정에서 우리는 바로 사용을 할 객체에 대해서는 join을 걸 수 있도록 조정해주어야 한다.

즉시 로딩은 jpql을 우선적으로 Select 하기 때문에

연관된 또 다른 쿼리가 날아가 N+1 문제가 필연적이라면,

지연 로딩도 지연 로딩된 값을 Select 할 때 따로 쿼리가 날아가서 N+1 문제가 발생하지만,

join을 걸 수 있도록 조정하여 N+1 문제를 해결할 수 있는 것이다.

이제 N+1 문제를 해결하는 방법을 살펴보면 다음과 같다.

 

Fetch Join 사용

첫 번째 해결 방법으로는 Fetch Join이 있다.

Fetch Join을 통해 JPQL에서 N+1 문제를 방지할 수 있다.

먼저 Fetch Join을 사용하지 않고, @Query를 통한 일반적인 JPQL을 구성하면 다음과 같다.

public interface MemberRepository extends JpaRepository<Member,Long>{

    @Query("select m from Member m left join m.posts")
    List<Member> findMemberByJPQL();
}

 

위와 같이 JPQL을 작성하고 아래와 같은 테스트 코드를 통해 테스트를 진행한다.

@Test
@Transactional
void JPQLTest(){
    System.out.println("== start ==");
    List<Member> users = memberRepository.findMemberByJPQL();
    System.out.println("== find all ==");
    for(Member member : users){
        System.out.println("Size : "+member.getPosts().size());
    }
}
== start ==
Hibernate: 
    select
        m1_0.id,
        m1_0.created_date_time,
        m1_0.email,
        m1_0.modified_date_time,
        m1_0.name,
        m1_0.password,
        m1_0.user_id 
    from
        member m1_0 
    left join
        post p1_0 
            on m1_0.id=p1_0.member_id
Hibernate: 
    select
        p1_0.member_id,
        p1_0.id,
        p1_0.content,
        p1_0.created_date_time,
        p1_0.modified_date_time,
        p1_0.title 
    from
        post p1_0 
    where
        p1_0.member_id=?
== find all ==
Size : 1

 

테스트 코드의 출력을 살펴보면 N+1문제는 동일하게 발생한다.

즉시로딩이 아닌 지연로딩이 걸려있지만, Join을 했어도 프록시를 통해 가져오는 건 동일하기 때문이다.

Member에 대해서 Select 쿼리를 Join과 함께 하나 날렸지만,

각각의 Member가 가진 Post를 Select 하는 쿼리문이 추가적으로 날아가는 것을 볼 수 있다.

이를 방지하기 위해 fetch join을 사용하는 것이다.

public interface MemberRepository extends JpaRepository<Member,Long> {

    @Query("select m from Member m left join fetch m.posts")
    List<Member> findMemberByJPQL();
}

 

위와 같이 join문에 fetch를 걸어주면 된다.

fetch는 지연 로딩이 걸려있는 연관관계에 대해서 한 번에 같이 즉시 로딩해 주는 구이다.

위의 테스트 코드를 통해 같은 방법으로 테스트를 하면 다음과 같다.

== start ==
Hibernate: 
    select
        m1_0.id,
        m1_0.created_date_time,
        m1_0.email,
        m1_0.modified_date_time,
        m1_0.name,
        m1_0.password,
        p1_0.member_id,
        p1_0.id,
        p1_0.content,
        p1_0.created_date_time,
        p1_0.modified_date_time,
        p1_0.title,
        m1_0.user_id 
    from
        member m1_0 
    left join
        post p1_0 
            on m1_0.id=p1_0.member_id
== find all ==
Size : 1

 

fetch join의 결과를 살펴보면 Jpa 내부에서 Join을 포함하여 쿼리를 반환하기 때문에,

각각의 Member가 가진 Post를 Select 하는 쿼리문이 추가적으로 날아가지 않는다.

지연 로딩된 Entity에 대하여 한번 더 Select 쿼리를 날리는 것이 아닌,

추가적인 Select 쿼리 없이 Join으로 연관된 데이터를 한 번에 가져올 수 있다.

 

EntityGraph 사용

fetch join의 다른 방법으로는 @EntityGraph를 사용하는 방법이 존재한다.

jpql에서 fetch join을 하게 된다면 하드코딩을 하게 된다는 단점이 존재한다.

이를 최소화하고 싶다면 @EntityGraph를 사용할 수 있다.

@EntityGraph는 연관관계가 지연로딩으로 되어있을 때 fetch 조인을 사용하여

여러 번의 쿼리를 한 번에 해결할 수 있는 점에서

fetch join을 어노테이션을 통해 사용할 수 있도록 한 기능이다.

public interface MemberRepository extends JpaRepository<Member,Long>{
    
    @EntityGraph(attributePaths = {"posts"}, type = EntityGraph.EntityGraphType.FETCH)
    @Query("select m from Member m left join  m.posts")
    List<Member> findMemberByJPQL();
}
== start ==
Hibernate: 
    select
        m1_0.id,
        m1_0.created_date_time,
        m1_0.email,
        m1_0.modified_date_time,
        m1_0.name,
        m1_0.password,
        p1_0.member_id,
        p1_0.id,
        p1_0.content,
        p1_0.created_date_time,
        p1_0.modified_date_time,
        p1_0.title,
        m1_0.user_id 
    from
        member m1_0 
    left join
        post p1_0 
            on m1_0.id=p1_0.member_id
== find all ==
Size : 1

 

위의 fetch join의 결과와 동일하게 추가적인 Select 쿼리 없이

Join으로 연관된 데이터를 한 번에 가져오는 것을 볼 수 있다.

이렇게 fetch join@EntityGraph를 통해 지연 로딩에서의 N+1 문제를 해결할 수 있다.

하지만 이 2가지 방법으로 모든 N+1 문제를 해결할 수 있는 것은 아니다.

바로 Pagination(페이징 처리)에서 문제점이 발생한다.

 

Paging 처리에서의 문제점

Paging 처리를 JPA에서 할 때 일반적인 fetch join으로는 해결할 수 있는 문제점이 존재한다.

fetch join과 @EntityGraph를 통해서 N+1을 개선한다고는 하지만,

Page를 반환하는 쿼리를 작성해 보면 여러 가지 문제점을 겪게 된다.

Fetch join을 Paging 처리해서 반환해 보는 예시를 살펴보자.

@EntityGraph(attributePaths = {"posts"},type= EntityGraph.EntityGraphType.FETCH)
@Query("select m from Member m left join  m.posts")
Page<Member> findMemberByPage(Pageable pageable);

@Test
@Transactional
void pagingTest(){
    System.out.println("== start ==");
    PageRequest pageRequest = PageRequest.of(0, 1);
    Page<Member> page = memberRepository.findMemberByPage(pageRequest);
    System.out.println("== find all ==");
    for (Member member : page) {
        System.out.println(member.getPosts().size());
    }
}

 

0 페이지의 총 1명의 유저를 반환하는 PageRequest 객체를 파라미터로 입력받는다.

해당 테스트의 결과를 살펴보면 다음과 같다.

== start ==
2024-09-20T11:24:13.887+09:00  WARN 14388 --- [QueryDSLProject] [    Test worker] org.hibernate.orm.query                  : HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
Hibernate: 
    select
        m1_0.id,
        m1_0.created_date_time,
        m1_0.email,
        m1_0.modified_date_time,
        m1_0.name,
        m1_0.password,
        p1_0.member_id,
        p1_0.id,
        p1_0.content,
        p1_0.created_date_time,
        p1_0.modified_date_time,
        p1_0.title,
        m1_0.user_id 
    from
        member m1_0 
    left join
        post p1_0 
            on m1_0.id=p1_0.member_id
Hibernate: 
    select
        count(m1_0.id) 
    from
        member m1_0 
    left join
        post p1_0 
            on m1_0.id=p1_0.member_id
== find all ==
1

 

Count 쿼리는 Page 반환 시 무조건 발생하는 쿼리이므로 제외하고,

Select 구문은 한 개만 실행되는 것을 볼 수 있다.

근데 쿼리를 자세히 보면 Mysql에서 페이징 처리를 할 때 사용을 하는 Limit, Offset이 존재하지 않는다.

분명 limit은 size 1로, offset은 page 0으로 지정했지만, MySQL의 실행 쿼리에서는 존재하지 않는다.

하지만 반환 값은 1명의 유저 Post size가 나온 것을 볼 수 있다.

이에 대한 이유는 Warning 구문에서 알 수 있다. Warning 구문은 다음과 같다.

2024-09-20T11:24:13.887+09:00  WARN 14388 --- [QueryDSLProject] [    Test worker] org.hibernate.orm.query                  : HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory

 

해석해 보면 collection fetch에 대해서 paging처리가 나와있지만 

applying in memory, 즉 인메모리를 적용해서 조인을 진한 것이다.

실제 쿼리와 이 문구를 통합해서 이해를 해보면 일단 List의 모든 값을 select 해서 인메모리에 저장하고,

application 단에서 필요한 페이지만큼 반환을 알아서 해주었다는 것이다.

 

이러면 사실상 Paging을 한 이유가 없어진다.

100만 건의 데이터가 있을 때 그중 10건의 데이터만 paging 하고 싶었으나

100만 건을 메모리에 가져온다면 OOM(Out of Memory)이 발생할 확률이 매우 높다.

따라서 Pagination에서는 fetch join을 적용하여도 해결할 수 없다.

그렇다면 왜 Jpa는 인메모리를 적용해서 조인을 진행하는 것일까?

이를 위해 Jpa의 distinctfetch join의 문제점에 대해 이해할 필요가 있다.

 

Fetch Join의 데이터 중복성 문제

Fetch Join을 사용 시 1:N 관계에서는 Distinct를 해주어야 한다.

위의 예제 코드에서는 모두 Distinct를 생략하였지만 이럴 경우 fetch join에서 문제가 발생한다.

JPA에서 @ToMany 관계에 대해 fetch join을 수행할 때,
One Entity 기준으로 Many Entity에 대한 데이터를 join 하게 되어 데이터의 수가 변하게 된다.

이해를 돕기 위해 그림을 이용하면 다음과 같다.

Member Fetch Join Post Example

 

위의 그림과 같이 One Entity 기준으로 Many Entity에 대한 데이터를 Join 하게 되어,

중복되는 Member 데이터가 생기게 된다. 여기서 데이터 중복데이터베이스 관점이다.

데이터베이스에서 선택된 SQL의 결과 row 수는 Post의 수만큼 증가되는 것이다.

즉 데이터베이스의 Row가 증가하고, 그 결과 같은 Member의 수도 증가하는 것이다.

One 쪽의 데이터를 가져오는 것이 주된 목적이고,

이때 연관된 엔티티(Many)를 부수적으로 함께 조회하고 싶었던 것인데,

조회 결과는 Many 쪽을 기준으로 조회된 것이므로, 의도한 바와는 조금 다른 결과를 얻게 된다.

이를 방지하기 위해 다음과 같이 Distinct 키워드를 사용해야 한다.

// Distinct 사용 전
@Query("select m from Member m left join fetch m.posts")
List<Member> findMemberByJPQL();

// Distinct 사용 후
@Query("select distinct m from Member m left join fetch m.posts")
List<Member> findMemberByJPQL();

 

하지만 위의 다른 예시 코드에서 보면 distinct를 사용하지 않았던 이유가 있다.

Hibernate 6.0부터는 HQL(JPQL의 구현체)에 DISTINCT가 자동 적용된다.

DISTINCT 키워드가 애플리케이션 단에서만 중복 엔티티를 제거해 주는 것이다.

https://github.com/hibernate/hibernate-orm/blob/6.0/migration-guide.adoc#distinct

 

hibernate-orm/migration-guide.adoc at 6.0 · hibernate/hibernate-orm

Hibernate's core Object/Relational Mapping functionality - hibernate/hibernate-orm

github.com

fetch join의 문제점과 distinct 키워드를 설명하기 위해 추가적으로 설명한 것이다.

Paging 처리에서 문제점이 생기는 이유를 이해하기 위해 설명을 부가적으로 한 것이다.

distinct 설명을 덧붙이면, @Query 부분에 사용하는 distinct 키워드는 MySQL 키워드와 조금 다르다.

이에 대해 살펴보면 다음과 같다.

 

Jpa Distinct

DB의 SQL의 문법에서 distinct는 전체 row가 모두 똑같아야 중복이 제거가 되지만

JPA의 distinct는 pk(식별자 , id값)가 같다면 중복을 제거해 준다.

JPA의 distinct는 SQL에 distinct를 추가해 주고

거기에 더해서 같은 엔티티가 조회가 된다면 중복을 걸러주는 기능을 가지고 있다.

따라서 fetch join을 사용하여 데이터를 조회할 때는

distinct 키워드를 같이 사용해야 중복된 데이터가 조회되는 것을 막을 수 있다.

 

하지만 fetch join과 distinct 키워드로 인해 page 처리에서의 문제점을 겪게 된다.

distinct를 쓰는 이유는 하나의 연관관계에 대해서 fetch join으로 가져온다고 했을 때

중복된 데이터가 많기 때문에 실제로 원하는 데이터의 양보다 중복되어 많이 들어오게 된다.

그 이유 때문에 개발자가 직접 distinct를 통해서 jpa에게 중복 처리를 지시하게 되는 것이다.

Hibernate 6.0 이상은 Distinct가 자동으로 붙지만, Distinct를 사용한다는 점은 변함이 없다.

 

Paging처리는 쿼리를 날릴 때 진행되기 때문에 jpa에게 pagination 요청을 하여도

jpa는 distinct때와 마찬가지로 중복된 데이터가 있을 수 있으니,

limit offset을 걸지 않고 일단 인메모리에 다 가져와서 application에서 처리하게 되는 것이다.

따라서 이를 해결하기 위해 다른 방법을 찾아봐야 한다.

 

Paging 처리의 해결법

1. ToOne 관계에서의 페이징 처리

사실 해결책이라기보다는 ~ToOne 관계라면 페이징 처리를 진행해도 상관없다.

public interface PostRepository extends JpaRepository<Post, Long> {

    @EntityGraph(attributePaths = {"member"}, type = EntityGraph.EntityGraphType.FETCH)
    @Query("select p from Post p left join p.member")
    Page<Post> findAllPost(Pageable pageable);
}

 

Post는 Member에 대해서 ManyToOne 연관관계이기 때문에

지금처럼 Pagination을 진행한다고 해도 인메모리에서 모든 Post를 조회하는 것이 아닌

limit을 걸어 필요한 데이터만 가져올 수 있다.

아래의 테스트 코드의 결과를 보아도 SQL 구문에 limit가 존재하는 것을 볼 수 있다.

@Test
@Transactional
void pagingTest(){
    System.out.println("== start ==");
    PageRequest pageRequest = PageRequest.of(0, 2);
    Page<Post> page = postRepository.findAllPost(pageRequest);
    System.out.println("== find all ==");
    for (Post post : page) {
        System.out.println(post.getTitle());
    }
}
== start ==
Hibernate: 
    select
        p1_0.id,
        p1_0.content,
        p1_0.created_date_time,
        m1_0.id,
        m1_0.created_date_time,
        m1_0.email,
        m1_0.modified_date_time,
        m1_0.name,
        m1_0.password,
        m1_0.user_id,
        p1_0.modified_date_time,
        p1_0.title 
    from
        post p1_0 
    left join
        member m1_0 
            on m1_0.id=p1_0.member_id 
    limit
        ?
Hibernate: 
    select
        count(p1_0.id) 
    from
        post p1_0
== find all ==
title
title

 

따라서 사실 Pagination의 해결책이라는 부제목이 달려있지만 

~ToOne 관계에 있는 경우 fetch join을 걸어도 Pagination이 원하는 대로 제공되는 것이지,

~ToMany의 근본적인 해결책은 아니다.

 

2. BatchSize 사용

~ToMany 관계에서는, 조인을 했을 경우 Many인 다객체들이 One에 매핑되어 fetch join 된다면

중복되는 데이터로 인해 Pagination에서 개수를 판단하기 힘들기 때문에

fetch join을 사용할 경우 임의로 인메모리에서 조정한다.

따라서 컬랙션 조인을 하는 경우에는 fetch join을 아예 사용하지 않고,

조회할 컬랙션 필드에 대해서 @BatchSize를 걸어 해결할 수 있다.

즉, 연관된 Entity에 대해 fetch join을 사용하는 JPQL 쿼리를 짜는 것이 아닌

컬랙션 필드에 대해 @BatchSize를 붙여 페이징 처리 된 Entity 결과를 반환하는 것이다.

@BatchSize(size=100)
@OneToMany(fetch=FetchType.LAZY, mappedBy = "member")
@JsonManagedReference
private List<Post> posts = new ArrayList<>();

// MemberRepository Method
@Override
Page<Member> findAll(Pageable pageable);

 

계속해서 사용하던 Member Entity이다.

하지만 달라지는 것은 List<Post> 필드에 @BatchSize 어노테이션을 붙인 후,

size를 명시하여 연관된 엔티티 조회 시 지정한 size 만큼 IN 쿼리를 사용하여 조회할 수 있다.

다음과 같은 테스트 코드를 통해 테스트 결과를 살펴보자.

@Test
@Transactional
void pagingTest(){
    System.out.println("== start ==");
    PageRequest pageRequest = PageRequest.of(0, 2);
    Page<Member> page = memberRepository.findAll(pageRequest);
    System.out.println("== find all ==");
    for (Member member : page) {
        System.out.println(member.getPosts().size());
    }
}
== start ==
Hibernate: 
    select
        m1_0.id,
        m1_0.created_date_time,
        m1_0.email,
        m1_0.modified_date_time,
        m1_0.name,
        m1_0.password,
        m1_0.user_id 
    from
        member m1_0 
    limit
        ?, ?
Hibernate: 
    select
        count(m1_0.id) 
    from
        member m1_0
== find all ==
Hibernate: 
    select
        p1_0.member_id,
        p1_0.id,
        p1_0.content,
        p1_0.created_date_time,
        p1_0.modified_date_time,
        p1_0.title 
    from
        post p1_0 
    where
        p1_0.member_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ....)
2
3

 

일반적인 지연로딩했을 때랑의 형태와 다르다는 것을 볼 수 있다.

Member에 대해서 limit 쿼리가 나갔기 때문에 인메모리가 아닌 정상적인

pagination이 작동되었는데 밑에 Post select 하는 쿼리가 하나가 생성된다.

그 이유는 지연로딩하는 객체에 대해서 Batch성 loading을 진행하기 때문이다.

 

기존에는 Member를 조회하는 쿼리 1개 조회된 N개의 Member에 대해

연관된 엔티티를 조회하는 쿼리 N개로 인해 N+1 문제가 발생했다면,

@BatchSize를 적용한 후에는 Member를 조회하는 쿼리 1개 (Member가 N개 조회된다.)

조회된 Member N개와 연관된 컬렉션 엔티티를 조회하는 IN 쿼리 1개의 쿼리가 실행된다.

즉, 1 + N 번 실행 되었던 쿼리가 1 + 1번 실행됨으로써 N+1 문제를 해결한 것이다.

 

기존의 지연로딩에 대해서는 객체를 조회할 때 그때그때 쿼리문을 날려서 N+1 문제가 발생한 반면

객체를 조회하는 시점에 쿼리를 하나만 날리는 게 아니라

해당하는 Post에 대해서 쿼리를 batch size개를 생성한다.

batch 쿼리에서 where 부분을 다시 보면 다음과 같다.

where
    p1_0.member_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ....)

 

in (?, ?, ?, ? ...)가 결국 Member Id를 100개를 가져오는 쿼리문으로써

그때그때 조회하는 것이 아닌 조회할 때

batch size만큼 한 번에 가져와서 뒤에 생길 지연로딩에 대해서 미연에 방지한다.

batch size는 Post가 아닌 Member의 개수가 기준이고, 테스트 출력에 size 크기만큼 ?가 존재한다.

 

다만, Batch Size는 연관관계에서의 데이터 사이즈를 확실하게 알 수 있다면

최적화된 size를 구할 수 있겠지만, 사실 일반적인 케이스에서 최적화된 데이터

사이즈를 알기 힘들고 일반적으로 100~1000을 사용하지만,

확실하게 알지 못한다면 좋지 않은 방법이 될 수 있다.

 

@BatchSize를 사용하는 대신, hibernate.default_batch_fetch_size를 설정 파일에 적어줌으로써

BatchSize 옵션을 전역적으로 설정할 수 있다.

spring.jpa.properties.hibernate.default_batch_fetch_size=10

 

각 엔티티에 대해서 별개로 size 옵션을 얼마로 설정할지 매번 계산하는 것보다

DB부하가 심하지 않은 선에서 전역적으로 설정하면 관리 비용이 줄어들기 때문에 효율적일 수 있다.

컬렉션 엔티티와 페이징을 같이 사용하는 경우가 많고 Size 옵션을 통일할 수 있다면,

전역적인 BatchSize 옵션 설정을 사용하는 것도 좋은 방법이다.

 

 

3. @Fetch(FetchMode.SUBSELECT) 사용

@BatchSize와 비슷하지만 다른 어노테이션이다.

@BatchSize의 경우 사이즈 개수 제한을 임의로 두어서

사용자가 최적화된 데이터 사이즈를 적용하게끔 도와준다면, 해당 어노테이션은

JPA에서 먼저 부모 엔티티들을 조회하고, 그 후에 자식 엔티티들을 가져오는데,

이때 부모 엔티티의 ID를 조건으로 하여 서브쿼리를 사용해 한 번에 자식 엔티티들을 조회한다.

코드를 통해 살펴보면 다음과 같다.

@Fetch(FetchMode.SUBSELECT)
@OneToMany(fetch=FetchType.LAZY, mappedBy = "member")
@JsonManagedReference
private List<Post> posts = new ArrayList<>();
== start ==
Hibernate: 
    select
        m1_0.id,
        m1_0.created_date_time,
        m1_0.email,
        m1_0.modified_date_time,
        m1_0.name,
        m1_0.password,
        m1_0.user_id 
    from
        member m1_0 
    limit
        ?, ?
Hibernate: 
    select
        count(m1_0.id) 
    from
        member m1_0
== find all ==
Hibernate: 
    select
        p1_0.member_id,
        p1_0.id,
        p1_0.content,
        p1_0.created_date_time,
        p1_0.modified_date_time,
        p1_0.title 
    from
        post p1_0 
    where
        p1_0.member_id in (select
            m1_0.id 
        from
            member m1_0)
2
3

 

Batch Size의 경우 주어진 size만큼 Member Id를 입력하여

그때그때 프록시 상태에 따라 지연로딩을 했다면, @Fetch(FetchMode.SUBSELECT)

연관된 모든 데이터를 서브쿼리로 한 번에 조회한다.

즉 @Fetch는 모든 Member Id를 조회한다. 마치 @BatchSize(size = 무한대)처럼 작동하는 것이다.

따라서 연관된 엔티티를 모두 가져오며, 데이터 양이 많으면 서브쿼리가 복잡해질 수 있다.

 

따라서 한 번에 모든 batch를 가져오는 것이 과연 좋은 판단인가에 대한 것은 의문을 가져야 한다.

Batch Size의 경우 size가 100일 때 100만 명의 유저에서

100명의 Member id에 대한 검색을 하는 반면,

SUBSELECT는 100만 명 모든 Member를 Select 하기 때문이다.

 

N+1 문제의 요약

JPA는 위에서 설명한 것처럼 N+1 문제가 존재하며,

이는 JPA가 등장함에 따라 자동화된 쿼리문들이 생겨나면서 어쩔 수 없이 발생하는 문제이다.

위의 여러 가지 케이스 및 해결방안을 정리하면 다음과 같다.

 

즉시로딩

  • jpql을 우선적으로 select 하기 때문에 즉시로딩을 감지하고
    이에 연관된 객체에 대한 쿼리가 날아가 N+1 문제가 발생

지연로딩

  • 지연로딩된 값을 select 할 때 연관된 객체에 대한 쿼리가 날아가 N+1 문제 발생

fetch join

  • 지연로딩의 해결책
  • 사용될 때 확정된 값을 한 번에 join에서 select 해서 가져온다.
  • Pagination이나 2개 이상의 collection join에서 문제가 발생한다.

Pagination

  • fetch join 시 limit, offset을 통한 쿼리가 아닌 인메모리에 모두 가져와
    application단에서 처리하여 OOM 발생한다.
  • BatchSize를 통해 필요시 배치 쿼리로 원하는 만큼 쿼리를 날린다.
  • 쿼리는 날아가지만 N번만큼의 무수한 쿼리는 발생되지 않는다.

해결하고자 하는 상황, 그리고 주어진 정책에 따라 여러 가지의 판단을 할 수 있겠지만,

즉시로딩의 N+1 문제를 방지하기 위해 지연로딩은 항상 기본으로 사용하는 것이 좋으며,

Paginatioin이 필수적으로 들어가는 상황이라면 fetch Join이 아닌

Batch Size를 사용해야 효율적일 것이다. 또한 fetch Join2개 이상의

Collection Join을 진행 시 문제가 발생하는데 이는 다음 포스트에서 다룰 예정이다.

 

참고자료

https://velog.io/@jinyoungchoi95/JPA-%EB%AA%A8%EB%93%A0-N1-%EB%B0%9C%EC%83%9D-%EC%BC%80%EC%9D%B4%EC%8A%A4%EA%B3%BC-%ED%95%B4%EA%B2%B0%EC%B1%85#%EC%A7%80%EC%97%B0%EB%A1%9C%EB%94%A9

https://velog.io/@guns95/%EC%BB%AC%EB%A0%89%EC%85%98-FetchJoin%EC%9D%98-%EB%AC%B8%EC%A0%9C%EC%A0%90%EA%B3%BC-%ED%95%9C%EA%B3%84-%EB%8F%8C%ED%8C%8CBetchSize

https://velog.io/@balparang/JPA-%EC%BB%AC%EB%A0%89%EC%85%98-%EC%97%94%ED%8B%B0%ED%8B%B0%EC%99%80-%ED%8E%98%EC%9D%B4%EC%A7%95%EC%9D%84-%ED%95%A8%EA%BB%98-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-feat.-BatchSize

 

반응형

loading