광고
~ / posts / developer
smith@lab:~/posts$ cat developer/jpa-n-plus-1-fetch-join-batch-size-selection-guide.md

JPA N+1 해결 방법 고르기, fetch join·배치 사이즈 판단표와 쿼리 수 계산

목록 API 하나가 로컬에서는 50ms 인데 운영에서는 1초를 넘고, SQL 로그를 켜면 where order_id=? 만 바뀐 쿼리가 줄줄이 찍히는 경우가 많아요. 결론부터 말하면 연관은 모두 LAZY 로 두고, 컬렉션이 하나이고 페이징이 없는 쿼리는 fetch join, 페이징이 있거나 컬렉션이 둘 이상인 쿼리는 부모만 페이징한 뒤 배치 페칭으로 채우는 것이 대부분의 화면에 맞는 답이에요. default_batch_fetch_size 는 한 화면의 부모 수를 한 번에 담을 크기로 전역에 걸어 두는 안전망이에요.

이 글은 N+1 이 생기는 내부 순서 대신, 어느 화면에서 무엇을 고르고 숫자가 얼마나 달라지는지, 운영에서 어떤 순서로 찾는지를 다뤄요. 기준은 Hibernate ORM 6.6 과 스프링 부트 3.5 공식 문서·소스이고, 쿼리 수와 행 수는 모두 직접 계산한 예시예요(2026-10-27).

광고

JPA N+1 해결 방법 판단표, 화면 유형별로 고르기

해결책은 엔티티가 아니라 쿼리(화면) 단위로 골라요. 같은 Order 라도 목록 화면, 상세 화면, 엑셀 내보내기마다 필요한 연관이 달라서 매핑 하나로 모두 맞출 수 없어요. 하이버네이트 입문서의 연관 가져오기 장도 대부분의 연관은 LAZY 로 매핑하고 필요한 곳에서 조인으로 가져오라고 권해요.

화면·쿼리 모양고를 방법SQL 수페이징주의할 점
일부 칼럼만 읽는 목록·통계DTO 프로젝션(select new …)1DB 에서 처리영속성 컨텍스트에 안 올라가 수정 불가
단일 값 연관(@ManyToOne)만 필요join fetch1DB 에서 처리행 수가 늘지 않아 안전
컬렉션 1개, 페이징 없음컬렉션 join fetch, @EntityGraph1—부모 수가 수천이면 행 수 확인
컬렉션 + 페이징부모만 페이징 + 배치 페칭2 이상DB 에서 처리카운트 쿼리 1번 더
컬렉션 2개 이상하나는 join fetch, 나머지는 배치·서브셀렉트2~3조건부bag 두 개 동시 fetch 는 예외
어느 연관을 쓸지 미리 모름전역 default_batch_fetch_size1+⌈N/B⌉DB 에서 처리해결이 아니라 완화
화면이 일부 칼럼만 읽는지, 연관이 @ManyToOne 뿐인지, 컬렉션 하나에 페이징이 없는지, 페이징이나 컬렉션 둘 이상인지, 연관을 미리 정할 수 없는지를 차례로 물어 DTO 프로젝션, join fetch, 컬렉션 join fetch, 부모 페이징과 배치 페칭, 전역 배치 설정 중 하나로 가는 판단 순서도
위에서부터 물어 처음 '예'가 나온 칸을 써요

첫 줄이 DTO 인 이유는 읽기만 하는 화면이면 엔티티도, 채울 연관 컬렉션도 없어 N+1 이 생기지 않아서예요.

@ManyToOne 이 많은 엔티티는 기본값부터 확인해요. Jakarta Persistence 3.2 ManyToOne 문서에서 fetch 기본값은 EAGER 예요. JPQL 은 EAGER 매핑을 조인으로 바꿔 주지 않아서, 회원 100명을 조회하면 아직 불러오지 않은 팀마다 SELECT 가 따라붙을 수 있어요. 매핑에 fetch = FetchType.LAZY 를 적고 필요한 쿼리에서 join fetch 하는 편이 안전해요. 반대로 EAGER 로 바꿔 N+1 을 없애려 하면 보조 SELECT 는 그대로 나가고, 그 연관이 필요 없는 화면에서도 늘 불러오게 돼요. 예외로, 입문서는 거의 늘 2차 캐시에 있는 코드성 참조 데이터는 EAGER + @Fetch(SELECT) 로 두라고 해요. 캐시에서 바로 찾기 때문이에요.

