BackEnd/JPA

[JPA] 지연 로딩과 조회 성능 최적화 - 1

샤아이인 2022. 4. 19.

내가 공부한 것을 올리며, 중요한 단원은 저 자신도 곱씹어 볼 겸 상세히 기록하고 얕은 부분들은 가볍게 포스팅하겠습니다.

 

간단한 주문 조회 V1: 엔티티를 직접 노출

우선 다음과 같은 controller가 있다고 해보자. (잘못 만든 컨트롤러이다. 오류 상황을 보이기 위한 목적이다)

@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {

    private final OrderRepository orderRepository;

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

위 코드의 문제는 List<Order>를 반환할 때이다.

 

Order 코드에 가보면 다음과 같이 되어있다.

public class Order {

    @Id @GeneratedValue
    @Column(name = "order_id")
    private Long id;

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

Order는 member에 대한 필드를 가지고 있다. 따라서 member에 대한 정보도 다시 조회해와야 한다.

 

member 클래스는 다음과 같다.

public class Member {

    @Id
    @GeneratedValue
    @Column(name = "member_id")
    private Long id;

    // 일부 생략...

    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();
}

문제는 여기서 다시 List<Order>를 조회해야 한다는 점이다.

 

맨 처음 List<Order> 호출되게 된다. => Order내부의 member를 호출 => Member 내부의 List<Order>호출

순환 오류에 빠지게 된다.

 

따라서 다음과 같이 끝없는 Json이 출력되게 되어버린다.

이러한 문제를 해결하기 위해서는 Member에서 반대로 가는 OneToMany 부분에 @JsonIgnore를 추가해줘야 한다.

 

따라서 코드는 다음과 같아진다.

@Entity
@Getter @Setter
public class Member {

    @Id
    @GeneratedValue
    @Column(name = "member_id")
    private Long id;

    private String name;

    @Embedded
    private Address address;

    @JsonIgnore // 이부분 추가!!!
    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();
}

위와 같은 방식으로 양방향이 걸리는 부분을 모두 @JsonIgnore 처리해줘야 한다!

 

이상태로 다시 PostMan을 통해 요청을 보내보자!

이번에는 다른 오류가 발생해 버렸다.... 

 

trace부분을 잘 읽어보면 hibernate.porxy.pojo.bytebuddy.~~ 의 오류가 발생했음을 알 수 있다.

이러한 오류는 왜 발생한 것 일까? 이는 JPA의 LAZY Loding과 관련이 있다.

 

우리의 Order Class를 다시 한번 살펴보자.

public class Order {

    @Id @GeneratedValue
    @Column(name = "order_id")
    private Long id;

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

Member에 대한 전략이 FetchType.LAZY로 설정되어 있다. 지연 로딩이기 때문에 진짜 Member 객체를 가지고 있지 않다!

 

Proxy 객체를 다음과 같이 가지고 있는 상황이 된다.

private Member member = new ByteBuddyInterceptor();

이렇게 Porxy를 가지고 있다가, 나중에 직접 member 객체에 대한 정보가 필요할 때 DB에 쿼리를 날려 가져오게 된다.

 

문제는 Json 라이브러리가 Order를 Json으로 변환시키려 할 때, member가 순수한 Member Class 가 아니라 변환시켜 줄 수 없는 것이다.

 

따라서 이런 지연 로딩인 경우 Json 라이브러리가 아무것도 하지 않도록 명시해줄 수 있다.

바로 Hibernate5Module 을 스프링 빈으로 등록하여 해결하는 것이다.(스프링( 부트 사용 중))

@SpringBootApplication
public class JpashopApplication {

	public static void main(String[] args) {
		SpringApplication.run(JpashopApplication.class, args);
	}

	@Bean
	Hibernate5Module hibernate5Module() {
		return new Hibernate5Module();
	}
}

이렇게 Bean을 등록해 준 후 다시 api를 통해 조회해 보면 다음과 같아진다.

id, orderDate, status 등은 정상적으로 출력되지만,

LAZY 로딩으로 걸려있는 필드들인 member, orderItems, delivery는 null로 출력되게 된다.

 

여기서 다음과 같이 설정하면 강제로 지연 로딩 부분을 함께 불러올 수 있다.

@Bean
Hibernate5Module hibernate5Module() {
    Hibernate5Module hibernate5Module = new Hibernate5Module();
    hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING, true);
    return hibernate5Module;
}

따라서 실행 결과는 다음과 같아진다.

아까는 null로 나오지 않던 부분들이 정상출력되고 있다. 하지만 모든 지연 로딩 관계들이 전부 로딩된다. (성능 하락)

 

내가 원하는 연관관계들만 로딩해오고 싶다면 다음과 같이 설정할 수도 있다. (기존의 FORCE_LAZY_LOADING은 주석처리)

@Bean
Hibernate5Module hibernate5Module() {
    Hibernate5Module hibernate5Module = new Hibernate5Module();
    // hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING, true);
    return hibernate5Module;
}


@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;
}

반복문을 돌면서 원하는 부분의 메서드만 호출해주면 된다.

위 코드에서도 order.getMember() 까지는 Proxy에 해당된다.

Proxy의 getName()이라는 메서드를 호출하는 순간, 지연 로딩 부분이 전부 강제 초기화돼서 데이터를 가지고 오게 된다.

 

실행 결과는 다음과 같다. member에 대한 부분은 초기화하여 데이터를 가져오게 되었다.

 

위와 같은 방식으로 "가능은 하다", 하지만 엔티티를 직접 반환해서는 안된다는 점을 떠올려 보자.

지금 우리의 Controller는 List<Order>를 직접적으로 반환하고 있다. 이런 식의 엔티티를 직접 노출하는 것은 좋지 않다.

 

간단한 주문 조회 V2: 엔티티를 DTO로 변환

V2에서는 Entity가 아닌, DTO를 반환하도록 변경해보자!

 

변경된 코드는 다음과 같다.

@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {

    private final OrderRepository orderRepository;

    @GetMapping("/api/v2/simple-orders")
    public List<SimpleOrderDto> ordersV2() {
        List<Order> orders = orderRepository.findAllByString(new OrderSearch());
        return orders.stream()
                .map(SimpleOrderDto::new)
                .collect(Collectors.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에서 인자로 Entity를 받는 것은 크게 문제가 되지 않는다.

따라서 인자로 Order를 받아 getter로 값을 추출한 후, 이를 받는 DTO를 만들게 되었다.

 

stream.map을 통해 변환하여 DTO List로 반환하게 된다.

 

postman으로 조화해보면 다음과 같은 결과가 나오게 된다.

DTO를 통해 반환하니 API의 스펙이 명확하게 정의되었다.

 

하지만 아직도 문제가 남아있으니... 바로 LAZY 로딩 초기화로 인해 쿼리가 너무 많이 나간다는 점이다.

Order 한번 조회 -> Member이름 조회 -> Address 조회로 한 명당 3번의 쿼리가 발생하게 된다.

 

가장 먼저 다음과 같이 Order를 조회해 온다. 총 2건의 Order를 우리의 예제에서는 조회하게 된다.

 

그다음으로 Member와 Delivery를 조회해 온다. 즉, 한 Order당 2번의 쿼리가 추가로 발생하였다.

1) 맨 처음 Order를 조회하는 쿼리 1번 (Order 2개 조회)

2) 1번 Order의 Member와 Delivery를 조회 (쿼리 2번)

3) 2번 Order의 Member와 Delivery를 조회 (쿼리 2번)

도합 5번의 쿼리가 발생하게 된다. 즉, N + 1 문제가 발생하였다.

 

여기서 만약 같은 member가 주문했다면 어떻게 되었을까?

원래는 다음 왼쪽 사진처럼 Member_id 가 1, 8로 서로 다른 member가 주문한 것 이였다.

하지만 2개의 주문 모두 1번 member가 주문했다면?, 즉 오른쪽 사진 처럼 된다면 어떻게 될까?

 

쿼리가 4번 나가게 된다!

1) 맨 처음 Order를 조회하는 쿼리 1번 (Order 2개 조회)

2) 1번 Order의 Member와 Delivery를 조회 (쿼리 2번)

3) 2번 Order의 Delivery를 조회 (쿼리 1번, Member는 이미 이전에 조회해서 영속성컨텍스트 안에 존재)

 

이러한 N+1 문제를 다음 시간에 Join Fetch를 활용하여 극복해 보자!

댓글