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

로컬에서 빠르던 조회 API가 운영 DB에서 갑자기 수십 배 느려지는 일, 대부분 원인은 N+1 쿼리입니다. 게시글 목록 20건을 조회했을 뿐인데 작성자 정보를 가져오느라 추가 쿼리 20개가 더 나가는 식이죠. 부모 1건 쿼리에 자식 N개 쿼리가 따라붙어 총 N+1개가 실행됩니다.

해결책은 크게 세 가지입니다. 그런데 셋은 우열이 아니라 상황에 따라 골라 쓰는 도구입니다. 어떤 화면에 무엇을 써야 하는지가 이 글의 핵심입니다.

먼저, N+1은 왜 생기나

PostMember@ManyToOne(fetch = LAZY)로 참조할 때, 목록을 조회한 뒤 반복문에서 작성자 이름에 접근하면 프록시가 초기화되며 작성자마다 별도 SELECT가 나갑니다.

List<Post> posts = postRepository.findAll();       // 쿼리 1번
for (Post post : posts)
    System.out.println(post.getMember().getName()); // 게시글 수만큼 쿼리 N번

게시글이 100건이면 쿼리는 101번. 즉시 로딩(EAGER)으로 바꾸면 단건은 해결되지만 컬렉션 조회에서 여전히 N+1이 생기므로 근본 해결책이 아닙니다. 지연 로딩을 유지한 채 아래 세 도구로 푸는 것이 정석입니다.

세 가지 도구를 빠르게 훑기

Fetch Join — JPQL에서 연관 엔티티를 한 번의 조인으로 함께 조회합니다. 가장 기본이자 강력하지만, 컬렉션 Fetch Join에는 페이징을 적용할 수 없다는 한계가 있습니다(메모리 페이징 + OOM 위험).

@Query("SELECT p FROM Post p JOIN FETCH p.member")
List<Post> findAllWithMember();

@EntityGraph — JPQL 없이 애너테이션만으로 Fetch Join 효과를 냅니다. 기존 파생 쿼리 메서드 위에 그대로 얹을 수 있어 코드량이 가장 적습니다. 대신 조인이 LEFT OUTER로 고정되고 동적 쿼리에 약합니다.

@EntityGraph(attributePaths = {"member"})
@Query("SELECT p FROM Post p")
List<Post> findAllWithMember();

QueryDSL — 타입 안전한 동적 쿼리를 자바 코드로 작성합니다. 제목·카테고리·기간 등 여러 필터가 선택적으로 조합되는 목록에서 BooleanExpression으로 조건을 깔끔하게 분리합니다. Q타입 빌드 설정과 학습 곡선이 비용입니다.

List<Post> posts = queryFactory
    .selectFrom(post)
    .join(post.member, member).fetchJoin()
    .where(titleContains(cond), categoryEq(cat))
    .fetch();

한 표로 보는 비교

구분Fetch Join@EntityGraphQueryDSL
작성 방식JPQL 직접애너테이션 선언자바 코드
동적 쿼리불리불리매우 유리
코드 간결성보통우수보통
타입 안전성없음없음있음
페이징(컬렉션)제약 있음제약 있음2단계 전략으로 해결
학습 비용낮음낮음다소 높음

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

실제 프로젝트는 셋을 함께 씁니다. 화면 단위로 기준을 잡으면 의사결정이 쉬워집니다.

단순 연관 로딩(작성자, 카테고리)은 @EntityGraph로 가장 짧게 처리합니다. 연관관계가 명확한 정적 조회는 Fetch Join으로 의도를 JPQL에 명시합니다. 검색 필터가 동적인 목록에 페이징까지 필요하면 QueryDSL로 ID만 먼저 페이징한 뒤 그 ID로 Fetch Join하는 2단계 전략을 씁니다. 컬렉션이 여러 개라 조인이 불가능하면 default_batch_fetch_size(보통 100~1000)를 설정해 IN 절 배치 조회로 N+1을 N개에서 1개로 줄입니다.

한 가지 덧붙이면, 지연 로딩은 영속성 컨텍스트가 살아 있는 동안에만 동작하므로 트랜잭션 경계를 함께 이해해야 N+1 해결이 견고해집니다. 운영에서 느려진 조회를 만났다면, 가장 먼저 실행되는 쿼리 개수부터 로그로 확인해 보세요. 원인이 N+1이라면 이 세 도구의 조합으로 대부분 해결됩니다.

참고: Hibernate ORM 공식 문서, Spring Data JPA Reference

댓글 남기기