본문 바로가기
Java

Spring Boot JPA N+1 쿼리 진단과 해결: generate_statistics, fetch join, @EntityGraph, @BatchSize

¯¯\_(ツ)_/¯·2026년 7월 29일·조회 5

코드 리뷰나 성능 점검을 하다 보면 JPA N+1 얘기를 반복해서 하게 된다. 목록 화면 하나 열었을 때 쿼리가 몇 개 나가는지 물어보면 답이 바로 안 나오는 경우가 많고, 원인을 찾은 뒤에도 fetch join과 @EntityGraph, @BatchSize 중 무엇을 써야 하는지에서 다시 막힌다. 그래서 진단부터 해결책 선택까지 한 번에 정리해 두려고 이 글을 쓴다.

N+1 쿼리는 목록을 한 번 조회한 뒤 각 행의 연관 엔티티를 한 건씩 다시 조회해서 JDBC statement가 1+N개로 늘어나는 문제다. 게시글 20건을 조회했는데 작성자 select가 20번 추가로 실행되면 statement는 21개가 된다.

진단은 hibernate.generate_statistics=true를 켜고 Hibernate Statistics의 statement 수를 세는 것으로 충분하다.

해결 방법은 네 가지로 갈린다.

  • ToOne 연관과 컬렉션 1개: left join fetch 또는 @EntityGraph
  • 컬렉션이 여러 개거나 페이징을 함께 쓸 때: @BatchSize
  • 읽기 전용 화면: DTO 프로젝션

아래에서는 N+1이 생기는 지점, 통계로 재는 방법, 네 가지 해결책과 선택 기준을 차례로 살펴본다.

1. 기준 환경

이 글의 명령과 출력은 다음 조합에서 확인한 것이다.

  • Spring Boot 3.5.x, Spring Data JPA
  • Hibernate ORM 6.6.x
  • PostgreSQL 16, JDK 21

2026년 7월 현재 Spring Boot 3.4와 3.5는 OSS 지원이 종료된 라인이고 현행은 4.x(Hibernate ORM 7.x)다.

여기서 다루는 진단 방법과 해결 기법은 7.x에서도 그대로 유효하다. 7.x에서 동작이 달라진 부분은 6절에서 따로 짚는다. 버전 번호와 SQL 컬럼 나열 순서는 저장소 상태와 방언 구현에 따라 다를 수 있다.

예제 도메인은 Post, Author, Comment 세 개다.

@Entity
@Table(name = "post")
public class Post {
    @Id @GeneratedValue
    private Long id;

    private String title;

    private Instant createdAt;

    // author_id는 nullable = true다. 익명 글이 존재한다.
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "author_id")
    private Author author;

    @OneToMany(mappedBy = "post")
    private Set<Comment> comments = new LinkedHashSet<>();
}

@ManyToOne@OneToOne의 JPA 기본값은 EAGER다. 기본값을 그대로 두면 해당 엔티티를 조회하는 쿼리마다 Hibernate가 연관을 채우려고 추가 select를 실행할 수 있다.

그래서 ToOne 연관은 전부 LAZY로 내려놓고, 필요한 쿼리에서만 명시적으로 함께 가져오는 방식이 유지보수에 유리하다.

2. N+1이 실제로 어떻게 생기는가

가장 흔한 형태는 목록을 조회한 뒤 반복문 안에서 연관 엔티티의 필드를 꺼내는 코드다.

List<Post> posts = postRepository.findAll();      // 1번
for (Post p : posts) {
    // 프록시 초기화 시점마다 select 1번
    log.info("{} / {}", p.getTitle(), p.getAuthor().getName());
}

게시글 20건에 작성자가 모두 다르면 SQL은 이렇게 나간다. 가독성을 위해 컬럼 목록은 줄였다.

select p1_0.id,p1_0.author_id,p1_0.created_at,p1_0.title from post p1_0
select a1_0.id,a1_0.email,a1_0.name from author a1_0 where a1_0.id=?
select a1_0.id,a1_0.email,a1_0.name from author a1_0 where a1_0.id=?
... (총 20번 반복)

막상 돌려보면 여기서 한 번 걸린다. 서비스 계층에 @Transactional이 없으면 같은 코드가 N+1이 아니라 예외로 이어진다.

