개요
이전 글에서 살펴보았듯 JPA로 OneToOne이나 ManyToOne 관계를 조회할 때는 비교적 단순한 방식으로 성능을 개선할 수 있다. 예를 들어, 페치 조인(fetch join) 을 적용하면 여러 쿼리를 한 번으로 줄일 수 있다.
하지만 OneToMany 같은 컬렉션 관계를 다룰 때는, 조인으로 인해 데이터가 중복 조회(일명 ‘뻥튀기’)될 수 있고, 페이징 문제도 발생한다. 이번 글에서는 이러한 상황에서 고려해야 할 대표적인 최적화 기법들을 살펴본다.
내용
엔티티를 직접 반환
아래 코드는 엔티티를 그대로 노출하는 예시이다.
@RestController
@RequiredArgsConstructor
public class OrderApiController {
private final OrderRepository orderRepository;
@GetMapping("/api/orders-entity")
public List<Order> getOrdersEntity() {
List<Order> all = orderRepository.findAllByString(new OrderSearch());
for (Order order : all) {
order.getMember().getName(); // 지연 로딩 강제 초기화
order.getDelivery().getAddress(); // 지연 로딩 강제 초기화
List<OrderItem> orderItems = order.getOrderItems();
orderItems.forEach(o -> o.getItem().getName()); // 지연 로딩 강제 초기화
}
return all;
}
}
@JsonIgnore를 사용해 무한 루프를 방지하고, 지연 로딩된 엔티티를 강제로 초기화해서 JSON으로 생성할 수 있다. 하지만 이전 글에서도 말하였듯 엔티티를 직접 노출하는 방식은 유지보수와 확장성 면에서 바람직하지 않다.
엔티티를 DTO로 변환
다음 예시에서는 엔티티 대신 DTO로 변환하여 외부에 노출한다. 특히 OrderItem 같은 컬렉션 관계도 별도 DTO를 만들어 반환해야 한다.
@RestController
@RequiredArgsConstructor
public class OrderDtoController {
private final OrderRepository orderRepository;
@GetMapping("/api/orders-dto")
public List<OrderDto> getOrdersDto() {
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
return orders.stream()
.map(OrderDto::new)
.collect(toList());
}
@Data
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(OrderItemDto::new)
.collect(toList());
}
}
@Data
static class OrderItemDto {
private String itemName;
private int orderPrice;
private int count;
public OrderItemDto(OrderItem orderItem) {
itemName = orderItem.getItem().getName();
orderPrice = orderItem.getOrderPrice();
count = orderItem.getCount();
}
}
}
지연 로딩으로 인해 order를 여러 건 조회할 때마다 추가 쿼리가 많이 발생할 수 있다. 예를 들어, Order가 N건일 경우 다음과 같은 쿼리들이 실행될 수 있다.
- order 조회 1번
- member, address 조회 N번
- orderItem 조회 N번
- item 조회 N번
즉, 최악의 경우 1 + N + N + N = 1 + 3N번의 쿼리가 실행될 수 있다. 이 과정을 N+1 문제라고 부른다. 트래픽이 적은 환경에서는 큰 문제가 되지 않을 수 있지만, 대규모 트래픽일 경우 성능 저하가 발생한다.
패치 조인 최적화
컬렉션 관계에서 패치 조인(fetch join) 을 사용하면 추가 쿼리 없이 한 번의 조인으로 연관된 엔티티를 모두 조회할 수 있다.
@RestController
@RequiredArgsConstructor
public class OrderFetchJoinController {
private final OrderRepository orderRepository;
@GetMapping("/api/orders-fetch-join")
public List<OrderDto> getOrdersFetchJoin() {
List<Order> orders = orderRepository.findAllWithItem();
return orders.stream()
.map(OrderDto::new)
.collect(toList());
}
@Data
static class OrderDto {
// 필드 및 생성자 동일
}
}
public List<Order> findAllWithItem() {
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();
}
distinct 키워드를 사용한 이유는 1대다 조인 시 중복 데이터가 발생하기 때문이다.JPA에서 distinct는 SQL에 추가로 적용되며, 동시에 애플리케이션 레벨에서 엔티티 중복도 제거한다.
하지만 컬렉션 패치 조인을 사용하면 페이징이 불가능하다는 단점이 있다. 하이버네이트는 경고 로그를 남기고 모든 데이터를 메모리에 올려놓은 뒤 페이징을 진행한다. 또한 컬렉션 패치 조인은 한 엔티티 당 1개만 사용하는 것이 권장된다. 둘 이상의 컬렉션에 대해 패치 조인을 사용할 경우, 조회 결과가 비정상적으로 합산되는 문제가 발생할 수 있다.
패치 조인 시 페이징하기 위해 BatchSize 적용
컬렉션 패치 조인을 사용하면 페이징이 어렵다. 그래서 페이징과 컬렉션 엔티티를 함께 조회해야 하는 상황에서는 다음과 같은 방식을 사용한다.
- ToOne 관계(예: ManyToOne, OneToOne)는 패치 조인으로 조회한다. (페이징 쿼리에 영향을 주지 않음)
- 컬렉션 관계(OneToMany)는 지연 로딩으로 조회한다.
- hibernate.default_batch_fetch_size 혹은 @BatchSize로 지연 로딩 시 일괄 조회(batch fetch)를 최적화한다.
public List<Order> findAllWithMemberDelivery(int offset, int limit) {
return em.createQuery(
"select o from Order o" +
" join fetch o.member m" +
" join fetch o.delivery d", Order.class)
.setFirstResult(offset)
.setMaxResults(limit)
.getResultList();
}
@RestController
@RequiredArgsConstructor
public class OrderBatchSizeController {
private final OrderRepository orderRepository;
@GetMapping("/api/orders-batch-size")
public List<OrderDto> getOrdersBatchSize(
@RequestParam(value = "offset", defaultValue = "0") int offset,
@RequestParam(value = "limit", defaultValue = "100") int limit) {
List<Order> orders = orderRepository.findAllWithMemberDelivery(offset, limit);
return orders.stream()
.map(OrderDto::new)
.collect(toList());
}
@Data
static class OrderDto {
// 필드 및 생성자 동일
}
}
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 1000
- BatchSize: 지정된 크기만큼 컬렉션이나 프록시 객체를 IN 쿼리로 한 번에 로딩한다.
- 장점: 쿼리 횟수를 줄이면서도, 페치 조인에 묶이지 않고 페이징을 자유롭게 적용할 수 있다.
- 단점: IN 절 파라미터 개수가 설정된 크기만큼 늘어나므로, 데이터베이스 부담도 함께 고려해야 한다.
추가: 하이버네이트 6.2의 array_contains 문법
스프링 부트 3.1 이상에서 하이버네이트 버전이 6.2로 올라가면서, 지연 로딩에 따른 IN 쿼리 대신 array_contains 문법이 사용된다. 이는 SQL 구문 자체를 재활용해 SQL 파싱 캐시를 효율적으로 활용하려는 목적이다. 기존의 where in (?, ?...) 형태는 파라미터 개수에 따라 SQL이 달라져 여러 개의 캐시 엔트리가 생기는 반면, array_contains는 배열 하나만 바인딩하므로 SQL 구문이 동일하게 유지된다.
-- 기존 IN 절
where item.item_id in(?, ?, ?, ?)
-- 하이버네이트 6.2에서의 array_contains
where array_contains(?, item.item_id)
실행 결과는 동일하지만, 캐시 관점에서 성능 이점이 있다. 특정 DB 벤더나 드라이버 버전에 따라 동작이 달라질 수 있으므로, 실제 운영 환경에서 호환성을 미리 확인하는 것이 좋다.
결론
- 컬렉션 페치 조인은 쿼리 횟수를 획기적으로 줄여주지만, 페이징이 불가능하다는 단점이 있다.
- BatchSize 설정을 통해 컬렉션을 지연 로딩하더라도, 일괄 로딩 방식으로 N+1 문제를 크게 줄일 수 있다.
- ToOne(ManyToOne, OneToOne) 관계는 페치 조인을 적용해도 페이징이 깨지지 않으므로, 적극 활용할 만하다.
- 스프링 부트 3.1 이상, 하이버네이트 6.2 환경에서는 array_contains 문법을 통해 IN 절 변형으로 인한 SQL 캐싱 문제를 완화할 수 있다.
결국 “컬렉션에는 무조건 페치 조인을 쓰지 말라”가 아니라, 조회 상황에 따라 적절히 혼합하는 것이 최선이다. 데이터 규모가 작다면 페치 조인도 고려해볼 수 있지만, 페이징이 필요한 서비스라면 ToOne만 페치 조인하고 컬렉션은 배치 사이즈로 처리하는 방식을 권장한다.
DB 부하, 트래픽 특성, 서비스 요구사항 등을 종합적으로 고려해 최적의 방안을 선택하는 것이 JPA 실무의 핵심이라 할 수 있다.
참고 자료