N+1 문제 정의
한 번의 조회로 주 엔티티 “N개”를 가져왔을 때, 각각의 연관 엔티티를 다시 조회하는 쿼리가 “N번” 추가 실행되어 총 (1 + N)번의 쿼리가 발생하는 현상이다.
public void printTeamMembers(EntityManager em) {
List<Team> teams = em.createQuery("SELECT t FROM Team t", Team.class)
.getResultList(); // 1️⃣ 1번 쿼리 (Team 전체 조회)
for (Team team : teams) {
for (Member member : team.getMembers()) {
System.out.println("Member:" + member.getUsername()); // 2️⃣ N(팀수)번 쿼리 발생
}
}
}
⬇️
for (Member member : team.getMembers()) {
System.out.println("Member:" + member.getUsername());
}
// 여기서 다음과 같은 쿼리 발생: SELECT * FROM member WHERE team_id = ?
👉 여기서 무슨 일이 벌어지냐면?
- SELECT * FROM team → 팀 전체 조회 (1번)
- 각 팀마다 SELECT * FROM member WHERE team_id = ? 쿼리 발생 (팀 수 만큼 N번)
- 팀이 5개면 → 총 6번 쿼리 실행 (1 + 5)
N+1 문제 발생 원인
N+1 문제의 근본적인 발생 원인은 바로 객체지향 패러다임과 관계형 데이터베이스(RDB)의 패러다임 차이, 즉 패러다임의 괴리(Mismatch) 때문이다.
이런 차이 때문에, 객체는 단순히 getMembers()처럼 메서드만 호출하면 끝이지만, RDB는 그때마다 추가 쿼리를 날려야 하는 상황이 발생해요. 이게 바로 N+1의 본질적인 원인입니다.
그래서 이 문제를 해결해줄 수 있는 방법으로는 페치 조인(Fetch Join), 배치 사이즈(Batch Size), 엔티티 그래프(Entity Graph), 쿼리 튜닝 / Native Query 4가지가 있다.
1️⃣ Fetch Join
JPQL에서 JOIN FETCH를 사용해 연관 엔티티를 함께 조회한다.
SELECT o FROM Order o
JOIN FETCH o.member
JOIN FETCH o.orderItems
WHERE o.ordersStatus = 'completed'
- 장점
- 쿼리 하나로 주 엔티티 + 연관 엔티티를 모두 로드
- N+1 방지
- 단점
- FetchType 설정이 무의미, Lazy 사용 불가
- 많은 조인을 남발하면 쿼리가 복잡해지고, 테이블 간 중복 데이터가 발생할 수 있음
- ⭐️ JPQL 페치 조인은 여러 컬렉션 동시 페치는 불가능 등 제약 존재 → MultipleBagFetchException 발생한다 왜냐하면 하나의 쿼리에서 여러 컬렉션 조인할때 카테시안 곱(Cartesian Product) 발생하기 때문이다. 카테시안 곱은 두 개의 컬렉션을 동시에 JOIN 하면, 각 조인된 row가 조합되기 때문에 실제보다 더 많은 row가 결과로 나오게 되어 중복 데이터 발생한다.
1차 쿼리 – Order와 Member만 fetch join
SELECT o FROM Order o JOIN FETCH o.member WHERE o.id IN (1, 2, 3);- 한 번에 주문 + 회원 정보까지 조회함
- 컬렉션은 하나(member)만 join → 안전
- OrderItem은 아직 조회 안 됨 (LAZY)
2차 쿼리 – OrderItem은 지연 로딩 되지만 배치 사이즈로 IN 쿼리
@OneToMany(mappedBy = "order")
@BatchSize(size = 100)
private List<OrderItem> orderItems;
JPA가 getOrderItems()를 여러 주문에 대해 호출하는 순간, 자동으로 다음 쿼리를 실행
SELECT i FROM OrderItem i
WHERE i.order.id IN (1, 2, 3);
2️⃣ Batch Size
지연 로딩 시 한 번에 로딩할 연관 엔티티 수를 제한한다.
@BatchSize(size = 100)
-- 예시: OrderItem을 100개씩 IN 절로 조회
SELECT * FROM order_item WHERE order_id IN (?, ?, ..., ?);
여기서 배치 사이즈 = IN 절 안에 들어가는 최대 파라미터 개수이다.
상황 1: 현재 영속성 컨텍스트에 Order가 3개뿐임 Hibernate는 아래처럼 3개만 묶어서 IN 쿼리 실행
SELECT * FROM order_item
WHERE order_id IN (1, 2, 3);
상황 2: 현재 Order가 250개 있음
Hibernate는 이를 100개 단위로 잘라서 3번에 나눠서 쿼리 보낸다.
-- 첫 번째 배치
SELECT * FROM order_item
WHERE order_id IN (1, 2, ..., 100);
-- 두 번째 배치
SELECT * FROM order_item
WHERE order_id IN (101, ..., 200);
-- 세 번째 배치
SELECT * FROM order_item
WHERE order_id IN (201, ..., 250);
- 장점
- Lazy 로딩을 유지하면서도, 1개씩(N번) 불러오는 대신 몇 차례에 걸쳐 묶음 로딩
- 쿼리 수를 N → N/100으로 감소
- 주의
- 너무 큰 배치 사이즈 → 쿼리 성능 악화
- DB 제한 초과 → max_allowed_packet = 16MB (오류 발생)
- 쿼리 너무 길어져 파싱 속도 느려짐
- DB 성능 저하 → DB I/O, 메모리 부하 증가
- Hibernate 내부 메모리 사용량 증가, 한 번에 너무 많은 엔티티를 로딩하면 → 메모리 사용량 ↑
- 너무 작은 배치 사이즈 → N+1 문제 근본 해소 어려움
- 너무 큰 배치 사이즈 → 쿼리 성능 악화
3️⃣ FetchMode.SUBSELECT
Hibernate에서 제공하는 또 다른 지연 로딩 최적화 기법, 다수의 부모 엔티티에 대한 연관 컬렉션을 한 번의 Subselect로 가져온다/
@OneToMany(mappedBy = "order")
@Fetch(FetchMode.SUBSELECT)
private List<OrderItem> orderItems;
첫 번째로 메인 엔티티(Order)를 조회한 뒤, 해당 Order의 id들을 모아서 WHERE order_id IN(...) 서브쿼리를 실행,
기존 Batch Size 전략과 유사하지만, Hibernate가 “Subselect”라는 별도 구문을 사용한다.
- 장점
- 일대다의 N+1을 Subselect 한 번으로 해결
- Batch Size 설정 없이도 묶음 로딩 가능
- 단점
- Subselect로 인한 SQL 복잡성. 대규모 데이터에서 성능이 꼭 좋다는 보장 없음
- 실무 주의
- 테스트 DB로 H2 등을 사용할 경우, 실제 MySQL/Oracle과 쿼리 계획이 다를 수 있음. EXPLAIN 분석 필수
- Batch Size, FetchMode.SUBSELECT 모두 “너무 큰 IN 절/서브쿼리”가 성능을 떨어뜨릴 수 있으므로, 실제 데이터 볼륨에서 검증
⭐️ 결론
페치 조인은 객체 그래프를 탐색할 때, 배치 사이즈는 단순히 데이터를 출력만 할 때 사용하는것이 좋다.
- 페치 조인은 객체 그래프를 탐색할 때 사용
- Order → Member → Address 처럼객체 간의 연관관계를 타고 들어가야 하는 경우
- 즉, 로직 상으로 연관된 엔티티를 즉시 접근해야 하는 경우엔 fetch join 사용
- 배치 사이즈는 단순히 데이터를 출력만 할 때 사용
- 단순히 데이터를 화면에 보여주는 용도 (예: 리스트 출력)라면
- fetch join보다 가볍고 유연한 @BatchSize + 지연 로딩 방식이 더 적합
끝.
'Study > SpringBoot' 카테고리의 다른 글
| JPA의 flush()는 언제, 왜 호출될까? (0) | 2025.04.12 |
|---|---|
| Spring Boot 테스트 코드: 통합 테스트 vs 단위 테스트 (0) | 2025.03.27 |
| Spring Been 객체와 프록시 패턴 (0) | 2025.03.10 |
| Spring Boot 주요 모듈 및 MVC 아키텍처 (1) | 2025.03.06 |
| Spring Boot의 AOP 활용 (1) | 2024.12.06 |