본문 바로가기
Java/Spring

[JPA/QueryDsl] 페이징에서의 N+1 해결 기록

by 오늘의개발부 2022. 5. 16.
반응형

상속관계로 이루어진 Item 엔티티, 이 Item 엔티티와 1 : N 관계를 지닌 Order 엔티티, 그리고 이 Order 엔티티를 페이징하다가(fetchResults()) 만난 N+1 문제를 해결해가는 과정에 대한 기록이다.

 

핵심 엔티티만 남겨 간략화하면 엔티티는 아래와 같다

 

Order 엔티티는 BasicItem과 SpecialItem을 가지고 있고 각각 1 : N 관계를 맺고 있다.

 

@Entity(name = "TB_ORDER")
@Table(name = "TB_ORDER")
public class OrderEntity {
	
    @Id
    @Column(name = "ORDER_ID", nullable = false)
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    private List<BasicItem> basicItems = new ArrayList<>(); 

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    private List<SpecialItem> specialItems = new ArrayList<>(); 

    String orderCode;
    String ...
    ...
    ...
}

 

 

 

BasicItem과 SpecialItem은 Item 엔티티를 상속받은 자녀 엔티티이다.

@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "ITEM_TYPE")
@DiscriminatorOptions(force = true)
@Entity(name = "TB_ITEM")
@Table(name = "TB_ITEM")
public abstract class ItemEntity {
	
    @Id
    @Column(name = "ITEM_ID", nullable = false)
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String itemName;
    private String ...
    ...
    
}


@DiscriminatorValue("BASIC")
@Entity
public class BasicItemEntity extends ItemEntity {
	...
}


@DiscriminatorValue("SPECIAL")
@Entity
public class SpecialItemEntity extends ItemEntity {
	...
}

 

 

 

 

이 상태에서 페이징 쿼리를 날려보자

 

public Page<OrderEntity> searchAll(Pageable pageable, SearchParam searchParam) {
		
QueryResults<OrderEntity> result = queryFactory.selectFrom(order)
                                                    .where(
                                                        ...
                                                        ...
                                                    )
                                                .offset(pageable.getOffset())
                                                .limit(pageable.getPageSize())
                                                .fetchResults();

return new PageImpl<>(result.getResults(), pageable, result.getTotal());
}

 

 

만약 Order 데이터가 한 개, Basic Item과 Special 아이템이 각각 한개씩 저장되어 있는 상태에서 쿼리를 날렸다면 위와 같은 코드를 실행했을 때 총 4개의 쿼리가 수행된다.

 


1. fetchResults()를 실행하여 페이징을 하기 위해 전체 데이터의 count() 쿼리

2. Order의 select 쿼리

3. 프록시 상태로 Order 안에 들어있던 BaiscItem의 데이터를 조회할 때 추가적으로 발생하는 BasicItem select 쿼리

4. 프록시 상태로 Order 안에 들어있던 SpecialItem의 데이터를 조회할 때 추가적으로 발생하는 SpecialItem select 쿼리

 

 

만약 Order가 수가 100개라면 count 쿼리, Order select 쿼리와 더불어 100개의 Order 안에 든 Item 엔티티의 개수만큼 쿼리가 추가적으로 발생하게 되는 것이다.

 

N+1 문제는 유명한 문제이기 때문에 더 자세한 설명은 굳이 필요 없을 것 같다.

 

 

Join

아무래도 두 Item 엔티티가 Lazy loading으로 설정되어있기때문이 아닐까?

아래와 같이 명시적으로 leftJoin을 해주면 어떨까?

 

public Page<OrderEntity> searchAll(Pageable pageable, SearchParam searchParam) {
		
QueryResults<OrderEntity> result = queryFactory.selectFrom(order)
                                                .leftJoin(order.basicItem, BasicItemEntity)
                                                .leftJoin(order.spcialItem, spcialItem)
                                                    .where(
                                                        ...
                                                        ...
                                                    )
                                                .offset(pageable.getOffset())
                                                .limit(pageable.getPageSize())
                                                .fetchResults();

return new PageImpl<>(result.getResults(), pageable, result.getTotal());
}

 

