안녕하세요. 새내기 개발자입니다. 공부하면서 정리하는 글로 틀린 부분은 언제나 댓글로 환영입니다!
JPA에 이어시 JPA 사용시 발생할 수 있는 N+1 문제에 대해서 정리했습니다.
N+1 문제는 JPA에서 연관된 엔티티를 조회할 때 발생하는 불필요한 추가 쿼리 문제를 의미합니다.
즉, 하나의 메인 엔티티(1)를 조회할 때, 연관된 N개의 엔티티를 각각 추가로 조회하면서 총 N+1개의 쿼리가 발생하는 문제입니다.
📌 N+1 문제 발생 예시
예를 들어, User와 Order가 1:N 관계를 가지고 있다고 가정해봅시다.
@Entity
public class User {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "user")
private List<Order> orders = new ArrayList<>();
}
@Entity
public class Order {
@Id @GeneratedValue
private Long id;
private String product;
@ManyToOne
@JoinColumn(name = "user_id")
private User user;
}
❌ N+1 문제 발생 코드
List<User> users = entityManager.createQuery("SELECT u FROM User u", User.class)
.getResultList();
for (User user : users) {
System.out.println(user.getOrders()); // 연관된 Order 조회
}
📌 실행되는 SQL 쿼리
-- 1. User 조회 (1개의 쿼리)
SELECT * FROM User;
-- 2. 각 User의 Order 조회 (N개의 쿼리)
SELECT * FROM Order WHERE user_id = ?;
SELECT * FROM Order WHERE user_id = ?;
SELECT * FROM Order WHERE user_id = ?;
...
✔ 문제점: User가 100명이라면, User를 가져오는 1개의 쿼리 + Order를 가져오는 100개의 쿼리 = 총 101개의 쿼리가 실행됩니다.
JPA N+1 문제 해결 방법
1️⃣ Fetch Join 사용하기 ✅ (가장 많이 쓰이는 방법)
JOIN FETCH를 사용하면 한 번의 SQL 쿼리로 연관된 데이터를 즉시 로딩(Eager Loading) 할 수 있습니다.
@Query("SELECT u FROM User u JOIN FETCH u.orders WHERE u.id = :id")
User findUserWithOrders(@Param("id") Long id);
📌 실행되는 SQL
SELECT u.*, o.*
FROM User u
JOIN Order o ON u.id = o.user_id
WHERE u.id = ?;
✔ 장점: 한 번의 쿼리로 User와 Order를 모두 가져올 수 있음
✔ 단점: 여러 개의 Fetch Join이 필요할 경우, 복잡한 쿼리가 발생할 수 있음
2️⃣ EntityGraph 사용하기
Spring Data JPA의 @EntityGraph를 사용하면, Fetch Join과 유사한 방식으로 한 번의 쿼리로 데이터를 가져올 수 있습니다.
@EntityGraph(attributePaths = {"orders"})
@Query("SELECT u FROM User u WHERE u.id = :id")
User findUserWithOrders(@Param("id") Long id);
✔ 장점: 특정 조회 메서드에서만 Fetch Join을 적용할 수 있음
✔ 단점: Fetch Join과 마찬가지로 복잡한 관계에서는 사용이 어려울 수 있음
3️⃣ Batch Size 설정 (IN 절 활용)
@BatchSize 어노테이션을 사용하거나 JPA의 글로벌 설정을 활용하면 IN 절을 사용하여 여러 개의 데이터를 한 번에 가져올 수 있습니다.
방법 1: @BatchSize 어노테이션 사용
@Entity
public class User {
@OneToMany(mappedBy = "user")
@BatchSize(size = 10) // Order 데이터를 10개씩 가져옴
private List<Order> orders = new ArrayList<>();
}
방법 2: 글로벌 설정
spring.jpa.properties.hibernate.default_batch_fetch_size=100
📌 실행되는 SQL
SELECT * FROM User;
SELECT * FROM Order WHERE user_id IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
✔ 장점: 모든 연관된 엔티티를 한 번의 IN 절로 가져와서 쿼리 개수를 줄일 수 있음
✔ 단점: Batch Size를 너무 크게 설정하면 한 번의 쿼리로 가져오는 데이터가 많아져 성능 저하 가능
4️⃣ Lazy Loading 전략을 유지하면서 @Query로 직접 조회하기
JPA의 기본 Lazy Loading 전략을 유지하면서 필요한 데이터만 조회하는 방법입니다.
@Query("SELECT u.id, u.name, o.product FROM User u LEFT JOIN u.orders o WHERE u.id = :id")
List<Object[]> findUsersWithOrders(@Param("id") Long id);
📌 실행되는 SQL
SELECT u.id, u.name, o.product
FROM User u
LEFT JOIN Order o ON u.id = o.user_id
WHERE u.id = ?;
✔ 장점: 성능 최적화 가능, 불필요한 컬럼 조회 방지
✔ 단점: 엔티티가 아닌 Object[] 형태로 반환되므로, DTO 변환 과정 필요
🔥 결론 (최적의 해결 방법)
- 간단한 Fetch Join(JOIN FETCH): 단순한 관계에서는 Fetch Join이 가장 효과적
- @EntityGraph 활용: Spring Data JPA를 사용하면 Fetch Join보다 가독성이 좋음
- Batch Size 설정: Lazy Loading을 유지하면서 N+1 문제를 해결할 때 사용
- DTO 조회(@Query): 필요 데이터만 가져와 성능 최적화
🚀 프로젝트의 요구사항에 따라 적절한 방식으로 해결하는 것이 중요합니다!
'백엔드' 카테고리의 다른 글
[DB] 관계형(RDB) vs 비관계형(NoSQL) 데이터베이스 비교 (1) | 2025.02.10 |
---|---|
[자바/JAVA] Null Pointer Exception (NPE) (1) | 2025.02.08 |
[자바/JAVA] 자바 스프링 AOP(Aspect-Oriented Programming) 이해하기 (0) | 2025.02.08 |
[자바/JAVA] JPA(Java Persistence API) 개념 정리🚀 (2) | 2025.02.02 |
[자바/JAVA] 스프링 기술 면접 질문 및 답변 정리 (2) | 2025.02.01 |