JPA - N+1 문제란 무엇인가?

StrayCat·2026년 2월 25일

CS지식

목록 보기
10/32

JPA N+1 문제란 무엇이며, 어떻게 해결하는가

들어가며

JPA를 사용하다 보면 반드시 마주치게 되는 유명한 성능 문제가 있습니다.
바로 N+1 문제입니다.
이 문제를 이해하지 못하면, 개발 환경에서는 아무 이상 없어 보이다가 실제 서비스에서 DB가 과부하로 다운되는 상황을 겪을 수 있습니다.


1. N+1 문제란?

N+1 문제란, 1번의 쿼리로 N개의 결과를 가져온 뒤, 연관 엔티티를 조회하기 위해 추가로 N번의 쿼리가 발생하는 현상을 말합니다.

즉, 총 1 + N번의 쿼리가 실행됩니다.

구체적인 시나리오

User 1명은 여러 개의 Food(주문 음식)를 가질 수 있다고 가정합니다. (1:N 관계)

@Entity
public class User {
    @Id @GeneratedValue
    private Long id;
    private String name;

    // 기본 fetch가 LAZY (지연 로딩)
    @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
    private List<Food> foodList = new ArrayList<>();
}

이제 모든 유저를 조회하고, 각 유저가 주문한 음식 목록을 출력해봅니다.

// UserRepository.findAll() 호출
List<User> users = userRepository.findAll(); // 쿼리 1번: SELECT * FROM users

for (User user : users) {
    // user.getFoodList() 호출 시점마다 쿼리 발생
    System.out.println(user.getFoodList().size()); // 유저 수만큼 쿼리 추가 발생
}

실제 발생하는 SQL

-- 1번: 유저 전체 조회
SELECT * FROM users;

-- N번: 각 유저의 음식 목록 조회 (유저가 5명이면 5번 추가 실행)
SELECT * FROM food WHERE user_id = 1;
SELECT * FROM food WHERE user_id = 2;
SELECT * FROM food WHERE user_id = 3;
SELECT * FROM food WHERE user_id = 4;
SELECT * FROM food WHERE user_id = 5;

유저가 100명이면 101번, 1000명이면 1001번의 쿼리가 발생합니다.
이것이 N+1 문제입니다.


2. 왜 발생하는가? (지연 로딩과의 관계)

JPA의 지연 로딩(LAZY)은 연관 엔티티를 실제로 사용하는 시점에 쿼리를 날립니다.
이 자체는 올바른 동작이지만, 반복문 안에서 연관 엔티티를 접근하면 매번 개별 쿼리가 실행되어 N+1 문제가 발생합니다.

즉시 로딩(EAGER)으로 설정한다고 해서 해결되지 않습니다.

EAGER로 설정하면 findAll() 시점에 이미 N번의 쿼리가 발생하거나, 예측하기 어려운 JOIN이 발생합니다. EAGER는 N+1 문제의 해결책이 아닙니다.

// EAGER로 설정해도 N+1은 그대로 발생
@OneToMany(mappedBy = "user", fetch = FetchType.EAGER) // 해결책 아님
private List<Food> foodList = new ArrayList<>();

3. 해결 방법

방법 1. JPQL Fetch Join (가장 기본적인 방법)

Fetch Join은 연관 엔티티를 하나의 JOIN 쿼리로 한꺼번에 가져오는 방식입니다.

// UserRepository.java
public interface UserRepository extends JpaRepository<User, Long> {

    // fetch join으로 food 목록을 한 번에 조회
    @Query("SELECT DISTINCT u FROM User u JOIN FETCH u.foodList")
    List<User> findAllWithFoodList();
}

실행되는 SQL:

-- 쿼리 딱 1번으로 모든 데이터를 가져옴
SELECT DISTINCT u.*, f.*
FROM users u
INNER JOIN food f ON f.user_id = u.id;

DISTINCT를 붙이는 이유는, JOIN 특성상 유저 데이터가 음식 개수만큼 중복 조회되기 때문입니다.


방법 2. @EntityGraph (애너테이션 기반)

@EntityGraph는 Fetch Join을 JPQL 없이 애너테이션으로 선언적으로 처리하는 방법입니다.

public interface UserRepository extends JpaRepository<User, Long> {

    // attributePaths에 함께 로딩할 연관 필드명을 지정
    @EntityGraph(attributePaths = {"foodList"})
    @Query("SELECT DISTINCT u FROM User u")
    List<User> findAllWithFoodList();

    // 메서드 이름 기반 조회에도 적용 가능
    @EntityGraph(attributePaths = {"foodList"})
    List<User> findByName(String name);
}

Fetch Join과 동일하게 JOIN 쿼리 1번으로 처리됩니다.
JPQL을 직접 작성하고 싶지 않을 때 편리하게 사용할 수 있습니다.


방법 3. Batch Size 설정 (IN 쿼리 최적화)

Fetch Join이 항상 최선은 아닙니다. 특히 컬렉션을 여러 개 동시에 Fetch Join하면 오류가 발생합니다.
이런 경우에는 @BatchSize를 사용하여 N번의 쿼리를 IN 절 하나로 묶어 줄입니다.

@Entity
public class User {
    @Id @GeneratedValue
    private Long id;
    private String name;

