Home JPA - 지연로딩과 성능 최적화
Post
Cancel

JPA - 지연로딩과 성능 최적화

OneToOne, ManyToOne 관계에서의 지연로딩과 성능 최적화 예시를 위해 간단한 주문조회 API 컨트롤러와 엔티티를 구현하고 단계별로 어떻게 최적화 하는지 확인

엔티티 예시

example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Entity
public class Member {

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

  private String name;

  @Embedded
  private Address address;

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

@Entity
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(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
  @JoinColumn(name = "delivery_id")
  private Delivery delivery;

  ...
}

주문조회 V1 - 엔티티를 직접 노출

1
2
3
4
5
@GetMapping("/api/v1/simple-orders")
public List<Order> ordersV1() {
  List<Order> all = orderRepository.findAllByString(new OrderSearch());
  return all;
}
  • 엔티티를 직접 노출
  • 위 코드를 실행하면 예외가 발생함
    • order -> member 와 order -> address 의 관계가 지연로딩일떄, member와 address는 실제 엔티티 대신 프록시가 존재하게 됨
    • jackson 라이브러리는 이러한 프록시 객체를 json으로 어떻게 생성해야 하는지 모르므로 예외를 발생 시킴
    • 예외를 해결하기 위해 Hibernate5Module 을 스프링 빈으로 등록하면 해결


위 코드에 대해 Hibernate5Module 을 설치하여 예외를 해결하더라도 api 동작시 응답을 확인해보면, order의 member 필드의 값이 JSON에서 null로 응답하게 됨. 이는 지연 로딩으로 인하여 member 가 실제 엔티티가 아닌 프록시이기 때문인데, 이에 대한 해결방법은 아래와 같이 강제로 초기화하여 엔티티를 로딩 시켜주면 해결이 됨.

1
2
3
4
5
6
7
8
9
10
11
12
@GetMapping("/api/v1/simple-orders")
public List<Order> ordersV1() {
  List<Order> all = orderRepository.findAllByString(new OrderSearch());
  for (Order order : all) {
    // Lazy 강제 초기화
    order.getMember().getName();

    // Lazy 강제 초기환
    order.getDelivery().getAddress();
  }
  return all;
}


위 V1 코드의 문제점 및 주의점들은 아래와 같음

  • 엔티티를 직접 노출할 때는 양방향 연관관계가 걸린 곳은 한곳을 @JsonIgnore 처리 해야 함
    • @JsonIgnore를 사용하지 않는다면 양쪽을 서로 호출하면서 무한 루프가 걸리게 됨
  • 정말 간단한 애플리케이션이 아니면 엔티티를 API 응답으로 외부로 노출하는 것은 피하는 것이 좋음
    • Hibernate5Module 를 사용하기 보다는 DTO로 변환해서 반환하는 것이 더 좋은 방법
  • 지연 로딩(LAZY)을 피하기 위해 즉시 로딩(EARGR)으로 설정하면 안됨
    • 즉시 로딩 때문에 연관관계가 필요 없는 경우에도 데이터를 항상 조회해서 성능 문제가 발생할 수 있음
    • 즉시 로딩으로 설정하면 성능 튜닝이 매우 어려워 짐
  • 항상 지연 로딩을 기본으로 하고, 성능 최적화가 필요한 경우에는 페치 조인(fetch join)을 사용하는 것을 추천

주문조회 V2 - 엔티티를 DTO로 변환

API의 응답을 엔티티로 바로 전달하는게 아닌 별도의 DTO를 만들어서 해당 DTO를 응답으로 반환하는 방식

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/**
 * V2. 엔티티를 조회해서 DTO로 변환(fetch join 사용X)
 * - 단점: 지연로딩으로 쿼리 N번 호출
 */
@GetMapping("/api/v2/simple-orders")
public Result<List<SimpleOrderDto>> ordersV2() {
  log.info("OrderSimpleApiController.ordersV2");
  List<Order> orders = repository.findAllByString(new OrderSearch());
  List<SimpleOrderDto> simpleOrderDtoes =
    orders.stream()
      .map(order -> new SimpleOrderDto(order))
      .collect(Collectors.toList());

  Result<List<SimpleOrderDto>> result = new Result<>(simpleOrderDtoes);
  return result;
}

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

  public SimpleOrderDto(Order order) {
    this.orderId = order.getId();

    // Lazy loading 초기화
    // 영속성 컨텍스트에 조재하지 않는다면 조회 쿼리 발생
    this.name = order.getMember().getName();
    this.orderDate = order.getOrderDate();
    this.orderStatus = order.getStatus();

    // Lazy loading 초기화
    // 영속성 컨텍스트에 조재하지 않는다면 조회 쿼리 발생
    this.address = order.getDelivery().getAddress();
  }
}

@Data
static class Result<T> {
  private T data;

