JPA 성능의 핵심인 N+1 문제를 이해하고 해결하는 다양한 방법을 학습합니다.
N+1 문제: JPA 성능 문제의 가장 흔한 원인
1개의 쿼리로 N개의 데이터를 조회한 후, 연관된 데이터를 조회하기 위해 N개의 추가 쿼리가 발생하는 문제입니다.
// Entity 구조
@Entity
public class Order {
@Id @GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
}
// 주문 목록 조회
List<Order> orders = orderRepository.findAll();
// 쿼리 1: SELECT * FROM orders
// 각 주문의 고객 이름 출력
for (Order order : orders) {
System.out.println(order.getCustomer().getName());
// 쿼리 2: SELECT * FROM customers WHERE id = 1
// 쿼리 3: SELECT * FROM customers WHERE id = 2
// 쿼리 4: SELECT * FROM customers WHERE id = 3
// ... N개의 추가 쿼리!
}
// 결과: 1(주문 조회) + N(고객 조회) = N+1 쿼리 발생!-- 1. 주문 목록 조회 (1개 쿼리)
SELECT o.id, o.customer_id, o.order_date, o.status
FROM orders o;
-- 2. 각 주문의 고객 조회 (N개 쿼리)
SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 1;
SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 2;
SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 3;
SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 4;
SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 5;
-- ... 주문 수만큼 반복!
-- 100개 주문 조회 시: 1 + 100 = 101개 쿼리 실행!1. LAZY 로딩 + 반복문에서 연관 Entity 접근
지연 로딩된 연관 Entity를 반복문에서 접근할 때
2. EAGER 로딩 (즉시 로딩)
JPQL 사용 시 EAGER도 N+1 발생 (JOIN이 아닌 추가 쿼리로 로딩)
3. @OneToMany 컬렉션 접근
부모 Entity 조회 후 자식 컬렉션에 접근할 때
// EAGER 로딩 설정
@Entity
public class Order {
@ManyToOne(fetch = FetchType.EAGER) // 즉시 로딩
@JoinColumn(name = "customer_id")
private Customer customer;
}
// JPQL로 조회 시 N+1 발생!
List<Order> orders = em.createQuery("SELECT o FROM Order o", Order.class)
.getResultList();
// 실행되는 쿼리:
// 1. SELECT * FROM orders
// 2. SELECT * FROM customers WHERE id = 1 (EAGER라서 즉시 로딩)
// 3. SELECT * FROM customers WHERE id = 2
// ... N개 추가 쿼리!
// EAGER는 em.find()에서만 JOIN으로 동작
Order order = em.find(Order.class, 1L);
// SELECT o.*, c.* FROM orders o JOIN customers c ON o.customer_id = c.id WHERE o.id = 1Fetch Join은 JPQL에서 연관된 Entity를 한 번의 쿼리로 함께 조회하는 방법입니다. N+1 문제의 가장 기본적인 해결책입니다.
// Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
// Fetch Join으로 Customer 함께 조회
@Query("SELECT o FROM Order o JOIN FETCH o.customer")
List<Order> findAllWithCustomer();
// 조건 추가
@Query("SELECT o FROM Order o JOIN FETCH o.customer WHERE o.status = :status")
List<Order> findByStatusWithCustomer(@Param("status") OrderStatus status);
}
// 사용
List<Order> orders = orderRepository.findAllWithCustomer();
for (Order order : orders) {
// 추가 쿼리 없음! 이미 로딩됨
System.out.println(order.getCustomer().getName());
}
// 실행되는 쿼리 (단 1개!)
// SELECT o.*, c.*
// FROM orders o
// INNER JOIN customers c ON o.customer_id = c.id// Order의 OrderItems 함께 조회
@Query("SELECT o FROM Order o JOIN FETCH o.orderItems")
List<Order> findAllWithOrderItems();
// 여러 연관관계 함께 조회
@Query("SELECT o FROM Order o " +
"JOIN FETCH o.customer " +
"JOIN FETCH o.orderItems oi " +
"JOIN FETCH oi.product")
List<Order> findAllWithCustomerAndItems();
// 실행되는 쿼리
// SELECT o.*, c.*, oi.*, p.*
// FROM orders o
// JOIN customers c ON o.customer_id = c.id
// JOIN order_items oi ON o.id = oi.order_id
// JOIN products p ON oi.product_id = p.id데이터 중복 문제
컬렉션 Fetch Join 시 1:N 관계로 인해 결과가 뻥튀기됩니다.
// Order 1개에 OrderItem 3개인 경우
// JOIN 결과: Order가 3번 중복됨!
// 해결: DISTINCT 사용
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.orderItems")
List<Order> findAllWithOrderItems();
// Hibernate 6+에서는 자동으로 중복 제거됨컬렉션 Fetch Join + 페이징 금지!
컬렉션 Fetch Join과 페이징을 함께 사용하면 메모리에서 페이징합니다.
// 위험! 모든 데이터를 메모리에 로드 후 페이징
@Query("SELECT o FROM Order o JOIN FETCH o.orderItems")
Page<Order> findAllWithOrderItems(Pageable pageable); // 경고 발생!
// 해결책: @BatchSize 또는 별도 쿼리로 분리1. 둘 이상의 컬렉션 Fetch Join 불가
카테시안 곱으로 데이터 폭발. MultipleBagFetchException 발생
2. 컬렉션 Fetch Join + 페이징 불가
메모리에서 페이징하여 OutOfMemoryError 위험
3. 별칭(alias) 사용 제한
Fetch Join 대상에 별칭을 주고 WHERE 조건 사용 비권장
Fetch Join 사용 가이드
@EntityGraph는 어노테이션 기반으로 Fetch 전략을 지정하는 방법입니다. JPQL 없이도 연관 Entity를 함께 조회할 수 있습니다.
public interface OrderRepository extends JpaRepository<Order, Long> {
// attributePaths로 함께 조회할 연관관계 지정
@EntityGraph(attributePaths = {"customer"})
List<Order> findAll();
// 여러 연관관계 지정
@EntityGraph(attributePaths = {"customer", "delivery"})
List<Order> findByStatus(OrderStatus status);
// 중첩 연관관계
@EntityGraph(attributePaths = {"customer", "orderItems", "orderItems.product"})
Optional<Order> findById(Long id);
// 메서드 이름 쿼리와 함께 사용
@EntityGraph(attributePaths = {"customer"})
List<Order> findByCustomerId(Long customerId);
}
// 사용
List<Order> orders = orderRepository.findAll();
// LEFT OUTER JOIN으로 customer 함께 조회// Entity에 Named EntityGraph 정의
@Entity
@NamedEntityGraph(
name = "Order.withCustomer",
attributeNodes = @NamedAttributeNode("customer")
)
@NamedEntityGraph(
name = "Order.withAll",
attributeNodes = {
@NamedAttributeNode("customer"),
@NamedAttributeNode("delivery"),
@NamedAttributeNode(value = "orderItems", subgraph = "orderItems")
},
subgraphs = @NamedSubgraph(
name = "orderItems",
attributeNodes = @NamedAttributeNode("product")
)
)
public class Order {
@Id @GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private Customer customer;
@OneToOne(fetch = FetchType.LAZY)
private Delivery delivery;
@OneToMany(mappedBy = "order")
private List<OrderItem> orderItems = new ArrayList<>();
}
// Repository에서 사용
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(value = "Order.withCustomer")
List<Order> findByStatus(OrderStatus status);
@EntityGraph(value = "Order.withAll")
Optional<Order> findDetailById(Long id);
}지정한 속성은 EAGER, 나머지는 LAZY
@EntityGraph(type = EntityGraphType.FETCH)지정한 속성은 EAGER, 나머지는 Entity 설정 따름
@EntityGraph(type = EntityGraphType.LOAD)| 구분 | Fetch Join | @EntityGraph |
|---|---|---|
| 방식 | JPQL에 직접 작성 | 어노테이션 |
| JOIN 타입 | INNER JOIN | LEFT OUTER JOIN |
| 유연성 | WHERE 조건 자유롭게 | 메서드 이름 쿼리와 조합 |
| 재사용 | 쿼리마다 작성 | Named로 재사용 |
선택 가이드
@BatchSize는 지연 로딩 시 IN 쿼리로 한 번에 여러 Entity를 조회합니다. N+1을 1+1로 줄여주는 효과적인 방법입니다.
@Entity
public class Order {
@Id @GeneratedValue
private Long id;
// @ManyToOne에 적용
@ManyToOne(fetch = FetchType.LAZY)
@BatchSize(size = 100) // 최대 100개씩 IN 쿼리
@JoinColumn(name = "customer_id")
private Customer customer;
// @OneToMany에 적용
@OneToMany(mappedBy = "order")
@BatchSize(size = 100)
private List<OrderItem> orderItems = new ArrayList<>();
}
// 또는 연관 Entity 클래스에 적용
@Entity
@BatchSize(size = 100) // 이 Entity를 조회할 때 배치 적용
public class Customer {
@Id @GeneratedValue
private Long id;
private String name;
}// @BatchSize 없이 (N+1 발생)
List<Order> orders = orderRepository.findAll(); // 100개 주문
for (Order order : orders) {
order.getCustomer().getName(); // 100번의 추가 쿼리!
}
// 총 101개 쿼리
// @BatchSize(size = 100) 적용 시
List<Order> orders = orderRepository.findAll(); // 100개 주문
for (Order order : orders) {
order.getCustomer().getName(); // IN 쿼리 1번!
}
// 총 2개 쿼리
// 실행되는 쿼리
-- 1. 주문 조회
SELECT * FROM orders;
-- 2. 고객 배치 조회 (IN 쿼리)
SELECT * FROM customers
WHERE id IN (1, 2, 3, 4, 5, ... 100);# application.yml
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100 # 전역 배치 사이즈
# 모든 지연 로딩에 자동 적용
# 개별 @BatchSize로 오버라이드 가능컬렉션 Fetch Join은 페이징이 불가능하지만, @BatchSize는 페이징과 함께 사용 가능합니다.
// 페이징 + @BatchSize 조합 (권장)
@Entity
public class Order {
@OneToMany(mappedBy = "order")
@BatchSize(size = 100) // 페이징과 함께 사용 가능!
private List<OrderItem> orderItems = new ArrayList<>();
}
// Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = {"customer"}) // ToOne은 Fetch Join
Page<Order> findByStatus(OrderStatus status, Pageable pageable);
}
// 사용
Page<Order> orders = orderRepository.findByStatus(
OrderStatus.PAID,
PageRequest.of(0, 20)
);
// 실행되는 쿼리
-- 1. 주문 페이징 조회 (customer JOIN)
SELECT o.*, c.* FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.status = 'PAID'
LIMIT 20;
-- 2. OrderItems 배치 조회 (IN 쿼리)
SELECT * FROM order_items
WHERE order_id IN (1, 2, 3, ... 20);N+1 해결 전략 정리
부모 Entity의 영속 상태 변화를 자식 Entity에 전파합니다. 부모를 저장할 때 자식도 함께 저장하는 등의 동작이 가능합니다.
| 타입 | 설명 | 사용 시점 |
|---|---|---|
| PERSIST | 저장 시 함께 저장 | 부모 저장 시 자식도 저장 |
| REMOVE | 삭제 시 함께 삭제 | 부모 삭제 시 자식도 삭제 |
| MERGE | 병합 시 함께 병합 | 준영속 → 영속 전환 시 |
| REFRESH | 새로고침 시 함께 새로고침 | DB에서 다시 조회 |
| DETACH | 분리 시 함께 분리 | 영속성 컨텍스트에서 분리 |
| ALL | 모든 상태 전이 | 생명주기가 완전히 같을 때 |
@Entity
@Table(name = "orders")
public class Order {
@Id @GeneratedValue
private Long id;
// Order 저장/삭제 시 OrderItem도 함께
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
// Order 저장/삭제 시 Delivery도 함께
@OneToOne(cascade = CascadeType.ALL)
@JoinColumn(name = "delivery_id")
private Delivery delivery;
public void addOrderItem(OrderItem item) {
orderItems.add(item);
item.setOrder(this);
}
}
// 사용: Order만 저장하면 OrderItem, Delivery도 함께 저장
Order order = new Order();
order.setDelivery(new Delivery(address));
order.addOrderItem(OrderItem.create(product1, 2));
order.addOrderItem(OrderItem.create(product2, 1));
orderRepository.save(order); // 3개 Entity 모두 INSERT부모 Entity와의 관계가 끊어진 자식 Entity를 자동으로 삭제합니다.
@Entity
public class Order {
@OneToMany(mappedBy = "order",
cascade = CascadeType.ALL,
orphanRemoval = true) // 고아 객체 자동 삭제
private List<OrderItem> orderItems = new ArrayList<>();
public void removeOrderItem(OrderItem item) {
orderItems.remove(item);
item.setOrder(null);
// orphanRemoval = true이면 자동 DELETE
}
}
// 사용
Order order = orderRepository.findById(orderId).orElseThrow();
order.removeOrderItem(order.getOrderItems().get(0));
// 컬렉션에서 제거만 해도 DELETE 쿼리 실행!
// 컬렉션 전체 비우기
order.getOrderItems().clear();
// 모든 OrderItem DELETECascade 사용 조건
이커머스에서의 Cascade 적용
// application.yml - 전역 설정
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
// Order Entity
@Entity
@Table(name = "orders")
public class Order extends BaseEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY) // 항상 LAZY
@JoinColumn(name = "customer_id")
private Customer customer;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> orderItems = new ArrayList<>();
@OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "delivery_id")
private Delivery delivery;
@Enumerated(EnumType.STRING)
private OrderStatus status;
}
// OrderRepository - 상황별 조회 메서드
public interface OrderRepository extends JpaRepository<Order, Long> {
// 목록 조회: Customer만 Fetch Join
@EntityGraph(attributePaths = {"customer"})
Page<Order> findByStatus(OrderStatus status, Pageable pageable);
// 상세 조회: 모든 연관관계 Fetch Join
@Query("SELECT o FROM Order o " +
"JOIN FETCH o.customer " +
"JOIN FETCH o.delivery " +
"WHERE o.id = :id")
Optional<Order> findDetailById(@Param("id") Long id);
// OrderItems는 @BatchSize로 자동 처리
}@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class OrderService {
private final OrderRepository orderRepository;
// 주문 목록 조회 (페이징)
public Page<OrderListDto> getOrders(OrderStatus status, Pageable pageable) {
Page<Order> orders = orderRepository.findByStatus(status, pageable);
// DTO 변환 (OrderItems는 @BatchSize로 IN 쿼리)
return orders.map(order -> new OrderListDto(
order.getId(),
order.getCustomer().getName(),
order.getStatus(),
order.getOrderItems().size(), // @BatchSize 적용
order.getTotalPrice()
));
}
// 주문 상세 조회
public OrderDetailDto getOrderDetail(Long orderId) {
Order order = orderRepository.findDetailById(orderId)
.orElseThrow(() -> new EntityNotFoundException("주문을 찾을 수 없습니다"));
return new OrderDetailDto(order);
}
// 주문 생성
@Transactional
public Long createOrder(Long customerId, List<OrderItemRequest> items, Address address) {
Customer customer = customerRepository.findById(customerId).orElseThrow();
Order order = Order.create(customer, new Delivery(address));
for (OrderItemRequest item : items) {
Product product = productRepository.findById(item.getProductId()).orElseThrow();
order.addOrderItem(OrderItem.create(product, item.getQuantity()));
}
orderRepository.save(order); // Cascade로 OrderItem, Delivery 함께 저장
return order.getId();
}
}모든 연관관계는 LAZY 로딩
EAGER는 N+1 문제 해결 안됨
@ManyToOne, @OneToOne: Fetch Join 또는 @EntityGraph
ToOne 관계는 자유롭게 Fetch Join
@OneToMany + 페이징: @BatchSize
전역 설정으로 default_batch_fetch_size 권장
Cascade는 단일 소유자 + 동일 생명주기
Order → OrderItem (O), OrderItem → Product (X)
다음 세션에서는 Spring Data JPA의 편의 기능을 학습합니다.
JpaRepository 인터페이스
기본 CRUD 메서드
Query Methods
메서드 이름으로 쿼리 생성
@Query와 JPQL
직접 쿼리 작성
Pageable과 Sort
페이징과 정렬
OSIV란?
영속성 컨텍스트를 뷰 렌더링까지 유지하는 전략입니다. Spring Boot에서는 기본값이 true이지만, 실시간 트래픽이 많은 서비스에서는 OFF를 권장합니다.
# application.yml
spring:
jpa:
open-in-view: true # 기본값 (개발 편의)
# open-in-view: false # 실무 권장// Command Service (쓰기)
@Service
@RequiredArgsConstructor
@Transactional
public class OrderService {
public Long order(Long memberId, Long productId, int count) {
// 비즈니스 로직
}
}
// Query Service (읽기) - OSIV OFF에서 필수
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class OrderQueryService {
public OrderDto findOrder(Long orderId) {
// 트랜잭션 안에서 DTO 변환 완료
Order order = orderRepository.findByIdWithMemberDelivery(orderId)
.orElseThrow();
return OrderDto.from(order);
}
}
// Controller
@RestController
@RequiredArgsConstructor
public class OrderController {
private final OrderQueryService orderQueryService;
@GetMapping("/api/orders/{id}")
public OrderDto getOrder(@PathVariable Long id) {
return orderQueryService.findOrder(id); // 이미 DTO
}
}@Service
@Transactional(readOnly = true) // 클래스 레벨 기본값
public class MemberQueryService {
public List<MemberDto> findMembers() {
// 읽기 전용 - 스냅샷 비교 생략, 성능 향상
}
@Transactional // 쓰기 작업은 오버라이드
public void updateMember(Long id, String name) {
// 쓰기 작업
}
}readOnly = true 효과
public interface MemberRepository extends JpaRepository<Member, Long> {
// 벌크 UPDATE
@Modifying(clearAutomatically = true) // 영속성 컨텍스트 자동 초기화
@Query("update Member m set m.age = m.age + 1 where m.age >= :age")
int bulkAgePlus(@Param("age") int age);
// 벌크 DELETE
@Modifying(clearAutomatically = true)
@Query("delete from Member m where m.age < :age")
int bulkDelete(@Param("age") int age);
}주의사항
벌크 연산은 영속성 컨텍스트를 무시하고 DB에 직접 쿼리합니다. 반드시 clearAutomatically = true를 사용하거나 em.clear()를 호출하세요.
// COUNT 쿼리 분리 (복잡한 조인이 있는 경우)
@Query(value = "select m from Member m left join m.team t",
countQuery = "select count(m) from Member m") // 조인 없이 카운트
Page<Member> findByAge(int age, Pageable pageable);
// No Offset 페이징 (커서 기반) - 대용량 데이터에 효과적
@Query("select m from Member m where m.id > :lastId order by m.id")
List<Member> findByIdGreaterThan(@Param("lastId") Long lastId, Pageable pageable);spring:
datasource:
hikari:
maximum-pool-size: 10
minimum-idle: 5
connection-timeout: 30000
jpa:
hibernate:
ddl-auto: validate # 운영: validate, 개발: update
properties:
hibernate:
format_sql: true
default_batch_fetch_size: 100 # N+1 방어
jdbc:
batch_size: 50
order_inserts: true
order_updates: true
open-in-view: false # 실시간 서비스 권장
logging:
level:
org.hibernate.SQL: DEBUG # 개발 환경만핵심 원칙 5가지
LazyInitializationException: could not initialize proxy - no Session
영속성 컨텍스트가 종료된 후 지연 로딩을 시도할 때 발생합니다.
// ❌ 문제 발생
@Service
public class MemberService {
@Transactional
public Member findMember(Long id) {
return memberRepository.findById(id).get();
}
}
@RestController
public class MemberController {
@GetMapping("/members/{id}")
public MemberDto getMember(@PathVariable Long id) {
Member member = memberService.findMember(id);
// 트랜잭션 종료 후 접근 → LazyInitializationException!
return new MemberDto(member.getName(), member.getTeam().getName());
}
}// ✅ 해결책 1: 트랜잭션 안에서 DTO 변환
@Service
@Transactional(readOnly = true)
public class MemberService {
public MemberDto findMemberDto(Long id) {
Member member = memberRepository.findById(id).get();
return new MemberDto(member.getName(), member.getTeam().getName());
}
}
// ✅ 해결책 2: Fetch Join 사용
@Query("select m from Member m join fetch m.team where m.id = :id")
Optional<Member> findByIdWithTeam(@Param("id") Long id);
// ✅ 해결책 3: EntityGraph 사용
@EntityGraph(attributePaths = {"team"})
Optional<Member> findById(Long id);MultipleBagFetchException: cannot simultaneously fetch multiple bags
2개 이상의 컬렉션(List)을 동시에 Fetch Join할 때 발생합니다.
// ❌ 문제 발생
@Query("select t from Team t " +
"join fetch t.members " +
"join fetch t.projects") // MultipleBagFetchException!
List<Team> findAllWithMembersAndProjects();
// ✅ 해결책 1: Set으로 변경
@Entity
public class Team {
@OneToMany(mappedBy = "team")
private Set<Member> members = new HashSet<>();
@OneToMany(mappedBy = "team")
private Set<Project> projects = new HashSet<>();
}
// ✅ 해결책 2: Batch Size 활용 (권장)
@Entity
public class Team {
@BatchSize(size = 100)
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
@BatchSize(size = 100)
@OneToMany(mappedBy = "team")
private List<Project> projects = new ArrayList<>();
}// ❌ 잘못된 사용 - 영속성 컨텍스트와 DB 불일치
@Transactional
public void wrongBulkUpdate() {
Member member = memberRepository.findById(1L).get();
System.out.println("변경 전: " + member.getAge()); // 20
memberRepository.bulkAgePlus(10); // DB에서 age + 1
// 영속성 컨텍스트에는 여전히 이전 값!
System.out.println("변경 후: " + member.getAge()); // 여전히 20
}
// ✅ 올바른 사용
@Transactional
public void correctBulkUpdate() {
memberRepository.bulkAgePlus(10);
em.flush();
em.clear(); // 영속성 컨텍스트 초기화
Member member = memberRepository.findById(1L).get();
System.out.println("변경 후: " + member.getAge()); // 21
}// 낙관적 락
@Entity
public class Product {
@Version
private Long version;
}
// 비관적 락
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from Product p where p.id = :id")
Optional<Product> findByIdWithPessimisticLock(@Param("id") Long id);JPA 성능 최적화 체크리스트