[JPA] 지연로딩 심화

Updated:

지연로딩 심화

JPA에서 헷갈리는 개념인 지연로딩에 대해 코드를 통해 구체적으로 살펴본다

배경지식

우선 JPA에서 제공하는 FetchType이 무엇이고 어떻게 적용하는지에 대해 간단히 살펴보자

참고로 어떠한 경우에도 즉시로딩을 사용해서는 안된다!!!

  • JPA에서 제공하는 FetchType을 이용하면 DB에서 임의의 엔티티 조회시에 내부 필드값에 속한 다른 엔티티를 함께 조회할 것인지 아니면 조회하지 않고 프록시로 대체할 것인지 선택이 가능하다
  • JPA에서 제공하는 FetchType은 즉시로딩, 지연로딩으로 나뉜다
    • 즉시로딩으로 설정한 필드값 엔티티의 경우 이를 포함하는 엔티티 조회시에 함께 조회해온다
      • 즉시로딩을 적용하려면 FetchType.EAGER을 명시해주면 된다
    • 지연로딩으로 설정한 필드값 엔티티의 경우 이를 포함하는 엔티티 조회시에 함께 조회하지 않고 프록시로 객체로 대체한다
      • 지연로딩으로 설정한 필드값 엔티티는 추후 해당 엔티티에 접근할 경우에 DB에 접근하여 조회해온다
      • 지연로딩을 적용하려면 FetchType.LAZY를 명시해주면 된다

1. 문제점 - 지연로딩 적용 시 발생하는 N+1 문제

우선 간단한 엔티티 2개를 만들어보자

@Entity
@Table(name = "orders")
@Getter @Setter
public class Order {

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

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

    //연관관계 편의 메서드
    public void setMember(Member member) {
        this.member = member;
        member.getOrders().add(this);
    }
}
@Entity
@Getter @Setter
public class Member {

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

    private String name;

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

  • 간단히 MemberOrder 엔티티를 만들고 1대다 관계로 설정하였다
  • Order 엔티티 내부의 Member 속성을 FetchType.LAZY로 설정하여 지연로딩임을 명시하였다

추가로 간단한 테스트 데이터 삽입을 위해 아래와 같이 코드를 추가해주자

@RequiredArgsConstructor
@SpringBootApplication
public class ForBlogTestApplication {

    private final MemberRepository memberRepository;
    private final OrderRepository orderRepository;


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

    //테스트 데이터 삽입
    @PostConstruct
    public void dataInit() {
        Member member1 = new Member();
        member1.setName("lim1");

        Member member2 = new Member();
        member2.setName("lim2");

        Order order1 = new Order();
        order1.setMember(member1);

        Order order2 = new Order();
        order2.setMember(member1);

        Order order3 = new Order();
        order3.setMember(member2);

        Order order4 = new Order();
        order4.setMember(member2);

        memberRepository.save(member1);
        memberRepository.save(member2);

        orderRepository.save(order1);
        orderRepository.save(order2);
        orderRepository.save(order3);
        orderRepository.save(order4);
    }
}

다음은 DB에 접근하기 위한 Repository를 추가한다

public interface MemberRepository extends JpaRepository<Member, Long> {
}
public interface OrderRepository extends JpaRepository<Order, Long> {
}
  • MemberRepositoryOrderRepository 인터페이스를 만들고 JpaRepository를 상속받는다
    • 추후 Spring Data JPA에 의해 기본으로 제공되는 findAll() 메서드를 통해 DB 측에 전달되는 SQL문을 살펴볼 것이다
@RequiredArgsConstructor
@RestController
public class SimpleApiController {

    private final OrderRepository orderRepository;

    @GetMapping("/api/v1/orders")
    public List<OrderResponseDto> ordersV1() {
        return orderRepository.findAll().stream()
                .map(order -> new OrderResponseDto(order))
                .collect(Collectors.toList());
    }

    @Data
    static class OrderResponseDto {
        private Long orderId;
        private String name;

        public SimpleOrderDto(Order order) {
            this.orderId = order.getId();
            this.name = order.getMember().getName();
        }
    }
}

