실무에서 너무 성능이 안 나와서 튜닝을 해야 할 경우, 코드를 확인해 보면 대부분 아래에 나열해 둔 문제가 자주 발생한다고 한다.
- 지연 로딩과 조회 성능 최적화
- 컬렉션 조회 최적화
- 페이징과 한계 돌파
- OSIV와 성능 최적화
등록 및 수정에선 거의 문제가 발생하지 않고, 90% 정도 조회할 때 성능 문제가 발생한다. 따라서 조회용 샘플 데이터를 입력한 뒤 각 문제에 맞춰 API 성능을 최적화하는 방법을 학습한다.
이번 섹션에선 주문 및 배송 정보와 회원을 조회하는 API를 만들면서, 지연 로딩 때문에 발생하는 성능 문제를 단계적으로 해결해 본다.
참고
실무에서 JPA를 사용하려면 지금부터 설명하는 내용을 100% 이해해야 한다. 안 그러면 엄청난 시간을 날리게 될 수도 있다.
간단한 주문 조회 V1: 엔티티를 직접 노출
우선 xToOne 관계를 최적화하는 방법에 대해 학습한다. 여기서 Order와 Member는 다대일(ManyToOne) 관계이고, Order와 Delivery는 일대일(OneToOne) 관계다. 우선 엔티티를 직접 반환하는 경우를 살펴보자.
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
private final OrderRepository orderRepository;
/**
* V1. 엔티티 직접 노출
* - Hibernate5Module 모듈 등록, LAZY=null 처리
* - 양방향 관계 문제 발생 -> @JsonIgnore
*/
@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;
}
}
양방향 관계의 무한(순환) 참조 문제
위 코드에서 Lazy 강제 초기화를 진행하는 for문을 삭제하고 Order 리스트를 반환하면 무한 루프에 빠지는 문제가 발생하게 된다.
- Order에서 Member 필드를 통해 Member로 이동하고, 이동한 Member에서 Order 리스트를 통해 Order로 이동한다. 이동한 Order에서 또 Member 필드를 통해 Member로 이동하고... 이 두 과정을 무한으로 반복하면서 JSON으로 뽑아내게 된다.
- 양방향 연관관계에서 흔히 볼 수 있는 무한(순환) 참조 문제다. 이 문제를 해결하려면, 아래처럼 두 엔티티 중 한 곳에 @JsonIgnore 애노테이션을 붙여야 한다.
@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;
}
}
///
@Entity
@Getter @Setter
public class Member {
...
@JsonIgnore // 양방향 연관관계에서 발생하는 순환 참조 문제 해결
@OneToMany
private List<Order> orders = new ArrayList<>();
}
프록시 객체 JSON 변환 문제
Order는 DB에서 직접 데이터를 가져오지만, Member와 Delivery는 지연 로딩으로 설정돼 있기 때문에 실제 객체를 상속받은 프록시 객체로 가져온다. 이후 실제 데이터를 사용해야 할 때 프록시를 초기화(DB에서 직접 데이터를 꺼내서 값을 채우고 반환)한다.
- 프록시 기술을 사용할 때 bytebuddy라는 라이브러리를 사용한다. 따라서 Order에 들어 있는 Member나 Delivery 객체의 class를 확인하면 ByteBuddyInterceptor로 돼 있는 걸 확인할 수 있다.
- jackson 라이브러리는 기본적으로 이 프록시 객체를 JSON으로 어떻게 생성해야 하는지 모르기 때문에 예외가 발생하게 된다. 이 문제는 Hibernate5JakartaModule을 스프링 빈으로 등록하면 해결할 수 있다. 아래에서 더 자세하게 알아보자.
- 하이버네이트 모듈을 사용하면 지연 로딩을 사용한 경우 JSON으로 생성할 때 값(프록시 객체)을 무시하는 전략을 사용하게 된다. 따라서 Order를 조회할 때 Member와 Delivery는 null로 설정된다.
하이버네이트 모듈을 등록하는 방법에 대해 알아보자. 스프링 부트 3.0 이상을 기준으로 작성한다. 우선 build.gradle에 다음 라이브러리를 추가한다.
dependencies {
...
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5-jarkarta'
}
이후 JpashopApplication에 다음 코드를 추가해 Hibernate5JakartaModule을 스프링 빈으로 등록한다.
@SpringBootApplication
public class JpashopApplication {
public static void main(String[] args) {
SpringApplication.run(JpashopApplication.class, args);
}
@Bean
Hibernate5JakartaModule hibernate5JakartaModule() {
return new Hibernate5JakartaModule();
}
}
이때, 아래와 같이 설정하면 강제로 지연 로딩을 설정할 수 있다. 이 옵션을 키면 Order와 Member, Delivery 같은 양방향 연관관계를 계속해서 로딩(호출) 하기 때문에 무한 루프에 빠지게 된다. 따라서 둘 중 한 곳에 @JsonIgnore 옵션을 설정해야 한다.
- 이렇게 강제 지연 로딩을 설정하기보단, Order를 호출할 때 Member와 Delivery 값도 조회해서 프록시를 강제로 초기화하는 방법을 사용하는 게 좋다. 맨 위에 있는 코드를 살펴보자.
@SpringBootApplication
public class JpashopApplication {
public static void main(String[] args) {
SpringApplication.run(JpashopApplication.class, args);
}
@Bean
Hibernate5JakartaModule hibernate5JakartaModule() {
Hibernate5JakartaModule hibernate5JakartaModule = new Hibernate5JakartaModule();
// 강제 지연 로딩 설정
// hibernate5JakartaModule.configure(Hibernate5JakartaModule.Feature.FORCE_LAZY_LOADING, true);
return hibernate5JakartaModule;
}
}
참고
물론 Hibernate5JakartaModule을 쓰는 것보단 DTO로 변환해서 반환하는 것이 더 좋은 방법이다.
주의
지연 로딩(LAZY)을 피하기 위해 즉시 로딩(EAGER)으로 설정하면 안 된다. 즉시 로딩 때문에 연관관계가 필요 없는 경우에도 데이터를 항상 조회해서 성능 문제가 발생할 수 있다. 따라서 성능 튜닝이 매우 어려워진다.
항상 지연 로딩을 기본으로 하고, 성능 최적화가 필요한 경우에는 페치 조인(fetch join)을 사용하자.
간단한 주문 조회 V2: 엔티티를 DTO로 변환
엔티티를 그대로 반환하지 말고 DTO로 변환해 반환하는 방식을 적용했다. 그런데 엔티티를 그대로 반환하는 방식과 동일하게도 쿼리가 총 1 + N + N번 실행된다는 문제가 해결되지 않는다.
- Order 조회 1번 → 조회 결과 N행
- Order에서 Member 지연 로딩 조회 N번
- Order에서 Delivery 지연 로딩 조회 N번
- 따라서 총 1 + N + N번의 쿼리가 나간다. Order 조회 결과가 4개의 행을 갖는다고 하면, 최악의 경우엔 쿼리가 9번 실행된다.
- 지연 로딩은 영속성 컨텍스트에 있으면 영속성 컨텍스트에서 있는 엔티티를 사용하고, 없으면 SQL을 실행한다. 따라서 같은 영속성 컨텍스트에서 이미 로딩한 회원 엔티티를 추가로 조회하면 SQL 쿼리를 생략한다. 따라서 무조건 최악의 경우까지 가진 않는다.
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
...
/**
* V2. 엔티티를 조회해서 DTO로 변환(fetch join 사용X)
* - 단점: 지연로딩으로 쿼리 N번 호출
*/
@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 order) {
orderId = order.getId();
name = order.getMember().getName(); // Lazy 강제 초기화
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress(); // Lazy 강제 초기화
}
}
}
참고
DTO가 엔티티를 참조하도록 만들어도 된다.
ex. SimpleOrderDto
DTO에 @Getter, @Setter 애노테이션을 붙이지 않으면 InvalidDefinitionException이 발생하기도 한다. no properties라는 에러가 뜬다면 애노테이션이 적절하게 설정돼 있는지 확인해 보자.
참고
지연 로딩을 즉시 로딩으로 바꾼다고 해도 성능이 좋아지진 않는다. 또, 예측하지 못한 SQL이 실행될 수 있다.
간단한 주문 조회 V3: 엔티티를 DTO로 변환 - 페치 조인 최적화
V2 코드에 페치 조인을 사용해 모든 엔티티를 쿼리 1번에 조회할 수 있도록 해보자. 먼저 OrderRepository에 아래 메서드를 추가한다.
- 페치 조인으로 Order를 조회할 때 Member와 Delivery도 한 번에 조회하기 때문에 지연 로딩이 발생하지 않는다.
@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();
}
}
위에서 만든 메서드로 Order 리스트를 뽑아내면 된다. 페치 조인을 사용했기 때문에 쿼리가 1 + N + N번에서 1번으로 줄어든 걸 확인할 수 있다.
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
...
/**
* V3. 엔티티를 조회해서 DTO로 변환(fetch join 사용O)
* - fetch join으로 쿼리 1번 호출
* 참고: fetch join에 대한 자세한 내용은 JPA 기본편 참고(정말 중요함)
*/
@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;
}
}
간단한 주문 조회 V4: JPA에서 DTO로 바로 조회
페치 조인을 사용하지 않고 JPA에서 DTO로 바로 조회하는 방식도 존재한다. 우선 조회 전용 리포지토리와 DTO를 만들자.
- 이 방식을 사용하면, 일반적인 SQL을 사용할 때처럼 원하는 값을 선택해서 조회할 수 있다.
- JPA는 기본적으로 엔티티나 내장 값 타입(Value Object)을 반환한다. DTO를 반환하려면 new 명령어를 사용해야 한다.
- DTO의 생성자에 Order를 넣으려고 해도, JPA를 사용하면 식별자를 넘기게 되기 때문에 엔티티 자체를 넘길 수 없다.
- 아래 코드를 보면 new 명령어를 사용해 JPQL의 결과를 DTO로 즉시 변환한다. SELECT 절에서 원하는 데이터를 직접 선택하므로 DB에서 애플리케이션으로 이어지는 네트워크의 용량을 최적화할 수 있다. 단, 생각보다 크게 최적화되진 않는다.
- 단점으로는 리포지토리 재사용성이 떨어지며, API 스펙에 맞춘 코드가 리포지토리에 들어간다는 점이 있다.
- repository/xxx/simplequery 경로에 xxxSimpleQueryRepository와 xxxSimpleQueryDto를 분리하면, 기존 리포지토리는 순수하게 엔티티를 조회하도록 남겨둘 수 있다. 화면을 위한 복잡한 DTO 조회 API와 엔티티 조회 API를 분리한다고 보면 된다.
- JPA는 기본적으로 엔티티나 내장 값 타입(Value Object)을 반환한다. DTO를 반환하려면 new 명령어를 사용해야 한다.
@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 fetch o.member m" +
" join fetch o.delivery d", OrderSimpleQueryDto.class)
.getResultList();
}
}
///
@Data
public class OrderSimpleQueryDto { // 조회 전용 DTO
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;
}
}
JPA에서 DTO로 바로 조회하는 방식을 사용해도 페치 조인처럼 쿼리가 1번만 나간다.
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
...
private final OrderSimpleQueryRepository orderSimpleQueryRepository;
...
/**
* V4. JPA에서 DTO로 바로 조회
* - 쿼리 1번 호출
* - select 절에서 원하는 데이터만 선택해서 조회
*/
@GetMapping("/api/v4/simple-orders")
public List<OrderSimpleQueryDto> ordersV4() {
return orderSimpleQueryRepository.findOrderDtos();
}
}
참고
가급적이면 Controller에서 Repository에 바로 접근하지 않고, Controller → Service → Repository처럼 접근하도록 설계하는 게 좋다. 무조건 이렇진 않더라도 한 방향으로 흐르도록 해야 한다.
V3와 V4 중에 어느 게 더 효율적일까?
- V3는 Order를 가지고 올 때 페치 조인으로 원하는 엔티티만 선택해 가져온다. 이때 Order를 줘야 한다는 외부 조건을 건드리지 않는 상태에서 내부의 원하는 값만 가져와 성능 튜닝을 진행한다. 원하는 DTO를 선택해 자유롭게 쓸 수 있기 때문에 다양한 API에서 활용할 수 있다. 추가로, 엔티티로 조회했기 때문에 비즈니스 로직으로 값을 변경할 수 있다.
- V4는 쿼리를 실제 SQL을 짜듯이 JPQL을 작성해 원하는 값을 가져온다. 따라서 SELECT 절에서 가져오는 값을 줄여 성능 튜닝을 진행하므로 V3보다 성능이 조금 더 좋다. 그러나 정해진 DTO가 있어야 사용할 수 있기 때문에 재사용성이 떨어진다. 추가로, DTO로 조회하면 값을 변경할 수 없다.
성능 차이는 대부분 FROM 절에서 생기기 때문에 SELECT 절에서 성능을 최적화하는 경우는 드물다. 따라서 부하 테스트를 해보고 결정하는 게 좋다.
정리
엔티티를 DTO로 변환하거나, JPA에서 DTO로 바로 조회하는 두 가지 방법엔 각각 장단점이 있다. 둘 중 상황에 따라 더 나은 방법을 선택하면 된다. 엔티티로 조회(+DTO 변환)하면 리포지토리 재사용성도 좋고, 개발도 단순해진다. 따라서 권장하는 방법은 다음과 같다.
쿼리 방식 선택 권장 순서
1. 우선, 엔티티를 DTO로 변환하는 방식 선택
2. 필요하면, 페치 조인으로 성능 최적화 → 여기서 대부분의 성능 이슈가 해결됨
3. 그래도 안 되면, DTO로 직접 조회하는 방법 사용
4. 최후의 방법 → JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template를 사용해 SQL을 직접 사용