우선 들어가기 전에 엔티티를 Request Body에 직접 매핑하게 되면 안 되고, 직접 노출해서도 안된다는 말을 많이 들었는데 정확하게 왜 안되는지 알아보겠다.
- 엔티티에 API 검증을 위한 로직이 들어간다. (@NotEmpty 등등)
➜ 실무에서는 하나의 엔티티를 위한 API가 다양하게 만들어지는데,
한 엔티티에 각각의 API를 위한 모든 요청의 요구사항을 담기는 어렵다.
➜ 예를 들어 1번 요구사항에서는 "이름의 유효성 검사를 3글자 이상으로 해라"라고 했는데
2번 요구사항에서는 "이름의 유효성 검사를 3글자 이하로 해라"라고 하면
매우 난감해진다. - 엔티티가 변경되면 API 스펙이 변한다.
- 실무에서는 보안이 중요하기 때문에 엔티티를 API 스펙에 노출하면 안 된다!
➜ DTO로 변환해서 Client단에 전달해야 보안적으로 안전하다.
결론: API 요청 스펙에 맞추어 별도의 DTO를 파라미터로 받는다.
지연 로딩과 조회 성능 최적화
우선 Entitiy가 어떻게 설계되었는지 보자.
@Entity
@Table(name = "orders")
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {
@Id @GeneratedValue
@Column(name = "order_id")
private Long id;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "member_id")
private Member member;
@OneToOne(fetch = LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "delivery_id")
private Delivery delivery;
private LocalDateTime orderDate; //주문시간
@Enumerated(EnumType.STRING)
private OrderStatus status; //주문상태 [ORDER, CANCEL]
...
}
xToOne(ManyToOne, OneToOne) 관계에서의 최적화부터 다뤄보겠다.
Order
Order ➜ Member
Order ➜ Delivery
/**
* 엔티티를 조회해서 DTO로 변환(fetch join 사용X)
* - 단점: 지연로딩으로 쿼리 N번 호출
*/
@GetMapping("/api/simple-orders1")
public List<SimpleOrderDto> orders1() {
List<Order> orders = orderRepository.findAll();
List<SimpleOrderDto> result = orders.stream()
.map(o -> new SimpleOrderDto(o))
.collect(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 초기화
}
}
여기서 LAZY 초기화가 무엇이냐?
영속성 콘텍스트가 member의 id값을 가지고 영속성 콘텍스트를 찾아본다. 그리고 없으면 DB에 쿼리를 날린다.
- 엔티티를 DTO로 변환하는 일반적인 방법이다.
- 쿼리가 총 1 + N + N번 실행된다.
- order 조회 1번(order 조회 결과 수가 N이 된다.)
- order ➜ member 지연 로딩 조회 N번
- order ➜ delivery 지연 로딩 조회 N번
- 만약에 order를 조회했는데 2개의 row가 나왔다면 최악의 경우 1+2+2번 실행된다.
(지연로딩은 영속성 컨텍스트에서 조회하므로, 이미 조회된 경우 쿼리를 생략한다.)
➜ order를 조회했을 때 2개의 order를 같은 member가 시켰다면 쿼리 한 번은 생략하고
영속성 컨텍스트에서 결과를 가져올 것이다. 그래서 최악의 경우 최대 2개라고 한 것이다.
실행
2023-06-15 16:17:36.336 DEBUG 36560 --- [nio-8080-exec-3] 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_
2023-06-15 16:17:36.337 DEBUG 36560 --- [nio-8080-exec-3] 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-15 16:17:36.338 DEBUG 36560 --- [nio-8080-exec-3] org.hibernate.SQL :
select
delivery0_.delivery_id as delivery1_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_.delivery_id=?
2023-06-15 16:17:36.339 DEBUG 36560 --- [nio-8080-exec-3] 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-15 16:17:36.340 DEBUG 36560 --- [nio-8080-exec-3] org.hibernate.SQL :
select
delivery0_.delivery_id as delivery1_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_.delivery_id=?
이렇게 콘솔창을 보면 order에 대한 쿼리 1개, 각각 order에 대한 member 쿼리 2개, delivery 쿼리 2개가 나왔다. 이것이 바로 많이 들었던 N+1, 어찌 보면 1+N이 맞는 것 같다.
➜ 그렇다면 이것을 어떻게 해결할 수 있을까??
- Fetch Join
- Batch Size
패치 조인 최적화
/**
* 엔티티를 조회해서 DTO로 변환(fetch join 사용O)
* - fetch join으로 쿼리 1번 호출
*/
@GetMapping("/api/simple-orders2")
public List<SimpleOrderDto> orders2() {
List<Order> orders = orderRepository.findAllWithMemberDelivery();
List<SimpleOrderDto> result = orders.stream()
.map(o -> new SimpleOrderDto(o))
.collect(toList());
return result;
}
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();
}
실행
2023-06-15 16:40:23.959 DEBUG 36560 --- [nio-8080-exec-7] org.hibernate.SQL :
select
order0_.order_id as order_id1_6_0_,
member1_.member_id as member_i1_4_1_,
delivery2_.delivery_id as delivery1_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_.delivery_id
- 엔티티를 패치 조인(fetch join)을 사용해서 쿼리 1번에 조회
- 패치 조인으로 order ➜ member, order ➜ delivery는 이미 조회된 상태 이므로 지연로딩 X
Batch Size 최적화
옵션 추가
// yml
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 1000 #최적화 옵션
// properties
spring.jpa.properties.hibernate.default_batch_fetch_size=1000
- application.yml파일이나 application.properties파일에 옵션을 추가해 준다.
추가해 주고 아까 5번의 쿼리를 불렀던 함수(@GetMapping("/api/simple-orders1") orders1())를 다시 실행해 보면
2023-06-15 18:44:44.946 DEBUG 47720 --- [nio-8080-exec-3] 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_
2023-06-15 18:44:44.947 DEBUG 47720 --- [nio-8080-exec-3] 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 in (
?, ?
)
2023-06-15 18:44:44.948 DEBUG 47720 --- [nio-8080-exec-3] org.hibernate.SQL :
select
delivery0_.delivery_id as delivery1_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_.delivery_id in (
?, ?
)
위의 결과에서 봤듯이 이 옵션은 정확히는 N+1 문제를 안 일어나게 하는 방법은 아니고 N+1 문제가 발생하더라도 select * from member where member_id in (?,?) 방식으로 발생하게 하는 방법이다. 이렇게 하면 100번 일어날 문제를 1번만 조회하는 방식으로 성능을 최적화할 수 있다.
참고: size에 1000을 줬다면 in절에 1000개까지만 가능하고 1000개 이상이면 쿼리를 또 날리게 된다.
인프런에서 진행한 김영한 님의 강의(스프링 부트와 JPA 활용 2)를 토대로 정리한 포스팅입니다.
자세한 내용은 링크를 참고하시기 바랍니다. 문제시 바로 삭제하겠습니다.