org.hibernate.LazyInitializationException: could not initialize proxy
  [com.example.post.Author#1] - no Session

반대로 spring.jpa.open-in-view가 켜져 있으면(Spring Boot 기본값 true) 뷰 렌더링 시점까지 세션이 열려 있다. 예외는 나지 않고 N+1만 조용히 발생한다.

기동 로그에는 이 경고가 남는다.

WARN  o.s.b.a.o.j.JpaBaseConfiguration$JpaWebConfiguration :
  spring.jpa.open-in-view is enabled by default. Therefore, database queries
  may be performed during view rendering. Explicitly configure
  spring.jpa.open-in-view to disable this warning

N+1을 찾을 때는 spring.jpa.open-in-view: false로 둔다. 지연 로딩 누락이 예외로 즉시 드러난다.

3. hibernate.generate_statistics로 진단한다

Hibernate 통계는 설정 프로퍼티 hibernate.generate_statisticstrue일 때 수집되고, 기본은 비활성이다. Spring Boot에서는 spring.jpa.properties. 접두어를 붙여 전달한다.

# application-local.yml
spring:
  jpa:
    open-in-view: false
    properties:
      hibernate:
        generate_statistics: true
logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.orm.jdbc.bind: TRACE
    org.hibernate.stat: DEBUG
    org.hibernate.engine.internal.StatisticalLoggingSessionEventListener: INFO

org.hibernate.orm.jdbc.bind는 Hibernate 6에서 바인딩 파라미터를 찍는 로거다. Hibernate 5의 org.hibernate.type.descriptor.sql은 6에서 동작하지 않는다.

통계를 켜면 세션이 닫힐 때 Session Metrics가 남는다. 시간 값은 환경마다 달라 생략했다.

Session Metrics {
    ... nanoseconds spent acquiring 1 JDBC connections;
    ... nanoseconds spent releasing 0 JDBC connections;
    ... nanoseconds spent preparing 21 JDBC statements;
    ... nanoseconds spent executing 21 JDBC statements;
    ... nanoseconds spent executing 0 JDBC batches;
    ... nanoseconds spent executing 0 flushes (flushing a total of 0 entities and 0 collections);
    ... nanoseconds spent executing 0 partial-flushes (flushing a total of 0 entities and 0 collections)
}

statement 21개, 게시글 20건 조회에 작성자 select 20번이 붙었다는 뜻이다.

로그를 눈으로 세는 대신 코드에서 직접 뽑으면 더 명확하다.

SessionFactory sf = entityManager.getEntityManagerFactory()
                                 .unwrap(SessionFactory.class);
Statistics stats = sf.getStatistics();
stats.clear();

postRepository.findAll().forEach(p -> p.getAuthor().getName());

System.out.printf("statements=%d queries=%d entityLoads=%d collectionFetches=%d%n",
        stats.getPrepareStatementCount(),
        stats.getQueryExecutionCount(),
        stats.getEntityLoadCount(),
        stats.getCollectionFetchCount());
statements=21 queries=1 entityLoads=40 collectionFetches=0

지표를 읽는 기준은 이렇다.

  • getPrepareStatementCount(): 획득한 PreparedStatement 개수. N+1 판정의 1차 지표다.
  • getQueryExecutionCount(): 실행된 쿼리 개수. 프록시 초기화로 나가는 단건 select는 여기 잡히지 않는다. 그래서 위 출력에서 1로 나온다. statement 수와 이 값의 격차가 크면 지연 로딩이 터지고 있다는 신호다.
  • getEntityLoadCount(): 로드된 엔티티 총 개수. Post 20 + Author 20 = 40.
  • getCollectionFetchCount(): 별도 쿼리로 페치된 컬렉션 개수. 컬렉션 N+1은 이 값으로 본다.

메서드 이름을 헷갈리기 쉽다. getQueryCount()는 없다. getQueryExecutionCount()다.

통계는 stats.setStatisticsEnabled(true)로 런타임에 켜고 끌 수도 있다.

4. fetch join으로 statement를 1개로 줄인다

JPQL에서 join fetch를 쓰면 연관 엔티티를 같은 SQL 하나로 채운다.

public interface PostRepository extends JpaRepository<Post, Long> {

    @Query("select p from Post p left join fetch p.author")
    List<Post> findAllWithAuthor();
}
select p1_0.id,a1_0.id,a1_0.email,a1_0.name,p1_0.created_at,p1_0.title
from post p1_0
left join author a1_0 on a1_0.id=p1_0.author_id
statements=1 queries=1 entityLoads=40 collectionFetches=0

엔티티 로드 개수는 40으로 같지만 statement는 21에서 1로 떨어졌다. 응답 지연의 원인은 로드한 데이터양이 아니라 왕복 횟수다.

여기서 left를 빼면 결과 집합이 달라진다. JPQL의 join fetch는 SQL INNER JOIN으로 번역되므로 author_id가 NULL인 익명 글은 결과에서 빠진다.

-- select p from Post p join fetch p.author
select p1_0.id,a1_0.id,a1_0.name,p1_0.title
from post p1_0
join author a1_0 on a1_0.id=p1_0.author_id
-- author_id IS NULL 인 행은 조회되지 않는다

nullable 연관에 join fetch를 쓰면 목록 건수가 조용히 줄어든다. 총 건수가 안 맞는다는 문의를 추적해 보면 이 지점인 경우가 많다.

nullable이면 left join fetch를 쓰고, NOT NULL 제약이 걸린 연관에만 INNER JOIN을 의도적으로 선택한다.

컬렉션도 같은 방식으로 가져온다.

@Query("select p from Post p left join fetch p.comments where p.id in :ids")
List<Post> findAllWithComments(@Param("ids") List<Long> ids);

Hibernate 6부터는 루트 엔티티 참조가 자동으로 중복 제거되므로 컬렉션 fetch join에 distinct를 붙일 필요가 없다. Hibernate 5 시절 습관으로 select distinct p를 남겨두면 SQL에 불필요한 DISTINCT가 붙어 정렬 비용만 늘어난다.

컬렉션을 두 개 이상 fetch join하면 즉시 실패한다.

org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch
  multiple bags: [com.example.post.Post.comments, com.example.post.Post.tags]

연관 타입을 List에서 Set으로 바꾸면 예외는 사라진다. 다만 이번에는 카티전 곱이 커진다. 댓글 30개, 태그 5개면 행이 150개로 불어난다.

컬렉션이 둘 이상이면 하나만 fetch join하고 나머지는 7절의 @BatchSize로 처리한다.

5. @EntityGraph는 LEFT OUTER JOIN이다

@EntityGraph는 JPQL을 쓰지 않고 어떤 연관을 함께 가져올지 선언한다. Spring Data JPA의 파생 메서드나 findAll 재정의에 그대로 붙는다.

public interface PostRepository extends JpaRepository<Post, Long> {

    @Override
    @EntityGraph(attributePaths = {"author"})
    List<Post> findAll();

    // 중첩 경로도 지정할 수 있다
    @EntityGraph(attributePaths = {"comments", "comments.writer"})
    Optional<Post> findById(Long id);
}

Spring Data JPA의 @EntityGraph는 지정한 경로를 LEFT OUTER JOIN으로 실행한다.

select p1_0.id,a1_0.id,a1_0.email,a1_0.name,p1_0.created_at,p1_0.title
from post p1_0
left join author a1_0 on a1_0.id=p1_0.author_id

@EntityGraph(attributePaths = {"author"})left join fetch p.author와 같은 조인 타입을 만들고, join fetch p.author와는 같지 않다.

둘을 같은 것으로 보고 JPQL을 @EntityGraph로 바꾸거나 그 반대로 옮기면 nullable 연관에서 결과 건수가 바뀔 수 있다. 리팩터링할 때 이 지점을 먼저 확인한다.

EntityGraphType은 두 가지다.

  • FETCH(기본): 그래프에 지정한 속성만 EAGER, 나머지는 매핑 무시하고 LAZY.
  • LOAD: 지정한 속성은 EAGER, 나머지는 엔티티 매핑에 선언된 기본 페치 전략을 따른다.

ToOne 연관을 전부 LAZY로 내려놓았다면 둘의 결과가 같다. 매핑에 EAGER가 남아 있는 레거시 엔티티에서는 LOAD가 추가 select를 끌고 온다. 기본값 FETCH를 그대로 쓴다.

@Query@EntityGraph를 같은 메서드에 함께 붙일 때는 JPQL 쪽에 이미 join을 써두었는지 확인한다. 같은 테이블에 조인이 두 번 생성되는 SQL이 나온다.

6. 페이징과 컬렉션 fetch join을 같이 쓰면 메모리에서 자른다

ToOne 연관은 페이징과 함께 써도 문제가 없다. 조인해도 행 수가 늘지 않기 때문이다.

@EntityGraph(attributePaths = {"author"})
List<Post> findByOrderByCreatedAtDesc(Pageable pageable);

PostgreSQL 16과 Hibernate 6.6 조합에서 생성되는 페이징 SQL은 다음 형태다. limit ? offset ?이 아니다.

select p1_0.id,a1_0.id,a1_0.name,p1_0.created_at,p1_0.title
from post p1_0
left join author a1_0 on a1_0.id=p1_0.author_id
order by p1_0.created_at desc
offset ? rows fetch first ? rows only

문제는 컬렉션이다. left join fetch p.commentsPageable을 같이 주면 Hibernate 6.6은 LIMIT을 SQL에 넣지 못하고 전체 결과를 읽어 JVM에서 자른다.

이때 경고가 로그에 남는다.

WARN  org.hibernate.orm.query : HHH90003004: firstResult/maxResults specified
  with collection fetch; applying in memory

테이블이 작을 때는 지나가지만, 데이터가 쌓이면 OOM으로 이어질 수 있다. 이 경고를 예외로 승격시켜 배포 전에 잡는다.

spring:
  jpa:
    properties:
      hibernate:
        query:
          fail_on_pagination_over_collection_fetch: true

Hibernate 6.6에서 컬렉션과 페이징을 함께 쓸 때의 답은 fetch join이 아니라 @BatchSize다(7절).

참고로 Hibernate ORM 7.4는 이 조합을 중첩 서브쿼리로 개선했다. 부모 식별자를 서브쿼리에서 LIMIT으로 먼저 확정하고 그 부모들에 대해서만 컬렉션을 조인하므로, 컬렉션 join fetch와 setMaxResults()를 함께 써도 DB가 자른다. 예전 동작이 필요하면 쿼리 힌트 org.hibernate.limitInMemory를 준다.

7. @BatchSize로 IN 절 묶음 조회를 만든다

@BatchSize는 N+1 자체를 제거하지는 않고 N을 N/size로 줄인다. 컬렉션이 여러 개거나 페이징을 함께 써야 할 때 쓴다.

@OneToMany(mappedBy = "post")
@BatchSize(size = 10)
private Set<Comment> comments = new LinkedHashSet<>();

게시글 20건을 조회하고 각 건의 comments를 건드리면 SQL은 3개다.

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 offset ? rows fetch first ? rows only
select c1_0.post_id,c1_0.id,c1_0.body from comment c1_0
  where c1_0.post_id in (?,?,?,?,?,?,?,?,?,?)
select c1_0.post_id,c1_0.id,c1_0.body from comment c1_0
  where c1_0.post_id in (?,?,?,?,?,?,?,?,?,?)

방언과 Hibernate 버전에 따라 in (?,?,...) 대신 배열 바인딩 = any (?) 형태로 나올 수 있다. 어느 쪽이든 statement 수가 묶음 단위로 줄어드는 결과는 같다.

연관마다 어노테이션을 붙이는 대신 전역 기본값을 줄 수도 있다. Hibernate는 이 값을 지정하지 않으면 @BatchSize가 명시된 엔티티와 컬렉션에만 배치 페치를 적용한다.

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

전역값은 100 근처가 무난하다. 1000처럼 크게 잡으면 IN 절 파라미터가 폭증해 PostgreSQL 쪽 실행 계획 준비 비용이 커지고, 바인딩 파라미터 개수 상한(65535)에도 걸릴 수 있다.

전역으로 100을 깔아두고, 특정 연관만 다르게 가야 할 때 @BatchSize로 덮어쓴다.

8. 쿼리 수를 테스트로 고정한다

한 번 고친 N+1은 다음 기능 추가에서 다시 돌아온다. statement 수를 단정하는 테스트를 남긴다.

@DataJpaTest
@TestPropertySource(properties = "spring.jpa.properties.hibernate.generate_statistics=true")
class PostRepositoryQueryCountTest {

    @Autowired PostRepository postRepository;
    @Autowired EntityManager em;

    private Statistics stats;

    @BeforeEach
    void setUp() {
        stats = em.getEntityManagerFactory()
                  .unwrap(SessionFactory.class)
                  .getStatistics();
        stats.clear();
    }

    @Test
    void 목록과_작성자를_한_번에_가져온다() {
        // 반환 타입이 List이므로 count 쿼리가 없다
        List<Post> posts =
                postRepository.findByOrderByCreatedAtDesc(PageRequest.of(0, 20));
        posts.forEach(p -> Optional.ofNullable(p.getAuthor())
                                   .ifPresent(Author::getName));

        assertThat(stats.getPrepareStatementCount()).isEqualTo(1);
    }
}

반환 타입에 따라 기대값이 달라진다. Spring Data JPA에서 Page를 반환하는 메서드는 전체 건수를 계산하려고 COUNT(...) 쿼리를 한 번 더 실행한다. SliceList는 실행하지 않는다.

같은 조회를 Page<Post>로 바꾸면 SQL은 두 개다.

select p1_0.id,a1_0.id,a1_0.name,p1_0.created_at,p1_0.title from post p1_0
  left join author a1_0 on a1_0.id=p1_0.author_id
  order by p1_0.created_at desc offset ? rows fetch first ? rows only
select count(p1_0.id) from post p1_0
assertThat(stats.getPrepareStatementCount()).isEqualTo(2);  // 조회 1 + count 1

테스트가 갑자기 2에서 3으로 늘었다면 그 사이 커밋에서 지연 로딩이 하나 추가된 것이다.

총 건수가 필요 없는 무한 스크롤 화면은 Slice로 바꿔 count 쿼리를 제거한다.

9. 무엇을 언제 쓰는가

  • ToOne 연관(작성자, 카테고리): @EntityGraph(attributePaths = {"author"}). 페이징과 함께 써도 안전하다. JPQL을 직접 쓸 이유가 있으면 left join fetch를 쓴다. nullable이면 left를 빼지 않는다.
  • 컬렉션 1개, 페이징 없음(상세 화면): left join fetch p.comments. Hibernate 6 이상이므로 distinct는 붙이지 않는다.
  • 컬렉션이 둘 이상이거나 페이징이 필요: @BatchSize 또는 default_batch_fetch_size. Hibernate 6.6에서 컬렉션 fetch join과 페이징을 섞지 않는다.
  • 읽기 전용 목록 화면: 엔티티를 아예 로드하지 않는 DTO 프로젝션. 필요한 컬럼만 가져오므로 N+1이 구조적으로 생기지 않는다.
public record PostSummary(Long id, String title, String authorName) {}

@Query("""
       select new com.example.post.PostSummary(p.id, p.title, a.name)
       from Post p left join p.author a
       order by p.createdAt desc
       """)
List<PostSummary> findSummaries(Pageable pageable);

작업 순서는 이렇게 잡는다. 통계로 statement 수를 먼저 세고, 목표 쿼리 수를 정한 뒤 위 네 가지 중 하나를 고르고, 그 숫자를 테스트로 못 박는다. 통계를 켜지 않고 SQL 로그만 눈으로 세다가 놓치는 경우가 많다.

운영 환경에서는 generate_statistics를 켜둔 채로 두지 않는다. 로컬과 테스트, 성능 검증 프로파일에만 켜고, 운영에서 필요할 때는 Statistics.setStatisticsEnabled(true)로 잠깐 켜서 확인하고 되돌린다.

자주 묻는 질문

hibernate.generate_statistics를 운영 환경에 켜두어도 되는가?

권장하지 않는다. 이 프로퍼티의 기본값은 비활성이며, 상시 켜두는 대신 로컬과 테스트, 성능 검증 프로파일에만 적용한다. 운영에서 확인이 필요하면 Statistics.setStatisticsEnabled(true)로 런타임에 켜고 확인 후 되돌린다.

join fetch와 @EntityGraph는 서로 바꿔 써도 되는가?

조인 타입이 다르므로 무조건 바꿔 쓸 수는 없다. JPQL의 join fetch는 INNER JOIN, Spring Data JPA의 @EntityGraph는 LEFT OUTER JOIN으로 실행된다. author_id가 NULL인 행이 존재하는 nullable 연관에서 join fetch는 그 행을 결과에서 누락시킨다. 두 방식을 오갈 때는 left join fetch를 쓰거나 조인 타입 차이를 먼저 확인한다.

컬렉션 fetch join에 페이징을 걸면 왜 HHH90003004 경고가 나오는가?

컬렉션을 조인하면 부모 1건이 여러 행으로 늘어나 SQL LIMIT을 그대로 적용할 수 없다. Hibernate 6.6은 전체 결과를 읽어 JVM에서 잘라내고 이 경고를 남긴다. hibernate.query.fail_on_pagination_over_collection_fetch=true로 예외 처리하고 @BatchSize로 바꾼다. Hibernate ORM 7.4부터는 중첩 서브쿼리로 LIMIT을 DB에 밀어넣어 이 조합이 안전해졌다.

테스트에서 기대 쿼리 수가 하나 더 나오는데 원인이 무엇인가?

반환 타입을 확인한다. Spring Data JPA에서 Page를 반환하는 메서드는 전체 건수 계산을 위해 COUNT(...) 쿼리를 추가로 실행한다. Slice와 List는 실행하지 않는다. 총 건수가 필요 없는 화면이면 Slice로 바꿔 count 쿼리를 제거한다.

default_batch_fetch_size는 얼마로 잡는가?

100 근처를 전역 기본값으로 두고 특정 연관만 @BatchSize로 덮어쓴다. 값을 지정하지 않으면 Hibernate는 @BatchSize가 명시된 대상에만 배치 페치를 적용한다. 1000처럼 크게 잡으면 IN 절 파라미터가 늘어나 실행 계획 준비 비용이 커지고 JDBC 바인딩 파라미터 상한에도 걸릴 수 있다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.