JPA로 엔티티를 조회할 때 연관된 엔티티를 언제 가져오느냐에 대한 전략이 두 가지 있다.
JPA에서는 @ManyToOne, @OneToOne, @OneToMany, @ManyToMany 같은
연관관계 애노테이션에 fetch 속성으로 설정한다.
@ManyToOne(fetch = FetchType.LAZY) // 지연 로딩
@ManyToOne(fetch = FetchType.EAGER) // 즉시 로딩
어떤 전략을 쓰느냐에 따라 쿼리 발생 시점, 쿼리 수, 성능이 크게 달라진다. 그래서 이 둘의 차이를 이해하는 것이 JPA 성능 최적화의 출발점이다.
두 전략이 왜 존재하는지부터 생각해보자.
항상 모든 연관 데이터를 함께 가져온다면 어떨까?
User를 조회할 때마다 그 사람의 Order 목록, 각 주문의 OrderItem, 각 아이템의 Product까지 전부 가져온다면?
단순 사용자 이름 하나를 확인하려고 수백 줄의 데이터를 DB에서 끌어오는 셈이다.
불필요한 데이터를 항상 가져오면 메모리와 네트워크 비용이 낭비된다.
반대로 항상 필요할 때만 가져온다면? 연관 데이터에 접근할 때마다 쿼리가 개별로 나가고, 잘못 쓰면 N+1 문제로 쿼리가 폭발한다.
두 전략은 트레이드오프다. 상황에 맞게 선택하거나, 기본값을 LAZY로 두고 필요한 경우에만 Fetch Join으로 함께 가져오는 것이 JPA의 권장 패턴이다.
[orderRepository.findById(1L) 호출]
|
↓
SELECT o.*, u.*
FROM orders o
LEFT OUTER JOIN users u ON o.user_id = u.id
WHERE o.id = 1
|
→ Order 엔티티 생성
→ User 엔티티도 즉시 생성해서 order.user 필드에 채워 넣음
↓
order.getUser() 호출
→ 이미 로딩된 User 객체 반환 (추가 쿼리 없음)
[orderRepository.findById(1L) 호출]
|
↓
SELECT * FROM orders WHERE id = 1
|
→ Order 엔티티 생성
→ order.user 필드에는 진짜 User 대신 프록시 객체 삽입
→ 프록시는 user_id 값만 알고 있고, 나머지 데이터는 null
↓
order.getId(), order.getTotalPrice() 호출
→ Order 자체 필드 → 추가 쿼리 없음
↓
order.getUser().getName() 호출 ← 실제 User 데이터 필요!
|
↓
SELECT * FROM users WHERE id = ?
|
→ 프록시가 실제 User 데이터로 초기화됨
→ getName() 반환
Lazy Loading의 핵심은 프록시 객체다. JPA는 연관 엔티티 자리에 실제 객체 대신 프록시를 넣어두고, 실제 데이터에 접근하는 순간 DB 쿼리를 날려서 프록시를 초기화한다.
연관 엔티티를 프록시 객체로 대체해두고, 실제 접근 시점에 SELECT 쿼리를 실행한다. 기본적으로 모든 연관관계에 LAZY를 쓰는 것이 JPA 모범 사례다.
JPA 애노테이션별 기본 fetch 전략이다.
@ManyToOne — 기본값 EAGER ← 주의! 명시적으로 LAZY로 바꿔야 함@OneToOne — 기본값 EAGER ← 주의!@OneToMany — 기본값 LAZY@ManyToMany — 기본값 LAZY
@ManyToOne과 @OneToOne의 기본값이 EAGER라는 것을 모르면
자신도 모르게 Eager Loading을 쓰게 된다.
실무에서는 모든 연관관계에 fetch = FetchType.LAZY를 명시하는 것이 안전하다.
엔티티를 조회할 때 연관 엔티티를 JOIN 쿼리로 즉시 함께 가져온다.
연관 엔티티가 항상 필요한 경우에는 편리하지만,
Spring Data JPA의 findAll() 같은 JPQL 기반 메서드에서는 N+1 문제가 발생한다.
LAZY 로딩에서 연관 엔티티 자리를 차지하는 가짜 객체다.
Hibernate가 실제 클래스를 상속해서 만든 클래스이며,
getId() 같은 식별자 접근이나 단순 존재 여부 확인은 프록시 상태에서도 가능하다.
실제 데이터가 필요한 필드(이름, 이메일 등)에 접근하는 순간 프록시가 초기화되면서 SELECT 쿼리가 실행된다.
단, 이미 영속성 컨텍스트가 종료된 상태라면 LazyInitializationException이 발생한다.
실제 프로젝트에서 두 전략이 어떻게 설정되고 동작하는지 보자.
// 권장 엔티티 설정 — 모든 연관관계 LAZY로 명시
@Entity
public class Order {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 기본값이 EAGER이므로 반드시 LAZY로 명시
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> items = new ArrayList<>();
private int totalPrice;
}
그리고 실제로 연관 데이터가 필요한 쿼리에서만 Fetch Join을 사용한다.
public interface OrderRepository extends JpaRepository<Order, Long> {
// user 정보가 필요한 경우에만 Fetch Join
@Query("SELECT o FROM Order o JOIN FETCH o.user WHERE o.id = :id")
Optional<Order> findByIdWithUser(@Param("id") Long id);
// items까지 필요한 경우
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.user JOIN FETCH o.items WHERE o.id = :id")
Optional<Order> findByIdWithUserAndItems(@Param("id") Long id);
// 그냥 목록만 필요한 경우 — Fetch Join 없이 (user 접근 안 함)
List<Order> findByUserId(Long userId);
}
핵심은 LAZY를 기본으로 두고, 필요한 경우에만 Fetch Join으로 필요한 데이터를 명시적으로 가져오는 것이다. 이 패턴을 유지하면 불필요한 데이터 로딩을 막으면서 N+1도 방지할 수 있다.
// EAGER 설정
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "user_id")
private User user;
// findById(1L) 실행 시 나가는 쿼리
// SELECT o.*, u.*
// FROM orders o
// LEFT OUTER JOIN users u ON o.user_id = u.id
// WHERE o.id = 1
// → 필요 없어도 항상 JOIN
// LAZY 설정
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
// findById(1L) 실행 시 나가는 쿼리
// SELECT * FROM orders WHERE id = 1
// → user는 프록시. 접근하기 전까지 추가 쿼리 없음
// order.getUser().getName() 호출 시
// SELECT * FROM users WHERE id = ?
// → 이때서야 user 쿼리 실행
// 문제 상황 — @Transactional 없는 서비스
public OrderResponse getOrder(Long orderId) {
// 조회 후 트랜잭션 종료 → 영속성 컨텍스트 닫힘
Order order = orderRepository.findById(orderId).orElseThrow();
// 트랜잭션 밖에서 LAZY 필드 접근 → LazyInitializationException!
String userName = order.getUser().getName();
return new OrderResponse(order.getId(), userName, order.getTotalPrice());
}
// 해결 1 — @Transactional로 감싸기
@Transactional(readOnly = true)
public OrderResponse getOrder(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
String userName = order.getUser().getName(); // 트랜잭션 안 → 정상 작동
return new OrderResponse(order.getId(), userName, order.getTotalPrice());
}
// 해결 2 — Fetch Join으로 미리 로딩 (권장)
@Transactional(readOnly = true)
public OrderResponse getOrder(Long orderId) {
Order order = orderRepository.findByIdWithUser(orderId).orElseThrow();
// user 이미 로딩됨 → 추가 쿼리 없음
return new OrderResponse(order.getId(), order.getUser().getName(), order.getTotalPrice());
}
@Transactional
public void debugProxy(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
// 프록시 초기화 여부 확인
boolean isLoaded = Hibernate.isInitialized(order.getUser());
System.out.println("user 로딩됨: " + isLoaded); // false — 아직 프록시
order.getUser().getName(); // 프록시 초기화
isLoaded = Hibernate.isInitialized(order.getUser());
System.out.println("user 로딩됨: " + isLoaded); // true — 초기화 완료
}
@Entity
public class User {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// @OneToOne 연관관계의 주인이 아닌 쪽 (mappedBy)은
// LAZY 설정을 해도 Hibernate가 EAGER로 동작하는 경우가 있음
// FK를 가진 쪽(주인)이 LAZY 설정 시에는 정상 작동
@OneToOne(mappedBy = "user", fetch = FetchType.LAZY)
private UserProfile profile;
}
@OneToOne에서 연관관계 주인이 아닌 쪽(mappedBy 쪽)에 LAZY를 설정하면
Hibernate가 null 여부를 판단하기 위해 즉시 로딩하는 경우가 있다.
완전한 LAZY를 원한다면 @OneToOne은 단방향으로 유지하거나, 양방향이 필요하면 별도 조회 메서드를 쓰는 것이 낫다.
JPA 스펙상 @ManyToOne과 @OneToOne의 기본 fetch 전략은 EAGER다.
설정을 따로 안 하면 자동으로 즉시 로딩이 된다.
연관관계가 많은 엔티티에서 이걸 모르고 쓰면
조회 하나에 JOIN이 줄줄이 엮여서 의도치 않은 대용량 쿼리가 나간다.
항상 fetch = FetchType.LAZY를 명시적으로 설정하자.
"EAGER로 설정하면 JOIN으로 한 번에 가져오니까 N+1이 없는 거 아닌가?"라고 생각하기 쉽다.
하지만 Spring Data JPA의 findAll()은 JPQL 기반이고,
JPQL은 연관관계 fetch 전략을 무시한다.
EAGER 설정이 있어도 findAll()에서는 N+1이 발생한다.
Fetch Join을 써야 진짜로 N+1을 막을 수 있다.
@Transactional이 없는 메서드에서 조회 후 연관 엔티티에 접근하면
영속성 컨텍스트가 이미 닫혔기 때문에 LazyInitializationException이 발생한다.
LAZY 필드에 접근하는 코드는 반드시 트랜잭션 범위 안에 있어야 한다.
실무에서 JPA를 쓸 때의 기본 원칙이다.
@ManyToOne, @OneToOne에 fetch = FetchType.LAZY 명시@OneToMany, @ManyToMany는 기본이 LAZY지만 명시해도 무방@EntityGraph로 명시적으로 로딩findAll() 쓰지 않는 경우에만@EntityGraph복잡한 연관관계를 다룰 때는 엔티티를 조회하는 대신 처음부터 필요한 필드만 DTO로 직접 조회하면 로딩 전략 고민 자체가 사라진다.
@Query("SELECT new com.example.dto.OrderSummary(o.id, u.name, o.totalPrice) " +
"FROM Order o JOIN o.user u " +
"WHERE o.status = :status")
List<OrderSummary> findSummariesByStatus(@Param("status") OrderStatus status);
엔티티 그래프 탐색이 없으므로 LAZY/EAGER 둘 다 관련이 없다. 조회 성능이 중요한 API에서 자주 쓰는 패턴이다.
@ManyToOne, @OneToOne의 기본값은 EAGER다 — 반드시 LAZY로 명시하자@EntityGraph로 명시적 로딩이 권장 패턴이다LazyInitializationException 발생
처음에 @ManyToOne 기본값이 EAGER인 걸 모르고 쓰다가
엔티티 하나 조회했는데 JOIN이 줄줄이 엮인 쿼리가 나가서 당황한 적이 있다.
설정을 따로 안 해도 동작하니까 넘어갔던 게 나중에 문제가 됐다.
LAZY와 EAGER는 단순히 "언제 데이터를 가져오냐"의 차이가 아니라 쿼리 수와 성능에 직접 연결되는 선택이다. 모든 연관관계에 LAZY를 명시하고, 필요한 데이터는 Fetch Join으로 명시적으로 가져오는 패턴을 습관으로 만드는 게 JPA를 안전하게 쓰는 방법이라는 걸 느꼈다.