Published on

Spring Boot JPA N+1 폭발, EntityGraph로 끝내기

Authors

서버 성능 이슈를 파고들다 보면 의외로 자주 만나는 게 JPA의 N+1 문제입니다. 처음엔 “쿼리가 조금 더 나가네?” 정도로 보이지만, 트래픽이 붙거나 목록 API가 커지는 순간 DB 커넥션과 CPU가 급격히 소모되며 장애로 이어질 수 있습니다. 특히 @OneToMany / @ManyToOne 연관이 섞인 도메인에서, 화면 요구사항(목록에 작성자, 댓글 수, 최근 댓글 등)을 맞추려다 보면 N+1이 “폭발” 형태로 나타납니다.

이 글에서는 N+1의 정확한 발생 원리와 흔한 오해를 짚고, EntityGraph로 필요한 연관만 “명시적으로” 페치해서 쿼리 수를 안정적으로 제어하는 방법을 정리합니다.

참고로 이런 유형의 문제는 데이터 처리에서도 비슷하게 나타납니다. 예를 들어 pandas merge에서 키 중복으로 행이 폭증하는 현상은, 원인(카디널리티)과 증상(행/쿼리 폭발)이 닮아 있습니다. 원리 중심으로 접근하면 해결이 빨라집니다: pandas merge 후 행 폭증·NaN - 키 중복 5분 진단

N+1이 생기는 진짜 이유

N+1은 보통 아래 조건이 겹칠 때 발생합니다.

  1. 루트 엔티티 목록을 조회하는 쿼리 1번 실행
  2. 루트 엔티티의 연관 필드가 LAZY 로딩
  3. 루트 목록을 순회하면서 연관 필드에 접근
  4. 접근 시점에 프록시 초기화가 일어나며 엔티티마다 추가 쿼리 실행

즉, “연관을 LAZY로 해뒀으니 안전하다”가 아니라, “LAZY인데 접근하면 그 순간 쿼리가 나간다”가 정확한 이해입니다.

폭발 패턴: N+1이 아니라 N*M+1이 되는 경우

예를 들어 게시글 목록 20개를 조회하고, 각 게시글의 작성자(다대일)와 댓글들(일대다)을 화면에 보여준다고 합시다.

  • 게시글 목록: 1
  • 작성자 로딩(게시글마다 1번): +20
  • 댓글 로딩(게시글마다 1번): +20
  • 댓글의 작성자까지 접근(댓글마다 1번): 댓글 총 200개면 +200

이렇게 되면 단순 N+1이 아니라, 연쇄 접근으로 쿼리가 계단식으로 늘어납니다.

재현용 예제 도메인

아래는 전형적인 게시판 도메인입니다.

@Entity
public class Post {
    @Id @GeneratedValue
    private Long id;

    private String title;

    @ManyToOne(fetch = FetchType.LAZY)
    private Member author;

    @OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
    private List<Comment> comments = new ArrayList<>();
}

@Entity
public class Comment {
    @Id @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    private Post post;

    @ManyToOne(fetch = FetchType.LAZY)
    private Member author;

    private String content;
}

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    private String name;
}

서비스에서 흔히 하는 코드가 아래처럼 생깁니다.

@Transactional(readOnly = true)
public List<PostSummaryDto> listPosts() {
    List<Post> posts = postRepository.findTop20ByOrderByIdDesc();

    return posts.stream()
        .map(p -> new PostSummaryDto(
            p.getId(),
            p.getTitle(),
            p.getAuthor().getName(),
            p.getComments().size()
        ))
        .toList();
}

이 코드는 겉보기엔 깔끔하지만,

  • p.getAuthor().getName() 에서 작성자 로딩
  • p.getComments().size() 에서 댓글 컬렉션 로딩

이 두 지점이 N+1 트리거가 됩니다.

EntityGraph란 무엇이고 왜 유효한가

EntityGraph는 “이번 쿼리에서는 어떤 연관을 함께 로딩할지”를 선언적으로 지정하는 기능입니다.

