목록 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 …) | 1 | DB 에서 처리 | 영속성 컨텍스트에 안 올라가 수정 불가 |
단일 값 연관(@ManyToOne)만 필요 | join fetch | 1 | DB 에서 처리 | 행 수가 늘지 않아 안전 |
| 컬렉션 1개, 페이징 없음 | 컬렉션 join fetch, @EntityGraph | 1 | — | 부모 수가 수천이면 행 수 확인 |
| 컬렉션 + 페이징 | 부모만 페이징 + 배치 페칭 | 2 이상 | DB 에서 처리 | 카운트 쿼리 1번 더 |
| 컬렉션 2개 이상 | 하나는 join fetch, 나머지는 배치·서브셀렉트 | 2~3 | 조건부 | bag 두 개 동시 fetch 는 예외 |
| 어느 연관을 쓸지 미리 모름 | 전역 default_batch_fetch_size | 1+⌈N/B⌉ | DB 에서 처리 | 해결이 아니라 완화 |
첫 줄이 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 횟수가 곧 비용인 이유는 왕복 시간이에요. DB 왕복 한 번에 1ms 가 걸린다고 놓으면(같은 데이터센터 안 예시 가정), 부모 500개 화면은 지연 로딩 그대로 약 501ms, B=100 이면 약 6ms 를 왕복에만 써요. DB 가 다른 가용 영역에 있어 왕복이 길어지면 차이는 그만큼 커져요. 쿼리 하나하나는 빠른데 화면이 느린 경우가 이 모양이에요.
batch size 를 고를 때는 "한 화면의 부모 수"를 기준으로 봐요. 부모 1,000개를 처리하는 배치·내보내기 작업이라면 B 에 따라 SQL 수가 이렇게 달라져요.
| batch size | SQL 횟수(부모 1,000개) | 묶음 하나의 키 수 | 맞는 상황 |
|---|---|---|---|
| 16 | 64 | 16 | 페이지 크기 10~20 화면 위주 |
| 50 | 21 | 50 | 페이지 크기 50 이하 |
| 100 | 11 | 100 | 일반 목록 + 가끔 큰 조회 |
| 500 | 3 | 500 | 대량 조회가 잦은 백오피스 |
| 1,000 | 2 | 1,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 가 작으면 안 보이다가 운영에서 메모리 부족으로 드러나요.
피하는 순서는 세 단계예요.
- 먼저 막아요.
hibernate.query.fail_on_pagination_over_collection_fetch를true로 켜요. 기본값은false이고, 켜면 메모리 페이징이 될 쿼리가 예외로 실패해서 테스트 단계에서 잡혀요. - 부모만 페이징하고 배치로 채워요. 페이징 쿼리에서는 컬렉션 fetch 를 빼고,
@ManyToOne처럼 행 수를 늘리지 않는 연관만 join fetch 해요. 컬렉션은 배치 페칭이 페이지 크기만큼 묶어 읽어요. - 정렬·필터가 컬렉션에 걸리면 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개가 있을 때 전송 행 수를 계산하면 이래요.
카티전 곱은 자식 수가 곱해지는 구조라 데이터가 조금만 늘어도 급격히 커져요. 회원이 60명으로 늘면 둘 다 조인한 쿼리는 12만 행이 되지만, 나눠 읽은 쪽은 8천 행이에요. 입문서가 배치·서브셀렉트 페칭이 최선인 드문 경우로 "조인하면 카티전 곱과 거대한 결과가 생길 때"를 꼽는 것이 이 상황이에요. 기준은 단순해요. 한 쿼리에서 fetch join 하는 컬렉션은 하나까지, 나머지는 배치나 서브셀렉트로 채워요.
open-in-view 끌까 말까, 스프링 부트 기본값 판단
스프링 부트는 웹 애플리케이션에서 OpenEntityManagerInViewInterceptor 를 기본으로 등록해요. 스프링 부트 SQL 데이터베이스 문서에 적힌 대로 뷰 렌더링 중에도 지연 로딩이 되게 하려는 장치이고, spring.jpa.open-in-view 의 기본값은 true 예요. 명시하지 않으면 시작 로그에 "뷰 렌더링 중에 DB 쿼리가 실행될 수 있다"는 경고가 찍혀요(JpaBaseConfiguration 소스).
| 상황 | 권장 | 이유 |
|---|---|---|
| 새 프로젝트, 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 쪽만 다뤄요.
- 같은 SQL 이 반복되는지 봐요. 개발 환경에서 SQL 로그를 켜고 한 요청 동안
where …_id=?모양이 몇 번 찍히는지 세요. 입문서도 SQL 로그를 직접 보기 전에는 영속성 로직이 맞는지 알 수 없다고 강조해요. - 요청당 SQL 수를 숫자로 남겨요.
hibernate.generate_statistics=true로 통계를 켜면Statistics#getPrepareStatementCount()로 SQL 준비 횟수를 읽을 수 있어요. 운영에서 상시로 켜기보다 스테이징·부하 테스트에서 써요. - 어디서 터지는지 찾아요. open-in-view 가 켜져 있으면 컨트롤러나 JSON 직렬화 중에 쿼리가 나가요. 응답 DTO 변환 코드, 직렬화되는 엔티티의 getter 를 먼저 봐요.
- EAGER 기본값을 확인해요. 컬렉션을 건드리지 않았는데 쿼리가 반복되면
@ManyToOne·@OneToOne의 기본 EAGER 가 원인인 경우가 많아요. - 외래 키 인덱스를 확인해요. 배치·서브셀렉트로 줄여도
where team_id in (…)가 인덱스를 못 타면 쿼리마다 전체 스캔이에요. - 고친 뒤 테스트로 고정해요. 서비스 메서드를 직렬화까지 통과시킨 뒤 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 는 켜기만 하면 효과가 나서 첫 조치로 좋지만, 한계도 알고 켜야 해요.
- 락을 거는 조회에는 안 먹어요. 사용자 가이드의 배치 페칭 절에 따르면
LockModeType이NONE이 아니면 배치 페칭을 하지 않아요. 비관적 잠금으로 읽는 화면은 join fetch 로 계획해요. - 해결이 아니라 완화예요. 입문서는 배치·서브셀렉트가 N+1 을 누그러뜨릴 뿐 해결하지는 않는다고 적어요. 부모 1,000개에 컬렉션 셋을 건드리면 B=100 이어도 SQL 이 31번이에요.
- 서브셀렉트는 첫 쿼리를 다시 실행해요. 첫 쿼리가 무거운 조인·집계라면 그 비용이 한 번 더 들어요. 첫 쿼리가 가벼울 때 고르는 편이 맞아요.
- 애그리거트 경계가 크면 근본 원인이 따로 있어요. 화면마다 연관을 다섯 단계씩 따라가야 한다면 엔티티 그래프가 너무 큰 거예요. 경계를 줄이는 기준은 DDD 애그리거트 경계 판단 글에 정리해 뒀어요.
자주 묻는 질문
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 로 예외를 덮는 방법은 공식 문서가 안전하지 않다고 분류해요.
정리
- 연관은 LAZY 로 매핑하고 해결책은 쿼리마다 골라요. 읽기 전용이면 DTO, 단일 값 연관이면 join fetch, 컬렉션 하나·페이징 없음이면 컬렉션 join fetch, 페이징·컬렉션 둘 이상이면 부모 페이징 + 배치 페칭이에요.
- SQL 수는 지연 로딩 1+N, 배치 1+⌈N/B⌉, 서브셀렉트 2, join fetch 1이에요. batch size 는 한 화면의 부모 수를 기준으로 정하고, DB 파라미터 한도를 넘으면 하이버네이트가 줄여요.
- 컬렉션 fetch join + 페이징은 메모리 페이징, 컬렉션 둘 fetch join 은 카티전 곱이에요. 각각
fail_on_pagination_over_collection_fetch와 "한 쿼리 한 컬렉션" 규칙으로 막아요. - open-in-view 는 새 API 서버라면 끄고, 요청당 SQL 수를 테스트로 고정해 회귀를 막아요.
더 깊이 읽기
이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 JPA N+1 문제 원리 — 지연 로딩·fetch join·배치 사이즈에서 도식과 코드로 단계별로 풀었어요.