  • Order 엔티티에 관한 API 통신을 담당하는 컨트롤러 클래스를 만들고 조회하는 메서드를 위와 같이 작성한다

  • 여기서 살펴볼 점은 orderRepository.findAll()List<Order> 타입으로 반환되고 컨트롤러의 ordersV1() 메서드는 List<OrderResponseDto> 타입으로 반환된다는 차이점이다

    • Steam API의 map() 함수를 이용하여 필요한 타입으로 변환한다
  • /api/v1/orders로 HTTP 요청을 보내면 orderRepository.findAll()이 호출되고 SQL문이 DB에 날아간다

  • 실제로 날아가는 SQL문은 아래와 같다(현재 2명의 Member가 각각 2개의 Order를 주문한 데이터가 DB에 있다는 가정이다)

        select
            o1_0.order_id,
            o1_0.member_id      
        from
            orders o1_0
    
        select
            m1_0.member_id,
            m1_0.name      
        from
            member m1_0      
        where
            m1_0.member_id=1
    
        select
            m1_0.member_id,
            m1_0.name      
        from
            member m1_0      
        where
            m1_0.member_id=2
    
    • 총 3개의 SQL 문이 날아간다
      • Order를 조회하는 SQL문 1개, Member를 조회하는 SQL문 2개가 날아간다
      • 하지만, 각각의 SQL문이 날아가는 시점을 확실히 인지해야 한다
        • Order를 조회하는 SQL문은 orderRepository.findAll()이 수행되는 시점 즉, DB에 접근시에 시행된다
        • Order 엔티티 내부의 Member 필드값을 FetchType.LAZY로 설정했기 때문MemberOrder 조회시에 함께 가져오지 않고 프록시 객체로 대체한다
        • Member를 조회하는 SQL문은 Order 타입을 OrderResponseDto로 변환하는 과정에서 생성자 호출 시점에 order.getMember().getName()에서 Member 엔티티를 필요로 하기 때문에 해당 시점에 조회한다
        • 총 2명의 Member가 Order를 주문했기 때문에 총 2개의 Member 조회 SQL문이 날아간다
      • 이를 N+1 문제라고 부르며 이를 해결하기 위한 방법으로 아래에서 fetch Joindefault_batch_fetch_size 설정에 대해 살펴본다

2. 해결책(@ManyToOne만을 가지는 도메인 조회시) - Fetch Join 적용

1+N 문제를 해결하기 위한 방법으로 Fetch Join에 대해 알아본다. 단, 이는 조회하고자 하는 도메인이 @OneToMany 즉, 컬렉션을 필드값으로 가지지 않는 경우에만 가능하다. 위에서 설명한 Orders 도메인 조회 시에 Member 도메인이 포함되어 있는 상황에 해당된다

우선 OrderRepositorySimpleApiController 측에 아래와 같이 코드를 추가한다

    @Query("select o from Order o join fetch o.member m")
    List<Order> findAllUsingJoinFetch();
  • OrderRepository 코드를 살펴보면 join fetch 키워드를 통해 패치조인을 적용하였음을 확인할 수 있다
    @GetMapping("/api/v2/orders")
    public List<OrderResponseDto> ordersV2() {
        return orderRepository.findAllUsingJoinFetch().stream()
                .map(order -> new OrderResponseDto(order))
                .collect(Collectors.toList());
    }
  • ordersV1() 코드와 유사하지만 orderRepository.findAllUsingJoinFetch()를 사용한다는 차이점이 있다
    • 마찬가지로 stream()map()을 사용하여 List<OrderResponseDto>로 변환해준다
  • 이와 같이 작성한 후 /api/v2/orders 측으로 HTTP 요청을 보내면 아래와 같이 SQL문 1개가 날아가는 것을 확인할 수 있다

        select
            o1_0.order_id,
            m1_0.member_id,
            m1_0.name      
        from
            orders o1_0      
        join
            member m1_0              
                on m1_0.member_id=o1_0.member_id
    