batch size 별 SQL 횟수 직접 계산

배치 페칭은 아직 초기화하지 않은 같은 종류의 컬렉션 주인 키를 B 개씩 묶어 읽어요. 부모가 N개면 초기화 SQL 은 ⌈N/B⌉번이고, 첫 쿼리를 더해 1+⌈N/B⌉번이에요. 부모 수별로 계산하면 이래요.

부모 엔티티 수별 SQL 횟수 (컬렉션 1개를 모두 건드릴 때)
지연 로딩 그대로배치 B서브셀렉트join fetch
0100200300400500600부모 20개 · 지연 로딩 그대로 21번부모 20개 · 배치 B 0번부모 20개 · 서브셀렉트 2번부모 20개 · join fetch 1번부모 20개부모 50개 · 지연 로딩 그대로 51번부모 50개 · 배치 B 2번부모 50개 · 서브셀렉트 2번부모 50개 · join fetch 1번부모 50개부모 100개 · 지연 로딩 그대로 101번부모 100개 · 배치 B 2번부모 100개 · 서브셀렉트 2번부모 100개 · join fetch 1번부모 100개부모 500개 · 지연 로딩 그대로 501번부모 500개 · 배치 B 6번부모 500개 · 서브셀렉트 2번부모 500개 · join fetch 1번부모 500개

배치·서브셀렉트·join fetch 는 막대가 거의 보이지 않을 만큼 작아요

단위: 번 · 자료: 1+N, 1+⌈N/B⌉, 2, 1 공식으로 직접 계산
표로 보기
구분지연 로딩 그대로배치 B서브셀렉트join fetch
부모 20개21021
부모 50개51221
부모 100개101221
부모 500개501621

SQL 횟수가 곧 비용인 이유는 왕복 시간이에요. DB 왕복 한 번에 1ms 가 걸린다고 놓으면(같은 데이터센터 안 예시 가정), 부모 500개 화면은 지연 로딩 그대로 약 501ms, B=100 이면 약 6ms 를 왕복에만 써요. DB 가 다른 가용 영역에 있어 왕복이 길어지면 차이는 그만큼 커져요. 쿼리 하나하나는 빠른데 화면이 느린 경우가 이 모양이에요.

batch size 를 고를 때는 "한 화면의 부모 수"를 기준으로 봐요. 부모 1,000개를 처리하는 배치·내보내기 작업이라면 B 에 따라 SQL 수가 이렇게 달라져요.

batch sizeSQL 횟수(부모 1,000개)묶음 하나의 키 수맞는 상황
166416페이지 크기 10~20 화면 위주
502150페이지 크기 50 이하
10011100일반 목록 + 가끔 큰 조회
5003500대량 조회가 잦은 백오피스
1,00021,000내보내기·배치 전용

크게 잡는다고 공짜는 아니에요. 묶음 하나가 키 1,000개짜리 IN 절이 되고, 하이버네이트 사용자 가이드의 IN 절 패딩 설정 설명처럼 실행 계획을 캐시하는 DB 에서는 IN 절 길이의 종류가 많을수록 캐시 적중률이 떨어져요. 그 설정을 켜면 자리표시자 수를 4·8·16·32 처럼 2의 거듭제곱으로 올려 맞추고 남는 자리는 null 로 채워요.

DB 의 파라미터 한도도 있어요. Hibernate 6.6 방언 소스를 보면 오라클 방언은 23 미만에서 IN 목록 한도를 1,000, 23 이상에서 65,535 로 알리고, SQL Server 방언은 2,048 로 알려요. Dialect 기본 구현은 batch size 가 이 한도를 넘으면 한도에 맞춰 줄여요. 그래서 오라클 19에서 B=5,000 으로 적어도 실제 묶음은 1,000개 이하예요. PostgreSQL 은 표준 배열을 지원해서 키 묶음을 배열 파라미터 하나로 넘기고, SQL 이 = any (?) 모양이 돼요.

