JPA N+1 해결, Fetch Join·EntityGraph·QueryDSL 중 무엇을 쓸까

결론부터 말하면, Fetch Join·@EntityGraph·QueryDSL은 셋 중 하나를 고르는 문제가 아니라 화면마다 다르게 꺼내 쓰는 도구다. 나는 사내 게시판의 목록 API가 운영에서 3초 넘게 걸리는 장애를 잡으며 이 셋을 전부 써 봤고, 그 과정에서 두 번은 잘못된 길로 갔다. 이 글은 그 일주일의 기록이고, 끝에 화면별 선택 기준을 표로 정리했다.

월요일 — 로컬 80ms, 운영 3.2초

아침에 운영 모니터링 알림이 왔다. 게시글 목록 API의 p95 응답 시간이 3.2초. 지난주 배포 전에는 200ms 안쪽이었다. 로컬에서 같은 API를 호출하면 80ms에 끝났다. 코드도 같고 쿼리도 같은데 환경만 다르니 처음엔 DB 서버 상태를 의심했다. 슬로우 쿼리 로그를 뒤졌지만 100ms를 넘는 쿼리는 하나도 없었다. DB 서버 쪽을 먼저 의심했다가 석 달을 헤맨 이야기는 MySQL 쿼리 지연 해결: 3개월 클라우드 DB 분투기에 따로 적어 두었다.

그때 “느린 쿼리가 없는데 API가 느리다”는 조합에서 감이 왔다. 쿼리 하나가 느린 게 아니라 쿼리 개수가 많은 것이다. 로컬 프로파일에 spring.jpa.properties.hibernate.generate_statistics=true를 켜고 목록 API를 한 번 호출했다. 로그에 찍힌 숫자는 이랬다.

Session Metrics {
    41 JDBC statements executed   // 게시글 20건 목록 조회에 41번
    ...
}

목록 20건에 쿼리 41번. 게시글 목록 1번, 작성자(Member) 20번, 카테고리(Category) 20번. 전형적인 N+1이었다. 로컬 DB는 같은 머신이라 쿼리 한 번에 1ms도 안 걸리니 41번이어도 80ms였고, 운영 DB는 다른 가용 영역에 있어 왕복만 수십 ms가 걸리니 41번이 3초가 된 것이다. 지난주 배포에서 목록 화면에 카테고리 이름을 추가한 게 20번을 더 얹었다.

@Entity
public class Post {
    @ManyToOne(fetch = FetchType.LAZY) private Member member;
    @ManyToOne(fetch = FetchType.LAZY) private Category category;
    @OneToMany(mappedBy = "post") private List<Comment> comments;
}

// 서비스 — 목록을 돌며 작성자·카테고리 이름에 접근하는 순간 프록시가 초기화된다
Page<Post> posts = postRepository.findAll(pageable);       // 쿼리 1번
posts.map(p -> new PostSummary(
        p.getTitle(),
        p.getMember().getName(),        // 게시글마다 1번
        p.getCategory().getName()));    // 게시글마다 1번

화요일 — EAGER로 바꿨다가 되돌린 이유

가장 먼저 한 일은 가장 게으른 방법이었다. @ManyToOne(fetch = EAGER). 목록 API의 쿼리는 41번에서 1번이 됐고 응답도 150ms로 떨어졌다. 그날 오후 배포했다.

문제는 다음 날 아침에 나왔다. 게시글 상세 API와 관리자 통계 API가 느려졌다는 알림이었다. EAGER는 “이 연관은 어디서든 항상 같이 가져와라”는 선언이라, Post를 건드리는 모든 쿼리에 Member와 Category 조인이 따라붙었다. 작성자 정보가 전혀 필요 없는 통계 API까지 조인 두 개를 떠안았다. 더 나쁜 건 JPQL로 Post를 조회하는 곳에서는 EAGER가 조인이 아니라 조회 후 즉시 추가 SELECT로 동작해 N+1이 그대로 남아 있었다는 점이다. 문제를 한 화면에서 다른 화면으로 옮겼을 뿐이었다. 저녁에 LAZY로 되돌렸다.

이때 배운 원칙 하나. 로딩 전략은 엔티티가 아니라 조회 시점에 정한다. 엔티티는 전부 LAZY로 두고, 어떤 화면이 무엇을 함께 필요로 하는지는 그 화면의 쿼리에서 결정해야 한다. 아래 세 도구는 전부 “조회 시점에 결정하는” 방법이다.

수요일 — Fetch Join, 그리고 페이징에서 막히다

정석대로 JPQL에 Fetch Join을 썼다. 작성자와 카테고리를 한 번의 조인으로 가져오니 쿼리는 1번, 다른 API에는 영향이 없었다.

@Query("SELECT p FROM Post p JOIN FETCH p.member JOIN FETCH p.category")
Page<Post> findAllWithMemberAndCategory(Pageable pageable);

여기서 욕심을 냈다. 목록에 댓글 수도 보여 주고 있었는데 이것도 p.getComments().size()로 N+1이 나고 있었다. 그래서 JOIN FETCH p.comments를 하나 더 붙였다. 테스트는 통과했다. 그런데 로그에 못 보던 경고가 떴다.

HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory

이 경고의 뜻을 그날 처음 정확히 알았다. 컬렉션(@OneToMany)을 Fetch Join하면 조인 결과의 행 수는 게시글 수가 아니라 댓글 수만큼 늘어난다. 이 상태에서 LIMIT 20을 걸면 게시글 20건이 아니라 댓글 20행이 잘리므로, Hibernate는 SQL에 LIMIT을 넣지 못하고 전체 결과를 메모리에 올린 뒤 자바에서 20건을 자른다. 게시글이 수만 건이고 댓글이 수십만 건인 운영 DB였다면 목록 한 페이지를 열 때마다 수십만 행을 애플리케이션 메모리로 끌어오는 셈이었다. 테스트 데이터가 100건이라 통과했을 뿐이다.

컬렉션 Fetch Join은 빼고, 댓글 수는 다른 방법으로 풀기로 했다. application.yml에 hibernate.default_batch_fetch_size: 100을 넣자 댓글 컬렉션은 게시글마다 1번이 아니라 WHERE post_id IN (?, ?, ..., ?) 한 번으로 묶여 나갔다. 목록 API 쿼리는 1(목록+조인) + 1(댓글 IN) = 2번이 됐다.

목요일 — @EntityGraph로 코드가 줄고, 검색 조건에서 다시 막히다

Fetch Join JPQL을 리포지토리 여기저기 복붙하다 보니 findByCategoryId, findByMemberId 같은 파생 쿼리 메서드마다 JPQL을 새로 써야 했다. 이걸 @EntityGraph로 바꾸자 코드가 눈에 띄게 줄었다. 파생 쿼리 메서드 위에 애너테이션 한 줄이면 Fetch Join과 같은 SQL이 나간다.

@EntityGraph(attributePaths = {"member", "category"})
Page<Post> findByCategoryId(Long categoryId, Pageable pageable);

@EntityGraph(attributePaths = {"member", "category"})
Page<Post> findByMemberId(Long memberId, Pageable pageable);

주의할 점 하나는 @EntityGraph의 조인이 LEFT OUTER JOIN으로 고정된다는 것이다. 카테고리가 없는 게시글도 목록에 나와야 했으니 우리 경우엔 오히려 맞았지만, INNER JOIN이 필요한 곳이라면 JPQL Fetch Join으로 직접 써야 한다.

막힌 건 검색 화면이었다. 제목 포함, 카테고리, 작성자, 기간 — 네 조건이 각각 선택 사항이라 조합이 16가지다. 파생 쿼리 메서드로는 findByTitleContainingAndCategoryIdAndCreatedAtBetween... 같은 메서드가 조합 수만큼 필요하고, 조건이 하나 늘면 두 배가 된다. 목요일 밤에 리포지토리 메서드가 아홉 개까지 늘어난 걸 보고 이 길이 아니라는 걸 인정했다.

금요일 — QueryDSL과 2단계 페이징으로 마무리

동적 조건은 QueryDSL로 옮겼다. 조건 하나가 BooleanExpression을 반환하는 메서드 하나가 되고, null을 반환하면 where에서 무시된다. 메서드 아홉 개가 쿼리 하나로 합쳐졌다. 페이지 번호가 깊어질수록 목록이 느려지는 건 OFFSET 자체의 한계라서, 커서 기반 페이징 vs OFFSET, 느려지는 이유에서 따로 다뤘다.

public Page<Post> search(PostSearchCond cond, Pageable pageable) {
    // 1단계: 조건에 맞는 게시글 ID만 페이징 (조인 없음 → LIMIT이 DB에서 정확히 적용)
    List<Long> ids = queryFactory
            .select(post.id)
            .from(post)
            .where(titleContains(cond.getTitle()),
                   categoryEq(cond.getCategoryId()),
                   memberEq(cond.getMemberId()),
                   createdBetween(cond.getFrom(), cond.getTo()))
            .orderBy(post.createdAt.desc())
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();

    // 2단계: 그 ID로 필요한 연관을 Fetch Join
    List<Post> content = queryFactory
            .selectFrom(post)
            .join(post.member, member).fetchJoin()
            .leftJoin(post.category, category).fetchJoin()
            .where(post.id.in(ids))
            .orderBy(post.createdAt.desc())
            .fetch();

    Long total = queryFactory.select(post.count()).from(post)
            .where(titleContains(cond.getTitle()), categoryEq(cond.getCategoryId()),
                   memberEq(cond.getMemberId()), createdBetween(cond.getFrom(), cond.getTo()))
            .fetchOne();

    return new PageImpl<>(content, pageable, total);
}

private BooleanExpression titleContains(String title) {
    return StringUtils.hasText(title) ? post.title.contains(title) : null;
}

