기본 콘텐츠로 건너뛰기

JPA N+1 문제, 왜 생기고 어떻게 줄일까: fetch join과 EntityGraph 선택 기준까지

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 로그, 바인드 값, 쿼리 수, 실행 계획까지 같이 봐야 판단할 수 있다.

시간 흐름으로 이해하기

T1
목록 조회 시작
T2
루트 쿼리 실행
T3
연관 로딩 대기
T4
연관 필드 접근
T5
추가 SQL 반복

Post 목록 API는 대개 루트 엔티티만 읽는 SQL로 시작한다. 문제는 그다음 시점이다. DTO 매핑이나 JSON 직렬화에서 post.getAuthor().getName() 같은 코드가 실행되면, 그 순간 연관 엔티티 초기화가 시작되고 결과 건수만큼 비슷한 SQL이 반복될 수 있다.

흐름으로 보기

흐름 다이어그램
JPA N+1 문제, 왜 생기고 어떻게 줄일까: fetch join과 EntityGraph 선택 기준까지 흐름 다이어그램

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() 한 번만 호출하지만, 데이터베이스 관점에서는 보통 두 단계로 보인다.

  1. Post 목록을 읽는 루트 쿼리 실행
  2. Postauthor가 실제로 필요해지는 순간 보조 쿼리 실행

그래서 N+1은 ORM이 비정상적으로 동작해서 생기는 현상이라기보다, "목록 조회 1번 뒤에 행마다 연관 조회가 이어지는 구조"가 SQL로 드러난 결과에 가깝다.

LAZY와 EAGER는 왜 둘 다 N+1에서 안전하지 않은가

LAZY는 문제를 없애기보다 뒤로 미룬다. 목록 조회 직후에는 SQL이 적어 보일 수 있지만, DTO 변환이나 직렬화에서 프록시가 초기화되는 순간 추가 SQL이 나타난다.

EAGER도 자동 해결책은 아니다. Spring Data JPA의 findAll()이나 일반 JPQL 목록 조회는 루트 엔티티 기준으로 실행되고, 조회문에 join fetchEntityGraph가 없으면 provider가 secondary select로 연관을 채울 수 있다. Hibernate User Guide도 entity query에서 EAGER 연관을 모두 JOIN FETCH하지 않으면 secondary select가 발생해 N+1로 이어질 수 있다고 설명한다. 같은 가이드는 연관은 기본적으로 LAZY로 두고, 필요한 조회에서 동적 fetch 전략을 쓰는 방향을 권한다.

조금 다르게 표현하면 이렇다.

  • LAZY: 언제 연관을 읽을지 늦춘다.
  • EAGER: 반환 전에 연관을 채우라고 요구한다.
  • join fetchEntityGraph: 그 연관을 이번 조회에서 어떤 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을 줄이는 출발점은 LAZYEAGER 중 하나를 먼저 고르는 일이 아니라, "이 화면에서 어떤 SQL이 몇 번 실행되는가"를 먼저 확인하는 일에 가깝다. 그다음에 to-one 중심 목록 조회인지, 컬렉션과 페이징이 함께 있는지, DTO 전용 조회가 더 나은지에 따라 fetch join, @EntityGraph, 배치 로딩, 2단계 조회를 나눠 쓰면 쿼리 패턴을 훨씬 예측하기 쉬워진다.

원문 참고

https://www.maeil-mail.kr/question/49

댓글

이 블로그의 인기 게시물

아이콘 폰트 (icomoon 사용법)

 장난감 프로젝트를 만들다 보면, 아이콘이 필요한 경우가 있다. 간단하게 아이콘을 인터넷에서 검색하여, 이미지로 넣어두고 이미지 태그를 이용하여, 사용하는 경우가 일반적이였지만...  요즘에는 대부분 폰트를 이용하여 아이콘을 노출 한다. 나 같은 경우에도 기본적으로  https://material.io/resources/icons 를 참고하여 아이콘 폰트를 이용할 수 있도록 처리하고, 추가적으로 필요한 아이콘이고, 일상적으로 사용 되지 않는 아이콘의 경우에는  https://icomoon.io 에서 제작하여, 아이콘 폰트로 이용 하곤 한다.  그래서 이번에는 아이콘  https://icomoon.io 의 사용법을 간단히 공유하고자 한다.   들어가자 마자 위의 icoMoonApp버튼을 누르면 아래와 같은 화면이 나타난다.  icomoon에서 무료로 제공하는 아이콘들이 보이면 위에 파란색으로 표시 되어있는 집 모양 세가지를 선택한 후, 아래의 빨간색으로 표시되어있는 Generate Font를 눌러보자.  그리고 나서 바로 다운로드를 요청해보자. icomoon.zip이 다운로드가 될텐데, 압축을 해제해 보면, 아래의 폴더 및 파일들이 있다. 아래에서 중요한 것은 font 폴더와 style.css이다. demo-files fonts demo.html Read Me.txt selection.json style.css <!doctype html > <html> <head> <link rel ="stylesheet" href ="style.css" ></head> </head> <body> <span class ="icon-home" ></span> <span class ="icon-home2" ></span> <span class ="icon-hom...