fetch join 페이징 문제, 메모리 페이징을 피하는 순서

컬렉션을 fetch join 하면 SQL 결과는 부모 행이 아니라 부모×자식 행이에요. 여기에 LIMIT 을 걸면 부모가 아니라 행이 잘려서, 하이버네이트는 SQL 에 LIMIT 을 붙이지 않고 결과를 전부 받은 뒤 메모리에서 잘라요. 로그에는 HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory 경고가 찍혀요.

비용을 계산해 보면 왜 위험한지 보여요. 주문 10만 건, 주문당 항목 평균 4개인 테이블에서 첫 페이지 20건을 컬렉션 fetch join 으로 가져오면 DB 는 40만 행을 보내고, 애플리케이션은 그 행으로 주문 10만 개를 만든 뒤 20개만 남겨요. 부모만 페이징하고 항목을 배치로 채우면 주문 20행 + 항목 약 80행이 오고 SQL 은 2번(카운트 쿼리 별도)이에요. 개발 DB 가 작으면 안 보이다가 운영에서 메모리 부족으로 드러나요.

피하는 순서는 세 단계예요.

  1. 먼저 막아요. hibernate.query.fail_on_pagination_over_collection_fetch 를 true 로 켜요. 기본값은 false 이고, 켜면 메모리 페이징이 될 쿼리가 예외로 실패해서 테스트 단계에서 잡혀요.
  2. 부모만 페이징하고 배치로 채워요. 페이징 쿼리에서는 컬렉션 fetch 를 빼고, @ManyToOne 처럼 행 수를 늘리지 않는 연관만 join fetch 해요. 컬렉션은 배치 페칭이 페이지 크기만큼 묶어 읽어요.
  3. 정렬·필터가 컬렉션에 걸리면 ID 를 먼저 뽑아요. 첫 쿼리로 부모 ID 만 페이징하고, 둘째 쿼리에서 where o.id in :ids 로 컬렉션까지 join fetch 해요. 둘째 쿼리는 ID 순서를 보장하지 않으니 order by 를 다시 적어요.

스프링 데이터 JPA 의 Page<T> 리포지토리 메서드에 @EntityGraph(attributePaths = …) 로 컬렉션을 지정해도 결국 컬렉션 fetch 라서 같은 경고가 나요. 페이징 메서드의 엔티티 그래프에는 단일 값 연관만 넣는 습관이 안전해요.

컬렉션 두 개 fetch join, 카티전 곱 행 수 계산

List 컬렉션 두 개를 한 쿼리에서 fetch join 하면 MultipleBagFetchException 이 나요. 하나를 Set 으로 바꾸면 예외는 사라지지만 행 수 문제는 그대로예요. 팀 100개, 팀마다 회원 30명과 일정 20개가 있을 때 전송 행 수를 계산하면 이래요.

팀 100개·회원 30명·일정 20개를 불러올 때 DB 가 보내는 행 수
둘 다 join fetch둘 다 join fetch · 60,000행60,000행회원 join + 일정 배치회원 join + 일정 배치 · 5,000행5,000행둘 다 배치·서브셀렉트둘 다 배치·서브셀렉트 · 5,100행5,100행

SQL 은 각각 1번, 2번 이상, 3번 이상이지만 행 수는 12배 차이예요

단위: 행 · 자료: 100×30×20 = 60000, 3000+2000, 100+3000+2000 으로 직접 계산
표로 보기
구분행 수
둘 다 join fetch60,000
회원 join + 일정 배치5,000
둘 다 배치·서브셀렉트5,100

카티전 곱은 자식 수가 곱해지는 구조라 데이터가 조금만 늘어도 급격히 커져요. 회원이 60명으로 늘면 둘 다 조인한 쿼리는 12만 행이 되지만, 나눠 읽은 쪽은 8천 행이에요. 입문서가 배치·서브셀렉트 페칭이 최선인 드문 경우로 "조인하면 카티전 곱과 거대한 결과가 생길 때"를 꼽는 것이 이 상황이에요. 기준은 단순해요. 한 쿼리에서 fetch join 하는 컬렉션은 하나까지, 나머지는 배치나 서브셀렉트로 채워요.

