Spring Data JPA N+1 문제 해결법: Fetch Join, EntityGraph, Hibernate 7.4
Spring Data JPA의 N+1 문제를 탐지하고 해결하기 위한 완벽 가이드입니다. Fetch join, @EntityGraph, batch fetching, 쿼리 성능 전략을 다룹니다.

N+1 문제는 JPA에서 가장 흔한 성능 함정 중 하나입니다. 100개의 주문을 가져오는 단순한 쿼리가 101개의 SQL 쿼리를 유발할 수 있습니다. 주문에 대해 1개, 그리고 연관된 각 고객에 대해 1개씩입니다. 이 조용한 쿼리 증식은 성능을 저하시키고 데이터베이스에 과부하를 일으킵니다.
저자와 함께 50개의 기사를 반환하는 엔드포인트가 N+1 때문에 10ms에서 500ms로 증가할 수 있습니다. 조기 발견이 운영 환경에서의 심각한 문제를 예방합니다.
JPA에서의 N+1 문제 이해
N+1 문제는 JPA가 엔티티 컬렉션을 로드한 후 각 엔티티의 연관 관계를 로드하기 위해 추가 쿼리를 실행할 때 발생합니다. 이 동작은 @OneToMany와 @ManyToMany 관계의 기본 lazy loading에서 비롯됩니다.
주문과 고객이 있는 고전적인 모델을 고려해 봅시다. 각 주문은 한 고객에게 속하며, 이 관계는 기본적으로 lazy loading을 사용합니다.
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String orderNumber;
private LocalDateTime createdAt;
// ManyToOne relationship is lazy by default since JPA 2.0
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
// OneToMany relationship lazy by default
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> items = new ArrayList<>();
// Getters and setters omitted
}@Entity
@Table(name = "customers")
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
// Getters and setters omitted
}쿼리가 주문을 가져온 다음 고객 이름에 접근할 때, Hibernate는 각 주문에 대해 추가 쿼리를 실행합니다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
public List<OrderDto> getAllOrders() {
// 1 query: SELECT * FROM orders
List<Order> orders = orderRepository.findAll();
// For each order, accessing customer triggers a query
return orders.stream()
.map(order -> new OrderDto(
order.getId(),
order.getOrderNumber(),
// N queries: SELECT * FROM customers WHERE id = ?
order.getCustomer().getName()
))
.toList();
}
}100개의 주문에 대해 이 코드는 101개의 SQL 쿼리를 실행합니다. Hibernate 로그가 이 파괴적인 패턴을 드러냅니다.
-- Query 1: fetch orders
SELECT o.id, o.order_number, o.created_at, o.customer_id FROM orders o
-- Queries 2-101: fetch each customer
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
-- ... 97 more identical queriesHibernate 로그로 N+1 문제 탐지
첫 번째 단계는 SQL 로그를 활성화하여 문제가 있는 쿼리를 식별하는 것입니다. 다음 설정은 Hibernate가 실행하는 모든 쿼리를 표시합니다.
# application.yml
spring:
jpa:
show-sql: true
properties:
hibernate:
# Format SQL for better readability
format_sql: true
# Display session statistics (queries, time)
generate_statistics: true
logging:
level:
# Detailed SQL query logging
org.hibernate.SQL: DEBUG
# Display prepared statement parameters
org.hibernate.orm.jdbc.bind: TRACEHibernate 통계는 각 트랜잭션이 끝날 때 가치 있는 요약을 제공합니다.
Session Metrics {
23421 nanoseconds spent acquiring 1 JDBC connection;
0 nanoseconds spent releasing 0 JDBC connections;
1254789 nanoseconds spent preparing 101 JDBC statements;
15478963 nanoseconds spent executing 101 JDBC statements;
0 nanoseconds spent executing 0 JDBC batches;
}간단한 주문 목록에 대한 101개의 JDBC 명령문 수는 N+1 문제를 명확하게 알려줍니다.
SQL 로그와 통계는 성능에 영향을 줍니다. 이 옵션들은 운영 환경에서는 비활성화하고 개발 및 테스트 환경에서만 사용해야 합니다.
해결책 1: JPQL을 사용한 Fetch Join
Fetch join은 join을 통해 단일 SQL 쿼리로 연관 관계를 로드합니다. 이 명시적인 접근 방식은 필요한 모든 데이터를 한 번에 가져와서 N+1을 해결합니다.
public interface OrderRepository extends JpaRepository<Order, Long> {
// Explicit fetch join to load customers
@Query("SELECT o FROM Order o JOIN FETCH o.customer")
List<Order> findAllWithCustomer();
// Multiple fetch join for several associations
@Query("SELECT o FROM Order o " +
"JOIN FETCH o.customer c " +
"JOIN FETCH o.items i")
List<Order> findAllWithCustomerAndItems();
// Fetch join with WHERE condition
@Query("SELECT o FROM Order o " +
"JOIN FETCH o.customer c " +
"WHERE o.createdAt > :since")
List<Order> findRecentOrdersWithCustomer(
@Param("since") LocalDateTime since
);
}Fetch join은 N+1 쿼리를 단일 최적화된 쿼리로 변환합니다.
-- Single query with join
SELECT o.id, o.order_number, o.created_at, o.customer_id,
c.id, c.name, c.email
FROM orders o
JOIN customers c ON o.customer_id = c.id이제 서비스는 비즈니스 코드를 수정하지 않고도 최적화된 메서드를 사용합니다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
public List<OrderDto> getAllOrders() {
// Single query with join
List<Order> orders = orderRepository.findAllWithCustomer();
// No additional queries
return orders.stream()
.map(order -> new OrderDto(
order.getId(),
order.getOrderNumber(),
order.getCustomer().getName() // Already loaded
))
.toList();
}
}Spring Boot 면접 준비가 되셨나요?
인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.
해결책 2: 선언적 제어를 위한 @EntityGraph
@EntityGraph 어노테이션은 fetch join에 대한 선언적 대안을 제공합니다. 사용자 정의 JPQL을 작성하지 않고도 어떤 연관 관계를 로드할지 정의합니다. JPA 모범 사례에 대한 자세한 내용은 Spring Data JPA 기초 모듈을 참조하십시오.
public interface OrderRepository extends JpaRepository<Order, Long> {
// Inline EntityGraph with attributePaths
@EntityGraph(attributePaths = {"customer"})
List<Order> findAll();
// EntityGraph with multiple attributes
@EntityGraph(attributePaths = {"customer", "items"})
List<Order> findByCreatedAtAfter(LocalDateTime since);
// Named EntityGraph referencing entity definition
@EntityGraph(value = "Order.withCustomerAndItems")
List<Order> findByCustomerId(Long customerId);
// Combination with custom query
@EntityGraph(attributePaths = {"customer"})
@Query("SELECT o FROM Order o WHERE o.orderNumber LIKE :prefix%")
List<Order> findByOrderNumberPrefix(@Param("prefix") String prefix);
}명명된 EntityGraph는 여러 리포지토리에서 재사용하기 위해 엔티티에 직접 정의됩니다.
@Entity
@Table(name = "orders")
@NamedEntityGraph(
name = "Order.withCustomer",
attributeNodes = @NamedAttributeNode("customer")
)
@NamedEntityGraph(
name = "Order.withCustomerAndItems",
attributeNodes = {
@NamedAttributeNode("customer"),
@NamedAttributeNode("items")
}
)
@NamedEntityGraph(
name = "Order.full",
attributeNodes = {
@NamedAttributeNode("customer"),
@NamedAttributeNode(value = "items", subgraph = "items-product")
},
subgraphs = @NamedSubgraph(
name = "items-product",
attributeNodes = @NamedAttributeNode("product")
)
)
public class Order {
// Fields unchanged
}서브그래프는 중첩된 연관 관계 로딩을 가능하게 합니다. 위의 예시는 주문, 그 항목들, 그리고 각 항목의 제품을 단일 쿼리로 로드합니다.
Hibernate 7은 어노테이션의 장황함을 줄이는 간소화된 텍스트 기반 @NamedEntityGraph 구문을 도입했습니다.
@Entity
@Table(name = "orders")
@org.hibernate.annotations.NamedEntityGraph(
name = "Order.full",
graph = "customer, items(product)"
)
public class Order {
// Fields unchanged
}이 단일 행 정의는 장황한 중첩된 @NamedAttributeNode 및 @NamedSubgraph 어노테이션을 대체합니다.
해결책 3: 컬렉션을 위한 Batch Fetching
Batch fetching은 @OneToMany 컬렉션에 대해 fetch join의 대안을 제공합니다. 각 컬렉션을 개별적으로 로드하는 대신, Hibernate는 쿼리를 배치로 그룹화합니다.
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String orderNumber;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
// Batch fetching on the collection
@OneToMany(mappedBy = "order")
@BatchSize(size = 25)
private List<OrderItem> items = new ArrayList<>();
}@BatchSize(size = 25)을 사용하면 Hibernate는 1개가 아닌 25개의 주문에 대한 항목을 한 번에 로드합니다. 100개의 주문에 대해 쿼리 수가 101에서 5로 감소합니다.
-- Without batch fetching: 100 queries
SELECT * FROM order_items WHERE order_id = 1
SELECT * FROM order_items WHERE order_id = 2
-- ... 98 more queries
-- With @BatchSize(size = 25): 4 queries
SELECT * FROM order_items WHERE order_id IN (1, 2, 3, ..., 25)
SELECT * FROM order_items WHERE order_id IN (26, 27, 28, ..., 50)
SELECT * FROM order_items WHERE order_id IN (51, 52, 53, ..., 75)
SELECT * FROM order_items WHERE order_id IN (76, 77, 78, ..., 100)전역 배치 크기 설정은 애플리케이션 내의 모든 컬렉션에 적용됩니다.
# application.yml
spring:
jpa:
properties:
hibernate:
# Global default batch size
default_batch_fetch_size: 25Batch fetching은 fetch join이 너무 큰 데카르트 곱을 생성하는 경우에 적합합니다. 10개의 항목과 5개의 결제가 있는 주문의 경우, fetch join은 50개의 행을 반환합니다. Batch fetching은 더 효율적인 2개의 별도 쿼리를 실행합니다.
Hibernate 7.4에서 Fetch Join과 페이지네이션
fetch join의 오랜 제한 사항은 페이지네이션과의 비호환성이었습니다. setMaxResults()를 컬렉션 fetch join과 결합하면 Hibernate는 모든 일치하는 행을 로드한 다음 메모리에서 제한을 적용했습니다. firstResult/maxResults specified with collection fetch; applying in memory라는 경고가 이 성능 문제를 나타냈습니다.
Hibernate 7.4가 이 문제를 해결했습니다. 컬렉션 fetch join을 사용한 페이지네이션이 이제 중첩된 쿼리를 사용하여 데이터베이스 레벨에서 작동합니다. Hibernate는 먼저 제한된 부모 엔티티 식별자 집합을 결정한 다음, 해당 부모 행에 대해서만 연관 컬렉션을 가져옵니다.
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT o FROM Order o JOIN FETCH o.items")
Page<Order> findAllWithItems(Pageable pageable);
@Query("SELECT o FROM Order o JOIN FETCH o.customer JOIN FETCH o.items")
Slice<Order> findAllWithCustomerAndItems(Pageable pageable);
}생성되는 SQL은 이제 데이터베이스 레벨에서 페이지네이션을 수행하기 위해 서브쿼리를 사용합니다.
-- Hibernate 7.4: database-level pagination with fetch join
SELECT o.*, i.*
FROM orders o
JOIN order_items i ON o.id = i.order_id
WHERE o.id IN (
SELECT id FROM orders
ORDER BY created_at DESC
LIMIT 10 OFFSET 0
)이 개선으로 부모-자식 관계를 표시하는 페이지네이션된 엔드포인트의 메모리 사용량이 크게 감소합니다. Spring Boot 4.1에는 Hibernate 7.4.5가 포함되어 있어 이 최적화를 기본적으로 사용할 수 있습니다.
이전 Hibernate 버전을 사용하는 애플리케이션의 경우, 2단계 페칭 패턴이 가장 안전한 접근 방식입니다. 먼저 페이지네이션으로 부모 ID를 가져온 다음, 해당 특정 ID에 대한 연관 관계를 로드합니다.
로딩 전략 비교
각 전략은 사용 컨텍스트에 따라 장점을 제공합니다. 다음 표는 최적의 사용 사례를 요약합니다.
| 전략 | 사용 사례 | 장점 | 단점 |
|---|---|---|---|
| Fetch Join | @ManyToOne 관계 | 단일 SQL 쿼리 | 컬렉션과 데카르트 곱 |
| @EntityGraph | 선언적 로딩 | 재사용 가능, 가독성 좋음 | JPQL보다 덜 유연 |
| Batch Fetching | @OneToMany 컬렉션 | 데카르트 곱 회피 | 다수의 쿼리 |
| Subselect | 자주 접근하지 않는 컬렉션 | 필요할 때만 로드 | 상관 서브쿼리 |
Subselect 전략은 어떤 요소든 처음 접근할 때 전체 컬렉션을 로드합니다.
@OneToMany(mappedBy = "order")
@Fetch(FetchMode.SUBSELECT)
private List<OrderItem> items = new ArrayList<>();-- Generated subselect query
SELECT * FROM order_items
WHERE order_id IN (SELECT id FROM orders WHERE created_at > ?)DTO 프로젝션으로 N+1 회피
DTO 프로젝션은 급진적이지만 효과적인 접근 방식을 제공합니다. 필요한 컬럼만 선택함으로써, 프로젝션은 엔티티와 그 연관 관계의 로딩을 완전히 회피합니다. 이 패턴은 읽기 중심 엔드포인트에 특히 관련이 있으며, JPA 쿼리 모듈에서 자세히 다루고 있습니다.
public record OrderSummaryDto(
Long orderId,
String orderNumber,
String customerName,
String customerEmail
) {}public interface OrderRepository extends JpaRepository<Order, Long> {
// DTO projection with constructor
@Query("SELECT new com.example.dto.OrderSummaryDto(" +
"o.id, o.orderNumber, c.name, c.email) " +
"FROM Order o JOIN o.customer c")
List<OrderSummaryDto> findAllOrderSummaries();
// DTO projection with condition
@Query("SELECT new com.example.dto.OrderSummaryDto(" +
"o.id, o.orderNumber, c.name, c.email) " +
"FROM Order o JOIN o.customer c " +
"WHERE o.createdAt > :since")
List<OrderSummaryDto> findRecentOrderSummaries(
@Param("since") LocalDateTime since
);
}이 접근 방식은 엔티티 매핑 오버헤드 없이 최적의 SQL 쿼리를 생성합니다.
SELECT o.id, o.order_number, c.name, c.email
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.created_at > ?Spring Data JPA의 고급 설정
Spring Boot 4.x의 Spring Data JPA는 EntityGraph 관리와 쿼리 최적화에 대한 개선된 기능을 제공합니다. 이러한 페치 전략을 복잡한 비즈니스 로직과 결합할 때 트랜잭션 전파에 대한 이해가 필수적입니다.
public interface OrderRepository extends JpaRepository<Order, Long> {
// Dynamic EntityGraph with Specification
@EntityGraph(attributePaths = {"customer"})
List<Order> findAll(Specification<Order> spec);
// Pagination with EntityGraph
@EntityGraph(attributePaths = {"customer"})
Page<Order> findByCustomerNameContaining(
String name,
Pageable pageable
);
// Slice for efficient pagination
@EntityGraph(attributePaths = {"customer"})
Slice<Order> findByCreatedAtBefore(
LocalDateTime date,
Pageable pageable
);
}조건부 로딩은 컨텍스트에 따라 다른 전략을 적용할 수 있게 해줍니다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final EntityManager entityManager;
public List<Order> getOrdersWithGraph(String graphName) {
// Dynamic EntityGraph retrieval
EntityGraph<?> graph = entityManager
.getEntityGraph(graphName);
return entityManager
.createQuery("SELECT o FROM Order o", Order.class)
.setHint("jakarta.persistence.loadgraph", graph)
.getResultList();
}
public List<Order> getOrdersForListing() {
// Minimal graph for listing
return getOrdersWithGraph("Order.withCustomer");
}
public List<Order> getOrdersForDetail() {
// Full graph for detail view
return getOrdersWithGraph("Order.full");
}
}연습을 시작하세요!
면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.
N+1 탐지를 위한 성능 테스트
자동화된 테스트는 실행된 SQL 쿼리 수를 카운트하여 N+1 문제가 없음을 확인합니다. 포괄적인 테스트 전략에 대해서는 Spring Boot의 Testcontainers 가이드를 참조하십시오.
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)
class OrderRepositoryPerformanceTest {
@Autowired
private OrderRepository orderRepository;
@Autowired
private EntityManager entityManager;
@PersistenceContext
private EntityManager em;
private Statistics statistics;
@BeforeEach
void setUp() {
// Enable Hibernate statistics
Session session = entityManager.unwrap(Session.class);
SessionFactory factory = session.getSessionFactory();
statistics = factory.getStatistics();
statistics.setStatisticsEnabled(true);
statistics.clear();
}
@Test
void findAllWithCustomer_shouldExecuteSingleQuery() {
// Given: 50 orders in database
createTestOrders(50);
entityManager.clear();
statistics.clear();
// When: fetch with fetch join
List<Order> orders = orderRepository.findAllWithCustomer();
// Then: single query executed
assertThat(orders).hasSize(50);
assertThat(statistics.getQueryExecutionCount())
.as("Should execute only 1 query with fetch join")
.isEqualTo(1);
}
@Test
void findAll_withoutOptimization_triggersNPlus1() {
// Given: 50 orders in database
createTestOrders(50);
entityManager.clear();
statistics.clear();
// When: fetch without optimization
List<Order> orders = orderRepository.findAll();
// Access customers to trigger lazy loading
orders.forEach(o -> o.getCustomer().getName());
// Then: N+1 queries (51 instead of 1)
assertThat(statistics.getQueryExecutionCount())
.as("Should detect N+1 problem")
.isGreaterThan(1);
}
private void createTestOrders(int count) {
for (int i = 0; i < count; i++) {
Customer customer = new Customer();
customer.setName("Customer " + i);
customer.setEmail("customer" + i + "@test.com");
entityManager.persist(customer);
Order order = new Order();
order.setOrderNumber("ORD-" + i);
order.setCustomer(customer);
order.setCreatedAt(LocalDateTime.now());
entityManager.persist(order);
}
entityManager.flush();
}
}getQueryExecutionCount()에 대한 단언은 코드 진화 중에도 최적화가 유지되도록 보장합니다.
참고 자료
- Hibernate 7.4 What's New : fetch join을 사용한 페이지네이션이 데이터베이스 레벨에서 안전하게 작동
- Spring Boot 4.1 릴리스 노트 : Hibernate 7.4.5.Final 포함
- Hibernate ORM 문서 : 페치 전략과 EntityGraph에 대한 공식 레퍼런스
Spring Data JPA의 N+1 방지 체크리스트
N+1 문제는 Spring Data JPA에서 주요한 성능 과제이지만, 여러 효과적인 해결책이 존재합니다. Fetch join과 @EntityGraph는 단일 쿼리에서 연관 관계를 로드하여 대부분의 경우를 해결합니다. Batch fetching은 fetch join이 데카르트 곱을 생성하는 큰 컬렉션에 대한 대안을 제공합니다. Hibernate 7.4에서는 fetch join을 사용한 페이지네이션이 마침내 데이터베이스 레벨에서 작동합니다.
운영 환경에서 적용할 사항:
- 개발 환경에서 SQL 로그를 활성화하여 다중 쿼리 탐지
- 자주 접근하는
@ManyToOne관계에JOIN FETCH사용 - 재사용 가능한 선언적 로딩을 위해
@EntityGraph적용 - 큰
@OneToMany컬렉션에@BatchSize설정 - 읽기 전용 작업에는 DTO 프로젝션 선호
- 실행되는 쿼리 수를 검증하는 테스트 작성
- 운영 환경에서는 SQL 로그와 통계 비활성화
- Hibernate 메트릭으로 중요한 엔드포인트를 정기적으로 프로파일링
- Hibernate 7.4 페이지네이션 수정 사항을 활용하기 위해 Spring Boot 4.1 이상으로 업그레이드
Spring Boot 코드의 버그를 찾을 수 있나요
실제 코드 한 조각, 숨은 버그 하나, 하루 한 번. 계정 없이 바로 도전할 수 있습니다.

작성자
Anthony Fillion-MailletSharpSkill 창업자
10년 이상 풀스택 개발을 해왔습니다. SharpSkill을 운영하며 이곳에 게시되는 모든 내용에 책임을 집니다.
2026년 9월 2일 업데이트
태그
공유
관련 기사

Spring GraphQL 면접: Resolver, DataLoader 및 N+1 문제 해결책
이 완전한 가이드로 Spring GraphQL 면접을 준비합니다. Resolver, DataLoader, N+1 문제 처리, mutation 및 기술 질문을 위한 모범 사례를 다룹니다.

Spring Boot 3.4 Virtual Threads: 면접 질문과 성능 벤치마크
Spring Boot 3.4와 함께 Java 21 Virtual Threads를 마스터하세요. 15가지 면접 질문, 성능 벤치마크, 마이그레이션 패턴으로 기술 면접을 통과하세요.

Spring Boot Observability 2026 완벽 가이드: OpenTelemetry, 분산 추적, 면접 질문
Spring Boot 3.4의 Observation API, OpenTelemetry 연동, 분산 추적 구현 방법을 상세히 설명합니다. Micrometer Tracing과 OTLP 내보내기 설정, 실무 면접 질문도 다룹니다.