핵심 장점은 다음과 같습니다.

  • 코드 레벨에서 페치 전략을 제어할 수 있음(글로벌 EAGER를 피함)
  • JPQL에 fetch join을 남발하지 않고도 특정 조회에 최적화 가능
  • 리포지토리 메서드 단위로 적용 가능하여 영향 범위가 좁음

즉, 화면/유스케이스별로 “필요한 연관만” 가져오게 만들기 좋습니다.

해결 1: @EntityGraph로 다대일(작성자) N+1 제거

다대일(@ManyToOne)은 조인으로 함께 가져오기 쉬워서 EntityGraph의 효과가 즉시 보입니다.

public interface PostRepository extends JpaRepository<Post, Long> {

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

이제 작성자는 게시글 조회 쿼리에서 조인으로 함께 로딩됩니다. 결과적으로 작성자 때문에 발생하던 추가 쿼리가 사라집니다.

주의: 컬렉션까지 한 번에 가져오면 항상 좋은가

@OneToMany 같은 컬렉션을 무턱대고 함께 페치하면 다른 문제가 생길 수 있습니다.

  • 조인으로 인해 결과 행이 “댓글 수만큼” 늘어남(루트 엔티티 중복)
  • 페이징이 깨질 수 있음(특히 fetch join + OneToMany 조합)
  • 중복 제거를 위해 distinct가 필요하거나, 메모리 사용량이 늘어남

그래서 목록 화면에서 댓글 “내용”이 아니라 “개수”만 필요하다면, 컬렉션 자체를 페치하는 대신 집계 쿼리로 푸는 게 더 안전합니다.

해결 2: 댓글 개수는 컬렉션 페치 대신 집계로

목록에서 comments.size() 때문에 컬렉션 로딩이 발생한다면, 다음처럼 집계로 분리하는 접근이 실무에서 안정적입니다.

(1) 게시글 목록은 작성자만 EntityGraph로 가져오기

public interface PostRepository extends JpaRepository<Post, Long> {

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

(2) 댓글 수는 group by로 한 번에 가져오기

public interface CommentRepository extends JpaRepository<Comment, Long> {

    @Query("""
        select c.post.id, count(c)
        from Comment c
        where c.post.id in :postIds
        group by c.post.id
    """)
    List<Object[]> countByPostIds(@Param("postIds") List<Long> postIds);
}

(3) 서비스에서 매핑

@Transactional(readOnly = true)
public List<PostSummaryDto> listPosts() {
    List<Post> posts = postRepository.findTop20ByOrderByIdDesc();

    List<Long> postIds = posts.stream().map(Post::getId).toList();

    Map<Long, Long> commentCountMap = commentRepository.countByPostIds(postIds).stream()
        .collect(Collectors.toMap(
            row -> (Long) row[0],
            row -> (Long) row[1]
        ));

    return posts.stream()
        .map(p -> new PostSummaryDto(
            p.getId(),
            p.getTitle(),
            p.getAuthor().getName(),
            commentCountMap.getOrDefault(p.getId(), 0L)
        ))
        .toList();
}

이 방식은 쿼리 수를 1 + 1로 고정시키고, 컬렉션 로딩을 원천 차단합니다.

해결 3: 정말로 컬렉션이 필요하면 EntityGraph + 설계 제약을 이해하기

상세 화면처럼 “게시글과 댓글 목록”이 꼭 필요하다면 컬렉션 페치가 유효합니다. 다만 페이징과 중복 행 문제를 이해하고 써야 합니다.

Named EntityGraph로 명시하기

@Entity
@NamedEntityGraph(
    name = "Post.withAuthorAndComments",
    attributeNodes = {
        @NamedAttributeNode("author"),
        @NamedAttributeNode("comments")
    }
)
public class Post {
    @Id @GeneratedValue
    private Long id;

    private String title;

    @ManyToOne(fetch = FetchType.LAZY)
    private Member author;

    @OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
    private List<Comment> comments = new ArrayList<>();
}

리포지토리에서 사용:

public interface PostRepository extends JpaRepository<Post, Long> {

