5. JPA 영속성 컨텍스트와 N+1 문제

JPA의 영속성 컨텍스트(Persistence Context)는 JPA가 Entity를 관리하는 1차 캐시이자 변경 감지 메커니즘입니다. N+1 문제는 가장 흔한 JPA 성능 문제로, 연관 Entity를 조회할 때 쿼리가 1 + N번 나가는 현상입니다. 원인과 해결책을 모르면 수십만 건의 불필요한 쿼리를 날리는 서비스가 됩니다.

1. 영속성 컨텍스트 — JPA가 Entity를 관리하는 방식

트랜잭션 내 영속성 컨텍스트 동작
영속성 컨텍스트 (1차 캐시)
Map<EntityKey, Entity>
User{id:1, name:"kim"} ← 스냅샷 보관
Order{id:101, amount:5000}
Product{id:55, name:"apple"}
em.find(User, 1L) → 캐시 hit → SELECT 없음
user.setName("lee") → dirty checking 감지
트랜잭션 커밋 → 변경된 것만 UPDATE 자동 발행

영속성 컨텍스트의 핵심 기능 세 가지:

1차 캐시 + 동일성 보장
// 같은 트랜잭션 내 동일 ID 조회 → SELECT 1번만
User u1 = em.find(User.class, 1L);
// SELECT * FROM user WHERE id=1 ← DB 조회

User u2 = em.find(User.class, 1L);
// 1차 캐시에서 반환 → SELECT 없음!

System.out.println(u1 == u2); // true
// 같은 트랜잭션 내에서 동일 객체 보장
Dirty Checking — 변경 감지
@Transactional
public void updateUser(Long id, String name) {
    User user = userRepo.findById(id).orElseThrow();
    // 영속 상태(Managed) → 스냅샷 보관

    user.setName(name);
    // 별도 save() 호출 없음!

    // 트랜잭션 커밋 시:
    // 현재 상태 vs 스냅샷 비교
    // → 변경 감지 → UPDATE 자동 실행
    // UPDATE user SET name=? WHERE id=?
}

Entity 4가지 상태

Transient (비영속)
new로 생성만 된 상태. JPA가 전혀 모름. DB와 무관.
Managed (영속)
영속성 컨텍스트에 등록됨. Dirty Checking 대상. em.persist() 또는 조회 후.
Detached (준영속)
영속성 컨텍스트에서 분리됨. 트랜잭션 종료 후. 변경해도 DB 반영 안 됨.
Removed (삭제)
em.remove() 호출. 커밋 시 DELETE 실행됨.

2. N+1 문제 — 발생 원인

1번의 쿼리로 N개의 Entity를 조회했는데, 각 Entity의 연관 Entity를 로딩하느라 N번의 추가 쿼리가 발생하는 문제입니다. FetchType.LAZY(지연 로딩) 설정에서 특히 자주 발생합니다.

N+1 발생 예시
@Entity
public class Order {
    @ManyToOne(fetch = FetchType.LAZY) // 지연 로딩
    private User user;
}

// 서비스 코드
List<Order> orders = orderRepo.findAll(); // SELECT * FROM order → 100개 반환

for (Order order : orders) {
    String name = order.getUser().getName(); // 여기서 User 로딩!
    // 각 Order마다 SELECT * FROM user WHERE id=? 실행
    // → 100번 추가 쿼리 발생
}
/* 1번 */ SELECT * FROM orders;
-- 결과 100행 → 각 row마다 아래 쿼리가 실행됨
/* 2번 */ SELECT * FROM users WHERE id = 1;
/* 3번 */ SELECT * FROM users WHERE id = 2;
/* 4번 */ SELECT * FROM users WHERE id = 3;
...총 101번 (1 + 100 = N+1)
EAGER 로딩도 N+1을 일으킨다

FetchType.EAGER로 설정하면 조회 시점에 자동으로 연관 Entity를 가져옵니다. 하지만 JPQL로 조회할 때 JPQL은 작성된 쿼리만 실행하고, 이후 각 Entity의 EAGER 필드를 초기화하기 위해 추가 쿼리를 N번 실행합니다. N+1은 LAZY의 문제가 아니라 연관 Entity를 별도 쿼리로 가져오는 구조의 문제입니다.

3. N+1 해결 방법

방법동작장단점
Fetch Join
(JPQL)
JOIN 쿼리 1번으로 Order + User를 한꺼번에 조회 ✅ 쿼리 1번. ⚠️ 페이징(LIMIT) 불가 (컬렉션 Fetch Join 시)
@EntityGraph 메서드 위에 선언, 내부적으로 Fetch Join 사용 ✅ JPQL 안 써도 됨. ⚠️ 복잡한 JOIN은 한계
@BatchSize IN 절로 연관 Entity를 N번이 아닌 묶음으로 조회 ✅ 페이징과 함께 사용 가능. ✅ 설정 한 줄. 쿼리 수가 N → N/batchSize로 감소
DTO Projection SELECT new DTO(o.id, u.name) — 필요한 필드만 조회 ✅ 가장 효율적 (불필요한 컬럼 없음). ⚠️ 영속성 컨텍스트 관리 안 됨
Fetch Join (JPQL)
// Repository
@Query("SELECT o FROM Order o JOIN FETCH o.user")
List<Order> findAllWithUser();

