N+1 문제(N+1 Problem)는 ORM(Object Relational Mapping) 환경에서 자주 발생하는 비효율적인 쿼리 문제이다.
하나의 조회 쿼리(1번)로 시작했지만, 그 결과로 가져온 각 엔티티마다 추가적인 N개의 쿼리가 발생하면서 총 N+1번의 SQL이 실행되는 현상을 말한다.
즉, “한 번에 가져올 수 있는 데이터를 여러 번 나눠서 불필요하게 조회”하는 상황이다.
다음과 같은 두 엔티티 관계를 가정하자.
@Entity
public class Member {
@Id
@GeneratedValue
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
private Team team;
}
@Entity
public class Team {
@Id
@GeneratedValue
private Long id;
private String name;
}
다음 코드를 실행한다고 가정하자.
List<Member> members = em.createQuery("select m from Member m", Member.class).getResultList();
for (Member member : members) {
System.out.println(member.getTeam().getName());
}
이 경우 실행되는 쿼리는 다음과 같다.
// (1) Member 목록 조회
select * from member;
// (2) 각 Member의 Team 조회 (회원 수만큼 반복)
select * from team where id = 1;
select * from team where id = 2;
select * from team where id = 3;
...
결과적으로 1 + N번의 쿼리가 발생한다.
회원이 100명이라면 총 101개의 SQL이 실행되는 셈이다.
JPA의 연관관계는 기본적으로 지연 로딩(LAZY Loading)으로 설정되어 있다.
즉, 연관된 엔티티를 처음부터 함께 가져오지 않고, 해당 필드가 실제로 접근될 때 추가 쿼리를 실행한다.
이는 성능 최적화를 위한 설계이지만, 다음과 같은 상황에서는 오히려 성능 저하를 초래한다.
JPQL에서 fetch join을 이용하면, 연관된 엔티티를 한 번의 쿼리로 함께 조회할 수 있다.
@Query("select m from Member m join fetch m.team")
List<Member> findAllWithTeam();
실행 쿼리:
select m.*, t.*
from member m
join team t on m.team_id = t.id;
이 방식은 가장 일반적이면서도 효과적인 해결책이다.
단, fetch join은 페이징(Pageable)과 함께 사용할 경우 주의가 필요하다.
(OneToMany 조인 시 중복된 row가 발생할 수 있음)
Spring Data JPA에서는 @EntityGraph를 사용하여
JPQL 작성 없이도 fetch join과 같은 효과를 낼 수 있다.
@EntityGraph(attributePaths = {"team"})
@Query("select m from Member m")
List<Member> findAllEntityGraph();
결과적으로 동일하게 join fetch가 적용된 SQL이 실행된다.
EntityGraph의 장점은 재사용성과 선언적 구성이 용이하다는 것이다.
@BatchSize를 설정하면, 지연 로딩 시 N개의 쿼리가 아니라 한 번에 묶어서 가져오도록 할 수 있다.
이는 N+1 문제를 완전히 제거하지는 않지만, 쿼리 호출 횟수를 크게 줄이는 완화책으로 활용할 수 있다.
@Entity
@BatchSize(size = 100)
public class Member { ... }
또는 전역 설정으로 적용할 수도 있다.
spring:
jpa:
properties:
hibernate.default_batch_fetch_size: 100
이 설정 시, 100명의 Member가 서로 다른 Team을 참조하더라도
최대 2~3번의 쿼리로 모든 Team 정보를 가져올 수 있다.
@ManyToOne(fetch = FetchType.EAGER)로 설정하면 N+1 문제를 방지할 수 있을 것 같지만, 이는 권장되지 않는다.
이유는 다음과 같다.
따라서, 즉시 로딩보다는 지연 로딩 + fetch join / EntityGraph 조합이 이상적이다.
N+1 문제는 대개 코드에서는 눈에 잘 띄지 않는다.
따라서 다음과 같은 방법으로 탐지한다.
spring:
jpa:
properties:
hibernate:
show_sql: true
format_sql: true
use_sql_comments: true
logging:
level:
org.hibernate.SQL: debug
org.hibernate.type.descriptor.sql.BasicBinder: trace| 구분 | 내용 |
|---|---|
| 문제 정의 | 한 번의 조회 쿼리로 인해 연관된 데이터가 N번 추가 조회되는 현상 |
| 원인 | 지연 로딩(LAZY)으로 인한 개별 쿼리 실행 |
| 대표 해결법 | Fetch Join, EntityGraph, BatchSize |
| 잘못된 해결법 | EAGER 로딩으로 강제 조인 |
| 탐지 방법 | Hibernate SQL 로그 분석 |
JPA의 N+1 문제는 ORM 사용 시 거의 필연적으로 마주하게 되는 성능 이슈다.
단순히 발생 원인을 이해하는 것에서 나아가,
도메인 구조에 따라 최적의 로딩 전략을 설계하는 것이 중요하다.
fetch join을 적극적으로 활용하고,BatchSize나 EntityGraph로 접근을 조절하며,