JPA 조회 성능 최적화

dev_314·2023년 3월 28일
0

JPA - Trial and Error

목록 보기
11/16

지연 로딩과 조회 성능 최적화

양방향 관계와 무한 루프

다음과 같은 두 Entity가 양방향 의존 관계를 가진다.

@Entity
public class Order {
	
    ...
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;
}

@Entity
public class Member {
	
    ...
    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();
}

아래의 jqpl을 통해 조회한 Order를, Jackson을 사용해서 @ResponseBody에 담아 전달하니

em.createQuery(
	"SELECT o FROM Order o",
    Order.class
).getResultList();

무한 루프에 빠진다.

  1. Order Entity는 Member를 필드로 가진다.
  2. Member Entity는 List<Order>를 필드로 가진다.

즉, 양방향 관계가 무한 루프를 만드는 것이다.

따라서 관계의 한 쪽에는 @JsonIgnore를 사용해서 응답에 포함되지 않도록 한다.

@Entity
public class Member {
	
    ...
    @JsonIgnore
    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();
}

Lazy Loading과 Proxy Object

@JsonIgnore를 통해 양방향 관계에서 비롯된 무한 루프를 해결했다.

이제 다시 Order 응답을 보내려고 하니 bytebuddy에서 예외가 발생한다.

bytebuddy: JPA의 Proxy를 담당하는 라이브러리

Order의 member 필드가 fetch = FetchType.lazy로 설정되어 있기 때문에, 단순히 Order만 조회하면 Member에는 프록시 객체가 설정되기 때문이다.

쉽게 말해 불완전한 객체(?)를 가지고 실제 응답을 만들려니 문제가 생기는 것이다.

여러 해결 방법이 있을 것이다.

  1. fetch = FetchType.EAGER 사용하기
    • 쿼리 최적화, 추적(예측)이 힘들다.
  2. jackson-datatype-hibernate5라이브러를 사용하기
    • jackson이 응답 만드는 과정에서, 프록시 객체를 만나면 force lazy loading 쿼리를 날려서 실제 데이터를 가져온 다음에 응답을 만듦
    • 결국에는 N+1 Problem
  3. 명시적으로 영속화 하기
    • member Entity의 필드를 조회해서 영속화한다.
    • 결국에는 N+1 Problem
    • 실수 가능성(?)
  4. DTO + Lazy Loading + Fetch Join 사용하기

DTO + Lazy Loading + Fetch Join

  1. Entity를 클라이언트에게 노출하는건 좋지 못하다. 그러므로 필요한 데이터만 담은 DTO를 사용하자.
  2. Eager Loading은 쿼리 최적화, 추적이 어렵다. 그러므로 Lazy Loading을 사용하자.
  3. 응답 직렬화(?)문제를 피하기 위해 Fetch Join을 사용하자.
// Controller
@RestController
@RequiredArgsConstructor
public class OrderController {

	private OrderRepository orderRepository;
    
    @GetMapping("/api/orders")
	public List<OrderDto> orders() {
    	return orderRepository.findAll().stream()
        	.map(OrderDto::new)
            .collect(Collectors.toList());
    }
}

// Repository
@Repository
@RequiredArgsConstructor
public class OrderRepository {

	private EntityManager em;
    
    public List<Order> findAll() {
    	return em.createQuery(
        	"SELECT o FROM Order o JOIN FETCH o.member m",
            Order.class
        ).getResultList();
    }
}

JPA 레벨에서 DTO 만들기

다음과 같이 Repository 레벨에서 DTO 객체를 만들 수 있다.

// Repository
@Repository
@RequiredArgsConstructor
public class OrderRepository {

	private EntityManager em;
    
    private List<OrderQueryDto> findOrders() {
        return em.createQuery(
                "select new jpabook.jpashop.repository.order.query.OrderQueryDto(o.id, m.name, o.orderDate, o.status, d.address)" +
                        " from Order o" +
                        " join o.member m" +
                        " join o.delivery d", OrderQueryDto.class)
                .getResultList();
    }
}

JQPL에서 new 키워드를 사용해서 객체를 만들 수 있다.