// 실행 쿼리 (1번만!)
// SELECT o.*, u.*
// FROM orders o
// INNER JOIN users u ON o.user_id = u.id

// 주의: 컬렉션 Fetch Join + 페이징 조합 금지
// @OneToMany Fetch Join 시 Hibernate가
// 전체를 메모리에 올려 페이징 → OOM 위험
@EntityGraph
// 메서드 선언부에 어떤 연관관계를 함께 로드할지 지정
@EntityGraph(attributePaths = {"user"})
@Query("SELECT o FROM Order o")
List<Order> findAllWithUser();

// @BatchSize — 전역 설정
@Configuration
public class JpaConfig {
    // application.yml
    // spring.jpa.properties.
    //   hibernate.default_batch_fetch_size: 100
}

// 또는 필드 단위
@OneToMany(fetch = LAZY)
@BatchSize(size = 100)
private List<OrderItem> items;
// → IN (?, ?, ..., ?) 한 번에 100개씩 로딩

4. Lazy vs Eager — 실무 기준

FetchType.LAZY — 기본 권장
@ManyToOne(fetch = FetchType.LAZY) // 권장
private User user;

// 장점:
// - user가 필요 없을 때 SELECT 안 함
// - 항상 필요하면 Fetch Join으로 명시적 로딩
// - N+1은 Fetch Join/BatchSize로 해결

// 기본값:
// @ManyToOne: EAGER (JPA 기본) → 명시적으로 LAZY 설정 권장
// @OneToMany: LAZY (JPA 기본) ← 그대로 쓰면 됨
// @OneToOne: EAGER (JPA 기본) → LAZY로 바꾸는 것 권장
FetchType.EAGER — 문제점
@ManyToOne(fetch = FetchType.EAGER) // 비권장
private User user;

// 문제 1: 항상 JOIN → 필요 없어도 가져옴
// 문제 2: JPQL로 조회 시 N+1 여전히 발생
//   "SELECT o FROM Order" 실행 후
//   각 Order.user를 EAGER로 즉시 로딩
//   → SELECT user WHERE id=? N번 실행

// 문제 3: 연관관계가 많아질수록
//   한 조회에서 수십 개 JOIN → 성능 저하

// JPA 스펙 기본값이지만 Spring Data JPA
// 실무에서는 모두 LAZY로 변경 권장

5. 면접 대비 핵심 Q&A

Q1. "N+1 문제가 무엇이고 어떻게 해결하나요?"

N+1은 1번의 쿼리로 N개의 Entity를 조회한 뒤, 각 Entity의 연관 Entity를 로딩하기 위해 N번의 추가 쿼리가 발생하는 성능 문제입니다. 해결 방법은 상황에 따라 다릅니다. 페이징이 필요 없는 경우엔 JPQL Fetch Join이나 @EntityGraph로 JOIN 쿼리 1번으로 해결합니다. 페이징이 필요한 경우엔 @BatchSize(hibernate.default_batch_fetch_size)를 설정해 IN 절로 연관 Entity를 묶음 조회합니다. 가장 이상적인 경우엔 DTO Projection으로 필요한 필드만 직접 조회합니다.

Q2. "영속성 컨텍스트의 Dirty Checking이 어떻게 동작하나요?"

JPA는 Entity를 영속성 컨텍스트에 등록할 때 초기 상태의 스냅샷을 함께 보관합니다. 트랜잭션 커밋 시점에 현재 Entity 상태와 스냅샷을 비교해 변경된 필드가 있으면 자동으로 UPDATE SQL을 생성해 실행합니다. 개발자가 save()를 명시적으로 호출하지 않아도 됩니다. 단, 영속 상태(Managed)인 Entity에만 적용됩니다 — 트랜잭션 종료 후 Detached 상태에서 변경하면 UPDATE가 발생하지 않습니다.

Q3. "컬렉션 Fetch Join과 페이징을 같이 쓰면 왜 안 되나요?"

@OneToMany 관계를 Fetch Join하면 결과가 카르테시안 곱으로 나옵니다. Order 10개에 각 OrderItem이 5개라면 JOIN 결과는 50행입니다. 여기에 LIMIT 10 OFFSET 0을 적용하면 "Order 10개"가 아닌 "50행 중 앞 10행"이 잘립니다 — 원하는 결과가 아닙니다. Hibernate는 이를 인지하고 DB에 LIMIT을 보내지 않고 전체를 메모리에 올린 뒤 Java에서 페이징합니다. 수백만 건이라면 OOM으로 이어집니다. 대신 @BatchSize나 별도 쿼리를 사용해야 합니다.

이 챕터의 핵심 한 줄

영속성 컨텍스트는 1차 캐시+Dirty Checking으로 JPA의 핵심이며, N+1 문제는 Fetch Join(페이징 없을 때) 또는 BatchSize(페이징 있을 때)로 해결한다.