자바 백엔드 진영에서 스프링 부트(Spring Boot)와 JPA(Java Persistence API)는 사실상 표준 기술 스택으로 자리를 잡았습니다. JPA는 복잡한 SQL을 직접 작성하지 않고도 객체 지향적으로 데이터를 다룰 수 있게 해주는 혁신적인 도구입니다.
하지만 JPA를 사용해 프로젝트를 진행하다 보면, 예상치 못하게 애플리케이션 성능이 심각하게 저하되는 현상을 마주하곤 합니다. 그 중심에는 실무와 면접을 막론하고 가장 많이 언급되는 ‘N+1 문제’가 있습니다.
이번 글에서는 JPA N+1 문제가 발생하는 근본적인 원인을 살펴보고, 이를 실무에서 어떻게 우아하게 해결할 수 있는지 완벽 가이드를 제시해 드리겠습니다.
1. JPA N+1 문제란 무엇인가?
N+1 문제란 요청한 데이터 1건(1)을 조회하기 위해, 연관된 데이터 조회를 위한 추가 쿼리가 N번(N) 더 실행되어 총 N+1번의 쿼리가 나가는 현상을 말합니다.
데이터가 몇 건 없을 때는 체감되지 않지만, 데이터가 수만 건으로 늘어나면 단 한 번의 페이지 요청에 수만 개의 SQL 쿼리가 데이터베이스(DB)로 날아가게 됩니다. 이는 DB 서버에 엄청난 과부하를 주고, 결국 시스템 전체가 마비되는 네트워크 병목 현상의 주범이 됩니다.
2. N+1 문제가 발생하는 원인과 시나리오
N+1 문제는 JPA의 작동 방식과 SQL의 격차 때문에 발생합니다. 객체 간의 연관 관계를 맺은 간단한 엔티티 코드로 상황을 가정해 보겠습니다.
2-1. 가상 시나리오 엔티티 코드
게시판 서비스를 만들며 ‘게시글(Post)’과 ‘댓글(Comment)’이 일대다(1:N) 관계를 맺고 있다고 가정해 봅시다.
@Entity
public class Post {
@Id @GeneratedValue
private Long id;
private String title;
@OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
private List<Comment> comments = new ArrayList<>();
}
@Entity
public class Comment {
@Id @GeneratedValue
private Long id;
private String content;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id")
private Post post;
}
2-2. 쿼리가 폭발하는 원인
많은 개발자가 “연관 관계를 지연 로딩(FetchType.LAZY)으로 설정하면 N+1이 발생하지 않는다”고 오해합니다. 하지만 지연 로딩은 문제를 우회하거나 발생 시점을 뒤로 미룰 뿐, 근본적인 해결책이 아닙니다.
만약 우리가 모든 게시글을 조회하는 메서드를 호출하면 어떻게 될까요?
List<Post> posts = postRepository.findAll();
// 1. 모든 게시글을 가져오는 쿼리 1번 실행 (1)
for (Post post : posts) {
// 2. 루프를 돌며 각 게시글의 댓글에 접근 (지연 로딩 프록시 초기화)
System.out.println(post.getComments().size());
// 3. 각 게시글마다 해당 댓글을 조회하는 쿼리가 각각 실행됨 (N)
}
JPA findAll()을 실행할 때 JPA는 연관 관계를 고려하지 않고 오직 select * from post라는 단 한 줄의 SQL만 생성해 실행합니다. 그 후 가져온 게시글이 10개라면, 루프를 돌며 각 게시글의 댓글을 읽을 때마다 DB에 추가 쿼리를 10번 던지게 됩니다. 이것이 바로 1 + 10 = N+1 문제의 실체입니다.
3. 실무 필수: N+1 문제를 해결하는 3가지 방법
JPA에서 N+1 문제를 해결하는 핵심 아이디어는 “따로따로 조회하지 말고, 처음부터 한 번에(Join) 묶어서 가져오자”입니다.
3-1. 페치 조인 (Fetch Join) – 가장 보편적인 해결책
JPQL에서 제공하는 fetch join 키워드를 사용하면, SQL의 INNER JOIN 문법을 활용해 연관된 엔티티를 한 번의 쿼리로 묶어서 힙 메모리에 적재합니다.
public interface PostRepository extends JpaRepository<Post, Long> {
@Query("select p from Post p join fetch p.comments")
List<Post> findAllWithComments();
}
- 효과: 쿼리가 단 1번만 실행되며
Post와Comment데이터를 전부 가져옵니다. - 단점 및 주의점: * JPA가 제공하는 페이지네이션(Paging) 기능을 일대다 관계에서 사용할 때
fetch join을 쓰면 메모리 과부하 경고(HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!)가 발생합니다. (DB가 아닌 WAS 메모리에서 페이징을 처리하므로 매우 위험)- 둘 이상의 컬렉션을 동시에 페치 조인하면 데이터가 기하급수적으로 늘어나는 카테시안 곱(Cartesian Product) 현상으로 인해
MultipleBagFetchException에러가 발생합니다.
- 둘 이상의 컬렉션을 동시에 페치 조인하면 데이터가 기하급수적으로 늘어나는 카테시안 곱(Cartesian Product) 현상으로 인해
3-2. @EntityGraph 어노테이션 활용
무거운 JPQL 문법 대신 스프링 데이터 JPA가 제공하는 어노테이션을 통해 간결하게 페치 조인을 구현하는 방법입니다.
public interface PostRepository extends JpaRepository<Post, Long> {
@EntityGraph(attributePaths = {"comments"})
List<Post> findAll();
}
- 원리: 내부적으로는
fetch join과 동일하게 작동하지만, 메서드 이름을 그대로 유지하면서 연관 관계만 쏙 골라 한 번에 가져올 수 있어 편리합니다. 기본적으로LEFT OUTER JOIN을 사용합니다.
3-3. Batch Size 설정 (default_batch_fetch_size) – 페이징 문제의 구원투수
일대다 관계에서 페이징 처리를 해야 하거나, 카테시안 곱 에디가 발생하는 상황이라면 Batch Size(배치 사이즈) 설정이 가장 훌륭한 대안입니다. 애플리케이션 전역 설정(application.yml)에 한 줄만 추가하면 됩니다.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100 # 보통 100 ~ 1000 사이 설정
- 원리: 연관된 데이터를 조회할 때 낱개로 N번 던지는 대신, 지정한 사이즈만큼
IN절을 사용하여 한 번에 묶어서 쿼리를 날립니다. - 결과: 만약 데이터가 100건이라면 N+1번 나가던 쿼리가 1 + 1 = 단 2번의 쿼리로 압축됩니다. 페이징 기능도 DB 레벨에서 안전하게 작동하므로 실무 필수 옵션으로 꼽힙니다.
4. 핵심 요약: 상황별 N+1 해결 전략 선택 기준
| 상황 | 추천 해결 기법 | 비고 |
| 단순 조회 및 일대일/다대일 관계 | Fetch Join 또는 @EntityGraph | 가장 깔끔하고 성능이 우수한 1방 쿼리 가능 |
| 일대다 관계 + 페이징(Paging) 필요 | default_batch_fetch_size 설정 | 메모리 초과 에러 방지를 위한 유일한 표준 선택지 |
| 2개 이상의 컬렉션 동시 조회 | default_batch_fetch_size 설정 | MultipleBagFetchException 방지 |
5. 결론
JPA의 N+1 문제는 기술의 결함이 아니라, 객체 지향 패러다임과 관계형 데이터베이스 간의 간극에서 발생하는 자연스러운 현상입니다.
실무 백엔드 시스템을 구축할 때는 기본적으로 연관 관계를 지연 로딩(LAZY)으로 꽁꽁 묶어두되, N+1 쿼리 폭발이 예상되는 지점마다 Fetch Join과 Batch Size 설정을 적절히 버무려 아키텍처를 방어해야 합니다. 이 원리를 정확히 이해하고 제어할 줄 아는 개발자가 비로소 시니어 레벨의 백엔드 엔지니어로 거듭날 수 있습니다.