장점

  1. DTO를 만드는 코드를 따로 만들지 않아도 된다.
  2. DTO를 만들기 위해 필요한 컬럼만 Projection하므로 약간 성능 개선

단점

  1. 재사용성이 떨어진다. (DTO에 의존적인 Repository)

그런데 Repository는 순수하게 Entity만 다뤄야 한다. DTO를 다뤄서는 안 된다. 그러므로 DTO를 다루는 Repository를 별도로 만들자.

결과적으로 Controller(Service)는 두 종류의 OrderRepository를 사용하게 되는 것이다.

권장

  1. Entity를 코드 레벨에서 DTO로 변환하는 방법을 선택
  2. Fetch Join으로 성능 개선
  3. 성능 개선이 더 필요한 경우, JPA 레벨에서 DTO로 조회 시도
  4. 그래도 안 되면 Native SQL 또는 JDBC Template 사용

Collection 조회

참고: Fetch Join

이제는 Collection(@XToMany)을 다뤄보자.

이전 내용을 통해 다음의 과정을 거친다.

  1. Lazy Loading에 의해 Collection의 Entity는 전부 프록시 객체

  2. 강제 초기화를 할 경우, Entity 마다 조회 쿼리가 발생한다(N+1 Problem). 그러므로 Fetch Join을 사용해서 성능을 개선한다.

  3. 그런데 Collection + Fetch Join는 데이터 중복이 발생할 수 있다. 이는 JPQL Distinct로 해결할 수 있다.

  4. 그런데 컬렉션 + Fetch Join은 페이징이 불가능하다.

    • setFirstResult, setMaxResult를 사용해도 실제 SQL 쿼리에 적용되지 않는다.

Hibernate는 모든 데이터 조회해서, 메모리 수준에서 페이징을 지원하나 권장되지 않는다.
DISTINCT를 사용해도 JPA 수준과 SQL 수준에서 조회 결과가 다르기 때문에 페이징을 지원하지 않는 것이다.

Collection 조회와 페이징 쿼리

Collection + Fetch Join은 페이징이 불가능하므로, 아래의 방법으로 해결하자.

  1. @xToOne관계는 컬렉션을 다루지 않으므로, 예상치 못한 데이터 중복 문제가 발생하지 않는다. 따라서 @xToOne관계만 Fetch Join을 사용해서 불러오자.

  2. Fetch Join으로 불러온 Entity들은 @xToMany가 아니므로 페이징 쿼리를 사용할 수 있다.

  3. Fetch Join으로 불러오지 않은 @xToMany (Collection)Lazy Loading으로 불러오자.

  4. Fetch join을 사용하지 않았으므로, N+1 Problem이 발생한다. 성능 최적화를 위해 다음 방법을 사용하자

default_batch_fetch_size (글로벌 설정)

다음과 같은 조회 API가 있다.

// repository
    public List<Order> findAllWithMemberDelivery(int offset, int limit) {
        return em.createQuery(
                "select o from Order o" +
                        " join fetch o.member m" +
                        " join fetch o.delivery d", Order.class)
                .setFirstResult(offset)
                .setMaxResults(limit)
                .getResultList();
    }
    
// controller
    @GetMapping("/api/v3.1/orders")
    public List<OrderDto> ordersV3_page(
    	@RequestParam(value = "offset", defaultValue = "0") int offset,
        @RequestParam(value = "limit", defaultValue = "100") int limit) 
	{

        List<Order> orders = orderRepository.findAllWithMemberDelivery(offset, limit);
        List<OrderDto> result = orders.stream()
				// 생성자 내부에서 orderItem(프록시 객체) & Item에 접근하는 상황
                .map(o -> new OrderDto(o)) 
                .collect(toList());

        return result;
    }

이런 상황에서 다음과 같이 설정을 하면

spring:
	jpa:
		properties:
			hiberante:
				default_batch_fetch_size: 100 
                # 조회 결과가 500건이면, ceil(500 / 100) = 6번의 쿼리가 나간다.

OrderItem에 대해서 Fetch Join이 아니고 Lazy Loading을 사용했으므로, OrderDto 생성자 내부에서 Order Entity의 List<OrderItem>에 접근하면 조회 쿼리가 나간다.

