JPA를 처음 사용할 때 많은 개발자들이 부딪히는 문제 중 하나가 바로 N+1 이슈입니다.
이번 글에서는 JPA 환경에서 N+1 이슈가 발생하는 원인을 알아보고, 이를 해결할 수 있는 방법을 정리해보겠습니다.
N+1 이슈란?
N+1 이슈는 1개의 메인 쿼리로 데이터를 가져오려 했지만, 추가로 N개의 쿼리가 실행되는 문제를 뜻합니다.
다음은 간단한 예제를 통해 이를 이해해보겠습니다.
엔티티 구조
Member와 Order 간에 일대다 관계가 있다고 가정합니다.
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
private List<Order> orders = new ArrayList<>();
}
@Entity
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String productName;
@ManyToOne(fetch = FetchType.LAZY)
private Member member;
}
문제 상황
모든 Member와 관련된 Order를 조회하겠습니다.
List<Member> members = em.createQuery("SELECT m FROM Member m", Member.class)
.getResultList();
for (Member member : members) {
System.out.println("Member: " + member.getName());
for (Order order : member.getOrders()) {
System.out.println("Order: " + order.getProductName());
}
}
쿼리 실행 분석
1. 첫 번째 쿼리
SELECT * FROM Member;
모든 멤버 데이터를 조회합니다.
2. 추가 쿼리 (멤버 수만큼 실행)
SELECT * FROM Order WHERE member_id = ?;
각 멤버에 대한 주문 데이터를 가져옵니다.
만약 Member가 100명이라면 총 1개의 메인 쿼리 + 100개의 추가 쿼리가 실행됩니다.
이처럼 N+1 이슈는 불필요한 쿼리 호출을 증가시켜 애플리케이션 성능에 심각한 영향을 미칠 수 있습니다.🥲
N+1 이슈가 발생하는 원인: FetchType.LAZY
JPA에서는 연관된 엔티티를 로드할 때 FetchType.LAZY와 FetchType.EAGER를 설정할 수 있습니다.
- LAZY: 실제로 엔티티를 사용할 때 데이터베이스에서 조회합니다.
- EAGER: 연관된 엔티티를 즉시 로드합니다.
N+1 이슈는 주로 @OneToMany, @ManyToMany 관계에서 FetchType.LAZY를 사용할 때 발생합니다.
- 메인 엔티티를 조회한 후, 연관된 데이터를 각각 별도로 조회하기 때문입니다.
JPA에서 N+1 이슈 해결 방법
1) Fetch Join 사용
JOIN FETCH를 사용해 연관된 엔티티를 한 번에 가져옵니다.
List<Member> members = em.createQuery(
"SELECT m FROM Member m JOIN FETCH m.orders", Member.class)
.getResultList();
실행 쿼리는 아래와 같습니다.
SELECT m.*, o.*
FROM Member m
INNER JOIN Order o ON m.id = o.member_id;
- 장점: 추가 쿼리 없이 연관 데이터를 모두 로드합니다.
- 주의점:
- 페이징 (setFirstResult, setMaxResults)과 함께 사용 시 메모리에서 페이징 처리됩니다.
- 데이터가 많을 경우 비효율적일 수 있으므로 적절히 설계해야 합니다.
2) EntityGraph 활용
Fetch Join을 선언형으로 간단히 설정할 수 있는 방법입니다.
@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
@EntityGraph(attributePaths = {"orders"})
List<Member> findAll();
}
- 장점: 코드 가독성이 높아지고 유지보수가 쉬워집니다.
3) Batch Size 설정
Hibernate의 Batch Size를 활용해 Lazy 로딩 최적화를 적용할 수 있습니다.
엔티티별 설정
@Entity
@BatchSize(size = 10)
public class Member {
// ...
}
글로벌 설정 (application.properties)
spring.jpa.properties.hibernate.default_batch_fetch_size=10
동작 방식
- 기본적으로 Lazy 로딩이지만, 한 번에 10개씩 데이터를 조회합니다.
- 100명의 멤버에 대해 N개의 쿼리가 실행되지 않고, 10번으로 제한됩니다.
실무에서의 N+1 이슈 대응 팁
- 쿼리 로깅 활성화
- JPA가 생성하는 쿼리를 주기적으로 점검하세요.
- Hibernate show_sql이나 p6spy를 활용해 실행 쿼리를 분석하세요.
- 쿼리 최적화에 Fetch Join 남발하지 않기
- 필요 이상의 데이터까지 조회하지 않도록 Fetch Join은 신중히 사용하세요.
- DTO를 사용한 명확한 데이터 설계
- 필요한 데이터만 조회해 성능을 최적화하세요.
- JPQL이나 QueryDSL로 DTO 매핑을 적용하면 효과적입니다.
- 테스트 환경에서 충분한 데이터로 점검
- 데이터가 적을 때는 문제가 없더라도 대량 데이터 환경에서 N+1 이슈가 발생할 수 있으니, 충분히 테스트하세요.
💡 JPA에서 N+1 이슈는 초보 개발자가 쉽게 놓칠 수 있는 함정입니다.
하지만 Fetch Join, EntityGraph, Batch Size 등의 기술을 적절히 활용하면 성능 저하를 효과적으로 방지할 수 있습니다.
쿼리를 설계하는 것도 코드 작성만큼 중요하다는 점을 기억하며, 실행되는 쿼리를 항상 점검하고 최적화하세요!
'Backend > Java' 카테고리의 다른 글
| JDK 23: 자바 핵심 변경점 정리 (0) | 2025.01.15 |
|---|---|
| Jackson 커스텀 어노테이션: @JacksonAnnotationsInside (1) | 2024.12.21 |
| Java의 Reflection 사용법과 주의점 (0) | 2024.11.24 |
| [Java] 빌더 패턴 (1) | 2024.11.23 |
| JPA FetchType.EAGER와 LAZY의 차이 알아보기 (0) | 2024.11.22 |