Spring Data JPA의 쿼리 전략은 왜 이렇게 많은가
JPQL 파싱 경로부터 N+1 해소 전략, 페이징 함정, QueryPlanCache 최적화, 2차 캐시까지 — Spring Data JPA의 모든 쿼리 결정이 공유하는 하나의 원칙을 추적한다.
- 01 Spring Data JPA는 인터페이스 선언만으로 어떻게 동작하는가
- 02 Spring 트랜잭션은 어떻게 비즈니스 코드를 깨끗하게 유지하는가
- 03 JPA는 어떻게 객체와 DB를 동기화하는가
- 04 Spring Data JPA의 쿼리 전략은 왜 이렇게 많은가
- 05 JdbcTemplate이 JDBC 보일러플레이트를 제거하는 방법
- 06 HikariCP는 왜 다른 Connection Pool보다 빠른가
- 07 Spring Data 테스트는 왜 이렇게 설계됐는가
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타입으로 컴파일 타임 검증 + 동적 쿼리.
BooleanExpression이null을 반환하면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를 제대로 쓰는 일이다.