[JPA] N+1 문제 원인과 해결: fetch join, @EntityGraph, default_batch_fetch_size
![[JPA] N+1 문제 원인과 해결: fetch join, @EntityGraph, default_batch_fetch_size](https://blog.kakaocdn.net/dna/ZQTCn/dJMcahYXvOP/AAAAAAAAAAAAAAAAAAAAAJqh2jIuNSa5miMEF538ylw2WyeTdS9gJyYuqVbHuOSc/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&expires=1788188399&allow_ip=&allow_referer=&signature=2MBzSIbsxuWasOIqQp1HFa%2BTULg%3D)
[JPA] N+1 문제 원인과 해결: fetch join, @EntityGraph, default_batch_fetch_size
JPA 성능 문제의 대명사가 N+1이에요. 목록 하나 조회했는데 쿼리가 수백 번 나가는 거죠. 개발 DB에서는 데이터가 적어 멀쩡하다가, 운영에서 데이터가 쌓이면 갑자기 느려지는 전형적인 패턴이에요. 원인과 해법이 명확한 문제라, 구조만 이해하면 확실하게 잡을 수 있어요.
N+1이 정확히 왜 생기는지, fetch join·@EntityGraph·batch size 세 해법과 각각의 함정(특히 컬렉션 fetch join + 페이징), 그리고 실무에서 셋을 어떻게 조합하는지까지 짚어요. 지연 로딩과 프록시 글과 바로 이어져요.
01. N+1이 생기는 구조
주문 목록을 조회하고 각 주문의 회원 이름을 출력한다고 해볼게요.
List<Order> orders = orderRepository.findAll(); // 쿼리 1번 (100건)
for (Order order : orders) {
log.info(order.getUser().getName()); // 건마다 쿼리 1번씩!
}

목록 조회 1번 + 연관 접근 N번 = N+1이에요. user가 LAZY라 루프에서 프록시가 초기화될 때마다 쿼리가 나가는 거예요.
"그럼 EAGER 때문 아니야?"라는 오해가 있는데, N+1은 LAZY든 EAGER든 생겨요. 시점만 달라요. LAZY는 접근하는 순간(루프에서), EAGER는 JPQL 조회 직후에 연관을 추가 쿼리로 채우면서 터져요. EAGER가 더 나쁜 건 쓰지도 않는 연관에서까지, 모든 조회에서 터진다는 점이에요. 기본은 LAZY로 두고 문제는 "가져오는 방법"으로 풀어야 해요.
02. 해법 1, fetch join
가장 직접적인 해법이에요. JPQL에서 연관을 조인으로 한 번에 가져와요.
Querydsl 환경에서 N+1을 잡은 기록은 Querydsl 성능개선 2편 (N+1)(2021)에 있어요.
@Query("select o from Order o join fetch o.user")
List<Order> findAllWithUser();
이러면 주문과 회원이 한 쿼리(SQL JOIN)로 조회되고 user는 프록시가 아니라 진짜 객체로 채워져요. 101번이 1번이 돼요.
일반 join과 다른 점을 짚어둘게요. JPQL의 일반 join은 조인 조건으로만 쓰고 연관 엔티티를 로딩하지 않아요. join fetch여야 SELECT 절에 연관까지 포함돼 로딩돼요. "join 했는데 N+1이 그대로예요"는 대부분 fetch를 빼먹은 거예요.
03. 해법 2, @EntityGraph
JPQL을 직접 안 쓰고 쿼리 메서드를 유지하고 싶으면 @EntityGraph로 로딩할 연관을 선언해요.
@EntityGraph(attributePaths = {"user"})
List<Order> findByStatus(OrderStatus status);
내부적으로 LEFT JOIN으로 연관을 함께 가져와요. 동작은 fetch join과 유사하고 메서드 이름 기반 쿼리와 조합하기 좋아요. "이 조회는 user까지 같이"를 선언적으로 표현하는 도구예요.
04. 해법 3, batch size
세 번째 해법은 N번의 쿼리를 IN 절로 묶는 거예요.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
이걸 켜면 지연 로딩이 일어날 때 한 건씩 조회하는 대신, 영속성 컨텍스트에 있는 같은 타입의 프록시들을 모아 WHERE id IN (1, 2, ..., 100)으로 한 번에 가져와요. 101번이 1 + ⌈N/100⌉번이 돼요. 100건이면 2번이죠.
fetch join만큼 쿼리가 줄진 않지만 결정적인 장점이 있어요. 페이징과 공존할 수 있고 전역 설정 한 줄로 애플리케이션 전체에 바닥을 깔 수 있어요. 그 이유는 다음 함정과 이어져요.
05. 함정, 컬렉션 fetch join과 페이징

fetch join에는 유명한 함정이 있어요. 컬렉션(@OneToMany)을 fetch join하면서 페이징을 걸면 안 돼요.

주문↔상품들(1:N)을 조인하면 주문 1건이 상품 수만큼 여러 행으로 늘어나요. "주문 10건만"이라는 페이징 기준이 SQL 레벨에서 모호해지는 거죠. 그래서 Hibernate는 전체를 다 가져와 메모리에서 페이징해요. 로그에 firstResult/maxResults specified with collection fetch; applying in memory 경고가 찍히는데, 데이터가 100만 건이면 100만 건이 메모리에 올라가는 거예요. 이 경고를 무시하면 OOM으로 돌아와요.
하나 더 — 컬렉션 두 개 이상을 동시에 fetch join하는 것도 안 돼요(MultipleBagFetchException). 정확히는 순서 정보 없는 List(bag) 컬렉션 2개 이상일 때예요. Set이나 <code>@OrderColumn</code> 붙은 List면 예외 없이 돌지만 카테시안 곱 폭발은 그대로예요.
06. 실무 조합 전략
그래서 실무 정석은 이 조합이에요.
~ToOne(다대일·일대일)은 fetch join — 행이 안 늘어나니 페이징과 함께 써도 안전해요. 필요한 만큼 fetch join으로 가져와요.- 컬렉션(
~ToMany)은default_batch_fetch_size— 페이징을 유지하면서 IN 절로 묶어 가져와요. 전역으로 100 정도 깔아두는 게 보통이에요. - 복잡한 화면용 조회는 DTO 직접 조회 — 엔티티 그래프를 통째로 가져올 필요 없이, 화면에 필요한 컬럼만 select해서 DTO로 받으면 N+1 자체가 없어요.
이 조합이면 페이징 + 연관 로딩 + 쿼리 수 제어가 동시에 돼요. "전부 fetch join"도 "전부 batch size"도 아니고 연관의 모양에 따라 나누는 거예요.
07. N+1을 미리 잡는 법
N+1은 터지고 나서 잡는 것보다 개발 중에 발견하는 게 싸요. 두 가지를 추천해요.
첫째, 로컬·테스트에서 SQL 로그를 켜두고 목록 화면을 열 때 쿼리가 몇 번 나가는지 봐요. 한 화면에 같은 모양의 SELECT가 반복되면 그게 N+1이에요. 둘째, 테스트 코드에서 쿼리 수를 검증할 수도 있어요. Hibernate 통계(Statistics)로 실행 쿼리 수를 받아서 "이 조회는 쿼리 2번 이내"를 단언해두면, 누가 연관을 추가해도 회귀를 잡아줘요.
08. 자주 만나는 문제
join을 했는데도 N+1이 나가요
일반 join은 연관을 로딩하지 않아요. join fetch인지 확인해요.
fetch join + 페이징에서 메모리가 터져요
컬렉션 fetch join의 메모리 페이징이에요. 컬렉션은 fetch join을 빼고 default_batch_fetch_size로 처리해요.
MultipleBagFetchException이 나요
컬렉션 둘 이상을 동시에 fetch join한 거예요. 하나만 fetch join하고 나머지는 batch size로, 또는 둘 다 batch size로 바꿔요.
설정을 다 했는데 특정 화면만 느려요
그 화면 조회가 엔티티 그래프를 너무 크게 끌고 오는 걸 수 있어요. 필요한 컬럼만 DTO로 직접 조회하는 걸 검토해요.
N+1, 모양으로 갈라서 푼다
N+1은 결국 "목록 1쿼리 + 연관 N쿼리"이고 해법은 "어떻게 묶어 가져올 것인가" 하나예요. 연관의 모양을 보고 갈라요.
- ~ToOne(다대일·일대일)이면 → fetch join 또는 @EntityGraph. 행이 안 늘어나니 페이징과도 안전해요.
- 컬렉션(~ToMany)이면 →
default_batch_fetch_size. 페이징을 지키면서 IN 절로 묶어요. - 화면 전용 복잡 조회면 → DTO 직접 조회. 그래프를 통째로 끌 필요가 없어 N+1 자체가 사라져요.
이 조합이면 페이징까지 지켜져요. 컬렉션 fetch join + 페이징의 메모리 함정만 기억하면 N+1은 더 무섭지 않아요. 영속성 컨텍스트·지연 로딩과 프록시 위에 선 이야기였고 이어서 연관관계 매핑으로 넘어가요.
출처: Hibernate User Guide — Batch Fetching · Spring Data JPA — Entity Graph
'백엔드 > 데이터 & DB' 카테고리의 다른 글
| [JPA] OSIV(open-in-view) 켤까 끌까: 영속성 컨텍스트 범위와 커넥션 고갈 (0) | 2026.07.13 |
|---|---|
| [JPA] 낙관적 락 vs 비관적 락: @Version, SELECT FOR UPDATE, 동시성 충돌 해결 (0) | 2026.07.10 |
| [JPA] 연관관계 매핑 정리: 연관관계 주인 mappedBy, 양방향, cascade, orphanRemoval (0) | 2026.07.09 |
| [JPA] 지연 로딩과 프록시: FetchType.LAZY 동작 원리, LazyInitializationException 해결 (0) | 2026.07.07 |
| [JPA] 영속성 컨텍스트 동작 원리: 1차 캐시, 더티 체킹, 쓰기 지연, flush 시점 (0) | 2026.07.06 |
📚 같이 보면 좋은
"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 일정액의 수수료를 제공받습니다."