일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |
- 스프링
- 벌크 연산
- 김영한
- jpa 활용
- Spring Data JPA
- 실무활용
- JPA 활용2
- JPQL
- 로그인
- API 개발 고급
- Bean Validation
- 값 타입 컬렉션
- 불변 객체
- 타임리프
- 트위터
- 페이징
- 기본문법
- 컬렉션 조회 최적화
- JPA
- 임베디드 타입
- 타임리프 문법
- JPA 활용 2
- 예제 도메인 모델
- QueryDSL
- 스프링 데이터 JPA
- 검증 애노테이션
- 프로젝트 환경설정
- 스프링 mvc
- 스프링MVC
- 일론머스크
- Today
- Total
RE-Heat 개발자 일지
[JPA 활용2] [3] API 개발 고급 - 지연 로딩과 조회 성능 최적화 본문
인프런 김영한 님의 강의를 듣고 작성한 글입니다.
[1] 간단한 주문 조회 V1 : 엔티티를 직접 노출
주문 + 배송정보 + 회원을 조회하는 API를 만들자
지연로딩 때문에 발생하는 성능 문제를 단계적으로 해결한다.
단, 여기선 xToOne만 다룰 계획이다.(Many To One, One To One)
OrderSimpleApiController
/**
* xToOne
* Order
* Order -> Member [Many to One]
* Order -> Delivery [One to One]
*/
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
private final OrderRepository orderRepository;
@GetMapping("/api/v1/simple-orders")
public List<Order> ordersV1() {
List<Order> all = orderRepository.findAllByString(new OrderSearch());
return all;
}
}
① 엔티티를 직접 노출하는 건 좋지 않다 => V2에서 DTO로 변환할 계획
② 그냥 실행하면 양방향 관계여서 무한 루프를 돈다.
③ 무한 루프를 해결하려면 양방향 중 한쪽에 @JsonIgnore를 붙여줘야 한다.
④ @JsonIgnore를 붙여도 typedefinition 오류가 발생한다.
이유 :
- 지연 로딩은 초기화 전엔 임시로 Proxy객체를 넣어둔다.
- 코드로 표현 private Member member = new ByteBuddyInterceptor()
- 요즘에 많이 쓰이는 프록시 객체가 bytebuddy
- 그런데 jackson 라이브러리는 프록시 객체를 json으로 변환하는 방법을 알지 못한다.
⑤ 해결방법은 Hibernate4Module을 등록하면 된다.
1. build.gradle에 implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5' 추가
2. JpashopApplication.java에 다음 코드 추가
@Bean
Hibernate5Module hibernate5Module() {
Hibernate5Module hibernate5Module = new Hibernate5Module();
//강제 지연 로딩 설정
//hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING, true);
return hibernate5Module;
}
- 강제 지연 로딩 설정을 하면 양방향 연관관계를 계속 로딩하게 되므로 @JsonIgnore 옵션을 한 곳에 꼭 달아야 한다.
■ 실행화면
- 왼쪽은 강제 지연 로딩 옵션 적용 이전 버전 : order만 있고 member, orderItem, delivery 등은 null 값이다.
- 우측은 강제지연로딩 옵션 적용 버전 : orderItem, delivery 등도 db에서 가져온 것을 확인할 수 있다.
강제 지연 로딩 옵션을 끄고 원하는 값만 얻는 방법
@GetMapping("/api/v1/simple-orders")
public List<Order> ordersV1() {
List<Order> all = orderRepository.findAllByString(new OrderSearch());
for (Order order : all) {
order.getMember().getName(); //Lazy 강제 초기화
order.getDelivery().getAddress(); //Lazy 강제 초기화
}
return all;
}
get으로 데이터를 가져오라는 명령을 하면 LAZY가 강제 초기화된다. 여기선 Member와 Delivery만 가져오게 세팅
■ 실행화면
문제점
1. API 스펙이 복잡하다.
2. 쓸데없는 정보까지 노출한다.
참고]
LAZY가 아닌 EAGER로 바꾸면 n+1 조회한다. 또 다른 API 쪽도 문제가 발생한다.
결론 : 엔티티를 노출하지 말고 DTO를 만들어라!!!
[2] 간단한 주문 조회 V2 : 엔티티를 DTO로 변환
OrderSimpleApiController
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
private final OrderRepository orderRepository;
@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> ordersV2() {
//ORDER 2개 조회됨.
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 초기화
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress(); //LAZY 초기화
}
}
}
- API 스펙에 맞는 DTO 작성. 생성자로 order를 받아와 각 필드에 넣어주는 방식
- orderRepository에 정의된 findAllByString()으로 주문 객체를 조회해 List<Order>에 옮김
- Order객체를 stream()을 이용해 Dto에 넣어 줌.
문제점
① V1, V2 모두 N+1 문제가 발생한다.
- 조건 : userA·userB가 각각 책 2개를 주문했고 Orders엔 Member, Delivery가 매핑돼 있다.
- 이때 주문 조회를 하면
- Order를 호출하는 쿼리 1번
- userA를 확인하기 위해 Member를 호출하는 쿼리 1번, 주소 확인 위해 delivery 호출하는 쿼리 1번
- userB를 확인하기 위해 Member를 호출하는 쿼리 1번, 주소 확인 위해 delivery 호출하는 쿼리 1번
- 총 다섯 번의 쿼리가 날아간다.
- 이를 N+1 문제라 부른다.
해결책 : 이렇게 많은 쿼리가 날아가는 것을 방지하려면 fetch join을 써야 한다.
[3] 간단한 주문 조회 V3 : 엔티티를 DTO로 변환 - 페치 조인 최적화
OrderSimpleApiController - V3
@GetMapping("/api/v3/simple-orders")
public List<SimpleOrderDto> ordersV3() {
List<Order> orders = orderRepository.findAllWithMemberDelivery();
List<SimpleOrderDto> result = orders.stream()
.map(o -> new SimpleOrderDto(o))
.collect(Collectors.toList());
return result;
}
- 엔티티를 조회한 후 DTO로 변환해서 반환하는 건 동일
- 페치 조인을 쓴 findAllWithMemberDelivery() 메소드 불러오는 게 차이점
OrderRepository
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();
}
- fetch join으로 member와 delivery를 inner join해서 불러온다.
SQL문
- V2와 달리 쿼리가 한 번만 날아간다. (N + 1 문제 해결)
결괏값
- 페치 조인을 사용하면 order → member, order → delivery는 이미 조회된 상태라 지연 로딩 X
- 다시 말해 조회 당시 실제 엔티티를 가져오므로 지연 로딩 없이 바로 사용가능하다. 따라서 페치 조인이 지연 로딩보다 우선이다.
자세한 내용은 [JPA] [11] 객체지향 쿼리 언어 - 중급 문법 (상편) 참조
[4] 간단한 주문 조회 V4 : JPA에서 DTO로 바로 조회
OrderSimpleApiController - V4
@GetMapping("/api/v4/simple-orders")
public List<OrderSimpleQueryDto> ordersV4() {
return orderSimpleQueryRepository.findOrderDtos();
}
OrderSimpleQueryRepository
@Repository
@RequiredArgsConstructor
public class OrderSimpleQueryRepository {
private final EntityManager em;
public List<OrderSimpleQueryDto> findOrderDtos() {
return em.createQuery("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", OrderSimpleQueryDto.class)
.getResultList();
}
}
- new를 이용해 객체를 생성. DTO로 바로 조회를 하도록 만드는 데, 이 객체를 생성할 때 반드시 full package path를 입력해야 한다.
- 이때 OrderSimpleQueryDto(Order o)로 받으면 엔티티의 식별자만 넘어와서 직접 값을 넣어줘야 한다.
- 그래서 Dto를 만들 때 엔티티로 넘기지 않고 분리해줘야 한다.
OrderSimpleQueryDto
@Data
public class OrderSimpleQueryDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address; //배송지 정보
public OrderSimpleQueryDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address) {
this.orderId = orderId;
this.name = name;
this.orderDate = orderDate;
this.orderStatus = orderStatus;
this.address = address; //LAZY 초기화
}
}
- V3 DTO와 달리 Order o로 넘기지 않고 다 분리시킨 것을 확인할 수 있다.
■ V4 장점과 단점
① 장점
- 일반적인 SQL를 사용할 때처럼 원하는 값을 선택해 조회할 수 있다.
- new 명령어를 사용해 JPQL의 결과를 DTO로 즉시 반환한다. - 따라서 V4는 V3와는 달리 stream()을 써서 DTO로 변환해 주는 과정이 없다.
- SELECT 절에서 원하는 데이터를 직접 선택하므로 DB → 애플리케이션 네트워크 용량 최적화를 할 수 있다. 단, 효과는 미비한 편이다.
② 단점
- 리포지토리 재사용성이 떨어진다. API 스펙에 맞춘 코드가 리포지토리에 들어가므로 딱 한 가지 케이스에만 활용할 수 있다.
V4 단점 관련 해결책
① 리포지토리에 order.simplequery 폴더를 별도로 만든다. - 범용 리포지토리 코드와 분리한다는 의미
- 이러면 범용 리포지토리는 순수 엔티티 조회할 때만 사용할 수 있다.(재사용성 UP)
■ V3(페치 조인) vs V4(JPA에서 DTO 바로 조회)
V3 : 재사용성이 좋음, JPQL 코드가 간략함
V4 : 성능이 V3보다 나은 편
=> 고객 트래픽이 많고 데이터 사이즈가 크면 V4도 고려해야 한다.
■ 쿼리 방식 선택 권장 순서
1. 우선 엔티티를 DTO로 변환하는 방법을 선택한다. V2
2. 필요하면 페치 조인으로 성능을 최적화한다. V3
3. 그래도 안되면 DTO로 직접 조회하는 방법을 사용한다. V4
4. 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해 SQL을 직접 사용하는 것이다.
'백엔드 > JPA' 카테고리의 다른 글
[JPA 활용2] [4] API 개발 고급 - 컬렉션 조회 최적화(하편) (0) | 2023.09.03 |
---|---|
[JPA 활용2] [4] API 개발 고급 - 컬렉션 조회 최적화(상편) (0) | 2023.09.01 |
[JPA 활용2] [2] API 개발 고급 - 준비 (0) | 2023.08.31 |
[JPA 활용2] [1] API 개발 기본 (0) | 2023.08.30 |
[JPA 활용1] [5] 웹 계층 개발(하편) (0) | 2023.08.27 |