전체 목록
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 Join1번불가 (컬렉션)불가단일 연관관계, 항상 함께 조회
@EntityGraph1번불가 (컬렉션)불가메서드 레벨 선언적 설정
Batch Size1+N/size번가능가능페이징, 복잡한 연관관계
DTO 직접 조회1번가능가능특정 필드만 필요한 목록 조회
발생 원인설명
지연 로딩 (LAZY)연관 엔티티 접근 시 개별 쿼리 발생
즉시 로딩 (EAGER) + JPQLJPQL 실행 후 EAGER 설정 보고 추가 쿼리 실행
findAll() + 반복 접근컬렉션 조회 후 루프에서 연관 엔티티 접근