- Published on
- •👁️
JPA N+1 문제 해결에 따른 성능 변화 분석 - 1. JPA N+1 문제 개요
- Authors

- Name
- River
목차 페이지로 이동
데이터 규모에 따른 JPA N+1 문제 진단과 해결 전략 비교 (Fetch Join, Entity Graph, Batch Fetching)
목차
(1)정의
1번의 쿼리를 날렸을 때 내부적으로 의도하지 않은 N번의 쿼리가 추가적으로 실행되는 문제. 즉, 설계하지 않는 추가적인 쿼리가 N개 발생하는 것이다.
루프 내에서 반복적으로 실행되는 쿼리 또한 N+1문제로 볼 수 있다.
(2)발생 원인
JPA는 객체와 테이블을 매핑하여 JPQL이나 Repository 메서드 호출을 통해 SQL을 자동으로 생성한다.
또한 연관 엔티티에 대하여 자동으로 추가적인 쿼리문을 생성하기도 한다.
N+1 문제는 JPA가 엔티티를 조회할 때 Join이 아닌 별도 추가 쿼리로 연관 엔티티를 가져오기 때문에 발생한다.
(3)JPA Hibernate에서 N+1 문제가 발생하는 경우
1. JPA Fetch 전략이 EAGER인 경우 (즉시 로딩)

- EAGER의 경우엔 Hibernate가 최적화를 수행하여 Join 쿼리로 한 번에 조회하는 경우가 있다.
- findById() 쿼리 메서드
Hibernate가 최적화를 수행 EAGER 연관 엔티티를 Join으로 함께 가져온다.
User user = userRepository.findById(1L).orElseThrow();
- 다른 쿼리 메서드 (내부적으로 JPQL 생성)
JPQL 기반으로 쿼리 실행 후, EAGER 연관 엔티티에 대하여 추가적인 쿼리가 발생한다.
⇒ N+1 문제 발생
List<User> users = userRepository.findAll();
- (중요) EAGER 연관 엔티티를 어떻게 가져오는지는 Hibernate의 내부 전략에 따라 달라진다.
2. JPA Fetch 전략이 LAZY인 경우 (지연 로딩)

List<Member> members = memberRepository.findAll(); // 1번
...
for (Member m : members) {
System.out.println(m.getTeam().getName()); // N번
}
- 우선 상위 엔티티에 대하여 1번의 조회 발생 (1번)
- Fetch가 LAZY인 엔티티에 대해선 즉시 추가 조회하지 않는다. 우선 프록시 객체를 대신 넣어두고 연관 엔티티를 사용할 때 그 엔티티에 대하여 조회 발생 (N번)
3. 루프 내에서 반복적으로 실행되는 쿼리

