1. API를 만들 때는 Request로 전달 받는 인자의 파라메터로 Entity를 그대로 사용하지 않고, 해당 API에 필요한 정보만을 전달받는 전용 DTO 클래스를 만드는 것이 바람직하다.
그러면 API 스펙 변경에 유연하게 대처할 수 있고, 값들의 Validation 조건 거는 것에도 용이하다.
2. 응답 규격의 확장성을 위해서 Generic 클래스 활용
지연 로딩과 조회 성능 최적화
작년 8월에 동일한 내용 이미 정리 했었었음. 뒤늦게 확인함
https://charactermail.tistory.com/427
근본적으로 Entity를 직접 Response(외부 노출) 하는 경우는 지양해야 하는 방법이다
그럼에도 Entity를 노출하는 경우 주의사항을 보자면
1. 양방향 연관관계가 걸린 곳은 한 곳을 꼭 @JsonIgnore 처리 해야한다. 안 그러면 Jaskson 라이브러리에서 무한 루프 걸린다.
@Entity
@Getter @Setter
public class Delivery {
@Id @GeneratedValue
@Column
private Long id;
@JsonIgnore
@OneToOne(mappedBy = "delivery", fetch = FetchType.LAZY)
private Order order;
2. @ManyToOne(fetch = FetchType.LAZY) 처럼 클래스 멤버에 LAZY 로딩 설정된 값이 있는 경우, 실제 데이터가 아닌 hibernate proxy 객체가 생성되어 저장되게되고, 해당 인스턴스를 jackson 라이브러리가 json으로 바꿀 수 없어서 에러 발생하는 문제가 있다.
-> Hibernate5Module hibernate5Module = new Hibernate5Module(); 라는 외부 라이브러리 쓰면 되긴 하는데 근본적으로는 Entity를 응답하면 안 된다.
// Entity를 직접 Response(외부 노출) 하는 경우는 지양해야 하는 방법임
// 그러나 아래 member 변수 처럼
// @ManyToOne(fetch = FetchType.LAZY)
// @JoinColumn(name = "member_id")
// private Member member;
// Jackson 라이브러리로 Response 위한 json 만들 때 LAZY 로딩이면 Member member에
// 실제 데이터가 아닌 hibernate proxy 객체가 생성되어 저장되게되고, 해당 인스턴스를
// jackson 라이브러리가 json으로 바꿀 수 없어서 에러 발생한다.
// 이런 케이스 발생 시 proxy 객체는 null로 응답하도록 기능 지원하는 라이브러리가 아래 Hibernate5Module 이다.
@Bean
Hibernate5Module hibernate5Module(){
Hibernate5Module hibernate5Module = new Hibernate5Module();
// hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING, true);
return hibernate5Module;
}
3. LAZY 로딩 설정으로 되어 있는 멤버의 실제 값 읽어와서 응답해야 하는 경우는, 아래처럼 강제 초기화를 하면 된다.
@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());
for(Order order : all){
order.getMember().getName(); //order.getMember() 까지는 proxy 객체임, .getName() 까지 하면 강제 초기화 되어 실제 Member값 저장
order.getDelivery().getAddress();
}
return all;
}
}
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> ordersV2(){
//ORDER 2개
// 1 + N -> 1 + 회원 N + 배송 N
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
// orders 사이즈가 2이니 반복문 2회 수행됨
// new SimpleOrderDto(o) 에서 order 하나와 연관된 member, address entity의 값을
// Lazy 초기화로 가져올 때 각 1번씩 select 쿼리가 나가기에 쿼리는 총 1 + 2 + 2 = 5번 나감
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 초기화, 이 때 쿼리 나감
}
}
1+N 문제 해결 : 페치 조인 최적화 사용
레퍼지토리에 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;
}
Lazy Loading 방법으로 초기화 ( 연관 Entity가 필요한 시점마다 SELECT 쿼리를 날리기 때문에, 위 로직 수행 시 SELECT 쿼리 5번 나간 것 알 수 있다.)
2023-06-17 14:03:51.157 DEBUG 21920 --- [nio-8080-exec-1] org.hibernate.SQL :
select
order0_.order_id as order_id1_6_,
order0_.delivery_id as delivery4_6_,
order0_.member_id as member_i5_6_,
order0_.order_date as order_da2_6_,
order0_.status as status3_6_
from
orders order0_
inner join
member member1_
on order0_.member_id=member1_.member_id limit ?
2023-06-17 14:03:51.183 DEBUG 21920 --- [nio-8080-exec-1] org.hibernate.SQL :
select
member0_.member_id as member_i1_4_0_,
member0_.city as city2_4_0_,
member0_.street as street3_4_0_,
member0_.zipcode as zipcode4_4_0_,
member0_.name as name5_4_0_
from
member member0_
where
member0_.member_id=?
2023-06-17 14:03:51.187 DEBUG 21920 --- [nio-8080-exec-1] org.hibernate.SQL :
select
delivery0_.id as id1_2_0_,
delivery0_.city as city2_2_0_,
delivery0_.street as street3_2_0_,
delivery0_.zipcode as zipcode4_2_0_,
delivery0_.status as status5_2_0_
from
delivery delivery0_
where
delivery0_.id=?
2023-06-17 14:03:51.188 DEBUG 21920 --- [nio-8080-exec-1] org.hibernate.SQL :
select
member0_.member_id as member_i1_4_0_,
member0_.city as city2_4_0_,
member0_.street as street3_4_0_,
member0_.zipcode as zipcode4_4_0_,
member0_.name as name5_4_0_
from
member member0_
where
member0_.member_id=?
2023-06-17 14:03:51.189 DEBUG 21920 --- [nio-8080-exec-1] org.hibernate.SQL :
select
delivery0_.id as id1_2_0_,
delivery0_.city as city2_2_0_,
delivery0_.street as street3_2_0_,
delivery0_.zipcode as zipcode4_2_0_,
delivery0_.status as status5_2_0_
from
delivery delivery0_
where
delivery0_.id=?
Fetch Join 사용했을 때 ( Lazy 되어 있어도 연관 엔티티의 정보도 한 번에 가져오기에, SQL Inner Join 사용해서 SELECT 쿼리 1건 나감)
2023-06-17 14:04:22.409 DEBUG 21920 --- [nio-8080-exec-2] org.hibernate.SQL :
select
order0_.order_id as order_id1_6_0_,
member1_.member_id as member_i1_4_1_,
delivery2_.id as id1_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_.id
JPA에서 DTO로 바로 조회
SELECT 절에서 new 명령어를 사용해서 JPQL의 결과를 DTO로 즉시 변환 가능하다.
필요한 data만 선택해서 가져오기에 약간의 최적화 부분에서 우위가 있지만, 리포지토리의 재사용성이 떨어지게되는 단점이 있다.
구조상 Repository는 순수한 Entity만 조회하는 것들로 구성되어 있는 것이 바람직하다.
Repository를 이용해서 직접 Query 날리는 방법으로 순수한 Entity가 아닌 Dto를 조회하는 로직은 아래 이미지처럼 특정 도메인.simplequery 패키지처럼 만들어서 분리해 놓는게 번거롭지만 추후 유지보수에 용이해 보이긴 한다. (특정 화면이나 그런 것에 의존성이 있는 경우)
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();
}
fetch join과 DTO 조회 쿼리 비교
fetch join - entity 모든 구성요소에 대해 쿼리 나감
2023-06-17 14:04:22.409 DEBUG 21920 --- [nio-8080-exec-2] org.hibernate.SQL :
select
order0_.order_id as order_id1_6_0_,
member1_.member_id as member_i1_4_1_,
delivery2_.id as id1_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_.id
dto - SELECT 쿼리에 지정한 요소에 관하여만 쿼리 나감
select
order0_.order_id as col_0_0_,
member1_.name as col_1_0_,
order0_.order_date as col_2_0_,
order0_.status as col_3_0_,
delivery2_.city as col_4_0_,
delivery2_.street as col_4_1_,
delivery2_.zipcode as col_4_2_
from
orders order0_
inner join
member member1_
on order0_.member_id=member1_.member_id
inner join
delivery delivery2_
on order0_.delivery_id=delivery2_.id
API 개발 고급 - 컬렉션 조회 최적화
1. 엔티티 직접 노출
private final OrderRepository orderRepository;
@GetMapping("/api/v1/orders")
public List<Order> ordersV1(){
List<Order> all = orderRepository.findAllByString(new OrderSearch());
for(Order order : all){
order.getMember().getName();
order.getDelivery().getAddress();
List<OrderItem> orderItems = order.getOrderItems();
//orderItems.stream().forEach(o->o.getOrderPrice()); //이러면 orderItems 까지만 초기화 됨
orderItems.stream().forEach(o->o.getItem().getName()); // 이러면 orderItems 뿐만 아니라 items 까지 함께 초기화 됨
}
return all;
}
아래에는 select문 6개만 복붙했지만, 위 코드 실행하면 실제로는 select 11번 나감
select
order0_.order_id as order_id1_6_,
order0_.delivery_id as delivery4_6_,
order0_.member_id as member_i5_6_,
order0_.order_date as order_da2_6_,
order0_.status as status3_6_
from
orders order0_
inner join
member member1_
on order0_.member_id=member1_.member_id limit ?
select
member0_.member_id as member_i1_4_0_,
member0_.city as city2_4_0_,
member0_.street as street3_4_0_,
member0_.zipcode as zipcode4_4_0_,
member0_.name as name5_4_0_
from
member member0_
where
member0_.member_id=?
select
delivery0_.id as id1_2_0_,
delivery0_.city as city2_2_0_,
delivery0_.street as street3_2_0_,
delivery0_.zipcode as zipcode4_2_0_,
delivery0_.status as status5_2_0_
from
delivery delivery0_
where
delivery0_.id=?
select
orderitems0_.order_id as order_id5_5_0_,
orderitems0_.order_item_id as order_it1_5_0_,
orderitems0_.order_item_id as order_it1_5_1_,
orderitems0_.count as count2_5_1_,
orderitems0_.item_id as item_id4_5_1_,
orderitems0_.order_id as order_id5_5_1_,
orderitems0_.order_price as order_pr3_5_1_
from
order_item orderitems0_
where
orderitems0_.order_id=?
select
item0_.item_id as item_id2_3_0_,
item0_.name as name3_3_0_,
item0_.price as price4_3_0_,
item0_.stock_quantity as stock_qu5_3_0_,
item0_.artist as artist6_3_0_,
item0_.etc as etc7_3_0_,
item0_.author as author8_3_0_,
item0_.isbn as isbn9_3_0_,
item0_.actor as actor10_3_0_,
item0_.director as directo11_3_0_,
item0_.dtype as dtype1_3_0_
from
item item0_
where
item0_.item_id=?
select
item0_.item_id as item_id2_3_0_,
item0_.name as name3_3_0_,
item0_.price as price4_3_0_,
item0_.stock_quantity as stock_qu5_3_0_,
item0_.artist as artist6_3_0_,
item0_.etc as etc7_3_0_,
item0_.author as author8_3_0_,
item0_.isbn as isbn9_3_0_,
item0_.actor as actor10_3_0_,
item0_.director as directo11_3_0_,
item0_.dtype as dtype1_3_0_
from
item item0_
where
item0_.item_id=?
2. 주문 조회 V2: 엔티티를 DTO로 변환
※ 주의사항 : DTO 클래스 안에 멤버변수로 Entity가 있으면 안 되고, DTO 만 포함해야 한다.
DTO 안에 Entity가 있으면, 결국 이 API 스펙은 Entity에 의존하는 것과 동일하기 때문이다. (entity가 변하면 스펙도 변한다.)
@GetMapping("/api/v2/orders")
public List<OrderDto> ordersV2(){
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
List<OrderDto> collect = orders.stream()
.map(o->new OrderDto(o))
.collect(Collectors.toList());
return collect;
}
@Getter
static class OrderDto{
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
private List<OrderItemDto> orderItems;
public OrderDto(Order order) {
orderId = order.getId();
name = order.getMember().getName();
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress();
orderItems = order.getOrderItems().stream()
.map(orderItem -> new OrderItemDto(orderItem))
.collect(Collectors.toList());
}
}
@Getter
static class OrderItemDto{
private String itemName; // 상품 명
private int orderPrice;
private int count;
public OrderItemDto(OrderItem orderItem) {
this.itemName = orderItem.getItem().getName();
this.orderPrice = orderItem.getOrderPrice();
this.count = orderItem.getCount();
}
}
3. 엔티티를 DTO로 변환- 페치 조인 최적화
-페치 조인으로 SQL이 1번만 실행되는 장점
- 1대 다 조인을 하면 SQL 쿼리 질의 결과가 다 에 해당하는 만큼 row가 증가한다.
그러면 같은 order 엔티티의 조회 수도 증가하게 된다. (JPA 동작 원리를 정확하게 모르겠는데, JPA로 1대다 조회한 raw의 개수가 몇 개 더라도, 하나의 order 엔티티에 다 매핑해서 넣는 로직이 있는 듯 하다.) 이 부분은 좀 더 확인이 필요함
JPA에서 distinct는 SQL에 distinct를 추가하고, 더해서 같은 엔티티가 조회되면, 애플리케이션에서 중복을 걸러준다.
단점 - 페이징 불가능
@GetMapping("/api/v3/orders")
public List<OrderDto> ordersV3(){
List<Order> orders = orderRepository.findAllWithItem();
for(Order order : orders){
System.out.println("order ref=" + order + " id=" + order.getId());
}
List<OrderDto> result = orders.stream()
.map(o->new OrderDto(o))
.collect(Collectors.toList());
return result;
}
order ref=jpabook.jpashop.domain.Order@5bcdf8e0 id=4
order ref=jpabook.jpashop.domain.Order@608e371a id=11
public List<Order> findAllWithItem() {
// jpa order의 id가 같은 값이면 raw가 완전히 같지 않아도 중복을 제거해준다.
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();
}
select
distinct order0_.order_id as order_id1_6_0_,
member1_.member_id as member_i1_4_1_,
delivery2_.id as id1_2_2_,
orderitems3_.order_item_id as order_it1_5_3_,
item4_.item_id as item_id2_3_4_,
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_,
orderitems3_.count as count2_5_3_,
orderitems3_.item_id as item_id4_5_3_,
orderitems3_.order_id as order_id5_5_3_,
orderitems3_.order_price as order_pr3_5_3_,
orderitems3_.order_id as order_id5_5_0__,
orderitems3_.order_item_id as order_it1_5_0__,
item4_.name as name3_3_4_,
item4_.price as price4_3_4_,
item4_.stock_quantity as stock_qu5_3_4_,
item4_.artist as artist6_3_4_,
item4_.etc as etc7_3_4_,
item4_.author as author8_3_4_,
item4_.isbn as isbn9_3_4_,
item4_.actor as actor10_3_4_,
item4_.director as directo11_3_4_,
item4_.dtype as dtype1_3_4_
from
orders order0_
inner join
member member1_
on order0_.member_id=member1_.member_id
inner join
delivery delivery2_
on order0_.delivery_id=delivery2_.id
inner join
order_item orderitems3_
on order0_.order_id=orderitems3_.order_id
inner join
item item4_
on orderitems3_.item_id=item4_.item_id
엔티티를 DTO로 변환 - 페이징과 한계 돌파
- 컬렉션을 패치 조인하면 페이징이 불가능하다. ( 자세한 이유는 강의 자료 참조 )
- 페이징 + 컬렉션 엔티티를 함께 조회하는 방법
1. 먼저 ToOne (OneToOne, ManyToOne) 관계를 모두 페치조인 한다. ToOne 관계는 쿼리 실행 시 row 수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다.
2. 컬렉션은 지연 로딩으로 조회한다.
3. 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size, @BatchSize를 적용한다.
- hibernate.default_batch_fetch_size - 글로벌 설정
- @BatchSize : 개별 최적화
- 이 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size 만큼 IN 쿼리로 조회한다.
아래 로그를 보면 지연로딩 시 where 부분에 IN 쿼리로 바뀐 것을 볼 수 있다.
select
order0_.order_id as order_id1_6_0_,
member1_.member_id as member_i1_4_1_,
delivery2_.id as id1_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_.id limit ?
select
orderitems0_.order_id as order_id5_5_1_,
orderitems0_.order_item_id as order_it1_5_1_,
orderitems0_.order_item_id as order_it1_5_0_,
orderitems0_.count as count2_5_0_,
orderitems0_.item_id as item_id4_5_0_,
orderitems0_.order_id as order_id5_5_0_,
orderitems0_.order_price as order_pr3_5_0_
from
order_item orderitems0_
where
orderitems0_.order_id in (
?, ?
)
select
item0_.item_id as item_id2_3_0_,
item0_.name as name3_3_0_,
item0_.price as price4_3_0_,
item0_.stock_quantity as stock_qu5_3_0_,
item0_.artist as artist6_3_0_,
item0_.etc as etc7_3_0_,
item0_.author as author8_3_0_,
item0_.isbn as isbn9_3_0_,
item0_.actor as actor10_3_0_,
item0_.director as directo11_3_0_,
item0_.dtype as dtype1_3_0_
from
item item0_
where
item0_.item_id in (
?, ?, ?, ?
)
'JPA' 카테고리의 다른 글
최범균 Spring Data JPA 기초 (0) | 2024.09.25 |
---|---|
@Transactional 어노테이션 관련 인상깊게 정보 본 블로그 (0) | 2023.08.29 |
JPA Entity에 index 거는 방법 (0) | 2022.11.26 |
객체지향 쿼리 언어(JPQL) (0) | 2022.09.03 |
스프링 데이터 JPA 페이징과 정렬 (0) | 2022.05.06 |
댓글