← all posts
DEV 2026.05.02 · 14 min read Intermediate

Spring Data JPA의 쿼리 전략은 왜 이렇게 많은가

JPQL 파싱 경로부터 N+1 해소 전략, 페이징 함정, QueryPlanCache 최적화, 2차 캐시까지 — Spring Data JPA의 모든 쿼리 결정이 공유하는 하나의 원칙을 추적한다.


Spring Data JPA는 동일한 데이터를 조회하는 방법을 여섯 가지쯤 제공한다. JPQL, Criteria API, QueryDSL, Fetch Join, @EntityGraph, @BatchSize — 이 선택지들은 각각 독립된 기능이 아니라 하나의 공통 문제에 대한 서로 다른 트레이드오프다. 그 문제는 “언제 무엇을 DB에서 가져오고, 그 비용을 어떻게 최소화하는가”다. 왜 이 결정들이 이렇게 복잡하게 얽혀 있는가?

세 가지 쿼리 방식은 결국 같은 엔진으로 수렴한다

JPQL, Criteria API, QueryDSL은 서로 다른 문법을 제공하지만 실행 경로의 끝은 같다.

JPQL    → HqlParser(ANTLR4) → SQM → SqlAstTranslator → SQL
Criteria → SQM 직접 생성   → SqlAstTranslator → SQL
QueryDSL → JPQLSerializer  → JPQL 문자열 → HqlParser → SQM → SQL

세 방식 모두 Hibernate의 SqlAstTranslator를 통해 동일한 SQL을 생성한다. 성능 차이는 SQL 생성 엔진이 아니라 개발자가 어떤 SQL을 표현하는가에서 나온다.

그렇다면 왜 세 가지가 존재하는가? 각자 해결하는 문제가 다르다.

  • JPQL: 정적 쿼리를 직관적으로 표현. 런타임 오류가 단점.
  • Criteria API: JPA 표준의 타입 안전성. 코드가 극도로 장황해 실무에서는 거의 QueryDSL로 대체된다.
  • QueryDSL: APT가 생성한 Q타입으로 컴파일 타임 검증 + 동적 쿼리. BooleanExpressionnull을 반환하면 where()가 자동으로 해당 조건을 무시한다.

N+1은 구조의 문제다

연관관계를 Lazy로 설정하면 N+1이 생긴다. Eager로 설정하면 불필요한 데이터를 항상 가져온다. 이 딜레마에 대한 답이 Fetch Join과 @EntityGraph@BatchSize다.

Fetch Join과 @EntityGraph같은 결과를 다른 방식으로 선언한다.

// Fetch Join — JPQL에서 명시적 선언, 기본은 INNER JOIN
@Query("SELECT t FROM Team t LEFT JOIN FETCH t.members WHERE t.status = :status")
List<Team> findWithMembers(@Param("status") Status status);

// @EntityGraph — 어노테이션 선언, 항상 LEFT OUTER JOIN
@EntityGraph(attributePaths = {"members"})
List<Team> findByStatus(Status status);

두 방식의 핵심 차이는 JOIN 방향이다. JOIN FETCH는 기본이 INNER JOIN이라 멤버 없는 팀을 결과에서 제외한다. @EntityGraph는 항상 LEFT OUTER JOIN이다. LEFT JOIN FETCH로 방향을 맞추면 생성되는 SQL은 동일하다.

그런데 컬렉션이 두 개 이상이면 Fetch Join이 막힌다. List를 두 개 동시에 Fetch Join하면 MultipleBagFetchException이 발생한다. 이때 @BatchSize가 답이다.

@Entity
public class Team {
    @OneToMany(mappedBy = "team")
    @BatchSize(size = 100)          // IN절로 배치 로딩
    private List<Project> projects;

    @OneToMany(mappedBy = "team")   // Fetch Join으로 처리
    private List<User> members;
}

@BatchSize(size=100)은 컬렉션에 처음 접근할 때 아직 로딩되지 않은 같은 타입의 ID를 최대 100개 모아 IN (id1, id2, ...) 한 번의 쿼리로 처리한다. 팀 100개의 멤버를 100번 조회하던 것이 1번으로 줄어든다.

트레이드오프

Fetch Join은 1번 쿼리지만 컬렉션 2개 이상에서 MultipleBagFetchException이 발생하고 Pagination과 함께 쓰면 메모리 페이징 위험이 있다. @BatchSize는 N+K번(K=컬렉션 수) 쿼리지만 이 제약이 없다. 단일 컬렉션은 Fetch Join, 복수 컬렉션은 Fetch Join 1개 + 나머지 @BatchSize가 실무 권장 조합이다.

Pagination의 숨겨진 비용

LIMIT/OFFSET 페이징은 직관적이지만 대용량에서 치명적이다. OFFSET 999980은 100만 행을 읽고 버린 뒤 20행을 반환한다. 인덱스가 있어도 OFFSET 이전 행은 스캔해야 한다.

Spring Data JPA의 Page<T>는 매번 COUNT 쿼리를 실행한다. 하지만 PageableExecutionUtils.getPage()는 첫 페이지에서 결과가 pageSize보다 적으면 COUNT를 생략하는 최적화가 내장되어 있다.