그런데 배치 사이즈를 설정하면 Collection의 Entity들을 설정한 배치 사이즈 만큼, SQL의 IN을 사용해서 한 번의 쿼리로 조회하는 쿼리가 발생한다.

SELECT ...
WHERE orderItem_id IN (...);

예를 들어 다음과 같은 상황이 있다면

총 2개의 Order에, 각각 2개의 OrderItem이 존재한다. 
OrderItem은 Order, Item과 각각 1:1 대응한다.

default_batch_fetch_size 사용 전에는 총 7번의 쿼리가 나간다.

1. Order를 조회하기 위해 1개의 조회 쿼리
2. OrderItem을 조회하기 위해 2개의 조회 쿼리
3. Item을 조회하기 위해 4개의 조회 쿼리가 발생해야 한다.

default_batch_fetch_size 사용 후에는 총 3번의 쿼리가 나간다.

1. Order를 조회하기 위해 1개의 조회 쿼리
2. OrderItem을 조회하기 위해, In을 사용한 1개의 쿼리
3. Item을 조회하기 위해, In을 사용한 1개의 쿼리

default_batch_fetch_size을 사용함으로써 다음의 이점을 얻었다.

  1. Entity 조회할 때 Collection을 조회 대상에서 배제함으로써, Order에 대한 페이징 쿼리도 가능하게 되었다.
  2. Collection을 조회할 때 발생하는 N+1 Problem을 해결했다.

결과적으로 Collection을 포함한 효과적인 페이징 쿼리를 한 것과 같다.

참고: @xToOne에 배치 사이즈 적용하기

em.createQuery(
	"SELECT o FROM Order o",
    Order.class
).getResultList();

@xToOne의 관계에도 default_batch_fetch_size가 적용된다.

1. Order 조회
2. 조회된 Order에 1:1 대응되는 각각의 Member들을 In으로 한 번에 조회
(SELECT * FROM Item WHERE m.order_id IN (...);)

이런 경우에는 In을 통해 Member를 한 번에 조회할 수 있으나, Member조회 쿼리가 발생한다는 단점이 있다. 일반적으로 @xToOne 경우에는 그냥 Fetch Join을 사용한다.

@BatchSize (개별 설정)

@BatchSize을 사용하면 default_batch_fetch_size보다 세세하게 설정할 수 있다.

@Entity
public class Order {
	
    ...
    @BatchSize(size = 100)
    @OneToMany(mappedBy = "order")
   	private List<OrderItem> orderItems = new ArrayList<>();
}

@xToOne의 경우에는 클레스 레벨에 사용해야 한다.

@BatchSize(size = 200)
@Entity
public class Item {...}

JPA 레벨에서 DTO 만들기

@Repository
public class OrderQueryRepository {

    public List<OrderQueryDto> findOrderQueryDtos() {
	    // 1. `findOrders`를 통해 `OrderQueryDto`를 바로 만든다.
		// 2. 그런데 JPA 스팩상, `Collection`은 바로 할당할 수 없다.
        List<OrderQueryDto> result = findOrders();
		// 3. 그러므로, Collection이 할당 안 된 상태로 `OrderQueryDto`를 만든 다음, 
    	// Collection만 따로 조회해서 코드로 값을 할당한다.
        // 결국 N+1 Problem 발생
        result.forEach(o -> {
            List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());
            o.setOrderItems(orderItems);
        });
        return result;
    }

	private List<OrderQueryDto> findOrders() {
        return em.createQuery(
                "select new jpabook.jpashop.repository.order.query.OrderQueryDto(o.id, m.name, o.orderDate, o.status, d.address)" +
                        " from Order o" +
                        " join o.member m" +
                        " join o.delivery d", OrderQueryDto.class)
                .getResultList();
    }

    private List<OrderItemQueryDto> findOrderItems(Long orderId) {
        return em.createQuery(
                "select new jpabook.jpashop.repository.order.query.OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count)" +
                        " from OrderItem oi" +
                        " join oi.item i" +
                        " where oi.order.id = : orderId", OrderItemQueryDto.class)
                .setParameter("orderId", orderId)
                .getResultList();
    }
}
  1. DTO를 JPA 레벨에서 바로 조회하려고 한다.
  2. OrderQueryDto를 접근 하는 과정에서 @xToMany관계인 OrderItemQueryDto는 제외 함
  3. 제외한 Collection은 따로 쿼리를 날려서 조회해야 함

