
본 글은 김영한님의 JPA 강의 일부분을 정리한 내용입니다.
JPA를 사용하여 연관 관계가 설정된 엔티티를 조회할 때 N+1 문제에 직면하게 된다. 이러한 문제를 해결하면서 조회 성능을 최적화 시키는 방법을 알아보고자 한다.
먼저 아래의 두가지를 가정을 하고 진행을 하자.
1. 모든 연관 관계는 지연 로딩으로 설정하자
2. 엔티티를 직접 반환 시키지 않는다. (조회한 엔티티를 DTO로 변환하거나 DTO로 직접 조회하여 반환하자)
테이블 연관 관계 및 테스트 데이터


XToOne 관계 (ManyToOne, OneToOne)
- Order & Member, Order & Delivery
엔티티를 DTO로 변환
List<Order> orders = orderRepository.findAll());
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 초기화 (N번)
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress(); //LAZY 초기화 (N번)
}
}
위의 방식대로 진행했을 경우 쿼리가 총 1 + N + N번 실행 된다.
order를 조회할 때 1번, 조회된 order의 조회 수(N)만큼 SimpleOrderDto에서 Lazy가 초기화 될 때마다 member와 delivery를 (N번) 조회하게 된다.
페치 조인 최적화(엔티티를 DTO로 변환)
List<Order> orders = orderRepository.findAllWithMemberDelivery();
List<SimpleOrderDto> result = orders.stream()
.map(o -> new SimpleOrderDto(o))
.collect(Collectors.toList());
return result;
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();
}
XToOne 관계에서 위와 같이 페치 조인을 사용하면 N + 1 문제를 해결할 수 있다.
XToOne 관계는 컬렉션이 아니기 때문에 조인을 사용하더라도 Row 수가 증가하지 않는다.
페치 조인 최적화(DTO로 바로 조회)
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 o.member m" +
" join o.delivery d", OrderSimpleQueryDto.class)
.getResultList();
}
엔티티를 조회하지 않고 바로 DTO로 조회하여 페치조인을 사용할 수 있다.
DTO를 직접 조회할 경우 화면에 최적화 되어있기 때문에 (API 스펙에 맞춘 코드가 Repository에 들어감) 엔티티 조회 방법에 비해 재사용성이 떨어질 수 있다.
반면 Select절에서 원하는 데이터를 직접 선택하므로 DB -> 애플리케이션 사이의 네트워크 용량이 최적화된다(생각보다 미비).
엔티티를 조회하여 DTO로 변환하는 방법과 DTO를 직접 조회하는 방법은 각각 장단점이 존재하므로 상황에 따라서 더 나은 방법을 선택하면 된다.
권장 순서
- N+1 문제 발생 시 페치 조인으로 성능을 최적화 한다.
- 그래도 안되면 DTO로 직접 조회하는 방법을 사용한다.
- 그래도 안되면 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template를 사용해서 SQL을 직접 사용한다.
XToMany관계 (OneToMany, ManyToMany)
- Order & OrderItems, Item & OrderItems
엔티티를 DTO로 변환
List<Order> orders = orderRepository.findAll();
List<OrderDto> result = orders.stream()
.map(o -> new OrderDto(o))
.collect(toList());
return result;
@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(); //LAZY 초기화 (N번)
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress(); //LAZY 초기화 (N번)
orderItems = order.getOrderItems().stream() //LAZY 초기화 (N번)
.map(orderItem -> new OrderItemDto(orderItem))
.collect(toList());
}
}
@Data
static class OrderItemDto {
private String itemName;
private int orderPrice;
private int count;
public OrderItemDto(OrderItem orderItem) {
itemName = orderItem.getItem().getName(); //LAZY 초기화 (N*M번)
orderPrice = orderItem.getOrderPrice();
count = orderItem.getCount();
}
}
위의 방식대로 진행했을 경우 지연 로딩으로 인해 너무 많은 SQL문이 실행된다.
order를 조회할 때 1번, 조회된 order의 조회 수(N)만큼 member, address가 N번, orderItem이 N번, orderItem 조회 수(M번) 만큼 Item이 N * M번 조회된다.
엔티티를 DTO로 변환 - 페치 조인 최적화
List<Order> orders = orderRepository.findAllWithItem();
List<OrderDto> result = orders.stream()
.map(o -> new OrderDto(o))
.collect(toList());
return result;
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대다 조인이므로 데이터베이스 row가 증가하게 된다. 그 결과 같은 order 엔티티의 조회 수도 증가하게 된다.