    // size 만큼을 한 번에 IN 쿼리로 처리
    @BatchSize(size = 100)
    @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
    private List<Food> foodList = new ArrayList<>();
}

또는 application.properties에서 전역으로 설정할 수 있습니다.

# 모든 컬렉션에 일괄 적용 (일반적으로 100~1000 사이 설정)
spring.jpa.properties.hibernate.default_batch_fetch_size=100

실행되는 SQL:

-- N번 → 1번(또는 소수)으로 줄어듦
SELECT * FROM food WHERE user_id IN (1, 2, 3, 4, 5, ...);

방법 4. QueryDSL (복잡한 조건 + Fetch Join)

조건이 복잡하거나 동적 쿼리가 필요한 경우, QueryDSL과 Fetch Join을 함께 사용합니다.
QueryDSL은 타입 안전한 쿼리 빌더로, 2026년 기준으로 Spring Boot 프로젝트에서 널리 사용됩니다.

// QueryDSL을 사용한 Fetch Join 예시
public List<User> findAllWithFoodList() {
    QUser user = QUser.user;
    QFood food = QFood.food;

    return queryFactory
        .selectFrom(user)
        .leftJoin(user.foodList, food).fetchJoin() // fetch join
        .distinct()
        .fetch();
}

참고: QueryDSL 5.x 버전은 Spring Boot 3.x와의 호환성 이슈가 있었으나, com.querydsl:querydsl-jpa:5.0.0:jakarta 의존성을 사용하면 정상적으로 동작합니다.


4. 방법별 비교 정리

방법장점단점적합한 상황
Fetch Join (JPQL)쿼리 1번, 직관적컬렉션 2개 이상 동시 적용 불가연관 컬렉션 1개 조회
@EntityGraph코드 간결, 선언적복잡한 조건에는 한계단순 조회 메서드
@BatchSize컬렉션 여러 개에 적용 가능쿼리가 1번은 아님연관 컬렉션 여러 개
QueryDSL타입 안전, 동적 쿼리 강점설정과 Q클래스 생성 필요복잡한 조건 + Fetch Join

5. 주의: Fetch Join과 페이징은 함께 쓰면 위험합니다

@OneToMany 컬렉션에 Fetch Join을 적용하면서 Pageable(페이징)을 함께 사용하면, Hibernate가 다음과 같은 경고를 출력하며 모든 데이터를 메모리에 올려서 페이징 처리합니다.

HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory

이는 데이터가 많을 경우 OutOfMemoryError를 유발할 수 있는 매우 위험한 동작입니다.

해결 방법: 컬렉션 페이징 시에는 @BatchSize 사용

// 페이징이 필요한 경우, Fetch Join 대신 BatchSize를 적용
@BatchSize(size = 100)
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Food> foodList = new ArrayList<>();

// Repository에서 페이징 처리
Page<User> findAll(Pageable pageable); // Fetch Join 없이 사용

6. 기타

application.properties 전역 BatchSize 설정이 기본값으로 권장

현재 많은 Spring Boot 프로젝트에서 아래 설정을 기본으로 적용합니다. 이 설정 하나만으로도 대부분의 N+1 상황을 무난하게 방어할 수 있습니다.

spring.jpa.properties.hibernate.default_batch_fetch_size=100

Spring Data JPA 3.x + Hibernate 6.x 변경 사항

Spring Boot 3.x 기반의 Hibernate 6.x에서는 HQL(Hibernate Query Language)의 문법이 일부 개선되었습니다. 또한 @BatchSize의 IN 절 처리 방식이 보다 정교해졌습니다.

가능한 사전에 해결하자

서비스 초기에는 데이터 양이 적어 문제가 잘 드러나지 않다가, 데이터가 쌓이면서 N+1 문제가 발목을 잡고, 갑자기 성능 저하가 나타나는 패턴이 반복됩니다.
신규 기능 개발 시에는 반드시 쿼리 로그를 확인하는 습관을 들이는 것이 중요합니다.

# 개발 환경에서는 반드시 SQL 로그를 켜서 쿼리 수를 확인할 것
spring.jpa.properties.hibernate.show_sql=true
spring.jpa.properties.hibernate.format_sql=true

정리

  1. N+1 문제란 1번 조회 결과 N개에 대해 추가로 N번의 쿼리가 발생하는 현상입니다.
  2. 지연 로딩(LAZY) 자체는 좋은 기본 전략이지만, 반복문에서 연관 엔티티를 접근하면 N+1이 발생합니다.
  3. 즉시 로딩(EAGER) 은 해결책이 아닙니다.
  4. 가장 기본적인 해결책은 Fetch Join 또는 @EntityGraph입니다.
  5. 컬렉션이 여러 개이거나 페이징이 필요한 경우 @BatchSize (또는 전역 default_batch_fetch_size)를 사용합니다.
  6. 복잡한 동적 쿼리가 필요하면 QueryDSL + Fetch Join 조합을 고려합니다.
  7. 개발 중에는 항상 SQL 로그를 켜두고 실제 발생하는 쿼리 수를 모니터링하는 습관이 중요합니다.
profile
알면 좋은 것보단 잊어버리기 싫은 것들을 기록합니다.

0개의 댓글