JPA N+1 문제, 왜 생기고 어떻게 줄일까: fetch join과 EntityGraph 선택 기준까지
빠른 답
EAGER는 N+1의 해결책이 아니라 연관 조회 시점을 앞당기는 설정에 가깝다. JPQL이나 Spring Data JPA 조회 메서드가 조인을 자동 보장하지는 않는다.LAZY는 문제를 없애는 방식이 아니라 미루는 방식이다. 목록을 읽은 뒤 연관 필드를 순회하는 순간 N+1이 다시 나타날 수 있다.- 목록 조회 최적화는
fetch join,@EntityGraph, 배치 로딩(@BatchSize,hibernate.default_batch_fetch_size)을 조회 형태와 페이징 요구사항에 맞춰 나눠 쓰는 편이 낫다. - 최적화 여부는 SQL 로그, 바인드 값, 쿼리 수, 실행 계획까지 같이 봐야 판단할 수 있다.
목차
시간 흐름으로 이해하기
Post 목록 API는 대개 루트 엔티티만 읽는 SQL로 시작한다. 문제는 그다음 시점이다. DTO 매핑이나 JSON 직렬화에서 post.getAuthor().getName() 같은 코드가 실행되면, 그 순간 연관 엔티티 초기화가 시작되고 결과 건수만큼 비슷한 SQL이 반복될 수 있다.
흐름으로 보기
N+1은 "첫 쿼리 하나가 느린 문제"라기보다 "첫 쿼리 뒤에 반복 SQL이 따라붙는 구조"에 가깝다. 그래서 단일 SQL의 실행 계획만 보면 놓치기 쉽고, 쿼리 개수와 호출 시점을 함께 봐야 원인이 선명해진다.
패치가 아니라 fetch 전략이다
원문의 "글로벌 패치 전략"은 현재 기준으로는 "매핑 레벨 fetch 전략"이라고 보는 편이 맞다. 여기서부터 구분이 흐려지면 EAGER, join fetch, @EntityGraph를 비슷한 도구로 오해하기 쉽다.
FetchType.LAZY,FetchType.EAGER: 엔티티 매핑에 적는 기본 정책이다.join fetch: JPQL/HQL에서 특정 조회에만 적용하는 조인 지시다.@EntityGraph: 조회 시점의 fetch plan을 선언하는 방식이다.
Jakarta Persistence 3.2 API에 따르면 EAGER는 즉시 가져오라는 요구사항이고, LAZY는 지연 로딩에 대한 힌트다. 둘 다 "항상 한 번의 SQL로 끝난다"는 뜻은 아니다. N+1은 이 차이에서 자주 시작된다.
N+1이 생기는 구조를 쿼리 관점에서 보기
다음과 같은 목록 조회 코드는 평범해 보이지만, 루트 조회와 연관 초기화가 분리돼 있다.
@Entity
class Post {
@Id
private Long id;
private String title;
private LocalDateTime createdAt;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private Author author;
}
@Transactional(readOnly = true)
public List<PostSummary> findSummaries() {
return postRepository.findAll().stream()
.map(post -> new PostSummary(
post.getId(),
post.getTitle(),
post.getAuthor().getName()
))
.toList();
}
애플리케이션 코드는 findAll() 한 번만 호출하지만, 데이터베이스 관점에서는 보통 두 단계로 보인다.
Post목록을 읽는 루트 쿼리 실행- 각
Post의author가 실제로 필요해지는 순간 보조 쿼리 실행
그래서 N+1은 ORM이 비정상적으로 동작해서 생기는 현상이라기보다, "목록 조회 1번 뒤에 행마다 연관 조회가 이어지는 구조"가 SQL로 드러난 결과에 가깝다.
LAZY와 EAGER는 왜 둘 다 N+1에서 안전하지 않은가
LAZY는 문제를 없애기보다 뒤로 미룬다. 목록 조회 직후에는 SQL이 적어 보일 수 있지만, DTO 변환이나 직렬화에서 프록시가 초기화되는 순간 추가 SQL이 나타난다.
EAGER도 자동 해결책은 아니다. Spring Data JPA의 findAll()이나 일반 JPQL 목록 조회는 루트 엔티티 기준으로 실행되고, 조회문에 join fetch나 EntityGraph가 없으면 provider가 secondary select로 연관을 채울 수 있다. Hibernate User Guide도 entity query에서 EAGER 연관을 모두 JOIN FETCH하지 않으면 secondary select가 발생해 N+1로 이어질 수 있다고 설명한다. 같은 가이드는 연관은 기본적으로 LAZY로 두고, 필요한 조회에서 동적 fetch 전략을 쓰는 방향을 권한다.
조금 다르게 표현하면 이렇다.
LAZY: 언제 연관을 읽을지 늦춘다.EAGER: 반환 전에 연관을 채우라고 요구한다.join fetch와EntityGraph: 그 연관을 이번 조회에서 어떤 SQL shape로 읽을지 지정한다.
N+1은 로딩 시점의 문제가 아니라, "이번 조회에서 연관을 어떻게 읽을지 명시하지 않은 상태"에서 더 자주 드러난다.
findAll 호출 뒤에 실제 SQL이 어떻게 늘어나는가
게시글 3건을 읽고 각 게시글의 작성자 이름을 DTO에 담을 때는 보통 이런 로그가 보인다.
2026-04-07 10:12:31 DEBUG org.hibernate.SQL -
select p1_0.id, p1_0.author_id, p1_0.created_at, p1_0.title
from post p1_0
order by p1_0.created_at desc
limit ?
2026-04-07 10:12:31 TRACE org.hibernate.orm.jdbc.bind -
binding parameter [1] as [INTEGER] - [3]
2026-04-07 10:12:31 DEBUG org.hibernate.SQL -
select a1_0.id, a1_0.name
from author a1_0
where a1_0.id=?
2026-04-07 10:12:31 TRACE org.hibernate.orm.jdbc.bind -
binding parameter [1] as [BIGINT] - [12]
2026-04-07 10:12:31 DEBUG org.hibernate.SQL -
select a1_0.id, a1_0.name
from author a1_0
where a1_0.id=?
2026-04-07 10:12:31 TRACE org.hibernate.orm.jdbc.bind -
binding parameter [1] as [BIGINT] - [18]
2026-04-07 10:12:31 DEBUG org.hibernate.SQL -
select a1_0.id, a1_0.name
from author a1_0
where a1_0.id=?
2026-04-07 10:12:31 TRACE org.hibernate.orm.jdbc.bind -
binding parameter [1] as [BIGINT] - [27]
첫 번째 SQL은 루트 쿼리다. 그 뒤의 세 개는 author를 초기화하기 위한 보조 쿼리다. 목록이 100건이면 비슷한 패턴이 더 길어진다.
다만 추가 쿼리 수가 항상 정확히 N은 아니다. 같은 영속성 컨텍스트 안에서 이미 읽은 author라면 1차 캐시가 재사용되기 때문이다. 그래서 실제로는 "결과 건수"보다 "초기화가 필요한 고유 연관 수"에 가까운 숫자가 찍히기도 한다. 그래도 결과 수에 따라 보조 쿼리가 함께 늘어난다면, 목록 API에서는 N+1로 보는 편이 맞다.
실행 계획과 인덱스로 보면 왜 더 느려지는가
N+1이 까다로운 이유는 보조 쿼리 하나만 떼어 보면 대개 매우 싸기 때문이다. author.id로 찾는 조회는 기본 키 인덱스를 잘 타면 몇 밀리초도 안 걸릴 수 있다. 문제는 그 비용이 반복 횟수만큼 누적된다는 점이다.
Limit (actual time=0.031..0.059 rows=20 loops=1)
-> Index Scan Backward using idx_post_status_created_at on post p
Index Cond: (status = 'PUBLISHED')
Index Scan using author_pkey on author a (actual time=0.004..0.005 rows=1 loops=20)
Index Cond: (id = $1)
여기서 눈여겨볼 부분은 author_pkey를 탔다는 사실보다 loops=20이다. 단건 조회는 싸지만, JDBC 호출, 네트워크 왕복, 파라미터 바인딩, 커넥션 점유가 20번 반복되면 API 지연 시간이 눈에 띄게 늘어난다.
컬렉션 연관에서는 인덱스 영향이 더 직접적이다. 예를 들어 post.comments를 묶어서 가져오려면 comment(post_id) 같은 외래 키 인덱스가 있어야 한다. 이 인덱스가 없으면 join fetch, 배치 로딩, SUBSELECT 어느 쪽을 선택하든 결국 큰 스캔 비용으로 돌아온다. N+1 대응은 ORM 설정만의 문제가 아니라, 최종 SQL이 어떤 인덱스를 타는지까지 함께 봐야 한다.
fetch join, EntityGraph, 배치 로딩은 어떻게 나눠 쓰나
세 방법은 목적이 비슷해 보여도 사용하는 층과 부작용이 조금 다르다.
fetch join: JPQL에 조인을 직접 적는다. SQL shape를 가장 명시적으로 통제할 수 있다.@EntityGraph: Repository 메서드 구조를 유지하면서 fetch plan만 바꾸고 싶을 때 편하다.- 배치 로딩: N+1을 한 번에 없애기보다
1 + ceil(N / batchSize)에 가깝게 줄인다. 페이지 조회와 궁합이 좋은 편이다.
코드로는 이렇게 나눠 쓸 수 있다.
public interface PostRepository extends JpaRepository<Post, Long> {
@Query("""
select p
from Post p
join fetch p.author
where p.status = :status
order by p.createdAt desc
""")
List<Post> findFeedWithAuthor(PostStatus status);
@EntityGraph(attributePaths = "author", type = EntityGraph.EntityGraphType.FETCH)
Page<Post> findByStatus(PostStatus status, Pageable pageable);
}
선택 기준은 조회 형태에 따라 달라진다.
- 여러
to-one연관을 함께 읽는 목록 조회라면fetch join이나@EntityGraph가 비교적 단순하다. - 같은 메서드 이름과 파생 쿼리를 유지하고 싶다면
@EntityGraph가 덜 침습적이다. - 페이지네이션이 중요하거나
to-many컬렉션이 붙으면 배치 로딩이나 2단계 조회가 더 안정적으로 보일 때가 많다.
Spring Data JPA @EntityGraph API에 따르면 attributePaths를 지정한 동적 그래프를 지원하고, 기본 타입은 FETCH다. 또 EntityGraphType 문서에서 FETCH는 그래프에 없는 속성을 지연 로딩처럼 다루고, LOAD는 매핑 기본값을 유지한 채 그래프의 속성만 더 적극적으로 읽는 의미로 설명한다.
설정과 검증 예시
최적화가 실제로 효과가 있었는지는 로그와 통계로 확인하는 편이 낫다. Hibernate 6.x 계열에서는 다음 정도 설정이면 흐름을 읽기 쉽다.
spring:
jpa:
properties:
hibernate.format_sql: true
hibernate.highlight_sql: true
hibernate.generate_statistics: true
hibernate.default_batch_fetch_size: 100
logging:
level:
org.hibernate.SQL: debug
org.hibernate.orm.jdbc.bind: trace
org.hibernate.stat: debug
이 설정으로는 세 가지를 같이 볼 수 있다.
- 어떤 SQL이 몇 번 실행됐는지
- 바인드 값이 무엇이었는지
- 배치 로딩 이후 보조 쿼리 수가 실제로 줄었는지
목록 100건 조회에서 author를 배치 로딩으로 묶으면, 로그는 1 + 100 대신 1 + 1 또는 1 + 2 정도로 바뀌는 경우가 많다. 반대로 EAGER로 바꿨는데 보조 쿼리 수가 그대로라면, 조회 전략은 앞당겨졌지만 SQL shape는 바뀌지 않았다고 해석하는 편이 맞다.
자주 부딪히는 함정과 현재 기준 버전 차이
가장 자주 부딪히는 함정은 컬렉션 fetch join과 페이징을 같이 쓰는 경우다. Post 하나에 Comment 여러 개가 붙으면 SQL 결과 행은 게시글 수가 아니라 "게시글 x 댓글 수"로 불어난다. 이 상태에서 limit이나 Pageable을 적용하면 원하는 루트 엔티티 개수가 아니라 조인된 행 일부만 잘릴 수 있다. Hibernate User Guide도 제한이나 페이지가 걸린 쿼리에서는 fetch join 사용을 대체로 피하라고 안내한다. 특히 to-many 컬렉션 fetch join과 페이징 조합이 더 민감하고, to-one join fetch는 결과 행 수를 불리지 않아 상대적으로 다루기 쉽지만 정렬과 제한 조건이 복잡하면 실제 SQL을 확인하는 편이 안전하다.
이 경우에는 보통 다음 대안이 더 잘 맞는다.
- 루트 ID만 먼저 페이지로 조회한 뒤, 두 번째 쿼리에서
where p.id in :ids로 연관을 읽는 2단계 조회 - 컬렉션은
LAZY로 두고hibernate.default_batch_fetch_size나@BatchSize로 묶어서 읽기 - 엔티티 그래프 대신 DTO 전용 쿼리로 필요한 컬럼만 바로 조회하기
버전 차이도 같이 봐둘 필요가 있다. 2026년 4월 기준으로 오래된 글과 달라진 지점은 다음 정도가 자주 눈에 띈다.
javax.persistence예시는 현재 Spring Boot 3 이상 프로젝트와 맞지 않는다. Spring Boot 3.0 Migration Guide는 Jakarta EE 10 채택과jakarta.*패키지 전환, Hibernate 6.1 기본 사용을 함께 설명한다.FetchType의 의미는 현재 Jakarta Persistence 3.2 API 기준으로 읽는 편이 안전하다.LAZY는 힌트이고EAGER는 요구사항이지만, 둘 다 조인 한 번을 보장하지는 않는다.- fetch join의 결과 행 중복과 의미는 Jakarta Persistence 3.2 스펙에서 확인할 수 있다. 컬렉션을 fetch join하면 부모 엔티티 참조가 중복되어 돌아올 수 있다는 점이 여기서 드러난다.
- Hibernate 5 시절 글에서 자주 보이던
QueryHints#HINT_PASS_DISTINCT_THROUGH=false팁은 Hibernate 6 이후 그대로 적용되지 않는다. Hibernate 6.0 Migration Guide는 부모 엔티티 중복 필터링 동작이 바뀌었고, 해당 플래그가 제거됐다고 안내한다.
N+1을 줄이는 출발점은 LAZY와 EAGER 중 하나를 먼저 고르는 일이 아니라, "이 화면에서 어떤 SQL이 몇 번 실행되는가"를 먼저 확인하는 일에 가깝다. 그다음에 to-one 중심 목록 조회인지, 컬렉션과 페이징이 함께 있는지, DTO 전용 조회가 더 나은지에 따라 fetch join, @EntityGraph, 배치 로딩, 2단계 조회를 나눠 쓰면 쿼리 패턴을 훨씬 예측하기 쉬워진다.
원문 참고
https://www.maeil-mail.kr/question/49
댓글
댓글 쓰기