List<User> users = userRepository.findAll();
for (User user : users) {
long postCount = postRepository.countByUser(user); // N번
}
SELECT COUNT(*) FROM post WHERE user_id = 1;
SELECT COUNT(*) FROM post WHERE user_id = 2;
SELECT COUNT(*) FROM post WHERE user_id = 3;
...
- N명의 User에 대해서 where 조건만 다르게 반복되는 쿼리
쿼리 수 증가 → DB 부하 증가
- 연관 데이터가 많을 수록 실행되는 쿼리 수가 기하급수적으로 증가
- 1000개의 연관 엔티티 ⇒ 1001번의 쿼리 발생
- 수백만 건 이상의 데이터에서 큰 문제로 확대될 수 있다.
- 이로 인해 DB Connection을 장시간 점유하게 되어, Connection Pool 고갈 가능
- 예시로 Connection Pool이 max = 3인 경우, 동시에 최대 3명만 DB에 접근 가능하다.
- 누군가 N+1 문제로 커넥션을 오래 점유한다면 나머지 요청은 Connection Pool에서 대기 상태가 된다.
- 즉, 전체 시스템 처리량이 저하된다.
응답 시간 증가 → 병목 발생
- 네트워크 Round Trip 증가
쿼리를 많이 날릴수록 Application과 Database 간의 요청/응답 횟수가 많아진다.
(Round Trip : 요청 후 응답까지 왕복 통신)
그에 따라, 총 소요 시간이 증가한다. 또 Application과 Database가 다른 서버일 경우 네트워크 트래픽 증가
- API 응답 시간 증가
- 특히 모든 사용자가 거치는 기능이라면, 전체 사용자 응답이 지연되어 시스템 전체 병목 구간으로 작용할 수 있다.
- 응답 시간 증가 ⇒ 화면 진입 시 느려짐 ⇒ 실시간 반응 저하
인프라 비용 증가
처리 시간이 늘어나면서 CPU, Memory, Network 자원의 사용량이 증가한다.
⇒ 서버를 추가해야 하는 등 비용 증가
(1)Fetch Join
@Query("select distinct u from User u left join fetch u.posts")
List<User> findAllWithPosts();
- JPQL에서
fetch키워드를 사용하여 연관 엔티티를 한 번의 쿼리로 함께 조회 - N+1 문제의 원인인 엔티티를 조회 시 연관된 엔티티를 따로 조회하는 것을 방지한다.
- Inner Join(
join fetch)이 기본 (직접 Outer Join(left join fetch) 설정) - 중복 제거가 필요한 경우
distinct키워드 사용 필요 - 단점
컬렉션(OneToMany) Fetch Join인 경우 페이징을 적용할 수 없다.
⇒ ToOne 관계에서만 Fetch Join과 페이징을 함께 적용 가능
(2)EntityGraph
JPA 기능으로 연관 엔티티를 로딩하는 전략 중 하나
JPQL인 경우
@EntityGraph(attributePaths = {"posts"}, type = EntityGraphType.FETCH) @Query("select u from User u") List<User> findAllWithPosts();쿼리 메서드인 경우
@EntityGraph(attributePaths = {"applicant", "applicant.shelter", "animalCase"}) List<Protection> findAll();Outer Join이 기본
JPQL 없이 사용 가능하고, 내부적으로 Fetch Join과 동일한 방식이다.
단점
- Fetch Join과 마찬가지로 컬렉션(OneToMany)의 경우 페이징을 적용할 수 없다.
(3)Batch Fetching
default_batch_fetch_size
# application.yml
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
지연 로딩 시 발생하는 N개의 쿼리를 하나의 IN 쿼리로 묶는 전역 설정이다.
(application.yml에 설정)
fetch join또는EntityGraph를 먼저 사용하면 Batch Fetching은 아예 실행되지 않는다.
@BatchSize
@BatchSize(size = 100)
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Article> articles = new ArrayList<>();
- 엔티티의 필드에 직접 설정해야 한다.
- 특정 연관 엔티티에 대해 배치 크기를 지정하는 것
- 지연 로딩된 연관 엔티티를 일괄 조회한다.
- 페이징 처리와 함께 사용 가능
(4)Subselect Fetching
@Fetch(FetchMode.SUBSELECT)
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
@Fetch(FetchMode.SUBSELECT)
private List<Post> posts = new ArrayList<>();
@BatchSize와 동일하게 엔티티의 필드에 직접 설정해야 하고 지연로딩된 연관 엔티티를 조회한다.
하지만 내부 적용 방식이 다르다.
메인 엔티티를 조회하는 쿼리의 결과를 연관 엔티티 조회 쿼리의 서브 쿼리로 사용해 한 번에 조회
-- 1. SELECT * FROM user WHERE name LIKE '%리버%'; -- 2. posts에 접근할 때 SUBSELECT를 사용한 쿼리 발생 SELECT * FROM post WHERE user_id IN ( SELECT id FROM user WHERE name LIKE '%리버%' );@BatchSize(size = 무한대)라는 표현처럼 모든 연관 데이터를 다 가져오는 것으로 메모리 부담이 있을 수 있다.
서브 쿼리를 사용하여 쿼리가 무거워진다.
(5)IN 기반 수동 최적화
루프 내 반복되는 조회를 직접 JPQL을 작성하여 IN 쿼리로 묶어 최적화
Batch Fetching 처럼 자동 해결이 아닌 직접 명시적으로 해결하는 것
JOIN FETCH를 쓰기 힘든 경우나, 다건 조회가 핵심인 곳에서 자주 사용된다.WHERE id IN (:ids)쿼리 작성// N+1 문제 발생 List<AnimalCase> list = animalCaseRepository.findAll(); // 1번 쿼리 for (AnimalCase ac : list) { Protection p = protectionRepository.findByAnimalCaseId(ac.getId()); // N번 쿼리 }// 최적화된 코드 List<AnimalCase> animalCases = animalCaseRepository.findAll(); // 1번 쿼리 List<Long> caseIds = animalCases.stream().map(AnimalCase::getId).collect(Collectors.toList()); @Query("SELECT p FROM Protection p WHERE p.animalCase.id IN :caseIds") List<Protection> protections = protectionRepository.findByAnimalCaseIds(caseIds); // 1번 쿼리
(6)전체 정리표
| 전략 | 장점 | 단점 | 페이징 처리 | 다중 컬렉션 |
|---|---|---|---|---|
| Fetch Join |
|
| XXXToMany 불가능 | X |
| EntityGraph |
|
| XXXToMany 불가능 | X |
| default batch fetch size |
|
| O | O |
| @BatchSize |
|
| O | O |
| @Fetch (SUBSELECT) |
|
| O | O |
| IN 기반 수동 최적화 |
|
| O | O |
(1)컬렉션(OneToMany, ManyToMany) 페이징 처리 문제
문제 원인
컬렉션 Fetch Join(or EntityGraph) + 페이징 사용 시 Hibernate가 페이징을 처리하지 않고
모든 데이터를 메모리에 로딩 후 애플리케이션에서 페이징 처리
⇒ 페이징의 목적인 필요한 데이터만 가져오는 것이 무의미해진다.
이유는 컬렉션 Join 시 데이터 중복이 발생할 수 있기 때문에 모든 데이터를 메모리에
로딩후 distinct를 사용하여 중복을 제거하고 이후 메모리에서 페이징 처리를 한다.
⇒ 결과적으로 실제 DB에서 페이징(limit)가 적용되지 않는다.
HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!경고 발생⇒ 메모리 사용량 때문에 대용량 데이터에서 OOM(Out Of Memory) 발생 위험 존재
해결책 1 : 지연 로딩 + Batch Fetching(ex. @BatchSize) 조합
// Entity
@BatchSize(size = 100)
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Article> articles = new ArrayList<>();
// Repository
Page<User> findAll(Pageable pageable);
- 컬렉션을 Fetch Join 하지 않고 지연 로딩으로 유지하면서 Batch Fetching 사용
- @BatchSize 또는 default_batch_fetch_size 설정으로 N+1 방지
해결책 2 : @Fetch(FetchMode.SUBSELECT) 사용
@Fetch(FetchMode.SUBSELECT)
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Article> articles = new ArrayList<>();
- 연관 엔티티를 서브쿼리로 한번에 조회
- 단, 모든 데이터를 한 번에 가져오기 때문에 대용량 데이터에서는 주의해야 한다.
(2)여러 컬렉션 조인 문제
문제 원인
- 두 개 이상의 컬렉션(OneToMany, ManyToMany)에 Fetch Join(or EntityGraph) 적용 시
MultipleBagFetchException발생 - Hibernate는 둘 이상의 컬렉션 타입(List)을 동시에 Fetch Join할 수 없도록 제한한다.⇒ 에러 발생
@EntityGraph(attributePaths = {"articles", "questions"}, type = EntityGraphType.FETCH) @Query("select distinct u from User u") List<User> findAllWithArticlesAndQuestions(); - 이유는 Cartesian Product으로 인한 중복 데이터 및 메모리 사용량 폭증
두 개의 컬렉션의 각 요소의 모든 조합으로 조인된다.
⇒ 중복 데이터가 기하급수적으로 증가 ⇒ 메모리 사용량 폭증
결과를 각 컬렉션에 매핑하는 것은 어렵고 비효율적
⇒ 명시적으로 제한함
해결책 1 : List 대신 Set 자료구조 사용
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private Set<Article> articles = new HashSet<>();
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private Set<Question> questions = new HashSet<>();
- 중복 제거 효과가 있는 Set을 사용하면 된다. 순서가 중요하면
LinkedHashSet사용 - 단, 페이징 문제는 여전히 발생한다.
해결책 2: @BatchSize 활용
@BatchSize(size = 100)
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Article> articles = new ArrayList<>();
@BatchSize(size = 100)
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Question> questions = new ArrayList<>();
- List 자료구조를 그대로 사용 가능
- 페이징과 함께 사용 가능
(3)Batch Fetching과 Fetch Join & EntityGraph의 관계
- Batch Fetching(
default_batch_fetch_size,@BatchSize)은 Fetch Join이나 EntityGraph로 명시적으로 지정된 연관관계에는 적용되지 않는다. - 이유는 Fetch Join이 우선적으로 적용되어 Batch Fetching 설정이 무시되기 때문
- Fetch Join과 batch fetching을 같이 사용하면
- Fetch Join으로 지정된 엔티티 ⇒ Fetch Join 방식으로 즉시 로딩
- Fetch Join으로 지정되지 않은 엔티티 ⇒ Batch Fetching 적용 (지연 로딩 시)
예시
@Query("SELECT p FROM Protection p JOIN FETCH p.applicant") List<Protection> findAllWithApplicant();Protection의 applicant 필드는 Fetch Join으로 로딩되며,
다른 지연 로딩 필드(예: comments)는 접근 시 Batch Fetching 설정에 따라 로딩된다
- 주의사항
- 동일한 필드에 @BatchSize를 설정하고 Fetch Join을 적용하면 Fetch Join이 우선 적용된다.
- 따라서 페이징 문제나 두 개이상의 컬렉션 조인 문제를 해결하려면 Fetch Join 대신 @BatchSize만 사용해야 한다.
(4)결론
- 연관 관계는 되도록 항상 지연 로딩(LAZY) 설정
- default_batch_fetch_size 설정 (100~1000 사이)
- 전역 설정을 기본으로 깔고 @BatchSize를 추가 설정하는 하이브리드 방식을 사용해 성능 최적화한다.
- 1번, 2번만 적용해도 N+1 문제는 거의 해결된 것이다. 다만 더욱 쿼리 수를 줄이고 성능 최적화를 해야 한다면,
- XXXToOne이라면 페이징 문제가 없으므로 Fetch Join을 항상 적용한다.
- 컬렉션인 경우, 단일 컬렉션이면서 페이징은 안 쓰는 경우에만 Fetch Join을 적용한다.
- 루프 내 반복적인 쿼리는 명시적으로 IN을 활용해 최적화한다.
연관 관계 Default 표
| 관계 | OneToMany | ManyToMany | OneToOne | ManyToOne |
|---|---|---|---|---|
| Default 값 | LAZY | LAZY | EAGER | EAGER |
| main / sub | 기본 sub | 설정에 따라 | 설정에 따라 | 항상 main |
- Default 값은 main이든 sub든 상관없다. 관계에 따라 정해진다.
- DB에 FK가 존재하는 쪽이 owner (
@JoinColumn을 명시하는 쪽) - sub의 경우 ⇒
mappedBy를 사용하는 쪽
다음 페이지로 이동 (2. 성능 측정 방법론)