2026년 Spring Data JPA N+1 문제 해결법: Fetch Join과 EntityGraph
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을 작성하지 않고도 어떤 연관 관계를 로드할지 정의합니다.
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
}서브그래프는 중첩된 연관 관계 로딩을 가능하게 합니다. 위의 예시는 주문, 그 항목들, 그리고 각 항목의 제품을 단일 쿼리로 로드합니다.
해결책 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개의 별도 쿼리를 실행합니다.
로딩 전략 비교
각 전략은 사용 컨텍스트에 따라 장점을 제공합니다. 다음 표는 최적의 사용 사례를 요약합니다.
| 전략 | 사용 사례 | 장점 | 단점 |
|----------|----------|------------|---------------|
| 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 프로젝션은 급진적이지만 효과적인 접근 방식을 제공합니다. 필요한 컬럼만 선택함으로써, 프로젝션은 엔티티와 그 연관 관계의 로딩을 완전히 회피합니다.
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 Data JPA 3.x는 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 문제가 없음을 확인합니다.
@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()에 대한 단언은 코드 진화 중에도 최적화가 유지되도록 보장합니다.
결론
N+1 문제는 Spring Data JPA에서 주요한 성능 도전 과제이지만, 여러 효과적인 해결책이 존재합니다. Fetch join과 @EntityGraph는 단일 쿼리에서 연관 관계를 로드하여 대부분의 경우를 해결합니다. Batch fetching은 fetch join이 데카르트 곱을 생성하는 큰 컬렉션에 대한 대안을 제공합니다.
N+1 예방 체크리스트:
- ✅ 개발 환경에서 SQL 로그를 활성화하여 다중 쿼리 탐지
- ✅ 자주 접근하는
@ManyToOne관계에JOIN FETCH사용 - ✅ 재사용 가능한 선언적 로딩을 위해
@EntityGraph적용 - ✅ 큰
@OneToMany컬렉션에@BatchSize설정 - ✅ 읽기 전용 작업에는 DTO 프로젝션 선호
- ✅ 실행되는 쿼리 수를 검증하는 테스트 작성
- ✅ 운영 환경에서는 SQL 로그와 통계 비활성화
- ✅ Hibernate 메트릭으로 중요한 엔드포인트를 정기적으로 프로파일링
태그
공유
관련 기사

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가지 면접 질문, 성능 벤치마크, 마이그레이션 패턴으로 기술 면접을 통과하세요.

2026년 Spring Boot 로깅: Logback과 JSON으로 구현하는 운영 환경 구조화 로그
Spring Boot 구조화 로깅 완벽 가이드입니다. Logback JSON 설정, 추적용 MDC, 운영 환경 모범 사례, ELK Stack 연동을 다룹니다.