Chart js와 amchart 비교

Chart js 특징은 위의 그림으로 대체 할 수 있을 듯 하다. 오픈 소스이고, 기본으로 제공하는 차트 종류가 8가지 Canv a s를 이용해서 차트를 그리고, 반응형을 지원한다. amchart amchart는 기본적으로 유료이며, 기본으로 제공하는 차트 종류가 기본적인 차트 + 주식 처럼 보이는 차트 + 지도에 관련된 차트(?) 까지 하면, 기본 제공 하는 종류가 20개 내외 이려나, 일일이 세기에는 양이 좀 많아 보인다. 렌더링은 svg를 통하여 그려지고, 당연 반응형도 지원이 된다. 그러면, 이 둘중에 어떤것이 내 프로젝트에 적합 하냐는 것이 문제이다. 일단, 주식 처럼 보이는 차트나 지도에 관련된 차트(?)가 필요하면, amchart를 선택해야 되는 것은 맞다. 그건 당연한 것이니 빼고 얘기 해보자! 여러 종류의 차트가 필요하다면, 일단은 amchart를 염두해 두는 것이 좋다. 돈 낸 만큼은 하는 듯 하다. 하지만, 기본적인 막대 그래프, 도넛 차트 등, 아주 기본적인 차트들인데, Chart js도 amchart도 그러한 차트가 없을 때가 문제가 된다. 그렇다면, 조금이라도 커스텀이 용이한 것을 찾는 것이 좋을 것이다.  일단 amchart에서 custom이라고 검색 하였을 때, 검색 결과가 61가지가 나온다. 차트의 종류도 많고, 각 차트마다 들어가는 속성이 매우 많기 때문에, 웬만한 내용들은 속성 값을 어떻게 주느냐에 따라서 변경이 가능 하게 된다. 커스텀의 예를 들면, 기본적으로 도넛 파이의 형태를 띄면서, 화살표로 목표를 표시해주는 차트가 필요하다고 생각 해보자. 이것은 amchart로 만든 그래프이고 이것은 chart js로 만든 그래프이다. 모양이 살짝 다르긴 하지만, 완벽하게 똑같이 구현 할 수도 있다. amchart로 만든 그래프의 경우, 저것은 도넛그래프가 아닌 guage 그래프이다. 원래 게이지 그래프는 이와 같...

javascript 압축 파일 다운로드

이번에는 전 게시글의 응용판? 이라고 해야하나....? 어쨋든! 우리는 각각의 파일들을 다운로드 해보았다. 그런데 생각보다 귀찮음?을 느꼇을 것이다. 파일을 각각 다운 받아야 한다는 현실때문에! 그래 파일 두개야 뭐 그렇다 치지... 하지만, 개발자도 사용자도 게으름뱅이이다. 자 결국, 우리가 해야 하는 것은 파일을 한 번에 둘다! 다운 받는 것이다. 물론, 클릭 한번에 여러개의 함수를 엮어서 다운받게 하면 되지만! 크롬에서 자주 봤듯이, 여러개의 파일을 다운로드를 시도하면 <- 여러개의 파일을 다운로드 합니다. 허용 합니까? 하고 물어보는 것을 볼 수 있다. 게다가 다운로드 한 파일들을 찾기도 귀찮다는 것. 자 해결책을 제시해보자면, https://github.com/Stuk/jszip 클라이언트 단에서 파일을 zip파일로 압축을 할 수가 있다! 필요한 작업은 아래와 같다. 0. 데이터 준비 1. BLOB(binary large object)를 만든다. 2. Blob을 URL.createObjectURL을 사용하여, 해당 binary의 주소를 생성. 3. 다운로드가 필요한 파일들을 Zip 객체에 셋팅! 4. a태그를 이용하여, 해당 url 셋팅 하고, 다운로드. 전 게시물과 별로 달라진게 없네... 자 그럼 샘플! 샘플을 보자! http://embed.plnkr.co/NMprnRxqYG0fkHa2J55D/ var util = {} function fixBinary(bin) { //binary to arrayBuffer var length = bin.length var buf = new ArrayBuffer(length) var arr = new Uint8Array(buf) for (var i = 0; i < length; i++) { arr[i] = bin.charCodeAt(i) } return buf } window.onload = function() { ...