    • 패치 조인을 이용하여 단순히 하나의 쿼리문만 날려 N+1 문제를 해결가능하다

3. 해결책(@OneToMany을 가지는 도메인 조회시) - default_batch_fetch_size 설정

이번에는 조회하고자 하는 도메인 내부에 @OneToMany 즉, 컬렉션을 가지는 경우에 대해 어떻게 N + 1 문제를 해결하는지에 대해 알아보자. 위에서 Member 도메인 조회시 내부 필드값인 Orders 도메인 컬렉션이 포함되어 있는 상황에 해당된다

먼저 SimpleApiController에 아래와 같이 코드를 추가하자

    @GetMapping("/api/members")
    public List<MemberResponseDto> members() {
        return memberRepository.findAll().stream()
                .map(member -> new MemberResponseDto(member))
                .collect(Collectors.toList());
    }

    @Data
    static class MemberResponseDto {
        private Long id;
        private String name;
        private List<OrderInMemberResponseDto> orderIdList;

        public MemberResponseDto(Member member) {
            this.id = member.getId();
            this.name = member.getName();
            this.orderIdList = member.getOrders().stream()
                    .map(order -> new OrderInMemberResponseDto(order))
                    .collect(Collectors.toList());
        }
    }

    @Data
    static class OrderInMemberResponseDto {
        private Long orderId;

        public OrderInMemberResponseDto(Order order) {
            this.orderId = order.getId();
        }
    }
  • Member의 필드값 중 하나인 orders@OneToMany로 설정된 컬렉션 형태로 기본값이 FetchType.LAZY로 설정된다

    • 앞에서 살펴보았듯이 fetch join을 사용하지 않고 일반적으로 지연로딩으로 설정된 도메인을 조회하는 경우에는 실제로 해당 도메인에 접근하는 시점에 DB에 SQL문을 날려 값을 조회한다

    • 실제로 /api/members로 요청을 보내 날아가는 SQL문을 살펴보면 아래와 같다

          select
              m1_0.member_id,
              m1_0.name      
          from
              member m1_0
      
          select
              o1_0.member_id,
              o1_0.order_id      
          from
              orders o1_0      
          where
              o1_0.member_id=1
      
          select
              o1_0.member_id,
              o1_0.order_id      
          from
              orders o1_0      
          where
              o1_0.member_id=2
      
  • 그렇다면 @OneToMany에 대해서도 앞에서 설명한 @ManyToOne처럼 fetch join을 사용하여 조회하면 되는 것 아니냐는 의문점이 생길 수 있다

  • 결론부터 이야기하면 @OneToMany를 대상으로는 웬만하면 fetch join은 사용하지 않는 것이 좋다

    • 우선 첫번째로 페이징 처리가 불가능하기 때문에 메모리에서 자체적으로 페이징 처리를 하여 성능 문제가 발생할 수 있다
    • 두번째로 2개 이상의 @OneToMany에 대해 fetch join을 적용하면 org.hibernate.loader.MultipleBagFetchException 오류가 발생한다
  • 결국, @OneToMany를 대상으로 쿼리 성능을 최적화하기 위해선 fetch join 이외의 방법이 필요하고 해결책으로써 제시된 것이 바로 default_batch_fetch_size 설정이다

    • spring.jpa.properties.hibernate.default_batch_fetch_size로 명시한 개수만큼은 IN 키워드를 사용하여 날아가는 쿼리 개수를 획기적으로 줄여 최적화가 가능하다

    • 실제로 application.propertiesspring.jpa.properties.hibernate.default_batch_fetch_size=10으로 설정후 /api/members로 다시 요청을 보내보면 아래처럼 2개의 쿼리만 날아간다

          select
              m1_0.member_id,
              m1_0.name      
          from
              member m1_0
      
          select
              o1_0.member_id,
              o1_0.order_id      
          from
              orders o1_0      
          where
              o1_0.member_id in (1, 2, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL)
      
      • 10개의 컬렉션 조회에 대해서는 IN 키워드를 이용하여 하나의 SQL문으로 처리한다는 사실을 알 수 있다

Tags: ,

Categories:

Updated:

Leave a comment