JPA의 영속성 컨텍스트(Persistence Context)는 JPA가 Entity를 관리하는 1차 캐시이자 변경 감지 메커니즘입니다. N+1 문제는 가장 흔한 JPA 성능 문제로, 연관 Entity를 조회할 때 쿼리가 1 + N번 나가는 현상입니다. 원인과 해결책을 모르면 수십만 건의 불필요한 쿼리를 날리는 서비스가 됩니다.
영속성 컨텍스트의 핵심 기능 세 가지:
// 같은 트랜잭션 내 동일 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
// 같은 트랜잭션 내에서 동일 객체 보장
@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=?
}
1번의 쿼리로 N개의 Entity를 조회했는데, 각 Entity의 연관 Entity를 로딩하느라 N번의 추가 쿼리가 발생하는 문제입니다. FetchType.LAZY(지연 로딩) 설정에서 특히 자주 발생합니다.
@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번 추가 쿼리 발생
}
FetchType.EAGER로 설정하면 조회 시점에 자동으로 연관 Entity를 가져옵니다.
하지만 JPQL로 조회할 때 JPQL은 작성된 쿼리만 실행하고, 이후 각 Entity의 EAGER 필드를 초기화하기 위해
추가 쿼리를 N번 실행합니다. N+1은 LAZY의 문제가 아니라 연관 Entity를 별도 쿼리로 가져오는 구조의 문제입니다.
| 방법 | 동작 | 장단점 |
|---|---|---|
| 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) — 필요한 필드만 조회 | ✅ 가장 효율적 (불필요한 컬럼 없음). ⚠️ 영속성 컨텍스트 관리 안 됨 |
// 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(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개씩 로딩
@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로 바꾸는 것 권장
@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로 변경 권장
N+1은 1번의 쿼리로 N개의 Entity를 조회한 뒤, 각 Entity의 연관 Entity를 로딩하기 위해
N번의 추가 쿼리가 발생하는 성능 문제입니다.
해결 방법은 상황에 따라 다릅니다.
페이징이 필요 없는 경우엔 JPQL Fetch Join이나 @EntityGraph로 JOIN 쿼리 1번으로 해결합니다.
페이징이 필요한 경우엔 @BatchSize(hibernate.default_batch_fetch_size)를 설정해
IN 절로 연관 Entity를 묶음 조회합니다. 가장 이상적인 경우엔 DTO Projection으로 필요한 필드만 직접 조회합니다.
JPA는 Entity를 영속성 컨텍스트에 등록할 때 초기 상태의 스냅샷을 함께 보관합니다.
트랜잭션 커밋 시점에 현재 Entity 상태와 스냅샷을 비교해 변경된 필드가 있으면
자동으로 UPDATE SQL을 생성해 실행합니다.
개발자가 save()를 명시적으로 호출하지 않아도 됩니다.
단, 영속 상태(Managed)인 Entity에만 적용됩니다 — 트랜잭션 종료 후 Detached 상태에서 변경하면 UPDATE가 발생하지 않습니다.
@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(페이징 있을 때)로 해결한다.