Post

FetchType과 N+1 문제

FetchType과 N+1 문제

1. 즉시 로딩(EAGER)과 지연 로딩(LAZY)


📌 FetchType 기본값 정리

JPA 연관관계 어노테이션의 기본 FetchType은 다음과 같다.

어노테이션기본 FetchType
@ManyToOneEAGER
@OneToOneEAGER
@OneToManyLAZY
@ManyToManyLAZY

@ManyToOne@OneToOne의 기본값이 EAGER라는 점이 중요하다. 아무것도 설정하지 않으면 즉시 로딩이 동작해서 N+1 문제를 유발한다. 실무에서는 모든 연관관계에 반드시 FetchType.LAZY를 명시해야 한다.

1
2
3
4
5
6
7
8
9
// 잘못된 예 — 기본값 EAGER 그대로 사용
@ManyToOne
@JoinColumn(name = "member_id")
private Member member; // EAGER — 조회 시마다 member SELECT 발생

// 올바른 예 — 항상 LAZY 명시
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;

📌 EAGER 동작 원리와 N+1 유발 메커니즘

EAGER로 설정하면 엔티티를 조회할 때 연관된 엔티티도 즉시 함께 로딩된다.

단건 조회(em.find())에서는 Hibernate가 자동으로 JOIN을 사용한다.

1
2
3
4
5
6
-- LoanRecord 단건 조회 시 EAGER면 JOIN 자동 생성
SELECT lr.*, m.*, b.*
FROM loan_record lr
JOIN member m ON lr.member_id = m.member_id
JOIN book b ON lr.book_id = b.book_id
WHERE lr.loan_id = 1;

그런데 JPQL이나 Spring Data JPA의 메서드 이름 쿼리를 사용하면 문제가 달라진다. JPQL은 SQL을 직접 제어하지 않고 엔티티 중심으로 작성된다. Hibernate가 JPQL을 실행한 뒤 EAGER 연관관계를 추가 조회한다.

1
2
3
4
// JPQL로 LoanRecord 목록 조회
List<LoanRecord> records = em.createQuery(
    "SELECT lr FROM LoanRecord lr", LoanRecord.class
).getResultList();

실제 실행되는 SQL:

1
2
3
4
5
6
7
8
-- 1번: JPQL → LoanRecord 전체 조회
SELECT lr.* FROM loan_record lr;

-- LoanRecord가 10건이면, member EAGER로 인해 10번 추가 실행
SELECT * FROM member WHERE member_id = 1;
SELECT * FROM member WHERE member_id = 2;
SELECT * FROM member WHERE member_id = 3;
-- ... (N개)

이것이 N+1 문제다. 1번의 쿼리 결과로 N건이 나왔을 때, 각 건에 대해 추가 쿼리가 N번 더 실행되는 현상이다.


📌 LAZY 프록시 객체 생성 원리

LAZY로 설정하면 연관 엔티티 자리에 즉시 SQL을 실행하지 않고 프록시(Proxy) 객체를 끼워 넣는다.

1
2
3
4
5
6
7
8
9
LoanRecord record = em.find(LoanRecord.class, 1L);
// 실행 SQL: SELECT * FROM loan_record WHERE loan_id = 1;
// record.member 자리에는 프록시 객체가 들어있음

Member member = record.getMember();
// 아직 SQL 실행 안 됨. member는 프록시 객체

String name = member.getName(); // 실제 필드 접근 시점에 SQL 실행
// 실행 SQL: SELECT * FROM member WHERE member_id = ?;

프록시 객체는 실제 Member 클래스를 상속한 Hibernate가 런타임에 동적으로 생성한 가짜 객체다. 내부에 실제 데이터 없이 id 값만 가지고 있다가, 실제 필드(name, age 등)에 접근하는 순간 SQL을 실행해서 데이터를 채운다.

1
2
3
4
5
6
record.getMember()        → 프록시 객체 반환 (SQL 없음)
                             Member$HibernateProxyXXX {id: 1, name: null, ...}
                                          ↓
record.getMember().getName() → 프록시 초기화 (SQL 실행)
                             SELECT * FROM member WHERE member_id = 1;
                             Member {id: 1, name: "홍길동", age: 30, ...}

📌 LazyInitializationException — LAZY의 핵심 주의사항

영속성 컨텍스트(Session)가 닫힌 후 프록시를 초기화하려 하면 LazyInitializationException이 발생한다.