    @EntityGraph(value = "Post.withAuthorAndComments")
    Optional<Post> findById(Long id);
}

이렇게 하면 상세 조회에서 작성자/댓글을 한 번에 가져올 수 있습니다.

한계: 댓글의 작성자까지 필요하면

comments.author처럼 2단계 연관까지 필요하면 subgraph가 필요합니다.

@Entity
@NamedEntityGraph(
    name = "Post.detail",
    attributeNodes = {
        @NamedAttributeNode("author"),
        @NamedAttributeNode(value = "comments", subgraph = "commentsSub")
    },
    subgraphs = {
        @NamedSubgraph(
            name = "commentsSub",
            attributeNodes = {
                @NamedAttributeNode("author")
            }
        )
    }
)
public class Post {
    @Id @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    private Member author;

    @OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
    private List<Comment> comments = new ArrayList<>();
}

그리고:

public interface PostRepository extends JpaRepository<Post, Long> {

    @EntityGraph(value = "Post.detail")
    Optional<Post> findWithDetailById(Long id);
}

이 구성은 상세 화면에선 강력하지만, 목록 + 페이징에는 여전히 주의가 필요합니다.

EntityGraph와 fetch join의 선택 기준

둘 다 “연관을 한 번에 가져온다”는 점은 같지만 운영 관점에서 트레이드오프가 있습니다.

  • EntityGraph

    • 장점: 리포지토리 메서드에 선언적으로 붙여 재사용 가능, JPQL 변경 없이 적용 가능
    • 단점: 복잡한 조건/튜닝(특정 조인 조건, 별칭 활용 등)은 JPQL이 더 유연
  • fetch join

    • 장점: 쿼리를 명시적으로 통제 가능, 복잡한 조회에서 강력
    • 단점: 남발하면 리포지토리 계층이 쿼리 문자열로 오염되고 유지보수가 어려움

실무에서는 “기본은 EntityGraph, 정말 복잡한 조회만 fetch join 또는 DTO 직접 조회”로 가는 경우가 많습니다.

운영에서 N+1을 놓치지 않는 체크리스트

N+1은 로컬에선 데이터가 적어 티가 안 나다가 운영에서 터지는 경우가 많습니다. 아래를 습관화하면 조기 발견에 도움이 됩니다.

  1. Hibernate SQL 로그로 쿼리 수 확인
  2. API 한 번 호출에 동일 패턴의 select가 반복되는지 확인
  3. 목록 API는 특히 “연관 접근”이 있는 DTO 매핑 코드를 의심
  4. 컬렉션 페치는 페이징과 중복 행(카디널리티) 문제를 같이 검토

장애 대응 관점에서는 “증상이 폭발하는 패턴”을 빠르게 파악하는 게 중요합니다. 인프라에서도 비슷한 폭발이 자주 보이는데, 예를 들어 컨테이너가 죽었다 살아나며 부하가 증폭되는 케이스는 원인 분석 루틴이 정형화돼 있습니다: K8s CrashLoopBackOff 진단 - OOMKilled·Probe

결론: EntityGraph는 ‘필요한 연관만’ 가져오게 만드는 안전장치

정리하면,

  • N+1은 LAZY 자체가 아니라 “LAZY 연관에 접근하는 코드”에서 발생합니다.
  • EntityGraph는 유스케이스 단위로 페치 전략을 통제하는 가장 실용적인 방법 중 하나입니다.
  • 목록 화면에서 컬렉션이 필요 없다면, 컬렉션 페치 대신 집계 쿼리로 쿼리 수를 고정시키는 게 더 안전합니다.
  • 상세 화면처럼 정말 필요한 경우에만 컬렉션/다단계 연관을 그래프로 명시하고, 페이징/중복 행 리스크를 이해한 상태에서 적용해야 합니다.

이 원칙대로만 정리해도 “개발 환경에서는 멀쩡한데 운영에서 DB가 터지는” 전형적인 N+1 폭발을 상당 부분 예방할 수 있습니다.