[개발 일기] 2025.04.05 - N+1 문제 (JPA)

2025. 4. 5. 17:19·개발 일기
목차
  1. 💡 개요
  2. 📕 N+1 문제
  3. 🛠️ Fetch Join
  4. 🛠️ Batch Size
  5. 🛠️ EntityGraph

💡 개요

 

오늘은 JPA에서 종종 발생하는 N+1 문제에 대해 정리해 보자.

 

 

 

📕 N+1 문제

 

N+1 문제는 연관관계가 매핑된 엔티티를 조회할 때, 예상보다 많은 쿼리가 실행되는 비효율적인 상황을 말한다.

 

 

이는 주로 ORM 기술을 사용할 때, 연관된 데이터를 지연 로딩(LAZY) 방식으로 가져오면서 발생한다.

 

 

한 명의 사용자가 여러 개의 주문을 한 상황을 예로 들자.

 

@Entity
class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    ...
    
    @OneToMany(mappedby="member")
    private List<Order> orders = new ArrayList<>();
    
    ...
}

 

@Entity
class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    ...
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;
    
    ...
}

 

List<Member> members = memberRepository.findAll();

for (Member member : members) {
    System.out.println("회원: " + member.getName());
    for (Order order : member.getOrders()) {
        System.out.println("  주문: " + order.getItemName());
    }
}

 

 

만약 회원 정보가 10개 저장되어 있다면, 쿼리는 다음과 같이 실행될 것이다.

 

-- 회원 정보 전체 조회
SELECT * FROM member;

-- 조회된 회원 정보 각각에 매핑된 주문 정보 조회
SELECT * FROM orders WHERE member_id = 1;
SELECT * FROM orders WHERE member_id = 2;
SELECT * FROM orders WHERE member_id = 3;
SELECT * FROM orders WHERE member_id = 4;
SELECT * FROM orders WHERE member_id = 5;
SELECT * FROM orders WHERE member_id = 6;
SELECT * FROM orders WHERE member_id = 7;
SELECT * FROM orders WHERE member_id = 8;
SELECT * FROM orders WHERE member_id = 9;
SELECT * FROM orders WHERE member_id = 10;

 

 

위 쿼리대로라면 데이터베이스에 총 11번 접근한다.

 

 

만약 회원 데이터가 1000개라면, 쿼리가 총 1001번 실행된다.

 

 

이렇게 데이터베이스에 접근하는 과정은 많은 지연시간을 발생시킨다.

 

 

어떻게 보면 ORM의 특징이자 장점은 엔티티 객체와 테이블을 직접 매핑하여 따로 쿼리문을 작성하지 않아도 데이터를 관리할 수 있는 것이다.

 

 

하지만 이 과정에서 쿼리 생성을 담당하는 Hibernate는 복잡한 조인 쿼리나 최적화된 조회 방식은 자동으로 적용되지 않는다.

 

 

그렇기 때문에 조인을 포함한 쿼리를 생성하기 위해선 개발자가 따로 설정해줘야 한다.

 

 

 

🛠️ Fetch Join

 

Fetch Join을 사용하면 연관관계가 매핑된 엔티티를 한 번에 조회하기 때문에 전달되는 쿼리의 수가 상당히 줄어든다.

 

@Query("SELECT m FROM Member m JOIN FETCH m.orders")
List<Member> findAllWithOrders();

 

 

위 findAllWithOrders() 메서드를 실행하면 다음과 같은 쿼리가 생성된다.

 

SELECT
    m.id AS m_id,
    m.name AS m_name,
    o.id AS o_id,
    o.item_name AS o_item_name,
    o.member_id AS o_member_id
FROM
    member m
JOIN
    orders o ON m.id = o.member_id

 

 

결과적으로 쿼리는 한 번만 나가고, 모든 데이터가 한 번에 조회된다.

 

 

 

🛠️ Batch Size

 

BatchSize는 Hibernate에게 연관관계가 매핑되어 있는 데이터를 설정해 놓은 수만큼 한 번에 조회할 때 사용한다.

 

 

N+1 문제가 발생하는 것은 지연로딩으로 설정된 엔티티를 하나하나 조회해서 발생한다.

 

 

하지만 @BatchSize(size = N)를 설정하면, Hibernate는 여러 엔티티를 한꺼번에 조회할 수 있도록 최적화된 쿼리를 사용한다.

 

 

