5편에서 영속성 컨텍스트와 Proxy, Lazy Loading을 다뤘다. 이번 편에서는 Lazy Loading의 구조적 문제인 N+1을 어떻게 해결하는지, 그리고 쿼리 실행 최적화를 위한 Query Plan Cache와 Batch Fetching 내부 동작을 파고든다.
@Entity
public class User {
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY) // 기본값
private List<Post> posts;
}
User를 조회하면 posts 필드에는 PersistentBag이라는 Proxy 컬렉션이 들어간다. 이 Proxy는 초기화되지 않은 상태다.
// 1번 쿼리: User 전체 조회
List<User> users = em.createQuery("SELECT u FROM User u", User.class)
.getResultList();
// N번 쿼리: 각 User의 posts 접근할 때마다
for (User user : users) {
user.getPosts().size(); // 여기서 SELECT * FROM post WHERE user_id = ?
}
User가 100명이면 첫 조회 1번 + posts 조회 100번 = 101번의 쿼리가 날아간다.
1. Lazy Loading의 설계 철학
@OneToMany 기본값이 LAZY다. 필요할 때만 가져온다는 철학인데, "필요할 때"가 반복문 안이면 매번 SQL이 실행된다.
2. Proxy의 독립성
User1.posts = PersistentBag (Session 참조, initialized=false)
User2.posts = PersistentBag (Session 참조, initialized=false)
User3.posts = PersistentBag (Session 참조, initialized=false)
각 엔티티의 연관관계는 별도의 Proxy로 관리된다. 각 Proxy는 서로 모르고 각자 DB에 쿼리를 날린다.
3. 1차 캐시의 한계
1차 캐시는 Map<EntityKey, Object> 구조다. User1에서 posts를 로딩했어도 User2는 다른 키로 관리되는 다른 객체이기 때문에 1차 캐시가 도움이 안 된다.
처음부터 연관 데이터를 한 번에 가져온다.
// N+1 발생
@Query("SELECT u FROM User u")
List<User> findAll();
// Fetch Join으로 해결
@Query("SELECT u FROM User u JOIN FETCH u.posts")
List<User> findAllWithPosts();
생성되는 SQL:
SELECT u.id, u.name, p.id, p.title, p.user_id
FROM user u
INNER JOIN post p ON u.id = p.user_id
Hibernate 내부 동작:
// 1. User + Post를 JOIN해서 한 번에 조회
// 2. ResultSet을 처리하면서 User별로 그룹핑
Map<Long, User> userMap = new HashMap<>();
while (rs.next()) {
Long userId = rs.getLong("u.id");
if (!userMap.containsKey(userId)) {
User user = new User();
user.setId(userId);
user.setPosts(new ArrayList<>()); // 진짜 컬렉션!
userMap.put(userId, user);
}
Post post = new Post();
post.setId(rs.getLong("p.id"));
userMap.get(userId).getPosts().add(post);
}
// 3. posts는 이미 초기화됨 (Proxy 아님!)
장점: 쿼리 1번으로 모든 데이터 조회. 가장 직관적.
단점:
@Query("SELECT u FROM User u JOIN FETCH u.posts")
Page<User> findAll(Pageable pageable);
// ⚠️ 경고: firstResult/maxResults specified with collection fetch; applying in memory!
Fetch Join + 페이징이 왜 위험한지 이해하려면 DB의 LIMIT 동작을 봐야 한다. User 3명을 요청하면 DB는 JOIN 결과 row 기준으로 3개를 자른다. User1의 Post가 3개라면 User1만 나오고 User2, User3는 잘린다.
Hibernate는 이걸 알기 때문에 LIMIT 없이 전체를 긁어와서 메모리에서 페이징한다. 데이터가 많으면 Java Heap 메모리 초과, GC 과부하, CPU 폭증으로 이어진다.
사용 시기:
JPQL 없이 선언적으로 Fetch Join 효과를 낸다.
public interface UserRepository extends JpaRepository<User, Long> {
// 일반 조회 (posts 로딩 안 함)
Optional<User> findById(Long id);
// 특정 메서드만 posts 함께 로딩
@EntityGraph(attributePaths = {"posts"})
Optional<User> findByIdWithPosts(Long id);
// 다른 조합도 가능
@EntityGraph(attributePaths = {"profile", "settings"})
Optional<User> findByIdWithProfileAndSettings(Long id);
}
JOIN 방식 차이
-- Fetch Join → INNER JOIN
SELECT u.*, p.*
FROM user u
INNER JOIN post p ON u.id = p.user_id
-- Post가 없는 User는 결과에서 제외됨
-- @EntityGraph → LEFT JOIN
SELECT u.*, p.*
FROM user u
LEFT JOIN post p ON u.id = p.user_id
-- Post가 없는 User도 결과에 포함됨
힌트 타입 — fetchgraph vs loadgraph
@EntityGraph를 쓸 때 type 속성으로 힌트를 지정할 수 있다.
// FETCH 힌트 (기본값)
@EntityGraph(attributePaths = {"posts"}, type = EntityGraph.EntityGraphType.FETCH)
Optional<User> findByIdWithPosts(Long id);
// LOAD 힌트
@EntityGraph(attributePaths = {"posts"}, type = EntityGraph.EntityGraphType.LOAD)
Optional<User> findByIdWithPosts(Long id);
차이가 명확하다.
FETCH (jakarta.persistence.fetchgraph): attributePaths에 명시한 필드만 EAGER로 로딩. 나머지는 전부 LAZY로 처리. 지정하지 않은 필드를 최소한으로 가져오고 싶을 때 사용.
LOAD (jakarta.persistence.loadgraph): attributePaths에 명시한 필드는 EAGER로 로딩. 나머지는 엔티티에 설정된 FetchType을 그대로 따름. 기존 설정을 유지하면서 특정 필드만 추가로 로딩하고 싶을 때 사용.
@Entity
public class User {
@OneToMany(fetch = FetchType.LAZY) // LAZY
private List<Post> posts;
@ManyToOne(fetch = FetchType.EAGER) // EAGER
private Team team;
}
// FETCH 힌트로 posts만 지정
@EntityGraph(attributePaths = {"posts"}, type = EntityGraphType.FETCH)
// → posts: EAGER (명시)
// → team: LAZY (나머지는 전부 LAZY 처리)
// LOAD 힌트로 posts만 지정
@EntityGraph(attributePaths = {"posts"}, type = EntityGraphType.LOAD)
// → posts: EAGER (명시)
// → team: EAGER (엔티티 설정 그대로 유지)
💡 팁: Hibernate에서는
fetchgraph를 사용해도EAGER로 선언된 필드는 여전히 EAGER로 로딩된다. JPA 스펙과 다르게 동작하는 Hibernate의 특성이다. 완벽하게 지정한 필드만 로딩하고 싶다면 모든 연관관계를LAZY로 선언해두고 필요한 경우에만FETCH힌트로 명시하는 방식이 가장 예측 가능하다.
언제 뭘 써야 하는가
| 상황 | 선택 | 이유 |
|---|---|---|
| Post가 없는 User도 결과에 포함해야 함 | @EntityGraph | LEFT JOIN 사용 |
| Post가 있는 User만 조회하면 됨 | Fetch Join | INNER JOIN으로 명확하게 |
| JPQL에 복잡한 WHERE 조건 필요 | Fetch Join | 조건 표현 자유로움 |
| Repository 메서드마다 로딩 전략을 다르게 가져가고 싶음 | @EntityGraph | 선언적으로 메서드별 전략 |
| 명시한 필드 외 나머지를 전부 LAZY로 강제하고 싶음 | @EntityGraph + FETCH | FETCH 힌트로 나머지 LAZY 처리 |
| 기존 엔티티 설정을 유지하면서 특정 필드만 추가 로딩 | @EntityGraph + LOAD | LOAD 힌트로 기존 설정 유지 |
| 서브그래프(연관관계의 연관관계)까지 로딩 | @EntityGraph | subgraph 지원 |
서브그래프 — @EntityGraph만 가능한 것
// Order → OrderItems → Product 3단계를 한 번에 로딩
@NamedEntityGraph(
name = "Order.detail",
attributeNodes = @NamedAttributeNode(
value = "orderItems",
subgraph = "orderItems"
),
subgraphs = @NamedSubgraph(
name = "orderItems",
attributeNodes = @NamedAttributeNode("product")
)
)
@Entity
public class Order { ... }
Fetch Join으로 3단계 연관관계를 한 번에 로딩하려면 JPQL이 복잡해지지만, @EntityGraph는 선언적으로 표현할 수 있다.
장점: JPQL 작성 불필요. 메서드별로 유연하게 적용. 코드가 선언적이고 깔끔함. 서브그래프 지원.
단점: Fetch Join과 동일한 한계(카테시안 곱, 페이징 불가). 복잡한 WHERE 조건 표현 불가.
사용 시기:
@ManyToOne, @OneToOne 같은 단일 연관관계N+1은 발생하지만 N번 쿼리를 묶어서 줄인다.
@Entity
public class User {
@OneToMany(mappedBy = "user")
@BatchSize(size = 10)
private List<Post> posts;
}
BatchSize 없는 경우:
SELECT * FROM user WHERE id IN (1, 2, 3);
SELECT * FROM post WHERE user_id = 1; -- 쿼리 1
SELECT * FROM post WHERE user_id = 2; -- 쿼리 2
SELECT * FROM post WHERE user_id = 3; -- 쿼리 3
BatchSize(size=10) 있는 경우:
SELECT * FROM user WHERE id IN (1, 2, 3);
SELECT * FROM post WHERE user_id IN (1, 2, 3); -- 쿼리 1번!
Hibernate가 내부적으로 BatchFetchQueue를 관리한다. 아직 초기화되지 않은 컬렉션들을 큐에 모아뒀다가, 첫 번째 접근 시 BatchSize만큼 묶어서 IN 절로 한 번에 조회한다.
실무에서 가장 많이 쓰는 전역 설정:
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
모든 @OneToMany, @ManyToMany에 기본 적용된다. 일단 켜두면 N+1 위험이 크게 감소한다.
💡 팁: BatchSize 값을 얼마로 설정해야 할지 고민될 때는 100~1000 사이가 일반적이다. 너무 작으면 쿼리 횟수가 많고, 너무 크면
IN절이 길어져서 DB 부담이 커진다. 페이지당 데이터 수 × 예상 연관 엔티티 수를 기준으로 잡으면 된다.
장점: 페이징 가능. 카테시안 곱 없음. 여러 컬렉션에 동시 적용 가능.
단점: 여전히 N+1 발생 (쿼리 횟수가 줄어드는 것). BatchSize 값을 튜닝해야 함.
사용 시기:
| Fetch Join | @EntityGraph | @BatchSize | |
|---|---|---|---|
| 쿼리 수 | 1번 | 1번 | 1+N/BatchSize번 |
| 페이징 | ❌ | ❌ | ✅ |
| 카테시안 곱 | 발생 | 발생 | 없음 |
| 여러 컬렉션 | ❌ | ❌ | ✅ |
| 적합한 상황 | 단건 상세 조회 | 메서드별 유연한 전략 | 목록 조회 + 페이징 |
for (int i = 0; i < 1000; i++) {
em.createQuery("SELECT u FROM User u WHERE u.id = :id")
.setParameter("id", (long) i)
.getSingleResult();
}
같은 JPQL을 1000번 실행하면 매번 파싱과 최적화를 하는 건 비효율적이다.
SQL 실행 전에는 세 단계가 필요하다:
1. 파싱: SQL 문법 분석
2. 최적화: 실행 계획 수립 (인덱스 사용? Full Scan?)
3. 실행: 실제 데이터 조회
이 중 파싱과 최적화 비용이 크다. 같은 JPQL은 실행 계획을 재사용하는 게 Query Plan Cache다.
public class QueryPlanCache {
// JPQL 문자열 → 실행 계획 (LRU, 기본 2048개)
private final Map<String, HQLQueryPlan> queryPlanCache;
public HQLQueryPlan getHQLQueryPlan(String queryString, ...) {
HQLQueryPlan plan = queryPlanCache.get(queryString);
if (plan == null) {
// 캐시 미스 → 새로 생성
plan = new HQLQueryPlan(queryString, ...);
queryPlanCache.put(queryString, plan);
}
return plan; // 캐시 히트 → 재사용
}
}
캐시 키는 JPQL 문자열 자체다. 완전히 동일해야 같은 키로 인식한다.
첫 번째 실행 (캐시 미스):
// JPQL 파싱 → AST 생성
// AST → SQL 변환: SELECT * FROM users WHERE id = ?
// 파라미터 메타데이터 분석
// HQLQueryPlan 생성 → 캐시에 저장
// 실행
두 번째 실행 (캐시 히트):
// 캐시에서 Plan 조회 → 파싱/최적화 생략!
// PreparedStatement에 파라미터만 바인딩
// 실행
// 🚨 캐시 오염 (하지 말 것)
String jpql = "SELECT u FROM User u WHERE u.name = '" + name + "'";
em.createQuery(jpql);
// 입력값마다 다른 JPQL 문자열 → 캐시가 매번 새로 생김
// "SELECT u FROM User u WHERE u.name = 'Alice'"
// "SELECT u FROM User u WHERE u.name = 'Bob'"
// → 캐시 2048개 꽉 차면 오래된 것부터 제거 → 유효한 Plan도 날아감
// ✅ 올바른 방법 (파라미터 바인딩)
String jpql = "SELECT u FROM User u WHERE u.name = :name";
em.createQuery(jpql).setParameter("name", name);
// 항상 같은 JPQL 문자열 → 캐시 재사용
💡 팁: 쿼리 문자열에 값을 직접 넣는 String 조합 방식은 SQL Injection 취약점에 더해 Query Plan Cache 오염까지 일으킨다. 항상
:param방식의 파라미터 바인딩을 써야 한다.
| MySQL Query Cache | Hibernate Query Plan Cache | |
|---|---|---|
| 캐싱 대상 | 쿼리 결과(데이터) | 실행 계획(SQL 변환 결과) |
| 무효화 조건 | 테이블에 쓰기 발생 시 | 거의 무효화 안 됨 |
| 현재 상태 | MySQL 8.0에서 제거 | 현재도 사용 |
| 쓰기 많은 환경 | 오히려 성능 저하 | 영향 없음 |
MySQL Query Cache가 제거된 이유는 쓰기가 발생할 때마다 관련된 모든 캐시를 무효화해야 해서, 쓰기가 빈번한 현대 애플리케이션에서는 오버헤드가 이득보다 컸기 때문이다.
@BatchSize가 실제로 어떻게 동작하는지 Hibernate 내부를 따라가본다.
User 10명을 조회하고 각각의 posts에 접근하는 시나리오:
List<User> users = em.createQuery("SELECT u FROM User u", User.class)
.setMaxResults(10)
.getResultList();
for (User user : users) {
user.getPosts().size();
}
Step 1 — User 조회 시
10개의 PersistentBag이 생성되고 BatchFetchQueue에 등록된다.
// User 10명 조회 후 내부 상태
User1.posts = PersistentBag(initialized=false, key=1)
User2.posts = PersistentBag(initialized=false, key=2)
...
User10.posts = PersistentBag(initialized=false, key=10)
// BatchFetchQueue
{
"User.posts" → [key=1, key=2, key=3, ..., key=10]
}
Step 2 — 첫 번째 User의 posts 접근
user1.getPosts().size() 호출 시 PersistentBag.initialize()가 실행된다.
private void initialize() {
// BatchFetchQueue에서 같이 로딩할 ID 수집
// BatchSize=10이면 최대 10개
Serializable[] ids = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
// IN 절 SQL 생성
String sql = "SELECT * FROM post WHERE user_id IN (?, ?, ..., ?)";
// 실행
executeBatchLoad(sql, ids, session);
}
Step 3 — 결과 분배
// ResultSet에서 Post들을 Owner별로 그룹핑
Map<Long, List<Post>> grouped = {
1L: [Post1, Post2, ...],
2L: [Post11, Post12, ...],
...
}
// 각 User의 PersistentBag에 데이터 주입
for (User user : users) {
PersistentBag bag = user.getPosts();
bag.injectLoadedState(grouped.get(user.getId()));
bag.afterInitialize(); // initialized = true
}
Step 4 — 나머지 User 접근
user2.getPosts().size(); // initialized=true → SQL 없음!
user3.getPosts().size(); // initialized=true → SQL 없음!
...
user10.getPosts().size(); // initialized=true → SQL 없음!
User 10명 조회
→ 10개 PersistentBag 생성
→ BatchFetchQueue에 10개 등록
user1.getPosts() 첫 접근
→ BatchFetchQueue 확인 → 10개 있음
→ 10개 모두 한 번에 로딩 (IN 절 1번)
→ BatchFetchQueue에서 10개 제거
user2~user10.getPosts() 접근
→ initialized=true → SQL 없음
일반 Proxy는 각자 독립적으로 동작하지만, BatchSize가 설정되면 첫 접근 시 큐에 있는 나머지 Proxy들을 함께 초기화한다. 이게 N번을 N/BatchSize번으로 줄이는 핵심 메커니즘이다.
N+1 해결책을 상황에 따라 정리하면:
default_batch_fetch_size)실무에서는 default_batch_fetch_size=100 을 기본으로 깔아두고, 성능이 중요한 특정 쿼리에만 Fetch Join을 추가하는 전략이 가장 많이 쓰인다.
Query Plan Cache는 파라미터 바인딩 방식을 쓰면 자동으로 이득을 본다. 동적 쿼리에서 String 조합 방식을 쓰는 순간 캐시 오염이 시작된다는 점을 기억하자.