MySQL 공부 6 - N+1 문제와 해결 전략, Query Plan Cache, Batch Fetching 내부 구현

Chu Sang Yoon·2026년 3월 19일

MySQL

목록 보기
6/9

MySQL 공부 6 - N+1 문제와 해결 전략, Query Plan Cache, Batch Fetching 내부 구현

5편에서 영속성 컨텍스트와 Proxy, Lazy Loading을 다뤘다. 이번 편에서는 Lazy Loading의 구조적 문제인 N+1을 어떻게 해결하는지, 그리고 쿼리 실행 최적화를 위한 Query Plan Cache와 Batch Fetching 내부 동작을 파고든다.


N+1 문제

왜 발생하는가

@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번의 쿼리가 날아간다.

근본 원인 3가지

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차 캐시가 도움이 안 된다.


해결책 3가지

Fetch Join

처음부터 연관 데이터를 한 번에 가져온다.

// 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번으로 모든 데이터 조회. 가장 직관적.

단점:

  • 카테시안 곱 문제: User 100명 × Post 10개 = 결과 1000 row. 중복 데이터 전송.
  • 페이징 불가능:
@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 폭증으로 이어진다.

사용 시기:

  • 상세 페이지 조회 (1건 + 연관 전체)
  • 소규모 데이터 전체 조회 (카테고리 메뉴 등)
  • 페이징이 필요 없는 통계 화면

@EntityGraph

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);
}

Fetch Join vs @EntityGraph 상세 비교

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도 결과에 포함해야 함@EntityGraphLEFT JOIN 사용
Post가 있는 User만 조회하면 됨Fetch JoinINNER JOIN으로 명확하게
JPQL에 복잡한 WHERE 조건 필요Fetch Join조건 표현 자유로움
Repository 메서드마다 로딩 전략을 다르게 가져가고 싶음@EntityGraph선언적으로 메서드별 전략
명시한 필드 외 나머지를 전부 LAZY로 강제하고 싶음@EntityGraph + FETCHFETCH 힌트로 나머지 LAZY 처리
기존 엔티티 설정을 유지하면서 특정 필드만 추가 로딩@EntityGraph + LOADLOAD 힌트로 기존 설정 유지
서브그래프(연관관계의 연관관계)까지 로딩@EntityGraphsubgraph 지원

서브그래프 — @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 조건 표현 불가.

사용 시기:

  • Post가 없는 User도 포함해야 하는 경우 (LEFT JOIN이 필요)
  • Repository 메서드별 로딩 전략을 다르게 가져가고 싶을 때
  • 3단계 이상 연관관계를 서브그래프로 선언적으로 로딩할 때
  • @ManyToOne, @OneToOne 같은 단일 연관관계

@BatchSize

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이 불가능한 경우
  • User별 데이터 크기가 불확실할 때 (Fetch Join은 데이터 폭발 위험)

해결책 비교

Fetch Join@EntityGraph@BatchSize
쿼리 수1번1번1+N/BatchSize번
페이징
카테시안 곱발생발생없음
여러 컬렉션
적합한 상황단건 상세 조회메서드별 유연한 전략목록 조회 + 페이징

Query Plan Cache

왜 필요한가

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 문자열 자체다. 완전히 동일해야 같은 키로 인식한다.

캐시 히트 vs 캐시 미스

첫 번째 실행 (캐시 미스):

// 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 vs Hibernate Query Plan Cache

MySQL Query CacheHibernate Query Plan Cache
캐싱 대상쿼리 결과(데이터)실행 계획(SQL 변환 결과)
무효화 조건테이블에 쓰기 발생 시거의 무효화 안 됨
현재 상태MySQL 8.0에서 제거현재도 사용
쓰기 많은 환경오히려 성능 저하영향 없음

MySQL Query Cache가 제거된 이유는 쓰기가 발생할 때마다 관련된 모든 캐시를 무효화해야 해서, 쓰기가 빈번한 현대 애플리케이션에서는 오버헤드가 이득보다 컸기 때문이다.


Batch Fetching 내부 구현

@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 없음!

BatchFetchQueue의 역할

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 해결책을 상황에 따라 정리하면:

  • 단건 상세 조회 (1건 + 연관 전체 필요) → Fetch Join
  • 메서드별 유연한 전략 (JPQL 쓰기 싫음) → @EntityGraph
  • 페이징 있는 목록 조회 → @BatchSize (또는 default_batch_fetch_size)
  • 여러 컬렉션 동시 로딩 → @BatchSize

실무에서는 default_batch_fetch_size=100 을 기본으로 깔아두고, 성능이 중요한 특정 쿼리에만 Fetch Join을 추가하는 전략이 가장 많이 쓰인다.

Query Plan Cache는 파라미터 바인딩 방식을 쓰면 자동으로 이득을 본다. 동적 쿼리에서 String 조합 방식을 쓰는 순간 캐시 오염이 시작된다는 점을 기억하자.

0개의 댓글