[JPA 활용 2편] 2. 지연 로딩과 성능 최적화

HJ·2024년 2월 15일
0

JPA 활용 2편

목록 보기
2/4
post-thumbnail
post-custom-banner

김영한 님의 실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화 강의를 보고 작성한 내용입니다.


[ Order ]

public class Order {
    @Id @GeneratedValue
    @Column(name = "order_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();

    @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    private Delivery delivery;

    @Enumerated(EnumType.STRING)
    private OrderStatus status; // 주문 상태 : ORDER, CANCEL

    private LocalDateTime orderDate;
    ...
}

@xToOne 관계인 Order, Order ➜ Member( N : 1 ), Order ➜ Delivery ( 1 : 1 ) 에 대한 내용입니다.


1. 엔티티를 직접 노출

@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {

    private final OrderRepository orderRepository;

    @GetMapping("/api/v1/simple-orders")
    public List<Order> ordersV1() {
        return orderRepository.findAllByString(new OrderSearch());
    }
}

1-1. 문제점

문제점 1 : 무한 루프

Order 를 보면 Member 가 있습니다. Member 를 가보면 List<Order> 가 있습니다. 그래서 양방향 관계에 의한 무한 루프에 빠지게 되고, 오류가 발생하게 됩니다.

➜ 해결 : 모든 양방향 관계에서 한쪽에 @JsonIgnore 를 사용합니다.


문제점 2 : Type definition error: [simple type, class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor]

Order 에서 Member 는 지연 로딩이기 때문에 Member 에 대한 내용은 가져오지 않고 하이버네이트에서 BtyeBuddy 라는 프록시 기술을 통해 프록시 객체를 생성해서 넣어둡니다.

Member 에 프록시 객체를 가짜로 넣어두고, Member 객체의 값을 이용하는 경우 그때 DB 에서 SQL 을 날려서 객체의 값을 가져와 채워주게 됩니다.

Jackson 라이브러리가 응답을 만들면서 Member 를 뽑아보려 할 때 순수한 Member 객체가 아니기 때문에 이 프록시 객체를 json으로 어떻게 생성해야 하는지 몰라 예외가 발생합니다.

➜ 해결 : Hibernate 5 라이브러리를 추가하고 Hibernate5Module 을 스프링 빈으로 등록


[ JpashopApplication ]

@Bean
Hibernate5JakartaModule hibernate5JakartaModule() {
	return new Hibernate5JakartaModule();
}

초기화 된 프록시 객체만 노출되고, 초기화 되지 않은 프록시 객체는 노출하지 않기 때문에 NULL 로 표현하게 됩니다.


1-2. 프록시 객체 초기화

NULL 로 표현되는 객체에 데이터를 출력하기 위해서 아래와 같은 방법을 사용할 수 있습니다.

1-2-1. 강제 지연 로딩 설정

@Bean
Hibernate5Module hibernate5Module() {
    Hibernate5Module hibernate5Module = new Hibernate5Module();
    //강제 지연 로딩 설정
    hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING, true);
    return hibernate5Module;
}

Hibernate5Module 을 빈으로 등록할 때 위의 코드처럼 작성하면 연관관계에 있는 모든 데이터들도 함께 가져오게 됩니다.


1-2-2. Lazy 초기화

@GetMapping("/api/v1/simple-orders")
public List<Order> ordersV1() {
    List<Order> all = orderRepository.findAllByString(new OrderSearch());
    for (Order order : all) {
        order.getMember().getName();
    }
    return all;
}

만약 연관관계 중 원하는 데이터만 가져오고 싶다면 Controller 에 위처럼 작성합니다.

문제점 2에서 설명한 것처럼 프록시 객체의 값을 이용하기 때문에 DB 에서 Member 의 데이터를 가져오게 됩니다.

하지만 엔티티를 노출하는 방법은 이전 게시글에서 말한 것처럼 좋지 않은 방법입니다. 또 강제로딩을 설정해 정보를 가져와도 안됩니다.




2. 엔티티를 DTO 로 변환

