| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | ||||||
| 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| 9 | 10 | 11 | 12 | 13 | 14 | 15 |
| 16 | 17 | 18 | 19 | 20 | 21 | 22 |
| 23 | 24 | 25 | 26 | 27 | 28 | 29 |
| 30 |
- 이스케이프 문자
- R
- datastructure
- 윤성우의 열혈 자료구조
- buffer
- 이것이 자바다
- stream
- 알기쉬운 알고리즘
- 윤성우 열혈자료구조
- C 언어 코딩 도장
- Selection Sorting
- Serialization
- JSON
- 혼자 공부하는 C언어
- s
- insertion sort
- coding test
- 메모리구조
- C programming
- list 컬렉션
- Graph
- Algorithm
- Stack
- Today
- Total
Engineering Note
[JPA] 지연로딩과 N+1문제해결, 실행 쿼리 비교 본문
본 포스팅은 인프런 김영한님의 강의를 보고 정리한 글입니다.
강의의 목표는 지연로딩으로 발생하는 문제(N+1문제)의 원인을 알아보고 해결하는 것입니다.
강의보고 흐름 그대로 정리한 글
Order Entity
package jpabook.jpashop.domain;
import static jakarta.persistence.FetchType.LAZY;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import lombok.Getter;
import lombok.Setter;
@Entity
@Table(name = "orders")
@Getter
@Setter
public class Order {
@Id
@GeneratedValue
@Column(name = "order_id")
private Long id;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "member_id")
private Member member;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
@OneToOne(fetch = LAZY, cascade = CascadeType.ALL)
@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);
}
//==생성 메서드==//
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() {
int totalPrice = 0;
for (OrderItem orderItem : orderItems) {
totalPrice += orderItem.getTotalPrice();
}
return totalPrice;
}
}
Order는 Member와 다대일 관계입니다. Delivery(배송정보)와는 일대일 관계입니다. API 스펙에서 Entity를 직접 노출하는 건 좋지 않으므로, 반환하기 위한 DTO 클래스를 만들었습니다.
@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 초기화
}
}
아래 코드는 Order를 조회하고 DTO클래스로 변환해서 반환하는 API입니다.
@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;
}
지연로딩은 연관관계에 있는 Entity를 필요할 때 조회하는 설정으로 처음 Order를 조회할 때는 함께 조회하지 않습니다. order.getMember.getName(); 같은 코드로 그래프 탐색을 할 때 조회를 시작합니다. delivery도 마찬가지입니다. 하지만 이렇게 되면 문제가 있습니다.
Order가 N개 라면 N번 만큼 Meber 조회를 위한 쿼리가 실행되고, 다시 N번 만큼 Delivery를 조회하는 쿼리가 실행됩니다.
이러한 문제를 N+1문제라고 합니다.
XToOne 관계는 기본적으로 fetch Type이 EAGER이고, EAGER는 실행되는 쿼리를 예측할 수 없어 사용하지 않습니다. 그래서 LAZY로 설정을 지정해주어야합니다. 하지만 LAZY로딩으로 설정하면 초기에는 프록시객체로 세팅되고, 실제 필요할 때 쿼리가 개별적으로 실행되면서 예상한 것보다 쿼리가 많이 실행되는 문제가 있습니다.
해결 방법과 실행쿼리 비교
쿼리를 비교하기 전에 DB에서 orders테이블을 조회해보면 2건의 데이터가 있다.