open-in-view 끌까 말까, 스프링 부트 기본값 판단

스프링 부트는 웹 애플리케이션에서 OpenEntityManagerInViewInterceptor 를 기본으로 등록해요. 스프링 부트 SQL 데이터베이스 문서에 적힌 대로 뷰 렌더링 중에도 지연 로딩이 되게 하려는 장치이고, spring.jpa.open-in-view 의 기본값은 true 예요. 명시하지 않으면 시작 로그에 "뷰 렌더링 중에 DB 쿼리가 실행될 수 있다"는 경고가 찍혀요(JpaBaseConfiguration 소스).

open-in-view 를 켜면 컨트롤러와 JSON 변환 중에도 지연 로딩이 조용히 성공해 N+1 이 응답 시간으로만 드러나고, 끄면 트랜잭션 밖 지연 로딩이 LazyInitializationException 으로 드러나 필요한 연관을 서비스 안에서 정하게 된다는 비교 카드
끄면 숨은 N+1 이 예외로 바뀌어 찾기 쉬워져요
상황권장이유
새 프로젝트, API 서버끔(false)필요한 연관을 서비스 안에서 정하게 됨
서버 렌더링 템플릿이 엔티티를 직접 탐색켬 유지 후 점진 전환끄면 템플릿마다 예외
오래된 서비스, 테스트가 적음켬 유지 + SQL 수 측정부터한 번에 끄면 장애 위험
커넥션 풀이 빠듯함끔 검토응답 작성 중 커넥션 점유를 줄임

끈 뒤 LazyInitializationException 이 나면 그 지점이 N+1 이 날 뻔한 곳이에요. 서비스 메서드 안에서 join fetch·엔티티 그래프·DTO 로 바꿔 고쳐요. 이때 hibernate.enable_lazy_load_no_trans 를 켜서 예외를 없애는 방법은 쓰지 않아요. 사용자 가이드가 "안전하지 않은 설정"으로 분류하고, 지연 로딩마다 임시 컨텍스트를 열어 트랜잭션 격리를 깨뜨릴 수 있다고 경고해요. 예외는 사라지지만 N+1 은 그대로 남아요.

광고
광고

운영에서 N+1 찾는 확인 순서, 체크리스트

응답이 느린 API 가 N+1 인지 확인할 때는 아래 순서로 봐요. 느린 응답을 좁혀 가는 일반 순서는 API 응답 디버깅 6단계와 같고, 여기서는 JPA 쪽만 다뤄요.

  1. 같은 SQL 이 반복되는지 봐요. 개발 환경에서 SQL 로그를 켜고 한 요청 동안 where …_id=? 모양이 몇 번 찍히는지 세요. 입문서도 SQL 로그를 직접 보기 전에는 영속성 로직이 맞는지 알 수 없다고 강조해요.
  2. 요청당 SQL 수를 숫자로 남겨요. hibernate.generate_statistics=true 로 통계를 켜면 Statistics#getPrepareStatementCount() 로 SQL 준비 횟수를 읽을 수 있어요. 운영에서 상시로 켜기보다 스테이징·부하 테스트에서 써요.
  3. 어디서 터지는지 찾아요. open-in-view 가 켜져 있으면 컨트롤러나 JSON 직렬화 중에 쿼리가 나가요. 응답 DTO 변환 코드, 직렬화되는 엔티티의 getter 를 먼저 봐요.
  4. EAGER 기본값을 확인해요. 컬렉션을 건드리지 않았는데 쿼리가 반복되면 @ManyToOne·@OneToOne 의 기본 EAGER 가 원인인 경우가 많아요.
  5. 외래 키 인덱스를 확인해요. 배치·서브셀렉트로 줄여도 where team_id in (…) 가 인덱스를 못 타면 쿼리마다 전체 스캔이에요.
  6. 고친 뒤 테스트로 고정해요. 서비스 메서드를 직렬화까지 통과시킨 뒤 SQL 수를 단언해요.
@Autowired EntityManagerFactory emf;

