쌍문동 척척박사
Spring / ReactJS / PS

JPA Query 최적화

JPA Query 최적화

관련 포스트

Proxy와 Lazy Loading

N+1 문제와 Fetch Join


순수 JPA와 다르게 Spring Boot에서 JPA를 사용하다보면 여러 성능 저하의 요인들이 생긴다. 주로 조회 쿼리를 작성할 때 문제가 발생하는데, 어느 계층에서 발생했는지에 따라 해결 방법을 제시하려 한다.

Optimization in Controller

1. Entity to Dto

Controller 계층에서 요청/반환 객체에 엔티티를 그대로 사용하면 다음과 같은 문제가 발생한다.

  • 엔티티에 프레젠테이션 계층을 위한 로직이 추가된다.
    • View 로직 (한 화면에 종속적이게 됨)
    • API 검증 로직 (예외 처리)
  • 엔티티의 모든 값이 노출된다.

결론적으로 ORM 설계만 있어야 하는 엔티티에 모든 API를 위한 요구사항이 명시되어 복잡해진다. 따라서 Controller에서 외부와의 통신은 API마다 스펙에 맞는 Dto를 사용하고, Service나 Repository 계층에는 Dto를 Entity로 변환하거나, 필요한 필드만을 인자로 전달한다.

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
// MemberApiController

@RequiredArgsConstructor
@RestController
public class MemberApiController {
  
  private final MemberService memberService;
  
  @PutMapping("/api/members/{id}")
  public UpdateMemberResponse updateMember(
      @PathVariable("id") Long id,
      @RequestBody @Valid UpdateMemberRequest request){

      memberService.update(id, request.getName());  // 필요한 인자만 전달
      Member member = memberService.findById(id);
      // Entity to Dto
      return new UpdateMemberResponse(member);
  }
  
  @GetMapping("/api/members")
  public Result members(){
  
    List<MemberDto> collect = memberService.findAll().stream()
        .map(MemberDto::new)  // Entity to Dto
        .collect(toList());
    return new Result(collect);
  }
}

@Getter
@AllArgsContstructor
class Result<T> {
  private T data;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// UpdateMemberResponse

@NoArgsConstructor
@Getter
public class UpdateMemberResponse {

  private Long id;
  private String name;
  
  public UpdateMemberResponse(Member member){
    this.id = member.getId();
    this.name = member.getName();
  }
}

Optimization in Repository

1. Lazy Loading to Fetch Join (..ToOne 관계)

엔티티가 ..ToOne 연관관계를 갖을 때 지연 로딩으로 조회 시 다음과 같은 문제가 발생할 수 있다.

  • 연관된 객체가 아직 호출되지 않아 Proxy 객체일때 Json 객체로 변환하면 무한 루프에 빠지게 된다.
  • 그렇다고 즉시 로딩으로 설정해도 마찬가지로 N+1 문제에 빠지게 된다.

해결 방법은 무한 루프는 마찬가지로 Controller 계층에서 Dto로 변환하고, 지연 로딩을 Fetch Join으로 변경하면 된다. 참고로 이 문제들은 언제까지나 연관된 객체를 참조하려 할 때 발생하는 문제다. 연관 객체를 사용하지 않는 경우엔 지연 로딩을 그대로 사용해도 된다.

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
// OrderQueryApiController

@RequiredArgsConstructor
public class OrderQueryApiController {

  private final OrderRepository orderRepository;
  private final OrderQueryRepository orderQueryRepository;  // 쿼리 전용 Repository

  @GetMapping("/api/orders")
  public List<OrderDto> orders(){
    List<Order> findOrders = orderQueryRepository.findAllWithOne();
    return findOrders.stream()
        .map(OrderDto::new)
        .collect(toList());
  }
}

class OrderDto {