  public Result(T data) {
    this.data = data;
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
  "data": [
    {
      "orderId": 4,
      "name": "userA",
      "orderDate": "2023-10-27T12:02:55.087198",
      "orderStatus": "ORDER",
      "address": {
        "city": "seoul",
        "street": "1",
        "zipcode": "1111"
      }
    },
    {
      "orderId": 11,
      "name": "userB",
      "orderDate": "2023-10-27T12:02:55.11428",
      "orderStatus": "ORDER",
      "address": {
        "city": "busan",
        "street": "2",
        "zipcode": "2222"
      }
    }
  ]
}


API의 응답으로 DTO로 반환하도록 하여 V1 보다는 개선됬지만, 이 코드에선 한가지 문제가 더 존재함. 해당 API 실행하고 로그를 확인해보면, order 한번을 조회하여 쿼리가 한개만 나가야 할것 처럼 보이지만 총 5번의 쿼리가 실행됨.

2023-10-27 12:03:00.106 DEBUG 78441 --- [nio-8080-exec-1] org.hibernate.SQL                        :
    select
        order0_.order_id as order_id1_6_,
        order0_.delivery_id as delivery4_6_,
        order0_.member_id as member_i5_6_,
        order0_.order_date as order_da2_6_,
        order0_.status as status3_6_
    from
        orders order0_
    inner join
        member member1_
            on order0_.member_id=member1_.member_id limit ?
...

2023-10-27 12:03:00.121 DEBUG 78441 --- [nio-8080-exec-1] org.hibernate.SQL                        :
    select
        member0_.member_id as member_i1_4_0_,
        member0_.city as city2_4_0_,
        member0_.street as street3_4_0_,
        member0_.zipcode as zipcode4_4_0_,
        member0_.name as name5_4_0_
    from
        member member0_
    where
        member0_.member_id=?
...

2023-10-27 12:03:00.124 DEBUG 78441 --- [nio-8080-exec-1] org.hibernate.SQL                        :
    select
        delivery0_.delivery_id as delivery1_2_0_,
        delivery0_.city as city2_2_0_,
        delivery0_.street as street3_2_0_,
        delivery0_.zipcode as zipcode4_2_0_,
        delivery0_.status as status5_2_0_
    from
        delivery delivery0_
    where
        delivery0_.delivery_id=?
...

2023-10-27 12:03:00.125 DEBUG 78441 --- [nio-8080-exec-1] org.hibernate.SQL                        :
    select
        member0_.member_id as member_i1_4_0_,
        member0_.city as city2_4_0_,
        member0_.street as street3_4_0_,
        member0_.zipcode as zipcode4_4_0_,
        member0_.name as name5_4_0_
    from
        member member0_
    where
        member0_.member_id=?
...

2023-10-27 12:03:00.126 DEBUG 78441 --- [nio-8080-exec-1] org.hibernate.SQL                        :
    select
        delivery0_.delivery_id as delivery1_2_0_,
        delivery0_.city as city2_2_0_,
        delivery0_.street as street3_2_0_,
        delivery0_.zipcode as zipcode4_2_0_,
        delivery0_.status as status5_2_0_
    from
        delivery delivery0_
    where
        delivery0_.delivery_id=?
...

이 로그에 대한 분석은 아래와 같으며 이러한 문제를 N + 1 문제 라고 함

  • 쿼리가 총 1 + N + N번 실행됨 (v1과 쿼리수 결과는 같음)
    • order 조회 1번 order 조회 결과 수가 N이 됨
    • order -> member 지연로딩 조회 N 번
    • order -> delivery 지연로징 조회 N 번
    • 예) order의 결과가 4개면 최악의 경우 1 + 4 + 4 번 실행 됨
      • 지연로딩은 영속성 컨텍스트에서 조회하므로 이미 조회된 경우 쿼리를 생략 함


이러한 N + 1 문제를 해결하기 위해선 최초 order를 조회할떄 연관된 엔티티들도 한번에 조회를 해야하는데, 이때 즉시로딩(EAGER)를 사용하면 안되고 fetch join 을 통하여 문제를 해결해야함.

  • 즉시로딩(EAGER)를 사용시 최초 조회시 연관된 엔티티를 모두 조회하긴 하지만 어떠한 쿼리가 발생할지 직관적으로 알기에 어려워 사용을 피하는 것이 좋음

주문조회 V3 - 엔티티를 DTO로 변환 및 페치 조인 최적화

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Repository
@RequiredArgsConstructor
public class OrderRepository {

  private final EntityManager entityManager;

  ...

  public List<Order> findAllWithMemberDelivery() {
    String jpql =
      "select o " +
      "from Order o " +
        "join fetch o.member m " +
        "join fetch o.delivery d";

    return entityManager.createQuery(jpql, Order.class).getResultList();
  }
}

/**
  * V3. 엔티티를 조회해서 DTO로 변환(fetch join 사용O)
  * - fetch join으로 쿼리 1번 호출
  * 참고: fetch join에 대한 자세한 내용은 JPA 기본편 참고(정말 중요함)
  */
