연관된 엔티티를 실제로 사용하는 순간까지 조회를 미루는 전략.
어떻게 미루나 — 프록시
@Entity
class Order {
@ManyToOne(fetch = FetchType.LAZY)
private Member member; // 진짜 Member 가 아니라 프록시가 들어온다
}
Order o = em.find(Order.class, 1L); // SELECT order ... (member 는 안 읽는다)
System.out.println(o.getMember().getClass());
// class Member$HibernateProxy$xxx ← 껍데기
System.out.println(o.getMember().getName()); // 이 순간 SELECT member ... 가 나간다
프록시는 id만 들고 있는 껍데기다. id 외의 필드에 접근하는 순간 초기화(조회)가 일어난다.
o.getMember().getId(); // 프록시가 이미 아는 값 → 쿼리가 안 나간다
왜 항상 LAZY인가
@ManyToOne · @OneToOne 의 기본값이 EAGER 다 ← 함정 @OneToMany · @ManyToMany 의 기본값은 LAZY
// 반드시 명시한다
@ManyToOne(fetch = FetchType.LAZY)
@OneToOne(fetch = FetchType.LAZY)
EAGER의 문제는 예측 불가능성이다.
EAGER 로 두면
- 필요 없어도 항상 조인해 읽는다
- JPQL 로 조회하면 조인이 아니라 추가 쿼리가 나간다 → N+1 이 된다
- 연관이 연쇄되면 한 번의 조회가 테이블 여러 개를 끌고 온다
"전부 LAZY로 두고, 필요할 때 페치 조인으로 명시적으로 가져온다" 가 정석이다.
프록시를 다룰 때의 함정
// ① instanceof · equals 가 어긋난다
if (o.getMember() instanceof Member) { } // 프록시도 Member 의 자식이라 true
member.equals(o.getMember()); // 클래스 비교로 짠 equals 면 false
// ② 초기화 여부 확인
Hibernate.isInitialized(o.getMember());
em.getEntityManagerFactory().getPersistenceUnitUtil().isLoaded(o.getMember());
equals/hashCode는 id 기준으로 작성하고 getClass() 대신 instanceof를 쓰는 것이 프록시 안전한 구현이다.
면접 함정
- ❌ "@ManyToOne의 기본값은 LAZY" → EAGER다. 명시하지 않으면 조용히 EAGER로 돈다.
- ❌ "EAGER면 N+1이 없다" → JPQL 조회에서는 오히려 N+1이 난다.
한 번에 여러 프록시를 초기화하기
// 컬렉션을 강제로 초기화한다
Hibernate.initialize(order.getItems());
// DTO 로 변환하며 자연스럽게 초기화 (권장 — 트랜잭션 안에서)
return new OrderDto(o.getId(), o.getMember().getName(),
o.getItems().stream().map(ItemDto::from).toList());
equals·hashCode를 프록시 안전하게
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Member m)) return false; // getClass() 비교는 프록시에서 깨진다
return id != null && id.equals(m.getId()); // id 기준
}
@Override public int hashCode() { return getClass().hashCode(); } // 상수여도 된다
getClass() == o.getClass() 로 비교하면
- Member 와 Member$HibernateProxy 가 다르다고 판정된다
- Set 에 넣거나 contains 로 찾을 때 조용히 실패한다