[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<>();
}
- 간단히
Member와Order엔티티를 만들고 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> {
}
MemberRepository와OrderRepository인터페이스를 만들고JpaRepository를 상속받는다- 추후 Spring Data JPA에 의해 기본으로 제공되는
findAll()메서드를 통해 DB 측에 전달되는 SQL문을 살펴볼 것이다
- 추후 Spring Data JPA에 의해 기본으로 제공되는
@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()함수를 이용하여 필요한 타입으로 변환한다
- Steam API의
-
/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_0select m1_0.member_id, m1_0.name from member m1_0 where m1_0.member_id=1select 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로 설정했기 때문에
Member는Order조회시에 함께 가져오지 않고 프록시 객체로 대체한다 Member를 조회하는 SQL문은Order타입을OrderResponseDto로 변환하는 과정에서 생성자 호출 시점에order.getMember().getName()에서Member엔티티를 필요로 하기 때문에 해당 시점에 조회한다- 총 2명의 Member가 Order를 주문했기 때문에 총 2개의
Member조회 SQL문이 날아간다
- 이를 N+1 문제라고 부르며 이를 해결하기 위한 방법으로 아래에서 fetch Join과 default_batch_fetch_size 설정에 대해 살펴본다
- 총 3개의 SQL 문이 날아간다
2. 해결책(@ManyToOne만을 가지는 도메인 조회시) - Fetch Join 적용
1+N 문제를 해결하기 위한 방법으로 Fetch Join에 대해 알아본다. 단, 이는 조회하고자 하는 도메인이 @OneToMany 즉, 컬렉션을 필드값으로 가지지 않는 경우에만 가능하다. 위에서 설명한 Orders 도메인 조회 시에 Member 도메인이 포함되어 있는 상황에 해당된다
우선 OrderRepository와 SimpleApiController 측에 아래와 같이 코드를 추가한다
@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_0select o1_0.member_id, o1_0.order_id from orders o1_0 where o1_0.member_id=1select 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.properties에spring.jpa.properties.hibernate.default_batch_fetch_size=10으로 설정후/api/members로 다시 요청을 보내보면 아래처럼 2개의 쿼리만 날아간다select m1_0.member_id, m1_0.name from member m1_0select 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문으로 처리한다는 사실을 알 수 있다
- 10개의 컬렉션 조회에 대해서는
-
Leave a comment