티스토리 뷰
JPA N+1 문제 원인 및 해결 방법: Fetch Join과 Batch Size 완벽 정리
Spring Boot와 JPA(Hibernate)를 이용해 백엔드를 개발할 때, 면접 단골 질문이자 실무 성능 저하의 주범인 JPA N+1 문제에 대해 자세히 알아보겠습니다. 로컬 개발 환경에서는 데이터가 적어 정상적으로 동작하는 것처럼 보이지만, 대용량 트래픽이 발생하는 운영 환경에서는 데이터베이스(DB) 부하로 인해 서버가 마비되는 치명적인 장애를 유발할 수 있습니다.
이 글에서는 실제 엔티티(Entity) 모델링 예시를 통해 N+1 문제가 발생하는 구체적인 원인(지연 로딩 vs 즉시 로딩)을 분석하고, 실무에서 사용하는 확실한 해결 방법인 Fetch Join과 @BatchSize 적용 코드까지 완벽하게 정리해 드리겠습니다.
📌 핵심 요약 미리보기
- 정의: 1번의 쿼리(조회)를 날렸을 때, 연관된 자식 엔티티를 조회하기 위한 N번의 추가 쿼리가 실행되는 현상
- 발생 시점: JPQL을 실행할 때 연관 관계 인터셉터가 동작하며 글로벌 로딩 전략에 상관없이 발생
- 치료법 1: 단일 관계나 1대다 1개인 경우, 관계형 SQL로 한 번에 묶어 가져오는
Fetch Join사용 - 치료법 2: 1대다 컬렉션 관계가 다수 얽혀 있을 때 카테시안 곱을 방지하기 위한
Batch Size설정
1. 가상 시나리오 및 JPA 엔티티 관계 설정
이해를 돕기 위해 하나의 회원(User)이 여러 개의 게시글(Post)을 작성할 수 있는 일대다(1:N) 양방향 연관 관계 구조를 자바 코드로 명시해 보겠습니다.
public class User {
@Id @GeneratedValue
private Long id;
private String name;
// 일대다 관계 설정 (지연 로딩 선언)
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Post> posts = new ArrayList<>();
}
@Entity
public class Post {
@Id @GeneratedValue
private Long id;
private String title;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
}
2. N+1 문제의 정의와 구체적인 발생 원인
JPA의 기본 findAll() 메서드나 일반 JPQL을 호출할 때, JPA는 오직 대상 엔티티 자체만을 기준으로 SQL을 생성합니다. 만약 전체 회원 데이터를 조회하기 위해 아래 메쏘드를 호출하면 어떻게 될까요?
이때 하이버네이트는 먼저 모든 회원을 읽어오는 1번의 최초 쿼리를 생성하여 실행합니다.
이후 비즈니스 로직이나 API 응답 객체(DTO)를 만드는 과정에서, 각 회원들이 작성한 게시글 데이터를 사용하기 위해 반복문을 순회하는 순간 문제가 터집니다.
System.out.println(user.getPosts().size()); // 각 유저의 가짜 프록시 객체 초기화 시점
});
조회된 회원이 총 N명이라면, 각 회원당 한 번씩 해당 회원의 posts 테이블을 긁어오는 추가 쿼리가 N번 독립적으로 발생하게 됩니다.
SELECT * FROM post WHERE user_id = 2; -- 2번째 유저의 글 조회 (추가 2)
SELECT * FROM post WHERE user_id = 3; -- 3번째 유저의 글 조회 (추가 3)
... (N번째 회원까지 총 N번 반복 수행)
지연 로딩(Lazy)과 즉시 로딩(Eager)의 오해
많은 초보 개발자분들이 "즉시 로딩(Eager) 전략으로 변경하면 해결된다"고 잘못 오해합니다. 하지만 즉시 로딩 구조에서도 일반 JPQL을 던지면 똑같이 N+1 현상이 터집니다. 지연 로딩은 실제 데이터를 쓰는 시점에 나눠서 N번 발생하는 것이고, 즉시 로딩은 최초 조회가 끝나자마자 연관 관계를 분석해 곧바로 연속해서 N번의 추가 쿼리를 날릴 뿐 결과적으로 날아가는 SQL의 총개수는 동일합니다.
3. 구글과 시니어가 공인하는 확실한 해결책 2가지
해결책 A: 패치 조인 (Fetch Join) - 최고의 정석
N+1 문제를 막는 가장 근본적인 대책은 SQL 단에서 애초에 두 테이블을 INNER JOIN 형태로 합쳐서 한 번에 가져오도록 명령하는 것입니다. JOIN FETCH 문법을 Repository 레이어에 직접 명시해 줍니다.
// JPQL 명시적 페치 조인 사용
@Query("SELECT u FROM User u JOIN FETCH u.posts")
List<User> findAllWithPostsFetchJoin();
}
이 메서드를 호출하게 되면 하이버네이트는 뒤에서 추가 쿼리를 일절 유발하지 않고 다음과 같은 강력한 단 1개의 결합 SQL 문장만을 날립니다.
해결책 B: 배치 사이즈 (Batch Size) 조절 - 대안적 카드
페치 조인은 매우 훌륭하지만, 하나의 엔티티에 일대다(1:N) 컬렉션 연관 관계가 2개 이상 존재할 때 페치 조인을 걸면 'MultipleBagFetchException' 에러가 발생하거나, 데이터의 정합성이 깨지고 페이징(Paging) 처리가 인메모리에서 수행되어 서버 폭발을 야기할 수 있습니다. 이때의 대안이 바로 배치 구조입니다.
spring:
jpa:
properties:
hibernate.default_batch_fetch_size: 100
이렇게 설정을 걸어두면 루프를 돌며 개별적으로 수행되던 추가 N번의 조회 작업이, 지정한 크기(예: 100개)만큼 모아서 SQL의 IN 절 연산으로 묶여 한꺼번에 나갑니다.
SELECT * FROM post WHERE user_id IN (1, 2, 3, ... 100);
💡 해결책 가이드라인 핵심 비교 요약
| 선택 솔루션 | 장점 및 최적의 상황 | 단점 및 주의사항 |
|---|---|---|
| Fetch Join | 쿼리가 정확히 단 1번만 수행되어 최고의 DB 리소스 절약 효과. 다대일(N:1) 관계 최적. | 2개 이상의 일대다 컬렉션 조인 불가, 페이징 명령어 처리 불가. |
| Batch Size | 일대다(1:N) 관계가 여러 개 얽혀있어도 안전하며, 데이터의 유실 없이 페이지네이션 적용 가능. | 데이터 크기에 비례해 최소한의 쿼리(1 + N/100) 조각이 누적되어 발생함. |
글을 마치며
대단위 프로젝트나 엔터프라이즈 환경으로 넘어갈수록 인덱싱과 더불어 쿼리 최소화가 가장 큰 튜닝의 목표가 됩니다. 기술 면접이나 실무 코드 리뷰에서 당당하게 논리를 펼칠 수 있도록, 엔티티를 설계할 때는 기본적으로 글로벌 패치 전략을 지연 로딩(LAZY)으로 세팅해 두고 조회가 잦은 구간에만 선택적으로 Fetch Join을 도입하는 습관을 들여보시기 바랍니다.
#JPAN1문제 #JPA성능최적화 #FetchJoin사용법 #BatchSize설정 #하이버네이트쿼리로그 #백엔드개발자실무 #지연로딩프록시 #SpringDataJPA
'데이터베이스' 카테고리의 다른 글
| DB 파티셔닝(Partitioning)과 샤딩(Sharding) 원리 및 알고리즘 (0) | 2026.06.07 |
|---|---|
| DB 인덱스(Index) 원리부터 B-Tree 구조, 복합 인덱스 설정 기준 완벽 정리 (0) | 2026.06.05 |
| SQL JOIN 종류 및 예제 (0) | 2026.05.24 |
| 데이터베이스 정규화 BCNF (0) | 2026.05.17 |
| 데이터베이스 제3정규화 (0) | 2026.05.17 |
| 데이터베이스 제2정규화 (0) | 2026.05.17 |
| 데이터베이스 제1정규화 (0) | 2026.05.17 |
| 가장 많이 사용된 데이터베이스(DBMS) TOP10 - 2026 최신버전 (0) | 2026.05.07 |
- Total
- Today
- Yesterday
- 블루투스
- 안드로이드
- C
- 데이터베이스
- C++ 클래스
- 파일처리
- html
- 아두이노
- 정보처리기사
- C++
- 자바
- 벡터
- c#
- Android
- MySQL
- C언어
- Class
- 클래스
- 문자열
- 상속
- 자료구조
- OpenCV
- 파이썬
- 문제풀이
- String
- 알고리즘
- 리스트
- 배열
- Java
- DB연동
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |