OneToOne, ManyToOne 관계에서의 지연로딩과 성능 최적화 예시를 위해 간단한 주문조회 API 컨트롤러와 엔티티를 구현하고 단계별로 어떻게 최적화 하는지 확인
엔티티 예시
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
31
32
33
34
35
36
37
38
@Entity
public class Member {
@Id
@GeneratedValue
@Column(name = "member_id")
private Long id;
private String name;
@Embedded
private Address address;
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
}
@Entity
public class Order {
@Id
@GeneratedValue
@Column(name = "order_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@JoinColumn(name = "delivery_id")
private Delivery delivery;
...
}
주문조회 V1 - 엔티티를 직접 노출
1
2
3
4
5
@GetMapping("/api/v1/simple-orders")
public List<Order> ordersV1() {
List<Order> all = orderRepository.findAllByString(new OrderSearch());
return all;
}
- 엔티티를 직접 노출
- 위 코드를 실행하면 예외가 발생함
- order -> member 와 order -> address 의 관계가 지연로딩일떄, member와 address는 실제 엔티티 대신 프록시가 존재하게 됨
- jackson 라이브러리는 이러한 프록시 객체를 json으로 어떻게 생성해야 하는지 모르므로 예외를 발생 시킴
- 예외를 해결하기 위해 Hibernate5Module 을 스프링 빈으로 등록하면 해결
위 코드에 대해 Hibernate5Module 을 설치하여 예외를 해결하더라도 api 동작시 응답을 확인해보면, order의 member 필드의 값이 JSON에서 null로 응답하게 됨. 이는 지연 로딩으로 인하여 member 가 실제 엔티티가 아닌 프록시이기 때문인데, 이에 대한 해결방법은 아래와 같이 강제로 초기화하여 엔티티를 로딩 시켜주면 해결이 됨.
1
2
3
4
5
6
7
8
9
10
11
12
@GetMapping("/api/v1/simple-orders")
public List<Order> ordersV1() {
List<Order> all = orderRepository.findAllByString(new OrderSearch());
for (Order order : all) {
// Lazy 강제 초기화
order.getMember().getName();
// Lazy 강제 초기환
order.getDelivery().getAddress();
}
return all;
}
위 V1 코드의 문제점 및 주의점들은 아래와 같음
- 엔티티를 직접 노출할 때는 양방향 연관관계가 걸린 곳은 한곳을 @JsonIgnore 처리 해야 함
- @JsonIgnore를 사용하지 않는다면 양쪽을 서로 호출하면서 무한 루프가 걸리게 됨
- 정말 간단한 애플리케이션이 아니면 엔티티를 API 응답으로 외부로 노출하는 것은 피하는 것이 좋음
- Hibernate5Module 를 사용하기 보다는 DTO로 변환해서 반환하는 것이 더 좋은 방법
- 지연 로딩(LAZY)을 피하기 위해 즉시 로딩(EARGR)으로 설정하면 안됨
- 즉시 로딩 때문에 연관관계가 필요 없는 경우에도 데이터를 항상 조회해서 성능 문제가 발생할 수 있음
- 즉시 로딩으로 설정하면 성능 튜닝이 매우 어려워 짐
- 항상 지연 로딩을 기본으로 하고, 성능 최적화가 필요한 경우에는 페치 조인(fetch join)을 사용하는 것을 추천
주문조회 V2 - 엔티티를 DTO로 변환
API의 응답을 엔티티로 바로 전달하는게 아닌 별도의 DTO를 만들어서 해당 DTO를 응답으로 반환하는 방식
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/**
* V2. 엔티티를 조회해서 DTO로 변환(fetch join 사용X)
* - 단점: 지연로딩으로 쿼리 N번 호출
*/
@GetMapping("/api/v2/simple-orders")
public Result<List<SimpleOrderDto>> ordersV2() {
log.info("OrderSimpleApiController.ordersV2");
List<Order> orders = repository.findAllByString(new OrderSearch());
List<SimpleOrderDto> simpleOrderDtoes =
orders.stream()
.map(order -> new SimpleOrderDto(order))
.collect(Collectors.toList());
Result<List<SimpleOrderDto>> result = new Result<>(simpleOrderDtoes);
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) {
this.orderId = order.getId();
// Lazy loading 초기화
// 영속성 컨텍스트에 조재하지 않는다면 조회 쿼리 발생
this.name = order.getMember().getName();
this.orderDate = order.getOrderDate();
this.orderStatus = order.getStatus();
// Lazy loading 초기화
// 영속성 컨텍스트에 조재하지 않는다면 조회 쿼리 발생
this.address = order.getDelivery().getAddress();
}
}
@Data
static class Result<T> {
private T data;
public Result(T data) {
this.data = data;
}
}
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
{
"data": [
{
"orderId": 4,
"name": "userA",
"orderDate": "2023-10-27T12:02:55.087198",
"orderStatus": "ORDER",
"address": {
"city": "seoul",
"street": "1",
"zipcode": "1111"
}
},
{
"orderId": 11,
"name": "userB",
"orderDate": "2023-10-27T12:02:55.11428",
"orderStatus": "ORDER",
"address": {
"city": "busan",
"street": "2",
"zipcode": "2222"
}
}
]
}
API의 응답으로 DTO로 반환하도록 하여 V1 보다는 개선됬지만, 이 코드에선 한가지 문제가 더 존재함. 해당 API 실행하고 로그를 확인해보면, order 한번을 조회하여 쿼리가 한개만 나가야 할것 처럼 보이지만 총 5번의 쿼리가 실행됨.
2023-10-27 12:03:00.106 DEBUG 78441 --- [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-10-27 12:03:00.121 DEBUG 78441 --- [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-10-27 12:03:00.124 DEBUG 78441 --- [nio-8080-exec-1] 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-10-27 12:03:00.125 DEBUG 78441 --- [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-10-27 12:03:00.126 DEBUG 78441 --- [nio-8080-exec-1] 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=?
...
이 로그에 대한 분석은 아래와 같으며 이러한 문제를 N + 1 문제 라고 함
- 쿼리가 총 1 + N + N번 실행됨 (v1과 쿼리수 결과는 같음)
- order 조회 1번 order 조회 결과 수가 N이 됨
- order -> member 지연로딩 조회 N 번
- order -> delivery 지연로징 조회 N 번
- 예) order의 결과가 4개면 최악의 경우 1 + 4 + 4 번 실행 됨
- 지연로딩은 영속성 컨텍스트에서 조회하므로 이미 조회된 경우 쿼리를 생략 함
이러한 N + 1 문제를 해결하기 위해선 최초 order를 조회할떄 연관된 엔티티들도 한번에 조회를 해야하는데, 이때 즉시로딩(EAGER)를 사용하면 안되고 fetch join 을 통하여 문제를 해결해야함.
- 즉시로딩(EAGER)를 사용시 최초 조회시 연관된 엔티티를 모두 조회하긴 하지만 어떠한 쿼리가 발생할지 직관적으로 알기에 어려워 사용을 피하는 것이 좋음
주문조회 V3 - 엔티티를 DTO로 변환 및 페치 조인 최적화
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
31
32
33
@Repository
@RequiredArgsConstructor
public class OrderRepository {
private final EntityManager entityManager;
...
public List<Order> findAllWithMemberDelivery() {
String jpql =
"select o " +
"from Order o " +
"join fetch o.member m " +
"join fetch o.delivery d";
return entityManager.createQuery(jpql, Order.class).getResultList();
}
}
/**
* V3. 엔티티를 조회해서 DTO로 변환(fetch join 사용O)
* - fetch join으로 쿼리 1번 호출
* 참고: fetch join에 대한 자세한 내용은 JPA 기본편 참고(정말 중요함)
*/
@GetMapping("/api/v3/simple-orders")
public Result<List<SimpleOrderDto>> ordersV3() {
List<Order> orders = repository.findAllWithMemberDelivery();
List<SimpleOrderDto> dto =
orders.stream()
.map(order -> new SimpleOrderDto(order))
.collect(Collectors.toList());
return new Result<>(dto);
}
2023-10-27 14:16:03.386 DEBUG 81195 --- [nio-8080-exec-1] 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는 order 조회시 fetch join으로 같이 조회되어 연속성 컨텍스트에 관리되고 있는 상태이므로 지연로딩X
주문조회 V4 - JPA 에서 DTO로 바로 조회
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@Data
public class OrderSimpleQueryDto {
private Long orderId;
private String name;
private LocalDateTime orderTime;
private OrderStatus orderStatus;
private Address address;
public OrderSimpleQueryDto(Long orderId, String name, LocalDateTime orderTime, OrderStatus orderStatus,
Address address) {
this.orderId = orderId;
this.name = name;
this.orderTime = orderTime;
this.orderStatus = orderStatus;
this.address = address;
}
}
@Repository
@RequiredArgsConstructor
public class OrderSimpleQueryRepository {
private final EntityManager entityManager;
public List<OrderSimpleQueryDto> findOrdersDtos() {
String jpql =
"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";
return entityManager.createQuery(jpql, OrderSimpleQueryDto.class)
.getResultList();
}
}
/**
* V4. JPA에서 DTO로 바로 조회
* - 쿼리 1번 호출
* - select 절에서 원하는 데이터만 선택해서 조회
*/
@GetMapping("/api/v4/simple-orders")
public Result<List<OrderSimpleQueryDto>> ordersV4() {
List<OrderSimpleQueryDto> orders =
orderSimpleQueryRepository.findOrdersDtos();
return new Result<>(orders);
}
2023-10-27 14:38:02.683 DEBUG 83425 --- [nio-8080-exec-1] org.hibernate.SQL :
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_.delivery_id
...
join 을 통해 필요한 정보가 있는 엔티티들도 한번에 조회한 이후 정의한 DTO에 맞춰 필요한 필드를 SELECT 함
- 일반적인 SQL을 사용할 때 처럼 원한는 값을 선택하여 조회
- new 명령어를 사용하여 JPQL의 결과를 DTO로 즉시 변환
- SELECT 절에서 원하는 데이터를 직접 선택하므로 DB -> 어플리케이션 네트워크 용량 최적화
- 리포지토리 재사용성이 떨어짐
- API 스펙에 맞춘 코드가 리포지토리에 들어가는 단점
정리
엔티티를 DTO로 변환(ordersV3 방법)하거나, DTO로 바로 조회(ordersV4 방법)하는 두가지 방법은 각각 장단점이 있음. 둘중 상황에 따라 더 나은 방법을 택하면 됨.
쿼리 방식 선택 권장 순서
- 우선 엔티티를 DTO로 변환하는 방법을 선택(ordersV2 방법)
- 필요하다면 페치 조인으로 성능 최적화 시도(ordersV3 방법)
- 대부분의 경우 이 단계에서 성능 이슈가 해결 됨
- 그래도 안된다면 DTO로 직접 조회하는 방법으로 성능 최적화 시도(ordersV4 방법)
- 최후의 방법으로는 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용하여 SQL을 직접 사용
참고
- 실전! 스프링 부트와 JPA 활용2 - 웹 애플리케이션 개발(김영한)