JPA의 주요 장점 중 하나는 객체 그래프 탐색을 통한 연관관계 조회가 가능하다는 점이다. 하지만 모든 연관 엔티티를 한 번에 조회하면 비효율적일 수 있기 때문에, JPA는 필요한 시점에 데이터를 조회하는 지연 로딩(Lazy Loading)을 지원한다.
이 지연 로딩 기능은 대부분의 구현체(Hibernate 등)에서 프록시 객체를 통해 구현된다.
프록시(proxy) 란 어떤 동작을 대신 수행해주는 객체를 의미한다.
JPA에서는 실제 엔티티 대신 프록시 객체를 먼저 반환하고, 해당 엔티티의 데이터에 접근하는 시점에 DB 조회를 수행한다.
이 기술은 Hibernate뿐만 아니라 Spring의 트랜잭션 처리, AOP 등 다양한 곳에서 사용된다.
JPA에서는 엔티티를 조회할 때 두 가지 메서드를 사용할 수 있다.
| 메서드 | 설명 |
|---|---|
| em.find() | 즉시 실제 객체를 조회하며 바로 SELECT 쿼리 실행 |
| em.getReference() | 프록시 객체 반환, 실제 필드 접근 시점에 SELECT 쿼리 실행 |
package hellojpa;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.List;
public class JpaMain {
public static void main(String[] args) {
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
Member member = new Member();
member.setUsername("hello");
em.persist(member);
em.flush();
em.clear();
// 실제 SELECT 안 나감
Member findMember = em.getReference(Member.class, member.getId());
System.out.println("findMember = " + findMember.getClass());
System.out.println("findMember.id = " + findMember.getId());
System.out.println("findMember.username = " + findMember.getUsername());
tx.commit();
} catch (Exception e) {
e.printStackTrace();
tx.rollback();
} finally {
em.close();
}
emf.close();
}
}
- em.find()는 바로 SELECT쿼리가 나간다.
- em.getReference()는 프록시를 반환한다.
프록시는 처음에는 단순한 껍데기 객체일 뿐이다.
실제 데이터 접근이 일어나면 영속성컨텍스트가 개입하여 프록시 내부를 초기화하고, 그 이후 실제 엔티티처럼 동작한다.
Member member1 = new Member();
member1.setUsername("member1");
em.persist(member1);
// INSERT 쿼리 실행
em.flush();
// 영속성 컨텍스트 초기화
em.clear();
Member refMember = em.getReference(Member.class, member1.getId());
System.out.println("refMember 클래스 = " + refMember.getClass());
refMember.getUsername();
// true
System.out.println("isLoaded = " + emf.getPersistenceUnitUtil().isLoaded(refMember));
초기화 순서는 다음과 같다.
getReference() 호출
→ 이 시점에는 DB 조회 없이 프록시 객체만 생성된다.
프록시 객체 접근 (getName() 등 호출)
→ 내부적으로 JPA가 영속성 컨텍스트에 실제 엔티티 조회 요청
영속성 컨텍스트 처리
→ 먼저 1차 캐시에서 엔티티를 찾고, 없으면 DB에서 조회
실제 엔티티 로딩 및 저장
→ DB에서 조회한 데이터를 바탕으로 엔티티를 만들고, 영속성 컨텍스트에 저장
프록시 → 실제 엔티티 위임
→ 프록시는 내부적으로 target.getName() 같은 방식으로 실 데이터에 접근
| 항목 | 설명 |
|---|---|
| 가짜 객체 | 프록시는 실제 엔티티가 아닌, Hibernate가 만든 클래스 |
| 초기화 시점 | 실제 필드에 접근할 때(DB 쿼리 실행) |
| 타입 | 클래스는 다르지만 원본 엔티티를 상속하므로 instanceof로 체크 가능 |
| 1차 캐시 확인 | 이미 엔티티가 로딩되어 있으면 프록시 대신 실제 객체 반환 |
| 초기화 후에도 프록시 유지 | 프록시 객체가 실제 엔티티로 바뀌지 않음, 단지 위임만 함 |
| 주의사항 | 초기화 전에 em.close()되면 LazyInitializationException 발생 |
프록시 객체가 초기화되지 않은 상태에서 영속성 컨텍스트가 종료되면, 실제 데이터를 로딩할 수 없어 LazyInitializationException 이 발생한다.
Member proxy = em.getReference(Member.class, id);
em.close();
proxy.getUsername(); // 예외 발생!
지연 로딩을 사용하는 경우, 반드시 영속성 컨텍스트 범위 안에서 프록시가 초기화되어야 한다.
System.out.println(emf.getPersistenceUnitUtil().isLoaded(refMember));
프록시 객체는 실제 데이터에 접근하는 시점에 영속성 컨텍스트를 통해 초기화된다.
예를 들어, 아래 코드처럼 refMember.getUsername() 처럼 프록시 객체의 실제 데이터를 조회하려고 하면, 내부적으로 초기화가 발생하고 PersistenceUnitUtil.isLoaded() 는 true를 반환한다.
- 반면, 실제 데이터에 접근하지 않은 상태에서는 프록시 객체는 초기화되지 않았기 때문에 isLoaded() 결과는 false 로 출력된다.
- 이처럼 프록시는 지연 로딩(Lazy Loading) 을 통해 성능을 최적화하지만, 예상치 못한 시점에 초기화될 수 있으므로 주의가 필요하다.
지금까지 JPA에서 프록시 객체가 어떻게 동작하는지 알아보았다.
프록시는 JPA의 지연 로딩(Lazy Loading) 을 가능하게 하는 핵심 요소이다.
다음 글에서는 프록시를 기반으로 동작하는 지연 로딩의 실제 활용 방식, 즉시 로딩과의 비교 등을 함께 살펴보겠습니다.