2-1. Lazy 초기화

public class OrderSimpleApiController {
    ...
    @GetMapping("/api/v2/simple-orders")
    public List<SimpleOrderDto> orderV2 () {
        // 2개
        List<Order> orders = orderRepository.findAllByString(new OrderSearch());
        // orders 에 의해 2번 반복
        return orders.stream().map(order -> new SimpleOrderDto(order)).toList();
    }

    @Data
    static class SimpleOrderDto {
        private Long orderId;
        private String name;
        private LocalDateTime orderDate;
        private OrderStatus orderStatus;
        private Address address;

        public SimpleOrderDto(Order order) {
            orderId = order.getId();
            name = order.getMember().getName(); // Lazy 초기화
            orderDate = order.getOrderDate();
            orderStatus = order.getStatus();
            address = order.getDelivery().getAddress(); // Lazy 초기화
        }
    }
}

이전 게시글에서 한 것처럼 필요한 정보만을 세팅한 DTO 를 만들어서 반환합니다. 이전과 다른 점은 연관관계가 있다는 점입니다.

위에서 프록시 객체의 값을 이용하면 실제 데이터를 불러온다고 했는데 이를 Lazy 초기화라고 합니다.

Lazy 초기화는 영속성 컨텍스트가 member의 ID 를 가지고 영속성 컨텍스트에서 찾습니다. 없다면 DB 에 쿼리를 날려서 데이터를 가져오게 됩니다.


2-2. 문제점

문제점 : 1 번과 2번의 동일한 문제인데 날라가는 쿼리의 수가 많다는 점입니다.
( N + 1 문제 )

현재 Order 에 데이터가 2개 존재합니다. 그래서 findAllByString() 의 결과가 2개가 나오게 되고, DTO 로 변환하는 stream 이 2번 반복되게 됩니다.

처음 루프를 돌 때 getMember() 에 의해 Member 테이블에, getDelivery() 에 의해 Delivery 테이블에 쿼리가 나가게 됩니다. 두 번째 루프를 돌 때도 동일하게 2번의 쿼리가 나가게 됩니다.

결과적으로 총 5번( Order 1번, Member 2번, Delivery 2번)의 쿼리가 나가게 됩니다.


2-3. N + 1 문제

위와 같은 상황을 N + 1 문제라고 하는데 처음 나간 쿼리의 결과로 N 번만큼 쿼리가 추가 실행 되는 것을 말합니다.

1 은 처음에 나간 쿼리를 의미합니다. 쿼리의 결과는 2개이므로 N = 2 가 됩니다.
첫 번째 N 은 회원, 두 번째 N 은 Delivery 가 되어 아래와 같은 계산식이 나옵니다.

(N + 1) ➜ (1 + 회원 N + 배송 N), 여기서 N = 2 라고 하였으니 1 + 2 + 2 = 5가 되어 5번의 쿼리가 나가게 됩니다.


단> 지연 로딩은 DB 에 쿼리를 바로 날리지 않고 먼저 영속성 컨텍스트를 찾기 때문에 영속성 컨텍스트에 있는 경우 쿼리가 생략됩니다.

예를 들어, 주문 두 개 모두 동일한 유저가 주문한 것인 경우 처음에는 영속성 컨텍스트에 없기 때문에 Member 테이블에 쿼리가 나가게 되고, 두 번째 루프돌 때는 영속성 컨텍스트에 있기 때문에 Member 테이블에 쿼리가 나가지 않게 됩니다.


2-4. v1 과 v2 의 차이점

v1 에서 order ➜ member 는 지연로딩이므로 실제 엔티티 대신 프록시 객체가 존재합니다.
jackson 라이브러리는 프록시 객체 자체를 json 으로 어떻게 생성하는지 모르기 때문에 Hibernate5JakartaModule 을 스프링 빈으로 등록해서 문제를 해결합니다.

