SpringHard#77
JPA N+1 문제란 무엇이며 해결 방법을 설명해주세요.
#Spring#JPA#N+1#성능
힌트
지연 로딩에서 연관 엔티티를 반복 조회할 때 발생합니다. Fetch Join, BatchSize를 생각해보세요.
정답 및 해설
JPA N+1 문제란 무엇이며 해결 방법을 설명해주세요.
N+1 문제는 JPA에서 가장 흔하게 발생하는 성능 이슈 중 하나입니다. 1번의 쿼리로 N개의 엔티티를 조회한 후, 연관된 엔티티를 하나씩 조회하여 N번의 추가 쿼리가 발생하는 현상을 말합니다. 예를 들어 주문 100건을 조회하면 1번의 쿼리 + 각 주문의 회원 정보를 조회하는 100번의 쿼리 = 총 101번의 쿼리가 실행됩니다.
N+1 문제 발생 원인
엔티티 구조
@Entity
public class Order {
@Id @GeneratedValue
private Long id;
private String orderNumber;
@ManyToOne(fetch = FetchType.LAZY) // 지연 로딩
@JoinColumn(name = "member_id")
private Member member;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> orderItems = new ArrayList<>();
}
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
private String email;
}
문제 발생 시나리오
@Service
@Transactional(readOnly = true)
public class OrderService {
public List<OrderDto> getAllOrders() {
// 1번 쿼리: SELECT * FROM orders
List<Order> orders = orderRepository.findAll();
return orders.stream()
.map(order -> {
// N번 쿼리: 각 Order마다 SELECT * FROM members WHERE id = ?
String memberName = order.getMember().getName(); // 여기서 쿼리 발생!
return new OrderDto(order.getId(), memberName);
})
.collect(Collectors.toList());
}
}
-- 실행된 SQL (Order 100건이 있을 경우)
SELECT * FROM orders; -- 1번
SELECT * FROM members WHERE id = 1; -- Order 1의 Member
SELECT * FROM members WHERE id = 2; -- Order 2의 Member
SELECT * FROM members WHERE id = 3; -- Order 3의 Member
-- ... 반복 ...
SELECT * FROM members WHERE id = 100; -- Order 100의 Member
-- 총 101번 쿼리 실행!
즉시 로딩(EAGER)에서도 발생
@ManyToOne(fetch = FetchType.EAGER) // 즉시 로딩
private Member member;
// JPQL로 조회 시 N+1 여전히 발생
// → JPQL은 작성된 쿼리 그대로 실행 후, EAGER 설정 보고 추가 쿼리 실행
List<Order> orders = em.createQuery("SELECT o FROM Order o", Order.class)
.getResultList();
// → SELECT * FROM orders
// → SELECT * FROM members WHERE id = 1 (즉시 로딩으로 N번 추가 발생)
해결 방법 1: Fetch Join (JPQL)
기본 Fetch Join
가장 직접적인 해결 방법으로, JPQL에서 JOIN FETCH를 사용하여 한 번의 쿼리로 연관 엔티티를 함께 조회합니다.
// Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT o FROM Order o JOIN FETCH o.member")
List<Order> findAllWithMember();
}
-- 실행 SQL: 1번의 JOIN 쿼리로 해결
SELECT o.*, m.*
FROM orders o
INNER JOIN members m ON o.member_id = m.id
컬렉션 Fetch Join (주의 필요)
// OneToMany 컬렉션 Fetch Join
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.orderItems")
List<Order> findAllWithOrderItems();
-- 실행 SQL
SELECT DISTINCT o.*, oi.*
FROM orders o
INNER JOIN order_items oi ON oi.order_id = o.id
Fetch Join의 주의사항
// 문제 1: 컬렉션 Fetch Join + 페이징 사용 불가 → 경고 발생
@Query("SELECT o FROM Order o JOIN FETCH o.orderItems")
Page<Order> findAllWithOrderItemsPageable(Pageable pageable);
// HibernateJpaDialect.warn: HHH90003004: firstResult/maxResults
// 지정되었지만 컬렉션 fetch join으로 인해 메모리에서 페이징 처리됨!
// 문제 2: 둘 이상의 컬렉션 Fetch Join 불가
@Query("SELECT o FROM Order o JOIN FETCH o.orderItems JOIN FETCH o.coupons")
List<Order> findAllWithMultipleCollections(); // MultipleBagFetchException!
해결 방법 2: @EntityGraph
사용 방법
public interface OrderRepository extends JpaRepository<Order, Long> {
// attributePaths에 지정한 연관 관계를 Fetch Join으로 처리
@EntityGraph(attributePaths = {"member"})
List<Order> findAll();
// 조건 쿼리와 함께 사용
@EntityGraph(attributePaths = {"member", "orderItems"})
@Query("SELECT o FROM Order o WHERE o.status = :status")
List<Order> findByStatusWithDetails(@Param("status") OrderStatus status);
}
@NamedEntityGraph 사용
@Entity
@NamedEntityGraph(
name = "Order.withMemberAndItems",
attributeNodes = {
@NamedAttributeNode("member"),
@NamedAttributeNode(value = "orderItems", subgraph = "orderItems.product")
},
subgraphs = {
@NamedSubgraph(
name = "orderItems.product",
attributeNodes = @NamedAttributeNode("product")
)
}
)
public class Order {
// ...
}
// Repository에서 사용
@EntityGraph("Order.withMemberAndItems")
List<Order> findAll();
해결 방법 3: Batch Size
@BatchSize 어노테이션
N+1을 N번 쿼리 대신 IN 절을 활용하여 몇 번의 쿼리로 최적화합니다.
@Entity
public class Order {
@Id @GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private Member member;
@BatchSize(size = 100) // 한 번에 100개씩 IN 쿼리로 조회
@OneToMany(mappedBy = "order")
private List<OrderItem> orderItems;
}
-- Batch Size 100으로 설정 시 (Order 100건 조회)
SELECT * FROM orders;
-- 1번의 쿼리로 100개의 Member를 IN 절로 조회
SELECT * FROM members WHERE id IN (1, 2, 3, ..., 100);
-- 결과: 2번의 쿼리로 해결 (1+1)
application.yml 전역 설정 (권장)
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 1000
// 전역 설정 적용 시 별도 어노테이션 불필요
@Entity
public class Order {
@ManyToOne(fetch = FetchType.LAZY)
private Member member; // 전역 batch size 자동 적용
}
Batch Size의 동작
Order 300건 조회, batch_fetch_size = 100
1. SELECT * FROM orders; (Order 300건)
2. SELECT * FROM members WHERE id IN (1~100); (1~100번 회원)
3. SELECT * FROM members WHERE id IN (101~200); (101~200번 회원)
4. SELECT * FROM members WHERE id IN (201~300); (201~300번 회원)
총 4번의 쿼리 (vs 원래 301번)
해결 방법 4: DTO 직접 조회
연관 엔티티가 불필요하고 특정 필드만 필요한 경우, DTO로 직접 조회하는 것이 가장 효율적입니다.
// DTO 정의
public class OrderSummaryDto {
private Long orderId;
private String orderNumber;
private String memberName;
public OrderSummaryDto(Long orderId, String orderNumber, String memberName) {
this.orderId = orderId;
this.orderNumber = orderNumber;
this.memberName = memberName;
}
}
// Repository - JPQL DTO 프로젝션
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT new com.example.dto.OrderSummaryDto(o.id, o.orderNumber, m.name) " +
"FROM Order o JOIN o.member m")
List<OrderSummaryDto> findOrderSummaries();
}
-- 실행 SQL: 1번의 쿼리로 필요한 데이터만 조회
SELECT o.id, o.order_number, m.name
FROM orders o
INNER JOIN members m ON o.member_id = m.id
QueryDSL을 활용한 DTO 조회
@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {
private final JPAQueryFactory queryFactory;
public List<OrderSummaryDto> findOrderSummaries() {
return queryFactory
.select(Projections.constructor(
OrderSummaryDto.class,
order.id,
order.orderNumber,
member.name
))
.from(order)
.join(order.member, member)
.fetch();
}
}
각 해결 방법 비교
Fetch Join vs Batch Size 선택 기준
연관 엔티티가 항상 필요한가?
→ YES + 단건 연관관계: Fetch Join
→ YES + 컬렉션 + 페이징: Batch Size (Fetch Join 불가)
→ YES + 복잡한 중첩 관계: Batch Size
특정 필드만 필요한가?
→ DTO 직접 조회
실전 권장 방법
@Service
@Transactional(readOnly = true)
public class OrderService {
// 1. 대부분의 경우: Batch Size 전역 설정 + 필요시 Fetch Join
public List<OrderDto> getOrders() {
List<Order> orders = orderRepository.findAll(); // batch size로 N+1 방지
return orders.stream().map(OrderDto::from).collect(Collectors.toList());
}
// 2. 특정 쿼리 최적화: Fetch Join
public OrderDetailDto getOrderDetail(Long orderId) {
Order order = orderRepository.findByIdWithMemberAndItems(orderId)
.orElseThrow(() -> new EntityNotFoundException("Order not found"));
return OrderDetailDto.from(order);
}
// 3. 목록/페이징: DTO 직접 조회
public Page<OrderSummaryDto> getOrderPage(Pageable pageable) {
return orderRepository.findOrderSummaries(pageable);
}
}
요약 표
| 해결 방법 | 쿼리 수 | 페이징 | 여러 컬렉션 | 적합한 상황 |
|---|---|---|---|---|
| Fetch Join | 1번 | 불가 (컬렉션) | 불가 | 단일 연관관계, 항상 함께 조회 |
| @EntityGraph | 1번 | 불가 (컬렉션) | 불가 | 메서드 레벨 선언적 설정 |
| Batch Size | 1+N/size번 | 가능 | 가능 | 페이징, 복잡한 연관관계 |
| DTO 직접 조회 | 1번 | 가능 | 가능 | 특정 필드만 필요한 목록 조회 |
| 발생 원인 | 설명 |
|---|---|
| 지연 로딩 (LAZY) | 연관 엔티티 접근 시 개별 쿼리 발생 |
| 즉시 로딩 (EAGER) + JPQL | JPQL 실행 후 EAGER 설정 보고 추가 쿼리 실행 |
| findAll() + 반복 접근 | 컬렉션 조회 후 루프에서 연관 엔티티 접근 |