@GetMapping("/api/v3/simple-orders")
public Result<List<SimpleOrderDto>> ordersV3() {
  List<Order> orders = repository.findAllWithMemberDelivery();
  List<SimpleOrderDto> dto =
    orders.stream()
      .map(order -> new SimpleOrderDto(order))
      .collect(Collectors.toList());
  return new Result<>(dto);
}
2023-10-27 14:16:03.386 DEBUG 81195 --- [nio-8080-exec-1] org.hibernate.SQL                        :
    select
        order0_.order_id as order_id1_6_0_,
        member1_.member_id as member_i1_4_1_,
        delivery2_.delivery_id as delivery1_2_2_,
        order0_.delivery_id as delivery4_6_0_,
        order0_.member_id as member_i5_6_0_,
        order0_.order_date as order_da2_6_0_,
        order0_.status as status3_6_0_,
        member1_.city as city2_4_1_,
        member1_.street as street3_4_1_,
        member1_.zipcode as zipcode4_4_1_,
        member1_.name as name5_4_1_,
        delivery2_.city as city2_2_2_,
        delivery2_.street as street3_2_2_,
        delivery2_.zipcode as zipcode4_2_2_,
        delivery2_.status as status5_2_2_
    from
        orders order0_
    inner join
        member member1_
            on order0_.member_id=member1_.member_id
    inner join
        delivery delivery2_
            on order0_.delivery_id=delivery2_.delivery_id
...

엔티티를 페치 조인(fetch join)을 사용하여 쿼리 1번에 조회

  • 페치 조인으로 order -> member, order -> delivery는 order 조회시 fetch join으로 같이 조회되어 연속성 컨텍스트에 관리되고 있는 상태이므로 지연로딩X

주문조회 V4 - JPA 에서 DTO로 바로 조회

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@Data
public class OrderSimpleQueryDto {

  private Long orderId;
  private String name;
  private LocalDateTime orderTime;
  private OrderStatus orderStatus;
  private Address address;

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

@Repository
@RequiredArgsConstructor
public class OrderSimpleQueryRepository {

  private final EntityManager entityManager;

  public List<OrderSimpleQueryDto> findOrdersDtos() {
    String jpql =
      "select new jpabook.jpashop.repository.order.simplequery.OrderSimpleQueryDto(o.id, m.name, o.orderDate, o.status, d.address) " +
      "from Order o " +
        "join o.Member m " +
        "join o.delivery d";

    return entityManager.createQuery(jpql, OrderSimpleQueryDto.class)
            .getResultList();
  }
}

/**
 * V4. JPA에서 DTO로 바로 조회
 * - 쿼리 1번 호출
 * - select 절에서 원하는 데이터만 선택해서 조회
 */
@GetMapping("/api/v4/simple-orders")
public Result<List<OrderSimpleQueryDto>> ordersV4() {
  List<OrderSimpleQueryDto> orders =
    orderSimpleQueryRepository.findOrdersDtos();
  return new Result<>(orders);
}
2023-10-27 14:38:02.683 DEBUG 83425 --- [nio-8080-exec-1] org.hibernate.SQL                        :
    select
        order0_.order_id as col_0_0_,
        member1_.name as col_1_0_,
        order0_.order_date as col_2_0_,
        order0_.status as col_3_0_,
        delivery2_.city as col_4_0_,
        delivery2_.street as col_4_1_,
        delivery2_.zipcode as col_4_2_
    from
        orders order0_
    inner join
        member member1_
            on order0_.member_id=member1_.member_id
    inner join
        delivery delivery2_
            on order0_.delivery_id=delivery2_.delivery_id
...

join 을 통해 필요한 정보가 있는 엔티티들도 한번에 조회한 이후 정의한 DTO에 맞춰 필요한 필드를 SELECT 함

  • 일반적인 SQL을 사용할 때 처럼 원한는 값을 선택하여 조회
  • new 명령어를 사용하여 JPQL의 결과를 DTO로 즉시 변환
  • SELECT 절에서 원하는 데이터를 직접 선택하므로 DB -> 어플리케이션 네트워크 용량 최적화
  • 리포지토리 재사용성이 떨어짐
    • API 스펙에 맞춘 코드가 리포지토리에 들어가는 단점

정리

엔티티를 DTO로 변환(ordersV3 방법)하거나, DTO로 바로 조회(ordersV4 방법)하는 두가지 방법은 각각 장단점이 있음. 둘중 상황에 따라 더 나은 방법을 택하면 됨.

쿼리 방식 선택 권장 순서

  • 우선 엔티티를 DTO로 변환하는 방법을 선택(ordersV2 방법)
  • 필요하다면 페치 조인으로 성능 최적화 시도(ordersV3 방법)
    • 대부분의 경우 이 단계에서 성능 이슈가 해결 됨
  • 그래도 안된다면 DTO로 직접 조회하는 방법으로 성능 최적화 시도(ordersV4 방법)
  • 최후의 방법으로는 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용하여 SQL을 직접 사용

참고

  • 실전! 스프링 부트와 JPA 활용2 - 웹 애플리케이션 개발(김영한)
This post is licensed under CC BY 4.0 by the author.