Spring Boot

Spring Boot JPA N+1 문제 원인과 해결 전략 (fetch join / EntityGraph / 배치 사이즈)

IT Lab 2026. 3. 16. 21:28

Spring Boot 3.x에서 JPA N+1 문제가 발생하는 조건을 정리하고, fetch join과 @EntityGraph로 해결하는 방법, 그리고 batch size로 보완하는 실무 전략을 예제 코드로 설명합니다.

도입 (문제 상황)

목록 API 하나 만들었을 뿐인데, 로컬에서는 빠르다가 데이터가 조금만 늘면 갑자기 DB 쿼리가 수십~수백 번 나가는 경험을 하실 때가 있습니다. 로그를 보면 “주 쿼리 1번 + 연관 엔티티 조회 쿼리 N번” 패턴이 반복되는데, 이게 대표적인 N+1 문제입니다.

핵심 개념: N+1이 왜 생기고, 무엇을 선택해야 하나요?

N+1은 “연관 로딩 전략”과 “조회 방식”이 맞물릴 때 발생합니다. 보통은 @ManyToOne(fetch = LAZY) / @OneToMany(fetch = LAZY)로 지연 로딩을 해두고(이 자체는 권장), 목록을 가져온 뒤 화면/DTO 구성 과정에서 연관 필드를 접근하면서 추가 쿼리가 터집니다.

예를 들어 Order 목록을 20개 조회한 뒤, 각 주문의 member.getName()을 접근하면:

  • 주문 목록 1번 쿼리
  • 각 주문의 member 로딩 20번 쿼리(영속성 컨텍스트에 없는 경우) → 총 21번이 됩니다.

여기서 중요한 포인트는 “LAZY가 나쁘다”가 아니라, LAZY를 유지하되 ‘필요한 화면’에서만 계획적으로 한 번에 가져오도록 조회 쿼리를 설계하는 것입니다. 해결책은 크게 3가지 축으로 정리할 수 있습니다.

전략 언제 쓰면 좋은가요? 장점 주의점
fetch join 특정 화면/기능에서 연관 데이터를 확실히 같이 써야 할 때 쿼리 1번으로 끝내기 쉬움, 명시적 컬렉션 fetch join은 페이징이 위험(메모리 페이징/중복 row)
@EntityGraph “기본 쿼리는 유지”하면서 연관 로딩만 옵션으로 붙이고 싶을 때 Repository 메서드에 선언적으로 적용 내부적으로 fetch join과 유사, 컬렉션 + 페이징 이슈는 동일
Batch Size (default_batch_fetch_size / @BatchSize) fetch join이 곤란(페이징, 다양한 화면)하지만 N+1을 줄이고 싶을 때 N+1을 1+N/batch로 완화, 페이징과 공존 “완전 제거”가 아니라 “완화”, IN 쿼리 크기/DB 부하 고려

요청 처리 관점에서 N+1이 터지는 흐름

N+1은 보통 “조회 시점”이 아니라 “연관 필드 접근 시점”에 발생합니다. 그래서 컨트롤러/서비스에서 DTO 변환을 하는 순간 갑자기 쿼리가 늘어납니다.

flowchart LR
  A["Controller"] --> B["Service: findOrders()"]
  B --> C["Repository: select orders (1 query)"]
  A --> D["DTO mapping: order.member.name access"]
  D --> E["Lazy loading: select member (N queries)"]

위 다이어그램은 “조회 1번 후 DTO 매핑에서 지연 로딩이 연쇄적으로 발생”하는 전형적인 N+1 흐름입니다.

N+1 문제 흐름을 보여주는 간단한 요청-조회-지연로딩 구조 다이어그램

코드 예제: fetch join / @EntityGraph / 배치 사이즈까지 한 번에 실행해보기

아래 예제는 Spring Boot 3.x + Java 17 + Hibernate(JPA) 기준입니다. H2로 실행 가능하게 구성했고, Order -> Member (ManyToOne) / Order -> OrderItem (OneToMany) 관계에서 N+1을 재현한 뒤 해결합니다.

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.3.2'
    id 'io.spring.dependency-management' version '1.1.6'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}