@Test
void 주문_목록은_SQL_두_번_이하() {
    Statistics stats = emf.unwrap(SessionFactory.class).getStatistics();
    stats.clear();

    List<OrderResponse> page = orderService.findPage(0, 20); // DTO 변환까지 포함

    assertThat(page).hasSize(20);
    assertThat(stats.getPrepareStatementCount()).isLessThanOrEqualTo(2);
}

단언 숫자는 "부모 쿼리 + 배치 묶음 수"로 정해요. 리포지토리 테스트에서 결과만 확인하면 지연 컬렉션을 건드리지 않아 N 부분이 보이지 않으니, 실제 응답을 만드는 계층까지 실행해야 해요.

도입 전에 따질 것, 배치 페칭의 한계와 설계 점검

전역 batch size 는 켜기만 하면 효과가 나서 첫 조치로 좋지만, 한계도 알고 켜야 해요.

광고
광고

자주 묻는 질문

default_batch_fetch_size 는 몇으로 설정하나요?

가장 흔한 페이지 크기 이상이면 한 화면의 컬렉션이 SQL 한 번에 채워져요. 페이지 크기가 20~50인 서비스라면 50~100 정도에서 시작하고, 대량 조회가 잦은 컬렉션만 @BatchSize 로 따로 올려요. 오라클 23 미만은 1,000, SQL Server 는 2,048 을 넘기면 하이버네이트가 한도로 줄여요.

fetch join 과 @EntityGraph 는 무엇이 다른가요?

둘 다 연관을 조인으로 함께 가져오는 방법이에요. join fetch 는 JPQL 문장에 직접 적고, @EntityGraph 는 쿼리 문장을 그대로 두고 메서드에 가져올 속성을 붙여요. 스프링 데이터 JPA 의 메서드 이름 쿼리에 연관을 더할 때 엔티티 그래프가 편해요. 컬렉션을 지정하면 페이징 함정은 똑같이 적용돼요.

Page 조회에서 컬렉션까지 한 번에 가져오고 싶어요.

부모만 페이징하고 컬렉션은 배치 페칭에 맡기는 것이 가장 간단해요. 컬렉션 조건으로 정렬·필터해야 하면 ID 만 먼저 페이징하고, 그 ID 로 컬렉션을 join fetch 하는 두 단계 쿼리를 써요. 어느 쪽이든 fail_on_pagination_over_collection_fetch=true 를 켜서 메모리 페이징을 막아 두세요.

MultipleBagFetchException 은 Set 으로 바꾸면 해결되나요?

예외는 사라지지만 카티전 곱은 남아요. 팀 100개·회원 30명·일정 20개면 6만 행이 와요. 한 쿼리에서 fetch join 하는 컬렉션은 하나로 두고, 다른 컬렉션은 배치나 서브셀렉트로 채우는 편이 행 수를 열 배 넘게 줄여요.

open-in-view 를 끄면 무엇부터 고치나요?

트랜잭션 밖에서 지연 컬렉션을 건드리는 곳마다 LazyInitializationException 이 나요. 그 지점의 서비스 메서드에서 필요한 연관을 join fetch·엔티티 그래프·DTO 로 미리 가져오게 고쳐요. enable_lazy_load_no_trans 로 예외를 덮는 방법은 공식 문서가 안전하지 않다고 분류해요.

정리

더 깊이 읽기

이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 JPA N+1 문제 원리 — 지연 로딩·fetch join·배치 사이즈에서 도식과 코드로 단계별로 풀었어요.

참고한 자료

#JPA N+1 해결#fetch join 페이징#default_batch_fetch_size 설정#BatchSize 몇으로#EntityGraph#open-in-view#MultipleBagFetchException#하이버네이트 성능
← 이전 글그리디 알고리즘 언제 쓰나, DP와 고르는 판단표와 반례 찾기 체크리스트다음 글 →LLM 파인튜닝 방식 고르기, SFT·DPO·GRPO 비용과 메모리 비교
광고

더 둘러보기

💻 개발자 다른 도구

🔥 많이 찾는 도구

🔮 재미로 한 번 더

📝 읽어 볼 만한 글

글 전체 보기 →