즉, 연관된 엔티티를 N개 단위로 IN 쿼리를 만들어 한 번에 조회하게 된다.

 

 

Member 엔티티 입장에서 Order 엔티티를 조회할 때 Batch Size를 사용하는 방법은 다음과 같다.

 

@Entity
public class Member {

    @Id @GeneratedValue
    private Long id;

    private String name;

    @OneToMany(mappedBy = "member")
    @BatchSize(size = 10)
    private List<Order> orders = new ArrayList<>();
}

 

SELECT * FROM member;
SELECT * FROM orders WHERE member_id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

 

 

 

🛠️ EntityGraph

 

EntityGraph는 특정 엔티티를 조회할 때 지연 로딩으로 설정된 연관 엔티티를 즉시 로딩(FETCH) 하도록 지시할 수 있다.

 

 

이 설명만 보면 위에서 언급한 Fetch Join과 동작 방식이 비슷하다고 느껴질 수 있는데, 실제로도 유사하게 동작한다.

 

 

하지만 Fetch Join 방식과 다른 점은 Fetch Join은 JPQL이나 네이티브 쿼리를 직접 작성해야 하지만 EntityGraph는 실제 쿼리를 작성할 필요가 없다.

 

 

Entity Graph 사용 방법은 @EntityGraph(attributePaths = {"조회할 엔티티"}) 을 JPA 메서드 위에 붙인다.

 

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    private String name;

    @OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
    private List<Order> orders;
}

 

public interface MemberRepository extends JpaRepository<Member, Long> {

    @EntityGraph(attributePaths = {"orders"})
    List<Member> findAll(); // orders를 함께 조회 (즉시 로딩)
}

 

SELECT m.*, o.* 
FROM member m
LEFT OUTER JOIN orders o ON m.id = o.member_id

 

 

생성되는 쿼리를 보면 알 수 있듯이, Fetch Join과 매우 유사하다.

'개발 일기' 카테고리의 다른 글

[개발 일기] 2025.04.07 - Offset  (0) 2025.04.07
[개발 일기] 2025.04.06 - 즉시로딩을 피해야 하는 이유  (1) 2025.04.06
[개발 일기] 2025.04.04 - isBlank() vs isEmpty()  (0) 2025.04.04
[개발 일기] 2025.04.03 - @DataJpaTest vs @SpringBootTest  (0) 2025.04.03
[개발 일기] 2025.04.02 - Spring Boot 서버가 작동될 때 발생되는 일  (0) 2025.04.02
  1. 💡 개요
  2. 📕 N+1 문제
  3. 🛠️ Fetch Join
  4. 🛠️ Batch Size
  5. 🛠️ EntityGraph
'개발 일기' 카테고리의 다른 글
  • [개발 일기] 2025.04.07 - Offset
  • [개발 일기] 2025.04.06 - 즉시로딩을 피해야 하는 이유
  • [개발 일기] 2025.04.04 - isBlank() vs isEmpty()
  • [개발 일기] 2025.04.03 - @DataJpaTest vs @SpringBootTest
오도형석
오도형석
  • 오도형석
    형석이의 성장일기
    오도형석
  • 전체
    오늘
    어제
    • 분류 전체보기 N
      • MSA 모니터링 서비스
        • DB
      • 스파르타 코딩클럽
        • SQL
        • Spring
      • 백엔드
        • Internet
        • Java
        • DB
      • 캡스톤
        • Django
        • 자연어처리
      • Spring
        • JPA
        • MSA
      • ETC
        • ERROR
      • 개발 일기 N
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 인기 글

  • 태그

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
오도형석
[개발 일기] 2025.04.05 - N+1 문제 (JPA)
상단으로

티스토리툴바

단축키

내 블로그

내 블로그 - 관리자 홈 전환
Q
Q
새 글 쓰기
W
W

블로그 게시글

글 수정 (권한 있는 경우)
E
E
댓글 영역으로 이동
C
C

모든 영역

이 페이지의 URL 복사
S
S
맨 위로 이동
T
T
티스토리 홈 이동
H
H
단축키 안내
Shift + /
⇧ + /

* 단축키는 한글/영문 대소문자로 이용 가능하며, 티스토리 기본 도메인에서만 동작합니다.