repositories { mavenCentral() }

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    runtimeOnly 'com.h2database:h2'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

application.yml

spring:
  datasource:
    url: jdbc:h2:mem:nplus1;MODE=MySQL;DB_CLOSE_DELAY=-1
    driver-class-name: org.h2.Driver
    username: sa
    password:

  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        format_sql: true
        highlight_sql: true
        show_sql: true
        # 배치 사이즈 전략(전역): 지연 로딩 컬렉션/프록시를 IN 쿼리로 묶어서 가져옵니다.
        default_batch_fetch_size: 100

엔티티

package com.example.nplus1.domain;

import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "members")
public class Member {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    protected Member() {}
    public Member(String name) { this.name = name; }

    public Long getId() { return id; }
    public String getName() { return name; }
}
package com.example.nplus1.domain;

import jakarta.persistence.*;

@Entity
@Table(name = "orders")
public class Order {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // 실무 기본값: LAZY
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

    protected Order() {}
    public Order(Member member) { this.member = member; }

    public Long getId() { return id; }
    public Member getMember() { return member; }
}
package com.example.nplus1.domain;

import jakarta.persistence.*;

@Entity
@Table(name = "order_items")
public class OrderItem {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String itemName;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id")
    private Order order;

    protected OrderItem() {}
    public OrderItem(Order order, String itemName) {
        this.order = order;
        this.itemName = itemName;
    }

    public Long getId() { return id; }
    public String getItemName() { return itemName; }
}

예제는 핵심을 위해 OrderItem 컬렉션 매핑을 생략했습니다(컬렉션 fetch join 페이징 이슈를 설명하기 위함). 실무에서는 Order@OneToMany(mappedBy="order")가 있을 가능성이 높고, 그 경우 N+1이 더 자주 터집니다.

Repository: 기본 조회 / fetch join / EntityGraph

package com.example.nplus1.repository;

import com.example.nplus1.domain.Order;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import java.util.List;

public interface OrderRepository extends JpaRepository<Order, Long> {

    // 1) 기본 조회: 이후 member 접근 시 N+1 발생 가능
    List<Order> findTop20ByOrderByIdAsc();

    // 2) fetch join: 필요한 연관을 명시적으로 한 번에 로딩
    @Query("select o from Order o join fetch o.member order by o.id asc")
    List<Order> findTop20WithMemberFetchJoin();

    // 3) EntityGraph: 선언적으로 fetch 옵션 부여(내부적으로 fetch join과 유사)
    @EntityGraph(attributePaths = "member")
    List<Order> findTop20ByOrderByIdAscWithGraph();
}

DTO + Service

package com.example.nplus1.web;

public record OrderResponse(Long orderId, String memberName) { }
package com.example.nplus1.service;

import com.example.nplus1.repository.OrderRepository;
import com.example.nplus1.web.OrderResponse;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@Transactional(readOnly = true)
public class OrderQueryService {
    private final OrderRepository orderRepository;

    public OrderQueryService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    public List<OrderResponse> nPlusOneCase() {
        return orderRepository.findTop20ByOrderByIdAsc()
                .stream()
                // 여기서 member 접근 시점에 추가 쿼리가 발생할 수 있습니다.
                .map(o -> new OrderResponse(o.getId(), o.getMember().getName()))
                .toList();
    }

    public List<OrderResponse> fetchJoinCase() {
        return orderRepository.findTop20WithMemberFetchJoin()
                .stream()
                .map(o -> new OrderResponse(o.getId(), o.getMember().getName()))
                .toList();
    }

    public List<OrderResponse> entityGraphCase() {
        return orderRepository.findTop20ByOrderByIdAscWithGraph()
                .stream()
                .map(o -> new OrderResponse(o.getId(), o.getMember().getName()))
                .toList();
    }
}

Controller

package com.example.nplus1.web;

import com.example.nplus1.service.OrderQueryService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
public class OrderController {
    private final OrderQueryService service;

    public OrderController(OrderQueryService service) {
        this.service = service;
    }

    @GetMapping("/orders/n-plus-one")
    public List<OrderResponse> nPlusOne() {
        return service.nPlusOneCase();
    }

