로컬에서 빠르던 조회 API가 운영 DB에서 갑자기 수십 배 느려지는 일, 대부분 원인은 N+1 쿼리입니다. 게시글 목록 20건을 조회했을 뿐인데 작성자 정보를 가져오느라 추가 쿼리 20개가 더 나가는 식이죠. 부모 1건 쿼리에 자식 N개 쿼리가 따라붙어 총 N+1개가 실행됩니다.
해결책은 크게 세 가지입니다. 그런데 셋은 우열이 아니라 상황에 따라 골라 쓰는 도구입니다. 어떤 화면에 무엇을 써야 하는지가 이 글의 핵심입니다.
먼저, N+1은 왜 생기나
Post가 Member를 @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 | @EntityGraph | QueryDSL |
|---|---|---|---|
| 작성 방식 | 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이라면 이 세 도구의 조합으로 대부분 해결됩니다.