아래는 '/api/v2/simple-orders' 요청했을 때 실행된 쿼리다.
2025-11-09T18:38:26.984+09:00 DEBUG 76411 --- [nio-8080-exec-6] org.hibernate.SQL :
select
o1_0.order_id,
o1_0.delivery_id,
o1_0.member_id,
o1_0.order_date,
o1_0.status
from
orders o1_0
join
member m1_0
on m1_0.member_id=o1_0.member_id
fetch
first ? rows only
2025-11-09T18:38:26.990+09:00 DEBUG 76411 --- [nio-8080-exec-6] org.hibernate.SQL :
select
m1_0.member_id,
m1_0.city,
m1_0.street,
m1_0.zipcode,
m1_0.name
from
member m1_0
where
m1_0.member_id=?
2025-11-09T18:38:26.992+09:00 DEBUG 76411 --- [nio-8080-exec-6] org.hibernate.SQL :
select
d1_0.delivery_id,
d1_0.city,
d1_0.street,
d1_0.zipcode,
d1_0.status
from
delivery d1_0
where
d1_0.delivery_id=?
2025-11-09T18:38:26.992+09:00 DEBUG 76411 --- [nio-8080-exec-6] org.hibernate.SQL :
select
m1_0.member_id,
m1_0.city,
m1_0.street,
m1_0.zipcode,
m1_0.name
from
member m1_0
where
m1_0.member_id=?
2025-11-09T18:38:26.993+09:00 DEBUG 76411 --- [nio-8080-exec-6] org.hibernate.SQL :
select
d1_0.delivery_id,
d1_0.city,
d1_0.street,
d1_0.zipcode,
d1_0.status
from
delivery d1_0
where
d1_0.delivery_id=?
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
여기서 실행된 쿼리가 아래 쿼리이다.
select
o1_0.order_id,
o1_0.delivery_id,
o1_0.member_id,
o1_0.order_date,
o1_0.status
from
orders o1_0
join
member m1_0
on m1_0.member_id=o1_0.member_id
fetch
first ? rows only
그리고 조회한 Order 리스트에서 SimpleOrderDto로 변환하는 과정에서
List<SimpleOrderDto> result = orders.stream()
.map(o -> new SimpleOrderDto(o))
.collect(Collectors.toList());
public SimpleOrderDto(Order order) {
orderId = order.getId();
name = order.getMember().getName(); //Lazy 초기화
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress(); //Lazy 초기화
}
Order 리스트의 Order 요소 하나마다 Member, Deliver가 하나씩 호출되면서 전체적으로 4개의 쿼리가 추가로 호출되었다.
이러한 문제를 해결하기 위해서는 Order를 조회할 때 지연로딩으로 설정된 Entity도 한 번에 조회해서 해결할 수 있습니다. 그렇다고 EAGER로 설정하면 안되고, fetch join으로 Entity를 한 번에 조회하는 방법을 사용하면 됩니다. 아래 코드는 문제를 해결한 코드입니다.
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();
}
@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;
}
fetch join으로 Order Entity와 연관된 Member, Deliver를 한 번에 조회한 결과 당연히 쿼리도 한 번만 수행되었다.
2025-11-09T19:47:01.731+09:00 DEBUG 76411 --- [nio-8080-exec-1] org.hibernate.SQL :
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
이번 강의에서는 컬렉션이 아니면서 Lazy로딩으로 설정된 ManyToOne과 OneToOne 연관관계에 있는 Entity를 조회할 때 실제 어떤 문제가 있는지 알아보고 해결하는 과정을 살펴보았다. 이번 강의를 통해 ToOne 관계에 대해 Lazy 로딩이 필수라는 걸 알았고, 이렇게 설정된 Lazy 로딩의 문제를 해결하기 위해 fetch join을 어떻게 사용해야 하는지도 알 수 있었다.
배운 내용
ManyToOne, OneToOne 연관관계는 기본값이 EAGER라서 LAZY로 설정을 해주어야 하고, 양방향 연관관계에 있다면 JsonIgnore를 통해 순환 참조를 막아주어야 한다. 대표적인 예로 Order와 Member는 다대일 관계이다. 회원은 여러 주문을 할 수 있기 때문이다. 그래서 Order와 Member는 ManyToOne 관계로 설정할 수 있다.
그런데 이 Order를 조회하고 Membe가 필요 없다면 상관이 없지만 Member 데이터도 필요하다면, order.getMember().getName();으로 접근할 수 있고, 이때 Lazy 초기화가 적용된다. 그리고 초기화를 위해 추가 쿼리가 수행되면서 JPA에서 유명한 N+1문제가 생긴다.
다시 정리하면 N+1문제는 지연로딩으로 설정된 엔티티를 조회할 때 추가로 쿼리가 수행되는 문제라고 할 수 있다.
참고자료 : JPA 활용2 11강(간단한 주문조회V2), 12강 간단한 주문 조회 V3
'Server > JPA ORM' 카테고리의 다른 글
| [JPA] 컬렉션 지연로딩(Lazy Loading)으로 인한 N+1 문제 (0) | 2025.11.08 |
|---|---|
| [JPA] 연관관계 주인 (1) | 2025.10.10 |
| [JPA] 기본 키 매핑 방법 (0) | 2025.10.08 |
| [JPA] JPA 동작 원리 중요 개념 정리(영속성 컨텍스트 생명주기와 더티체킹) (0) | 2025.10.06 |
| [JPA] 영속성 컨텍스트 생명주기와 @Transacional의 동작 원리: 영속성 컨텍스트 유지 (0) | 2025.10.05 |