JPA를 사용하다 보면 반드시 마주치게 되는 유명한 성능 문제가 있습니다.
바로 N+1 문제입니다.
이 문제를 이해하지 못하면, 개발 환경에서는 아무 이상 없어 보이다가 실제 서비스에서 DB가 과부하로 다운되는 상황을 겪을 수 있습니다.
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()); // 유저 수만큼 쿼리 추가 발생
}
-- 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 문제입니다.
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<>();
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 특성상 유저 데이터가 음식 개수만큼 중복 조회되기 때문입니다.
@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을 직접 작성하고 싶지 않을 때 편리하게 사용할 수 있습니다.
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, ...);
조건이 복잡하거나 동적 쿼리가 필요한 경우, 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의존성을 사용하면 정상적으로 동작합니다.
| 방법 | 장점 | 단점 | 적합한 상황 |
|---|---|---|---|
| Fetch Join (JPQL) | 쿼리 1번, 직관적 | 컬렉션 2개 이상 동시 적용 불가 | 연관 컬렉션 1개 조회 |
| @EntityGraph | 코드 간결, 선언적 | 복잡한 조건에는 한계 | 단순 조회 메서드 |
| @BatchSize | 컬렉션 여러 개에 적용 가능 | 쿼리가 1번은 아님 | 연관 컬렉션 여러 개 |
| QueryDSL | 타입 안전, 동적 쿼리 강점 | 설정과 Q클래스 생성 필요 | 복잡한 조건 + Fetch Join |
@OneToMany 컬렉션에 Fetch Join을 적용하면서 Pageable(페이징)을 함께 사용하면, Hibernate가 다음과 같은 경고를 출력하며 모든 데이터를 메모리에 올려서 페이징 처리합니다.
HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
이는 데이터가 많을 경우 OutOfMemoryError를 유발할 수 있는 매우 위험한 동작입니다.
// 페이징이 필요한 경우, 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 없이 사용
현재 많은 Spring Boot 프로젝트에서 아래 설정을 기본으로 적용합니다. 이 설정 하나만으로도 대부분의 N+1 상황을 무난하게 방어할 수 있습니다.
spring.jpa.properties.hibernate.default_batch_fetch_size=100
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
default_batch_fetch_size)를 사용합니다.