[Java] JPA - 프록시

daheenamic·2025년 11월 19일
0

JAVA

목록 보기
38/41

프록시

Member 테이블과 Team 테이블이 있고 두 개의 Entity가 연관관게에 있다고 가정했을 때, member와 team을 출력하는 비즈니스 로직이 있다면 member를 조회할 때 연관된 Team도 한번에 가져오는 게 좋다.

그런데 member만 출력하고 싶은데 team까지 같이 조회된다면 최적화되어 있지 않은 것이다.
JPA는 이런 문제를 프록시와 지연로딩으로 해결한다.


프록시란?

프록시는 실제 객체 대신 사용되는 '대리 객체'다.
실제 데이터베이스 조회를 필요한 시점까지 미루기 위해 사용한다.
겉모습은 실제 엔티티와 똑같지만, 내부는 비어있는 껍데기이고 실제 객체에 대한 참조만 가지고 있다. 실제로 해당 객체의 데이터가 필요할 때 그제서야 데이터베이스를 조회해서 실제 객체를 생성한다.

예를 들어, Member를 조회하는데 연관된 Team 정보가 당장 필요하지 않다면 Team은 프록시 객체로 가져와서 실제 사용하는 시점에 데이터베이스를 조회하느 방식이다.


프록시 기초

JPA에는 em.find()외에도 em.getReference() 메서드가 있다.

  • em.find(): 데이터베이스를 통해서 실제 엔티티 객체 조회
  • em.getReference(): 데이터베이스 조회를 미루는 가짜(프록시) 엔티티 객체 조회

getReference() 메서드를 사용하는 시점에는 쿼리가 나가지 않는다. 그런데 getReference()를 findMember에 저장하고 findMember.getId()를 호출하는 순간 쿼리가 나간다.

Member findMember = em.getReference(Member.class, member.getId());

이렇게 했을 때 System.out.println(findMember.getClass())를 찍어보면 class hellojpa.Member$HibernateProxy$odcVHpjy 이런식으로 출력된다.
이건 Member가 아니라 Hibernate가 강제로 만든 가짜(프록시) 클래스다.
내부적으로 Entity target = null로 되어있고 껍데기에 Id값만 들고 있다.

반면 em.find()로 조회하면 진짜 객체를 준다.

// em.getReference() 사용
Member refMember = em.getReference(Member.class, 1L);
System.out.println("refMember = " + refMember.getClass()); // Proxy 객체

// em.find() 사용
Member findMember = em.find(Member.class, 1L);
System.out.println("findMember = " + findMember.getClass()); // 실제 Member 객체

프록시 특징

  • 실제 클래스를 상속받아서 만들어진다.
  • 실제 클래스와 겉 모양이 같다.
  • 사용하는 입장에서는 진짜 객체인지 프록시 객체인지 구분하지 않고 사용하면 된다(이론상).

프록시 객체는 실제 객체의 참조(target)를 보관하고 있다가, 프록시 객체를 호출하면 실제 객체의 메서드를 호출한다.

위임(delegate)
프록시 객체가 실제 작업을 직접 처리하지 않고, 내부에 보관하고 있는 실제 객체(targer)에게 작업을 넘기는 방식을 말한다. 프록시는 껍데기 역할만 하고 실제 일은 target이 처리한다.


프록시 객체의 초기화

프록시 객체는 실제로 사용될 때 데이터베이스를 조회해서 실제 엔티티 객체를 생성하는데, 이것을 프록시 객체의 초기화라고 한다.

Member member = em.getReference(Member.class, "id1");
member.getName(); // 이 시점에 초기화

흐름을 보면 다음과 같다.

  1. Client가 getName() 호출 -> MemberProxy
  2. 초기화 요청 -> 영속성 컨텍스트
  3. DB 조회 -> DB
  4. 실제 Entity 생성 -> Member
  5. target.getName() 실행

프록시의 중요한 특징

1. 프록시 객체는 처음 사용할 때 한 번만 초기화 된다.
프록시 객체를 초기화 할 때, 프록시 객체가 실제 엔티티로 바뀌는 것은 아니다. 초기화되면 프록시 객체를 통해서 실제 엔티티에 접근 가능하다.

