지연 로딩과 조회 성능 최적화
- xToOne(OnetoOne, ManyToOne) 관계일 때 최적화 하기
엔티티를 그대로 반환
Order Entity
@Entity
@Getter @Setter
@Table(name = "orders")
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;
private LocalDateTime orderDate;
@Enumerated(EnumType.STRING)
private OrderStatus status; // 주문 상태 {ORDER, CANCEL}
//==연관관계 메서드==
public void setMember(Member member) {
this.member = member;
member.getOrders().add(this);
}
public void addOrderItem(OrderItem orderItem) {
orderItems.add(orderItem);
orderItem.setOrder(this);
}
public void setDelivery(Delivery delivery) {
this.delivery = delivery;
delivery.setOrder(this);
}
protected Order() {}
//==생성 메서드==//
public static Order createOrder(Member member, Delivery delivery, OrderItem... orderItems) {
Order order = new Order();
order.setMember(member);
order.setDelivery(delivery);
for (OrderItem orderItem : orderItems) {
order.addOrderItem(orderItem);
}
order.setStatus(OrderStatus.ORDER);
order.setOrderDate(LocalDateTime.now());
return order;
}
//==비즈니스 로직==//
public void cancel() {
if (delivery.getStatus() == DeliveryStatus.COMP) {
throw new IllegalStateException("이미 배송완료된 상품은 취소가 불가합니다.");
}
this.setStatus(OrderStatus.CANCEL);
for (OrderItem orderItem : orderItems) {
orderItem.cancel();
}
}
//==조회 로직==//
public int getTotalPrice() {
return orderItems.stream()
.mapToInt(OrderItem::getTotalPrice)
.sum();
}
}
Member Entity
import java.util.ArrayList;
import java.util.List;
@Entity
@Getter
@Setter
public class Member {
@Id @GeneratedValue
@Column(name = "member_id")
private Long id;
@NotEmpty
private String name;
@Embedded
private Address address;
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
}
- Order와 Member 엔티티를 보면, Order에 Member가 있고, Member에 Order가 있다. 즉, Order를 그대로 JSON으로 직렬화 하면 Order <-> Member(서로를 계속 호출) 양방향 연관관계의 무한 루프에 빠질 수 있다.
- 그래서, 양방향 연관관계일 때는 한 쪽에 JsonIgnore를 걸어주어서 양방향 호출을 끊어주어야 한다.
- DTO로 변환해서 반환하면 원칙적으로 JsonIgnore는 필요 없다.
- 엔티티를 JSON으로 직렬화하지 않기 때문
- DTO에는 엔티티 대신 필요한 값만 담기 때문
- DTO에는 양방향 관계를 그대로 유지하지 않기 때문
하지만 -> - 엔티티를 DTO로 변환해서 반환하더라도 웬만하면 JsonIgnore 걸어주는 게 좋다. (언제 실수가 터질지 모르기 때문) 또한 DTO 안에서도 엔티티를 그대로 노출하거나 양방향 구조를 그대로 두면 무한루프가 빠질 수 있기 때문. (DTO 안에 엔티티를 두는 것도 엔티티를 노출하는 것이기에, 이렇게 설계하면 안된다.)
@Entity
@Getter
@Setter
public class Member {
@Id @GeneratedValue
@Column(name = "member_id")
private Long id;
@NotEmpty
private String name;
@Embedded
private Address address;
@JsonIgnore
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
}
- Order <-> Member 양방향 연관관계로 인해, Member에서 Order에 JsonIgnore를 걸어줬다.
- 근데 이렇게 해주면 Type Definition Error가 난다. 왜냐하면, Order에서 Member는 지연로딩으로 설정되어 있어서 실제 Member를 가져오는 것이 아닌 프록시 객체로 넣어두기 때문이다.
- 그래서 Jackson 라이브러리는 이 프록시 객체를 어떻게 JSON으로 바꿔야 할지 몰라서 에러를 낸다.
- 프록시 객체로 넣어두고 나서 실제 Member를 조회 및 수정하는 작업을 요청하면 그때서야 실제 Member를 가져와서 할당한다.
- 그래서 아래처럼 HibernateJakarta5Module (Spring 부트 3.0 이상일 때)을 스프링 빈으로 등록해서 이 프록시 객체를 무시하거나 강제 초기화하도록 지원한다.
- 또한 프록시는 Member의 ‘가짜 객체’인데, 실제로 getName()처럼 필드를 접근하는 시점에 DB 쿼리를 날려서 데이터 채운다.
- 단, 그 시점에 영속성 컨텍스트(세션)가 살아 있어야 한다. 이미 트랜잭션/세션이 끝났다면 → LazyInitializationException가 터진다.
- Jackson이 직렬화 과정에서 member.getName() 등을 호출하면서 이 과정이 트리거되기도 한다.
@SpringBootApplication
public class JpashopApplication {
public static void main(String[] args) {
SpringApplication.run(JpashopApplication.class, args);
}
@Bean
Hibernate5JakartaModule hibernate5Module() {
return new Hibernate5JakartaModule();
}
}
@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 엔티티의 속성을 그대로 모두 반환하기 때문에 성능 상의 문제도 있다.
- 즉, 필요한 속성만을 뽑아서 전달해주는 DTO를 변환해서 반환해주어야 한다.
영속성 컨텍스트 범위
| 구분 | 트랜잭션 범위 전략 (OSIV: false) | OSIV 전략 (OSIV: true, 스프링 부트 기본) |
| 생성 시점 | 트랜잭션 시작 시 | 클라이언트 요청 들어올 때 |
| 소멸 시점 | 트랜잭션 커밋/롤백 시 | 클라이언트 응답 완료 시 |
| 특징 | 트랜잭션 끝나면 지연 로딩 불가 | 컨트롤러에서도 지연 로딩 가능 |
| 장점 | DB 커넥션을 짧게 씀 (리소스 효율 좋음) | 지연 로딩 코드를 자유롭게 작성 가능 |
| 단점 | 지연 로딩 코드를 서비스 계층에 몰아넣어야 함 | DB 커넥션을 너무 오래 붙들고 있음 |
영속 상태와 준영속 상태
| 상태 | 객체 타입 | 동작 | 결과 |
| 영속 상태 (Tx 안) | 진짜 객체 (이미 로딩됨) | getName() 호출 | 쿼리 안 나감 (메모리에서 바로 조회) |
| 영속 상태 (Tx 안) | 프록시 객체 (지연 로딩) | getName() 호출 | 쿼리 나감 (DB 연결해서 데이터 가져옴) -> 여기서 N+1 발생 |
| 준영속 상태 (Tx 밖) | 프록시 객체 (지연 로딩) | getName() 호출 | 쿼리 불가 (에러 발생) (LazyInitializationException) |
엔티티를 DTO로 변환해서 반환
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
private final OrderRepository orderRepository;
@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) {
this.orderId = order.getId();
this.name = order.getMember().getName(); // LAZY 초기화
this.orderDate = order.getOrderDate();
this.orderStatus = order.getStatus();
this.address = order.getDelivery().getAddress(); // LAZY 초기화
}
}
}
- 이전과 달리, 필요한 속성만을 API 요청 스펙에 맞춰서 DTO를 정의한 후 엔티티를 DTO로 변환해서 반환해준다.
- 하지만 V1과 V2 모두 N+1 문제가 발생한다.
- select o from Order o만 날린다고 하면:
1. orders 전체 조회
2. DTO 변환하면서 order.getMember().getName() 호출
→ Order 개수만큼 Member 지연 로딩 조회 쿼리 N번
3. order.getDelivery().getAddress() 호출
→ Delivery 지연 로딩 조회 쿼리 N번
총 N+1+N 쿼리 나가는 최악의 상황(모든 주문의 회원이 다르고, 배송지도 다른)이 발생한다. - 따라서 fetch join을 이용해서 최적화 하는 방안이 필요하다.
Fetch Join을 이용한 최적화
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
private final OrderRepository orderRepository;
@GetMapping("/api/v3/simple-orders")
public List<SimpleOrderDto> ordersV3() {
List<Order> orders = orderRepository.findAllWithMemberDelivery();
return orders.stream()
.map(SimpleOrderDto::new)
.collect(Collectors.toList());
}
@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();
this.name = order.getMember().getName();
this.orderDate = order.getOrderDate();
this.orderStatus = order.getStatus();
this.address = order.getDelivery().getAddress();
}
}
}
@Repository
@RequiredArgsConstructor
public class OrderRepository {
private final EntityManager em;
public void save(Order order) {
em.persist(order);
}
public Order findOne(Long id) {
return em.find(Order.class, id);
}
...
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, Member, Delivery를 한 번의 SQL로 가져온다. 즉, 연관 엔티티가 이미 영속성 컨텍스트에 로딩되어 있어서 쿼리가 1번만 나간다.
- 이후, order.getMember(), order.getDelivery()를 호출해도 이미 로딩되어 있어서 추가 select 쿼리가 나가지 않는다.
- 그래서 모두 지연 로딩으로 설정해두고, N + 1 문제가 발생할 수 있는 조회를 fetch join을 사용하면 대부분의 성능 최적화 문제는 해결될 수 있다. (xToOne 관계일 때)
- 하지만 1:N 관계에서, fetch join을 사용하면 데이터 뻥튀기가 발생할 수 있고 페이징을 할 수 없다는 한계가 있다.
일반 Join:
select o from Order o join o.member m
- 조인은 하지만, SELECT 절에서 Order 데이터만 가져온다. Member 데이터는 가져오지 않는다.
- 따라서 조회된 Order의 getMember()를 호출하면 여전히 쿼리가 나간다(N+1 발생).
Fetch Join:
select o from Order o join fetch o.member m
- Member 데이터까지 한 번에 퍼올려서 영속성 컨텍스트에 다 채워 넣는다.
JPA에서 바로 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;
this.orderDate = orderDate;
this.orderStatus = orderStatus;
this.address = address;
}
}
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
private final OrderRepository orderRepository;
...
@GetMapping("/api/v4/simple-orders")
public List<OrderSimpleQueryDto> ordersV4() {
return orderRepository.findOrderDtos();
}
}
@Repository
@RequiredArgsConstructor
public class OrderRepository {
private final EntityManager em;
...
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();
}
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();
}
}
- JPA에서 바로 DTO로 조회해서 반환하기에 성능면에서 더 뛰어나다.
- 하지만 엔티티가 아닌 화면에 맞게 설계된 DTO로 조회하기에 변경 감지가 불가능하다. 따라서 재사용성이 낮다.
- 그리고 본래 레포지토리는 엔티티를 조회하는 용도로 사용되는 계층이다.
- 하지만 레포지토리 계층에서 API 스펙에 맞는 조회를 하는, 레포지토리에 화면(API 스펙)에 맞춘 코드가 들어가지는 것이다.
- 그래서 trade-off 관계에 있어서 상황에 따라 맞게 설계해야 한다.
- 그리고 쓴다면 해당 DTO를 조회하는 별도 레포지토리를 따로 만들어서 구현하는 것이 좋다.
별도 분리해서 Repository를 구현
@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 o.member m" +
" join o.delivery d", OrderSimpleQueryDto.class)
.getResultList();
}
}
쿼리 방식 선택 권장 순서
1. 우선 엔티티를 DTO로 변환하는 방법을 선택한다.
2. 필요하면 페치 조인으로 성능을 최적화 한다. 대부분의 성능 이슈가 해결된다. (여기서 95% 해결)
3. 그래도 안되면 DTO로 직접 조회하는 방법을 사용한다.
4. 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 사용한다
컬렉션 조회 최적화
- xToOne 최적화를 알아봤으니, OneToMay 조회를 최적화해보자.
엔티티를 그대로 노출해서 반환
@RestController
@RequiredArgsConstructor
public class OrderApiController {
private final OrderRepository orderRepository;
@GetMapping("/api/v1/orders")
public List<Order> ordersV1() {
List<Order> all = orderRepository.findAllByString(new OrderSearch());
// LAZY 초기화 (프록시 초기화)
for (Order order : all) {
order.getMember().getName();
order.getDelivery().getAddress();
List<OrderItem> orderItems = order.getOrderItems();
orderItems.stream().forEach(o -> o.getItem().getName());
}
return all;
}
}
- 먼저 엔티티를 그대로 노출하는 경우를 알아 보자. 이렇게 하면 당연히 안된다. (프록시 초기화 복습용)
컬렉션을 DTO로 변환해서 반환
@RestController
@RequiredArgsConstructor
public class OrderApiController {
private final OrderRepository orderRepository;
// 이렇게 배열로 반환하는 것도 좋지 않다. (why?)
// List<OrderDto>를 담는 별도 DTO를 만들어서 반환하는 것이 베스트이다. (why?)
@GetMapping("api/v2/orders")
public List<OrderDto> ordersV2() {
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
List<OrderDto> result = orders.stream()
.map(o -> new OrderDto(o))
.collect(Collectors.toList());
return result;
}
@Data
static class OrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address; // 이런 value object는 DTO로 안바꿔도 괜찮다.
private List<OrderItemDto> orderItems;
public OrderDto(Order order) {
this.orderId = order.getId();
this.name = order.getMember().getName();
this.orderDate = order.getOrderDate();
this.orderStatus = order.getStatus();
this.address = order.getDelivery().getAddress();
this.orderItems = order.getOrderItems().stream()
.map(OrderItemDto::new)
.collect(Collectors.toList());
}
}
@Data
static class OrderItemDto {
private String itemName;
private int orderPrice;
private int count;
public OrderItemDto(OrderItem orderItem) {
this.itemName = orderItem.getItem().getName();
this.orderPrice = orderItem.getOrderPrice();
this.count = orderItem.getCount();
}
}
}
- 쿼리가 아주 많이 나간다.
1. Orders 조회 (select * from orders where ...)
2. DTO 변환 과정에서 member 접근 -> N번 (order.getMember().getName()) : Order 수 만큼 Member 조회
3. delivery 접근 -> N번 (order.getDelivery().getAddress()) : N번
4. orderItems 접근 -> N번 (order.getOrderItems()) : Order 수 만큼 OrderItem 조회
5. orderItem().getItem() -> 각 orderItem마다 Item 로딩 : OrderItem만큼 Item 로딩 (Lazy)
즉, 1 + N + N + N + 총 주문 아이템 수
- 따라서 컬렉션 Lazy 초기화를 하면 성능이 매우 낮아진다.
[
{ orderDto... },
{ orderDto... }
]
- 그리고 현재 위처럼 루트가 없는 배열 구조로 반환하는데, 이러면 후에 다른 정보를 넣고 싶을 때 확장이 불가능하다.
- 또한 프론트엔드에서도 공통 처리, 에러 처리, wrapper 구조가 통일되지 않아서 관리가 힘들다.
- 따라서 무조건 응답을 객체로 감싸서 반환하는 것이 좋다.
페치 조인으로 최적화
@RestController
@RequiredArgsConstructor
public class OrderApiController {
private final OrderRepository orderRepository;
...
@GetMapping("/api/v3/orders")
public List<OrderDto> ordersV3() {
List<Order> orders = orderRepository.findAllWithItems();
List<OrderDto> result = orders.stream()
.map(OrderDto::new)
.collect(Collectors.toList());
return result;
}
@Data
static class OrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address; // 이런 value object는 DTO로 안바꿔도 괜찮다.
private List<OrderItemDto> orderItems;
public OrderDto(Order order) {
this.orderId = order.getId();
this.name = order.getMember().getName();
this.orderDate = order.getOrderDate();
this.orderStatus = order.getStatus();
this.address = order.getDelivery().getAddress();
this.orderItems = order.getOrderItems().stream()
.map(OrderItemDto::new)
.collect(Collectors.toList());
}
}
}
@Repository
@RequiredArgsConstructor
public class OrderRepository {
private final EntityManager em;
...
public List<Order> findAllWithItems() {
return em.createQuery(
"select distinct o from Order o" +
" join fetch o.member" +
" join fetch o.delivery d" +
" join fetch o.orderItems oi" +
" join fetch oi.item i", Order.class)
.getResultList();
}
}
JPA distinct
- 1대다 조인이 있으므로 row가 증가한다. 따라서 distinct로 중복을 제거해준다. (Order가 1개지만 OrderItem이 3개라면, row가 3개로 뻥튀기된다.)
- 즉, 1:N 관계를 조인하면 부모 엔티티의 개수 * 자식 개수 만큼 row가 증가한다.
- jpa의 distinct는 2가지 기능을 해준다.
1. SQL 레벨에서 distinct 쿼리를 날려준다. 하지만, 행의 모든 속성 값이 대부분 다 다를거기 때문에 중복 제거가 되지 않는 경우가 많다.
2. JPA 메모리 차원에서 중복을 제거해준다. 엔티티가 같은 PK를 가지면 엔티티 인스턴스를 하나만 유지한다. - 덕분에 페치 조인을 사용하면 쿼리는 한 번만 실행된다.
- 하지만, 1:N 조인으로 인해 중복된 row가 DB에서 그대로 조회되어 네트워크를 통해 애플리케이션으로 전송된다.
- JPA는 영속성 컨텍스트 차원에서 같은 PK를 가진 엔티티를 하나로 합치지만, 데이터 전송량과 처리해야 할 row 수 자체는 줄어들지 않는다.
페이징 사용 불가의 단점
- 하지만 페이징을 사용할 수 없다는 단점이 있다. 페이징을 쓰면 SQL 쿼리에 limit나 offset 등이 붙어야 정상 페이징이 된다.
- 그런데 1:N 페치 조인에서는 이미 SQL 결과에서 row가 뻥튀기 되고 이 때문에 페이징을 SQL에서 정확하게 계산할 수가 없다.
- 그래서 Hibernate는 DB에서 페이징을 하지 않고 모든 결과를 메모리로 가져와서 자바 컬렉션에서 페이징을 하는데, 이렇게 하면 OutOfMemory가 날 수 있는 아주 위험한 사태가 발생할 수 있다.
- 즉, 1:N 컬렉션 페치 조인은 페이징과 절대 함께 쓰지 말 것.
- 물론, 페이징을 사용하지 않을 것이라면 페치 조인 사용하면 된다.
컬렉션 페치 조인은 1개만 사용 가능
- 컬렉션 페치 조인은 1개만 사용 가능하다. 만약 Order가 Items(1:N)도 가지고, Images(1:M)도 가진다고 가정하고, 이걸 둘 다 fetch join 걸어버리면?
- SQL: Order JOIN Items JOIN Images
- 결과: (주문 1개) * (아이템 10개) * (이미지 5개) = 50줄(Row)의 데이터가 생성된다. (Cartesian Product, 카르테시안 곱)
- 즉, 곱하기의 곱하기(N * M)가 되어버린다.
- JPA(하이버네이트) 입장에서 이렇게 뻥튀기된 데이터 속에서, 어떤 줄이 어떤 아이템이고 어떤 이미지인지 매핑하는 기준이 모호해진다.
- 이를 억지로 맞추려다 보면 데이터가 누락되거나 꼬이는 현상이 발생하여 부정확한 데이터가 된다.
페치 조인 vs 조인
| 구분 | join | fetch join |
| 목적 | 조건, 조인 대상 사용 | 연관 엔티티/컬렉션을 함께 로딩 |
| 연관 엔티티 로딩 보장? | X (LAZY면 나중에 또 쿼리 나갈 수 있음) | O (한 번에 로딩) |
| N+1 해결? | 상황에 따라 여전히 발생 가능 | 적절히 쓰면 N+1 크게 줄일 수 있음 |
엔티티를 DTO로 반환 - 페이징과 한계 돌파
hibernate.default_batch_fetch_size 옵션을 이용한 지연 로딩 최적화
- application.yml 에 hibernate.default_batch_fetch_size 옵션을 설정함으로써, 지연 로딩(LAZY)될 프록시나 컬렉션들을 여러 개 모아서 한 번의 IN 쿼리로 가져올 수 있다.
- 원래라면 각 주문마다 order.getOrderItems()가 호출되어서 N + 1 문제가 발생될 것을, 초기화 안된 Order.orderItems 컬렉션을 전부 한 번에 IN 쿼리로 가져온다.
- 개별적으로 하고 싶으면 엔티티에 컬렉션인 속성에 @BatchSize. 컬렉션이 아니라 One인 속성은 엔티티 자체에다가 @BatchSize.
- 보통 전역 설정인 hibernate.default_batch_fetch_size 사용을 선호
- 이 방식은 페치 조인 방식보다 쿼리 호출 수는 약간 증가하지만, DB -> 애플리케이션 데이터 전송량이 감소한다.
- 무엇보다, 이 방식은 컬렉션 페치 조인으로는 불가능했던 페이징을 가능하게 한다.
결론
- toOne 관계는 계속해서 페치 조인해도 상관 없다.(데이터가 뻥튀기 되지 않고, 페이징에 영향을 주지 않으므로)
- 컬렉션은 지연 로딩으로 조회하고, hibernate.default_batch_fetch_size로 최적화 한다. (최대값=1000 / 100 ~ 1000 권장) 값이 무엇이든 간에 결국 전체 데이터를 로딩해야 하므로 메모리 사용량은 같음.
- 결국 순간 부하를 어디까지 견딜 수 있는지로 결정하면 된다.
@Repository
@RequiredArgsConstructor
public class OrderRepository {
private final EntityManager em;
...
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 OrderApiController {
private final OrderRepository orderRepository;
@GetMapping("/api/v3.1/orders")
public List<OrderDto> ordersV3_page(@RequestParam(value = "offset", defaultValue = "0") int offset,
@RequestParam(value = "limit", defaultValue = "100") int limit) {
List<Order> orders = orderRepository.findAllWithMemberDelivery(offset, limit);
List<OrderDto> result = orders.stream()
.map(OrderDto::new)
.collect(Collectors.toList());
return result;
}
@Data
static class OrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address; // 이런 value object는 DTO로 안바꿔도 괜찮다.
private List<OrderItemDto> orderItems;
public OrderDto(Order order) {
this.orderId = order.getId();
this.name = order.getMember().getName();
this.orderDate = order.getOrderDate();
this.orderStatus = order.getStatus();
this.address = order.getDelivery().getAddress();
this.orderItems = order.getOrderItems().stream()
.map(OrderItemDto::new)
.collect(Collectors.toList());
}
}
@Data
static class OrderItemDto {
private String itemName;
private int orderPrice;
private int count;
public OrderItemDto(OrderItem orderItem) {
this.itemName = orderItem.getItem().getName();
this.orderPrice = orderItem.getOrderPrice();
this.count = orderItem.getCount();
}
}
}
JPA에서 컬렉션이 있을 때 DTO 직접 조회
@RestController
@RequiredArgsConstructor
public class OrderApiController {
private final OrderRepository orderRepository;
private final OrderQueryRepository orderQueryRepository;
...
@GetMapping("/api/v4/orders")
public List<OrderQueryDto> ordersV4() {
return orderQueryRepository.findOrderQueryDtos();
}
}
@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {
private final EntityManager em;
public List<OrderQueryDto> findOrderQueryDtos() {
List<OrderQueryDto> result = findOrders();
result.forEach(o -> { // N번 실행
List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());
o.setOrderItems(orderItems);
});
return result;
}
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();
}
// sql에서는 컬렉션을 넣어서 생성자 호출을 못한다
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();
}
}
@Data
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;
}
}
@Data
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;
}
}
- JPQL의 new 생성자 표현식은 컬렉션을 직접 생성자에 넣을 수 없다.
- 그래서 orderItems를 함께 넣는 한 방짜리 DTO 생성이 안 되는 것.
- 또, join으로 row가 늘어나서 주문 기준으로 중복 row가 생성돼버림.
- 쿼리는 Order(루트) + Member + Delivery 1번(루트와 toOne 관계에 있는), 컬렉션 N번 실행
- toOne 관계들을 먼저 조회하고, toMany 관계는 각각 별도로 처리. toMany 관계는 조회하면 row 수가 증가하기 때문
createQuery가 필요한 상황 예시
| 상황 | 이유 |
| fetch join 사용 | findAll로는 제어 불가능 |
| DTO projection | Repository 기본 메서드 불가 |
| 여러 테이블 join | 복잡한 JPQL 필요 |
| 조건이 복잡 | 메서드명으로 표현 불가 |
| 통계/집계 | count, group by 등 |
JPA 컬렉션 DTO 조회 최적화 - 1
@RestController
@RequiredArgsConstructor
public class OrderApiController {
private final OrderRepository orderRepository;
private final OrderQueryRepository orderQueryRepository;
...
@GetMapping("/api/v5/orders")
public List<OrderQueryDto> ordersV5() {
return orderQueryRepository.findAllByDto_optimization();
}
}
@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {
private final EntityManager em;
...
// sql에서는 컬렉션을 넣어서 생성자 호출을 못한다
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();
}
public List<OrderQueryDto> findAllByDto_optimization() {
List<OrderQueryDto> result = findOrders();
Map<Long, List<OrderItemQueryDto>> orderItemMap = findOrderItemMap(toOrderIds(result));
result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));
return result;
}
private Map<Long, List<OrderItemQueryDto>> findOrderItemMap(List<Long> orderIds) {
// id를 한 번에 가져옴
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::getOrderId));
return orderItemMap;
}
private static List<Long> toOrderIds(List<OrderQueryDto> result) {
List<Long> orderIds = result.stream()
.map(OrderQueryDto::getOrderId)
.collect(Collectors.toList());
return orderIds;
}
}
- toMany 관계에 있는 OrderItem에 대해, 쿼리를 N번 날려서 가져오지 않고 한 번의 쿼리로 가져온다.
- 그리고 이를 맵으로 바꾸어서 OrderQueryDto에 매핑해주어서 효율적으로 한다. 즉, 조립을 메모리에서 하는 것.
- 그래서 1 + 1의 쿼리로 최적화 되었다.
컬렉션 DTO 조회 최적화 - 2
@RestController
@RequiredArgsConstructor
public class OrderApiController {
private final OrderRepository orderRepository;
private final OrderQueryRepository orderQueryRepository;
...
@GetMapping("/api/v6/orders")
public List<OrderQueryDto> ordersV6() {
List<OrderFlatDto> flats = orderQueryRepository.findAllByDto_flat();
return flats.stream()
.collect(groupingBy(o -> new OrderQueryDto(o.getOrderId(),
o.getName(), o.getOrderDate(), o.getOrderStatus(), o.getAddress()),
mapping(o -> new OrderItemQueryDto(o.getOrderId(),
o.getItemName(), o.getOrderPrice(), o.getCount()), toList())
)).entrySet().stream()
.map(e -> new OrderQueryDto(e.getKey().getOrderId(),
e.getKey().getName(), e.getKey().getOrderDate(), e.getKey().getOrderStatus(),
e.getKey().getAddress(), e.getValue()))
.collect(toList());
}
}
@Data
public class OrderFlatDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
private String itemName;
private int orderPrice;
private int count;
public OrderFlatDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address, String itemName, int orderPrice, int count) {
this.orderId = orderId;
this.name = name;
this.orderDate = orderDate;
this.orderStatus = orderStatus;
this.address = address;
this.itemName = itemName;
this.orderPrice = orderPrice;
this.count = count;
}
}
@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {
private final EntityManager em;
...
public List<OrderFlatDto> findAllByDto_flat() {
return em.createQuery(
"select new" +
" jpabook.jpashop.repository.order.query.OrderFlatDto(o.id, m.name, o.orderDate, o.status, d.address, i.name, i.price, oi.count)" +
" from Order o" +
" join o.member m" +
" join o.delivery d" +
" join o.orderItems oi" +
" join oi.item i", OrderFlatDto.class)
.getResultList();
}
}
- 쿼리는 한 번만 나가지만, 조인으로 인해 DB에서 애플리케이션에 전달하는 데이터에 중복 데이터가 추가되므로 상황에 따라 V5보다 느릴 수 있다.
- 그리고 애플리케이션에서 추가 작업이 크다.
- 이 경우, Order 기준으로는 페이징이 불가능하다.
| 관계 | 페이징 여부 | 전략 | 이유 |
| xToOne (Member -> Team) | 상관없음 | Fetch Join | 데이터 뻥튀기 없음. 쿼리 1방이 제일 빠름. |
| xToMany (Team -> Members) | 페이징 함 | Join X (Batch Size) | Fetch Join 하면 메모리 터짐. Batch Size가 유일한 답. |
| xToMany (Team -> Members) | 페이징 안 함 | Fetch Join (선택) | 상세 화면(Detail)처럼 데이터 1개만 조회할 땐 써도 됨. |
| 관계 무관 | 조건/정렬만 필요 | 일반 Join | 데이터는 안 필요하고 WHERE, ORDER BY에만 쓸 때. |
'공부 > Spring' 카테고리의 다른 글
| [인프런 김영한] 커넥션 풀과 트랜잭션 (1) | 2026.02.01 |
|---|---|
| [인프런 김영한] OSIV와 성능 최적화 (0) | 2025.11.25 |
| [인프런 김영한] DTO를 이용한 데이터 전달 (0) | 2025.11.19 |
| [인프런 김영한] 준영속 엔티티 그리고 변경 감지와 병합 (0) | 2025.11.18 |
| [인프런 김영한] 실전! 스프링 부트와 JPA 활용1 - 2 (0) | 2025.11.07 |
