본문 바로가기
Spring/JPA

[JPA] 1:N / N:1 상황에서의 N+1 문제 해결 (Fetch Join ~ 지연로딩/Batch Size)

by 코딩균 2022. 3. 9.

DB의 테이블이 아래와 같은 관계를 가지고 있는 경우

 

N+1 문제 고려하지 않은 코드 ( DB n:1의 관계 ) 

data 가정

  • Order가 2개 존재
  • 각각의 order와 연결된 member는 모두 다른 사람
  • 모든 entity fetchType 은 LAZY -> 지연 로딩

controller

@GetMapping("/orders")
public List<OrderDto> getOrder(){
    List<OrderDto> result = orderRepository.findAll().stream().map(OrderDto::new).collect(toList());
    return result
    }

@Data
@RequiredArgsConstructor
static class OrderDto{
	private Long orderId;
    private String userName;
    private LocalDateTime orderDate;
    
    public OrderDto(Order order){
    	orderId = order.getOrderId();
        userName = order.getMember().getName();
        orderDate = order.getOrderDate();
      	}
    }

repository

public List<Order> findAll(){
	//em - entity manager
    return em.createQuery("select o from Order o join o.user u", Order.class)
        .setMaxResults(1000) 
        .getResultList();
    }

 

결과

  •  > SQL 1회 - Order 결과 2 row

findByName 메소드를 통해 모든 Order를 가져온다

1번의 SQL문이 DB로 가게되며

이때, 일반 join을 사용하는 경우 지연로딩 설정으로 인해 Order의 user 자리에는 Proxy 객체가 들어간다

즉, findAll의 JPQL에서 'join'을 사용해도 연관된 모든 table을 엮어오지 않는다

SELECT
	order0_.order_id as id1_1_,
    order0_.user_id as user_id2_,
    order0_.order_date as order_date1_,
    order0_.status as status2_1,
FROM
	order order0_
INNER JOIN
	user user1_ 
    order0_.order_id=user1_ on user1_.user_id

hibernate를 통해 실제로 SQL이 나간 것을 확인해 보면 위와 같이 join은 하지만 order 관련한 필드만 select 해오는 것을 확인할 수 있다.

즉, JPQL에서 조회하는 주체 Entity (위의 JPQL에서 o)만 영속화 한다

 

  •  > SQL 2회 - 1번째 Order의 member 조회
  •  > SQL 3회 - 2번째 Order의 member 조회

결국 OrderDto 생성자에서 SQL문이 한번더 발생된다

userName = order.getMember().getName();

user의 정보를 조회하는 SQL문이 한번더 발생된다

 

1 (한번의 쿼리) + N (앞의 쿼리로 인해 가져온 row 수만큼 한번 더) 의 SQL이 나가게되는 것이다

위의 예에서는 Order가 2개여서 간단해 보이지만 Order의 개수가 10000개가 넘어간다면 DB I/O에서 엄청난 시간이 소요될 것이다.

 

그럼 애초에 repository에서 한번에 엮어서 조회해오면 되잖아!

 

Fetch Join으로 N+1 문제 해결하기 (DB n:1 관계)

Fetch Join?

fetch join이 걸린 연관 Entity도 모두 SELECT 하여서 영속화

proxy 객체를 넣어두는 것이 아니라 실제로 생 SQL문에서 join 거는 것과 같이 JPA가 가져와서 영속성 컨텍스트에 영속화 시켜버린다

 

SELECT
	order0_.order_id as id1_1_,
    order0_.user_id as user_id2_,
    order0_.order_date as order_date1_,
    order0_.status as status2_1,
   	user1_.user_id as user_id3_,
    user1_.name as name3_,
    user1_.email as email3_
FROM
	order order0_
INNER JOIN
	user user1_ 
    order0_.order_id=user1_ on user1_.user_id

위와 같이 일반 join 할 때와는 달리 user에 대한 data도 모두 select 해와서 

JPA 영속성 context에서 영속화하여 관리할 수 있도록 한다

 

repository

JPQL로는 join fetch 라고 표기