1
2
3
4
5
6
7
8
9
10
// OSIV OFF 환경 시나리오
@Transactional(readOnly = true)
public LoanRecord findRecord(Long id) {
    return loanRecordRepository.findById(id).orElseThrow();
    // 반환 시 트랜잭션 종료 → 영속성 컨텍스트 닫힘
}

// Controller (트랜잭션 없음)
LoanRecord record = service.findRecord(1L); // 준영속 상태
String name = record.getMember().getName(); // → LazyInitializationException!

해결 방법은 다음 섹션에서 다루는 fetch join, @EntityGraph, @BatchSize다.


2. N+1 문제 완전 정복


📌 N+1 발생 시나리오

시나리오 1 — JPQL에서 EAGER 연관관계

위에서 설명한 케이스. JPQL은 SQL에 JOIN을 자동 추가하지 않고, 이후 EAGER 설정에 따라 추가 쿼리를 실행한다.

시나리오 2 — Spring Data JPA 메서드 이름 쿼리 + LAZY

1
2
3
4
5
6
7
List<LoanRecord> records = loanRecordRepository.findAll();
// SELECT * FROM loan_record; (1번)

for (LoanRecord record : records) {
    System.out.println(record.getMember().getName());
    // 각 record마다 SELECT * FROM member WHERE member_id = ?; (N번)
}

LAZY 설정도 N+1에서 자유롭지 않다. 루프 안에서 연관 엔티티에 접근하면 건당 추가 쿼리가 발생한다.

📌 Hibernate 쿼리 로그로 문제 확인

1
2
3
4
5
6
7
8
9
10
spring:
  jpa:
    show-sql: true
    properties:
      hibernate:
        format_sql: true
logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.orm.jdbc.bind: TRACE   # 파라미터 바인딩 로그 (Hibernate 6.x)

쿼리 로그를 확인했을 때 같은 테이블에 대한 SELECT가 반복적으로 나타나면 N+1을 의심해야 한다.


📌 해결 전략 1 — JPQL Fetch Join

Fetch Join은 JPQL에서 연관 엔티티를 한 번의 JOIN으로 함께 조회하는 방법이다. 가장 직접적이고 확실한 해결 방법이다.

1
2
3
4
5
6
7
// N+1 발생 쿼리
@Query("SELECT lr FROM LoanRecord lr")
List<LoanRecord> findAll();

// Fetch Join으로 해결
@Query("SELECT lr FROM LoanRecord lr JOIN FETCH lr.member JOIN FETCH lr.book")
List<LoanRecord> findAllWithMemberAndBook();

실행 SQL:

1
2
3
4
SELECT lr.*, m.*, b.*
FROM loan_record lr
INNER JOIN member m ON lr.member_id = m.member_id
INNER JOIN book b ON lr.book_id = b.book_id;

한 번의 쿼리로 모든 데이터를 가져온다.

컬렉션 Fetch Join 주의점

1
2
3
// 컬렉션(OneToMany) fetch join — 데이터 중복 발생
@Query("SELECT m FROM Member m JOIN FETCH m.loanRecords")
List<Member> findAllWithLoanRecords();

OneToMany 관계에서 fetch join을 사용하면 member가 대출 기록 수만큼 중복되어 반환된다. DISTINCT로 중복을 제거해야 한다.

1
2
@Query("SELECT DISTINCT m FROM Member m JOIN FETCH m.loanRecords")
List<Member> findAllWithLoanRecords();

컬렉션 Fetch Join + 페이징 불가 문제

컬렉션에 fetch join을 사용하면 페이징을 적용할 수 없다. Hibernate는 경고를 출력하고 전체 데이터를 메모리로 가져온 뒤 메모리에서 페이징(HHH90003004)한다. 이는 매우 위험한 동작이다.

1
2
3
// 위험한 패턴 — In Memory 페이징 발생
@Query("SELECT m FROM Member m JOIN FETCH m.loanRecords")
Page<Member> findAllWithLoanRecords(Pageable pageable); // ← 절대 안 됨

컬렉션 + 페이징이 동시에 필요하면 @BatchSize 또는 default_batch_fetch_size 설정을 사용한다.


📌 해결 전략 2 — @EntityGraph

@EntityGraph는 JPQL을 직접 작성하지 않고 어노테이션으로 fetch join을 지정하는 방법이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
// 방법 1 — @EntityGraph 어노테이션
@EntityGraph(attributePaths = {"member", "book"})
@Query("SELECT lr FROM LoanRecord lr")
List<LoanRecord> findAllWithMemberAndBook();