N(OrderItemQueryDto 조회) + 1(OrderQueryDto 조회) 문제가 발생했다.

JPA 레벨에서 DTO 만들기: 개선1

명시적으로 IN을 사용해서 개선할 수 있다.

@Repository
public class OrderQueryRepository {

    public List<OrderQueryDto> findOrderQueryDtos_opt() {
    	// 1. Collector를 제외해서, Fetch Join으로 불러오기
        List<OrderQueryDto> result = findOrders();
        // 2. 위 결과에서 Id만 뽑아오기
        List<Long> ids = result.stream()
        							.map(o -> o.getId())
                                    .collect(Collectors.toList());

		// 3. `IN`을 사용해서 한 번에 불러오기 (N+1 Problem 해결)
		List<OrderItemQueryDto> orderItems = em.createQuery(
			"select new jpabook.jpashop.repository.order.query.OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count) " +
			"from OrderItem oi " +
			"join oi.item i " +
			" where oi.order.id IN :ids",
            OrderItemQueryDto.class
        ).setParameter("ids", ids)
        .getResultList();
        // 4. OrderId : OrderItem 형태로 만들기
        Map<Long, List<OrderItemQueryDto>> orderItemMap = orderItems
        .stream()
        .collect(Collectors.groupingBy(OrderItemQueryDto::getOrderId));
		// 5. Order에 OrderItem 넣어주기
		result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));
        
		return result;
    }
} 
  1. Order조회를 위해 쿼리 1번
  2. 각 Order에 대한 Item 조회를 위한 쿼리 1번

IN을 통해 N+1 Problem을 해결했다.

JPA 레벨에서 DTO 만들기: 개선2

N + 11 + 1로 개선했는데, 이를 1로 개선할 수 있다.

1. Order 불러오기
2. OrderItem + Item 불러오기
DTO를 사용해서, 한 번에 전부 조회하는 한 방 쿼리를 만들 수 있다.
1. Order + OrderItem + Item 불러오기
// DTO
@Data
@EqualsAndHashCode(of = "orderId")
@AllArgsConstructor
public class OrderFlatDto {
	private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;
    
    private String itemName;
    private int orderPrice;
    private int count;
}

Collection 고려 없이, 그냥 전부 Join으로 묶어서 쿼리를 날리면

  1. 데이터 중복
  2. 페이징 불가능

문제가 발생함을 알고 있다.

	em.createQuery(
    	"SELECT NEW 경로명.OrderFlatDto(o.id, m.name, o.orderDate, o.status,. d.address, i.name, )" +
        "FROM Order o " +
        "JOIN o.member m " +
        "JOIN o.delivery d " + 
        "JOIN o.orderItems oi " +
        "JOIN oi.item i",
        OrderFlatDto.class
    ).getResultList();

아래 처럼, 코드 레벨에서 중복을 제거할 수 있긴 하다.

    @GetMapping("/api/v6/orders")
    public List<OrderQueryDto> ordersV6() {
    	// 중복이 포함된 결과
        List<OrderFlatDto> flats = orderQueryRepository.findAllByDto_flat();

		// OrderFlatDto의 "orderId" 기준으로 중복을 제거한 뒤,
        // OrderFlatDto -> OrderQueryDto로 만드는 코드
        return flats.stream()
                .collect(groupingBy(o -> new OrderQueryDto(o.getOrderId(), o.getName(), o.getOrderDate(), o.getOrderStatus(), o.getAddress()),
                        mapping(o -> new OrderItemQueryDto(o.getOrderId(), o.getItemName(), o.getOrderPrice(), o.getCount()), toList())
                )).entrySet().stream()
                .map(e -> new OrderQueryDto(e.getKey().getOrderId(), e.getKey().getName(), e.getKey().getOrderDate(), e.getKey().getOrderStatus(), e.getKey().getAddress(), e.getValue()))
                .collect(toList());
    }