public List<Order> findAll(){
	//em - entity manager
    return em.createQuery("select o from Order o " 
    	+ "join fetch o.user u", Order.class)
        .setMaxResults(1000) 
        .getResultList();
    }

 

 

 

DB 1:n 관계에서 Fetch Join의 문제점

data 가정

  • Order가 2개 존재
  • 각 Order가 OrderItem을 2개씩 가지고 있음
  • 각 OrderItem은 해당 주문에 해당하는 Item을 하나 가지고 있음

controller

@GetMapping("/orders")
public List<OrderDto> getOrder(){
    List<OrderDto> result = orderRepository.findAll().stream().map(OrderDto::new).collect(toList());
    return result
    }

@Data
@RequiredArgsConstructor
static class OrderItemDto{
	private Long orderItemId;
    private String itemName;
    private int price;
    private int count;
    
    public OrderItemDto(OrderItem orderItem){
    	orderItemId = orderItem.getOrderItemId();
    	itemName = orderItem.getItem().getTitle();
        price = orderItem.getPrice();
        count = orderItem.getCount();
        }
    }


@Data
@RequiredArgsConstructor
static class OrderDto{
	private Long orderId;
    private String userName;
    private LocalDateTime orderDate;
    private List<OrderItemDto> orderItems;
    
    public OrderDto(Order order){
    	orderId = order.getOrderId();
        userName = order.getMember().getName();
        orderDate = order.getOrderDate();
        orderItems = order.getOrderItems().stream().map(orderItem->new OrderItemDto(orderItem)).collect(Collectors.toList());
      	}
    }

repository

public List<Order> findAll(){
	//em : entity manager
    return em.createQuery("select distinct o from order o " 
    	+ "join fetch o.user u " 
        + "join fetch o.orderItem oi "
        + "join fetch oi.item i", Order.class).getResultList();
        
    }

JPQL distinct?

  1. DB 에서 중복제거  - SQL 문에 distinct를 추가하여 join 시에 카티션 프로덕트에서 정확히 똑같은, tuple 자체가 동일한 row 를 중복 제거  ( 위의 예에서는 row가 정확하게 일치하지 않기 때문에 DB에서 중복제거 되지 않는다 )
  2. Application에서 중복 제거 - JPA 영속성 컨텍스트에서 자체적으로 order의 id를 보고 entity 단위로 중복을 제거

1:n 관계이므로 카티션 프로덕트로 인해서 아래와 같이 DB에서 SELECT된 결과물이 나온다

 

그러나 DB에서 DISTICNT는 정확히 일치하는 row들에 한하여 중복제거해준다

이를 JPA로 가져와서 application 메모리 단으로 가져오면

컬렉션 리스트 포인터들이 영속성 컨텍스트가 관리 중인 영속된 order entity를 가리키고 있는 구조가 된다

JPQL의 distinct는 메모리 상에서 중복제거를 진행해준다.

 

    --> 이렇게 order가 컬렉션 fetch join 으로 중복되는 것을 방지해준다

 

하지만 fetch join은 치명적 단점이 있다

 

paging이 불가능하다

 

n:1관계에서는 패치 조인을 하여도 .setFirstResult(0) / .setMaxResults(100) 같은 페이징 API를 사용할 수 있었으나

1:n 관계에서는 불가능하다

  • 1. fetch join 으로 인해서 paging의 기준이 모호해진다 -> 1:n 관계에서는 1을 기준으로 paging이 진행되어야 하는데 중복 row가 너무 많아지기 때문에 paging의 기준이 애매해진다
  • 2. 방대한 카티션 프로덕트를 메모리로 가져와서 paging 처리를 한다면 CPU가 망가진다
  • 3. 1:N:N 관계인 경우 row가 많아지고 JPA가 메모리에서 작업을 할 때, data가 혼란스러워져서 정합성이 맞지 않을 수 있다

 

위의 이유로 컬렉션 페치 조인은 아래와 같은 오류를 뱉는다

error collection fetch; applying in memory

데이터가 1000만개라고 한다면 그걸 다 메모리에 올려서 처리한다는 것은 CPU에게 무리가 되므로 오류를 발생시킨다

 

n:1은 fetch join으로 가져오되 1:n 컬렉션 join은 지연 로딩 유지하기