// 방법 2 — 메서드 이름 쿼리에 적용
@EntityGraph(attributePaths = {"member"})
List<LoanRecord> findByStatus(LoanStatus status);

// 방법 3 — findAll에 적용
@EntityGraph(attributePaths = {"member", "book"})
@Override
List<LoanRecord> findAll();

생성되는 SQL은 fetch join과 동일하게 LEFT OUTER JOIN을 사용한다.

1
2
3
4
SELECT lr.*, m.*, b.*
FROM loan_record lr
LEFT OUTER JOIN member m ON lr.member_id = m.member_id
LEFT OUTER JOIN book b ON lr.book_id = b.book_id;

fetch join vs @EntityGraph 차이

항목JPQL fetch join@EntityGraph
JOIN 방식INNER JOIN (기본)LEFT OUTER JOIN
선언 위치@Query 안 JPQL어노테이션
복잡한 조건가능제한적
가독성JPQL 직접 작성어노테이션으로 간결

📌 해결 전략 3 — @BatchSize / default_batch_fetch_size

@BatchSize는 N+1을 “1+1”이 아닌 “1+소수의 쿼리”로 완화하는 방법이다. N번의 개별 쿼리 대신 IN 절로 묶어서 조회한다.

1
2
3
4
5
6
7
8
9
// 엔티티 클래스에 적용
@Entity
@BatchSize(size = 100)
public class Member { ... }

// 컬렉션 필드에 적용
@OneToMany(mappedBy = "member")
@BatchSize(size = 100)
private List<LoanRecord> loanRecords = new ArrayList<>();

애플리케이션 전역 설정 (권장)

1
2
3
4
5
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

이 설정 하나로 모든 지연 로딩 컬렉션과 단일 연관관계에 IN 절 배치 조회가 적용된다.

동작 원리:

1
2
3
4
5
6
7
8
9
-- N+1 (원래 방식)
SELECT * FROM member WHERE member_id = 1;
SELECT * FROM member WHERE member_id = 2;
SELECT * FROM member WHERE member_id = 3;
-- ... 100번

-- @BatchSize(size=100) 적용 후
SELECT * FROM member WHERE member_id IN (1, 2, 3, ..., 100);
-- 1번으로 줄어듦 (100건 기준)

컬렉션 fetch join + 페이징 시 필수 대안

1
2
3
4
5
6
// fetch join 대신 batch size로 해결
@Query("SELECT m FROM Member m") // fetch join 없음
Page<Member> findAll(Pageable pageable); // 페이징 정상 동작

// application.yml에 default_batch_fetch_size 설정
// → 루프에서 loanRecords 접근 시 IN 절로 일괄 조회

📌 해결 전략 선택 기준

상황권장 해결책
단건 또는 소량 조회, 단순 연관관계JPQL Fetch Join
메서드 이름 쿼리에 fetch join 추가@EntityGraph
컬렉션 + 페이징 동시 필요default_batch_fetch_size
전체 애플리케이션 기본 최적화default_batch_fetch_size 전역 설정
복잡한 동적 쿼리QueryDSL fetchJoin() (Phase 9)

💡 실무에서는 default_batch_fetch_size: 100을 기본으로 설정하고, 성능이 중요한 조회에는 Fetch Join을 추가로 적용하는 방식이 가장 일반적이다.


3. 정리


개념핵심 요약
EAGER연관 엔티티를 즉시 함께 로딩. @ManyToOne, @OneToOne의 기본값. JPQL에서 N+1 유발
LAZY프록시를 끼워두고 실제 접근 시점에 SQL 실행. 실무에서 모든 연관관계에 명시 권장
프록시Hibernate가 동적 생성한 가짜 객체. 필드 접근 시 초기화(SQL 실행)
LazyInitializationException영속성 컨텍스트 닫힌 후 프록시 초기화 시도 시 발생
N+1 문제1번 쿼리 결과로 N건이 나왔을 때 추가 쿼리가 N번 발생하는 현상
Fetch JoinJPQL에서 JOIN FETCH로 한 번에 로딩. 가장 명시적인 해결 방법
@EntityGraph어노테이션으로 fetch join 지정. LEFT OUTER JOIN 사용
@BatchSizeIN 절로 묶어서 배치 조회. 컬렉션 + 페이징 조합에 필수
default_batch_fetch_size전역 배치 크기 설정. 실무에서 기본으로 적용 권장

참고 자료

This post is licensed under CC BY 4.0 by the author.