하지만 v2 를 호출할 때 Hibernate5JakartaModule 을 제거하고 실행했을 때 정상적으로 응답이 됩니다. 그 이유는 v1 에서는 Entity 를 직접 반환하기 때문에 프록시 객체를 읽어야 됐었지만, v2 의 경우에는 Entity 를 직접 반환하는 것이 아닌 DTO 를 반환하기 때문에 지연로딩으로 인한 프록시 객체가 있어도 DTO 는 jackson 라이브러리가 읽을 수 있는 객체이기 때문에 제거해도 정상적으로 동작합니다.




3. 패치 조인 최적화

Fetch join 은 데이터베이스에서 엔티티를 조회할 때 연관된 엔티티의 데이터를 함께 가져오는 기능을 말합니다. 이것은 지연 로딩으로 설정된 연관된 엔티티를 즉시로딩하여
N + 1 문제를 방지하고 성능을 최적화하는 데 사용됩니다.

[ OrderSimpleApiController ]

@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> orderV3 () {
    List<Order> orders = orderRepository.findAllWithMemberDelivery();
    return orders.stream().map(order -> new SimpleOrderDto(order)).toList();
}

[ OrderRepository ]

public List<Order> findAllWithMemberDelivery() {
    return em.createQuery(
        "select o from Order o " +
        "join fetch o.member m " +
        "join fetch o.delivery d", Order.class)
    .getResultList();
}

Order 를 조회하면서 Member 와 Delivery 를 한 번에 가져오는 JPQL 입니다.

Member 와 Delivery 가 Lazy 가 걸려있지만 이를 다 무시하고 위의 쿼리를 수행할 때는 그냥 다 값을 채워서 프록시가 아닌 진짜 객체를 가져옵니다.

결과적으로 이전에 5번의 쿼리로 조회하던 것을 한 번의 쿼리로 조회할 수 있게 됩니다.

즉, 기본적으로 전부 Lazy 를 걸어놓고, 필요한 것만 fetch join 으로 가져오는 방법이 가장 좋은 방법입니다.




4. JPA 에서 DTO 로 바로 조회

[ OrderSimpleApiController ]

@GetMapping("/api/v4/simple-orders")
public List<OrderSimpleQueryDto> orderV4 () {
    return orderRepository.findOrderDtos();
}

[ OrderSimpleQueryDto ]

@Data
public class OrderSimpleQueryDto {
    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;

    public OrderSimpleQueryDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address) {
        this.orderId = orderId;
        this.name = name;
        this.orderDate = orderDate;
        this.orderStatus = orderStatus;
        this.address = address;
    }
}

[ OrderRepository ]

public List<OrderSimpleQueryDto> findOrderDtos() {
    return em.createQuery(
                "select new jpabook.jpashop.OrderSimpleQueryDto(o.id, m.name, o.orderDate, o.status, d.address) "  +
                "from Order o " +
                "join o.member m " +
                "join o.delivery d", OrderSimpleQueryDto.class)
            .getResultList();
}

3번은 Entity 의 모든 필드들을 들고오는 반면 new 를 사용해서 JPQL 의 결과를 DTO로 즉시 변환함으로써 일반적인 SQL을 사용할 때처럼 원하는 값을 선택해서 조회할 수 있습니다.

다만 엔티티로 조회할 떄는 모든 필드를 반환하기 때문에 나중에 필요한 필드를 뽑아서 사용할 수 있지만, 특정한 필드만을 가진 DTO 를 반환하기 때문에 재사용성이 떨어지게 됩니다.

즉, findAllWithMemberDelivery() 는 다른 곳에서도 사용할 수 있지만 findOrderDtos() 는 그렇지 않다는 의미입니다.

영한님이 권장하는 쿼리 방식 선택 순서는 아래와 같습니다.

  1. 우선 엔티티를 DTO로 변환하는 방법을 선택한다.

  2. 필요하면 패치 조인으로 성능을 최적화 한다. ➜ 대부분의 성능 이슈가 해결된다.

  3. 그래도 안되면 DTO로 직접 조회하는 방법을 사용한다. ➜ DTO 를 반환하는게 패치조인보다 성능이 좋음

  4. 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 사용한다.

post-custom-banner

0개의 댓글