장점

  1. 한 방 쿼리로 데이터를 불러올 수 있다.

단점

  1. 데이터 중복 발생
  2. 데이터 중복을 제거하기 위한 App 비용 발생
  3. 한 방 쿼리긴 한데, 결과 데이터 크기가 너무 크다 (네트워크 비용)
  4. 페이징 불가능

권장

  1. Entity 조회 방식으로 우선 접근
    1. Fetch Join으로 쿼리 수 최적화
    2. 컬렉션 최적화
      1. 페이징 필요: default_batch_fetch_size 사용
      2. 페이징 불필요: 그냥 Fetch Join해도 무관 (+ Distinct)
  2. Entity 조회 방식으로 해결 안 되면 DTO 사용
  3. DTO 조회 방식으로 해결 안 되면 Native SQL or JDBC Template 사용
  • 코드 복잡도 <--> 성능 최적화 사이의 Tradeoff를 고려해야 함

Open Session In view

Session(Hibernate) = Entity Manager(JPA)

JPA는 언제 DB Connection을 가져오고 반납할까?

Persistence Context(이하 PC)가 정상적으로 작동하려면 DB Connection을 가지고 있어야 한다. 그래야 Lazy Loading, Dirty Checking 등이 가능할 것이다.

PC와 DB Connection은 밀접히 연관되어 있는데, 기본적으로 PC는 DB Transaction이 시작할 때 Connection을 가져온다.

그러면 반납은 언제 이뤄질까? 이는 Open Session In View에 따라 다르다.

spirng:
	jpa:
    	open-in-view: false # default true

OSIV가 켜져있으면 (기본값 true), Transaction이 종료되어도 커넥션을 반환하지 않는다. 다른 곳(계층)에서 쿼리가 나갈 수 있기 때문이다. (Lazy loading 등)
대신 API응답을 완료하면 (View Render가 될 때 까지) 커넥션을 반환한다,

그런데 응답이 나갈때 까지 커넥션을 붙잡고 있으면, 커넥션 풀에 커넥션이 말라버릴 수 있다는 단점이 있다.

OSIV이 꺼져있으면 Transaction에 맞춰 PC가 날라가고 커넥션도 반환한다.
이를 통해 커넥션을 빠르게 반환할 수 있다는 장점이 있다. 그렇지만 트랜잭션 밖에서 지연 로딩을 사용하지 못한다는 단점이 있다.

OSIV를 끈 상태에서, 다른 계층 (트랜잭션 밖의 범위)에서 프록시 객체를 초기화 하려먼 org.hibernate.LazyInitializeationException이 발생한다. PC를 통해 프록시 초기화를 하려고 했는데, PC가 없기 때문이다. (준영속 상태)

그렇다면 OSIV를 끈 상태도 어떻게 서비스를 만들까? 여러 방법이 잇다.

  1. 트랜잭션 내부에서 프록시 객체 초기화 하기
  2. Fetch Join을 사용해서 프록시 객체 상태 없도록 하기
  3. 구조적으로 해결하기

구조적으로 해결하기

프록시 초기화 작업을 별도로 묶어 놓는 방식으로 설계할 수 있다.

// 데이터 접근 서비스 계층
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true);
public class OrderQueryService {
	
    // Query
    public List<OrderDto> orders() {
    	List<Order> orders = orderRepository.findAllWithItem();
        
        List<OrderDto> result = orders.stream()
        								.map(OrderDto::from)
                                        .collect(toList());
		return result;
    }
}
// 비즈니스 로직 수행 서비스 계층
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true);
public class OrderSerivce {
	
    private OrderQueryService orderQueryService;
    
    // Command
    public List<OrderDto> orders() {
    	return orderQueryService.orders();
    }
}

OrderService
OrderService: 핵심 비즈니스 로직
OrderQueryService: API에 맞춘 서비스 (주로 읽기 전용)

이처럼 CommandQuery를 분리하는 것이 좋다.

profile
블로그 이전했습니다 https://dev314.tistory.com/

0개의 댓글