본문 바로가기
스프링 관련/스프링 프레임워크

API 만들 때 생길 수 있는 문제 (지연 로딩과 조회 성능 최적화)

by 문자메일 2022. 8. 28.

쿼리 방식 선택 권장 순서

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

2. 필요시 페치 조인으로 성능을 최적화 한다. -> 대부분의 성능 이슈가 해결된다.

3. 그래도 안되면 DTO 로 직접 조회한다.

4. 그래도 안되면 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 사용한다.

 

 

 

1. Entity 순환참조 문제 (양방향 연관관계, 무한루프)

해결 -> 양방향 연관관계에서 한쪽은 @JsonIgnore 해야 한다. (json 응답 만들 때 관계를 끊어줘야 한다.)

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

궁금증 : 그런데 이 문제는 HTTP Response를 Entity를 쓰지 않으면 안 생기는 문제 아닌가?

              실제로 위 문제는 DTO 객체 만들어서 사용하면 발생하지 않을 문제로 보이며, 굳이 entity로 응답하는 경우에 사용하는 방법으로 보임

 

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

아래처럼 지연로딩(FetchType.LAZY)인 경우 Hibernate에서 null 대신에 Proxy 객체(new ByteBuddyInterceptor())를 만들어서 저장해놓는다.

문제는 Jackson 라이브러리가 Member 객체를 json 형식으로 바꿔보려고 하는데, Member객체가 아니라 알 수 없는 Proxy 객체가 있어서 예외 터진 것

Proxy 객체를 만드는데 사용하는 라이브러리가 bytebuddy 라이브러리여서 위 exception 문구에 출력된 것

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

해결 : 지연로딩인 경우 json 응답 만들 때 해당 객체 정보는 뿌리지 말라고 설정해야 한다. 

 

1. build.gradle에 위처럼 의존성 추가 필요

implementation group: 'com.fasterxml.jackson.datatype', name: 'jackson-datatype-hibernate5'

 

 

2. hibernate5Module Bean 등록 필요함 ( json 응답 만들 때 default로 지연로딩일 경우 null 값 넣도록 설정되어 있음.)

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

 

 

[
   {
      "id":4,
      "member":null,
      "orderItems":null,
      "delivery":null,
      "orderDate":"2022-08-28T03:46:41.688642",
      "status":"ORDER",
      "totalPrice":50000
   },
   {
      "id":11,
      "member":null,
      "orderItems":null,
      "delivery":null,
      "orderDate":"2022-08-28T03:46:41.758645",
      "status":"ORDER",
      "totalPrice":220000
   }
]

2-1) 번외로 옵션을 FORCE_LAZY_LOADING으로 주면 아래 예시처럼 값 다 읽어와서 뿌려주는 것 확인 가능함.

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

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

 

[
   {
      "id":4,
      "member":{
         "id":1,
         "name":"userA",
         "address":{
            "city":"서울",
            "street":"1",
            "zipcode":"1111"
         }
      },
      "orderItems":[
         {
            "id":6,
            "item":{
               "id":2,
               "name":"JPA1 BOOK",
               "price":10000,
               "stockQuantity":99,
               "categories":[
                  
               ],
               "author":null,
               "isbn":null
            },
            "orderPrice":10000,
            "count":1,
            "totalPrice":10000
         },
         {
            "id":7,
            "item":{
               "id":3,
               "name":"JPA2 BOOK",
               "price":20000,
               "stockQuantity":98,
               "categories":[
                  
               ],
               "author":null,
               "isbn":null
            },
            "orderPrice":20000,
            "count":2,
            "totalPrice":40000
         }
      ],
      "delivery":{
         "id":5,
         "address":{
            "city":"서울",
            "street":"1",
            "zipcode":"1111"
         },
         "status":null
      },
      "orderDate":"2022-08-28T03:49:59.607454",
      "status":"ORDER",
      "totalPrice":50000
   },
 

3. LAZY Loading으로 인한 N+1문제

위 문제 발생하는 케이스 : 사용하는 Entity나 DTO 객체에 연관된 다른 Entity가 포함되어 있는 경우

연관된 entity의 data를 필요한 시점에 가져오는 lazy loading의 특성 때문에, SELECT * FROM ORDERS; 쿼리를 실행하게 되면, 쿼리가 1 + N + N번 실행된다.

- order 조회 1번(order 조회 결과 수가 N)

- order -> member의 lazy loading N번

- order -> delivery의 lazy loading N번

- ex : order의 결과가 4개면 최악의 경우 1 + 4 + 4 번 실행된다.

 - 지연로딩은 영속성 컨텍스트에서 조회하므로, 이미 조회된 경우 쿼리를 생략함

 

아래 코드는 엔티티를 DTO로 변환하는 일반적인 방법이다.

@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> orderV2(){
    //ORDER 2개
    //N + 1 -> 1 + 회원 N + 배송 N
    List<Order> orders = orderRepository.findAllByString(new OrderSearch());
    List<SimpleOrderDto> result = orders.stream()
            .map(o->new SimpleOrderDto(o))
            .collect(Collectors.toList());

    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){
        orderId = order.getId();
        name = order.getMember().getName(); //LAZY 초기화 : 영속성 컨텍스트가 MemberID를 가지고 영속성 컨텍스트를 찾아온다.
                                            // 없으면 DB 쿼리 날린다.
        orderDate = order.getOrderDate();
        orderStatus = order.getStatus();
        address = order.getDelivery().getAddress();
    }
}

 

 

N+1 문제 해결 : 페치 조인 최적화 사용

레퍼지토리에 fetch join 문을 작성하면 lazy로 설정되어 있더라도 한번에 SQL JOIN으로 동작해서 연관된 엔티티 정보까지 가져온다. 그래서 조회 쿼리가 한 번만 나가게 되어 효율적이다.

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();
}
@GetMapping("/api/v3/simple-orders")
public List<SimpleOrderDto> orderV3(){
    List<Order> orders = orderRepository.findAllWithMemberDelivery();
    List<SimpleOrderDto> result =orders.stream()
            .map(o -> new SimpleOrderDto(o))
            .collect(Collectors.toList());
    return result;
}

2022-08-28 14:19:03.623 DEBUG 33204 --- [nio-9090-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

 

 

 

JPA에서 DTO로 바로 조회

SELECT 절에서 new 명령어를 사용해서 JPQL의 결과를 DTO로 즉시 변환 가능하다.

필요한 data만 선택해서 가져오기에 약간의 최적화 부분에서 우위가 있지만, 리포지토리의 재사용성이 떨어지게되는 단점이 있다.

public List<OrderSimpleQueryDto> findOrderDtos() {
    return em.createQuery(
                    "select new jpabook.jpashop.repository.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();
}

 

댓글