    @GetMapping("/orders/fetch-join")
    public List<OrderResponse> fetchJoin() {
        return service.fetchJoinCase();
    }

    @GetMapping("/orders/entity-graph")
    public List<OrderResponse> entityGraph() {
        return service.entityGraphCase();
    }
}

초기 데이터 로더

package com.example.nplus1;

import com.example.nplus1.domain.Member;
import com.example.nplus1.domain.Order;
import jakarta.persistence.EntityManager;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.annotation.Transactional;

@Configuration
public class DataInit {

    @Bean
    CommandLineRunner init(EntityManager em) {
        return args -> initData(em);
    }

    @Transactional
    void initData(EntityManager em) {
        for (int i = 1; i <= 20; i++) {
            Member m = new Member("member-" + i);
            em.persist(m);

            Order o = new Order(m);
            em.persist(o);
        }
        em.flush();
        em.clear();
    }
}

실행 후:

  • /orders/n-plus-one 호출: orders 1번 + members 최대 20번(상황에 따라) 로그가 찍힐 수 있습니다.
  • /orders/fetch-join, /orders/entity-graph 호출: 대체로 1번 쿼리로 해결됩니다.

배치 사이즈 전략은 어디에 효과가 있나요?

위 예제처럼 ManyToOne만 있는 경우에도 배치 로딩이 동작할 수 있지만, 실무에서 더 체감되는 구간은 컬렉션(OneToMany) 지연 로딩입니다. 예를 들어 주문 20개를 조회한 뒤 각 주문의 orderItems.size()를 접근하면 원래는 1 + 20 쿼리인데, default_batch_fetch_size=100이면 대략 1 + 1(또는 몇 번)로 줄어듭니다.
즉, 배치 사이즈는 “N+1을 없애는 칼”이라기보다 “N을 뭉쳐서 덜 아프게 만드는 진통제”에 가깝습니다.

[IMAGE_PROMPT] 위치: 코드 예제 섹션의 배치 사이즈 설명 문단 아래 alt: default_batch_fetch_size로 N개의 지연 로딩 쿼리가 IN 쿼리 몇 번으로 합쳐지는 그림 prompt: clean minimal tech blog style white background infographic showing N+1 queries collapsing into fewer batched IN queries using default_batch_fetch_size, simple database icon and query bubbles, minimal text style: infographic [/IMAGE_PROMPT]

실무 팁

💡 실무에서는: fetch join과 페이징은 특히 조심해 보세요
컬렉션(OneToMany)에 fetch join을 걸고 Pageable을 적용하면, SQL row가 뻥튀기되어 DB 페이징이 깨지거나(중복 row) Hibernate가 메모리에서 페이징하려고 시도할 수 있습니다. 목록 + 페이징이 필요하면 보통은

  1. 루트 엔티티만 페이징 조회 → 2) 필요한 연관은 배치 로딩으로 완화
    같은 2단계 접근이 안전합니다.

💡 실무에서는: “화면/유스케이스 단위”로 로딩 전략을 고르세요
엔티티의 fetch 타입을 EAGER로 바꿔서 응급처치하는 경우가 있는데, 이는 다른 화면에서 불필요한 조인/쿼리를 유발해 더 큰 성능 문제로 돌아오는 일이 많습니다(공식적으로도 EAGER 남용은 비추천입니다).
대신 Repository 레벨에서

  • 이 화면은 fetch join
  • 저 API는 @EntityGraph
  • 페이징 목록은 batch size
    처럼 조회 메서드 단위로 의도를 드러내는 방식이 유지보수에 유리합니다.

핵심 요약: N+1은 “조회 후 DTO/뷰 구성 중 지연 로딩 접근”에서 터지는 경우가 가장 흔합니다.
fetch join과 @EntityGraph는 “필요한 연관을 한 번에” 가져오는 1차 해결책이고, 배치 사이즈는 페이징/다양한 화면에서 N+1을 완화하는 현실적인 보완책입니다.
EAGER로 덮기보다 “유스케이스별 조회 전략”으로 제어해 보세요.

다음 글: #18 페이징과 정렬 실전(성능 함정 포함)