지연 로딩으로 Proxy 객체에 접근했을 때, data 가져오는 방식 'IN' 으로 설정 변경해주기 (Batch Size)

 

다시 한번 ERD를 본다면 Order와 n:1 관계인 User는 fetch join으로 가져와주고

Order와 1:n 관계인 Order_Item 은 application에서 for 문을 돌면서 지연 로딩으로 JPA가 한번더 SQL 보내어 가져오게 한다

@GetMapping("/api/orders")
public List<OrderDto> orders(@RequestParam(value="offset", defaultValue="0") int offset, @RequestParam(value="limit", defaultValue = "100") int limit){
	List<Order> order_list = orderRepository.findAll(offset, limit);
    
    // OrderDto 로 Wrapping 하는 동시에 지연 로딩으로 가져오기
    List<OrderDto> result = order_list.stream().
    	.map(o-> new OrderDto(o))
        .collect(Collectors.toList());
    
    return result;
}

stream 혹은 for 문을 돌면서 OrderDto 와 OrderItemDto 안의 생성자를 통해서 지연로딩으로 설정된 객체들을 가져온다

 

이는 이미 영속성 컨텍스트 안에서 orderItem 과 Item은 proxy 객체로 대체되어있는 상태로 객체 그래프(? - 확실하지는 않지만 현재 내 지식으로는 그렇다) 가 형성되어 있다. 

 

 

그래서 Batch Size에 대한 설정(global 한 방법, entity에 대한 방법, field에 대한 방법) 을 해준다면 

  • SQL의 방식을 IN으로 바꾸어 쿼리수를 줄이고
  • 가져온 데이터를 영속성 컨텍스트 안에서 요리해준다

Batch Size 설정 방법은 따로 포스팅으로 빼놓는다

https://codinggyun.tistory.com/100

 

[JPA] Hibernate Batch Size 설정

Batch Size? 1:N 관계 ( 여기서는 Order : OrderItem 이라고 가정 )에서의 지연 로딩 시에 SQL 문이 각각의 N개의 객체에 대해서 select orderItem0_.order_item_id as order_item_id5_5_1_, orderItem0_.order_id..

codinggyun.tistory.com

 

orderItem SQL

where 절에서 '=' 를 사용하여 각 Order에 해당하는 orderItem을 하나씩 select해 오는 것이 아니라

in 을 사용하여서 order_id가 위의 fetch join 에서 select된 모든 order_id를 담아서 가져온다

JPA 안에는 이미 어떤게 어떤거와 연결되어있다는 그래프?가 있으니 쿼리 한번에 가져와서 JPA가 연결시켜주면 된다

select 
	orderItem0_.order_item_id as order_item_id5_5_1_,
    orderItem0_.order_id as order_id_it1_5_1_,
    orderItem0_.item_id as item_id_it1_5_0,
    orderItem0_.order_price as order_price_id5_5_0,
    orderItem0_.count as count_id5_3_0
from 
	orderItem orderItem0_
where
	orderitem0_.order_id in (
    	?, ?
    )

Item SQL

마찬가지로 order_item에 있는 item_id를 가져다가 in 절에 넣어주어 한번에 조회해와서 

JPA에서 그래프를 보고 맞춰주는 방식이다

select 
	item0_.item_id as item_id2_3_0,
    item0_.title as title2_3_0,
    item0_.price as price3_3_0,
    item0_.stock as stock3_3_2
from
	item as item0_
where
	item0_.item_id in (
    	?, ?, ?, ?
      	)

나간 쿼리의 수는 총 세번이 된다

  • Order + User 쿼리 1번 
  • OrderItem 가져오는 쿼리 1번
  • Item 가져오는 쿼리 1번

-> 1 + 1 + 1

 

정리하자면...

  • n:1인 경우에는 fetch join 으로 한번에 DB에서 다 가져와서 쿼리 수를 최적화 
  • 1:n인 경우에는 지연로딩 전략을 유지하고 Batch Size 설정 적용 후, DTO로 wrapping 할 때, 생성자 등을 통해 가져오기 ( IN SQL로 JPA가 쏴줌 )