하지만 결과는 마찬가지다.

왜냐하면 join()을 쓰더라도 selectFrom 절에 있는 대상 엔티티만을 영속화시킬 뿐 연관 엔티티까지 조회해 영속화시키지는 않기 때문이다. join()은 보통 where() 절에서 연관관계 엔티티와의 조회 조건을 설정할 때 사용하게 된다.

 

 

FetchJoin

 이럴 때 사용하는 것이 FetchJoin이다. fetchJoin은 selectFrom() 절에 있는 대상 엔티티의 연관 엔티티까지 모조리 조회해준다. 

 

아래와같이 leftJoin() 밑에서 fetchJoin()을 사용해주자. 일단 SpecialItem만 fetchJoin으로 가져와보자. 참고로 둘 중 한 군데에만 fetchJoin()을 붙이면 붙인 쪽만 같이 조회해오고 안 붙인 쪽은 조회해오지 않는다. 

 

public Page<OrderEntity> searchAll(Pageable pageable, SearchParam searchParam) {
		
QueryResults<OrderEntity> result = queryFactory.selectFrom(order)
                                                .leftJoin(order.basicItem, BasicItemEntity)
                                                .leftJoin(order.spcialItem, spcialItem)
                                                .fetchJoin()
                                                    .where(
                                                        ...
                                                        ...
                                                    )
                                                .offset(pageable.getOffset())
                                                .limit(pageable.getPageSize())
                                                .fetchResults();

return new PageImpl<>(result.getResults(), pageable, result.getTotal());
}

 

 

실행된 쿼리의 로그를 확인해보면 left join으로 연결이 되어 있고 select문 안에 specialfile이 포함되어 있음을 확인할 수있다.

 

select
    order0_.ID as order_id1_29_0_,
    specialitem2_.ID as file_id2_31_1_,
    order0_.DB_STATUS as db_statu2_29_0_,
    specialitem2_.DB_STATUS as db_statu3_31_1_,
    specialitem2_.REG_DATE as reg_date4_31_1_,
    ...
from
    TB_ORDER order0_ 
left outer join
    TB_ORDER_FILE basicitem1_ 
    on order0_.ORDER_ID=basicitem1_.ORDER_ID 
    and basicitem1_.ITEM_TYPE='BASIC' 
left outer join
    TB_ORDER_FILE specialitem2_ 
    on order0_.ORDER_ID=specialitem2_.ORDER_ID
    and specialitem2_.ITEM_TYPE='SPECIAL'

 

하지만 WARN 로그까지 함께 확인할 수 있다.

 

14:48 WARN  o.h.h.i.ast.QueryTranslatorImpl - HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!

 

이제 다시 한번 생성된 쿼리를 확인해보면 이상한 점을 찾을 수 있다. LIMIT이 안 걸려있다. 그런데도 페이징은 수행됐다.

 

모든 데이터를 select해와서 메모리에 적재한 후 어플리케이션에서 페이징을 수행한 것이다. WARN 로그이나 만약 데이터가 수천만행으로 늘어나고 그 모든 데이터를 메모리에 올리다가 OOM 에러라도 난다고 생각하면 끔찍한 내용의 WARN 로그이다. 

 

  LIMIT이 동작하지 않고 어플리케이션에서 페이징을 수행하는 이유가 무엇일까?

  Order와 Item과 같은 1:N 관계에서는 Order 하나에 관계된 Item이 몇 개일지 알 수 없어 LIMIT을 수행할 수 없다.  이러한 이유로 JPA에서는 OneToMany, ManyToMany의 연관관계가 있을 때 LIMIT을 사용하면 전체 데이터를 가져온 후 어플리케이션에서 LIMIT을 수행한다. 반대로 생각하면 Item을 기준으로 N:1로 select할 경우에는 어느 Item이든 관련된 Order가 1개라는 것이 확실하기 때문에 이러한 문제가 있을 때 역으로 select를 하는 방법도 존재한다.

 