  public OrderDto(Order order){
    // ...
    // 필요한 필드만 삽입해 무한 루프 벗어남
    address = order.getDelivery().getAddress();
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
// OrderQueryRepository

public class OrderQueryRepository {
  // 페치 조인
  public List<Order> findAllWithOne(){
    return em.createQuery(
      "select o from Order o" +
        " join fetch o.member m" +
        " join fetch o.delivery d", Order.class)
      .getResultList();
  }
}

2. Lazy Loading to Fetch Join (..ToMany 관계)

같은 지연로딩 문제라도 ..ToOne 관계와 다르게 ..ToMany 관계에서는 컬렉션이 존재하기 때문에 조금 복잡해진다. 결론부터 말하면 기존의 Fetch Join 방식에 DISTINCT 키워드를 이용해 중복을 제거하면 된다. 하지만 여전히 다음과 같은 문제가 발생한다.

  • 2개 이상의 컬렉션에 대해 Fetch Join을 하면 DISTINCT 키워드로도 데이터 중복을 제어할 수 없다.
  • 페이징이 불가능하다. (반대로 ..ToOne 관계의 경우 row 수에 영향을 미치지 않기 때문에 페이징이 가능하다.)

반대로 말하면 페이징이 필요 없으며, 1개의 컬렉션와 연관관계를 맺는 상황에서는 Fetch Join & DISTINCT로도 충분히 성능 최적화가 가능하다는 얘기다.

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
// OrderQueryApiController

public class OrderQueryApiController {
  // ...
  @GetMapping("/api/orders")
  public List<OrderDto> orders(){
    List<Order> findOrders = orderQueryRepository.findAllWithMany();
    return findOrders.stream()
        .map(OrderDto::new)
        .collect(toList());
  }
}

class OrderDto {
  // ...
  private List<OrderItemDto> orderItems;

  public OrderDto(Order order){
    // ...
    // Dto 내부에서 다시 Dto List로 변환
    orderItems = order.getOrderItems().stream()
        .map(OrderItemDto::new)
        .collect(toList());
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// OrderQueryRepository

public class OrderQueryRepository {
  // 페치 조인 & distinct
  public List<Order> findAllWithMany(){
    return em.createQuery(
      "select distinct o from Order o" +
        " join fetch o.member m" +
        " join fetch o.delivery d" +
        " join fetch o.orderItems oi" +
        " join fetch oi.item i", Order.class)
      .getResultList();
  }
}

3. Lazy Loading + Batch Fetch Size (..ToMany 관계)

2개 이상의 컬렉션 연관관계와 페이징을 지원하기 위한 최적화 방법이다.

  • (해당 쿼리 내에서) ..ToOne 관계는 기존과 동일하게 모두 Fetch Join을 한다.
  • ..ToMany 관계는 Join을 사용하지 않는다. (명시 X)
  • 대신 hibernate.default_batch_fetch_size (글로벌 설정) or @BatchSize (개별 최적화)를 지정한다.
  • 따라서 distinct 키워드는 더 이상 필요 없다.

Batch Fetch Size 옵션

  • 최초 쿼리는 중복 데이터가 없는 엔티티를 반환하기 때문에 페이징에 영향을 주지 않는다.
  • 이후 지연 로딩으로 가져온 엔티티에서 N+1 문제가 발생하면, 프록시 객체를 1개씩이 아닌 한꺼번에 설정한 size만큼 IN 쿼리로 조회한다.
  • 다시 말해 Batch Size는 IN 쿼리에 들어갈 데이터의 개수이며, 보통 100~1000개가 최적이다.
  • 1+N 문제가 1+1 문제로 변환된다.
  • 만약 Batch Size가 원하는 개수보다 작은 경우엔 원하는 데이터가 포함될 때까지 루프가 돌아간다. (1+N/size)

2.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// OrderQueryApiController

public class OrderQueryApiController {
  // ...
  @GetMapping("/api/orders")
  public List<OrderDto> orders(
      @RequestParam(value = "offset", defaultValue = "0") int offset,
      @RequestParam(value = "limit", defaultValue = "100") int limit) {

    List<Order> findOrders = orderQueryRepository.findAllWithMany(offset, limit);
    return findOrders.stream()
        .map(OrderDto::new)
        .collect(toList());
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// OrderQueryRepository

public class OrderQueryRepository {
  // ..ToOne (member, delivery) -> Fetch Join
  // ..ToMany (orderItems, item) -> Lazy Loading + Batch Size
  public List<Order> findAllWithMany(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();
    }
}
1
2
3
4
5
6
7
# application.yml
# 글로벌 설정
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 1000

3.gif

최적화 정리

  1. 기본은 지연 로딩 사용 (Controller에서 Entity -> Dto 변환은 필수)
  2. 연관관계 호출 시 1+N 문제가 자주 발생하면 Fetch Join 사용
  3. 연관관계 호출 시 컬렉션에 대해서 페이징을 지원하거나 N개 이상의 컬렉션을 호출하는 경우 Batch Size 설정
  4. Dto 조회 방식 이용 (자바 ORM 표준 JPA 프로그래밍 참고)
  5. NativeSQL or JDBC Template 사용

참고 자료

자바 ORM 표준 JPA 프로그래밍

comments powered by Disqus