대용량 데이터라면 No-Offset 페이징이 답이다.

@Query("SELECT o FROM Order o WHERE o.id < :lastId ORDER BY o.id DESC")
List<Order> findNextPage(@Param("lastId") Long lastId, Pageable pageable);
// SQL: SELECT * FROM orders WHERE id < ? ORDER BY id DESC LIMIT ?
// → 인덱스 직접 활용 → O(log N)
// 100만 번째 페이지도 첫 페이지만큼 빠르다

Fetch Join과 Pagination을 동시에 쓰면 Hibernate가 경고를 낸다. 컬렉션 JOIN 결과에 LIMIT을 적용하면 Team 단위가 아니라 JOIN 행 단위로 잘리기 때문에 Hibernate가 전체 결과를 메모리에 올린 뒤 페이징한다. 해결책은 ID 페이징 후 Fetch Join하는 2단계 쿼리다.

QueryPlanCache — 파싱 비용은 한 번만 내야 한다

JPQL은 매번 파싱하면 수십 ms가 소요된다. Hibernate는 JPQL 문자열을 키로 파싱 결과(QueryPlan)를 SessionFactory 레벨에서 캐시한다. 같은 문자열이면 두 번째 실행부터 파싱을 건너뛴다.

캐시 미스를 유발하는 패턴이 두 가지 있다.

첫째, 파라미터 값을 문자열에 직접 포함하는 것이다. "WHERE u.id = " + id는 호출마다 다른 캐시 키를 만든다. :id 파라미터 바인딩을 써야 한다.

둘째, 가변 크기 IN절이다. IN (?1, ?2, ?3)IN (?1, ?2)는 다른 캐시 엔트리다. Hibernate 6.x는 IN절 파라미터를 특정 크기 계단으로 패딩해 캐시 엔트리 수를 줄인다. @BatchSize가 2의 제곱수로 패딩하는 이유가 이것이다 — QueryPlanCache 재사용을 위해서다.

캐시 미스율이 높으면 Hibernate Statistics로 진단한다.

Statistics stats = emf.unwrap(SessionFactory.class).getStatistics();
stats.setStatisticsEnabled(true);
// 미스율 = misses / (hits + misses)
// 20% 초과 → 동적 JPQL 최적화 또는 캐시 크기(plan_cache_max_size) 증가 검토

2차 캐시 — DB 조회 자체를 제거한다

1차 캐시(Persistence Context)는 트랜잭션이 끝나면 사라진다. 2차 캐시(Second-Level Cache)는 SessionFactory 범위, 즉 애플리케이션 전체 생명주기 동안 유지된다.

카테고리, 코드 테이블처럼 읽기 비중이 압도적인 데이터에 적합하다.

@Entity
@Cache(usage = CacheConcurrencyStrategy.READ_ONLY)  // 변경 없는 참조 데이터
public class Category { ... }

캐시 전략은 동시성 요구사항을 반영한다. READ_ONLY는 Lock이 없어 가장 빠르다. READ_WRITE는 Soft Lock으로 수정 중인 항목에 다른 스레드가 구버전을 읽지 못하게 한다. NONSTRICT_READ_WRITE는 Lock 없이 약간의 불일치를 허용하는 대신 성능이 높다.

멀티 인스턴스 환경에서는 JVM 힙 기반 Ehcache가 인스턴스 간 불일치를 만든다. Redisson 같은 Redis 기반 구현은 모든 인스턴스가 같은 캐시를 공유하므로 분산 환경에서 일관성을 보장한다. 네트워크 왕복 비용이 추가되지만 DB 왕복보다는 빠르다.

정리

  • JPQL, Criteria API, QueryDSL은 모두 같은 Hibernate SQL 생성 엔진으로 수렴한다. 선택 기준은 성능이 아니라 타입 안전성과 동적 쿼리 편의성이다.
  • N+1 해소 전략은 상황에 따라 다르다. 단일 컬렉션은 Fetch Join, 복수 컬렉션은 Fetch Join + @BatchSize, Pagination과 컬렉션을 함께 쓰면 ID 페이징 후 Fetch Join이다.
  • OFFSET 페이징은 대용량에서 선형으로 느려진다. 100만 건 이상이라면 WHERE id < :lastId 방식의 No-Offset이 필요하다.
  • QueryPlanCache는 JPQL 문자열이 완전히 일치해야 히트한다. 값을 문자열에 포함하거나 가변 IN절을 쓰면 캐시가 오염된다.
  • 2차 캐시는 읽기 비중이 압도적인 참조 데이터에만 적용한다. 자주 변경되는 엔티티에 붙이면 무효화 비용이 이익을 초과한다.

이 결정들은 각각 독립적으로 보이지만 하나의 원칙으로 연결된다 — DB와의 왕복을 언제, 얼마나, 어떤 단위로 할 것인가. 그 답을 상황마다 다르게 내리는 것이 Spring Data JPA를 제대로 쓰는 일이다.