즉, 프록시는 계속 프록시로 남아있고, 내부의 target만 채워진다.

2. 프록시 객체는 원본 엔티티를 상속 받는다. 따라서 타입 체크시 주의해야 한다.
== 비교를 하면 실패한다. 대신 instanceof를 사용해야 한다.

프록시 Member와 프록시가 아닌 Membersms == 비교에서 다르다고 나온다. 프록시를 안 쓸 수도 있기 때문에 Member 타입이라고 해서 == 비교를 하면 안된다.

Member member1 = new Member();
member1.setUsername("member1");
em.persist(member1);

Member member2 = new Member();
member2.setUsername("member2");
em.persist(member2);

em.flush();
em.clear();

Member m1 = em.find(Member.class, member1.getId());
Member m2 = em.find(Member.class, member2.getId());

System.out.println(m1.getClass() == m2.getClass()); // true
Member member1 = new Member();
member1.setUsername("member1");
em.persist(member1);

Member member2 = new Member();
member2.setUsername("member2");
em.persist(member2);

em.flush();
em.clear();

Member m1 = em.find(Member.class, member1.getId());
Member m2 = em.getReference(Member.class, member2.getId());

System.out.println(m1.getClass() == m2.getClass()); // false
System.out.println(m1 instanceof Member); // true
System.out.println(m2 instanceof Member); // true

→ 타입이 다르기 때문에 == 비교는 false가 나온다. 하지만 둘 다 Member의 인스턴스이므로 instanceof로는 둘 다 true다.

private static void logic(Member m1, Member m2) {
    System.out.println(m1.getClass() == m2.getClass());
}

실제 로직은 이런식으로 넘어오기 때문에 프록시가 넘어오는지 진짜 객체가 넘어오는지 알 수 없다. 그래서 == 비교를 하면 안된다.

3. 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 em.getReference()를 호출해도 실제 엔티티를 반환한다. (그 반대도 마찬가지)

Member m1 = em.find(Member.class, member1.getId());
System.out.println(m1.getClass()); // class hellojpa.Member - 진짜 객체

Member reference = em.getReference(Member.class, member1.getId());
System.out.println(reference.getClass()); // class hellojpa.Member - 프록시가 아닌 원본 반환

System.out.println(m1 == reference); // true

JPA는 한 영속성 컨텍스트 안에서 같은 PK로 조회한 엔티티는 항상 같은 인스턴스를 보장한다.

JPA의 동일성 보장
JPA는 한 트랜잭션 안에서 같은 식별자로 조회한 엔티티는 항상 동일한 인스턴스(== 비교시 true)를 보장한다. 이는 영속성 컨텍스트가 1차 캐시 역할을 하기 때문이다.

Member refMember = em.getReference(Member.class, member1.getId());
System.out.println(refMember.getClass()); // Proxy

Member findMember = em.find(Member.class, member1.getId());
System.out.println(findMember.getClass()); // Member가 나올 것 같지만 Proxy

System.out.println(refMember == findMember); // true

JPA는 == 비교를 보장해야 한다. 프록시를 한 번 조회하면 em.find()도 프록시를 반환한다.

4. 영속성 컨텍스트의 도움을 받을 수 없는 준영속 상태일 때, 프록시를 초기화하면 문제가 발생한다.
Hibernate는 org.hibernate.LazyInitializationException 예외를 터트린다. 프록시를 호출한 다음에 em.detach(refMember)를 하거나 em.close()를 하면 "could not initialize proxy" 에러가 발생하면서 예외를 던진다.


프록시 확인

프록시 인스턴스의 초기화 여부 확인

PersistenceUnitUtil.isLoaded(Object entity)

프록시 클래스 확인 방법

entity.getClass().getName() // ..javassist.. or HibernateProxy...

프록시 강제 초기화

org.hibernate.Hibernate.initialize(entity);

참고
JPA 표준은 강제 초기화가 없다. 강제로 초기화 하려면 member.getName() 같은 메서드를 직접 호출해야 한다.

0개의 댓글