https://surrealcode.tistory.com/120
API 개발 고급 - 준비
현업에서는 API 개발을 한 이후 성능이 나오지 않아 튜닝을 하기도 하는데 보통은 아래와 같은 문제를 지키지 않아 발생한다.- 지연로딩, 조회 성능 최적화- 컬렉션 조회 최적화- 페이징, 한계 돌
surrealcode.tistory.com
개발에 앞서, 이전 포스팅을 참고하여 조회용 데이터 샘플을 넣어주도록 하자.
지연 로딩과 조회 성능 최적화
이전까지는 단일 엔티티만을 조회하는 API를 만들었다. 이번엔 주문 + 배송정보 + 회원을 조회하는 API를 만들어본다.
여러 정보를 조회하는 API의 문제는 지연 로딩이다.
지연 로딩 때문에 발생하는 성능 문제를 단계적으로 해결해보자.
**참고 : 지금부터 설명되는 내용은 정말 중요하다. 실무에서 JPA를 사용하려면 100% 이해를 요한다.
우선 xToOne(ManyToOne, OneToOne)에 대한 관계를 어떻게 풀어내는지 알아보자.
아래 API의 핵심은 Order를 조회하고, Order에서 Member와 연관이 걸리고 Order에서 Delivery와 연관이 걸린 것이다.
/**
* XToOne(ManyToOne, OneToOne)
* Order
* Order -> Member
* Order -> Delivery
*/
@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;
}
}
위와 같은 코드를 돌리면 에러가 발생하게 된다.
Order엔 Member가 있고 member에 가보면 orders가 있기 때문에 JSO이 이 객체를 무한루프를 돌면서 뽑아내기 때문이다.
이전에 보았던것처럼 양방향 연관관계일때 둘 중 하나를 @JsonIgnore를 하여 해결해보자
@JsonIgnore
@OneToMany(mappedBy = "member",fetch = FetchType.LAZY)
private List<Order> orders = new ArrayList<>();
등등 양방향 연관관계가 걸리는 곳에 @JsonIgnore를 하여 해결하려 하였다.
postman을 돌려보면
또 오류가 발생하는 것을 볼 수 있다. 에러메시지를 보면
라고 한다.
즉 무슨 문제냐면 Order를 가지고 왔을 때 member의 fetch가 lazy이기 때문이다.
지연로딩은 진짜 new해서 멤버객체를 가져오는 것이 아닌 프록시를 가져오기 때문이다.
그 프록시가 에러 코드에 포함된 bytebuddy이다.
--> 잭슨 라이브러리가 루프를 돌릴 때 이 오더를 가지고 member를 뽑으려는데 member가 프록시이기 때문에 오류가 발생한 것이다.
이렇게 지연로딩인 경우에는 Hibernate 5 모듈을 통해 해결할 수 있다.
스프링 3.0이상의 경우 jakarta hibernate 5를 통해 해결해야한다.
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5-jakarta'
@Bean
Hibernate5JakartaModule hibernate5Module() {
return new Hibernate5JakartaModule();
}
// 아무곳에나 있어도 괜찮다.
등록 후 postman을 실행해보면 다음과 같은 결과를 볼 수 있다.
member orderItems deliver등이 null 인 것을 볼 수 있는데, 위 세개는 지연 로딩이기 때문에 DB에서 조회한 것이 아니다. 따라서 JSON에서 뿌릴 때 기본 젼략이 지연 로딩은 무시하고 뿌리는 것이다.
이 지연 로딩을 해결하기 위한 포스 로딩이 있다.
@Bean
Hibernate5JakartaModule hibernate5Module() {
Hibernate5JakartaModule hibernate5JakartaModule = new Hibernate5JakartaModule();
hibernate5JakartaModule.configure(Hibernate5JakartaModule.Feature.FORCE_LAZY_LOADING, true);
return hibernate5JakartaModule;
}
--> 위의 코드의 문제점은 역시 엔티티를 그대로 노출하기때문에 엔티티가 변경되면 API 스펙이 다 변경되어야 한다.
성능 상의 문제 또한 발생한다. API의 스펙 상 필요없는 orderItems와 같은 부분들도 전부 긁어와야 해서 쿼리가 많이 나간다.
HibernateModule 자체를 lazy때문에 사용하는 것 자체가 모순이다.
그럼 내가 강제적으로 초기화를 해주는것은 어떨까?
@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();
}
return all;
}
get을 통해 DB에서 값을 직접 긁어오게끔 쿼리를 짰다.
우리가 원했던대로 값을 긁어오는 것을 볼 수 있다.
하지만 이건 복잡하고, 깔끔하지 않다.
나는 두세가지 값이 필요한 API를 만들고 싶은데 불필요한 데이터들이 전부 노출되는 것이 문제이다.
--> 가급적이면 꼭 필요한 데이터만 API 스펙에 노출해야 한다.
정리
1. 엔티티를 직접 노출할때는 양방향 연관관계가 걸린 곳은 꼭 한쪽에 @JsonIgnore처리를 해야한다. 안그러면 무한루프가 발생한다.
2. 정말 간단한 애플리케이션이 아니면 엔티티를 API 응답으로 외부 노출하는 것은 좋지 않다. 따라서 HibernateModule을 사용하는 것 보다 DTO를 변환해서 반환하는 것이 더 좋은 방법이다.
3. 지연 로딩(LAZY)를 피하기 위해 즉시로딩(EAGER)를 설정하면 안된다. 연관관계가 필요 없는 경우에도 데이터를 항상 조회해서 성능 문제가 발생할 수 있다. 즉시 로딩으로 설정하면 성능 튜닝이 어려워진다. 항상 지연 로딩을 기본으로 하고, 성능 최적화가 필요한 경우에는 페치 조인(fetch join)을 사용하여 해결하자.
엔티티를 DTO로 변환해서 반환해보자.
이번엔 DTO로 한번 감싸서 원하는 값들만 뽑아내볼 수 있도록 코드를 개발해보자.
@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> ordersV2() {
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 o) {
orderId = o.getId();
name = o.getMember().getName();
orderDate = o.getOrderDate();
orderStatus = o.getStatus();
address = o.getDelivery().getAddress();
}
}
v2는 api 스펙에 맞추어서 개발을 한 것이라고 볼 수 있다.
만약 Member 엔티티의 name이 username으로 변경되었다 하더라도, DTO 내부의 컴파일 오류를 통해 문제를 찾을 수 있다.
v1의 경우엔 api에서 변경을 해주어야 하기때문에 api의 스펙이 변경되는 문제가 발생하게 된다.
가급적이면 엔티티가 아닌 DTO로 바꾸어 보내주어야 한다.
v1과 v2가 둘 다 가지고 있는 문제가 있다.
Lazy 로딩으로 인한 쿼리가 너무 많이 호출된다는 문제이다.
v2를 보면 Member, Delivery(Address가 있기에), Order
총 세개의 테이블을 건드려야 한다.
public SimpleOrderDto(Order o) {
orderId = o.getId();
name = o.getMember().getName(); //LAZY 초기화
orderDate = o.getOrderDate();
orderStatus = o.getStatus();
address = o.getDelivery().getAddress(); //LAZY 초기화
}
o.getMember().getName()을 통해 LAZY를 초기화 해줄때 쿼리가 나가고
o.getDelivery().getAddress()로 LAZY를 초기화 해줄때 쿼리가 또 나가게 된다.
현재 order가 총 두개이다.
ORDER -> SQL 1번 실행 -> 결과 주문수 2개
첫번째 루프가 돌 때 첫번째 Member를 찾으려고 SQL이 실행된다. 이후 첫번째 멤버의 Deliver Address를 찾기 위해 SQL이 실행되게 되고,
이후 두번째 루프를 돌며 위 과정을 반복하게 된다.(v1과 쿼리수 결과는 같다)
--> 총 5개의 쿼리가 나가게 되는 것이다. 이것이 N+1 문제이다.
@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> ordersV2() {
//ORDER 2개
//N + 1 -> 1 + 회원 N(2개) + 배송 N
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
List<SimpleOrderDto> result = orders.stream()
.map(o -> new SimpleOrderDto(o))
.collect(Collectors.toList());
return result;
}
쿼리는 쿼리대로 나가고, 성능은 성능대로 안나오는 좋지 못한 모습을 보여주고 있다.
그렇다고 해서 연관관계를 EAGER로 해결하면 안된다. 연관관계는 무조건 LAZY이어야만 한다.
-> 이 문제의 해결 방법은 fetch join을 통해 해결할 수 있다.
fetch join 최적화
v2에서 발생한 성능 문제를 fetch join을 통해 나가는 쿼리의 갯수를 줄여 성능을 향상시켜보자.
@Repository
@RequiredArgsConstructor
public class 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();
}
}
OrderRepository에 findAllWithMemberDelivery라는 메서드를 만들었다. 이 메서드는 JPQL을 통해 Order를 조인하는데, 조인할 때 member와 delivery를 join하면서 한번에 다 긁어온다.
Order에 보면 Member와 Delivery가 Lazy로 되어있지만, LAZY를 무시하고 한번에 다 긁는다.
@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;
}
만들어진 v3를 사용하고 쿼리 갯수를 확인해보면
select
o1_0.order_id,
d1_0.delivery_id,
d1_0.city,
d1_0.street,
d1_0.zipcode,
d1_0.status,
m1_0.member_id,
m1_0.city,
m1_0.street,
m1_0.zipcode,
m1_0.name,
o1_0.order_date,
o1_0.status
from
orders o1_0
join
member m1_0
on m1_0.member_id=o1_0.member_id
join
delivery d1_0
on d1_0.delivery_id=o1_0.delivery_id
총 쿼리는 한개만 나간것을 확인할 수 있다.
기존이랑 완전히 똑같은데, 엔티티를 페치 조인(fetch join)을 사용해서 쿼리 한개로 조회를 한 것이다.
fetch join을 통해서 'order -> member', 'order -> delivery'가 미리 조회되었기 때문에 지연로딩 자체가 일어나지 않게 된다.
이것을 좀 더 간단히 만들 수 있는 방법이 있다.
JPA에서 DTO로 바로 조회하는 방법이다.
기존에는 엔티티를 한번 조회하고, 이 엔티티를 DTO로 중간에 한번 변환하는 변환과정을 거쳤다.
현재까지의 DTO는 Controller에 클래스가 있다. 이번엔 DTO를 Repository에서 사용해야하기 때문에
Repository와 Controller 간의 의존관계 문제가 발생할수도 있다.
따라서 별도의 DTO 클래스를 생성하고자 한다.
의존관계는 한방향으로만 흘러야 하기 때문이다.
@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; //LAZY 초기화
this.orderDate = orderDate;
this.orderStatus = orderStatus;
this.address = address; //LAZY 초기화
}
}
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();
}
@GetMapping("/api/v4/simple-orders")
public List<OrderSimpleQueryDto> ordersV4() {
return orderRepository.findOrderDtos();
}
v4의 특징은 다음과 같다.
- 일반적인 SQL을 사용할때처럼 원하는 값을 선택해서 조회한다.
- new 명령어를 사용해서 JPQL의 결과를 DTO로 즉시 반환하였다.
- SELECT 절에서 원하는 데이터를 직접 선택하므로 DB -> 애플리케이션 네트워크 용량이 최적화 된다.(생각보다 미비함)
- 리포지토리 재사용성이 떨어짐, API 스펙에 맞춘 코드가 리포지토리에 들어가는 단점 발생
postman의 결과는 v3와 동일하다. 하지만 Query를 보면 다른 것을 볼 수 있는데, 기존의 v3는 전부 찔러서 Select를 한 반면 v4는 내가 필요한 것들, id, name, orderdate, status, address만 찔러서 가져온 것을 확인할 수 있다.
select
o1_0.order_id,
m1_0.name,
o1_0.order_date,
o1_0.status,
d1_0.city,
d1_0.street,
d1_0.zipcode
from
orders o1_0
join
member m1_0
on m1_0.member_id=o1_0.member_id
join
delivery d1_0
on d1_0.delivery_id=o1_0.delivery_id
그렇다면 v3보다 v4가 성능이 더 좋은것일까?
-> v4랑 v3는 둘 간의 우열을 가리기가 어렵다. 트레이드 오프가 있기 때문이다.
v3는 Order를 가져올 때 fetch join으로 내가 원하는 것만 select를 한 것이다. 반면 v4는 실제 SQL 짜듯이 JPQL을 짜서 가져온 것이다. 화면에 최적화는 되어있지만, 다른 API에 적용시키기 어렵다. 재사용성이 떨어진다.
엔티티를 DTO로 변환하거나, DTO로 바로 조회하는 두가지 방법은 각각의 장단점이 있다. 둘 중 상황에 따라서 더 나은 방법을 선택하면 된다. 엔티티로 조회하면 리포지토리 재사용성도 좋고, 개발도 단순해진다. 따라서 권장하는 방법은 다음과 같다.
*쿼리 방식 선택 권장 순서*
1. 우선 엔티티를 DTO 로 변환하는 방법을 선택한다.
2. 필요하면 페지 조인으로 성능을 최적화한다. -> 대부분의 성능 이슈가 해결된다.
3. 그래도 안되면 DTO로 직접 조회하는 방법을 사용한다.
4. 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template를 사용해서 SQL을 직접 사용한다.
'공부 > Spring Boot' 카테고리의 다른 글
API 개발 고급 - 실무 필수 최적화 (0) | 2025.02.19 |
---|---|
API 개발 고급 - 컬렉션 조회 최적화 (0) | 2025.02.19 |
API 개발 고급 - 준비 (0) | 2025.01.31 |
API 개발 기본 (0) | 2025.01.31 |
웹 계층 개발 (1) | 2024.11.25 |