ID를 먼저 페이징하고 그 ID로 다시 조회하는 2단계 전략을 쓴 이유는 수요일의 경고 때문이다. 1단계는 조인이 없으니 LIMIT이 DB에서 정확히 걸리고, 2단계는 최대 20건의 ID로만 조인하니 결과가 커질 일이 없다. 댓글 수는 여전히 default_batch_fetch_size가 IN 절로 묶어 준다. 최종적으로 검색 API 한 번에 나가는 쿼리는 ID 조회, 본 조회, count, 댓글 IN — 4번이다. 41번에서 시작해 여기까지 왔다.

금요일 저녁 배포 후 목록 API p95는 3.2초에서 140ms로 내려갔다. 운영 DB 왕복이 수십 ms인 환경에서 쿼리 개수를 줄이는 것 말고는 답이 없었다. 쿼리 개수를 줄인 다음에는 쿼리 하나하나의 속도를 인덱스가 결정한다. 검색 조건에 맞춰 인덱스 컬럼 순서를 정하는 방법은 복합 인덱스는 순서가 전부다에 정리했다.

한 표로 보는 비교

구분 Fetch Join @EntityGraph QueryDSL
작성 방식 JPQL 직접 애너테이션 선언 자바 코드
동적 쿼리 불리 불리 매우 유리
코드 간결성 보통 우수 보통
조인 종류 INNER / LEFT 선택 LEFT OUTER 고정 자유
타입 안전성 없음 없음 있음
컬렉션 페이징 메모리 페이징 경고 메모리 페이징 경고 2단계 전략으로 해결
학습 비용 낮음 낮음 Q타입 빌드 설정 필요

그래서, 화면별 선택 가이드

일주일이 지나고 리포지토리에는 셋이 다 남았다. 서로 대체재가 아니라 화면 성격에 따라 갈라진 것이다.

작성자·카테고리처럼 단순한 @ManyToOne 로딩은 @EntityGraph가 가장 짧다. 파생 쿼리 메서드를 그대로 두고 한 줄만 얹으면 된다. 조인 종류를 명시해야 하거나 연관관계가 고정된 정적 조회는 JPQL Fetch Join으로 의도를 드러낸다. 선택적 필터가 여러 개 조합되는 검색 목록은 QueryDSL로 가고, 페이징이 있으면 ID 먼저 페이징하는 2단계로 간다. 컬렉션(@OneToMany)은 목록 화면에서 Fetch Join하지 말고 default_batch_fetch_size(100~1000)로 IN 절 배치 조회에 맡긴다. 컬렉션이 둘 이상이면 Fetch Join은 MultipleBagFetchException으로 아예 막히므로 이 방법이 유일하다.

다시 한다면

쿼리 개수를 테스트로 고정했을 것이다. 이 장애는 목록 화면에 카테고리 이름 한 줄을 추가한 배포에서 시작됐다. 목록 API 통합 테스트에 “이 API는 쿼리 3번 이하”라는 단언이 있었다면 그 배포는 CI에서 막혔다. 지금은 Hibernate Statistics의 getPrepareStatementCount()를 읽어 주요 목록 API마다 상한을 걸어 두었다.

@Test
void 게시글_목록은_쿼리_3번을_넘지_않는다() {
    statistics.clear();
    postService.list(PageRequest.of(0, 20));
    assertThat(statistics.getPrepareStatementCount()).isLessThanOrEqualTo(3);
}

목록 화면은 엔티티 대신 DTO 프로젝션으로 갔을 것이다. 목록에 필요한 건 제목·작성자 이름·카테고리 이름·댓글 수, 네 컬럼이다. 엔티티를 통째로 가져와 연관을 초기화하는 대신 QueryDSL의 Projections.constructor로 그 네 컬럼만 SELECT했다면 N+1이라는 문제 자체가 생기지 않았다. 연관 엔티티 그래프가 필요한 건 상세 화면과 수정 로직이지, 목록이 아니었다.

로컬에서도 운영과 같은 지연을 봤어야 했다. 로컬 DB가 같은 머신에 있으면 쿼리 41번도 80ms라서 N+1이 보이지 않는다. 지금은 로컬 프로파일에서 generate_statistics를 항상 켜 두고, 개발 중인 API를 호출할 때마다 로그의 JDBC statements 수를 한 번씩 본다. 숫자가 페이지 크기와 비슷하면 어디선가 프록시가 돌고 있는 것이다.

마지막으로, 지연 로딩은 영속성 컨텍스트가 살아 있는 동안에만 동작한다. open-in-view를 끄고 트랜잭션 경계 밖에서 프록시에 접근하면 N+1 대신 LazyInitializationException을 만나게 되는데, 이건 다른 글에서 다룬다. 운영에서 느려진 조회를 만났다면 슬로우 쿼리보다 먼저 쿼리 개수부터 세어 보길 권한다. 그 숫자가 페이지 크기의 배수라면 원인은 이미 나온 셈이다.

참고: Hibernate ORM User Guide — Fetching, Spring Data JPA Reference — Entity Graph

“JPA N+1 해결, Fetch Join·EntityGraph·QueryDSL 중 무엇을 쓸까”에 대한 1개의 생각

댓글 남기기