Study/SpringBoot

SpringBoot + JPA: N+1 문제 발생 원인과 해결 방법

kanado 2025. 3. 22. 17:18

N+1 문제 정의

한 번의 조회로 주 엔티티 “N개”를 가져왔을 때, 각각의 연관 엔티티를 다시 조회하는 쿼리가 “N번” 추가 실행되어 총 (1 + N)번의 쿼리가 발생하는 현상이다.

public void printTeamMembers(EntityManager em) {
    List<Team> teams = em.createQuery("SELECT t FROM Team t", Team.class)
                          .getResultList(); // 1️⃣ 1번 쿼리 (Team 전체 조회)

    for (Team team : teams) {
        for (Member member : team.getMembers()) {
            System.out.println("Member:" + member.getUsername()); // 2️⃣ N(팀수)번 쿼리 발생
        }
    }
}

⬇️

for (Member member : team.getMembers()) {
    System.out.println("Member:" + member.getUsername());
}
// 여기서 다음과 같은 쿼리 발생: SELECT * FROM member WHERE team_id = ?

👉 여기서 무슨 일이 벌어지냐면?

  1. SELECT * FROM team → 팀 전체 조회 (1번)
  2. 각 팀마다 SELECT * FROM member WHERE team_id = ? 쿼리 발생 (팀 수 만큼 N번)
    • 팀이 5개면 → 총 6번 쿼리 실행 (1 + 5)


N+1 문제 발생 원인

N+1 문제의 근본적인 발생 원인은 바로 객체지향 패러다임과 관계형 데이터베이스(RDB)의 패러다임 차이, 즉 패러다임의 괴리(Mismatch) 때문이다.

이런 차이 때문에, 객체는 단순히 getMembers()처럼 메서드만 호출하면 끝이지만, RDB는 그때마다 추가 쿼리를 날려야 하는 상황이 발생해요. 이게 바로 N+1의 본질적인 원인입니다.

그래서 이 문제를 해결해줄 수 있는 방법으로는 페치 조인(Fetch Join), 배치 사이즈(Batch Size), 엔티티 그래프(Entity Graph), 쿼리 튜닝 / Native Query 4가지가 있다.


1️⃣ Fetch Join

JPQL에서 JOIN FETCH를 사용해 연관 엔티티를 함께 조회한다.

SELECT o FROM Order o
JOIN FETCH o.member
JOIN FETCH o.orderItems
WHERE o.ordersStatus = 'completed'
  •  장점
    • 쿼리 하나로 주 엔티티 + 연관 엔티티를 모두 로드
    • N+1 방지
  • 단점
    • FetchType 설정이 무의미, Lazy 사용 불가
    • 많은 조인을 남발하면 쿼리가 복잡해지고, 테이블 간 중복 데이터가 발생할 수 있음
    • ⭐️ JPQL 페치 조인은 여러 컬렉션 동시 페치는 불가능 등 제약 존재 → MultipleBagFetchException 발생한다 왜냐하면 하나의 쿼리에서 여러 컬렉션 조인할때 카테시안 곱(Cartesian Product) 발생하기 때문이다. 카테시안 곱은 두 개의 컬렉션을 동시에 JOIN 하면, 각 조인된 row가 조합되기 때문에 실제보다 더 많은 row가 결과로 나오게 되어 중복 데이터 발생한다.
    👉 fetch join의 여러 컬렉션 동시 페치는 불가능한 단점을 해결하는 방법은 1개의 컬렉션은 fetch join으로 즉시 로딩하고, 나머지 컬렉션은 배치 사이즈 기반 지연 로딩을 통해 IN 쿼리로 한 번에 로딩한다.

    1차 쿼리 – Order와 Member만 fetch join
    SELECT o FROM Order o
    JOIN FETCH o.member
    WHERE o.id IN (1, 2, 3);
    
    • 한 번에 주문 + 회원 정보까지 조회함
    • 컬렉션은 하나(member)만 join → 안전
    • OrderItem은 아직 조회 안 됨 (LAZY)

       2차 쿼리 – OrderItem은 지연 로딩 되지만 배치 사이즈로 IN 쿼리

@OneToMany(mappedBy = "order")
@BatchSize(size = 100)
private List<OrderItem> orderItems;

 