다시 fetchJoin()을 없애고 다음엔 BatchSize 를 이용한 해결을 시도해보자


 참고로, 만약 FetchJoin이나 Join을 사용하지 않고 OrderEntity 자체에서 연관관계를 설정할 때 FetchType.Eager 로 설정했다면 어땠을까?

 처음에 본 것처럼 FetchJoin이나 Join을 사용하지 않고 FetchType.Lazy일 때는 count() 쿼리, Order select(), 그리고 Order 안의 Item을 select 하는 무수한 쿼리가 나갔었다. 이때 수많은 Item select 쿼리가 수행된 시점은 프록시 객체를 이용해 Item 데이터에 접근한 시점이다. 예컨데 조회한 엔티티를 DTO로 변환할 때, 혹은 View에서 조회했을 때 등일 것이다.

 Order 엔티티에서 FetchType.Eager를 걸고 Querydsl에서 조회했다면 데이터 접근 시점이 아니라 queryFactory.selectFrom(order)...fetchResults();를 수행한 시점에서 무수한 Item들을 조회해온다. 따라서 N+1의 해결법이 될 수 없다.

 

 

BatchSize

@BatchSize 어노테이션이 있으면 1 : N 연관관계 엔티티의 컬렉션을 초기화할 때 주인 엔티티의 ID를 모아 IN 절 안에서 조회한다. 예를들어 LIMIT된 개수만큼 Order를 조회한 뒤 Order의 ID를 모두 모은다. 그리고 Item을 조회하기 위해 SELECT * FROM TB_ITEM WHERE ORDER_ID IN (1, 2, 3, 4); 같은 방식으로 ITEM과 연관관계를 맺고 있는 Order의 ID로 조회해온다.

 

아래와같이 @BatchSize 어노테이션을 @OneToMany 연관관계 위에 추가해준다. 그리고 Querydsl의 코드에서 join은 모두 삭제한다.

 

@Entity(name = "TB_ORDER")
@Table(name = "TB_ORDER")
public class OrderEntity {
	
    @Id
    @Column(name = "ORDER_ID", nullable = false)
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @BatchSize(size = 100)
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    private List<BasicItem> basicItems = new ArrayList<>(); 

    @BatchSize(size = 100)
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    private List<SpecialItem> specialItems = new ArrayList<>(); 

    String orderCode;
    String ...
    ...
    ...
}



public Page<OrderEntity> searchAll(Pageable pageable, SearchParam searchParam) {
		
	QueryResults<OrderEntity> result = queryFactory.selectFrom(order)
                                                    .where(
                                                        ...
                                                        ...
                                                    )
                                                .offset(pageable.getOffset())
                                                .limit(pageable.getPageSize())
                                                .fetchResults();

	return new PageImpl<>(result.getResults(), pageable, result.getTotal());
}

 

총 4개의 쿼리가 수행된다.

count 쿼리, Order select 쿼리, orderId를 이용한 Basic Item 조회 쿼리, orderId를 이용한 SpecialItem 조회 쿼리

 

이제 Item이 몇 개가 등록되어있든 쿼리 개수는 늘어나지 않는다.

 

@BatchSize 안의 size 옵션은 한번에 조회하는 주인 엔티티의 아이디 개수이다.

만약 기본 페이지 사이즈 개수(LIMIT)가 5일 때, @BatchSize(size = 3) 으로 되어있다면 처음에 3개의 Order에 대한 Item을 가져오고, 한번의 쿼리를 더 날려서 나머지 2개의 Order에 대한 Item을 가져온다.

  SELECT * FROM TB_ITEM WHERE ORDER_ID IN (1, 2, 3);

  SELECT * FROM TB_ITEM WHERE ORDER_ID IN (4, 5);

 

만약 @BatchSize를 FetchType.EAGER와 함께 사용한다면 즉시로딩해야하기 때문에 Order를 조회함과 동시에 ITEM을 가지고 오는 쿼리를 수행한다

 

@BatchSize를 전역으로 설정하고싶다면 spring.jpa.properties.hibernate.default_batch_fetch_size를 통해 전역설정할 수 있다.

 

반응형