distinct 를 추가함으로써 JPA가 내부에서 중복을 제거하여 반환해준다.
페치 조인으로 SQL문이 1번만 실행되지만 컬렉션을 페치 조인하게 되면 페이징이 불가능해진다.
모든 데이터를 DB에서 읽어오고, 메모리에서 페이징 처리를 해버린다(매우 위험).
추가로 컬렉션 페치 조인은 최대 1개만 가능하다. 2개 이상의 컬렉션을 페치 조인하면 데이터가 부정합하게 조회될 수 있다.
엔티티를 DTO로 변환 - 페이징 문제 해결
엔티티 컬렉션을 조회하면서 페이징 문제를 해결해보자.
- 먼저 XToOne(OneToOne, ManyToOne) 관계를 모두 페치 조인한다. XToOne 관계는 row 수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다.
- 컬렉션은 지연 로딩으로 조회한다.
- 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size , @BatchSize를 적용한다.
- hibernate.default_batch_fetch_size: 글로벌 설정
- @BatchSize: 개별 최적화
- 이 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size 만큼 IN 쿼리로 조회한다.
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
# show_sql: true
format_sql: true
default_batch_fetch_size: 100
List<Order> orders = orderRepository.findAllWithMemberDelivery(offset, limit);
List<OrderDto> result = orders.stream()
.map(o -> new OrderDto(o))
.collect(toList());
return result;
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();
}
@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() //Order의 데이터 수 만큼 In절 수행
.map(orderItem -> new OrderItemDto(orderItem)) //OrderItem의 데이터 수 만큼 In절 수행
.collect(toList());
}
}
지정한 배치 사이즈의 수를 넘어가지 않는다면 수행되는 쿼리 수 1 + 1 + 1로 최적화고 페이징도 가능하게 된다.
JPA에서 DTO 직접 조회
컬렉션을 DTO로 바로 조회하는 경우에는 배치 사이즈를 적용할 수 없다. 배치 사이즈 옵션은 엔티티에 적용 된다.
OrderQueryDto & OrderItemQueryDto
public class OrderQueryDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
private List<OrderItemQueryDto> orderItems;
public OrderQueryDto(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;
}
}
public class OrderItemQueryDto {
@JsonIgnore
private Long orderId;
private String itemName;
private int orderPrice;
private int count;
public OrderItemQueryDto(Long orderId, String itemName, int orderPrice, int count) {
this.orderId = orderId;
this.itemName = itemName;
this.orderPrice = orderPrice;
this.count = count;
}
}
/**
* 1:N 관계(컬렉션)를 제외한 나머지를 한번에 조회
*/
private List<OrderQueryDto> findOrders() {
return em.createQuery(
"select new jpabook.jpashop.repository.order.query.OrderQueryDto(o.id, m.name, o.orderDate, o.status, d.address) from Order o" +
" join o.member m" +
" join o.delivery d", OrderQueryDto.class
).getResultList();
}
/**
* 1:N 관계인 orderItems 조회
*/
private List<OrderItemQueryDto> findOrderItems(Long orderId) {
return em.createQuery(
"select new jpabook.jpashop.repository.order.query.OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count)" +
" from OrderItem oi" +
" join oi.item i" +
" where oi.order.id = :orderId", OrderItemQueryDto.class)
.setParameter("orderId", orderId)
.getResultList();
}
두 개의 쿼리 결과를 얻은 후 아래와 같이 적용한다.
List<OrderQueryDto> result = findOrders();
result.forEach(o ->{
List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());
o.setOrderItems(orderItems);
});
return result;
루트를 조회하는 쿼리 1번, 컬렉션을 조회하는 쿼리 N번을 수행하게 된다.
JPA에서 DTO 직접 조회 - 컬렉션 조회 최적화
List<OrderQueryDto> result = findOrders(); //쿼리1번
Map<Long, List<OrderItemQueryDto>> orderItemMap = findOrderItemMap(toOrderIds(result)); //쿼리1번
result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));
return result;
/**
* 1:N 관계(컬렉션)를 제외한 나머지를 한번에 조회
*/
private List<OrderQueryDto> findOrders() {
return em.createQuery(
"select new jpabook.jpashop.repository.order.query.OrderQueryDto(o.id, m.name, o.orderDate, o.status, d.address) from Order o" +
" join o.member m" +
" join o.delivery d", OrderQueryDto.class
).getResultList();
}
private List<Long> toOrderIds(List<OrderQueryDto> result) {
List<Long> orderIds = result.stream()
.map(o -> o.getOrderId())
.collect(Collectors.toList());
return orderIds;
}
private Map<Long, List<OrderItemQueryDto>> findOrderItemMap(List<Long> orderIds) {
List<OrderItemQueryDto> orderItems = em.createQuery(
"select new jpabook.jpashop.repository.order.query.OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count)" +
" from OrderItem oi" +
" join oi.item i" +
" where oi.order.id in :orderIds", OrderItemQueryDto.class)
.setParameter("orderIds", orderIds)
.getResultList();
Map<Long, List<OrderItemQueryDto>> orderItemMap = orderItems.stream()
.collect(Collectors.groupingBy(orderItemQueryDto -> orderItemQueryDto.getOrderId()));
return orderItemMap;
}
루트를 조회하는 쿼리 1번, 컬렉션을 조회하는 쿼리 1번을 수행하게 된다.
XToOne 관계들을 먼저 조회한 후, 여기서 얻은 식별자 orderId로 XToMany 관계인 OrderItem을 한꺼번에 조회한다.(In)
권장 순서
- 엔티티 조회 방식으로 우선 접근
- 페치 조인으로 쿼리 수를 최적화
- 컬렉션 최적화
- 페이징 필요 O -> hibernate.default_batch_fetch_size , @BatchSize 로 최적화
- 페이징 필요 X -> 페치 조인 사용
- 엔티티 조회 방식으로 해결이 안되면 DTO 조회 방식 사용
- DTO 조회 방식으로 해결이 안되면 NativeSQL or 스프링 JdbcTemplate 사용
마무리
JPA를 사용하면서 조회 성능이 제대로 나오지 않는다면 N + 1 문제인지 확인해보자.
만약 N + 1 문제에 해당한다면 'XToOne', 'XToMany' 연관 관계로 구분하여 위에서 정리한 권장 순서대로 성능 최적화 시도를 해볼 수 있을 것 같다.
참고
실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화 - 인프런 | 강의
스프링 부트와 JPA를 활용해서 API를 개발합니다. 그리고 JPA 극한의 성능 최적화 방법을 학습할 수 있습니다., - 강의 소개 | 인프런...
www.inflearn.com