JPA가 getOrderItems()를 여러 주문에 대해 호출하는 순간, 자동으로 다음 쿼리를 실행

SELECT i FROM OrderItem i
WHERE i.order.id IN (1, 2, 3);

 

2️⃣ Batch Size

지연 로딩 시 한 번에 로딩할 연관 엔티티 수를 제한한다.


@BatchSize(size = 100)

-- 예시: OrderItem을 100개씩 IN 절로 조회
SELECT * FROM order_item WHERE order_id IN (?, ?, ..., ?);

여기서 배치 사이즈 = IN 절 안에 들어가는 최대 파라미터 개수이다.

상황 1: 현재 영속성 컨텍스트에 Order가 3개뿐임 Hibernate는 아래처럼 3개만 묶어서 IN 쿼리 실행

SELECT * FROM order_item
WHERE order_id IN (1, 2, 3);

상황 2: 현재 Order가 250개 있음

Hibernate는 이를 100개 단위로 잘라서 3번에 나눠서 쿼리 보낸다.

-- 첫 번째 배치
SELECT * FROM order_item
WHERE order_id IN (1, 2, ..., 100);

-- 두 번째 배치
SELECT * FROM order_item
WHERE order_id IN (101, ..., 200);

-- 세 번째 배치
SELECT * FROM order_item
WHERE order_id IN (201, ..., 250);
  • 장점
    • Lazy 로딩을 유지하면서도, 1개씩(N번) 불러오는 대신 몇 차례에 걸쳐 묶음 로딩
    • 쿼리 수를 N → N/100으로 감소
  • 주의
    • 너무 큰 배치 사이즈 → 쿼리 성능 악화
      • DB 제한 초과 → max_allowed_packet = 16MB (오류 발생)
      • 쿼리 너무 길어져 파싱 속도 느려짐
      • DB 성능 저하 → DB I/O, 메모리 부하 증가
      • Hibernate 내부 메모리 사용량 증가, 한 번에 너무 많은 엔티티를 로딩하면 → 메모리 사용량 ↑
    • 너무 작은 배치 사이즈 → N+1 문제 근본 해소 어려움


3️⃣ FetchMode.SUBSELECT

Hibernate에서 제공하는 또 다른 지연 로딩 최적화 기법, 다수의 부모 엔티티에 대한 연관 컬렉션을 한 번의 Subselect로 가져온다/

@OneToMany(mappedBy = "order")
@Fetch(FetchMode.SUBSELECT)
private List<OrderItem> orderItems;

첫 번째로 메인 엔티티(Order)를 조회한 뒤, 해당 Order의 id들을 모아서 WHERE order_id IN(...) 서브쿼리를 실행,

기존 Batch Size 전략과 유사하지만, Hibernate가 “Subselect”라는 별도 구문을 사용한다.

  • 장점
    • 일대다의 N+1을 Subselect 한 번으로 해결
    • Batch Size 설정 없이도 묶음 로딩 가능
  • 단점
    • Subselect로 인한 SQL 복잡성. 대규모 데이터에서 성능이 꼭 좋다는 보장 없음
  • 실무 주의
    • 테스트 DB로 H2 등을 사용할 경우, 실제 MySQL/Oracle과 쿼리 계획이 다를 수 있음. EXPLAIN 분석 필수
    • Batch Size, FetchMode.SUBSELECT 모두 “너무 큰 IN 절/서브쿼리”가 성능을 떨어뜨릴 수 있으므로, 실제 데이터 볼륨에서 검증


⭐️ 결론

페치 조인은 객체 그래프를 탐색할 때, 배치 사이즈는 단순히 데이터를 출력만 할 때 사용하는것이 좋다.

  1. 페치 조인은 객체 그래프를 탐색할 때 사용
    • Order → Member → Address 처럼객체 간의 연관관계를 타고 들어가야 하는 경우
    • 즉, 로직 상으로 연관된 엔티티를 즉시 접근해야 하는 경우엔 fetch join 사용
  2. 배치 사이즈는 단순히 데이터를 출력만 할 때 사용
    • 단순히 데이터를 화면에 보여주는 용도 (예: 리스트 출력)라면
    • fetch join보다 가볍고 유연한 @BatchSize + 지연 로딩 방식이 더 적합

 

끝.